diff --git a/.github/actions/init/action.yml b/.github/actions/init/action.yml new file mode 100644 index 00000000..e2e77d3f --- /dev/null +++ b/.github/actions/init/action.yml @@ -0,0 +1,19 @@ +name: Init +description: Sets up the environment for build, lint, and test jobs + +runs: + using: composite + steps: + - uses: actions/setup-python@v4 + with: + cache: pip + cache-dependency-path: | + pyproject.toml + requirements.txt + requirements-dev.txt + + - name: Installing dependencies + shell: sh + run: >- + pip install -U pip && + pip install -U -r requirements.txt -r requirements-dev.txt diff --git a/.github/actions/lint/action.yml b/.github/actions/lint/action.yml new file mode 100644 index 00000000..6363c7f9 --- /dev/null +++ b/.github/actions/lint/action.yml @@ -0,0 +1,23 @@ +name: Lint +description: Lints the codebase with black, isort, pyflakes, and mypy + +runs: + using: composite + steps: + - uses: ./.github/actions/init + + - name: Linting with black + run: black --check mnamer tests + shell: sh + + - name: Linting isort + run: isort --check-only mnamer tests + shell: sh + + - name: Linting pyflakes + run: pyflakes mnamer tests + shell: sh + + - name: Linting mypy + run: mypy mnamer tests + shell: sh diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml new file mode 100644 index 00000000..623eae0d --- /dev/null +++ b/.github/actions/test/action.yml @@ -0,0 +1,46 @@ +name: Test +description: Tests the codebase with pytest + +runs: + using: composite + steps: + - uses: ./.github/actions/init + + - name: Running Local Unit Tests + run: >- + python -m pytest + -m local + --durations=10 + --cov=./ + --cov-append + --cov-report=term-missing + --cov-report=xml + shell: sh + + - name: Running Network Unit Tests + run: >- + python -m pytest + -m network + --reruns 3 + --durations=10 + --cov=./ + --cov-append + --cov-report=term-missing + --cov-report=xml + shell: sh + + - name: Running End to End Tests + run: >- + python -m pytest + -m e2e + --reruns 3 + --durations=10 + --cov=./ + --cov-append + --cov-report=term-missing + --cov-report=xml + shell: sh + + - name: Reporting Coverage Statistics + uses: codecov/codecov-action@v1 + if: success() && github.ref == 'refs/heads/main' diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 05804ec1..28fe2693 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -3,43 +3,16 @@ name: pr on: pull_request jobs: - - lint: # ------------------------------------------------------------------------------ + lint: runs-on: ubuntu-latest steps: - - name: Checking out Git Commit - uses: actions/checkout@v2 - - - name: Installing Python 3 - uses: actions/setup-python@v2 - with: - python-version: 3.x - cache: pip - cache-dependency-path: pyproject.toml - - - name: Installing Linters from PyPI - run: pip install .[test,dev] - - - name: Linting with black - run: black --check mnamer tests + - uses: actions/checkout@v3 + - uses: ./.github/actions/lint - - name: Linting isort - run: isort --check-only mnamer tests - - - name: Linting mypy - run: mypy mnamer - - test: # ------------------------------------------------------------------------------ + test: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: 3.x - cache: pip - cache-dependency-path: pyproject.toml - - run: pip install -q -U pip .[test] - - run: >- - python -m pytest -m 'not tvdb' + steps: + - uses: actions/checkout@v3 + - uses: ./.github/actions/test diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index cab215ab..b1cfcc0b 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -3,154 +3,45 @@ name: push on: push: schedule: - - cron: "0 8 * * 1" # Mondays at 8am + - cron: "0 8 * * 1" # mondays at 8am jobs: - build: # ----------------------------------------------------------------------------- + lint: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.8", "3.x"] - name: build-v${{matrix.python-version}} - steps: - - name: Checking out Git Commit - uses: actions/checkout@v2 - - - name: Installing Python - uses: actions/setup-python@v2 - with: - python-version: ${{matrix.python-version}} - cache: pip - cache-dependency-path: pyproject.toml - - - uses: actions/cache@v2 - with: - path: ${{ env.pythonLocation }} - key: ${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('MANIFEST.in') }}-${{ hashFiles('pyproject.toml') }} + - uses: actions/checkout@v3 + - uses: ./.github/actions/lint - - name: Installing dependencies - run: pip install -U -r requirements.txt -r requirements-test.txt -r requirements-dev.txt - - - name: Attempting build - run: python -m build --wheel --no-isolation - - lint: # ------------------------------------------------------------------------------ + test: runs-on: ubuntu-latest - needs: build steps: - - name: Checking out Git Commit - uses: actions/checkout@v2 - - - name: Installing Python - uses: actions/setup-python@v2 - with: - python-version: 3.x - cache: pip - cache-dependency-path: pyproject.toml + - uses: actions/checkout@v3 + - uses: ./.github/actions/test - - uses: actions/cache@v2 - with: - path: ${{ env.pythonLocation }} - key: ${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-text.txt') }}-${{ hashFiles('MANIFEST.in') }} - - - name: Linting with black - run: black --check mnamer tests - - - name: Linting isort - run: isort --check-only mnamer tests - - - name: Linting mypy - run: mypy mnamer - - test: # ------------------------------------------------------------------------------ + publish: runs-on: ubuntu-latest - needs: build - - strategy: - matrix: - python-version: ["8", "3.x"] - name: test-v${{matrix.python-version}} - steps: - - name: Checking out Git Commit - uses: actions/checkout@v2 - - - name: Installing Python - uses: actions/setup-python@v2 - with: - python-version: ${{matrix.python-version}} - cache: pip - cache-dependency-path: pyproject.toml - - - uses: actions/cache@v2 - with: - path: ${{ env.pythonLocation }} - key: ${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-text.txt') }}-${{ hashFiles('MANIFEST.in') }} - - name: Running Local Unit Tests - run: >- - python -m pytest - -m local - --durations=10 - --cov=./ - --cov-append - --cov-report=term-missing - --cov-report=xml - - - name: Running Network Unit Tests - run: >- - python -m pytest - -m network - --reruns 3 - --durations=10 - --cov=./ - --cov-append - --cov-report=term-missing - --cov-report=xml + if: >- + success() + && github.event_name == 'push' + && github.ref == 'refs/heads/main' - - name: Running End to End Tests - run: >- - python -m pytest - -m e2e - --reruns 3 - --durations=10 - --cov=./ - --cov-append - --cov-report=term-missing - --cov-report=xml - - - name: Reporting Coverage Statistics - if: > - success() - && github.event_name == 'push' - && github.ref == 'refs/heads/master' - && matrix['python-version'] == '3.x' - uses: codecov/codecov-action@v1 - - publish: # --------------------------------------------------------------------------- - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags') needs: - - build - lint - test steps: - - name: Checking out Git Commit - uses: actions/checkout@v2 - - - name: Installing Python - uses: actions/setup-python@v2 - with: - python-version: 3.8 + - uses: actions/checkout@v3 + with: { fetch-depth: 0 } # required for setuptools_scm to detect git tags + - uses: ./.github/actions/init - - name: Installing Requirements - run: pip install -U -r requirements.txt -r -r requirements-dev.txt + - name: Building + run: python3 -m build --sdist --wheel --no-isolation - - name: Building wheel and source distribution - run: python -m build --sdist --wheel + - name: Reporting Version + run: python3 -m mnamer --version - name: Uploading to PyPI run: >- diff --git a/.gitignore b/.gitignore index f6a1f54a..fe565c31 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ __pycache__/ .idea .mypy_cache/ .pytest_cache/ -.python-version .vimrc .vs .vscode @@ -20,6 +19,7 @@ coverage.xml demo/ dist/ MANIFEST +mnamer/__version__.py playground.py testing/test_files_* -venv* \ No newline at end of file +venv* diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..c8cfe395 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/.vscode/settings.json b/.vscode/settings.json index 2e6c4db1..02d2fa53 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,13 +14,12 @@ "**/.DS_Store": true, "**/.git": true, "**/.pytest_cache/": true, - "**/.svn": true, "**/__pycache__": true, "*.egg-info": true, - "*.egg-info/": true, ".idea": true, "demo": true, "venv": true }, - "python.formatting.provider": "black" -} \ No newline at end of file + "python.formatting.provider": "black", + "python.linting.mypyEnabled": true +} diff --git a/Dockerfile b/Dockerfile index 036de0d0..b93e4670 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ FROM python:alpine -ARG MNAMER_VERSION=2.4.2 +ARG MNAMER_VERSION=2.5.2 ARG UID=1000 ARG GID=1000 RUN addgroup mnamer -g $GID diff --git a/README.md b/README.md index 715c3437..dd6d3237 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ [![PyPI](https://img.shields.io/pypi/v/mnamer.svg?style=for-the-badge)](https://pypi.python.org/pypi/mnamer) -[![Tests](https://img.shields.io/github/workflow/status/jkwill87/mnamer/push?style=for-the-badge&label=Tests)](https://github.com/jkwill87/mnamer/actions?query=workflow:push) -[![Coverage](https://img.shields.io/codecov/c/github/jkwill87/mnamer/master.svg?style=for-the-badge)](https://codecov.io/gh/jkwill87/mnamer) +[![Tests](https://img.shields.io/github/workflow/status/jkwill87/mnamer/push?style=for-the-badge&label=Tests)](https://img.shields.io/github/actions/workflow/status/jkwill87/mnamer/.github/workflows/push.yml?branch=main) +[![Coverage](https://img.shields.io/codecov/c/github/jkwill87/mnamer/main.svg?style=for-the-badge)](https://codecov.io/gh/jkwill87/mnamer) [![Licence](https://img.shields.io/github/license/jkwill87/mnamer.svg?style=for-the-badge)](https://en.wikipedia.org/wiki/MIT_License) [![Style: Black](https://img.shields.io/badge/Style-Black-black.svg?style=for-the-badge)](https://github.com/ambv/black) - + # mnamer @@ -12,7 +12,7 @@ mnamer (**m**edia re**namer**) is an intelligent and highly configurable media o Currently it has integration support with [TVDb](https://thetvdb.com) and [TvMaze](https://www.tvmaze.com) for television episodes and [TMDb](https://www.themoviedb.org/) and [OMDb](https://www.omdbapi.com) for movies. - + ## Documentation diff --git a/makefile b/makefile index ecf8a0bb..acabcb1e 100644 --- a/makefile +++ b/makefile @@ -1,7 +1,6 @@ help: $(info * deployment: build, publish, publish-test) - $(info * versioning: bump-patch, bump-minor, bump-major) - $(info * setup: venv, demo) + $(info * setup: init, demo) clean-build: $(info * cleaning build files) @@ -12,63 +11,28 @@ clean-demo: $(info * cleaning demo files) @rm -rf build dist *.egg-info demo -clean-venv: - $(info * removing venv files) - @deactivate 2> /dev/null || true - @rm -rf venv - -clean: clean-build clean-demo clean-venv +clean: clean-build clean-demo # Deployment Helpers ----------------------------------------------------------- -build: clean-build +init: + $(info * installing dependencies) + pip3 install -U pip + pip3 install -Ur requirements.txt + pip3 install -Ur requirements-dev.txt + +build: clean-build init $(info * building distributable) - @python3 -m build --sdist --wheel --no-isolation > /dev/null 2>&1 + python3 -m build --sdist --wheel --no-isolation > /dev/null 2>&1 publish: build $(info * publishing to PyPI repository) - twine upload --repository-url https://upload.pypi.org/legacy/ dist/* + python3 -m twine upload --repository-url https://upload.pypi.org/legacy/ dist/* publish-test: build $(info * publishing to PyPI test repository) - twine upload --repository-url https://test.pypi.org/legacy/ dist/* - - -# Version Helpers -------------------------------------------------------------- - -bump-patch: - $(info * resetting any current changes) - git reset -- - $(info * bumping patch version) - @vbump --patch - git add mnamer/__version__.py - $(info * commiting version change) - git commit -m "Patch version bump" - $(info * creating tag) - git tag `vbump | egrep -o '[0-9].*'` - -bump-minor: - $(info * resetting any current changes) - git reset -- - $(info * bumping minor version) - vbump --minor - git add mnamer/__version__.py - $(info * commiting version change) - git commit -m "Minor version bump" - $(info * creating tag) - git tag `vbump | egrep -o '[0-9].*'` - -bump-major: - $(info * resetting any current changes) - git reset -- - $(info * bumping major version) - vbump --major - git add mnamer/__version__.py - $(info * commiting version change) - git commit -m "Major version bump" - $(info * creating tag) - git tag `vbump | egrep -o '[0-9].*'` + python3 -m twine upload --repository-url https://test.pypi.org/legacy/ dist/* # Setup Helpers ---------------------------------------------------------------- @@ -107,10 +71,3 @@ demo: clean-demo "s.w.a.t.2017.s02e01.mkv" \ "scan001.tiff" \ "temp.zip" - -venv: clean-venv - $(info * initializing venv) - @python3 -m venv venv - $(info * installing dev requirements) - @./venv/bin/pip install -qU pip - @./venv/bin/pip install -qr requirements-dev.txt diff --git a/mnamer/__version__.py b/mnamer/__version__.py deleted file mode 100644 index 65fd358a..00000000 --- a/mnamer/__version__.py +++ /dev/null @@ -1 +0,0 @@ -VERSION = "2.5.4" diff --git a/mnamer/argument.py b/mnamer/argument.py index cf0afbed..42de2ca3 100644 --- a/mnamer/argument.py +++ b/mnamer/argument.py @@ -1,12 +1,10 @@ import argparse -from typing import Any, Dict +from typing import Any from mnamer.const import USAGE from mnamer.setting_spec import SettingSpec from mnamer.types import SettingType -__all__ = ["ArgLoader"] - HELP_TEMPLATE = """ {usage} @@ -32,9 +30,8 @@ class ArgLoader(argparse.ArgumentParser): """ - An overridden ArgumentParser class which is build to accommodate mnamer's - setting patterns and delineation of parameter, directive, and positional - arguments. + An overridden ArgumentParser class which is build to accommodate mnamer's setting + patterns and delineation of parameter, directive, and positional arguments. """ def __init__(self, *specs: SettingSpec): @@ -70,7 +67,7 @@ def _add_spec(self, spec: SettingSpec): __iadd__ = _add_spec - def load(self) -> Dict[str, Any]: + def load(self) -> dict[str, Any]: load_arguments, unknowns = self.parse_known_args() if unknowns: raise RuntimeError(f"invalid arguments: {','.join(unknowns)}") @@ -78,8 +75,8 @@ def load(self) -> Dict[str, Any]: def format_help(self) -> str: """ - Overrides ArgumentParser's format_help to dynamically generate a help - message for use with the `--help` flag. + Overrides ArgumentParser's format_help to dynamically generate a help message + for use with the `--help` flag. """ def help_for_group(group: SettingType) -> str: diff --git a/mnamer/const.py b/mnamer/const.py index cfa22fc4..c6c966a4 100644 --- a/mnamer/const.py +++ b/mnamer/const.py @@ -5,6 +5,8 @@ from platform import platform, python_version from sys import argv, gettrace, version_info +from setuptools_scm import get_version # type: ignore + try: from appdirs import __version__ as appdirs_version # type: ignore except ModuleNotFoundError: @@ -37,20 +39,6 @@ except ModuleNotFoundError: teletype_version = "N/A" -from mnamer.__version__ import VERSION - -__all__ = [ - "CACHE_PATH", - "CURRENT_YEAR", - "DEPRECATED", - "IS_DEBUG", - "SUBTITLE_CONTAINERS", - "SYSTEM", - "USAGE", - "VERSION", - "VERSION_MAJOR", -] - CACHE_PATH = Path( cache_dir, f"mnamer-py{version_info.major}.{version_info.minor}" @@ -64,6 +52,8 @@ SUBTITLE_CONTAINERS = [".srt", ".idx", ".sub"] +VERSION = get_version(root="..", relative_to=__file__, local_scheme="dirty-tag") + SYSTEM = { "date": dt.date.today(), "platform": platform(), @@ -79,5 +69,3 @@ } USAGE = "USAGE: mnamer [preferences] [directives] target [targets ...]" - -VERSION_MAJOR = int(VERSION[0]) diff --git a/mnamer/endpoints.py b/mnamer/endpoints.py index 19a41353..1cbab96f 100644 --- a/mnamer/endpoints.py +++ b/mnamer/endpoints.py @@ -3,7 +3,6 @@ import datetime from re import match from time import sleep -from typing import Optional, Union from mnamer.exceptions import ( MnamerException, @@ -13,41 +12,19 @@ from mnamer.language import Language from mnamer.utils import clean_dict, parse_date, request_json -__all__ = [ - "omdb_search", - "omdb_title", - "tmdb_find", - "tmdb_movies", - "tmdb_search_movies", - "tvdb_episodes_id", - "tvdb_login", - "tvdb_refresh_token", - "tvdb_search_series", - "tvdb_series_id_episodes_query", - "tvdb_series_id_episodes", - "tvdb_series_id", - "tvmaze_episode_by_number", - "tvmaze_episodes_by_date", - "tvmaze_show", - "tvmaze_show_episodes_list", - "tvmaze_show_lookup", - "tvmaze_show_search", - "tvmaze_show_single_search", -] - OMDB_PLOT_TYPES = {"short", "long"} MAX_RETRIES = 5 def omdb_title( api_key: str, - id_imdb: Optional[str] = None, - media: Optional[str] = None, - title: Optional[str] = None, - season: Optional[int] = None, - episode: Optional[int] = None, - year: Optional[int] = None, - plot: Optional[str] = None, + id_imdb: str | None = None, + media: str | None = None, + title: str | None = None, + season: int | None = None, + episode: int | None = None, + year: int | None = None, + plot: str | None = None, cache: bool = True, ) -> dict: """ @@ -87,8 +64,8 @@ def omdb_title( def omdb_search( api_key: str, query: str, - year: Optional[int] = None, - media: Optional[str] = None, + year: int | None = None, + media: str | None = None, page: int = 1, cache: bool = True, ) -> dict: @@ -122,7 +99,7 @@ def tmdb_find( api_key: str, external_source: str, external_id: str, - language: Optional[Language] = None, + language: Language | None = None, cache: bool = True, ) -> dict: """ @@ -165,7 +142,7 @@ def tmdb_find( def tmdb_movies( api_key: str, id_tmdb: str, - language: Optional[Language] = None, + language: Language | None = None, cache: bool = True, ) -> dict: """ @@ -188,9 +165,9 @@ def tmdb_movies( def tmdb_search_movies( api_key: str, title: str, - year: Optional[Union[int, str]] = None, - language: Optional[Language] = None, - region: Optional[str] = None, + year: int | str | None = None, + language: Language | None = None, + region: str | None = None, adult: bool = False, page: int = 1, cache: bool = True, @@ -220,7 +197,7 @@ def tmdb_search_movies( return content -def tvdb_login(api_key: Optional[str]) -> str: +def tvdb_login(api_key: str | None) -> str: """ Logs into TVDb using the provided api key. @@ -256,7 +233,7 @@ def tvdb_refresh_token(token: str) -> str: def tvdb_episodes_id( token: str, id_tvdb: str, - language: Optional[Language] = None, + language: Language | None = None, cache: bool = True, ) -> dict: """ @@ -286,7 +263,7 @@ def tvdb_episodes_id( def tvdb_series_id( token: str, id_tvdb: str, - language: Optional[Language] = None, + language: Language | None = None, cache: bool = True, ) -> dict: """ @@ -318,7 +295,7 @@ def tvdb_series_id_episodes( token: str, id_tvdb: str, page: int = 1, - language: Optional[Language] = None, + language: Language | None = None, cache: bool = True, ) -> dict: """ @@ -348,10 +325,10 @@ def tvdb_series_id_episodes( def tvdb_series_id_episodes_query( token: str, id_tvdb: str, - episode: Optional[int] = None, - season: Optional[int] = None, + episode: int | None = None, + season: int | None = None, page: int = 1, - language: Optional[Language] = None, + language: Language | None = None, cache: bool = True, ) -> dict: """ @@ -384,10 +361,10 @@ def tvdb_series_id_episodes_query( def tvdb_search_series( token: str, - series: Optional[str] = None, - id_imdb: Optional[str] = None, - id_zap2it: Optional[str] = None, - language: Optional[Language] = None, + series: str | None = None, + id_imdb: str | None = None, + id_zap2it: str | None = None, + language: Language | None = None, cache: bool = True, ) -> dict: """ @@ -489,8 +466,8 @@ def tvmaze_show_single_search(query: str, cache: bool = True, attempt: int = 1) def tvmaze_show_lookup( - id_imdb: Optional[str] = None, - id_tvdb: Optional[str] = None, + id_imdb: str | None = None, + id_tvdb: str | None = None, cache: bool = True, attempt: int = 1, ) -> dict: @@ -545,7 +522,7 @@ def tvmaze_show_episodes_list( def tvmaze_episodes_by_date( id_tvmaze: str, - air_date: Union[datetime.date, str], + air_date: datetime.date | str, cache: bool = True, attempt: int = 1, ) -> dict: @@ -570,8 +547,8 @@ def tvmaze_episodes_by_date( def tvmaze_episode_by_number( id_tvmaze: str, - season: Optional[int], - episode: Optional[int], + season: int | None, + episode: int | None, cache: bool = True, attempt: int = 1, ) -> dict: diff --git a/mnamer/frontends.py b/mnamer/frontends.py index 39c2de9b..0f47a6ac 100644 --- a/mnamer/frontends.py +++ b/mnamer/frontends.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from typing import List from mnamer import tty from mnamer.const import SYSTEM, USAGE, VERSION @@ -18,7 +17,7 @@ class Frontend(ABC): settings: SettingStore - targets: List[Target] + targets: list[Target] def __init__(self, settings: SettingStore): self.settings = settings @@ -152,7 +151,7 @@ def _process_targets(self) -> None: self._rename_and_move_file(target) def _announce_file(self, target: Target): - media_type = target.metadata.media.value.title() + media_type = target.metadata.to_media_type().value.title() description = ( f"{media_type} Subtitle" if is_subtitle(target.metadata.container) diff --git a/mnamer/language.py b/mnamer/language.py index 7548fa6f..afe520ee 100644 --- a/mnamer/language.py +++ b/mnamer/language.py @@ -1,11 +1,11 @@ from __future__ import annotations import dataclasses -from typing import Any, Optional, Tuple +from typing import Any from mnamer.exceptions import MnamerException -_KNOWN = ( +KNOWN_LANGUAGES = ( ("arabic", "ar", "ara"), ("chinese", "zh", "zho"), ("croatian", "hr", "hrv"), @@ -41,7 +41,7 @@ class Language: a3: str @classmethod - def parse(cls, value: Any) -> Optional[Language]: + def parse(cls, value: Any) -> Language | None: if not value: return None if isinstance(value, cls): @@ -56,21 +56,23 @@ def parse(cls, value: Any) -> Optional[Language]: except: raise MnamerException("Could not determine language") value = value.lower() - for row in _KNOWN: + for row in KNOWN_LANGUAGES: for item in row: if value == item: return cls(row[0].capitalize(), row[1], row[2]) raise MnamerException("Could not determine language") @classmethod - def all(cls) -> Tuple[Language, ...]: - return tuple(cls(row[0].capitalize(), row[1], row[2]) for row in _KNOWN) + def all(cls) -> tuple[Language, ...]: + return tuple( + cls(row[0].capitalize(), row[1], row[2]) for row in KNOWN_LANGUAGES + ) def __str__(self) -> str: return self.a2 @staticmethod - def ensure_valid_for_tvdb(language: Optional[Language]): + def ensure_valid_for_tvdb(language: Language | None): valid = { "cs", "da", diff --git a/mnamer/metadata.py b/mnamer/metadata.py index f035da9b..df43382a 100644 --- a/mnamer/metadata.py +++ b/mnamer/metadata.py @@ -1,8 +1,10 @@ +from __future__ import annotations + import dataclasses import datetime as dt import re from string import Formatter -from typing import Any, Callable, Dict, Mapping, Optional, Sequence, Union +from typing import Any, Callable, Mapping, Sequence from mnamer.language import Language from mnamer.types import MediaType @@ -17,16 +19,14 @@ year_parse, ) -__all__ = ["Metadata", "MetadataMovie", "MetadataEpisode"] - class _MetaFormatter(Formatter): - def format_field(self, value: Union[None, int, str], format_spec: str) -> str: + def format_field(self, value: None | int | str, format_spec: str) -> str: return format(value, format_spec) if value is not None else "" def get_value( - self, key: Union[str, int], args: Sequence[Any], kwargs: Mapping[str, Any] - ) -> Union[None, int, str]: + self, key: str | int, args: Sequence[Any], kwargs: Mapping[str, Any] + ) -> None | int | str: if isinstance(key, int): assert args return args[key] @@ -38,16 +38,24 @@ def get_value( class Metadata: """A dataclass which transforms and stores media metadata information.""" - container: Optional[str] = None - group: Optional[str] = None - language: Optional[Language] = None - language_sub: Optional[Language] = None - quality: Optional[str] = None - synopsis: Optional[str] = None - media: Union[MediaType, str, None] = None + container: str | None = None + group: str | None = None + language: Language | None = None + language_sub: Language | None = None + quality: str | None = None + synopsis: str | None = None + + @classmethod + def to_media_type(cls) -> MediaType: + if cls is MetadataEpisode: + return MediaType.EPISODE + elif cls is MetadataMovie: + return MediaType.MOVIE + else: + raise ValueError(f"Unknown metadata class: {cls}") def __setattr__(self, key: str, value: Any): - converter_map: Dict[str, Callable] = { + converter_map: dict[str, Callable] = { "container": normalize_container, "group": str.upper, "language": Language.parse, @@ -56,12 +64,12 @@ def __setattr__(self, key: str, value: Any): "quality": str.lower, "synopsis": str.capitalize, } - converter: Optional[Callable] = converter_map.get(key) + converter: Callable | None = converter_map.get(key) if value is not None and converter: value = converter(value) super().__setattr__(key, value) - def __format__(self, format_spec: Optional[str]): + def __format__(self, format_spec: str | None): raise NotImplementedError def __str__(self) -> str: @@ -74,7 +82,7 @@ def extension(self): else: return self.container - def as_dict(self) -> Dict[str, Any]: + def as_dict(self) -> dict[str, Any]: d = dataclasses.asdict(self) d["extension"] = self.extension return d @@ -86,7 +94,7 @@ def _format_repl(self, mobj) -> str: value = str_title_case(value) return value - def update(self, metadata: "Metadata"): + def update(self, metadata: Metadata): """Overlays all none value from another Metadata instance.""" for field in dataclasses.asdict(self).keys(): value = getattr(metadata, field) @@ -102,13 +110,12 @@ class MetadataMovie(Metadata): to movies. """ - name: Optional[str] = None - year: Optional[str] = None - id_imdb: Optional[str] = None - id_tmdb: Union[str, None] = None - media: MediaType = MediaType.MOVIE + name: str | None = None + year: str | None = None + id_imdb: str | None = None + id_tmdb: str | None = None - def __format__(self, format_spec: Optional[str]): + def __format__(self, format_spec: str | None): default = "{name} ({year})" re_pattern = r"({(\w+)(?:\[[\w:]+\])?(?:\:\d{1,2})?})" s = re.sub(re_pattern, self._format_repl, format_spec or default) @@ -116,11 +123,11 @@ def __format__(self, format_spec: Optional[str]): return s def __setattr__(self, key: str, value: Any): - converter_map: Dict[str, Callable] = { + converter_map: dict[str, Callable] = { "name": fn_pipe(str_replace_slashes, str_title_case), "year": year_parse, } - converter: Optional[Callable] = converter_map.get(key) + converter: Callable | None = converter_map.get(key) if value is not None and converter: value = converter(value) super().__setattr__(key, value) @@ -129,18 +136,17 @@ def __setattr__(self, key: str, value: Any): @dataclasses.dataclass class MetadataEpisode(Metadata): """ - A dataclass which transforms and stores media metadata information specific - to television episodes. + A dataclass which transforms and stores media metadata information specific to + television episodes. """ - series: Optional[str] = None - season: Optional[int] = None - episode: Optional[int] = None - date: Optional[dt.date] = None - title: Optional[str] = None - id_tvdb: Optional[str] = None - id_tvmaze: Optional[str] = None - media: MediaType = MediaType.EPISODE + series: str | None = None + season: int | None = None + episode: int | None = None + date: dt.date | None = None + title: str | None = None + id_tvdb: str | None = None + id_tvmaze: str | None = None def __post_init__(self): if isinstance(self.season, str): @@ -150,7 +156,7 @@ def __post_init__(self): if isinstance(self.date, str): self.date = parse_date(self.date) - def __format__(self, format_spec: Optional[str]): + def __format__(self, format_spec: str | None): default = "{series} - {season:02}x{episode:02} - {title}" re_pattern = r"({(\w+)(?:\[[\w:]+\])?(?:\:\d{1,2})?})" s = re.sub(re_pattern, self._format_repl, format_spec or default) @@ -158,14 +164,14 @@ def __format__(self, format_spec: Optional[str]): return s def __setattr__(self, key: str, value: Any): - converter_map: Dict[str, Callable] = { + converter_map: dict[str, Callable] = { "date": parse_date, "episode": int, "season": int, "series": fn_pipe(str_replace_slashes, str_title_case), "title": fn_pipe(str_replace_slashes, str_title_case), } - converter: Optional[Callable] = converter_map.get(key) + converter: Callable | None = converter_map.get(key) if value is not None and converter: value = converter(value) super().__setattr__(key, value) diff --git a/mnamer/providers.py b/mnamer/providers.py index 010fc633..a650f893 100644 --- a/mnamer/providers.py +++ b/mnamer/providers.py @@ -5,7 +5,7 @@ import datetime as dt from abc import ABC, abstractmethod from os import environ -from typing import Iterator, Optional, TypeVar +from typing import Iterator from mnamer.endpoints import ( omdb_search, @@ -30,16 +30,14 @@ from mnamer.types import MediaType, ProviderType from mnamer.utils import parse_date, year_range_parse -__all__ = ["Provider", "Omdb", "Tmdb", "Tvdb", "TvMaze"] - class Provider(ABC): """ABC for Providers, high-level interfaces for metadata media providers.""" - api_key: Optional[str] = None + api_key: str | None = None cache: bool = True - def __init__(self, api_key: Optional[str] = None, cache: bool = True): + def __init__(self, api_key: str | None = None, cache: bool = True): """Initializes the provider.""" if api_key: self.api_key = api_key @@ -75,7 +73,7 @@ class Omdb(Provider): api_key: str = environ.get("API_KEY_OMDB", "477a7ebc") - def __init__(self, api_key: Optional[str] = None, cache: bool = True): + def __init__(self, api_key: str | None = None, cache: bool = True): super().__init__(api_key, cache) assert self.api_key @@ -112,7 +110,7 @@ def _lookup_movie(self, id_imdb: str) -> Iterator[MetadataMovie]: meta.synopsis = None yield meta - def _search_movie(self, name: str, year: Optional[str]) -> Iterator[MetadataMovie]: + def _search_movie(self, name: str, year: str | None) -> Iterator[MetadataMovie]: assert self.api_key year_from, year_to = year_range_parse(year, 5) found = False @@ -145,7 +143,7 @@ class Tmdb(Provider): api_key: str = environ.get("API_KEY_TMDB", "db972a607f2760bb19ff8bb34074b4c7") - def __init__(self, api_key: Optional[str] = None, cache: bool = True): + def __init__(self, api_key: str | None = None, cache: bool = True): super().__init__(api_key, cache) assert self.api_key @@ -161,7 +159,7 @@ def search(self, query: MetadataMovie) -> Iterator[MetadataMovie]: yield from results def _search_id( - self, id_tmdb: str, language: Optional[Language] = None + self, id_tmdb: str, language: Language | None = None ) -> Iterator[MetadataMovie]: assert self.api_key response = tmdb_movies(self.api_key, id_tmdb, language, self.cache) @@ -174,9 +172,7 @@ def _search_id( id_imdb=response["imdb_id"], ) - def _search_name( - self, name: str, year: Optional[str], language: Optional[Language] - ): + def _search_name(self, name: str, year: str | None, language: Language | None): assert self.api_key year_from, year_to = year_range_parse(year, 5) page = 1 @@ -221,7 +217,7 @@ class Tvdb(Provider): api_key: str = environ.get("API_KEY_TVDB", "E69C7A2CEF2F3152") - def __init__(self, api_key: Optional[str] = None, cache: bool = True): + def __init__(self, api_key: str | None = None, cache: bool = True): super().__init__(api_key, cache) assert self.api_key self.token = "" if self.cache else self._login() @@ -254,9 +250,9 @@ def search(self, query: MetadataEpisode) -> Iterator[MetadataEpisode]: def _search_id( self, id_tvdb: str, - season: Optional[int] = None, - episode: Optional[int] = None, - language: Optional[Language] = None, + season: int | None = None, + episode: int | None = None, + language: Language | None = None, ): found = False series_data = tvdb_series_id( @@ -300,9 +296,9 @@ def _search_id( def _search_series( self, series: str, - season: Optional[int], - episode: Optional[int], - language: Optional[Language], + season: int | None, + episode: int | None, + language: Language | None, ): found = False series_data = tvdb_search_series( @@ -322,7 +318,7 @@ def _search_series( raise MnamerNotFoundException def _search_tvdb_date( - self, id_tvdb: str, release_date: dt.date, language: Optional[Language] + self, id_tvdb: str, release_date: dt.date, language: Language | None ): release_date = parse_date(release_date) found = False @@ -334,7 +330,7 @@ def _search_tvdb_date( raise MnamerNotFoundException def _search_series_date( - self, series: str, release_date: dt.date, language: Optional[Language] + self, series: str, release_date: dt.date, language: Language | None ): release_date = parse_date(release_date) series_data = tvdb_search_series( @@ -380,7 +376,7 @@ def search(self, query: MetadataEpisode) -> Iterator[MetadataEpisode]: raise MnamerNotFoundException def _lookup_with_tmaze_id_and_season_and_episode( - self, id_tvmaze: str, season: Optional[int], episode: Optional[int] + self, id_tvmaze: str, season: int | None, episode: int | None ) -> Iterator[MetadataEpisode]: series_data = tvmaze_show(id_tvmaze) episode_data = tvmaze_episode_by_number(id_tvmaze, season, episode) @@ -388,7 +384,7 @@ def _lookup_with_tmaze_id_and_season_and_episode( yield self._transform_meta(id_tvmaze, id_tvdb, series_data, episode_data) def _lookup_with_id_and_date( - self, id_tvmaze: Optional[str], id_tvdb: Optional[str], air_date: dt.date + self, id_tvmaze: str | None, id_tvdb: str | None, air_date: dt.date ) -> Iterator[MetadataEpisode]: assert id_tvmaze or id_tvdb if id_tvmaze: @@ -407,10 +403,10 @@ def _lookup_with_id_and_date( def _lookup_with_id( self, - id_tvmaze: Optional[str], - id_tvdb: Optional[str], - season: Optional[int], - episode: Optional[int], + id_tvmaze: str | None, + id_tvdb: str | None, + season: int | None, + episode: int | None, ) -> Iterator[MetadataEpisode]: assert id_tvmaze or id_tvdb if id_tvmaze: @@ -433,7 +429,7 @@ def _lookup_with_id( yield meta def _search_with_season_and_episode( - self, series: str, season: Optional[int], episode: Optional[int] + self, series: str, season: int | None, episode: int | None ) -> Iterator[MetadataEpisode]: assert season series_data = tvmaze_show_search(series) @@ -454,7 +450,7 @@ def _search_with_season_and_episode( yield meta def _search( - self, series: str, season: Optional[int], episode: Optional[int] + self, series: str, season: int | None, episode: int | None ) -> Iterator[MetadataEpisode]: assert series series_data = tvmaze_show_search(series) @@ -477,7 +473,7 @@ def _search( @staticmethod def _transform_meta( - id_tvmaze: str, id_tvdb: Optional[str], series_entry: dict, episode_entry: dict + id_tvmaze: str, id_tvdb: str | None, series_entry: dict, episode_entry: dict ) -> MetadataEpisode: return MetadataEpisode( date=episode_entry["airdate"] or None, diff --git a/mnamer/setting_spec.py b/mnamer/setting_spec.py index 9e09c10f..258ed1d3 100644 --- a/mnamer/setting_spec.py +++ b/mnamer/setting_spec.py @@ -1,10 +1,8 @@ import dataclasses -from typing import Any, Dict, List, Optional, Tuple +from typing import Any from mnamer.types import SettingType -__all__ = ["SettingSpec"] - @dataclasses.dataclass(frozen=True) class SettingSpec: @@ -14,22 +12,22 @@ class SettingSpec: """ group: SettingType - dest: Optional[str] = None - action: Optional[str] = None - choices: Optional[List[str]] = None - flags: Optional[List[str]] = None - help: Optional[str] = None - nargs: Optional[str] = None - type: Optional[type] = None - - def as_dict(self) -> Dict[str, Any]: + dest: str | None = None + action: str | None = None + choices: list[str] | None = None + flags: list[str] | None = None + help: str | None = None + nargs: str | None = None + typevar: type | None = None + + def as_dict(self) -> dict[str, Any]: """Converts ArgSpec instance into a Python dictionary.""" return {k: v for k, v in vars(self).items() if k} __call__ = as_dict @property - def registration(self) -> Tuple[List[str], Dict[str, Any]]: + def registration(self) -> tuple[list[str], dict[str, Any]]: names = self.flags or [] options = { "action": self.action, @@ -38,12 +36,12 @@ def registration(self) -> Tuple[List[str], Dict[str, Any]]: "dest": self.dest, "help": self.help, "nargs": self.nargs, - "type": self.type, + "type": self.typevar, } return names, {k: v for k, v in options.items() if v is not None} @property - def name(self) -> Optional[str]: + def name(self) -> str | None: if self.flags: return max(self.flags, key=len).lstrip("-") return None diff --git a/mnamer/setting_store.py b/mnamer/setting_store.py index ce6d4d7a..8981d335 100644 --- a/mnamer/setting_store.py +++ b/mnamer/setting_store.py @@ -1,18 +1,17 @@ import dataclasses import json from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Union +from typing import Any, Callable from mnamer.argument import ArgLoader from mnamer.const import SUBTITLE_CONTAINERS from mnamer.exceptions import MnamerException from mnamer.language import Language +from mnamer.metadata import Metadata from mnamer.setting_spec import SettingSpec from mnamer.types import MediaType, ProviderType, SettingType from mnamer.utils import crawl_out, json_loads, normalize_containers -__all__ = ["SettingStore"] - @dataclasses.dataclass class SettingStore: @@ -23,7 +22,7 @@ class SettingStore: # positional attributes ---------------------------------------------------- - targets: List[Path] = dataclasses.field( + targets: list[Path] = dataclasses.field( default_factory=lambda: [], metadata=SettingSpec( flags=["targets"], @@ -87,10 +86,10 @@ class SettingStore: flags=["--hits"], group=SettingType.PARAMETER, help="--hits=: limit the maximum number of hits for each query", - type=int, + typevar=int, ).as_dict(), ) - ignore: List[str] = dataclasses.field( + ignore: list[str] = dataclasses.field( default_factory=lambda: [".*sample.*", "^RARBG.*"], metadata=SettingSpec( flags=["--ignore"], @@ -99,7 +98,7 @@ class SettingStore: nargs="+", ).as_dict(), ) - language: Optional[Language] = dataclasses.field( + language: Language | None = dataclasses.field( default=None, metadata=SettingSpec( flags=["--language"], @@ -107,7 +106,7 @@ class SettingStore: help="--language=: specify the search language", ).as_dict(), ) - mask: List[str] = dataclasses.field( + mask: list[str] = dataclasses.field( default_factory=lambda: [ "avi", "m4v", @@ -154,7 +153,7 @@ class SettingStore: help="--no-style: print to stdout without using colour or unicode chars", ).as_dict(), ) - movie_api: Union[ProviderType, str] = dataclasses.field( + movie_api: ProviderType | str = dataclasses.field( default=ProviderType.TMDB, metadata=SettingSpec( choices=[ProviderType.TMDB.value, ProviderType.OMDB.value], @@ -164,7 +163,7 @@ class SettingStore: help="--movie-api={*tmdb,omdb}: set movie api provider", ).as_dict(), ) - movie_directory: Optional[Path] = dataclasses.field( + movie_directory: Path | None = dataclasses.field( default=None, metadata=SettingSpec( dest="movie_directory", @@ -186,7 +185,7 @@ class SettingStore: help="--movie-format: set movie renaming format specification", ).as_dict(), ) - episode_api: Union[ProviderType, str] = dataclasses.field( + episode_api: ProviderType | str = dataclasses.field( default=ProviderType.TVMAZE, metadata=SettingSpec( choices=[ProviderType.TVDB.value, ProviderType.TVMAZE.value], @@ -196,7 +195,7 @@ class SettingStore: help="--episode-api={tvdb,*tvmaze}: set episode api provider", ).as_dict(), ) - episode_directory: Optional[Path] = dataclasses.field( + episode_directory: Path | None = dataclasses.field( default=None, metadata=SettingSpec( dest="episode_directory", @@ -260,7 +259,7 @@ class SettingStore: help="--config-ignore: skips loading config file for session", ).as_dict(), ) - config_path: Optional[str] = dataclasses.field( + config_path: str | None = dataclasses.field( default=None, metadata=SettingSpec( flags=["--config_path", "--config-path"], @@ -268,7 +267,7 @@ class SettingStore: help="--config-path=: specifies configuration path to load", ).as_dict(), ) - id_imdb: Optional[str] = dataclasses.field( + id_imdb: str | None = dataclasses.field( default=None, metadata=SettingSpec( flags=["--id_imdb", "--id-imdb", "--idimdb"], @@ -276,7 +275,7 @@ class SettingStore: help="--id-imdb=: specify an IMDb movie id override", ).as_dict(), ) - id_tmdb: Optional[str] = dataclasses.field( + id_tmdb: str | None = dataclasses.field( default=None, metadata=SettingSpec( flags=["--id_tmdb", "--id-tmdb", "--idtmdb"], @@ -284,7 +283,7 @@ class SettingStore: help="--id-tmdb=: specify a TMDb movie id override", ).as_dict(), ) - id_tvdb: Optional[str] = dataclasses.field( + id_tvdb: str | None = dataclasses.field( default=None, metadata=SettingSpec( flags=["--id_tvdb", "--id-tvdb", "--idtvdb"], @@ -292,7 +291,7 @@ class SettingStore: help="--id-tvdb=: specify a TVDb series id override", ).as_dict(), ) - id_tvmaze: Optional[str] = dataclasses.field( + id_tvmaze: str | None = dataclasses.field( default=None, metadata=SettingSpec( flags=["--id_tvmaze", "--id-tvmaze", "--idtvmaze"], @@ -310,7 +309,7 @@ class SettingStore: help="--no-cache: disable request cache", ).as_dict(), ) - media: Optional[MediaType] = dataclasses.field( + media: MediaType | None = dataclasses.field( default=None, metadata=SettingSpec( choices=[MediaType.EPISODE.value, MediaType.MOVIE.value], @@ -331,33 +330,33 @@ class SettingStore: # config-only attributes --------------------------------------------------- - api_key_omdb: Optional[str] = dataclasses.field( + api_key_omdb: str | None = dataclasses.field( default=None, metadata=SettingSpec(group=SettingType.CONFIGURATION).as_dict(), ) - api_key_tmdb: Optional[str] = dataclasses.field( + api_key_tmdb: str | None = dataclasses.field( default=None, metadata=SettingSpec(group=SettingType.CONFIGURATION).as_dict(), ) - api_key_tvdb: Optional[str] = dataclasses.field( + api_key_tvdb: str | None = dataclasses.field( default=None, metadata=SettingSpec(group=SettingType.CONFIGURATION).as_dict(), ) - api_key_tvmaze: Optional[str] = dataclasses.field( + api_key_tvmaze: str | None = dataclasses.field( default=None, metadata=SettingSpec(group=SettingType.CONFIGURATION).as_dict(), ) - replace_before: Dict[str, str] = dataclasses.field( + replace_before: dict[str, str] = dataclasses.field( default_factory=lambda: {}, metadata=SettingSpec(group=SettingType.CONFIGURATION).as_dict(), ) - replace_after: Dict[str, str] = dataclasses.field( + replace_after: dict[str, str] = dataclasses.field( default_factory=lambda: {"&": "and", "@": "at", ";": ","}, metadata=SettingSpec(group=SettingType.CONFIGURATION).as_dict(), ) @classmethod - def specifications(cls) -> List[SettingSpec]: + def specifications(cls) -> list[SettingSpec]: return [ SettingSpec(**f.metadata) for f in dataclasses.fields(SettingStore) @@ -365,11 +364,11 @@ def specifications(cls) -> List[SettingSpec]: ] @staticmethod - def _resolve_path(path: Union[str, Path]) -> Path: + def _resolve_path(path: str | Path) -> Path: return Path(path).resolve() def __setattr__(self, key: str, value: Any): - converter_map: Dict[str, Callable] = { + converter_map: dict[str, Callable] = { "episode_api": ProviderType, "episode_directory": self._resolve_path, "language": Language.parse, @@ -379,12 +378,12 @@ def __setattr__(self, key: str, value: Any): "movie_directory": self._resolve_path, "targets": lambda targets: [Path(target) for target in targets], } - converter: Optional[Callable] = converter_map.get(key) + converter: Callable | None = converter_map.get(key) if value is not None and converter: value = converter(value) super().__setattr__(key, value) - def as_dict(self) -> Dict[str, Any]: + def as_dict(self) -> dict[str, Any]: return dataclasses.asdict(self) def as_json(self) -> str: @@ -415,7 +414,7 @@ def as_json(self) -> str: sort_keys=True, ) - def bulk_apply(self, d: Dict[str, Any]): + def bulk_apply(self, d: dict[str, Any]): for k, v in d.items(): if v: setattr(self, k, v) @@ -434,17 +433,18 @@ def load(self) -> None: self.bulk_apply(arguments) return None - def api_for(self, media_type: Optional[MediaType]) -> Optional[ProviderType]: + def api_for(self, media_type: MediaType | None) -> ProviderType | None: """Returns the ProviderType for a given media type.""" if media_type: return getattr(self, f"{media_type.value}_api") return None - def api_key_for(self, provider_type: ProviderType) -> Optional[str]: + def api_key_for(self, provider_type: ProviderType) -> str | None: """Returns the API key for a provider type.""" if provider_type: return getattr(self, f"api_key_{provider_type.value}") return None - def formatting_for(self, media_type: MediaType) -> str: - return getattr(self, f"{media_type.value}_format") if media_type else "" + def formatting_for(self, media: MediaType | Metadata) -> str: + """Returns the formatting string for a given media type or metadata.""" + return getattr(self, f"{ media.to_media_type().value}_format") diff --git a/mnamer/target.py b/mnamer/target.py index 9f072892..cd9927e2 100644 --- a/mnamer/target.py +++ b/mnamer/target.py @@ -4,7 +4,7 @@ from os import path from pathlib import Path from shutil import move -from typing import Any, ClassVar, Dict, List, Optional, Type +from typing import Any, ClassVar, Type from guessit import guessit # type: ignore @@ -19,29 +19,29 @@ filename_replace, filter_blacklist, filter_containers, + is_subtitle, str_replace, str_sanitize, str_scenify, ) -__all__ = ["Target"] - class Target: """Manages metadata state for a media file and facilitates its relocation.""" - _providers: ClassVar[Dict[ProviderType, Provider]] = {} + _providers: ClassVar[dict[ProviderType, Provider]] = {} _settings: SettingStore _provider: Provider _has_moved: bool _has_renamed: bool - _raw_metadata: Dict[str, str] + _raw_metadata: dict[str, str] _parsed_metadata: Metadata source: Path + metadata: Metadata - def __init__(self, file_path: Path, settings: SettingStore = None): + def __init__(self, file_path: Path, settings: SettingStore | None = None): self.source = file_path self._settings = settings or SettingStore() self._has_moved = False @@ -58,7 +58,7 @@ def __str__(self) -> str: return str(self.source) @classmethod - def populate_paths(cls: Type[Target], settings: SettingStore) -> List[Target]: + def populate_paths(cls: Type[Target], settings: SettingStore) -> list[Target]: """Creates a list of Target objects for media files found in paths.""" file_paths = crawl_in(settings.targets, settings.recurse) file_paths = filter_blacklist(file_paths, settings.ignore) @@ -73,21 +73,22 @@ def reset_providers(cls): cls._providers.clear() @staticmethod - def _matches_media(target: "Target") -> bool: + def _matches_media(target: Target) -> bool: if not target._settings.media: return True else: - return target._settings.media is target.metadata.media + return target._settings.media is target.metadata.to_media_type() @property def provider_type(self) -> ProviderType: - provider_type = self._settings.api_for(self.metadata.media) + provider_type = self._settings.api_for(self.metadata.to_media_type()) assert provider_type return provider_type @property - def directory(self) -> Optional[Path]: - directory = getattr(self._settings, f"{self.metadata.media.value}_directory") + def directory(self) -> Path | None: + settings_key = f"{self.metadata.to_media_type().value}_directory" + directory = getattr(self._settings, settings_key) return Path(directory) if directory else None @property @@ -102,9 +103,7 @@ def destination(self) -> Path: dir_head = Path(dir_head_) else: dir_head = self.source.parent - file_path = format( - self.metadata, self._settings.formatting_for(self.metadata.media) - ) + file_path = format(self.metadata, self._settings.formatting_for(self.metadata)) dir_tail, filename = path.split(Path(file_path)) filename = filename_replace(filename, self._settings.replace_after) if self._settings.scene: @@ -116,8 +115,14 @@ def destination(self) -> Path: return Path(directory, filename) def _parse(self, file_path: Path): - path_data: Dict[str, Any] = {} - options = {"type": self._settings.media} + path_data: dict[str, Any] = {"language": self._settings.language} + if is_subtitle(self.source): + try: + path_data["language"] = Language.parse(self.source.stem[-2:]) + file_path = Path(self.source.parent, self.source.stem[:-2]) + except MnamerException: + pass + options = {"type": self._settings.media, "language": path_data["language"]} raw_data = dict(guessit(str(file_path), options)) if isinstance(raw_data.get("season"), list): raw_data = dict(guessit(str(file_path.parts[-1]), options)) @@ -142,7 +147,7 @@ def _parse(self, file_path: Path): MediaType.MOVIE: MetadataMovie, None: Metadata, }[media_type] - self.metadata = meta_cls(language=self._settings.language) + self.metadata = meta_cls() self.metadata.quality = ( " ".join( path_data[key] @@ -159,6 +164,7 @@ def _parse(self, file_path: Path): ) or None ) + self.metadata.language = path_data.get("language") self.metadata.group = path_data.get("release_group") self.metadata.container = file_path.suffix or None if not self.metadata.language: @@ -170,10 +176,10 @@ def _parse(self, file_path: Path): self.metadata.language_sub = path_data.get("subtitle_language") except MnamerException: pass - if self.metadata.media is MediaType.MOVIE: + if isinstance(self.metadata, MetadataMovie): self.metadata.name = path_data.get("title") self.metadata.year = path_data.get("year") - elif self.metadata.media is MediaType.EPISODE: + elif isinstance(self.metadata, MetadataEpisode): self.metadata.date = path_data.get("date") self.metadata.episode = path_data.get("episode") self.metadata.season = path_data.get("season") @@ -216,7 +222,7 @@ def _replace_before(self) -> None: value = str_replace(value, self._settings.replace_before) setattr(self.metadata, attr, value) - def query(self) -> List[Metadata]: + def query(self) -> list[Metadata]: """Queries the target's respective media provider for metadata.""" results = self._provider.search(self.metadata) if not results: diff --git a/mnamer/tty.py b/mnamer/tty.py index 29630d2b..979b7fa8 100644 --- a/mnamer/tty.py +++ b/mnamer/tty.py @@ -1,7 +1,7 @@ """Provides an interface for handling user input and printing output.""" import traceback -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable from teletype import codes from teletype.components import ChoiceHelper, SelectOne @@ -18,16 +18,8 @@ no_style: bool = False verbose: bool = False -__all__ = [ - "msg", - "metadata_prompt", - "configure", - "metadata_guess", - "crash_report", -] - -def _chars() -> Dict[str, str]: +def _chars() -> dict[str, str]: if no_style: chars = codes.CHARS_ASCII else: @@ -36,7 +28,9 @@ def _chars() -> Dict[str, str]: return chars -def _abort_helpers() -> List[ChoiceHelper]: +def _abort_helpers() -> tuple[ + ChoiceHelper[MnamerSkipException], ChoiceHelper[MnamerAbortException] +]: if no_style: style = None skip_mnemonic = "[s]" @@ -45,21 +39,21 @@ def _abort_helpers() -> List[ChoiceHelper]: style = "dark" skip_mnemonic = "s" quit_mnemonic = "q" - return [ - ChoiceHelper(MnamerSkipException, "skip", style, skip_mnemonic), - ChoiceHelper(MnamerAbortException, "quit", style, quit_mnemonic), - ] + return ( + ChoiceHelper(MnamerSkipException(), "skip", style, skip_mnemonic), + ChoiceHelper(MnamerAbortException(), "quit", style, quit_mnemonic), + ) def _msg_format(body: Any): - converter_map: Dict[type, Callable] = { + converter_map: dict[type, Callable] = { dict: format_dict, list: format_iter, tuple: format_iter, set: format_iter, MnamerException: format_exception, } - converter: Optional[Callable] = converter_map.get(type(body), str) + converter: Callable | None = converter_map.get(type(body), str) if converter: body = converter(body) else: @@ -91,20 +85,17 @@ def error(body: Any): msg(body, message_type=MessageType.ERROR, debug=False) -def metadata_prompt(matches: List[Metadata]) -> Optional[Metadata]: # pragma: no cover +def metadata_prompt(matches: list[Metadata]) -> Metadata: # pragma: no cover """Prompts user to choose a match from a list of matches.""" msg("select match") - options = matches + _abort_helpers() # type: ignore - selector = SelectOne(options, **_chars()) + selector = SelectOne([*matches, *_abort_helpers()], **_chars()) choice = selector.prompt() if isinstance(choice, (MnamerAbortException, MnamerSkipException)): raise choice return choice -def metadata_guess( - metadata: Metadata, -) -> Optional[Metadata]: # pragma: no cover +def metadata_guess(metadata: Metadata) -> Metadata: # pragma: no cover """Prompts user to confirm a single match.""" label = str(metadata) if no_style: @@ -112,7 +103,7 @@ def metadata_guess( else: label += style_format(" (best guess)", "blue") option = ChoiceHelper(metadata, label) - selector = SelectOne([option] + _abort_helpers(), **_chars()) + selector = SelectOne([option, *_abort_helpers()], **_chars()) choice = selector.prompt() if isinstance(choice, (MnamerAbortException, MnamerSkipException)): raise choice @@ -120,10 +111,10 @@ def metadata_guess( return choice -def subtitle_prompt() -> Metadata: +def subtitle_prompt() -> Language: msg("select language") choices = [ChoiceHelper(language, language.name) for language in Language.all()] - selector = SelectOne(choices + _abort_helpers(), **_chars()) + selector = SelectOne([*choices, *_abort_helpers()], **_chars()) choice = selector.prompt() if isinstance(choice, (MnamerAbortException, MnamerSkipException)): raise choice diff --git a/mnamer/types.py b/mnamer/types.py index 8d2a251d..9999a4dd 100644 --- a/mnamer/types.py +++ b/mnamer/types.py @@ -1,14 +1,18 @@ """Enum type definitions.""" +from __future__ import annotations from enum import Enum - -__all__ = ["MediaType", "MessageType", "ProviderType", "SettingType"] +from typing import Type class MediaType(Enum): EPISODE = "episode" MOVIE = "movie" + @classmethod + def to_media_type(cls) -> Type[MediaType]: + return cls + class MessageType(Enum): INFO = None diff --git a/mnamer/utils.py b/mnamer/utils.py index ee8457da..84df7870 100644 --- a/mnamer/utils.py +++ b/mnamer/utils.py @@ -6,7 +6,7 @@ from os import walk from os.path import exists, expanduser, expandvars, getsize, splitdrive, splitext from pathlib import Path, PurePath -from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union +from typing import Any, Callable, Iterator from unicodedata import normalize import requests_cache @@ -14,41 +14,8 @@ from mnamer.const import CACHE_PATH, CURRENT_YEAR, SUBTITLE_CONTAINERS -__all__ = [ - "clean_dict", - "clear_cache", - "crawl_in", - "crawl_out", - "filename_replace", - "filter_blacklist", - "filter_containers", - "findall", - "fn_chain", - "fn_pipe", - "format_dict", - "format_exception", - "format_iter", - "get_filesize", - "get_session", - "is_subtitle", - "json_dumps", - "json_loads", - "normalize_container", - "normalize_containers", - "parse_date", - "request_json", - "str_fix_padding", - "str_replace", - "str_replace_slashes", - "str_sanitize", - "str_scenify", - "str_title_case", - "year_parse", - "year_range_parse", -] - - -def clean_dict(target_dict: Dict[Any, Any], whitelist=None) -> Dict[Any, Any]: + +def clean_dict(target_dict: dict, whitelist=None) -> dict: """Convenience function that removes a dicts keys that have falsy values.""" return { str(k).strip(): str(v).strip() @@ -62,7 +29,7 @@ def clear_cache(): get_session().cache.clear() -def crawl_in(file_paths: List[Path], recurse: bool = False) -> List[Path]: +def crawl_in(file_paths: list[Path], recurse: bool = False) -> list[Path]: """Looks for files amongst or within paths provided.""" found_files = set() for file_path in file_paths: @@ -79,7 +46,7 @@ def crawl_in(file_paths: List[Path], recurse: bool = False) -> List[Path]: return sorted(list(found_files)) -def crawl_out(filename: Union[str, Path, PurePath]) -> Optional[Path]: +def crawl_out(filename: str | Path | PurePath) -> Path | None: """Looks for a file in the home directory and each directory up from cwd.""" working_dir = Path.cwd() while True: @@ -94,14 +61,14 @@ def crawl_out(filename: Union[str, Path, PurePath]) -> Optional[Path]: return target if target.exists() else None -def filename_replace(filename: str, replacements: Dict[str, str]) -> str: +def filename_replace(filename: str, replacements: dict[str, str]) -> str: """Replaces keys in replacements dict with their values.""" base, container = splitext(filename) base = str_replace(base, replacements) return base + container -def filter_blacklist(paths: List[Path], blacklist: List[str]) -> List[Path]: +def filter_blacklist(paths: list[Path], blacklist: list[str]) -> list[Path]: """Filters (set difference) paths by a collection of regex pattens.""" return [ path.absolute() @@ -115,8 +82,8 @@ def filter_blacklist(paths: List[Path], blacklist: List[str]) -> List[Path]: def filter_containers( - file_paths: List[Path], valid_containers: List[str] -) -> List[Path]: + file_paths: list[Path], valid_containers: list[str] +) -> list[Path]: """Filters (set intersection) a collection of containers.""" valid_containers = normalize_containers(valid_containers) return [ @@ -150,7 +117,7 @@ def resolver(x): return resolver -def format_dict(body: Dict[Any, Any]) -> str: +def format_dict(body: dict) -> str: """ Formats a dictionary into a multi-line bulleted string of key-value pairs. """ @@ -168,11 +135,11 @@ def format_iter(body: list) -> str: return "\n".join(sorted([f" - {getattr(v, 'value', v)}" for v in body])) -def is_subtitle(container: Optional[str]) -> bool: +def is_subtitle(container: str | Path | None) -> bool: """Returns True if container is a subtitle container.""" if not container: return False - return container.endswith(tuple(SUBTITLE_CONTAINERS)) + return str(container).endswith(tuple(SUBTITLE_CONTAINERS)) def get_session() -> requests_cache.CachedSession: @@ -209,7 +176,7 @@ def get_filesize(path: Path) -> str: return f"{size:.{2}f}{unit}" -def json_dumps(d: Dict[str, Any]) -> str: +def json_dumps(d: dict[str, Any]) -> str: """A wrapper for json.dumps.""" return json.dumps( {k: getattr(v, "value", v) for k, v in d.items()}, @@ -222,7 +189,7 @@ def json_dumps(d: Dict[str, Any]) -> str: ) -def json_loads(path: str) -> Dict[str, Any]: +def json_loads(path: str) -> dict[str, Any]: json_data = "" path = expanduser(path) path = expandvars(path) @@ -240,12 +207,12 @@ def normalize_container(container: str) -> str: return container.lower() -def normalize_containers(container_list: List[str]) -> List[str]: +def normalize_containers(container_list: list[str]) -> list[str]: """For a list of containers ensures that all containers begin with a dot.""" return [normalize_container(container) for container in container_list] -def parse_date(value: Union[str, dt.date, dt.datetime]) -> dt.date: +def parse_date(value: str | dt.date | dt.datetime) -> dt.date: """Converts an ambiguously formatted date type into a date object.""" if isinstance(value, str): value = value.replace("/", "-") @@ -258,11 +225,11 @@ def parse_date(value: Union[str, dt.date, dt.datetime]) -> dt.date: def request_json( url, - parameters: Optional[Union[dict, list]] = None, - body: Optional[dict] = None, - headers: Optional[dict] = None, + parameters: dict | list | None = None, + body: dict | None = None, + headers: dict | None = None, cache: bool = True, -) -> Tuple[int, dict]: +) -> tuple[int, dict]: """ Queries a url for json data. @@ -330,7 +297,7 @@ def str_fix_padding(s: str) -> str: return s if len_before == len_after else str_fix_padding(s) -def str_replace(s: str, replacements: Dict[str, str]) -> str: +def str_replace(s: str, replacements: dict[str, str]) -> str: """Replaces keys in replacements dict with their values.""" for word, replacement in replacements.items(): if word in s: @@ -511,7 +478,7 @@ def str_title_case(s: str) -> str: return s -def year_parse(s: str) -> Optional[int]: +def year_parse(s: str) -> int | None: """Parses a year from a string.""" regex = r"((?:19|20)\d{2})(?:$|[-/]\d{2}[-/]\d{2})" try: @@ -520,9 +487,7 @@ def year_parse(s: str) -> Optional[int]: return None -def year_range_parse( - years: Optional[Union[str, int]], tolerance: int = 1 -) -> Tuple[int, int]: +def year_range_parse(years: str | int | None, tolerance: int = 1) -> tuple[int, int]: """Parses a year or dash-delimited year range.""" regex = r"^((?:19|20)\d{2})?([-,: ]*)?((?:19|20)\d{2})?$" default_start = 1900 diff --git a/pyproject.toml b/pyproject.toml index ec8edcc9..7ad6c4dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ description = "A command-line utility for organizing media files." authors = [{ name = "Jessy Williams", email = "jessy@jessywilliams.com" }] maintainers = [{ name = "Jessy Williams", email = "jessy@jessywilliams.com" }] license = { file = "LICENSE.txt" } -requires-python = ">=3.8" +requires-python = ">=3.10" dynamic = ["version", "readme", "dependencies", "optional-dependencies"] scripts = { mnamer = "mnamer.__main__:main" } urls = { repository = "https://github.com/jkwill87/mnamer" } @@ -16,25 +16,23 @@ packages = ["mnamer"] "*" = ["LICENSE.txt", "README.md", "py.typed"] [tool.setuptools.dynamic] -version = { attr = "mnamer.__version__.VERSION" } readme = { file = ["README.md"] } dependencies = { file = "requirements.txt" } [tool.setuptools.dynamic.optional-dependencies] dev = { file = "requirements-dev.txt" } -test = { file = "requirements-test.txt" } [build-system] -requires = ["setuptools >= 61.0.0", "wheel"] +requires = ["setuptools >= 61.0.0", "setuptools_scm[toml] >= 6.2", "wheel"] build-backend = "setuptools.build_meta" [tool.black] line-length = 88 -target-version = ["py38"] +target-version = ["py310"] [tool.pyright] reportGeneralTypeIssues = "information" -pythonVersion = "3.8" +pythonVersion = "3.10" include = ["mnamer"] exclude = ["playground.py", "venv"] venv = ["venv"] @@ -54,4 +52,13 @@ disable = ["C0103", "C0114", "C0116", "C0209", "E0401", "R0801"] [tool.mypy] incremental = false -python_version = "3.8" +python_version = "3.10" + +[tool.setuptools_scm] +local_scheme = "dirty-tag" +write_to = "mnamer/__version__.py" +write_to_template = '''# file generated by setuptools_scm +# don't change, don't track in version control + +__version__ = "{version}" +''' diff --git a/requirements-dev.txt b/requirements-dev.txt index d625530c..4ff1a930 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,10 +1,14 @@ black ~= 22.10.0 build ~= 0.9.0 +codecov ~= 2.1.0 isort ~= 5.10.0 mypy ~= 0.982 +pyflakes ~= 3.0.1 pylint ~= 2.15.0 +pytest ~= 7.2.0 +pytest-cov ~= 4.0.0 +pytest-rerunfailures ~= 10.2 setuptools ~= 65.6.3 twine ~= 4.0.0 types-requests ~= 2.28 -vbump ~= 1.2.0 wheel ~= 0.38.0 diff --git a/requirements-test.txt b/requirements-test.txt deleted file mode 100644 index b7c0da6a..00000000 --- a/requirements-test.txt +++ /dev/null @@ -1,4 +0,0 @@ -codecov ~= 2.1.0 -pytest-cov ~= 4.0.0 -pytest-rerunfailures ~= 10.2 -pytest ~= 7.2.0 diff --git a/requirements.txt b/requirements.txt index cafd400c..a7b0e29e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,6 @@ babelfish ~= 0.6.0 guessit ~= 3.5.0 requests == 2.* requests_cache ~= 0.9.7 -setuptools ~= 61.0.0 +setuptools_scm ~= 7.1.0 teletype >= 1.1.0,<1.4.0 +typing-extensions ~= 4.4.0 diff --git a/tests/__init__.py b/tests/__init__.py index 267d7118..51f5f36e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,21 +1,10 @@ import datetime as dt -from typing import Any, Dict, NamedTuple +from typing import Any, NamedTuple from mnamer.const import SUBTITLE_CONTAINERS from mnamer.language import Language from mnamer.types import ProviderType -__all__ = [ - "DEFAULT_SETTINGS", - "EPISODE_META", - "JUNK_TEXT", - "MOVIE_META", - "TEST_DATE", - "RUSSIAN_LANG", - "E2EResult", - "MockRequestResponse", -] - DEFAULT_SETTINGS = { "batch": False, "config_dump": False, @@ -127,7 +116,7 @@ def __init__(self, status: int, content: str) -> None: self.status_code = status self.content = content - def json(self) -> Dict[str, Any]: + def json(self) -> dict[str, Any]: from json import loads return loads(self.content) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 076df464..5cead780 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -9,7 +9,7 @@ from mnamer.frontends import Cli from mnamer.setting_store import SettingStore from mnamer.target import Target -from tests import * +from tests import E2EResult # Move up to root directory if run from subdirectory cwd = Path().resolve() diff --git a/tests/e2e/test_directives.py b/tests/e2e/test_directives.py index 99013ca1..391d23e4 100644 --- a/tests/e2e/test_directives.py +++ b/tests/e2e/test_directives.py @@ -2,9 +2,12 @@ import pytest -from mnamer.__version__ import VERSION +from mnamer.const import VERSION -pytestmark = pytest.mark.e2e +pytestmark = [ + pytest.mark.e2e, + pytest.mark.flaky(reruns=1), +] @pytest.mark.parametrize("flag", ("-V", "--version")) @@ -22,21 +25,6 @@ def test_directives__clear_cache(mock_clear_cache: MagicMock, e2e_run): mock_clear_cache.assert_called_once() -# @pytest.mark.parametrize("key", SettingStore._serializable_fields()) -# @patch("mnamer.utils.crawl_out") -# def test_directives__config_dump(mock_crawl_out: MagicMock, key: str, e2e_run): -# mock_crawl_out.return_value = None -# result = e2e_run("--config_dump") -# assert result.code == 0 -# if key.startswith("api_key"): -# return -# json_out = json.loads(result.out) -# value = DEFAULT_SETTINGS[key] -# expected = getattr(value, "value", value) -# actual = json_out[key] -# assert actual == expected - - @pytest.mark.omdb @pytest.mark.usefixtures("setup_test_dir") def test_id__omdb(e2e_run, setup_test_files): diff --git a/tests/e2e/test_errors.py b/tests/e2e/test_errors.py index a3ef0944..d3d739ce 100644 --- a/tests/e2e/test_errors.py +++ b/tests/e2e/test_errors.py @@ -1,9 +1,12 @@ import pytest from mnamer.argument import ArgLoader -from tests import * +from tests import JUNK_TEXT -pytestmark = pytest.mark.e2e +pytestmark = [ + pytest.mark.e2e, + pytest.mark.flaky(reruns=1), +] def test_invalid_arguments(e2e_run): diff --git a/tests/e2e/test_moving.py b/tests/e2e/test_moving.py index 51c3658f..edf004af 100644 --- a/tests/e2e/test_moving.py +++ b/tests/e2e/test_moving.py @@ -4,7 +4,10 @@ from mnamer.const import SUBTITLE_CONTAINERS -pytestmark = pytest.mark.e2e +pytestmark = [ + pytest.mark.e2e, + pytest.mark.flaky(reruns=2), +] @pytest.mark.usefixtures("setup_test_dir") diff --git a/tests/local/test_argument.py b/tests/local/test_argument.py index d0be69db..13dd4f91 100644 --- a/tests/local/test_argument.py +++ b/tests/local/test_argument.py @@ -78,21 +78,27 @@ def test_arg_loader__format_help(): def test_arg_parser__load__valid_parameter(): - spec = SettingSpec(group=SettingType.PARAMETER, flags=["-f"], help="foo", type=int) + spec = SettingSpec( + group=SettingType.PARAMETER, flags=["-f"], help="foo", typevar=int + ) arg_parser = ArgLoader(spec) with patch.object(sys, "argv", ["mnamer", "-f", "01"]): assert arg_parser.load() == {"f": 1} def test_arg_parser__load__valid_directive(): - spec = SettingSpec(group=SettingType.DIRECTIVE, flags=["-f"], help="foo", type=int) + spec = SettingSpec( + group=SettingType.DIRECTIVE, flags=["-f"], help="foo", typevar=int + ) arg_parser = ArgLoader(spec) with patch.object(sys, "argv", ["mnamer", "-f", "01"]): assert arg_parser.load() == {"f": 1} def test_arg_parser__load__valid_positional(): - spec = SettingSpec(group=SettingType.POSITIONAL, flags=["f"], help="foo", type=int) + spec = SettingSpec( + group=SettingType.POSITIONAL, flags=["f"], help="foo", typevar=int + ) arg_parser = ArgLoader(spec) with patch.object(sys, "argv", ["mnamer", "01"]): assert arg_parser.load() == {"f": 1} diff --git a/tests/local/test_metadata.py b/tests/local/test_metadata.py index 8fe1bbed..eda54705 100644 --- a/tests/local/test_metadata.py +++ b/tests/local/test_metadata.py @@ -2,19 +2,13 @@ import pytest -from mnamer.metadata import * -from mnamer.types import MediaType +from mnamer.metadata import Metadata, MetadataEpisode, MetadataMovie pytestmark = pytest.mark.local TEXT_CASES = ["test", "Test", "TEST", "TeSt"] -def test_metadata__convert__media(): - metadata = Metadata(media="episode") - assert metadata.media is MediaType.EPISODE - - @pytest.mark.parametrize("value", TEXT_CASES) def test_metadata__convert_synopsis(value): metadata = Metadata(synopsis=value) diff --git a/tests/local/test_setting_spec.py b/tests/local/test_setting_spec.py index 45e21ccb..f19b010b 100644 --- a/tests/local/test_setting_spec.py +++ b/tests/local/test_setting_spec.py @@ -15,7 +15,7 @@ def test_setting_spec__serialize__default(): "group": SettingType.PARAMETER, "help": None, "nargs": None, - "type": None, + "typevar": None, } setting_spec = SettingSpec(SettingType.PARAMETER) assert setting_spec.as_dict() == default @@ -30,7 +30,7 @@ def test_setting_spec__serialize__override(): "group": SettingType.PARAMETER, "help": "foos your bars", "nargs": "+", - "type": int, + "typevar": int, } setting_spec = SettingSpec(**spec) assert setting_spec.as_dict() == spec diff --git a/tests/local/test_setting_store.py b/tests/local/test_setting_store.py index 085b1bd3..f0605913 100644 --- a/tests/local/test_setting_store.py +++ b/tests/local/test_setting_store.py @@ -2,7 +2,7 @@ from mnamer.setting_store import SettingStore from mnamer.types import MediaType, ProviderType -from tests import * +from tests import DEFAULT_SETTINGS pytestmark = pytest.mark.local diff --git a/tests/local/test_target.py b/tests/local/test_target.py index 64fd71b1..1eaca9ec 100644 --- a/tests/local/test_target.py +++ b/tests/local/test_target.py @@ -3,8 +3,9 @@ import pytest +from mnamer.metadata import MetadataEpisode, MetadataMovie from mnamer.setting_store import SettingStore -from mnamer.target import * +from mnamer.target import Target from mnamer.types import MediaType pytestmark = pytest.mark.local @@ -12,12 +13,12 @@ def test_parse__media__movie(): target = Target(Path("ninja turtles (1990).mkv"), SettingStore()) - assert target.metadata.media is MediaType.MOVIE + assert target.metadata.to_media_type() is MediaType.MOVIE def test_parse__media__episode(): target = Target(Path("ninja turtles s01e01.mkv"), SettingStore()) - assert target.metadata.media is MediaType.EPISODE + assert target.metadata.to_media_type() is MediaType.EPISODE def test_parse__quality(): @@ -41,43 +42,49 @@ def test_parse__container(): def test_parse__date(): file_path = Path("the.colbert.show.2010.10.01.avi") target = Target(file_path, SettingStore()) + assert isinstance(target.metadata, MetadataEpisode) assert target.metadata.date == dt.date(2010, 10, 1) def test_parse__episode(): file_path = Path("ninja.turtles.s01e04.1080p.ac3.rargb.sample.mp4") target = Target(file_path, SettingStore()) + assert isinstance(target.metadata, MetadataEpisode) assert target.metadata.episode == 4 def test_parse__season(): file_path = Path("ninja.turtles.s01e04.1080p.ac3.rargb.sample.mp4") target = Target(file_path, SettingStore()) + assert isinstance(target.metadata, MetadataEpisode) assert target.metadata.season == 1 def test_parse__series(): file_path = Path("ninja.turtles.s01e04.1080p.ac3.rargb.sample.mp4") target = Target(file_path, SettingStore()) + assert isinstance(target.metadata, MetadataEpisode) assert target.metadata.series == "Ninja Turtles" def test_parse__year(): file_path = Path("the.goonies.1985") target = Target(file_path, SettingStore()) + assert isinstance(target.metadata, MetadataMovie) assert target.metadata.year == 1985 def testparse__name(): file_path = Path("the.goonies.1985") target = Target(file_path, SettingStore()) + assert isinstance(target.metadata, MetadataMovie) assert target.metadata.name == "The Goonies" @pytest.mark.parametrize("media", MediaType) def test_media__override(media: MediaType): target = Target(Path(), SettingStore(media=media)) - assert target.metadata.media is media + assert target.metadata.to_media_type() == media def test_directory__movie(): @@ -105,7 +112,7 @@ def test_ambiguous_subtitle_language(): def test_destination__simple(): - target = Target(Path("star.trek.enterprise.s01e1.mkv")) + pass # TODO def test_query(): diff --git a/tests/local/test_tty.py b/tests/local/test_tty.py index 45b5e64d..69037654 100644 --- a/tests/local/test_tty.py +++ b/tests/local/test_tty.py @@ -45,10 +45,10 @@ def test_abort_helpers(): helpers = tty._abort_helpers() assert len(helpers) == 2 assert helpers[0].label == "skip" - assert helpers[0].value == MnamerSkipException + assert isinstance(helpers[0].value, MnamerSkipException) assert helpers[0]._bracketed is False assert helpers[1].label == "quit" - assert helpers[1].value == MnamerAbortException + assert isinstance(helpers[1].value, MnamerAbortException) assert helpers[1]._bracketed is False @@ -58,8 +58,8 @@ def test_abort_helpers__no_style(): helpers = tty._abort_helpers() assert len(helpers) == 2 assert helpers[0].label == "skip" - assert helpers[0].value == MnamerSkipException + assert isinstance(helpers[0].value, MnamerSkipException) assert helpers[0]._bracketed is True assert helpers[1].label == "quit" - assert helpers[1].value == MnamerAbortException + assert isinstance(helpers[1].value, MnamerAbortException) assert helpers[1]._bracketed is True diff --git a/tests/local/test_utils.py b/tests/local/test_utils.py index c576578a..0a8fe09b 100644 --- a/tests/local/test_utils.py +++ b/tests/local/test_utils.py @@ -1,5 +1,4 @@ from pathlib import Path -from typing import Dict, List from unittest.mock import patch import pytest @@ -7,12 +6,34 @@ from mnamer.const import CURRENT_YEAR, SUBTITLE_CONTAINERS from mnamer.types import MediaType -from mnamer.utils import * -from tests import * +from mnamer.utils import ( + clean_dict, + crawl_in, + crawl_out, + filter_blacklist, + filter_containers, + fn_chain, + fn_pipe, + format_dict, + format_exception, + format_iter, + is_subtitle, + normalize_container, + parse_date, + request_json, + str_fix_padding, + str_replace, + str_sanitize, + str_scenify, + str_title_case, + year_parse, + year_range_parse, +) +from tests import JUNK_TEXT, MockRequestResponse pytestmark = pytest.mark.local -TEST_FILES: Dict[str, Path] = { +TEST_FILES: dict[str, Path] = { test_file: Path(*test_file.split("/")) for test_file in ( "Avengers Infinity War/Avengers.Infinity.War.srt", @@ -351,7 +372,7 @@ def test_filter_containers__filter_none(): @pytest.mark.parametrize("containers", (["jpg"], [".jpg"])) def test_filter_containers__filter_multiple_paths_single_pattern( - containers: List[str], + containers: list[str], ): expected = paths_for("Images/Photos/DCM0001.jpg", "Images/Photos/DCM0002.jpg") actual = filter_containers(FILTER_FILENAMES, containers) @@ -360,7 +381,7 @@ def test_filter_containers__filter_multiple_paths_single_pattern( @pytest.mark.parametrize("containers", (["mkv", "zip"], [".mkv", ".zip"])) def test_filter_containers__filter_multiple_paths_multi_pattern( - containers: List[str], + containers: list[str], ): expected = paths_for( "Desktop/temp.zip", @@ -378,7 +399,7 @@ def test_filter_containers__filter_multiple_paths_multi_pattern( @pytest.mark.parametrize("containers", (["mp4", "zip"], [".mp4", ".zip"])) def test_filter_containers__filter_single_path_multi_pattern( - containers: List[str], + containers: list[str], ): filepaths = paths_for("Images/Skiing Trip.mp4") expected = filepaths diff --git a/tests/network/test_endpoints__omdb.py b/tests/network/test_endpoints__omdb.py index 9b64858c..605312ad 100644 --- a/tests/network/test_endpoints__omdb.py +++ b/tests/network/test_endpoints__omdb.py @@ -5,7 +5,7 @@ from mnamer.endpoints import omdb_search, omdb_title from mnamer.exceptions import MnamerException, MnamerNotFoundException from mnamer.providers import Omdb -from tests import * +from tests import JUNK_TEXT, MockRequestResponse pytestmark = [ pytest.mark.network, diff --git a/tests/network/test_endpoints__tmdb.py b/tests/network/test_endpoints__tmdb.py index 697e39c2..55e2deab 100644 --- a/tests/network/test_endpoints__tmdb.py +++ b/tests/network/test_endpoints__tmdb.py @@ -3,7 +3,7 @@ from mnamer.endpoints import tmdb_find, tmdb_movies, tmdb_search_movies from mnamer.exceptions import MnamerException, MnamerNotFoundException from mnamer.providers import Tmdb -from tests import * +from tests import JUNK_TEXT, RUSSIAN_LANG pytestmark = [ pytest.mark.network, diff --git a/tests/network/test_endpoints__tvdb.py b/tests/network/test_endpoints__tvdb.py index ce738447..87d5c905 100644 --- a/tests/network/test_endpoints__tvdb.py +++ b/tests/network/test_endpoints__tvdb.py @@ -12,7 +12,7 @@ from mnamer.exceptions import MnamerException, MnamerNotFoundException from mnamer.language import Language from mnamer.providers import Tvdb -from tests import * +from tests import JUNK_TEXT, RUSSIAN_LANG pytestmark = [ pytest.mark.network, diff --git a/tests/network/test_endpoints__tvmaze.py b/tests/network/test_endpoints__tvmaze.py index c0ce5937..f5ace10e 100644 --- a/tests/network/test_endpoints__tvmaze.py +++ b/tests/network/test_endpoints__tvmaze.py @@ -10,7 +10,7 @@ tvmaze_show_single_search, ) from mnamer.exceptions import MnamerException, MnamerNotFoundException -from tests import * +from tests import EPISODE_META, JUNK_TEXT, TEST_DATE pytestmark = [ pytest.mark.network, diff --git a/tests/network/test_providers__omdb.py b/tests/network/test_providers__omdb.py index f06d4227..5cc9b97f 100644 --- a/tests/network/test_providers__omdb.py +++ b/tests/network/test_providers__omdb.py @@ -1,11 +1,9 @@ -from typing import Dict - import pytest from mnamer.exceptions import MnamerNotFoundException from mnamer.metadata import MetadataMovie from mnamer.providers import Omdb -from tests import * +from tests import JUNK_TEXT, MOVIE_META pytestmark = [ pytest.mark.network, @@ -20,7 +18,7 @@ def provider(): @pytest.mark.parametrize("meta", MOVIE_META.values(), ids=list(MOVIE_META)) -def test_search__id(meta: Dict[str, str], provider: Omdb): +def test_search__id(meta: dict[str, str], provider: Omdb): query = MetadataMovie(id_imdb=meta["id_imdb"]) results = list(provider.search(query)) assert len(results) == 1 diff --git a/tests/network/test_providers__tmdb.py b/tests/network/test_providers__tmdb.py index 7171d4de..ae615ed5 100644 --- a/tests/network/test_providers__tmdb.py +++ b/tests/network/test_providers__tmdb.py @@ -3,7 +3,7 @@ from mnamer.exceptions import MnamerNotFoundException from mnamer.metadata import MetadataMovie from mnamer.providers import Tmdb -from tests import * +from tests import JUNK_TEXT, MOVIE_META pytestmark = [ pytest.mark.network, diff --git a/tests/network/test_providers__tvdb.py b/tests/network/test_providers__tvdb.py index 06b8066f..14f1fb8c 100644 --- a/tests/network/test_providers__tvdb.py +++ b/tests/network/test_providers__tvdb.py @@ -5,7 +5,7 @@ from mnamer.exceptions import MnamerNotFoundException from mnamer.metadata import MetadataEpisode from mnamer.providers import Tvdb -from tests import * +from tests import EPISODE_META, JUNK_TEXT pytestmark = [ pytest.mark.network, diff --git a/tests/network/test_providers__tvmaze.py b/tests/network/test_providers__tvmaze.py index efd96c75..9e00a227 100644 --- a/tests/network/test_providers__tvmaze.py +++ b/tests/network/test_providers__tvmaze.py @@ -3,7 +3,7 @@ from mnamer.exceptions import MnamerNotFoundException from mnamer.metadata import MetadataEpisode from mnamer.providers import TvMaze -from tests import * +from tests import EPISODE_META, JUNK_TEXT, TEST_DATE pytestmark = [ pytest.mark.network,