def test_setting_spec__serialize__default(): default = { "action": None, "choices": None, "dest": None, "flags": None, "group": SettingType.PARAMETER, "help": None, "nargs": None, "type": None, } setting_spec = SettingSpec(SettingType.PARAMETER) assert setting_spec.as_dict() == default
def test_setting_spec__serialize__override(): spec = { "action": "count", "choices": ["a", "b", "c"], "dest": "foo", "flags": ["-f", "--foo"], "group": SettingType.PARAMETER, "help": "foos your bars", "nargs": "+", "type": int, } setting_spec = SettingSpec(**spec) assert setting_spec.as_dict() == spec
def test_arg_loader__add_spec_other(): arg_loader = ArgLoader() spec = SettingSpec(group=SettingType.CONFIGURATION, flags=["-f"], help="foo") with pytest.raises(RuntimeError): arg_loader += spec
def test_arg_loader__add_spec(settings_type: SettingType): arg_loader = ArgLoader() spec = SettingSpec(group=settings_type, flags=["-f"], help="foo") actions = getattr(arg_loader, f"_{settings_type.value}_group") assert len(actions._group_actions) == 0 arg_loader += spec assert len(actions._group_actions) == 1
def test_setting_spec__name__no_flags(): spec = { "dest": "foo", "group": SettingType.CONFIGURATION, "help": "foos your bars", } setting_spec = SettingSpec(**spec) assert setting_spec.name is None
def test_arg_parser__load__valid_positional(): spec = SettingSpec(group=SettingType.POSITIONAL, flags=["f"], help="foo", type=int) arg_parser = ArgLoader(spec) with patch.object(sys, "argv", ["mnamer", "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) arg_parser = ArgLoader(spec) with patch.object(sys, "argv", ["mnamer", "-f", "01"]): assert arg_parser.load() == {"f": 1}
def test_arg_parser__load__invalid_configuration(): spec = SettingSpec(group=SettingType.CONFIGURATION, flags=["-f"], help="foo") arg_parser = ArgLoader(spec) with patch.object(sys, "argv", ["mnamer", "-f", "1"]): with pytest.raises(RuntimeError): arg_parser.load()
def test_setting_spec__name__flags(): spec = { "dest": "foo", "flags": ["-f", "--foo"], "group": SettingType.DIRECTIVE, "help": "foos your bars", } setting_spec = SettingSpec(**spec) assert setting_spec.name == "foo"
def test_setting_spec__registration(): spec = { "dest": "foo", "flags": ["-f", "--f"], "group": SettingType.DIRECTIVE, "help": "foos your bars", } setting_spec = SettingSpec(**spec) args, kwargs = setting_spec.registration assert args == ["-f", "--f"] assert kwargs == {"dest": "foo", "help": "foos your bars"}
def test_arg_loader__format_help(): arg_loader = ArgLoader() for spec in ( SettingSpec(SettingType.POSITIONAL, flags=["--foo1"], help="foo1"), SettingSpec(SettingType.POSITIONAL, flags=["--foo2"], help="foo2"), SettingSpec(SettingType.POSITIONAL, flags=["--foo3"], help="foo3"), SettingSpec(SettingType.PARAMETER, flags=["--bar1"], help="bar1"), SettingSpec(SettingType.PARAMETER, flags=["--bar2"], help="bar2"), SettingSpec(SettingType.PARAMETER, flags=["--bar3"], help="bar3"), SettingSpec(SettingType.DIRECTIVE, flags=["--baz1"], help="baz1"), SettingSpec(SettingType.DIRECTIVE, flags=["--baz2"], help="baz2"), SettingSpec(SettingType.DIRECTIVE, flags=["--baz3"], help="baz3"), ): arg_loader._add_spec(spec) assert (arg_loader.format_help() == """ USAGE: mnamer [preferences] [directives] target [targets ...] POSITIONAL: foo1 foo2 foo3 PARAMETERS: The following flags can be used to customize mnamer's behaviour. Their long forms may also be set in a '.mnamer-v2.json' config file, in which case cli arguments will take precedence. bar1 bar2 bar3 DIRECTIVES: Directives are one-off arguments that are used to perform secondary tasks like overriding media detection. They can't be used in '.mnamer-v2.json'. baz1 baz2 baz3 Visit https://github.com/jkwill87/mnamer for more information. """)
def specifications(cls) -> List[SettingSpec]: return [ SettingSpec(**f.metadata) for f in dataclasses.fields(SettingStore) if f.metadata ]
class SettingStore: """ A dataclass which stores settings loaded from command line arguments and configuration files. """ # positional attributes ---------------------------------------------------- targets: List[Path] = dataclasses.field( default_factory=lambda: [], metadata=SettingSpec( flags=["targets"], group=SettingType.POSITIONAL, help="[TARGET,...]: media file file path(s) to process", nargs="*", )(), ) # parameter attributes ----------------------------------------------------- batch: bool = dataclasses.field( default=False, metadata=SettingSpec( action="store_true", dest="batch", flags=["--batch", "-b"], group=SettingType.PARAMETER, help= "-b, --batch: process automatically without interactive prompts", )(), ) lower: bool = dataclasses.field( default=False, metadata=SettingSpec( action="store_true", flags=["--lower", "-l"], group=SettingType.PARAMETER, help="-l, --lower: rename files using lowercase characters", )(), ) recurse: bool = dataclasses.field( default=False, metadata=SettingSpec( action="store_true", flags=["--recurse", "-r"], group=SettingType.PARAMETER, help="-r, --recurse: search for files within nested directories", )(), ) scene: bool = dataclasses.field( default=False, metadata=SettingSpec( action="store_true", flags=["--scene", "-s"], group=SettingType.PARAMETER, help="-s, --scene: use dots in place of alphanumeric chars", )(), ) verbose: bool = dataclasses.field( default=False, metadata=SettingSpec( action="store_true", flags=["--verbose", "-v"], group=SettingType.PARAMETER, help="-v, --verbose: increase output verbosity", )(), ) hits: int = dataclasses.field( default=5, metadata=SettingSpec( flags=["--hits"], group=SettingType.PARAMETER, help= "--hits=<NUMBER>: limit the maximum number of hits for each query", type=int, )(), ) ignore: List[str] = dataclasses.field( default_factory=lambda: [".*sample.*", "^RARBG.*"], metadata=SettingSpec( flags=["--ignore"], group=SettingType.PARAMETER, help= "--ignore=<PATTERN,...>: ignore files matching these regular expressions", nargs="+", )(), ) language: Optional[Language] = dataclasses.field( default=None, metadata=SettingSpec( flags=["--language"], group=SettingType.PARAMETER, help="--language=<LANG>: specify the search language", )(), ) mask: List[str] = dataclasses.field( default_factory=lambda: [ "avi", "m4v", "mp4", "mkv", "ts", "wmv", ] + SUBTITLE_CONTAINERS, metadata=SettingSpec( flags=["--mask"], group=SettingType.PARAMETER, help="--mask=<EXTENSION,...>: only process given file types", nargs="+", )(), ) no_guess: bool = dataclasses.field( default=False, metadata=SettingSpec( action="store_true", dest="no_guess", flags=["--no_guess", "--no-guess", "--noguess"], group=SettingType.PARAMETER, help= "--no-guess: disable best guess; e.g. when no matches or network down", )(), ) no_overwrite: bool = dataclasses.field( default=False, metadata=SettingSpec( action="store_true", dest="no_overwrite", flags=["--no_overwrite", "--no-overwrite", "--nooverwrite"], group=SettingType.PARAMETER, help= "--no-overwrite: prevent relocation if it would overwrite a file", )(), ) no_style: bool = dataclasses.field( default=False, metadata=SettingSpec( action="store_true", dest="no_style", flags=["--no_style", "--no-style", "--nostyle"], group=SettingType.PARAMETER, help= "--no-style: print to stdout without using colour or unicode chars", )(), ) movie_api: Union[ProviderType, str] = dataclasses.field( default=ProviderType.TMDB, metadata=SettingSpec( choices=[ProviderType.TMDB.value, ProviderType.OMDB.value], dest="movie_api", flags=["--movie_api", "--movie-api", "--movieapi"], group=SettingType.PARAMETER, help="--movie-api={*tmdb,omdb}: set movie api provider", )(), ) movie_directory: Optional[Path] = dataclasses.field( default=None, metadata=SettingSpec( dest="movie_directory", flags=[ "--movie_directory", "--movie-directory", "--moviedirectory", ], group=SettingType.PARAMETER, help="--movie-directory: set movie relocation directory", )(), ) movie_format: str = dataclasses.field( default="{name} ({year}).{extension}", metadata=SettingSpec( dest="movie_format", flags=["--movie_format", "--movie-format", "--movieformat"], group=SettingType.PARAMETER, help="--movie-format: set movie renaming format specification", )(), ) episode_api: Union[ProviderType, str] = dataclasses.field( default=ProviderType.TVMAZE, metadata=SettingSpec( choices=[ProviderType.TVDB.value, ProviderType.TVMAZE.value], dest="episode_api", flags=["--episode_api", "--episode-api", "--episodeapi"], group=SettingType.PARAMETER, help="--episode-api={tvdb,*tvmaze}: set episode api provider", )(), ) episode_directory: Path = dataclasses.field( default=None, metadata=SettingSpec( dest="episode_directory", flags=[ "--episode_directory", "--episode-directory", "--episodedirectory", ], group=SettingType.PARAMETER, help="--episode-directory: set episode relocation directory", )(), ) episode_format: str = dataclasses.field( default="{series} - S{season:02}E{episode:02} - {title}.{extension}", metadata=SettingSpec( dest="episode_format", flags=["--episode_format", "--episode-format", "--episodeformat"], group=SettingType.PARAMETER, help="--episode-format: set episode renaming format specification", )(), ) # directive attributes ----------------------------------------------------- version: bool = dataclasses.field( default=False, metadata=SettingSpec( action="store_true", flags=["-V", "--version"], group=SettingType.DIRECTIVE, help="-V, --version: display the running mnamer version number", )(), ) clear_cache: bool = dataclasses.field( default=False, metadata=SettingSpec( action="store_true", dest="clear_cache", flags=["--clear_cache", "--clear-cache", "--clearcache"], group=SettingType.DIRECTIVE, help="--clear-cache: clear request cache", )(), ) config_dump: bool = dataclasses.field( default=False, metadata=SettingSpec( action="store_true", dest="config_dump", flags=["--config_dump", "--config-dump", "--configdump"], group=SettingType.DIRECTIVE, help= "--config-dump: prints current config JSON to stdout then exits", )(), ) config_ignore: bool = dataclasses.field( default=False, metadata=SettingSpec( action="store_true", dest="config_ignore", flags=["--config_ignore", "--config-ignore", "--configignore"], group=SettingType.DIRECTIVE, help="--config-ignore: skips loading config file for session", )(), ) config_path: str = dataclasses.field( default=None, metadata=SettingSpec( flags=["--config_path", "--config-path"], group=SettingType.DIRECTIVE, help="--config-path=<PATH>: specifies configuration path to load", )(), ) id_imdb: str = dataclasses.field( default=None, metadata=SettingSpec( flags=["--id_imdb", "--id-imdb", "--idimdb"], group=SettingType.DIRECTIVE, help="--id-imdb=<ID>: specify an IMDb movie id override", )(), ) id_tmdb: str = dataclasses.field( default=None, metadata=SettingSpec( flags=["--id_tmdb", "--id-tmdb", "--idtmdb"], group=SettingType.DIRECTIVE, help="--id-tmdb=<ID>: specify a TMDb movie id override", )(), ) id_tvdb: str = dataclasses.field( default=None, metadata=SettingSpec( flags=["--id_tvdb", "--id-tvdb", "--idtvdb"], group=SettingType.DIRECTIVE, help="--id-tvdb=<ID>: specify a TVDb series id override", )(), ) id_tvmaze: str = dataclasses.field( default=None, metadata=SettingSpec( flags=["--id_tvmaze", "--id-tvmaze", "--idtvmaze"], group=SettingType.DIRECTIVE, help="--id-tvmaze=<ID>: specify a TvMaze series id override", )(), ) no_cache: bool = dataclasses.field( default=False, metadata=SettingSpec( action="store_true", dest="no_cache", flags=["--no_cache", "--no-cache", "--nocache"], group=SettingType.DIRECTIVE, help="--no-cache: disable request cache", )(), ) media: Optional[Union[MediaType, str]] = dataclasses.field( default=None, metadata=SettingSpec( choices=[MediaType.EPISODE.value, MediaType.MOVIE.value], flags=["--media"], group=SettingType.DIRECTIVE, help="--media={movie,episode}: override media detection", )(), ) test: bool = dataclasses.field( default=False, metadata=SettingSpec( action="store_true", flags=["--test"], group=SettingType.DIRECTIVE, help="--test: mocks the renaming and moving of files", )(), ) # config-only attributes --------------------------------------------------- api_key_omdb: str = dataclasses.field( default=None, metadata=SettingSpec(group=SettingType.CONFIGURATION)(), ) api_key_tmdb: str = dataclasses.field( default=None, metadata=SettingSpec(group=SettingType.CONFIGURATION)(), ) api_key_tvdb: str = dataclasses.field( default=None, metadata=SettingSpec(group=SettingType.CONFIGURATION)(), ) api_key_tvmaze: str = dataclasses.field( default=None, metadata=SettingSpec(group=SettingType.CONFIGURATION)(), ) replace_before: Dict[str, str] = dataclasses.field( default_factory=lambda: {}, metadata=SettingSpec(group=SettingType.CONFIGURATION)(), ) replace_after: Dict[str, str] = dataclasses.field( default_factory=lambda: { "&": "and", "@": "at", ";": "," }, metadata=SettingSpec(group=SettingType.CONFIGURATION)(), ) @classmethod def specifications(cls) -> List[SettingSpec]: return [ SettingSpec(**f.metadata) for f in dataclasses.fields(SettingStore) if f.metadata ] @staticmethod def _resolve_path(path: Union[str, Path]) -> Path: return Path(path).resolve() def __setattr__(self, key: str, value: Any): converter = { "episode_api": ProviderType, "episode_directory": self._resolve_path, "language": Language.parse, "mask": normalize_containers, "media": MediaType, "movie_api": ProviderType, "movie_directory": self._resolve_path, "targets": lambda targets: [Path(target) for target in targets], }.get(key) if value is not None and converter: value = converter(value) super().__setattr__(key, value) def as_dict(self): return dataclasses.asdict(self) def as_json(self): payload = {} serializable_fields = tuple( str(field.name) for field in dataclasses.fields(self) if field.metadata.get("group") in {SettingType.PARAMETER, SettingType.CONFIGURATION}) # transform values into primitive JSON-serializable types for k, v in self.as_dict().items(): if k not in serializable_fields: continue if hasattr(v, "value"): payload[k] = v.value elif isinstance(v, Path): payload[k] = str(v.resolve()) else: payload[k] = v return json.dumps( payload, allow_nan=False, check_circular=False, ensure_ascii=True, indent=4, skipkeys=True, sort_keys=True, ) def bulk_apply(self, d: Dict[str, Any]): [setattr(self, k, v) for k, v in d.items() if v] def load(self): arg_loader = ArgLoader(*self.specifications()) try: arguments = arg_loader.load() except RuntimeError as e: raise MnamerException(e) config_path = arguments.get("config_path", crawl_out(".mnamer-v2.json")) config = json_loads(str(config_path)) if config_path else {} if not self.config_ignore and not arguments.get("config_ignore"): self.bulk_apply(config) if arguments: self.bulk_apply(arguments) def api_for(self, media_type: MediaType) -> Optional[ProviderType]: """Returns the ProviderType for a given media type.""" if media_type: return getattr(self, f"{media_type.value}_api") def api_key_for(self, provider_type: ProviderType) -> Optional[str]: """Returns the API key for a provider type.""" if provider_type: return getattr(self, f"api_key_{provider_type.value}") def formatting_for(self, media_type: MediaType) -> Optional[str]: if media_type: return getattr(self, f"{media_type.value}_format")
def test_arg_parser__missing_help(): spec = SettingSpec(group=SettingType.DIRECTIVE, flags=["-f"]) with pytest.raises(RuntimeError): ArgLoader(spec)