feat: Add type hints and improve type safety across multiple modules

This commit is contained in:
sha
2026-04-12 22:41:47 +03:00
parent f2af482154
commit bcc6b03ae2
20 changed files with 79 additions and 51 deletions
+1
View File
@@ -18,6 +18,7 @@ dependencies = [
[project.optional-dependencies]
dev = [
"mypy>=1.0.0",
"types-requests>=2.31.0",
]
[project.scripts]
+10 -11
View File
@@ -6,6 +6,7 @@ from textual.command import Provider, Hit
from rich.markup import escape
from pathlib import Path
from functools import partial
from typing import TYPE_CHECKING, cast, Any
import threading
import logging
@@ -43,7 +44,7 @@ class CacheCommandProvider(Provider):
yield Hit(
score,
matcher.highlight(display_name),
partial(self.app.action_cache_command, command_name),
partial(cast('MomaApp', self.app).action_cache_command, command_name),
help=help_text
)
@@ -208,11 +209,11 @@ class MomaApp(App):
# File type icons
icons = {
'mkv': '󰈫', # Video camera for MKV
'mk3d': '󰟽', # Clapper board for 3D
'mp4': '󰎁', # Video camera
'mov': '󰎁', # Video camera
'webm': '', # Video camera
'mkv': '🎥', # Video camera for MKV
'mk3d': '🕹️', # Clapper board for 3D
'mp4': '🎥', # Video camera
'mov': '🎥', # Video camera
'webm': '🎥', # Video camera
'avi': '💿', # Film frames for AVI
'wmv': '📀', # Video camera
'm4v': '📹', # Video camera
@@ -308,14 +309,12 @@ class MomaApp(App):
extractor = MediaExtractor(file_path)
mode = self.settings.get("mode")
poster_content = ""
poster_content: Any = ""
if mode == "technical":
formatter = MediaPanelView(extractor)
full_info = formatter.file_info_panel()
full_info = MediaPanelView(extractor).file_info_panel()
else: # catalog
formatter = CatalogFormatter(extractor, self.settings)
full_info, poster_content = formatter.format_catalog_info()
full_info, poster_content = CatalogFormatter(extractor, self.settings).format_catalog_info()
# Update UI
self.call_later(
+1
View File
@@ -16,6 +16,7 @@ class Cache:
_instance: Optional['Cache'] = None
_lock_init = threading.Lock()
_initialized: bool
def __new__(cls, cache_dir: Optional[Path] = None):
"""Create or return singleton instance."""
+2 -1
View File
@@ -18,7 +18,7 @@ from .media_constants import (
get_extension_from_format
)
from .source_constants import SOURCE_DICT
from .frame_constants import FRAME_CLASSES, NON_STANDARD_QUALITY_INDICATORS
from .frame_constants import FRAME_CLASSES, NON_STANDARD_QUALITY_INDICATORS, FrameClassInfo
from .moviedb_constants import MOVIE_DB_DICT
from .edition_constants import SPECIAL_EDITIONS
from .lang_constants import SKIP_WORDS
@@ -35,6 +35,7 @@ __all__ = [
# Frame classes
'FRAME_CLASSES',
'NON_STANDARD_QUALITY_INDICATORS',
'FrameClassInfo',
# Movie databases
'MOVIE_DB_DICT',
# Special editions
+10 -1
View File
@@ -7,6 +7,15 @@ Also includes non-standard quality indicators that appear in filenames but don't
represent specific resolutions.
"""
from typing import TypedDict
class FrameClassInfo(TypedDict):
"""Information about a video frame class."""
nominal_height: int
typical_widths: list[int]
description: str
# Non-standard quality indicators that don't have specific resolution values
# These are used in filenames to indicate quality but aren't proper frame classes
# When found, we return None instead of trying to classify them
@@ -15,7 +24,7 @@ represent specific resolutions.
# the exact resolution, so we treat them as non-standard indicators
NON_STANDARD_QUALITY_INDICATORS = ['SD', 'LQ', 'HD', 'QHD', 'FHD', 'FullHD', '4K', '8K']
FRAME_CLASSES = {
FRAME_CLASSES: dict[str, FrameClassInfo] = {
"480p": {
"nominal_height": 480,
"typical_widths": [640, 704, 720],
+1 -1
View File
@@ -87,7 +87,7 @@ MEDIA_TYPES = {
# Reverse mapping: meta_type -> list of extensions
# Built once at module load instead of rebuilding in every extractor instance
META_TYPE_TO_EXTENSIONS = {}
META_TYPE_TO_EXTENSIONS: dict[str, list[str]] = {}
for ext, info in MEDIA_TYPES.items():
meta_type = info.get('meta_type')
if meta_type:
+2 -1
View File
@@ -1,6 +1,7 @@
from pathlib import Path
from pymediainfo import MediaInfo
from collections import Counter
from typing import Any
from ..constants import FRAME_CLASSES, get_extension_from_format
from ..cache import cached_method, Cache
import langcodes
@@ -52,7 +53,7 @@ class MediaInfoExtractor:
Cache() if use_cache else None
) # Singleton cache for @cached_method decorator
self.settings = None # Will be set by Settings singleton if needed
self._cache = {} # Internal cache for method results
self._cache: dict[str, Any] = {} # Internal cache for method results
@cached_method()
def _get_media_info(self) -> MediaInfo | None:
+2 -6
View File
@@ -1,7 +1,3 @@
import json
import os
import time
import hashlib
import requests
import logging
from pathlib import Path
@@ -17,7 +13,7 @@ class TMDBExtractor:
self.cache = Cache() if use_cache else None # Singleton cache
self.settings = Settings() # Singleton settings
self.ttl_seconds = self.settings.get("cache_ttl_extractors", 21600)
self._movie_db_info = None
self._movie_db_info: Optional[Dict[str, Any]] = None
def _get_cached_data(self, cache_key: str) -> Optional[Dict[str, Any]]:
"""Get data from cache if valid"""
@@ -301,5 +297,5 @@ class TMDBExtractor:
local_path = self.cache.set_image(cache_key, image_data, self.ttl_seconds)
return str(local_path) if local_path else None
except requests.RequestException as e:
logging.warning(f"Failed to download poster from {poster_url}: {e}")
logging.warning(f"Failed to download poster from {url}: {e}")
return None
+3 -3
View File
@@ -5,7 +5,7 @@ should inherit from. This ensures a consistent interface and enables type checki
"""
from abc import ABC, abstractmethod
from typing import Any
from typing import Any, Callable
class Formatter(ABC):
@@ -106,7 +106,7 @@ class MarkupFormatter(Formatter):
pass
class CompositeFormatter(Formatter):
class CompositeFormatter:
"""Formatter that applies multiple formatters in sequence.
This class allows chaining multiple formatters together in a specific order.
@@ -122,7 +122,7 @@ class CompositeFormatter(Formatter):
formatters: List of formatter functions to apply in order
"""
def __init__(self, formatters: list[callable]):
def __init__(self, formatters: list[Callable[..., Any]]):
"""Initialize the composite formatter.
Args:
+9 -7
View File
@@ -1,6 +1,7 @@
from .text_formatter import TextFormatter
from src.views.posters import AsciiPosterRenderer, ViuPosterRenderer, RichPixelsPosterRenderer
from typing import Union
from typing import Union, Any
from io import StringIO
import os
@@ -11,7 +12,7 @@ class CatalogFormatter:
self.extractor = extractor
self.settings = settings
def format_catalog_info(self) -> tuple[str, Union[str, object]]:
def format_catalog_info(self) -> tuple[str, Any]:
"""Format catalog information for display.
Returns:
@@ -66,18 +67,18 @@ class CatalogFormatter:
text_content = "\n\n".join(lines) if lines else "No catalog information available"
from rich.console import Console
from io import StringIO
console = Console(file=StringIO(), width=120, legacy_windows=False)
sio = StringIO()
console = Console(file=sio, width=120, legacy_windows=False)
console.print(text_content, markup=True)
rendered_text = console.file.getvalue()
rendered_text = sio.getvalue()
# Get poster separately
poster_content = self.get_poster()
return rendered_text, poster_content
def get_poster(self) -> Union[str, object]:
def get_poster(self) -> Any:
"""Get poster content for separate display.
Returns:
@@ -99,7 +100,7 @@ class CatalogFormatter:
return f"{TextFormatter.bold('Poster:')} {poster_path} (not cached yet)"
return ""
def _display_poster(self, image_path: str, mode: str) -> Union[str, object]:
def _display_poster(self, image_path: str, mode: str) -> Any:
"""Display poster image based on mode setting.
Args:
@@ -113,6 +114,7 @@ class CatalogFormatter:
return f"Image file not found: {image_path}"
# Select renderer based on mode
renderer: Union[ViuPosterRenderer, AsciiPosterRenderer, RichPixelsPosterRenderer]
if mode == "viu":
renderer = ViuPosterRenderer()
elif mode == "pseudo":
+5 -4
View File
@@ -4,11 +4,12 @@ class SizeFormatter:
@staticmethod
def format_size(bytes_size: int) -> str:
"""Format bytes to human readable with unit"""
size: float = bytes_size
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if bytes_size < 1024:
return f"{bytes_size:.1f} {unit}"
bytes_size /= 1024
return f"{bytes_size:.1f} TB"
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} TB"
@staticmethod
def format_size_full(bytes_size: int) -> str:
+1 -1
View File
@@ -287,7 +287,7 @@ class ConversionService:
logger.debug(f"Expanded languages from '{audio_langs_str}' to: {langs}")
# Map to tracks (pad with None if needed)
result = []
result: List[Optional[str]] = []
for i in range(audio_track_count):
if i < len(langs):
result.append(langs[i])
+4 -5
View File
@@ -9,7 +9,7 @@ This service manages the extraction of metadata from media files with:
import logging
from pathlib import Path
from typing import Optional, Callable
from typing import Optional, Callable, Any
from concurrent.futures import ThreadPoolExecutor, Future
from threading import Lock
@@ -169,12 +169,11 @@ class MetadataService:
mode = self.settings.get("mode")
# Format based on mode
formatted_info: Any
if mode == "technical":
formatter = MediaPanelView(extractor)
formatted_info = formatter.file_info_panel()
formatted_info = MediaPanelView(extractor).file_info_panel()
else: # catalog
formatter = CatalogFormatter(extractor, self.settings)
formatted_info = formatter.format_catalog_info()
formatted_info = CatalogFormatter(extractor, self.settings).format_catalog_info()
# Generate proposed name
proposed_formatter = ProposedFilenameView(extractor)
+2 -2
View File
@@ -247,13 +247,13 @@ class RenameService:
is_valid, error = self.validate_filename(sanitized_filename)
if not is_valid:
logger.error(f"Invalid filename: {error}")
return False, error
return False, error or "Invalid filename"
# Check for conflicts
has_conflict, conflict_msg = self.check_name_conflict(source_path, sanitized_filename)
if has_conflict:
logger.warning(f"Name conflict: {conflict_msg}")
return False, conflict_msg
return False, conflict_msg or "Name conflict"
# Build the new path
new_path = source_path.parent / sanitized_filename
+1
View File
@@ -22,6 +22,7 @@ class Settings:
_instance: Optional['Settings'] = None
_lock = threading.Lock()
_initialized: bool
def __new__(cls, config_dir: Path | None = None):
"""Create or return singleton instance."""
+1
View File
@@ -540,3 +540,4 @@ class MediaPanelProperties:
@track_decorators.subtitle_track()
def subtitle_track(self, track, index) -> str:
"""Get subtitle track info formatted with label."""
return track
+4 -3
View File
@@ -1,12 +1,13 @@
"""ASCII art poster renderer."""
from .base import PosterRenderer
from typing import Any
class AsciiPosterRenderer(PosterRenderer):
"""Render posters as ASCII art using PIL."""
def render(self, image_path: str, width: int = 35) -> str:
def render(self, image_path: str, width: int = 35) -> Any:
"""Render poster as ASCII art.
Args:
@@ -28,7 +29,7 @@ class AsciiPosterRenderer(PosterRenderer):
from PIL import Image, ImageEnhance
# Open image
img = Image.open(image_path)
img: Any = Image.open(image_path)
# Enhance contrast for better detail
enhancer = ImageEnhance.Contrast(img)
@@ -57,7 +58,7 @@ class AsciiPosterRenderer(PosterRenderer):
# Map pixel brightness to character
# Invert: 0 (black) -> dark char, 255 (white) -> light char
char_index = (255 - avg) * (len(ascii_chars) - 1) // 255
char_index = int((255 - avg) * (len(ascii_chars) - 1) // 255)
char = ascii_chars[char_index]
row.append(char)
ascii_art.append(''.join(row))
+4 -3
View File
@@ -1,6 +1,7 @@
"""Base class for poster renderers."""
from abc import ABC, abstractmethod
from typing import Any
import os
@@ -8,15 +9,15 @@ class PosterRenderer(ABC):
"""Abstract base class for poster rendering implementations."""
@abstractmethod
def render(self, image_path: str, width: int = 40) -> str:
"""Render a poster image to a string.
def render(self, image_path: str, width: int = 40) -> Any:
"""Render a poster image.
Args:
image_path: Path to the poster image file
width: Desired width in characters
Returns:
Rendered poster as a string
Rendered poster as a string or Rich Renderable
"""
pass
+2 -2
View File
@@ -1,13 +1,13 @@
"""Rich-pixels renderer for high-quality terminal image display."""
from .base import PosterRenderer
from typing import Union
from typing import Any
class RichPixelsPosterRenderer(PosterRenderer):
"""Render posters using rich-pixels library for high-quality display."""
def render(self, image_path: str, width: int = 40) -> Union[str, object]:
def render(self, image_path: str, width: int = 40) -> Any:
"""Render poster using rich-pixels.
Args:
Generated
+14
View File
@@ -224,6 +224,7 @@ dependencies = [
[package.optional-dependencies]
dev = [
{ name = "mypy" },
{ name = "types-requests" },
]
[package.metadata]
@@ -237,6 +238,7 @@ requires-dist = [
{ name = "requests", specifier = ">=2.31.0" },
{ name = "rich-pixels", specifier = ">=1.0.0" },
{ name = "textual", specifier = ">=6.11.0" },
{ name = "types-requests", marker = "extra == 'dev'", specifier = ">=2.31.0" },
]
provides-extras = ["dev"]
@@ -501,6 +503,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/fc/5e2988590ff2e0128eea6446806c904445a44e17256c67141573ea16b5a5/textual-6.11.0-py3-none-any.whl", hash = "sha256:9e663b73ed37123a9b13c16a0c85e09ef917a4cfded97814361ed5cccfa40f89", size = 714886, upload-time = "2025-12-18T10:48:36.269Z" },
]
[[package]]
name = "types-requests"
version = "2.33.0.20260408"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/6a/749dc53a54a3f35842c1f8197b3ca6b54af6d7458a1bfc75f6629b6da666/types_requests-2.33.0.20260408.tar.gz", hash = "sha256:95b9a86376807a216b2fb412b47617b202091c3ea7c078f47cc358d5528ccb7b", size = 23882, upload-time = "2026-04-08T04:34:49.33Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/b8/78fd6c037de4788c040fdd323b3369804400351b7827473920f6c1d03c10/types_requests-2.33.0.20260408-py3-none-any.whl", hash = "sha256:81f31d5ea4acb39f03be7bc8bed569ba6d5a9c5d97e89f45ac43d819b68ca50f", size = 20739, upload-time = "2026-04-08T04:34:48.325Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"