def mainloop(self): """ The main loop. """ # Print usage if not enough args or bad options if len(self.args) < 1: self.parser.error("You have to provide the root directory of your watch tree, or a metafile path!") configuration.engine.load_config() pathname = os.path.abspath(self.args[0]) if os.path.isdir(pathname): watch = TreeWatch(Bunch(path=pathname, job_name="watch", active=True, dry_run=True, load_mode=None)) asyncore.loop(timeout=~0, use_poll=True) else: config = Bunch() config.update(dict((key.split('.', 2)[-1], val) for key, val in configuration.torque.items() if key.startswith("job.treewatch.") )) config.update(dict(path=os.path.dirname(os.path.dirname(pathname)), job_name="treewatch", active=False, dry_run=True)) watch = TreeWatch(config) handler = MetafileHandler(watch, pathname) ok = handler.parse() self.LOG.debug("Metafile '%s' would've %sbeen loaded" % (pathname, "" if ok else "NOT ")) handler.addinfo() post_process = str if self.options.verbose else logutil.shorten self.LOG.info("Templating values are:\n %s" % "\n ".join("%s=%s" % (key, post_process(repr(val))) for key, val in sorted(handler.ns.items()) ))
def _parse_triggers(self): """ Parse trigger definitions from config. """ # TODO: Load triggers from their own file (INI or YAML), and watch it with a timer?! self.triggers = [] current = Bunch(nick=None, flags=re.I, sysattr='') for token in shlex.split(self.api.config_get_plugin("triggers") or ''): if '=' in token: key, val = token.split('=', 1) current[key] = val if key == 'series': missing = [i for i in ('dbname', 'buffer', 'regex') if i not in current] if missing: self.log('triggers: Ignoring series "{0}" due to missing fields: {1}', val, missing, prefix='warn') continue triggerdef = Bunch(current) triggerdef.sysattr = set(i.strip() for i in triggerdef.sysattr.split(',')) try: triggerdef.regex = re.compile(triggerdef.regex, triggerdef.flags) except re.error, exc: self.log('Ignoring series "{0}" with malformed regex "{1}": {2}', val, current.regex, exc, prefix='warn') else: self.triggers.append(triggerdef) self.trace("trigger#{0} = {1} ", len(self.triggers), current) elif token == 'ignorecase': current.flags |= re.I
class FilterTest(unittest.TestCase): DATA = [ Bunch(name="T1", num=1, flag=True, tags=set("ab")), Bunch(name="F0", num=0, flag=False, tags=set()), Bunch(name="T11", num=11, flag=True, tags=set("b")), ] CASES = [ ("flag=y", "T1 T11"), ("num=-1", "F0"), ("num=-2", "F0 T1"), ("num=+99", ""), ("num>10", "T11"), ("num>11", ""), ("num>=11", "T11"), ("num<=11", "F0 T1 T11"), ("num<11", "F0 T1"), ("num<>1", "F0 T11"), ("num!=1", "F0 T11"), ("T?", "T1"), ("T*", "T1 T11"), ("tags=a", "T1"), ("tags=b", "T1 T11"), ("tags=a,b", "T1 T11"), ("tags==b", "T11"), ("tags=", "F0"), ("tags=!", "T1 T11"), ("tags=!a", "F0 T11"), ] def test_conditions(self): for cond, expected in self.CASES: keep = matching.ConditionParser(lookup, "name").parse(cond) result = set(i.name for i in self.DATA if keep(i)) expected = set(expected.split()) assert result == expected, "Expected %r, but got %r, for '%s' [ %s ]" % (expected, result, cond, keep)
def __init__(self, config=None): """ Set up InfluxDB logger. """ self.config = config or Bunch() self.influxdb = Bunch(config_ini.influxdb) self.influxdb.timeout = float(self.influxdb.timeout or '0.250') self.LOG = pymagic.get_class_logger(self) self.LOG.debug("InfluxDB statistics feed created with config %r" % self.config)
def __init__(self, job, pathname): """ Create a metafile handler. """ self.job = job self.metadata = None self.ns = Bunch( pathname=os.path.abspath(pathname), info_hash=None, tracker_alias=None, )
def load_config(self, namespace=None, rcfile=None): """ Load file given in "rcfile". """ if namespace is None: namespace = config if namespace.scgi_url: return # already have the connection to rTorrent # Get and check config file name if not rcfile: rcfile = getattr(config, "rtorrent_rc", None) if not rcfile: raise error.UserError( "No 'rtorrent_rc' path defined in configuration!") if not os.path.isfile(rcfile): raise error.UserError("Config file %r doesn't exist!" % (rcfile, )) # Parse the file self.LOG.debug("Loading rtorrent config from %r" % (rcfile, )) rc_vals = Bunch(scgi_local='', scgi_port='') with open(rcfile) as handle: continued = False for line in handle.readlines(): # Skip comments, continuations, and empty lines line = line.strip() continued, was_continued = line.endswith('\\'), continued if not line or was_continued or line.startswith("#"): continue # Be lenient about errors, after all it's not our own config file try: key, val = line.split("=", 1) except ValueError: self.LOG.warning("Ignored invalid line %r in %r!" % (line, rcfile)) continue key, val = key.strip(), val.strip() key = self.RTORRENT_RC_ALIASES.get(key, key).replace('.', '_') # Copy values we're interested in if key in self.RTORRENT_RC_KEYS: self.LOG.debug("rtorrent.rc: %s = %s" % (key, val)) rc_vals[key] = val # Validate fields if rc_vals.scgi_local: rc_vals.scgi_local = os.path.expanduser(rc_vals.scgi_local) if rc_vals.scgi_local.startswith('/'): rc_vals.scgi_local = "scgi://" + rc_vals.scgi_local if rc_vals.scgi_port and not rc_vals.scgi_port.startswith("scgi://"): rc_vals.scgi_port = "scgi://" + rc_vals.scgi_port # Prefer UNIX domain sockets over TCP sockets namespace.scgi_url = rc_vals.scgi_local or rc_vals.scgi_port
def mainloop(self): """ The main loop. """ # Print field definitions? if self.options.help_fields: self.parser.print_help() print_help_fields() sys.exit(1) # Print usage if no conditions are provided if not self.args: self.parser.error("No filter conditions given!") # Check special action options actions = [] if self.options.ignore: actions.append(Bunch(name="ignore", method="ignore", label="IGNORE" if int(self.options.ignore) else "HEED", help="commands on torrent", interactive=False, args=(self.options.ignore,))) if self.options.prio: actions.append(Bunch(name="prio", method="set_prio", label="PRIO" + str(self.options.prio), help="for torrent", interactive=False, args=(self.options.prio,))) # Check standard action options # TODO: Allow certain combinations of actions (like --tag foo --stop etc.) # How do we get a sensible order of execution? for action_mode in self.ACTION_MODES: if getattr(self.options, action_mode.name): if actions: self.parser.error("Options --%s and --%s are mutually exclusive" % ( ", --".join(i.name.replace('_', '-') for i in actions), action_mode.name.replace('_', '-'), )) if action_mode.argshelp: action_mode.args = (getattr(self.options, action_mode.name),) actions.append(action_mode) if not actions and self.options.flush: actions.append(Bunch(name="flush", method="flush", label="FLUSH", help="flush session data", interactive=False, args=())) self.options.flush = False # No need to flush twice if any(i.interactive for i in actions): self.options.interactive = True # Reduce results according to index range selection = None if self.options.select: try: if '-' in self.options.select: selection = tuple(int(i or default, 10) for i, default in zip(self.options.select.split('-', 1), ("1", "-1"))) else: selection = 1, int(self.options.select, 10) except (ValueError, TypeError), exc: self.fatal("Bad selection '%s' (%s)" % (self.options.select, exc))
def __init__(self, config=None): """ Set up statistics logger. """ self.config = config or Bunch() self.LOG = pymagic.get_class_logger(self) self.LOG.debug("Statistics logger created with config %r" % self.config)
def test_magic_matching(self): item = Bunch(name="foo", date=time.time() - 86401, one=1, year=2011, size=1024**2) match = lambda c: matching.ConditionParser( lambda _: { "matcher": matching.MagicFilter }, "name").parse(c).match(item) assert match("f??") assert match("name=f*") assert match("date=+1d") assert match("one=y") assert match("one=+0") assert match("year=+2000") assert match("size=1m") assert match("size=1024k") assert not match("a*") assert not match("date=-1d") assert not match("one=false") assert not match("one=+1") assert not match("year=+2525") assert not match("size=-1m")
def mainloop(self): """ The main loop. """ # Print usage if not enough args or bad options if len(self.args) < 1: self.parser.error("You have to provide the root directory of your watch tree, or a metafile path!") configuration.engine.load_config() pathname = os.path.abspath(self.args[0]) if os.path.isdir(pathname): watch = TreeWatch(Bunch(path=pathname, job_name="watch", active=True, dry_run=True, load_mode=None)) asyncore.loop(timeout=~0, use_poll=True) else: config = Bunch() config.update(dict((key.split('.', 2)[-1], val) for key, val in configuration.torque.items() if key.startswith("job.treewatch.") )) config.update(dict(path=os.path.dirname(os.path.dirname(pathname)), job_name="treewatch", active=False, dry_run=True)) watch = TreeWatch(config) handler = MetafileHandler(watch, pathname) ok = handler.parse() self.LOG.debug("Metafile '%s' would've %sbeen loaded" % (pathname, "" if ok else "NOT ")) if ok: handler.addinfo() post_process = str if self.options.verbose else logutil.shorten self.LOG.info("Templating values are:\n %s" % "\n ".join("%s=%s" % (key, post_process(repr(val))) for key, val in sorted(handler.ns.items()) ))
def expand_template(template, namespace): """ Expand the given (preparsed) template. Currently, only Tempita templates are supported. @param template: The template, in preparsed form, or as a string (which then will be preparsed). @param namespace: Custom namespace that is added to the predefined defaults and takes precedence over those. @return: The expanded template. @raise LoggableError: In case of typical errors during template execution. """ # Create helper namespace formatters = dict((name.split('_', 1)[1], method) for name, method in globals().items() if name.startswith("fmt_") or name.startswith("filter_")) helpers = Bunch() helpers.update(formatters) # Default templating namespace variables = dict(h=helpers, c=config.custom_template_helpers) variables.update(formatters) # redundant, for backwards compatibility # Provided namespace takes precedence variables.update(namespace) # Expand template try: template = preparse(template) return template.substitute(**variables) except (AttributeError, ValueError, NameError, TypeError) as exc: hint = '' if "column" in str(exc): try: col = int(str(exc).split("column")[1].split()[0]) except (TypeError, ValueError): pass else: hint = "%svVv\n" % (' ' * (col + 4)) content = getattr(template, "content", template) raise error.LoggableError( "%s: %s in template:\n%s%s" % (type(exc).__name__, exc, hint, "\n".join( "%3d: %s" % (i + 1, line) for i, line in enumerate(content.splitlines()))))
def _parse_triggers(self): """ Parse trigger definitions from config. """ # TODO: Load triggers from their own file (INI or YAML), and watch it with a timer?! self.triggers = [] current = Bunch(nick=None, flags=re.I, sysattr='') for token in shlex.split(self.api.config_get_plugin("triggers") or ''): if '=' in token: key, val = token.split('=', 1) current[key] = val if key == 'series': missing = [ i for i in ('dbname', 'buffer', 'regex') if i not in current ] if missing: self.log( 'triggers: Ignoring series "{0}" due to missing fields: {1}', val, missing, prefix='warn') continue triggerdef = Bunch(current) triggerdef.sysattr = set( i.strip() for i in triggerdef.sysattr.split(',')) try: triggerdef.regex = re.compile(triggerdef.regex, triggerdef.flags) except re.error, exc: self.log( 'Ignoring series "{0}" with malformed regex "{1}": {2}', val, current.regex, exc, prefix='warn') else: self.triggers.append(triggerdef) self.trace("trigger#{0} = {1} ", len(self.triggers), current) elif token == 'ignorecase': current.flags |= re.I
def _get_files(self, attrs=None): """ Get a list of all files in this download; each entry has the attributes C{path} (relative to root), C{size} (in bytes), C{mtime}, C{prio} (0=off, 1=normal, 2=high), C{created}, and C{opened}. This is UNCACHED, use C{fetch("files")} instead. @param attrs: Optional list of additional attributes to fetch. """ try: # Get info for all files f_multicall = self._engine._rpc.f.multicall f_params = [ self._fields["hash"], 0, "f.path=", "f.size_bytes=", "f.last_touched=", "f.priority=", "f.is_created=", "f.is_open=", ] for attr in (attrs or []): f_params.append("f.%s=" % attr) rpc_result = f_multicall(*tuple(f_params)) except xmlrpc.ERRORS as exc: raise error.EngineError( "While %s torrent #%s: %s" % ("getting files for", self._fields["hash"], exc)) else: #self._engine.LOG.debug("files result: %r" % rpc_result) # Return results result = [ Bunch( path=i[0], size=i[1], mtime=i[2] / 1000000.0, prio=i[3], created=i[4], opened=i[5], ) for i in rpc_result ] if attrs: for idx, attr in enumerate(attrs): if attr.startswith("get_"): attr = attr[4:] for item, rpc_item in zip(result, rpc_result): item[attr] = rpc_item[6 + idx] return result
def add_fast_resume(meta, datapath): """ Add fast resume data to a metafile dict. """ # Get list of files files = meta["info"].get("files", None) single = files is None if single: if os.path.isdir(datapath): datapath = os.path.join(datapath, meta["info"]["name"]) files = [ Bunch( path=[os.path.abspath(datapath)], length=meta["info"]["length"], ) ] # Prepare resume data resume = meta.setdefault("libtorrent_resume", {}) resume["bitfield"] = len(meta["info"]["pieces"]) // 20 resume["files"] = [] piece_length = meta["info"]["piece length"] offset = 0 for fileinfo in files: # Get the path into the filesystem filepath = os.sep.join(fileinfo["path"]) if not single: filepath = os.path.join(datapath, fmt.to_utf8(filepath.strip(os.sep))) # Check file size if os.path.getsize(filepath) != fileinfo["length"]: raise OSError( errno.EINVAL, "File size mismatch for %r [is %d, expected %d]" % ( filepath, os.path.getsize(filepath), fileinfo["length"], )) # Add resume data for this file resume["files"].append( dict( priority=1, mtime=int(os.path.getmtime(filepath)), completed=(offset + fileinfo["length"] + piece_length - 1) // piece_length - offset // piece_length, )) offset += fileinfo["length"] return meta
def __call__(self, environ, start_response): req = Request(environ) self.LOG.debug("Incoming request at %r" % (req.path_info, )) for regex, controller, kwargs in self.routes: match = regex.match(req.path_info) if match: req.urlvars = Bunch(kwargs) req.urlvars.update(match.groupdict()) self.LOG.debug("controller=%r; vars=%r; req=%r; env=%r" % (controller, req.urlvars, req, environ)) return controller(environ, start_response) return exc.HTTPNotFound()(environ, start_response)
def addinfo(self): """ Add known facts to templating namespace. """ # Basic values self.ns.watch_path = self.job.config.path self.ns.relpath = None for watch in self.job.config.path: if self.ns.pathname.startswith(watch.rstrip('/') + '/'): self.ns.relpath = os.path.dirname( self.ns.pathname)[len(watch.rstrip('/')) + 1:] break # Build indicator flags for target state from filename flags = self.ns.pathname.split(os.sep) flags.extend(flags[-1].split('.')) self.ns.flags = set(i for i in flags if i) # Metafile stuff announce = self.metadata.get("announce", None) if announce: self.ns.tracker_alias = configuration.map_announce2alias(announce) main_file = self.ns.info_name if "files" in self.metadata["info"]: main_file = list( sorted((i["length"], i["path"][-1]) for i in self.metadata["info"]["files"]))[-1][1] self.ns.filetype = os.path.splitext(main_file)[1] # Add name traits kind, info = traits.name_trait(self.ns.info_name, add_info=True) self.ns.traits = Bunch(info) self.ns.traits.kind = kind self.ns.label = '/'.join( traits.detect_traits(name=self.ns.info_name, alias=self.ns.tracker_alias, filetype=self.ns.filetype)).strip('/') # Finally, expand commands from templates self.ns.commands = [] for key, cmd in sorted(self.job.custom_cmds.items()): try: self.ns.commands.append( formatting.expand_template(cmd, self.ns)) except error.LoggableError as exc: self.job.LOG.error("While expanding '%s' custom command: %s" % (key, exc))
def format_item(format_spec, item, defaults=None): """ Format an item according to the given output format. The format can be gioven as either an interpolation string, or a Tempita template (which has to start with "E{lb}E{lb}"), @param format_spec: The output format. @param item: The object, which is automatically wrapped for interpolation. @param defaults: Optional default values. """ template_engine = getattr(format_spec, "__engine__", None) # TODO: Make differences between engines transparent if template_engine == "tempita" or (not template_engine and format_spec.startswith("{{")): # Set item, or field names for column titles namespace = dict(headers=not bool(item)) if item: namespace["d"] = item else: namespace["d"] = Bunch() for name in engine.FieldDefinition.FIELDS: namespace["d"][name] = name.upper() # Justify headers to width of a formatted value namespace.update( (name[4:], lambda x, m=method: str(x).rjust(len(str(m(0))))) for name, method in globals().items() if name.startswith("fmt_")) return expand_template(format_spec, namespace) else: # Interpolation format_spec = getattr(format_spec, "fmt", format_spec) if item is None: # For headers, ensure we only have string formats format_spec = re.sub( r"(\([_.a-zA-Z0-9]+\)[-#+0 ]?[0-9]*?)[.0-9]*[diouxXeEfFgG]", lambda m: m.group(1) + 's', format_spec) return format_spec % OutputMapping(item, defaults)
return '.'.join(parts.netloc.split(':')[0].split('.')[-2:]) except IndexError: return parts.netloc # Remember predefined names _PREDEFINED = tuple(_ for _ in globals() if not _.startswith('_')) # Set some defaults to shut up pydev / pylint; # these later get overwritten by loading the config debug = False config_dir = None scgi_local = "" scgi_port = "" scgi_url = "" throttle_names = set(("NONE", "NULL")) engine = Bunch(open=lambda: None) formats = {} sort_fields = "" announce = {} config_validator_callbacks = [] custom_field_factories = [] xmlrpc = {} output_header_ecma48 = "" output_header_frequency = 1 waif_pattern_list = [] traits_by_alias = {} torque = {} log_execute = None magnet_watch = None
for attr in (attrs or []): f_params.append("f.%s=" % attr) rpc_result = f_multicall(*tuple(f_params)) except xmlrpc.ERRORS, exc: raise error.EngineError( "While %s torrent #%s: %s" % ("getting files for", self._fields["hash"], exc)) else: #self._engine.LOG.debug("files result: %r" % rpc_result) # Return results result = [ Bunch( path=i[0], size=i[1], mtime=i[2] / 1000000.0, prio=i[3], created=i[4], opened=i[5], ) for i in rpc_result ] if attrs: for idx, attr in enumerate(attrs): if attr.startswith("get_"): attr = attr[4:] for item, rpc_item in zip(result, rpc_result): item[attr] = rpc_item[6 + idx] return result def _memoize(self, name, getter, *args, **kwargs):
def __init__(self, **kwargs): self.LOG = pymagic.get_class_logger(self) self.cfg = Bunch(kwargs)
import time import logging import asyncore from pyrobase import logutil from pyrobase.parts import Bunch from pyrocore import error from pyrocore import config as configuration from pyrocore.util import os, fmt, xmlrpc, pymagic, metafile, traits from pyrocore.torrent import matching, formatting from pyrocore.scripts.base import ScriptBase, ScriptBaseWithConfig try: import pyinotify except ImportError as exc: pyinotify = Bunch(WatchManager=None, ProcessEvent=object, _import_error=str(exc)) # bogus pylint: disable=C0103 class MetafileHandler(object): """ Handler for loading metafiles into rTorrent. """ def __init__(self, job, pathname): """ Create a metafile handler. """ self.job = job self.metadata = None self.ns = Bunch( pathname=os.path.abspath(pathname), info_hash=None, tracker_alias=None,
def _validate_config(self): """ Handle and check configuration. """ groups = dict( job=defaultdict(Bunch), httpd=defaultdict(Bunch), ) for key, val in config.torque.items(): # Auto-convert numbers and bools if val.isdigit(): config.torque[key] = val = int(val) elif val.lower() in (matching.TRUE | matching.FALSE): val = matching.truth(str(val), key) # Assemble grouped parameters stem = key.split('.', 1)[0] if key == "httpd.active": groups[stem]["active"] = val elif stem in groups: try: stem, name, param = key.split('.', 2) except (TypeError, ValueError): self.fatal( "Bad %s configuration key %r (expecting %s.NAME.PARAM)" % (stem, key, stem)) else: groups[stem][name][param] = val for key, val in groups.iteritems(): setattr(self, key.replace("job", "jobs"), Bunch(val)) # Validate httpd config if self.httpd.active: if self.httpd.waitress.url_scheme not in ("http", "https"): self.fatal("HTTP URL scheme must be either 'http' or 'https'") if not isinstance(self.httpd.waitress.port, int) or not ( 1024 <= self.httpd.waitress.port < 65536): self.fatal("HTTP port must be a 16 bit number >= 1024") # Validate jobs for name, params in self.jobs.items(): for key in ("handler", "schedule"): if key not in params: self.fatal( "Job '%s' is missing the required 'job.%s.%s' parameter" % (name, name, key)) bool_param = lambda k, default, p=params: matching.truth( p.get(k, default), "job.%s.%s" % (name, k)) params.job_name = name params.dry_run = bool_param("dry_run", False) or self.options.dry_run params.active = bool_param("active", True) params.schedule = self._parse_schedule(params.schedule) if params.active: try: params.handler = pymagic.import_name(params.handler) except ImportError as exc: self.fatal("Bad handler name '%s' for job '%s':\n %s" % (params.handler, name, exc))
# Return 2nd level domain name if no alias found try: return '.'.join(parts.netloc.split(':')[0].split('.')[-2:]) except IndexError: return parts.netloc # Remember predefined names _PREDEFINED = tuple(_ for _ in globals() if not _.startswith('_')) # Set some defaults to shut up pydev / pylint; # these later get overwritten by loading the config debug = False config_dir = None scgi_url = "" engine = Bunch(open=lambda: None) fast_query = 0 formats = {} sort_fields = "" announce = {} config_validator_callbacks = [] custom_field_factories = [] custom_template_helpers = Bunch() xmlrpc = {} output_header_ecma48 = "" output_header_frequency = 1 waif_pattern_list = [] traits_by_alias = {} torque = {} magnet_watch = None influxdb = {}
class RtorrentControl(ScriptBaseWithConfig): ### Keep things wrapped to fit under this comment... ############################## """ Control and inspect rTorrent from the command line. Filter expressions take the form "<field>=<value>", and all expressions must be met (AND). If a field name is omitted, "name" is assumed. You can also use uppercase OR to build a list of alternative conditions. For numeric fields, a leading "+" means greater than, a leading "-" means less than. For string fields, the value is a glob pattern (*, ?, [a-z], [!a-z]), or a regex match enclosed by slashes. All string comparisons are case-ignoring. Multiple values separated by a comma indicate several possible choices (OR). "!" in front of a filter value negates it (NOT). See https://pyrocore.readthedocs.io/en/latest/usage.html#rtcontrol for more. Examples: - All 1:1 seeds ratio=+1 - All active torrents xfer=+0 - All seeding torrents up=+0 - Slow torrents down=+0 down=-5k - Older than 2 weeks completed=+2w - Big stuff size=+4g - 1:1 seeds not on NAS ratio=+1 'realpath=!/mnt/*' - Music kind=flac,mp3 """ # argument description for the usage information ARGS_HELP = "<filter>..." # additonal stuff appended after the command handler's docstring ADDITIONAL_HELP = [ "", "", "Use --help to get a list of all options.", "Use --help-fields to list all fields and their description.", ] # additional values for output formatting FORMATTER_DEFAULTS = dict(now=time.time(), ) # choices for --ignore IGNORE_OPTIONS = ('0', '1') # choices for --prio PRIO_OPTIONS = ('0', '1', '2', '3') # choices for --alter ALTER_MODES = ('append', 'remove') # action options that perform some change on selected items ACTION_MODES = ( Bunch(name="start", options=("--start", ), help="start torrent"), Bunch(name="close", options=("--close", "--stop"), help="stop torrent", method="stop"), Bunch(name="hash_check", label="HASH", options=("-H", "--hash-check"), help="hash-check torrent", interactive=True), # TODO: Bunch(name="announce", options=("--announce",), help="announce right now", interactive=True), # TODO: --pause, --resume? # TODO: implement --clean-partial #self.add_bool_option("--clean-partial", # help="remove partially downloaded 'off'ed files (also stops downloads)") Bunch(name="delete", options=("--delete", ), help="remove torrent from client", interactive=True), Bunch(name="purge", options=("--purge", "--delete-partial"), help="delete PARTIAL data files and remove torrent from client", interactive=True), Bunch(name="cull", options=("--cull", "--exterminate", "--delete-all"), help="delete ALL data files and remove torrent from client", interactive=True), Bunch( name="throttle", options=( "-T", "--throttle", ), argshelp="NAME", method="set_throttle", help="assign to named throttle group (NULL=unlimited, NONE=global)", interactive=True), Bunch(name="tag", options=("--tag", ), argshelp='"TAG +TAG -TAG..."', help="add or remove tag(s)", interactive=False), Bunch(name="custom", label="SET_CUSTOM", options=("--custom", ), argshelp='KEY=VALUE', method="set_custom", help="set value of 'custom_KEY' field (KEY might also be 1..5)", interactive=False), Bunch(name="exec", label="EXEC", options=("--exec", "--xmlrpc"), argshelp='CMD', method="execute", help="execute XMLRPC command pattern", interactive=True), # TODO: --move / --link output_format / the formatted result is the target path # if the target contains a '//' in place of a '/', directories # after that are auto-created # "--move tracker_dated", with a custom output format # like "tracker_dated = ~/done//$(alias)s/$(completed).7s", # will move to ~/done/OBT/2010-08 for example # self.add_value_option("--move", "TARGET", # help="move data to given target directory (implies -i, can be combined with --delete)") # TODO: --copy, and --move/--link across devices ) def __init__(self): """ Initialize rtcontrol. """ super(RtorrentControl, self).__init__() self.prompt = PromptDecorator(self) self.plain_output_format = False self.raw_output_format = None def add_options(self): """ Add program options. """ super(RtorrentControl, self).add_options() # basic options self.add_bool_option( "--help-fields", help="show available fields and their description") self.add_bool_option( "-n", "--dry-run", help="don't commit changes, just tell what would happen") self.add_bool_option("--detach", help="run the process in the background") self.prompt.add_options() # output control self.add_bool_option("-S", "--shell", help="escape output following shell rules") self.add_bool_option( "-0", "--nul", "--print0", help="use a NUL character instead of a linebreak after items") self.add_bool_option("-c", "--column-headers", help="print column headers") self.add_bool_option("-+", "--stats", help="add sum / avg / median of numerical fields") self.add_bool_option( "--summary", help="print only statistical summary, without the items") #self.add_bool_option("-f", "--full", # help="print full torrent details") self.add_bool_option( "--json", help= "dump default fields of all items as JSON (use '-o f1,f2,...' to specify fields)" ) self.add_value_option( "-o", "--output-format", "FORMAT", help="specify display format (use '-o-' to disable item display)") self.add_value_option( "-O", "--output-template", "FILE", help="pass control of output formatting to the specified template") self.add_value_option( "-s", "--sort-fields", "[-]FIELD[,...] [-s...]", action='append', default=[], help= "fields used for sorting, descending if prefixed with a '-'; '-s*' uses output field list" ) self.add_bool_option("-r", "--reverse-sort", help="reverse the sort order") self.add_value_option( "-A", "--anneal", "MODE [-A...]", type='choice', action='append', default=[], choices=('dupes+', 'dupes-', 'dupes=', 'invert', 'unique'), help="modify result set using some pre-defined methods") self.add_value_option( "-/", "--select", "[N-]M", help="select result subset by item position (counting from 1)") self.add_bool_option( "-V", "--view-only", help="show search result only in default ncurses view") self.add_value_option( "--to-view", "--to", "NAME", help="show search result only in named ncurses view") self.add_bool_option("--append-view", "--append", help="DEPRECATED: use '--alter append' instead") self.add_value_option( "--alter-view", "--alter", "MODE", type='choice', default=None, choices=self.ALTER_MODES, help= "alter view according to mode: {} (modifies -V and --to behaviour)" .format(', '.join(self.ALTER_MODES))) self.add_bool_option( "--tee-view", "--tee", help= "ADDITIONALLY show search results in ncurses view (modifies -V and --to behaviour)" ) self.add_value_option( "--from-view", "--from", "NAME", help= "select only items that are on view NAME (NAME can be an info hash to quickly select a single item)" ) self.add_value_option( "-M", "--modify-view", "NAME", help= "get items from given view and write result back to it (short-cut to combine --from-view and --to-view)" ) self.add_value_option( "-Q", "--fast-query", "LEVEL", type='choice', default='=', choices=('=', '0', '1', '2'), help= "enable query optimization (=: use config; 0: off; 1: safe; 2: danger seeker)" ) self.add_value_option("--call", "CMD", help="call an OS command pattern in the shell") self.add_value_option("--spawn", "CMD [--spawn ...]", action="append", default=[], help="execute OS command pattern(s) directly") # TODO: implement -S # self.add_bool_option("-S", "--summary", # help="print statistics") # torrent state change (actions) for action in self.ACTION_MODES: action.setdefault("label", action.name.upper()) action.setdefault("method", action.name) action.setdefault("interactive", False) action.setdefault("argshelp", "") action.setdefault("args", ()) if action.argshelp: self.add_value_option( *action.options + (action.argshelp, ), **{ "help": action.help + (" (implies -i)" if action.interactive else "") }) else: self.add_bool_option( *action.options, **{ "help": action.help + (" (implies -i)" if action.interactive else "") }) self.add_value_option("--ignore", "|".join(self.IGNORE_OPTIONS), type="choice", choices=self.IGNORE_OPTIONS, help="set 'ignore commands' status on torrent") self.add_value_option("--prio", "|".join(self.PRIO_OPTIONS), type="choice", choices=self.PRIO_OPTIONS, help="set priority of torrent") self.add_bool_option( "-F", "--flush", help="flush changes immediately (save session data)") def help_completion_fields(self): """ Return valid field names. """ for name, field in sorted(engine.FieldDefinition.FIELDS.items()): if issubclass(field._matcher, matching.BoolFilter): yield "%s=no" % (name, ) yield "%s=yes" % (name, ) continue elif issubclass(field._matcher, matching.PatternFilter): yield "%s=" % (name, ) yield "%s=/" % (name, ) yield "%s=?" % (name, ) yield "%s=\"'*'\"" % (name, ) continue elif issubclass(field._matcher, matching.NumericFilterBase): for i in range(10): yield "%s=%d" % (name, i) else: yield "%s=" % (name, ) yield r"%s=+" % (name, ) yield r"%s=-" % (name, ) yield "custom_" yield "kind_" # TODO: refactor to engine.TorrentProxy as format() method def format_item(self, item, defaults=None, stencil=None): """ Format an item. """ from pyrobase.osutil import shell_escape try: item_text = fmt.to_console( formatting.format_item(self.options.output_format, item, defaults)) except (NameError, ValueError, TypeError), exc: self.fatal( "Trouble with formatting item %r\n\n FORMAT = %r\n\n REASON =" % (item, self.options.output_format), exc) raise # in --debug mode if self.options.shell: item_text = '\t'.join( shell_escape(i) for i in item_text.split('\t')) # Justify headers according to stencil if stencil: item_text = '\t'.join( i.ljust(len(s)) for i, s in zip(item_text.split('\t'), stencil)) return item_text
class MetafileHandler(object): """ Handler for loading metafiles into rTorrent. """ def __init__(self, job, pathname): """ Create a metafile handler. """ self.job = job self.metadata = None self.ns = Bunch( pathname=os.path.abspath(pathname), info_hash=None, tracker_alias=None, ) def parse(self): """ Parse metafile and check pre-conditions. """ try: if not os.path.getsize(self.ns.pathname): # Ignore 0-byte dummy files (Firefox creates these while downloading) self.job.LOG.warn("Ignoring 0-byte metafile '%s'" % (self.ns.pathname,)) return self.metadata = metafile.checked_open(self.ns.pathname) except EnvironmentError as exc: self.job.LOG.error("Can't read metafile '%s' (%s)" % ( self.ns.pathname, str(exc).replace(": '%s'" % self.ns.pathname, ""), )) return except ValueError as exc: self.job.LOG.error("Invalid metafile '%s': %s" % (self.ns.pathname, exc)) return self.ns.info_hash = metafile.info_hash(self.metadata) self.ns.info_name = self.metadata["info"]["name"] self.job.LOG.info("Loaded '%s' from metafile '%s'" % (self.ns.info_name, self.ns.pathname)) # Check whether item is already loaded try: name = self.job.proxy.d.name(self.ns.info_hash, fail_silently=True) except xmlrpc.HashNotFound: pass except xmlrpc.ERRORS as exc: if exc.faultString != "Could not find info-hash.": self.job.LOG.error("While checking for #%s: %s" % (self.ns.info_hash, exc)) return else: self.job.LOG.warn("Item #%s '%s' already added to client" % (self.ns.info_hash, name)) return return True def addinfo(self): """ Add known facts to templating namespace. """ # Basic values self.ns.watch_path = self.job.config.path self.ns.relpath = None for watch in self.job.config.path: if self.ns.pathname.startswith(watch.rstrip('/') + '/'): self.ns.relpath = os.path.dirname(self.ns.pathname)[len(watch.rstrip('/'))+1:] break # Build indicator flags for target state from filename flags = self.ns.pathname.split(os.sep) flags.extend(flags[-1].split('.')) self.ns.flags = set(i for i in flags if i) # Metafile stuff announce = self.metadata.get("announce", None) if announce: self.ns.tracker_alias = configuration.map_announce2alias(announce) main_file = self.ns.info_name if "files" in self.metadata["info"]: main_file = list(sorted((i["length"], i["path"][-1]) for i in self.metadata["info"]["files"]))[-1][1] self.ns.filetype = os.path.splitext(main_file)[1] # Add name traits kind, info = traits.name_trait(self.ns.info_name, add_info=True) self.ns.traits = Bunch(info) self.ns.traits.kind = kind self.ns.label = '/'.join(traits.detect_traits( name=self.ns.info_name, alias=self.ns.tracker_alias, filetype=self.ns.filetype)).strip('/') # Finally, expand commands from templates self.ns.commands = [] for key, cmd in sorted(self.job.custom_cmds.items()): try: self.ns.commands.append(formatting.expand_template(cmd, self.ns)) except error.LoggableError as exc: self.job.LOG.error("While expanding '%s' custom command: %s" % (key, exc)) def load(self): """ Load metafile into client. """ if not self.ns.info_hash and not self.parse(): return self.addinfo() # TODO: dry_run try: # TODO: Scrub metafile if requested # Determine target state start_it = self.job.config.load_mode.lower() in ("start", "started") queue_it = self.job.config.queued if "start" in self.ns.flags: start_it = True elif "load" in self.ns.flags: start_it = False if "queue" in self.ns.flags: queue_it = True # Load metafile into client load_cmd = self.job.proxy.load.verbose if queue_it: if not start_it: self.ns.commands.append("d.priority.set=0") elif start_it: load_cmd = self.job.proxy.load.start_verbose self.job.LOG.debug("Templating values are:\n %s" % "\n ".join("%s=%s" % (key, repr(val)) for key, val in sorted(self.ns.items()) )) load_cmd(xmlrpc.NOHASH, self.ns.pathname, *tuple(self.ns.commands)) time.sleep(.05) # let things settle # Announce new item if not self.job.config.quiet: msg = "%s: Loaded '%s' from '%s/'%s%s" % ( self.job.__class__.__name__, fmt.to_utf8(self.job.proxy.d.name(self.ns.info_hash, fail_silently=True)), os.path.dirname(self.ns.pathname).rstrip(os.sep), " [queued]" if queue_it else "", (" [startable]" if queue_it else " [started]") if start_it else " [normal]", ) self.job.proxy.log(xmlrpc.NOHASH, msg) # TODO: Evaluate fields and set client values # TODO: Add metadata to tied file if requested # TODO: Execute commands AFTER adding the item, with full templating # Example: Labeling - add items to a persistent view, i.e. "postcmd = view.set_visible={{label}}" # could also be done automatically from the path, see above under "flags" (autolabel = True) # and add traits to the flags, too, in that case except xmlrpc.ERRORS as exc: self.job.LOG.error("While loading #%s: %s" % (self.ns.info_hash, exc)) def handle(self): """ Handle metafile. """ if self.parse(): self.load()
def do_session(self): """Restore state from session files.""" def filenames(): 'Helper' for arg in self.args: if os.path.isdir(arg): for name in glob.glob(os.path.join(arg, '*.torrent.rtorrent')): yield name elif arg == '@-': for line in sys.stdin.read().splitlines(): if line.strip(): yield line.strip() elif arg.startswith('@'): if not os.path.isfile(arg[1:]): self.parser.error("File not found (or not a file): {}".format(arg[1:])) with io.open(arg[1:], encoding='utf-8') as handle: for line in handle: if line.strip(): yield line.strip() else: yield arg proxy = self.open() for filename in filenames(): # Check filename and extract infohash self.LOG.debug("Reading '%s'...", filename) match = re.match(r'(?:.+?[-._])?([a-fA-F0-9]{40})(?:[-._].+?)?\.torrent\.rtorrent', os.path.basename(filename)) if not match: self.LOG.warn("Skipping badly named session file '%s'...", filename) continue infohash = match.group(1) # Read bencoded data try: with open(filename, 'rb') as handle: raw_data = handle.read() data = Bunch(bencode.bdecode(raw_data)) except EnvironmentError as exc: self.LOG.warn("Can't read '%s' (%s)" % ( filename, str(exc).replace(": '%s'" % filename, ""), )) continue ##print(infohash, '=', repr(data)) if 'state_changed' not in data: self.LOG.warn("Skipping invalid session file '%s'...", filename) continue # Restore metadata was_active = proxy.d.is_active(infohash) proxy.d.ignore_commands.set(infohash, data.ignore_commands) proxy.d.priority.set(infohash, data.priority) if proxy.d.throttle_name(infohash) != data.throttle_name: proxy.d.pause(infohash) proxy.d.throttle_name.set(infohash, data.throttle_name) if proxy.d.directory(infohash) != data.directory: proxy.d.stop(infohash) proxy.d.directory_base.set(infohash, data.directory) for i in range(5): key = 'custom%d' % (i + 1) getattr(proxy.d, key).set(infohash, data[key]) for key, val in data.custom.items(): proxy.d.custom.set(infohash, key, val) for name in data.views: try: proxy.view.set_visible(infohash, name) except xmlrpclib.Fault as exc: if 'Could not find view' not in str(exc): raise if was_active and not proxy.d.is_active(infohash): (proxy.d.resume if proxy.d.is_open(infohash) else proxy.d.start)(infohash) proxy.d.save_full_session(infohash) """ TODO:
from pyrobase import fmt if not os.path.supports_unicode_filenames: # Make a Unicode-aware copy of os and os.path from pyrobase.parts import Bunch def _encode_path(text): """ Return a string suitable for calling file system functions. """ if isinstance(text, str): return text else: return text.encode("UTF-8") # copy "os" identifiers _os = Bunch() for _key in os.__all__: _os[_key] = getattr(os, _key) def _wrap(name, func): "Wrapping helper." setattr(_os, name, func) func.__name__ = name func.__doc__ = getattr(os, name).__doc__ # wrap some os functions _wrap("makedirs", lambda path, o=os: o.makedirs(_encode_path(path))) _wrap("readlink", lambda path, o=os: o.readlink(_encode_path(path))) _wrap("remove", lambda path, o=os: o.remove(_encode_path(path))) _wrap( "rename",