# -*- coding: utf-8 -*-
# pylint: disable=
""" Perform raw XMLRPC calls.
Copyright (c) 2010 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, print_function
import io
import os
import re
import sys
import glob
import logging
import tempfile
import textwrap
import xmlrpclib
from pprint import pformat
try:
import requests
except ImportError:
requests = None
from pyrobase import bencode
from pyrobase.parts import Bunch
from pyrocore import config, error
from pyrocore.util import fmt, xmlrpc
from pyrocore.scripts.base import ScriptBase, ScriptBaseWithConfig
[docs]def read_blob(arg):
"""Read a BLOB from given ``@arg``."""
result = None
if arg == '@-':
result = sys.stdin.read()
elif any(arg.startswith('@{}://'.format(x)) for x in {'http', 'https', 'ftp', 'file'}):
if not requests:
raise error.UserError("You must 'pip install requests' to support @URL arguments.")
try:
response = requests.get(arg[1:])
response.raise_for_status()
result = response.content
except requests.RequestException as exc:
raise error.UserError(str(exc))
else:
try:
with open(os.path.expanduser(arg[1:]), 'rb') as handle:
result = handle.read()
except IOError as exc:
raise error.UserError('While reading @blob argument: {}'.format(exc))
return result
[docs]class RtorrentXmlRpc(ScriptBaseWithConfig):
### Keep things wrapped to fit under this comment... ##############################
"""
Perform raw rTorrent XMLRPC calls, like "rtxmlrpc throttle.global_up.max_rate".
To enter a XMLRPC REPL, pass no arguments at all.
Start arguments with "+" or "-" to indicate they're numbers (type i4 or i8).
Use "[1,2,..." for arrays. Use "@" to indicate binary data, which can be
followed by a file path (e.g. "@/path/to/file"), a URL (https, http, ftp,
and file are supported), or '-' to read from stdin.
"""
# log level for user-visible standard logging
STD_LOG_LEVEL = logging.DEBUG
# argument description for the usage information
ARGS_HELP = (
"<method> <args>..."
" |\n -i <commands>... | -i @<filename> | -i @-"
" |\n --session <session-file>... | --session <directory>"
" |\n --session @<filename-list> | --session @-"
)
[docs] def add_options(self):
""" Add program options.
"""
super(RtorrentXmlRpc, self).add_options()
# basic options
self.add_bool_option("-r", "--repr", help="show Python pretty-printed response")
self.add_bool_option("-x", "--xml", help="show XML response")
self.add_bool_option("-i", "--as-import",
help="execute each argument as a private command using 'import'")
self.add_bool_option("--session", "--restore",
help="restore session state from .rtorrent session file(s)")
# TODO: Tempita with "result" object in namespace
#self.add_value_option("-o", "--output-format", "FORMAT",
# help="pass result to a template for formatting")
self.proxy = None
[docs] def open(self):
"""Open connection and return proxy."""
if not self.proxy:
if not config.scgi_url:
config.engine.load_config()
if not config.scgi_url:
self.LOG.error("You need to configure a XMLRPC connection, read"
" https://pyrocore.readthedocs.io/en/latest/setup.html")
self.proxy = xmlrpc.RTorrentProxy(config.scgi_url)
self.proxy._set_mappings()
return self.proxy
[docs] def cooked(self, raw_args):
"""Return interpreted / typed list of args."""
args = []
for arg in raw_args:
# TODO: use the xmlrpc-c type indicators instead / additionally
if arg and arg[0] in "+-":
try:
arg = int(arg, 10)
except (ValueError, TypeError), exc:
self.LOG.warn("Not a valid number: %r (%s)" % (arg, exc))
elif arg.startswith('[['): # escaping, not a list
arg = arg[1:]
elif arg == '[]':
arg = []
elif arg.startswith('['):
arg = arg[1:].split(',')
if all(i.isdigit() for i in arg):
arg = [int(i, 10) for i in arg]
elif arg.startswith('@'):
arg = xmlrpclib.Binary(read_blob(arg))
args.append(arg)
return args
[docs] def execute(self, proxy, method, args):
"""Execute given XMLRPC call."""
try:
result = getattr(proxy, method)(raw_xml=self.options.xml, *tuple(args))
except xmlrpc.ERRORS as exc:
self.LOG.error("While calling %s(%s): %s" % (method, ", ".join(repr(i) for i in args), exc))
self.return_code = error.EX_NOINPUT if "not find" in getattr(exc, "faultString", "") else error.EX_DATAERR
else:
if not self.options.quiet:
if self.options.repr:
# Pretty-print if requested, or it's a collection and not a scalar
result = pformat(result)
elif hasattr(result, "__iter__"):
result = '\n'.join(i if isinstance(i, basestring) else pformat(i) for i in result)
print(fmt.to_console(result))
[docs] def repl_usage(self):
"""Print a short REPL usage summary."""
print(textwrap.dedent("""
rTorrent XMLRPC REPL Help Summary
=================================
? / help Show this help text.
Ctrl-D Exit the REPL and show call stats.
stats Show current call stats.
cmd=arg1,arg2,.. Call a XMLRPC command.
""".strip('\n')))
[docs] def do_repl(self):
"""REPL for rTorrent XMLRPC commands."""
from prompt_toolkit import prompt
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.contrib.completers import WordCompleter
self.options.quiet = False
proxy = self.open()
ps1 = proxy.session.name() + u'> '
words = ['help', 'stats', 'exit']
words += [x + '=' for x in proxy.system.listMethods()]
history_file = os.path.join(config.config_dir, '.rtxmlrpc_history')
while True:
try:
try:
cmd = prompt(ps1, completer=WordCompleter(words),
auto_suggest=AutoSuggestFromHistory(),
history=FileHistory(history_file))
except KeyboardInterrupt:
cmd = ''
if not cmd:
print("Enter '?' or 'help' for usage information, 'Ctrl-D' to exit.")
if cmd in {'?', 'help'}:
self.repl_usage()
continue
elif cmd in {'', 'stats'}:
print(repr(proxy).split(None, 1)[1])
continue
elif cmd in {'exit'}:
raise EOFError()
try:
method, raw_args = cmd.split('=', 1)
except ValueError:
print("ERROR: '=' not found")
continue
raw_args = raw_args.split(',')
args = self.cooked(raw_args)
self.execute(proxy, method, args)
except EOFError:
print('Bye from {!r}'.format(proxy))
break
[docs] def do_import(self):
"""Handle import files or streams passed with '-i'."""
tmp_import = None
try:
if self.args[0].startswith('@') and self.args[0] != '@-':
import_file = os.path.expanduser(self.args[0][1:])
if not os.path.isfile(import_file):
self.parser.error("File not found (or not a file): {}".format(import_file))
args = (xmlrpc.NOHASH, os.path.abspath(import_file))
else:
script_text = '\n'.join(self.args + [''])
if script_text == '@-\n':
script_text = sys.stdin.read()
with tempfile.NamedTemporaryFile(suffix='.rc', prefix='rtxmlrpc-', delete=False) as handle:
handle.write(script_text)
tmp_import = handle.name
args = (xmlrpc.NOHASH, tmp_import)
self.execute(self.open(), 'import', args)
finally:
if tmp_import and os.path.exists(tmp_import):
os.remove(tmp_import)
[docs] def do_command(self):
"""Call a single command with arguments."""
method = self.args[0]
raw_args = self.args[1:]
if '=' in method:
if raw_args:
self.parser.error("Please don't mix rTorrent and shell argument styles!")
method, raw_args = method.split('=', 1)
raw_args = raw_args.split(',')
self.execute(self.open(), method, self.cooked(raw_args))
[docs] 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:
NO public "set" command! 'timestamp.finished': 1503012786,
NO public "set" command! 'timestamp.started': 1503012784,
NO public "set" command! 'total_uploaded': 0,
"""
[docs] def mainloop(self):
""" The main loop.
"""
self.check_for_connection(maxpos=1)
# Enter REPL if no args
if len(self.args) < 1:
return self.do_repl()
# Check for bad options
if self.options.repr and self.options.xml:
self.parser.error("You cannot combine --repr and --xml!")
if sum([self.options.as_import, self.options.session]) > 1:
self.parser.error("You cannot combine -i and --session!")
# Dispatch to handlers
if self.options.as_import:
self.do_import()
elif self.options.session:
self.do_session()
else:
self.do_command()
# XMLRPC stats
self.LOG.debug("XMLRPC stats: %s" % self.open())
[docs]def run(): #pragma: no cover
""" The entry point.
"""
ScriptBase.setup()
RtorrentXmlRpc().run()
if __name__ == "__main__":
run()