# -*- coding: utf-8 -*-
# pylint: disable=I0011,W0212
""" RTorrent client proxy.
Copyright (c) 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 absolute_import
import sys
import time
import socket
import xmlrpclib
from pyrobase.io import xmlrpc2scgi
from pyrocore import config, error
from pyrocore.util import os, fmt, pymagic
NOHASH = '' # use named constant to make new-syntax commands with no hash easily searchable
[docs]class XmlRpcError(Exception):
"""Base class for XMLRPC protocol errors."""
def __init__(self, msg, *args):
Exception.__init__(self, msg, *args)
self.message = msg.format(*args)
self.faultString = self.message
self.faultCode = -500
def __str__(self):
return self.message
[docs]class HashNotFound(XmlRpcError):
"""Non-existing or disappeared hash."""
def __init__(self, msg, *args):
XmlRpcError.__init__(self, msg, *args)
self.faultCode = -404
# Currently, we don't have our own errors, so just copy it
ERRORS = (XmlRpcError,) + xmlrpc2scgi.ERRORS
[docs]class RTorrentMethod(object):
""" Collect attribute accesses to build the final method name.
"""
# Actually, many more methods might need a fake target added; but these are the ones we call...
NEEDS_FAKE_TARGET = set((
"ui.current_view.set",
"view_filter",
))
def __init__(self, proxy, method_name):
self._proxy = proxy
self._method_name = method_name
def __getattr__(self, attr):
""" Append attr to the existing method name.
"""
self._method_name += '.' + attr
return self
def __str__(self):
""" Return statistics for this call.
"""
return "out %s, in %s, took %.3fms/%.3fms" % (
fmt.human_size(self._outbound).strip(),
fmt.human_size(self._inbound).strip(),
self._net_latency * 1000.0,
self._latency * 1000.0,
)
def __call__(self, *args, **kwargs):
""" Execute the method call.
`raw_xml=True` returns the unparsed XML-RPC response.
`flatten=True` removes one nesting level in a result list (useful for multicalls).
"""
self._proxy._requests += 1
start = time.time()
raw_xml = kwargs.get("raw_xml", False)
flatten = kwargs.get("flatten", False)
fail_silently = kwargs.get("fail_silently", False)
try:
# Map multicall arguments
if not self._proxy._use_deprecated:
if self._method_name.endswith(".multicall") or self._method_name.endswith(".multicall.filtered"):
if self._method_name in ("d.multicall", "d.multicall.filtered"):
args = (0,) + args
if config.debug:
self._proxy.LOG.debug("BEFORE MAPPING: %r" % (args,))
if self._method_name == "system.multicall":
for call in args[0]:
call["methodName"] = self._proxy._map_call(call["methodName"])
else:
args = args[0:2] + tuple(self._proxy._map_call(i) for i in args[2:])
if config.debug:
self._proxy.LOG.debug("AFTER MAPPING: %r" % (args,))
elif self._method_name in self.NEEDS_FAKE_TARGET:
args = (0,) + args
# Prepare request
xmlreq = xmlrpclib.dumps(args, self._proxy._map_call(self._method_name))
##xmlreq = xmlreq.replace('\n', '')
self._outbound = len(xmlreq)
self._proxy._outbound += self._outbound
self._proxy._outbound_max = max(self._proxy._outbound_max, self._outbound)
if config.debug:
self._proxy.LOG.debug("XMLRPC raw request: %r" % xmlreq)
# Send it
scgi_req = xmlrpc2scgi.SCGIRequest(self._proxy._transport)
xmlresp = scgi_req.send(xmlreq)
self._inbound = len(xmlresp)
self._proxy._inbound += self._inbound
self._proxy._inbound_max = max(self._proxy._inbound_max, self._inbound)
self._net_latency = scgi_req.latency
self._proxy._net_latency += self._net_latency
# Return raw XML response?
if raw_xml:
return xmlresp
# This fixes a bug with the Python xmlrpclib module
# (has no handler for <i8> in some versions)
xmlresp = xmlresp.replace("<i8>", "<i4>").replace("</i8>", "</i4>")
try:
# Deserialize data
result = xmlrpclib.loads(xmlresp)[0][0]
except (KeyboardInterrupt, SystemExit):
# Don't catch these
raise
except:
exc_type, exc = sys.exc_info()[:2]
if exc_type is xmlrpclib.Fault and exc.faultCode == -501 and exc.faultString == 'Could not find info-hash.':
raise HashNotFound("Unknown hash for {}({}) @ {}", self._method_name, args[0] if args else '', self._proxy._url)
if not fail_silently:
# Dump the bad packet, then re-raise
filename = "/tmp/xmlrpc2scgi-%s.xml" % os.getuid()
handle = open(filename, "w")
try:
handle.write("REQUEST\n")
handle.write(xmlreq)
handle.write("\nRESPONSE\n")
handle.write(xmlresp)
print >>sys.stderr, "INFO: Bad data packets written to %r" % filename
finally:
handle.close()
raise
else:
try:
return sum(result, []) if flatten else result
except TypeError:
if result and isinstance(result, list) and isinstance(result[0], dict) and 'faultCode' in result[0]:
raise error.LoggableError("XMLRPC error in multicall: " + repr(result[0]))
else:
raise
finally:
# Calculate latency
self._latency = time.time() - start
self._proxy._latency += self._latency
if config.debug:
self._proxy.LOG.debug("%s(%s) took %.3f secs" % (
self._method_name,
", ".join(repr(i) for i in args),
self._latency
))
[docs]class RTorrentProxy(object):
""" Proxy to rTorrent's XMLRPC interface.
Method calls are built from attribute accesses, i.e. you can do
something like C{proxy.system.client_version()}.
"""
def __init__(self, url, mapping=None):
self.LOG = pymagic.get_class_logger(self)
self._url = os.path.expandvars(url)
try:
self._transport = xmlrpc2scgi.transport_from_url(self._url)
except socket.gaierror as exc:
raise XmlRpcError("Bad XMLRPC URL {0}: {1}", self._url, exc)
self._versions = ("", "")
self._version_info = ()
self._use_deprecated = True
self._mapping = mapping or config.xmlrpc
self._fix_mappings()
# Statistics (traffic w/o HTTP overhead)
self._requests = 0
self._outbound = 0
self._outbound_max = 0
self._inbound = 0
self._inbound_max = 0
self._latency = 0.0
self._net_latency = 0.0
def __str__(self):
""" Return statistics.
"""
return "%d req, out %s [%s max], in %s [%s max], %.3fms/%.3fms avg latency" % (
self._requests,
fmt.human_size(self._outbound).strip(),
fmt.human_size(self._outbound_max).strip(),
fmt.human_size(self._inbound).strip(),
fmt.human_size(self._inbound_max).strip(),
self._net_latency * 1000.0 / self._requests,
self._latency * 1000.0 / self._requests,
)
def _set_mappings(self):
""" Set command mappings according to rTorrent version.
"""
try:
self._versions = (self.system.client_version(), self.system.library_version(),)
self._version_info = tuple(int(i) for i in self._versions[0].split('.'))
self._use_deprecated = self._version_info < (0, 8, 7)
# Merge mappings for this version
self._mapping = self._mapping.copy()
for key, val in sorted(i for i in vars(config).items() if i[0].startswith("xmlrpc_")):
map_version = tuple(int(i) for i in key.split('_')[1:])
if map_version <= self._version_info:
if config.debug:
self.LOG.debug("MAPPING for %r added: %r" % (map_version, val))
self._mapping.update(val)
self._fix_mappings()
except ERRORS as exc:
raise error.LoggableError("Can't connect to %s (%s)" % (self._url, exc))
return self._versions, self._version_info
def _fix_mappings(self):
""" Add computed stuff to mappings.
"""
self._mapping.update((key+'=', val+'=') for key, val in self._mapping.items() if not key.endswith('='))
if config.debug:
self.LOG.debug("CMD MAPPINGS ARE: %r" % (self._mapping,))
def _map_call(self, cmd):
""" Map old to new command names.
"""
if config.debug and cmd != self._mapping.get(cmd, cmd):
self.LOG.debug("MAP %s ==> %s" % (cmd, self._mapping[cmd]))
cmd = self._mapping.get(cmd, cmd)
# These we do by code, to avoid lengthy lists in the config
if not self._use_deprecated and any(cmd.startswith(i) for i in ("d.get_", "f.get_", "p.get_", "t.get_")):
cmd = cmd[:2] + cmd[6:]
return cmd
def __getattr__(self, attr):
""" Return a method object for accesses to virtual attributes.
"""
return RTorrentMethod(self, attr)
def __repr__(self):
""" Return info & statistics.
"""
return "%s(%r) [%s]" % (self.__class__.__name__, self._url, self)