chore: Bump version to 0.6.1 and update decorators to use new cache system

This commit is contained in:
sHa
2026-01-02 11:01:08 +00:00
parent 60f32a7e8c
commit e64aaf320b
17 changed files with 799 additions and 27 deletions

BIN
dist/renamer-0.6.1-py3-none-any.whl vendored Normal file

Binary file not shown.

View File

@@ -1,6 +1,6 @@
[project]
name = "renamer"
version = "0.6.0"
version = "0.6.1"
description = "Terminal-based media file renamer and metadata viewer"
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -102,7 +102,7 @@ class DefaultExtractor:
def extract_extension(self) -> Optional[str]:
"""Return file extension. Returns None as no extension is available."""
return None
return "ext"
def extract_tmdb_url(self) -> Optional[str]:
"""Return TMDB URL. Returns None as no TMDB URL is available."""

View File

@@ -7,7 +7,7 @@ file system metadata such as size, timestamps, paths, and extensions.
from pathlib import Path
import logging
import os
from ..decorators import cached_method
from ..cache import cached_method
# Set up logging conditionally
if os.getenv('FORMATTER_LOG', '0') == '1':

View File

@@ -8,7 +8,7 @@ from ..constants import (
is_valid_year,
CYRILLIC_TO_ENGLISH
)
from ..decorators import cached_method
from ..cache import cached_method
from ..utils.pattern_utils import PatternExtractor
import langcodes

View File

@@ -2,7 +2,7 @@ from pathlib import Path
from pymediainfo import MediaInfo
from collections import Counter
from ..constants import FRAME_CLASSES, MEDIA_TYPES
from ..decorators import cached_method
from ..cache import cached_method
import langcodes
import logging

View File

@@ -8,7 +8,7 @@ import mutagen
import logging
from pathlib import Path
from ..constants import MEDIA_TYPES
from ..decorators import cached_method
from ..cache import cached_method
logger = logging.getLogger(__name__)

View File

@@ -23,6 +23,12 @@ from .track_formatter import TrackFormatter
from .special_info_formatter import SpecialInfoFormatter
from .formatter import FormatterApplier
# Decorator instances
from .date_decorators import date_decorators, DateDecorators
from .special_info_decorators import special_info_decorators, SpecialInfoDecorators
from .text_decorators import text_decorators, TextDecorators
from .conditional_decorators import conditional_decorators, ConditionalDecorators
__all__ = [
# Base classes
'Formatter',
@@ -41,4 +47,14 @@ __all__ = [
'TrackFormatter',
'SpecialInfoFormatter',
'FormatterApplier',
# Decorator instances and classes
'date_decorators',
'DateDecorators',
'special_info_decorators',
'SpecialInfoDecorators',
'text_decorators',
'TextDecorators',
'conditional_decorators',
'ConditionalDecorators',
]

View File

@@ -0,0 +1,88 @@
"""Conditional formatting decorators.
Provides decorators for conditional formatting (wrap, replace_slashes, default):
@conditional_decorators.wrap("[", "]")
def get_order(self):
return self.extractor.get('order')
"""
from functools import wraps
from typing import Callable, Any
class ConditionalDecorators:
"""Conditional formatting decorators (wrap, replace_slashes, default)."""
@staticmethod
def wrap(left: str, right: str = "") -> Callable:
"""Decorator to wrap value with delimiters if it exists.
Can be used for prefix-only (right=""), suffix-only (left=""), or both.
Usage:
@conditional_decorators.wrap("[", "]")
def get_order(self):
return self.extractor.get('order')
# Prefix only
@conditional_decorators.wrap(" ")
def get_source(self):
return self.extractor.get('source')
# Suffix only
@conditional_decorators.wrap("", ",")
def get_hdr(self):
return self.extractor.get('hdr')
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> str:
result = func(*args, **kwargs)
return f"{left}{result}{right}" if result else ""
return wrapper
return decorator
@staticmethod
def replace_slashes() -> Callable:
"""Decorator to replace forward and back slashes with dashes.
Usage:
@conditional_decorators.replace_slashes()
def get_title(self):
return self.extractor.get('title')
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> str:
result = func(*args, **kwargs)
if result:
return str(result).replace("/", "-").replace("\\", "-")
return result or ""
return wrapper
return decorator
@staticmethod
def default(default_value: Any) -> Callable:
"""Decorator to provide a default value if result is None or empty.
NOTE: It's better to handle defaults in the extractor itself rather than
using this decorator. This decorator should only be used when the extractor
cannot provide a sensible default.
Usage:
@conditional_decorators.default("Unknown")
def get_value(self):
return self.extractor.get('value')
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> Any:
result = func(*args, **kwargs)
return result if result else default_value
return wrapper
return decorator
# Singleton instance
conditional_decorators = ConditionalDecorators()

View File

@@ -0,0 +1,37 @@
"""Date formatting decorators.
Provides decorator versions of DateFormatter methods for cleaner code:
@date_decorators.year()
def get_year(self):
return self.extractor.get('year')
"""
from functools import wraps
from typing import Callable
from .date_formatter import DateFormatter
class DateDecorators:
"""Date and time formatting decorators."""
@staticmethod
def modification_date() -> Callable:
"""Decorator to format modification dates.
Usage:
@date_decorators.modification_date()
def get_mtime(self):
return self.file_path.stat().st_mtime
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> str:
result = func(*args, **kwargs)
return DateFormatter.format_modification_date(result)
return wrapper
return decorator
# Singleton instance
date_decorators = DateDecorators()

View File

@@ -1,37 +1,99 @@
from rich.markup import escape
from .text_formatter import TextFormatter
from .date_formatter import DateFormatter
from .special_info_formatter import SpecialInfoFormatter
from .special_info_decorators import special_info_decorators
from .conditional_decorators import conditional_decorators
from .text_decorators import text_decorators
class ProposedNameFormatter:
"""Class for formatting proposed filenames"""
"""Class for formatting proposed filenames using decorator pattern with properties."""
def __init__(self, extractor):
"""Initialize with media extractor data"""
self.__order = f"[{extractor.get('order')}] " if extractor.get("order") else ""
self.__title = (extractor.get("title") or "Unknown Title").replace("/", "-").replace("\\", "-")
self.__year = DateFormatter.format_year(extractor.get("year"))
self.__source = f" {extractor.get('source')}" if extractor.get("source") else ""
self.__frame_class = extractor.get("frame_class") or None
self.__hdr = f",{extractor.get('hdr')}" if extractor.get("hdr") else ""
self.__audio_langs = extractor.get("audio_langs") or None
self.__special_info = f" [{SpecialInfoFormatter.format_special_info(extractor.get('special_info'))}]" if extractor.get("special_info") else ""
self.__db_info = f" [{SpecialInfoFormatter.format_database_info(extractor.get('movie_db'))}]" if extractor.get("movie_db") else ""
self.__extension = extractor.get("extension") or "ext"
self._extractor = extractor
def __str__(self) -> str:
"""Convert the proposed name to string"""
return self.rename_line()
return self.rename_line
@property
@conditional_decorators.wrap("[", "] ")
def _order(self) -> str:
"""Get the order number formatted as [XX] """
return self._extractor.get("order")
@property
@conditional_decorators.replace_slashes()
def _title(self) -> str:
"""Get the title with slashes replaced"""
return self._extractor.get("title")
@property
@conditional_decorators.wrap(" (", ")")
def _year(self) -> str:
"""Get the year formatted as (YYYY)"""
return self._extractor.get("year")
@property
@conditional_decorators.wrap(" ")
def _source(self) -> str:
"""Get the source"""
return self._extractor.get("source")
@property
def _frame_class(self) -> str:
"""Get the frame class"""
return self._extractor.get("frame_class") or ""
@property
@conditional_decorators.wrap(",")
def _hdr(self) -> str:
"""Get the HDR info formatted with a trailing comma if present"""
return self._extractor.get("hdr")
@property
def _audio_langs(self) -> str:
"""Get the audio languages formatted with a trailing comma if present"""
return self._extractor.get("audio_langs") or ""
@property
@conditional_decorators.wrap(" [", "]")
@special_info_decorators.special_info()
def _special_info(self) -> str:
"""Get the special info formatted within brackets"""
return self._extractor.get("special_info")
@property
@conditional_decorators.wrap(" [", "]")
@special_info_decorators.database_info()
def _db_info(self) -> str:
"""Get the database info formatted within brackets"""
return self._extractor.get("movie_db")
@property
def _extension(self) -> str:
"""Get the file extension"""
return self._extractor.get("extension")
@property
def rename_line(self) -> str:
result = f"{self.__order}{self.__title} {self.__year}{self.__special_info}{self.__source} [{self.__frame_class}{self.__hdr},{self.__audio_langs}]{self.__db_info}.{self.__extension}"
"""Generate the proposed filename."""
result = f"{self._order}{self._title}{self._year}{self._special_info}{self._source} [{self._frame_class}{self._hdr},{self._audio_langs}]{self._db_info}.{self._extension}"
return result.replace("/", "-").replace("\\", "-")
def rename_line_formatted(self, file_path) -> str:
"""Format the proposed name for display with color"""
proposed = escape(str(self))
if file_path.name == str(self):
return f">> {TextFormatter.green(proposed)} <<"
return f">> {TextFormatter.bold_yellow(proposed)} <<"
return self.rename_line_similar
return self.rename_line_different
@property
@text_decorators.green()
def rename_line_similar(self) -> str:
"""Generate a simplified proposed filename for similarity checks."""
return escape(str(self))
@property
@text_decorators.orange()
def rename_line_different(self) -> str:
"""Generate a detailed proposed filename for difference checks."""
return escape(str(self))

View File

@@ -0,0 +1,54 @@
"""Special info formatting decorators.
Provides decorator versions of SpecialInfoFormatter methods:
@special_info_decorators.special_info()
def get_special_info(self):
return self.extractor.get('special_info')
"""
from functools import wraps
from typing import Callable
from .special_info_formatter import SpecialInfoFormatter
class SpecialInfoDecorators:
"""Special info and database formatting decorators."""
@staticmethod
def special_info() -> Callable:
"""Decorator to format special info lists.
Usage:
@special_info_decorators.special_info()
def get_special_info(self):
return self.extractor.get('special_info')
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> str:
result = func(*args, **kwargs)
return SpecialInfoFormatter.format_special_info(result)
return wrapper
return decorator
@staticmethod
def database_info() -> Callable:
"""Decorator to format database info.
Usage:
@special_info_decorators.database_info()
def get_db_info(self):
return self.extractor.get('movie_db')
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> str:
result = func(*args, **kwargs)
return SpecialInfoFormatter.format_database_info(result)
return wrapper
return decorator
# Singleton instance
special_info_decorators = SpecialInfoDecorators()

View File

@@ -0,0 +1,130 @@
"""Text formatting decorators.
Provides decorator versions of TextFormatter methods:
@text_decorators.bold()
def get_title(self):
return self.title
"""
from functools import wraps
from typing import Callable
from .text_formatter import TextFormatter
class TextDecorators:
"""Text styling and color decorators."""
@staticmethod
def bold() -> Callable:
"""Decorator to make text bold."""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> str:
result = func(*args, **kwargs)
return TextFormatter.bold(str(result))
return wrapper
return decorator
@staticmethod
def italic() -> Callable:
"""Decorator to make text italic."""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> str:
result = func(*args, **kwargs)
return TextFormatter.italic(str(result))
return wrapper
return decorator
@staticmethod
def green() -> Callable:
"""Decorator to color text green."""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> str:
result = func(*args, **kwargs)
return TextFormatter.green(str(result))
return wrapper
return decorator
@staticmethod
def yellow() -> Callable:
"""Decorator to color text yellow."""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> str:
result = func(*args, **kwargs)
return TextFormatter.yellow(str(result))
return wrapper
return decorator
@staticmethod
def cyan() -> Callable:
"""Decorator to color text cyan."""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> str:
result = func(*args, **kwargs)
return TextFormatter.cyan(str(result))
return wrapper
return decorator
@staticmethod
def magenta() -> Callable:
"""Decorator to color text magenta."""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> str:
result = func(*args, **kwargs)
return TextFormatter.magenta(str(result))
return wrapper
return decorator
@staticmethod
def red() -> Callable:
"""Decorator to color text red."""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> str:
result = func(*args, **kwargs)
return TextFormatter.red(str(result))
return wrapper
return decorator
@staticmethod
def orange() -> Callable:
"""Decorator to color text orange."""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> str:
result = func(*args, **kwargs)
return TextFormatter.orange(str(result))
return wrapper
return decorator
@staticmethod
def uppercase() -> Callable:
"""Decorator to convert text to uppercase."""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> str:
result = func(*args, **kwargs)
return TextFormatter.uppercase(str(result))
return wrapper
return decorator
@staticmethod
def lowercase() -> Callable:
"""Decorator to convert text to lowercase."""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> str:
result = func(*args, **kwargs)
return TextFormatter.lowercase(str(result))
return wrapper
return decorator
# Singleton instance
text_decorators = TextDecorators()

View File

@@ -78,6 +78,10 @@ class TextFormatter:
def yellow(text: str) -> str:
return f"[yellow]{text}[/yellow]"
@staticmethod
def orange(text: str) -> str:
return f"[orange]{text}[/orange]"
@staticmethod
def magenta(text: str) -> str:
return f"[magenta]{text}[/magenta]"

View File

@@ -0,0 +1,186 @@
"""Tests for formatter decorators."""
import pytest
from renamer.formatters import (
date_decorators,
special_info_decorators,
text_decorators,
conditional_decorators
)
class TestDateDecorators:
"""Test date formatting decorators."""
def test_modification_date_decorator(self):
"""Test @date_decorators.modification_date() decorator."""
class TestClass:
def __init__(self, mtime):
self.mtime = mtime
@date_decorators.modification_date()
def get_mtime(self):
return self.mtime
# Test with a known timestamp (2020-01-01 00:00:00 UTC)
obj = TestClass(1577836800.0)
result = obj.get_mtime()
assert "2020-01-01" in result # Date part should be present
class TestSpecialInfoDecorators:
"""Test special info formatting decorators."""
def test_special_info_decorator(self):
"""Test @special_info_decorators.special_info() decorator."""
class TestClass:
def __init__(self, special_info):
self.special_info = special_info
@special_info_decorators.special_info()
def get_special_info(self):
return self.special_info
obj = TestClass(["Director's Cut", "Extended Edition"])
assert obj.get_special_info() == "Director's Cut, Extended Edition"
obj_none = TestClass(None)
assert obj_none.get_special_info() == ""
def test_database_info_decorator(self):
"""Test @special_info_decorators.database_info() decorator."""
class TestClass:
def __init__(self, db_info):
self.db_info = db_info
@special_info_decorators.database_info()
def get_db_info(self):
return self.db_info
obj = TestClass(["tmdb", "12345"])
assert obj.get_db_info() == "tmdbid-12345"
obj_dict = TestClass({"name": "imdb", "id": "tt1234567"})
assert obj_dict.get_db_info() == "imdbid-tt1234567"
class TestTextDecorators:
"""Test text formatting decorators."""
def test_bold_decorator(self):
"""Test @text_decorators.bold() decorator."""
class TestClass:
@text_decorators.bold()
def get_text(self):
return "Hello"
obj = TestClass()
assert obj.get_text() == "[bold]Hello[/bold]"
def test_green_decorator(self):
"""Test @text_decorators.green() decorator."""
class TestClass:
@text_decorators.green()
def get_text(self):
return "Success"
obj = TestClass()
assert obj.get_text() == "[green]Success[/green]"
class TestConditionalDecorators:
"""Test conditional formatting decorators."""
def test_wrap_decorator_both_sides(self):
"""Test @conditional_decorators.wrap() with both left and right."""
class TestClass:
def __init__(self, order):
self.order = order
@conditional_decorators.wrap("[", "] ")
def get_order(self):
return self.order
obj = TestClass("01")
assert obj.get_order() == "[01] "
obj_none = TestClass(None)
assert obj_none.get_order() == ""
def test_wrap_decorator_prefix_only(self):
"""Test @conditional_decorators.wrap() as prefix (right="")."""
class TestClass:
def __init__(self, source):
self.source = source
@conditional_decorators.wrap(" ")
def get_source(self):
return self.source
obj = TestClass("BDRip")
assert obj.get_source() == " BDRip"
obj_none = TestClass(None)
assert obj_none.get_source() == ""
def test_wrap_decorator_suffix_only(self):
"""Test @conditional_decorators.wrap() as suffix (left="")."""
class TestClass:
def __init__(self, hdr):
self.hdr = hdr
@conditional_decorators.wrap("", ",")
def get_hdr(self):
return self.hdr
obj = TestClass("HDR")
assert obj.get_hdr() == "HDR,"
obj_none = TestClass(None)
assert obj_none.get_hdr() == ""
def test_replace_slashes_decorator(self):
"""Test @conditional_decorators.replace_slashes() decorator."""
class TestClass:
def __init__(self, title):
self.title = title
@conditional_decorators.replace_slashes()
def get_title(self):
return self.title
obj = TestClass("Movie/Title\\Test")
assert obj.get_title() == "Movie-Title-Test"
def test_default_decorator(self):
"""Test @conditional_decorators.default() decorator."""
class TestClass:
def __init__(self, title):
self.title = title
@conditional_decorators.default("Unknown Title")
def get_title(self):
return self.title
obj = TestClass(None)
assert obj.get_title() == "Unknown Title"
obj_with_title = TestClass("Movie Title")
assert obj_with_title.get_title() == "Movie Title"
def test_chained_decorators(self):
"""Test chaining multiple decorators."""
class TestClass:
def __init__(self, title):
self.title = title
@conditional_decorators.replace_slashes()
@conditional_decorators.default("Unknown Title")
def get_title(self):
return self.title
obj = TestClass("Movie/Title")
assert obj.get_title() == "Movie-Title"
obj_none = TestClass(None)
assert obj_none.get_title() == "Unknown Title"

View File

@@ -0,0 +1,195 @@
"""Tests for ProposedNameFormatter with decorator pattern."""
import pytest
from pathlib import Path
from renamer.formatters.proposed_name_formatter import ProposedNameFormatter
class TestProposedNameFormatter:
"""Test ProposedNameFormatter with decorator pattern."""
def test_basic_formatting(self):
"""Test basic filename formatting with all fields."""
extractor = {
'order': '01',
'title': 'Movie Title',
'year': 2020,
'source': 'BDRip',
'frame_class': '1080p',
'hdr': 'HDR',
'audio_langs': 'ukr,eng',
'special_info': ["Director's Cut"],
'movie_db': ['tmdb', '12345'],
'extension': 'mkv'
}
formatter = ProposedNameFormatter(extractor)
result = formatter.rename_line
assert '[01]' in result
assert 'Movie Title' in result
assert '(2020)' in result
assert 'BDRip' in result
assert '1080p' in result
assert 'HDR' in result
assert 'ukr,eng' in result
assert "Director's Cut" in result
assert 'tmdbid-12345' in result
assert '.mkv' in result
def test_minimal_formatting(self):
"""Test formatting with minimal fields."""
extractor = {
'title': 'Simple Movie',
'year': 2020,
'extension': 'mp4'
}
formatter = ProposedNameFormatter(extractor)
result = formatter.rename_line
assert 'Simple Movie' in result
assert '(2020)' in result
assert '.mp4' in result
assert '[01]' not in result # No order
def test_title_slash_replacement(self):
"""Test that slashes in title are replaced with dashes."""
extractor = {
'title': 'Movie/Title\\Test',
'year': 2020,
'extension': 'mkv'
}
formatter = ProposedNameFormatter(extractor)
result = formatter.rename_line
assert 'Movie-Title-Test' in result
assert '/' not in result
assert '\\' not in result
def test_none_title(self):
"""Test formatting when title is None (extractor should provide default)."""
extractor = {
'title': None,
'year': 2020,
'extension': 'mkv'
}
formatter = ProposedNameFormatter(extractor)
result = formatter.rename_line
# Since title is None, it won't appear (unless extractor provides default)
assert result is not None
def test_none_extension(self):
"""Test formatting when extension is None (extractor should provide default)."""
extractor = {
'title': 'Movie',
'year': 2020,
'extension': None
}
formatter = ProposedNameFormatter(extractor)
result = formatter.rename_line
# Extension handling depends on extractor default
assert result is not None
def test_special_info_list_formatting(self):
"""Test special info list is formatted correctly."""
extractor = {
'title': 'Movie',
'year': 2020,
'special_info': ['Extended Edition', 'Remastered'],
'extension': 'mkv'
}
formatter = ProposedNameFormatter(extractor)
result = formatter.rename_line
assert 'Extended Edition, Remastered' in result
def test_database_info_formatting(self):
"""Test database info is formatted correctly."""
extractor = {
'title': 'Movie',
'year': 2020,
'movie_db': {'name': 'imdb', 'id': 'tt1234567'},
'extension': 'mkv'
}
formatter = ProposedNameFormatter(extractor)
result = formatter.rename_line
assert 'imdbid-tt1234567' in result
def test_str_method(self):
"""Test __str__ method returns same as rename_line()."""
extractor = {
'title': 'Movie',
'year': 2020,
'extension': 'mkv'
}
formatter = ProposedNameFormatter(extractor)
assert str(formatter) == formatter.rename_line
def test_formatted_display_matching_name(self):
"""Test rename_line_formatted when filename matches proposed name."""
extractor = {
'title': 'Movie',
'year': 2020,
'extension': 'mkv'
}
formatter = ProposedNameFormatter(extractor)
proposed = str(formatter)
file_path = Path(proposed)
result = formatter.rename_line_formatted(file_path)
assert '>>' in result
assert '<<' in result
assert '[green]' in result
def test_formatted_display_different_name(self):
"""Test rename_line_formatted when filename differs from proposed name."""
extractor = {
'title': 'Movie',
'year': 2020,
'extension': 'mkv'
}
formatter = ProposedNameFormatter(extractor)
file_path = Path('different_name.mkv')
result = formatter.rename_line_formatted(file_path)
assert '>>' in result
assert '<<' in result
def test_year_formatting(self):
"""Test year is wrapped in parentheses."""
extractor = {
'title': 'Movie',
'year': 2020,
'extension': 'mkv'
}
formatter = ProposedNameFormatter(extractor)
result = formatter.rename_line
assert '(2020)' in result
def test_no_year(self):
"""Test formatting when no year provided."""
extractor = {
'title': 'Movie',
'year': None,
'extension': 'mkv'
}
formatter = ProposedNameFormatter(extractor)
result = formatter.rename_line
# Should not have empty parentheses
assert '()' not in result

2
uv.lock generated
View File

@@ -462,7 +462,7 @@ wheels = [
[[package]]
name = "renamer"
version = "0.6.0"
version = "0.6.1"
source = { editable = "." }
dependencies = [
{ name = "langcodes" },