Source code for pyrocore.torrent.rtorrent

# -*- coding: utf-8 -*-
# pylint: disable=I0011
""" rTorrent Proxy.

    Copyright (c) 2009, 2010, 2011 The PyroScope Project <pyroscope.project@gmail.com>
"""
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
from __future__ import with_statement
from __future__ import absolute_import

import sys
import time
import errno
import shlex
import fnmatch
import logging
import operator

from pyrobase.parts import Bunch
from pyrocore import config, error
from pyrocore.util import os, xmlrpc, load_config, traits, fmt, matching
from pyrocore.torrent import engine


[docs]class CommaLexer(shlex.shlex): """Helper to split argument lists.""" def __init__(self, text): shlex.shlex.__init__(self, text, None, True) self.whitespace += ',' self.whitespace_split = True self.commenters = ''
[docs]class RtorrentItem(engine.TorrentProxy): """ A single download item. """ def __init__(self, engine_, fields): """ Initialize download item. """ super(RtorrentItem, self).__init__() self._engine = engine_ self._fields = dict(fields) def _make_it_so(self, command, calls, *args, **kwargs): """ Perform some error-checked XMLRPC calls. """ observer = kwargs.pop('observer', False) args = (self._fields["hash"],) + args try: for call in calls: self._engine.LOG.debug("%s%s torrent #%s (%s)" % ( command[0].upper(), command[1:], self._fields["hash"], call)) if call[:2].endswith('.'): namespace = self._engine._rpc else: namespace = self._engine._rpc.d result = getattr(namespace, call)(*args) if observer: observer(result) except xmlrpc.ERRORS as exc: raise error.EngineError("While %s torrent #%s: %s" % (command, self._fields["hash"], exc)) 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 _memoize(self, name, getter, *args, **kwargs): """ Cache a stable expensive-to-get item value for later (optimized) retrieval. """ field = "custom_m_" + name cached = self.fetch(field) if cached: value = cached else: value = getter(*args, **kwargs) self._make_it_so("caching %s=%r for" % (name, value,), ["custom.set"], field[7:], value) self._fields[field] = value return value def _get_kind(self, limit): """ Get a set of dominant file types. The files must contribute at least C{limit}% to the item's total size. """ histo = self.fetch("custom_kind") if histo: # Parse histogram from cached field histo = [i.split("%_") for i in str(histo).split()] histo = [(int(val, 10), ext) for val, ext in histo] ##self._engine.LOG.debug("~~~~~~~~~~ cached histo = %r" % histo) else: # Get filetypes histo = traits.get_filetypes(self.fetch("files"), path=operator.attrgetter("path"), size=operator.attrgetter("size")) # Set custom cache field with value formatted like "80%_flac 20%_jpg" (sorted by percentage) histo_str = ' '.join(("%d%%_%s" % i).replace(' ', '_') for i in histo) self._make_it_so("setting kind cache %r on" % (histo_str,), ["custom.set"], "kind", histo_str) self._fields["custom_kind"] = histo_str # Return all non-empty extensions that make up at least <limit>% of total size return set(ext for val, ext in histo if ext and val >= limit)
[docs] def as_dict(self): """ Return known fields. """ return self._fields.copy()
[docs] def fetch(self, name, engine_name=None): """ Get a field on demand. """ # TODO: Get each on-demand field in a multicall for all other items, since # we likely need it anyway; another (more easy) way would be to pre-fetch dynamically # with the list of fields from filters and output formats try: return self._fields[name] except KeyError: if isinstance(name, (int, long)): name = "custom_%d" % name if name == "done": val = float(self.fetch("completed_chunks")) / self.fetch("size_chunks") elif name == "files": val = self._get_files() elif name.startswith("kind_") and name[5:].isdigit(): val = self._get_kind(int(name[5:], 10)) elif name.startswith("custom_"): key = name[7:] try: if len(key) == 1 and key in "12345": val = getattr(self._engine._rpc.d, "get_custom"+key)(self._fields["hash"]) else: val = self._engine._rpc.d.custom(self._fields["hash"], key) except xmlrpc.ERRORS as exc: raise error.EngineError("While accessing field %r: %s" % (name, exc)) else: getter_name = engine_name if engine_name else RtorrentEngine.PYRO2RT_MAPPING.get(name, name) if getter_name[0] == '=': getter_name = getter_name[1:] else: getter_name = "get_" + getter_name getter = getattr(self._engine._rpc.d, getter_name) try: val = getter(self._fields["hash"]) except xmlrpc.ERRORS as exc: raise error.EngineError("While accessing field %r: %s" % (name, exc)) # TODO: Currently, NOT caching makes no sense; in a demon, it does! #if isinstance(FieldDefinition.FIELDS.get(name), engine.ConstantField): self._fields[name] = val return val
[docs] def datapath(self): """ Get an item's data path. """ path = self._fields['path'] if not path: # stopped item with no base_dir? path = self.fetch('directory') if path and not self._fields['is_multi_file']: path = os.path.join(path, self._fields['name']) return os.path.expanduser(fmt.to_unicode(path))
[docs] def announce_urls(self, default=[]): # pylint: disable=dangerous-default-value """ Get a list of all announce URLs. Returns `default` if no trackers are found at all. """ try: response = self._engine._rpc.t.multicall(self._fields["hash"], 0, "t.url=", "t.is_enabled=") except xmlrpc.ERRORS as exc: raise error.EngineError("While getting announce URLs for #%s: %s" % (self._fields["hash"], exc)) if response: return [i[0] for i in response if i[1]] else: return default
[docs] def start(self): """ (Re-)start downloading or seeding. """ self._make_it_so("starting", ["open", "start"])
[docs] def stop(self): """ Stop and close download. """ self._make_it_so("stopping", ["stop", "close"])
[docs] def ignore(self, flag): """ Set ignore status. """ self._make_it_so("setting ignore status for", ["ignore_commands.set"], int(flag))
[docs] def set_prio(self, prio): """ Set priority (0-3). """ self._make_it_so("setting priority for", ["priority.set"], max(0, min(int(prio), 3)))
[docs] def tag(self, tags): """ Add or remove tags. """ # Get tag list and add/remove given tags tags = tags.lower() previous = self.tagged tagset = previous.copy() for tag in tags.replace(',', ' ').split(): if tag.startswith('-'): tagset.discard(tag[1:]) elif tag.startswith('+'): tagset.add(tag[1:]) else: tagset.add(tag) # Write back new tagset, if changed tagset.discard('') if tagset != previous: tagset = ' '.join(sorted(tagset)) self._make_it_so("setting tags %r on" % (tagset,), ["custom.set"], "tags", tagset) self._fields["custom_tags"] = tagset
[docs] def set_throttle(self, name): """ Assign to throttle group. """ if name.lower() == "null": name = "NULL" if name.lower() == "none": name = "" if (name or "NONE") not in config.throttle_names: raise error.UserError("Unknown throttle name %r" % (name or "NONE",)) if (name or "NONE") == self.throttle: self._engine.LOG.debug("Keeping throttle %r on torrent #%s" % (self.throttle, self._fields["hash"])) return active = self.is_active if active: self._engine.LOG.debug("Torrent #%s stopped for throttling" % (self._fields["hash"],)) self.stop() self._make_it_so("setting throttle %r on" % (name,), ["throttle_name.set"], name) if active: self._engine.LOG.debug("Torrent #%s restarted after throttling" % (self._fields["hash"],)) self.start()
[docs] def set_custom(self, key, value=None): """ Set a custom value. C{key} might have the form "key=value" when value is C{None}. """ # Split combined key/value if value is None: try: key, value = key.split('=', 1) except (ValueError, TypeError) as exc: raise error.UserError("Bad custom field assignment %r, probably missing a '=' (%s)" % (key, exc)) # Check identifier rules if not key: raise error.UserError("Custom field name cannot be empty!") elif len(key) == 1 and key in "12345": method, args = "custom"+key+".set", (value,) elif not (key[0].isalpha() and key.replace("_", "").isalnum()): raise error.UserError("Bad custom field name %r (must only contain a-z, A-Z, 0-9 and _)" % (key,)) else: method, args = "custom.set", (key, value) # Make the assignment self._make_it_so("setting custom_%s = %r on" % (key, value), [method], *args) self._fields["custom_"+key] = value
[docs] def hash_check(self): """ Hash check a download. """ self._make_it_so("hash-checking", ["check_hash"])
[docs] def execute(self, commands): """ Execute XMLRPC command(s). """ try: commands = [i.strip() for i in commands.split(' ; ')] except (TypeError, AttributeError): pass # assume an iterable for command in commands: try: method, args = command.split('=', 1) args = tuple(CommaLexer(args)) except (ValueError, TypeError) as exc: raise error.UserError("Bad command %r, probably missing a '=' (%s)" % (command, exc)) def print_result(data): "Helper to print XMLRPC call results" args_list = '' if args: args_list = '"' + '","'.join(args) + '"' namespace = '' if method[:2].endswith('.') else 'd.' print('%s\t%s\t%s%s=%s' % (self._fields["hash"], data, namespace, method, args_list)) observer = print_result if method.startswith('>') else None method = method.lstrip('>') self._make_it_so("executing command on", [method], *args, observer=observer)
[docs] def delete(self): """ Remove torrent from client. """ self.stop() self._make_it_so("removing metafile of", ["delete_tied"]) self._make_it_so("erasing", ["erase"])
#TODO: def set_files_priority(self, pattern, prio) # Set priority of selected files # NOTE: need to call d.update_priorities after f.priority.set!
[docs] def purge(self): """ Delete PARTIAL data files and remove torrent from client. """ def partial_file(item): "Filter out partial files" #print "???", repr(item) return item.completed_chunks < item.size_chunks self.cull(file_filter=partial_file, attrs=["get_completed_chunks", "get_size_chunks"])
[docs] def cull(self, file_filter=None, attrs=None): """ Delete ALL data files and remove torrent from client. @param file_filter: Optional callable for selecting a subset of all files. The callable gets a file item as described for RtorrentItem._get_files and must return True for items eligible for deletion. @param attrs: Optional list of additional attributes to fetch for a filter. """ dry_run = 0 # set to 1 for testing def remove_with_links(path): "Remove a path including any symlink chains leading to it." rm_paths = [] while os.path.islink(path): target = os.readlink(path) rm_paths.append(path) path = target if os.path.exists(path): rm_paths.append(path) else: self._engine.LOG.debug("Real path '%s' doesn't exist," " but %d symlink(s) leading to it will be deleted..." % (path, len(rm_paths))) # Remove the link chain, starting at the real path # (this prevents losing the chain when there's permission problems) for rm_path in reversed(rm_paths): is_dir = os.path.isdir(rm_path) and not os.path.islink(rm_path) self._engine.LOG.debug("Deleting '%s%s'" % (rm_path, '/' if is_dir else '')) if not dry_run: try: (os.rmdir if is_dir else os.remove)(rm_path) except OSError as exc: if exc.errno == errno.ENOENT: # Seems this disappeared somehow inbetween (race condition) self._engine.LOG.info("Path '%s%s' disappeared before it could be deleted" % (rm_path, '/' if is_dir else '')) else: raise return rm_paths # Assemble doomed files and directories files, dirs = set(), set() base_path = os.path.expanduser(self.directory) item_files = list(self._get_files(attrs=attrs)) if not self.directory: raise error.EngineError("Directory for item #%s is empty," " you might want to add a filter 'directory=!'" % (self._fields["hash"],)) if not os.path.isabs(base_path): raise error.EngineError("Directory '%s' for item #%s is not absolute, which is a bad idea;" " fix your .rtorrent.rc, and use 'directory.default.set = /...'" % (self.directory, self._fields["hash"],)) if self.fetch("=is_multi_file") and os.path.isdir(self.directory): dirs.add(self.directory) for item_file in item_files: if file_filter and not file_filter(item_file): continue #print repr(item_file) path = os.path.join(base_path, item_file.path) files.add(path) if '/' in item_file.path: dirs.add(os.path.dirname(path)) # Delete selected files if not dry_run: self.stop() for path in sorted(files): ##self._engine.LOG.debug("Deleting file '%s'" % (path,)) remove_with_links(path) # Prune empty directories (longer paths first) doomed = files | dirs for path in sorted(dirs, reverse=True): residue = set(os.listdir(path) if os.path.exists(path) else []) ignorable = set(i for i in residue if any(fnmatch.fnmatch(i, pat) for pat in config.waif_pattern_list) #or os.path.join(path, i) in doomed ) ##print "---", residue - ignorable if residue and residue != ignorable: self._engine.LOG.info("Keeping non-empty directory '%s' with %d %s%s!" % ( path, len(residue), "entry" if len(residue) == 1 else "entries", (" (%d ignorable)" % len(ignorable)) if ignorable else "", )) else: ##print "---", ignorable for waif in ignorable:# - doomed: waif = os.path.join(path, waif) self._engine.LOG.debug("Deleting waif '%s'" % (waif,)) if not dry_run: try: os.remove(waif) except EnvironmentError as exc: self._engine.LOG.warn("Problem deleting waif '%s' (%s)" % (waif, exc)) ##self._engine.LOG.debug("Deleting empty directory '%s'" % (path,)) doomed.update(remove_with_links(path)) # Delete item from engine if not dry_run: self.delete()
[docs] def flush(self): """ Write volatile data to disk. """ self._make_it_so("saving session data of", ["save_full_session"])
[docs]class RtorrentEngine(engine.TorrentEngine): """ The rTorrent backend proxy. """ # throttling config keys RTORRENT_RC_THROTTLE_KEYS = ("throttle_up", "throttle_down", "throttle_ip", ) # keys we read from rTorrent's configuration RTORRENT_RC_KEYS = ("scgi_local", "scgi_port", "log.execute") + RTORRENT_RC_THROTTLE_KEYS # mapping from new to old commands, and thus our config keys RTORRENT_RC_ALIASES = { "network.scgi.open_local": "scgi_local", "network.scgi.open_port": "scgi_port", #"log.execute": "", "throttle.up": "throttle_up", "throttle.down": "throttle_down", "throttle.ip": "throttle_ip", } # rTorrent names of fields that never change CONSTANT_FIELDS = set(( "hash", "name", "is_private", "is_multi_file", "tracker_size", "size_bytes", )) # rTorrent names of fields that need to be pre-fetched CORE_FIELDS = CONSTANT_FIELDS | set(( "complete", "tied_to_file", )) # rTorrent names of fields that get fetched in multi-call PREFETCH_FIELDS = CORE_FIELDS | set(( "is_open", "is_active", "ratio", "up_rate", "up_total", "down_rate", "down_total", "base_path", "custom=m_alias", "custom=tm_completed", "custom=tm_loaded", "custom=tm_started", )) # mapping of our names to rTorrent names (only those that differ) PYRO2RT_MAPPING = dict( is_complete="complete", is_ignored="ignore_commands", down="down_rate", up="up_rate", path="base_path", metafile="tied_to_file", size="size_bytes", prio="priority", throttle="throttle_name", custom_m_alias="custom=m_alias", custom_tm_completed="custom=tm_completed", custom_tm_loaded="custom=tm_loaded", custom_tm_started="custom=tm_started", ) # inverse mapping of rTorrent names to ours RT2PYRO_MAPPING = dict((v, k) for k, v in PYRO2RT_MAPPING.items()) def __init__(self): """ Initialize proxy. """ super(RtorrentEngine, self).__init__() self.versions = (None, None) self.startup = time.time() self._rpc = None self._session_dir = None self._download_dir = None self._item_cache = {}
[docs] def load_config(self, namespace=None, rcfile=None): """ Load file given in "rcfile". """ def cfgkey(key): "Sanitize rtorrent config keys" return key.replace('.', '_') if namespace is None: namespace = config # Only load when needed (also prevents multiple loading) if not all(getattr(namespace, key, False) for key in self.RTORRENT_RC_KEYS): # 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,)) 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) # Copy values we're interested in if key in self.RTORRENT_RC_THROTTLE_KEYS: val = val.split(',')[0].strip() self.LOG.debug("rtorrent.rc: added throttle %r" % (val,)) namespace.throttle_names.add(val) elif key in self.RTORRENT_RC_KEYS and not getattr(namespace, cfgkey(key), None): self.LOG.debug("rtorrent.rc: %s = %s" % (key, val)) setattr(namespace, cfgkey(key), val) # Validate fields for key in self.RTORRENT_RC_KEYS: key = cfgkey(key) setattr(namespace, key, load_config.validate(key, getattr(namespace, key, None))) if config.scgi_local and config.scgi_local.startswith("/"): config.scgi_local = "scgi://" + config.scgi_local if config.scgi_port and not config.scgi_port.startswith("scgi://"): config.scgi_port = "scgi://" + config.scgi_port # Prefer UNIX domain sockets over TCP sockets config.scgi_url = config.scgi_local or config.scgi_port
def __repr__(self): """ Return a representation of internal state. """ if self._rpc: # Connected state return "%s connected to %s [%s, up %s] via %r" % ( self.__class__.__name__, self.engine_id, self.engine_software, fmt.human_duration(self.uptime, 0, 2, True).strip(), config.scgi_url, ) else: # Unconnected state self.load_config() return "%s connectable via %r" % ( self.__class__.__name__, config.scgi_url, ) @property def uptime(self): """ rTorrent's uptime. """ return time.time() - self.startup def _resolve_viewname(self, viewname): """ Check for special view names and return existing rTorrent one. """ if viewname == "-": try: # Only works with rTorrent-PS at this time! viewname = self.open().ui.current_view() except xmlrpc.ERRORS as exc: raise error.EngineError("Can't get name of current view: %s" % (exc)) return viewname
[docs] def open(self): """ Open connection. """ # Only connect once if self._rpc is not None: return self._rpc # Get connection URL from rtorrent.rc self.load_config() # Reading abilities are on the downfall, so... if not config.scgi_url: raise error.UserError("You need to configure a XMLRPC connection, read" " https://pyrocore.readthedocs.io/en/latest/setup.html") # Connect and get instance ID (also ensures we're connectable) self._rpc = xmlrpc.RTorrentProxy(config.scgi_url) self.versions, self.version_info = self._rpc._set_mappings() self.engine_id = self._rpc.session.name() time_usec = self._rpc.system.time_usec() # Make sure xmlrpc-c works as expected if time_usec < 2**32: self.LOG.warn("Your xmlrpc-c is broken (64 bit integer support missing," " %r returned instead)" % (type(time_usec),)) # Get other manifest values self.engine_software = "rTorrent %s/%s" % self.versions if "+ssh:" in config.scgi_url: self.startup = int(self._rpc.startup_time() or time.time()) else: self._session_dir = self._rpc.session.path() if not self._session_dir: raise error.UserError("You need a session directory, read" " https://pyrocore.readthedocs.io/en/latest/setup.html") if not os.path.exists(self._session_dir): raise error.UserError("Non-existing session directory %r" % self._session_dir) self._download_dir = os.path.expanduser(self._rpc.directory.default()) if not os.path.exists(self._download_dir): raise error.UserError("Non-existing download directory %r" % self._download_dir) self.startup = os.path.getmtime(os.path.join(self._session_dir, "rtorrent.lock")) # Return connection self.LOG.debug(repr(self)) return self._rpc
[docs] def log(self, msg): """ Log a message in the torrent client. """ self.open().log(0, msg)
[docs] def item(self, infohash, prefetch=None, cache=False): """ Fetch a single item by its info hash. """ return next(self.items(infohash, prefetch, cache))
[docs] def items(self, view=None, prefetch=None, cache=True): """ Get list of download items. @param view: Name of the view. @param prefetch: OPtional list of field names to fetch initially. @param cache: Cache items for the given view? """ # TODO: Cache should be by hash. # Then get the initial data when cache is empty, # else get a list of hashes from the view, make a diff # to what's in the cache, fetch the rest. Getting the # fields for one hash might be done by a special view # (filter: $d.hash == hashvalue) if view is None: view = engine.TorrentView(self, "default") elif isinstance(view, basestring): view = engine.TorrentView(self, self._resolve_viewname(view)) else: view.viewname = self._resolve_viewname(view.viewname) if not cache or view.viewname not in self._item_cache: # Map pyroscope names to rTorrent ones if prefetch: prefetch = self.CORE_FIELDS | set((self.PYRO2RT_MAPPING.get(i, i) for i in prefetch)) else: prefetch = self.PREFETCH_FIELDS # Fetch items items = [] try: # Prepare multi-call arguments args = ["d.%s%s" % ("" if field.startswith("is_") else "get_", field) for field in prefetch ] infohash = view._check_hash_view() if infohash: multi_call = self.open().system.multicall args = [dict(methodName=field.rsplit('=', 1)[0], params=[infohash] + (field.rsplit('=', 1)[1].split(',') if '=' in field else [])) for field in args] raw_items = [[i[0] for i in multi_call(args)]] else: multi_call = self.open().d.multicall args = [view.viewname] + [field if '=' in field else field + '=' for field in args] if view.matcher and int(config.fast_query): pre_filter = matching.unquote_pre_filter(view.matcher.pre_filter()) self.LOG.info("!!! pre-filter: {}".format(pre_filter or 'N/A')) if pre_filter: multi_call = self.open().d.multicall.filtered args.insert(1, pre_filter) raw_items = multi_call(*tuple(args)) ##self.LOG.debug("multicall %r" % (args,)) ##import pprint; self.LOG.debug(pprint.pformat(raw_items)) self.LOG.debug("Got %d items with %d attributes from %r [%s]" % ( len(raw_items), len(prefetch), self.engine_id, multi_call)) for item in raw_items: items.append(RtorrentItem(self, zip( [self.RT2PYRO_MAPPING.get(i, i) for i in prefetch], item ))) yield items[-1] except xmlrpc.ERRORS as exc: raise error.EngineError("While getting download items from %r: %s" % (self, exc)) # Everything yielded, store for next iteration if cache: self._item_cache[view.viewname] = items else: # Yield prefetched results for item in self._item_cache[view.viewname]: yield item
[docs] def show(self, items, view=None): """ Visualize a set of items (search result), and return the view name. """ proxy = self.open() view = self._resolve_viewname(view or "rtcontrol") # Add view if needed if view not in proxy.view.list(): proxy.view.add(xmlrpc.NOHASH, view) # Clear view and show it proxy.view.filter(xmlrpc.NOHASH, view, "false=") proxy.ui.current_view.set(view) # Add items # TODO: should be a "system.multicall" for item in items: proxy.view.set_visible(item.hash, view) return view
[docs]def run(): """ Module level test. """ logging.basicConfig(level=logging.DEBUG) load_config.ConfigLoader().load() config.debug = True print(repr(config.engine.item(sys.argv[1])))
if __name__ == "__main__": run()