Source code for torrentinfo

#!/usr/bin/env python
# -*- coding: utf-8 -*-

# This file is part of torrentinfo.
#
# Foobar 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.
#
# Foobar 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 torrentinfo.  If not, see <http://www.gnu.org/licenses/>.
"""
Parses .torrent files and displays various summaries of the
information contained within.

Published under the GNU Public License: http://www.gnu.org/copyleft/gpl.html
"""

import sys
import argparse
import os.path
import time

#  see pylint ticket #2481
from string import printable  # pylint: disable-msg=W0402

VERSION = '1.8.4'

[docs]class TextFormatter: """Class used to format strings before printing.""" NONE = 0x000000 NORMAL = 0x000001 BRIGHT = 0x000002 WHITE = 0x000004 GREEN = 0x000008 RED = 0x000010 CYAN = 0x000020 YELLOW = 0x000040 MAGENTA = 0x000080 DULL = 0x000100 escape = chr(0x1b) mapping = [(NORMAL, '[0m'), (BRIGHT, '[1m'), (DULL, '[22m'), (WHITE, '[37m'), (GREEN, '[32m'), (CYAN, '[36m'), (YELLOW, '[33m'), (RED, '[31m'), (MAGENTA, '[35m'), ] def __init__(self, colour): self.colour = colour
[docs] def string_format(self, format_spec, config, string=''): """Attaches colour codes to strings before outputting them. :param format_spec: value of the colour code :type format_spec: int :param string: string to colour :type string: str """ if self.colour: codestring = '' for name, code in TextFormatter.mapping: if format_spec & name: codestring += TextFormatter.escape + code config.out.write(codestring + string) else: config.out.write(string)
[docs]class Config: """Class storing configuration propagated throughout the program.""" def __init__(self, formatter, out=sys.stdout, err=sys.stderr, tab_char=' '): """Initialises a config class. :param formatter: formatter to use when printing :type formatter: TextFormatter :param out: default output destination :type out: file :param err: default error destination :type err: file :param tab_char: character to use as a tab :type tab_char: str """ self.formatter = formatter self.out = out self.err = err self.tab_char = tab_char
[docs]class Torrent(dict): """A class modelling a parsed torrent file.""" def __init__(self, filename, string_buffer): tmp_dict = decode(string_buffer) if type(tmp_dict) != dict: raise UnexpectedType(self.__class__, dict) super(Torrent, self).__init__(tmp_dict) self.filename = filename
[docs]class UnexpectedType(Exception): """Thrown when the torrent file is not just a single dictionary""" pass
[docs]class UnknownTypeChar(Exception): """Thrown when Torrent.parse encounters unexpected character""" pass
[docs]def dump_as_date(number, config): """Dumps out the Integer instance as a date. :param n: number to format :type n: int :param config: configuration object to use in this method :type config: Config """ config.formatter.string_format(TextFormatter.MAGENTA, config, time.strftime( '%Y/%m/%d %H:%M:%S %Z\n', time.gmtime(number)))
[docs]def dump_as_size(number, config, depth): """Dumps the string to the stdout as file size after formatting it. :param n: number to format :type n: int :param config: configuration object to use in this method :type config: Config :param depth: indentation depth :type depth: int """ size = float(number) sizes = ['B', 'KB', 'MB', 'GB'] while size >= 1024 and len(sizes) > 1: size /= 1024 sizes = sizes[1:] config.formatter.string_format(TextFormatter.CYAN, config, '%s%.1f%s\n' % ( config.tab_char * depth, size, sizes[0]))
[docs]def dump(item, config, depth, newline=True, as_utf_repr=False): """Printing method. :param item: item to print :type item: dict or list or str or int :param config: configuration object to use in this method :type config: Config :param depth: indentation depth :type depth: int :param newline: indicates whether to insert a newline after certain strings :type newline: bool :param as_utf_repr: indicates whether only ASCII should be printed :param as_utf_repr: bool """ def teq(comp_type): """Helper that checks for type equality.""" return type(item) == comp_type if teq(dict) or teq(Torrent): for key in sorted(item): config.formatter.string_format( TextFormatter.NORMAL | TextFormatter.GREEN, config) if depth < 2: config.formatter.string_format(TextFormatter.BRIGHT, config) dump(key, config, depth, as_utf_repr=as_utf_repr) config.formatter.string_format(TextFormatter.NORMAL, config) if key == 'pieces': dump(item[key], config, depth + 1, as_utf_repr=True) else: dump(item[key], config, depth + 1, as_utf_repr=as_utf_repr) elif teq(list): if len(item) == 1: dump(item[0], config, depth, as_utf_repr=as_utf_repr) else: for index in range(len(item)): config.formatter.string_format(TextFormatter.BRIGHT | TextFormatter.YELLOW, config, '%s%d\n' % (config.tab_char * depth, index)) config.formatter.string_format(TextFormatter.NORMAL, config) dump(item[index], config, depth + 1, as_utf_repr=as_utf_repr) elif teq(str): if is_ascii_only(item) or not as_utf_repr: str_output = '%s%s' % ( config.tab_char * depth, item) + ('\n' if newline else '') config.formatter.string_format(TextFormatter.NONE, config, str_output) else: str_output = '%s[%d UTF-8 Bytes]' % ( config.tab_char * depth, len(item)) + ('\n' if newline else '') config.formatter.string_format( TextFormatter.BRIGHT | TextFormatter.RED, config, str_output) elif teq(int): config.formatter.string_format( TextFormatter.CYAN, config, '%s%d\n' % (config.tab_char * depth, item)) else: config.err.write("Don't know how to print %s" % str(item)) sys.exit(1)
[docs]def decode(string_buffer): """Decodes a bencoded string. :param string_buffer: bencoded torrent file content buffer :type string_buffer: StringBuffer :returns: dict """ content_type = string_buffer.peek() if content_type == 'd': string_buffer.get(1) tmp_dict = dict() while string_buffer.peek() != 'e': key = string_buffer.get(int(string_buffer.get_upto(':'))) tmp_dict[key] = decode(string_buffer) string_buffer.get(1) return tmp_dict elif content_type == 'l': string_buffer.get(1) tmp_list = list() while string_buffer.peek() != 'e': tmp_list.append(decode(string_buffer)) string_buffer.get(1) return tmp_list elif content_type == 'i': string_buffer.get(1) return int(string_buffer.get_upto('e')) elif content_type in [str(x) for x in range(0, 10)]: return string_buffer.get(int(string_buffer.get_upto(':'))) raise UnknownTypeChar(content_type, string_buffer)
[docs]def load_torrent(filename): """Loads file contents from a torrent file :param filename: torrent file path :type filename: str: :returns: StringBuffer """ handle = open(filename, 'rb') read_handle = handle.read() # Python 2 → 3 compatibility hack if (type(read_handle) == str): return StringBuffer(read_handle) string = '' for i in read_handle: string += chr(i) return StringBuffer(string)
[docs]class StringBuffer: """String processing class.""" def __init__(self, string): """Creates an instance of StringBuffer. :param string: string to use to create the StringBuffer :type string: str """ self.string = string
[docs] def is_eof(self): """Checks whether we're at the end of the string. :returns: bool -- true if this instance reached end of line """ return len(self.string) == 0
[docs] def peek(self): """Peeks at the next character in the string. :returns: str -- next character of this instance :raises: `BufferOverrun` """ if self.is_eof(): raise StringBuffer.BufferOverrun(1) return self.string[0]
[docs] def get(self, length): """Gets certain amount of characters from the buffer. :param length: Number of characters to get from the buffer :type length: int :returns: str -- first `length` characters from the buffer :raises: BufferOverrun """ if length > len(self.string): raise StringBuffer.BufferOverrun(length - len(self.string)) segment, self.string = self.string[:length], self.string[length:] return segment
[docs] def get_upto(self, character): """Gets all characters in a string until the specified one, exclusive. :param character: Character until which the string should be collected :type character: str :returns: str -- collected string from the buffer up to `character` :raises: CharacterExpected """ string_buffer = '' while not self.is_eof(): next_char = self.get(1) if next_char == character: return string_buffer string_buffer += next_char raise StringBuffer.CharacterExpected(character)
[docs] class BufferOverrun (Exception): """Raised when the buffer goes past EOF.""" pass
[docs] class CharacterExpected (Exception): """Raised when the buffer doesn't find the expected character.""" pass
[docs]def get_arg_parser(): """Parses command-line arguments. :returns: ArgumentParser """ parser = argparse.ArgumentParser(description='Print information ' + 'about torrent files') parser.add_argument('-v', '--version', action='version', version='torrentinfo %s' % VERSION, help='Print version and quit') group = parser.add_mutually_exclusive_group() group.add_argument('-t', '--top', dest='top', action='store_true', help='Only show top level file/directory') group.add_argument('-f', '--files', dest='files', action='store_true', help='Show files within the torrent') group.add_argument('-d', '--detailed', dest='detailed', action='store_true', help='Print more information about the files') group.add_argument('-e', '--everything', dest='everything', action='store_true', help='Print everything we can about the torrent') parser.add_argument('-a', '--ascii', dest='ascii', action='store_true', help='Only print out ascii') parser.add_argument('-n', '--nocolour', dest='nocolour', action='store_true', help='No ANSI colour') parser.add_argument('filename', type=str, metavar='filename', nargs='+', help='Torrent files to process') return parser
[docs]def start_line(config, prefix, depth, postfix='', format_spec=TextFormatter.NORMAL): """Print the first line during information output. :param config: configuration object to use in this method :type config: Config :param prefix: prefix to insert in front of the line :type prefix: str :param depth: indentation depth :type depth: int :param postfix: postfix to insert at the back of the line :type postfix: str :param format_spec: default colour to use for the text :type format_spec: int """ config.formatter.string_format(TextFormatter.BRIGHT | TextFormatter.GREEN, config, '%s%s' % (config.tab_char * depth, prefix)) config.formatter.string_format(format_spec, config, '%s%s' % (config.tab_char, postfix))
[docs]def get_line(config, prefix, key, torrent, is_date=False): """Print lines from a torrent instance. :param config: configuration object to use in this method :type config: Config :param prefix: prefix to insert in front of the line :type prefix: str :param key: key name in the torrent to print out :type key: str :param torrent: torrent instance to use for information :type torrent: Torrent :param depth: indentation depth :type depth: int :param is_date: indicates whether the line is a date :type is_date: bool :param format_spec: default colour to use for the text :type format_spec: int """ start_line(config, prefix, 1, format_spec=TextFormatter.NORMAL) if key in torrent: if is_date: if type(torrent[key]) == int: dump_as_date(torrent[key], config) else: config.formatter.string_format(TextFormatter.BRIGHT | TextFormatter.RED, config, '[Not An Integer]') else: local_config = Config(config.formatter, out=config.out, err=config.err, tab_char = '') dump(torrent[key], local_config, 0) else: config.formatter.string_format(TextFormatter.NORMAL, config, '\n')
[docs]def is_ascii_only(string): """Checks whether a string is ascii only. :param string: string to check :type string: str :returns: bool """ is_ascii = True for char in string: if char not in printable: is_ascii = False break return is_ascii
[docs]def basic(config, torrent): """Prints out basic information about a Torrent instance. :param config: configuration object to use in this method :type config: Config :param torrent: torrent instance to use for information :type torrent: Torrent """ if not 'info' in torrent: config.err.write('Missing "info" section in %s' % torrent.filename) sys.exit(1) get_line(config, 'name ', 'name', torrent['info']) get_line(config, 'comment ', 'comment', torrent) get_line(config, 'tracker url', 'announce', torrent) get_line(config, 'created by ', 'created by', torrent) get_line(config, 'created on ', 'creation date', torrent, is_date=True)
[docs]def top(config, torrent): """Prints out the top file/directory name as well as torrent file name. :param config: configuration object to use in this method :type config: Config :param torrent: torrent instance to use for information :type torrent: Torrent """ if not 'info' in torrent: config.err.write('Missing "info" section in %s' % torrent.filename) sys.exit(1) local_config = Config(config.formatter, out=config.out, err=config.err, tab_char = '') dump(torrent['info']['name'], local_config, 1, newline=False)
[docs]def basic_files(config, torrent): """Prints out basic file information of a Torrent instance. :param config: configuration object to use in this method :type config: Config :param torrent: torrent instance to use for information :type torrent: Torrent """ if not 'info' in torrent: config.err.write('Missing "info" section in %s' % torrent.filename) sys.exit(1) local_config = Config(config.formatter, out=config.out, err=config.err, tab_char = '') if not 'files' in torrent['info']: get_line(config, 'file name ', 'name', torrent['info']) start_line(config, 'file size ', 1) dump_as_size(torrent['info']['length'], local_config, 0) else: filestorrent = torrent['info']['files'] numfiles = len(filestorrent) if numfiles > 1: start_line(config, 'num files ', 1, '%d\n' % numfiles) lengths = [filetorrent['length'] for filetorrent in filestorrent] start_line(config, 'total size ', 1) dump_as_size(sum(lengths), local_config, 0) else: get_line(config, 'file name ', 'path', filestorrent[0]) start_line(config, 'file size ', 1) dump_as_size(filestorrent[0]['length'], local_config, 0)
[docs]def list_files(config, torrent, detailed=False): """Prints out a list of files using a Torrent instance :param config: configuration object to use in this method :type config: Config :param torrent: torrent instance to use for information :type torrent: Torrent :param detailed: indicates whether to print more information about files :param detailed: bool """ if not 'info' in torrent: config.err.write('Missing "info" section in %s' % torrent.filename) sys.exit(1) start_line(config, 'files', 1, postfix='\n') if not 'files' in torrent['info']: config.formatter.string_format(TextFormatter.YELLOW | TextFormatter.BRIGHT, config, '%s%d' % (config.tab_char * 2, 0)) config.formatter.string_format(TextFormatter.NORMAL, config, '\n') dump(torrent['info']['name'], config, 3) dump_as_size(torrent['info']['length'], config, 3) else: filestorrent = torrent['info']['files'] for index in range(len(filestorrent)): config.formatter.string_format(TextFormatter.YELLOW | TextFormatter.BRIGHT, config, '%s%d' % (config.tab_char * 2, index)) config.formatter.string_format(TextFormatter.NORMAL, config, '\n') if detailed: for kwrd in filestorrent[index]: start_line(config, kwrd, 3, postfix='\n') dump(filestorrent[index][kwrd], config, 4) else: if type(filestorrent[index]['path']) == str: dump(filestorrent[index]['path'], config, 3) else: dump(os.path.join(*filestorrent[index]['path']), config, 3) dump_as_size(filestorrent[index]['length'], config, 3) if detailed: start_line(config, 'piece length', 1, postfix='\n') dump(torrent['info']['piece length'], config, 3) start_line(config, 'pieces', 1, postfix='\n') dump(torrent['info']['pieces'], config, 3, as_utf_repr=True)
[docs]def main(alt_args=None, out=sys.stdout, err=sys.stderr): """Main control flow function used to encapsulate initialisation.""" try: args = get_arg_parser().parse_args() if alt_args is None else alt_args formatter = TextFormatter(not args.nocolour) config = Config(formatter, out=out, err=err, tab_char=' ') for filename in args.filename: try: torrent = Torrent(filename, load_torrent(filename)) config.formatter.string_format(TextFormatter.BRIGHT, config, '%s\n' % os.path.basename( torrent.filename)) if args.everything: dump(torrent, config, 1) elif args.detailed: list_files(config, torrent, detailed=True) elif args.files: basic(config, torrent) list_files(config, torrent, detailed=False) elif args.top: top(config, torrent) else: basic(config, torrent) basic_files(config, torrent) config.formatter.string_format(TextFormatter.NORMAL, config, '\n') except UnknownTypeChar: err.write( 'Could not parse %s as a valid torrent file.\n' % filename) sys.exit(1) except KeyboardInterrupt: pass
if __name__ == "__main__": main()