Source code for flexget.options

from __future__ import annotations

import copy
import random
import string
import sys
from argparse import (
    _UNRECOGNIZED_ARGS_ATTR,
    PARSER,
    REMAINDER,
    SUPPRESS,
    Action,
    ArgumentError,
    Namespace,
    _SubParsersAction,
    _VersionAction,
)
from argparse import ArgumentParser as ArgParser
from typing import IO, TYPE_CHECKING, Any, TextIO

from packaging.version import Version

import flexget
from flexget.entry import Entry
from flexget.event import fire_event
from flexget.utils.tools import get_current_flexget_version, get_latest_flexget_version_number

if TYPE_CHECKING:
    from collections.abc import Callable

_UNSET = object()

core_parser: CoreArgumentParser | None = None


[docs] def get_parser(command: str | None = None) -> ArgumentParser: global core_parser if not core_parser: core_parser = CoreArgumentParser() # Add all plugin options to the parser fire_event('options.register') if command: return core_parser.get_subparser(command) return core_parser
[docs] def register_command( command: str, callback: Callable[[flexget.manager.Manager, Namespace], Any], **kwargs ) -> ArgumentParser: """Register a callback function to be executed when flexget is launched with the given `command`. :param command: The command being defined. :param callback: Callback function executed when this command is invoked from the CLI. Should take manager instance and parsed argparse namespace as parameters. :param kwargs: Other keyword arguments will be passed to the :class:`arparse.ArgumentParser` constructor :returns: An :class:`argparse.ArgumentParser` instance ready to be configured with the options for this command. """ return get_parser().add_subparser( command, parent_defaults={'cli_command_callback': callback}, **kwargs )
[docs] def required_length(nmin: int, nmax: int): """Generate a custom Action to validate an arbitrary range of arguments.""" class RequiredLength(Action): def __call__(self, parser: ArgParser, args, values, option_string=None): if not nmin <= len(values) <= nmax: raise ArgumentError(self, f'requires between {nmin} and {nmax} arguments') setattr(args, self.dest, values) return RequiredLength
[docs] class VersionAction(_VersionAction): """Action to print the current version. Also checks latest release revision.""" def __call__(self, parser: ArgParser, namespace: Namespace, values, option_string=None): from flexget.terminal import console current = get_current_flexget_version() latest = get_latest_flexget_version_number() # Print the version number console(f'{get_current_flexget_version()}') # Check for latest version from server if latest: if Version(current) >= Version(latest): console("You're up to date.") else: console(f'Latest release: {latest}') else: console( 'Error getting latest version number from https://pypi.python.org/pypi/FlexGet' ) parser.exit()
[docs] class HelpAction(Action): """Override the default help command so that we can conditionally disable it to prevent program exit.""" def __call__(self, parser, namespace, values, option_string=None): if getattr(parser, 'do_help', True): parser.print_help() parser.exit()
[docs] class DebugAction(Action): def __call__(self, parser, namespace, values, option_string=None): setattr(namespace, self.dest, True) namespace.loglevel = 'DEBUG'
[docs] class DebugTraceAction(Action): def __call__(self, parser, namespace, values, option_string=None): setattr(namespace, self.dest, True) namespace.debug = True namespace.log_level = 'trace'
[docs] class CronAction(Action): def __call__(self, parser, namespace, values, option_string=None): setattr(namespace, self.dest, True) # Only set loglevel if it has not already explicitly been set if not hasattr(namespace, 'loglevel'): namespace.loglevel = 'INFO'
# This makes the old --inject form forwards compatible
[docs] class InjectAction(Action): def __call__(self, parser, namespace, values, option_string=None): kwargs = {'title': values.pop(0)} if values: kwargs['url'] = values.pop(0) else: kwargs['url'] = 'http://localhost/inject/{}'.format( ''.join(random.sample(string.ascii_letters + string.digits, 30)) ) if 'force' in [v.lower() for v in values]: kwargs['immortal'] = True entry = Entry(**kwargs) if 'accept' in [v.lower() for v in values]: entry.accept(reason='accepted by --inject') existing = getattr(namespace, self.dest, None) or [] setattr(namespace, self.dest, [*existing, entry])
[docs] class ParseExtrasAction(Action): """Extra arguments are taken, and then parsed with a different parser.""" def __init__(self, option_strings, parser, help=None, metavar=None, dest=None, required=False): if metavar is None: metavar = f'<{parser.prog} arguments>' if help is None: help = f'arguments for the `{parser.prog}` command are allowed here' self._parser = parser super().__init__( option_strings=option_strings, dest=SUPPRESS, help=help, metavar=metavar, nargs=REMAINDER, required=required, ) def __call__(self, parser, namespace, values, option_string=None): namespace, extras = self._parser.parse_known_args(values, namespace) if extras: parser.error('unrecognized arguments: {}'.format(' '.join(extras)))
[docs] class ScopedNamespace(Namespace): def __init__(self, **kwargs): super().__init__(**kwargs) self.__parent__ = None def __getattr__(self, key): if '.' in key: scope, key = key.split('.', 1) return getattr(getattr(self, scope), key) if self.__parent__: return getattr(self.__parent__, key) raise AttributeError(f"'{type(self).__name__}' object has no attribute '{key}'") def __setattr__(self, key: str, value): if '.' in key: scope, key = key.split('.', 1) if not hasattr(self, scope): setattr(self, scope, type(self)()) sub_ns = getattr(self, scope, None) return object.__setattr__(sub_ns, key, value) # Let child namespaces keep track of us if key != '__parent__' and isinstance(value, ScopedNamespace): value.__parent__ = self return object.__setattr__(self, key, value) def __iter__(self): return (i for i in self.__dict__.items() if i[0] != '__parent__') def __copy__(self): new = self.__class__() new.__dict__.update(self.__dict__) # Make copies of any nested namespaces for key, value in self: if isinstance(value, ScopedNamespace): setattr(new, key, copy.copy(value)) return new
[docs] class NestedSubparserAction(_SubParsersAction): def __init__(self, *args, **kwargs): self.nested_namespaces = kwargs.pop('nested_namespaces', False) self.parent_defaults = {} super().__init__(*args, **kwargs)
[docs] def add_parser(self, name: str, parent_defaults: dict | None = None, **kwargs): if parent_defaults: self.parent_defaults[name] = parent_defaults return super().add_parser(name, **kwargs)
def __call__(self, parser, namespace, values, option_string=None): parser_name = values[0] if parser_name in self.parent_defaults: for dest in self.parent_defaults[parser_name]: if not hasattr(namespace, dest): setattr(namespace, dest, self.parent_defaults[parser_name][dest]) if self.nested_namespaces: subnamespace = ScopedNamespace() super().__call__(parser, subnamespace, values, option_string) # If dest is set, it should be set on the parent namespace, not subnamespace if self.dest is not SUPPRESS: setattr(namespace, self.dest, parser_name) delattr(subnamespace, self.dest) setattr(namespace, parser_name, subnamespace) # Propagate unrecognized arguments back to parent namespace vars(namespace).setdefault(_UNRECOGNIZED_ARGS_ATTR, []) getattr(namespace, _UNRECOGNIZED_ARGS_ATTR).extend( getattr(subnamespace, _UNRECOGNIZED_ARGS_ATTR) ) else: super().__call__(parser, namespace, values, option_string)
[docs] class ParserError(Exception): def __init__(self, message, parser): self.message = message self.parser = parser def __unicode__(self): return self.message def __repr__(self): return f'ParserError({self.message}, {self.parser})'
[docs] class ArgumentParser(ArgParser): """Mimics the default :class:`argparse.ArgumentParser` class. There are a few distinctions, mostly to ease subparser usage: - If ``add_subparsers`` is called with the ``nested_namespaces`` kwarg, all subcommand options will be stored in a nested namespace based on the command name for the subparser - Adds the ``add_subparser`` method. After ``add_subparsers`` has been called, the ``add_subparser`` method can be used instead of the ``add_parser`` method of the object returned by the ``add_subparsers`` call. - ``add_subparser`` takes takes the ``parent_defaults`` argument, which will set/change the defaults for the parent parser when that subparser is selected. - The ``get_subparser`` method will get the :class:`ArgumentParser` instance for an existing subparser on this parser - For any arguments defined both in this parser and one of its subparsers, the selected subparser default will override the main one. - Adds the ``set_post_defaults`` method. This works like the normal argparse ``set_defaults`` method, but all actions and subparsers will be run before any of these defaults are set. - Command shortening: If the command for a subparser is abbreviated unambiguously, it will still be accepted. - The add_argument ``nargs`` keyword argument supports a range of arguments, e.g. ``2-4`` - If the ``raise_errors`` keyword argument to ``parse_args`` is True, a ``ParserError`` will be raised instead of ``sys.exit`` - If the ``file`` argument is given to ``parse_args``, output will be printed there instead of ``sys.stdout`` or ``stderr`` """ # These are created as a class attribute so that we can set it for parser and all subparsers at once file: IO[str] | None = None do_help = True def __init__(self, **kwargs): """Init an ArgumentParser instance. :param nested_namespace_name: When used as a subparser, options from this parser will be stored nested under this attribute name in the root parser's namespace """ self.subparsers = None self.raise_errors = None add_help = kwargs.pop('add_help', True) kwargs['add_help'] = False ArgParser.__init__(self, **kwargs) if add_help: self.add_argument( '--help', '-h', action=HelpAction, dest=SUPPRESS, default=SUPPRESS, nargs=0, help='Show this help message and exit', ) # Overwrite _SubparserAction with our custom one self.register('action', 'parsers', NestedSubparserAction) self.post_defaults = {} if kwargs.get('parents'): for parent in kwargs['parents']: if hasattr(parent, 'post_defaults'): self.set_post_defaults(**parent.post_defaults)
[docs] def add_argument(self, *args, **kwargs): if isinstance(kwargs.get('nargs'), str) and '-' in kwargs['nargs']: # Handle a custom range of arguments min, max = kwargs['nargs'].split('-') min, max = int(min), int(max) kwargs['action'] = required_length(min, max) # Make the usage string a bit better depending on whether the first argument is optional if min == 0: kwargs['nargs'] = '*' else: kwargs['nargs'] = '+' return super().add_argument(*args, **kwargs)
[docs] def _print_message(self, message, file=None): """If a file argument was passed to `parse_args` make sure output goes there.""" if self.file: file = self.file super()._print_message(message, file)
[docs] def set_post_defaults(self, **kwargs): """Like set_defaults method, but these defaults will be defined after parsing instead of before.""" self.post_defaults.update(kwargs) # if these defaults match any existing arguments, suppress # the previous default so that it can be filled after parsing for action in self._actions: if action.dest in kwargs: action.default = SUPPRESS
[docs] def error(self, msg: str): raise ParserError(msg, self)
[docs] def parse_args( self, args: list[str] | None = None, namespace: Namespace | None = None, raise_errors: bool = False, file: TextIO | None = None, ): """:param raise_errors: If this is true, errors will be raised as ``ParserError`` instead of calling sys.exit""" ArgumentParser.file = file try: return super().parse_args(args, namespace) except ParserError as e: if raise_errors: raise super(ArgumentParser, e.parser).error(e.message) finally: ArgumentParser.file = None
[docs] def parse_known_args( self, args: list[str] | None = None, namespace: Namespace | None = None, do_help: bool | None = None, ): if args is None: args = sys.argv[1:] if namespace is None: namespace = ScopedNamespace() old_do_help = ArgumentParser.do_help if do_help is not None: ArgumentParser.do_help = do_help try: namespace, args = super().parse_known_args(args, namespace) finally: ArgumentParser.do_help = old_do_help # add any post defaults that aren't present for dest in self.post_defaults: if not hasattr(namespace, dest): setattr(namespace, dest, self.post_defaults[dest]) return namespace, args
[docs] def add_subparsers(self, **kwargs): """Add a parser for a new subcommand and return it. :param nested_namespaces: If True, options from subparsers will appear in nested namespace under the subparser name. """ # Set the parser class so subparsers don't end up being an instance of a subclass, like CoreArgumentParser kwargs.setdefault('parser_class', ArgumentParser) kwargs.setdefault('required', True) self.subparsers = super().add_subparsers(**kwargs) return self.subparsers
[docs] def add_subparser(self, name: str, **kwargs): """Add a parser for a new subcommand and return it. :param name: Name of the subcommand :param parent_defaults: Default argument values which should be supplied to the parent parser if this subparser is selected. """ if not self.subparsers: raise TypeError('This parser does not have subparsers') return self.subparsers.add_parser(name, **kwargs)
[docs] def get_subparser(self, name: str, default=_UNSET): if not self.subparsers: raise TypeError('This parser does not have subparsers') p = self.subparsers.choices.get(name, default) if p is _UNSET: raise ValueError(f'{name} is not an existing subparser name') return p
[docs] def _get_values(self, action, arg_strings): """Complete the full name for partial subcommands.""" if action.nargs == PARSER and self.subparsers: subcommand = arg_strings[0] if subcommand not in self.subparsers.choices: matches = [x for x in self.subparsers.choices if x.startswith(subcommand)] if len(matches) == 1: arg_strings[0] = matches[0] return super()._get_values(action, arg_strings)
# This will hold just the arguments directly for Manager. manager_parser = ArgumentParser(add_help=False) manager_parser.add_argument( '-V', '--version', action=VersionAction, version=flexget.__version__, help='Print FlexGet version and exit.', ) manager_parser.add_argument( '--test', action='store_true', dest='test', default=0, help='Verbose what would happen on normal execution.', ) manager_parser.add_argument( '-c', dest='config', default='config.yml', help='Specify configuration file. Default: %(default)s', ) manager_parser.add_argument( '--logfile', '-l', default='flexget.log', help='Specify a custom logfile name/location. Default: %(default)s in the config directory.', ) manager_parser.add_argument( '--loglevel', '-L', metavar='LEVEL', help='Set the verbosity of the logger. Levels: %(choices)s', choices=['NONE', 'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'VERBOSE', 'DEBUG', 'TRACE'], type=str.upper, ) manager_parser.set_post_defaults(loglevel='VERBOSE') manager_parser.add_argument( '--profile', metavar='OUTFILE', nargs='?', const='flexget.profile', help='Use the python profiler for this run to debug performance issues.', ) manager_parser.add_argument('--debug', action=DebugAction, nargs=0, help=SUPPRESS) manager_parser.add_argument('--debug-trace', action=DebugTraceAction, nargs=0, help=SUPPRESS) manager_parser.add_argument('--debug-sql', action='store_true', default=False, help=SUPPRESS) manager_parser.add_argument('--experimental', action='store_true', default=False, help=SUPPRESS) manager_parser.add_argument('--ipc-port', type=int, help=SUPPRESS) manager_parser.add_argument( '--cron', action=CronAction, default=False, nargs=0, help='use when executing FlexGet non-interactively: allows background ' 'maintenance to run, disables stdout and stderr output, reduces logging level', )
[docs] class CoreArgumentParser(ArgumentParser): """The core argument parser, contains the manager arguments, command parsers, and plugin arguments. Warning: Only gets plugin arguments if instantiated after plugins have been loaded. """ def __init__(self, **kwargs): kwargs.setdefault('parents', [manager_parser]) kwargs.setdefault('prog', 'flexget') super().__init__(**kwargs) self.add_subparsers( title='commands', metavar='<command>', dest='cli_command', nested_namespaces=True ) # The parser for the execute command exec_parser = self.add_subparser('execute', help='execute tasks now') exec_parser.add_argument( '--tasks', nargs='+', metavar='TASK', help='run only specified task(s), optionally using glob patterns ("tv-*"). ' 'matching is case-insensitive', ) exec_parser.add_argument( '--learn', action='store_true', dest='learn', default=False, help='matches are not downloaded but will be skipped in the future', ) exec_parser.add_argument('--profile', action='store_true', default=False, help=SUPPRESS) exec_parser.add_argument('--disable-phases', nargs='*', help=SUPPRESS) exec_parser.add_argument('--inject', nargs='+', action=InjectAction, help=SUPPRESS) # Plugins should respect these flags where appropriate exec_parser.add_argument( '--retry', action='store_true', dest='retry', default=False, help=SUPPRESS ) exec_parser.add_argument( '--no-cache', action='store_true', dest='nocache', default=False, help='disable caches. works only in plugins that have explicit support', ) daemonize_help = SUPPRESS if not sys.platform.startswith('win'): daemonize_help = 'causes process to daemonize after starting' # The parser for the daemon command daemon_parser = self.add_subparser( 'daemon', parent_defaults={'loglevel': 'INFO'}, help='run continuously, executing tasks according to schedules defined in config', ) daemon_parser.add_subparsers(title='actions', metavar='<action>', dest='action') start_parser = daemon_parser.add_subparser('start', help='start the daemon') start_parser.add_argument('-d', '--daemonize', action='store_true', help=daemonize_help) start_parser.add_argument( '--autoreload-config', action='store_true', help='This option is already the default behavior. It is retained only for compatibility and will be removed in a future version. Please stop using this option.', ) start_parser.add_argument( '--no-autoreload-config', action='store_true', help='do not automatically reload the config from disk if the daemon detects any changes', ) start_parser.add_argument( '--enable-tray-icon', action='store_true', dest='tray_icon', help='Enable the tray icon', ) stop_parser = daemon_parser.add_subparser('stop', help='shutdown the running daemon') stop_parser.add_argument( '--wait', action='store_true', help='wait for all queued tasks to finish before stopping daemon', ) daemon_parser.add_subparser('status', help='check if a daemon is running') daemon_parser.add_subparser( 'reload-config', help='causes a running daemon to reload the config from disk' )
[docs] def add_subparsers(self, **kwargs): # The subparsers should not be CoreArgumentParsers kwargs.setdefault('parser_class', ArgumentParser) return super().add_subparsers(**kwargs)
[docs] def parse_args(self, *args, **kwargs): result = super().parse_args(*args, **kwargs) # Make sure we always have execute parser settings even when other commands called if result.cli_command != 'execute': exec_options = get_parser('execute').parse_args([]) if hasattr(result, 'execute'): exec_options.__dict__.update(result.execute.__dict__) result.execute = exec_options # Set the 'allow_manual' flag to True for any usage of the CLI result.allow_manual = True return result