PK!]&&thorod/__about__.py__all__ = [ '__author__', '__author_email__', '__copyright__', '__license__', '__summary__', '__title__', '__url__', '__version__', '__version_info__' ] __title__ = 'thorod' __summary__ = 'A CLI utility for torrent creation and manipulation.' __url__ = 'https://github.com/thebigmunch/thorod' __version__ = '1.1.0' __version_info__ = tuple(int(i) for i in __version__.split('.') if i.isdigit()) __author__ = 'thebigmunch' __author_email__ = 'mail@thebigmunch.me' __license__ = 'MIT' __copyright__ = f'2018 {__author__} <{__author_email__}>' PK!BZZthorod/__init__.py"""A set of useful torrent scripts.""" from .__about__ import ( __author__, __author_email__, __copyright__, __license__, __summary__, __title__, __url__, __version__, __version_info__ ) __all__ = [ '__author__', '__author_email__', '__copyright__', '__license__', '__summary__', '__title__', '__url__', '__version__', '__version_info__' ] PK!)VVthorod/__main__.py#!/usr/bin/env python3 from .cli import thorod if __name__ == '__main__': thorod() PK![7thorod/bencode.py"""A simple bencoding implementation with standard API.""" from io import SEEK_CUR, BytesIO def _bytes(data): return bytes(str(data), 'utf8') def _str(data): try: return data.decode("utf8") except UnicodeDecodeError: return data def _read_until(data, end=b'e'): buffer = bytearray() d = data.read(1) while d != end: buffer += d d = data.read(1) return buffer def _bdecode_dict(data): result = {} key = _bdecode(data) while key: result[key] = _bdecode(data) key = _bdecode(data) return result def _bdecode_int(data): return int(_read_until(data)) def _bdecode_list(data): result = [] item = _bdecode(data) while item: result.append(item) item = _bdecode(data) return result def _bdecode_str(data): length = int(_read_until(data, b':')) item = data.read(length) return _str(item) def _bdecode(data): type_char = data.read(1) if type_char == b'i': return _bdecode_int(data) elif type_char.isdigit(): data.seek(-1, SEEK_CUR) return _bdecode_str(data) elif type_char == b'l': return _bdecode_list(data) elif type_char == b'd': return _bdecode_dict(data) def _bencode(data): if isinstance(data, int): return _bytes(f'i{data}e') elif isinstance(data, str): length = len(_bytes(data)) return _bytes(f'{length}:{data}') elif isinstance(data, (bytes, bytearray)): return _bytes(len(data)) + b':' + data elif isinstance(data, list): return b'l' + b''.join(_bencode(d) for d in data) + b'e' elif isinstance(data, dict): enc_dict = bytes() for key in sorted(data): enc_dict += _bencode(key) + _bencode(data[key]) return b'd' + enc_dict + b'e' else: raise TypeError(f"{type(data)} is not a valid type for bencoding.") def dump(obj, fp): fp.write(_bencode(obj)) def dumps(obj): return _bencode(obj) def load(fp): return _bdecode(fp) def loads(data): if isinstance(data, str): data = data.encode() return _bdecode(BytesIO(data)) PK!h^8080 thorod/cli.py"""Command line interface of thorod.""" import math import os import click import colorama import crayons import pendulum from click_default_group import DefaultGroup from . import __title__, __version__ from .config import ABBRS, CONFIG_FILE, DEFAULT_ABBRS, get_config, write_config_file from .constants import CYGPATH_RE, PIECE_SIZES, PIECE_SIZE_STRINGS from .core import ( create_dir_info_dict, create_file_info_dict, generate_magnet_link, read_torrent_file, write_torrent_file ) from .utils import ( calculate_data_size, calculate_piece_size, calculate_torrent_size, convert_cygwin_path, generate_unique_string, get_files, hash_info_dict, humanize_size ) colorama.init() # I use Windows Python install from Cygwin or other Unix-like environments on Windows. # This custom click type converts Unix-style paths to Windows-style paths in these cases. class CustomPath(click.Path): def convert(self, value, param, ctx): if os.name == 'nt' and CYGPATH_RE.match(value): value = convert_cygwin_path(value) return super().convert(value, param, ctx) def is_torrent_file(ctx, param, value): if not value.endswith('.torrent'): click.confirm( f"Is '{value}' a torrent file?", abort=True ) return value def is_usable_abbr(ctx, param, value): if value in DEFAULT_ABBRS: raise click.BadParameter( f"'{value}' is a default abbreviation. Please choose another.", ctx=ctx, param=param ) return value def output_abbreviations(conf): def abbr_list(abbrs): lines = [] for abbr, tracker in abbrs.items(): if isinstance(tracker, list): line = f'{crayons.cyan(abbr)}: ' + '\n'.ljust(23).join(crayons.magenta(track) for track in tracker) else: line = f'{crayons.cyan(abbr)}: {crayons.magenta(tracker)}' lines.append(line) return '\n'.ljust(17).join(lines) auto_abbrs = abbr_list({'open': 'All default trackers in a random tiered order.', 'random': 'A single random default tracker.'}) default_abbrs = abbr_list({abbr: tracker for abbr, tracker in DEFAULT_ABBRS.items() if abbr not in ['open', 'random']}) user_abbrs = abbr_list(conf['trackers']) summary = ( f"\n" f"{crayons.yellow('Config File')}: {crayons.cyan(CONFIG_FILE)}\n\n" f"{crayons.yellow('Auto')}: {auto_abbrs}\n\n" f"{crayons.yellow('Default')}: {default_abbrs}\n\n" f"{crayons.yellow('User')}: {user_abbrs}" ) click.echo(summary) def output_summary(torrent_info, torrent_file, show_files=False): info_hash = hash_info_dict(torrent_info['info']) private = 'Yes' if torrent_info['info'].get('private') == 1 else 'No' if 'announce-list' in torrent_info: announce_list = torrent_info['announce-list'] else: announce_list = [[torrent_info['announce']]] tracker_list = '\n\n'.ljust(18).join('\n'.ljust(17).join(tracker for tracker in tier) for tier in announce_list) data_size = calculate_torrent_size(torrent_info) piece_size = torrent_info['info']['piece length'] piece_count = math.ceil(data_size / piece_size) tz = pendulum.tz.local_timezone() creation_date = pendulum.from_timestamp(torrent_info['creation date'], tz).format('YYYY-MM-DD HH:mm:ss Z') created_by = torrent_info.get('created by', '') comment = torrent_info.get('comment', '') source = torrent_info.get('source', '') magnet_link = generate_magnet_link(torrent_info, torrent_file) summary = ( f"\n" f"{crayons.yellow('Info Hash')}: {crayons.cyan(info_hash)}\n" f"{crayons.yellow('Torrent Name')}: {crayons.cyan(torrent_file)}\n" f"{crayons.yellow('Data Size')}: {crayons.cyan(humanize_size(data_size, precision=2))}\n" f"{crayons.yellow('Piece Size')}: {crayons.cyan(humanize_size(piece_size))}\n" f"{crayons.yellow('Piece Count')}: {crayons.cyan(piece_count)}\n" f"{crayons.yellow('Private')}: {crayons.cyan(private)}\n" f"{crayons.yellow('Creation Date')}: {crayons.cyan(creation_date)}\n" f"{crayons.yellow('Created By')}: {crayons.cyan(created_by)}\n" f"{crayons.yellow('Comment')}: {crayons.cyan(comment)}\n" f"{crayons.yellow('Source')}: {crayons.cyan(source)}\n" f"{crayons.yellow('Trackers')}: {crayons.cyan(tracker_list)}\n\n" f"{crayons.yellow('Magnet')}: {crayons.cyan(magnet_link)}" ) if show_files: file_infos = [] if 'files' in torrent_info['info']: for f in torrent_info['info']['files']: file_infos.append((humanize_size(f['length'], precision=2), os.path.join(*f['path']))) else: file_infos.append((humanize_size(torrent_info['info']['length'], precision=2), torrent_info['info']['name'])) pad = len(max([size for size, _ in file_infos], key=len)) summary += f"\n\n{crayons.yellow('Files')}:\n\n" for size, path in file_infos: summary += f" {crayons.white(f'{size:<{pad}}')} {crayons.green(path)}\n" click.echo(summary) def replace_abbreviations(ctx, param, value): announce_list = [] def process_trackers(trackers): tier_list = [] for item in trackers: if isinstance(item, list): process_trackers(item) elif item == 'open': for tracker in ABBRS['open']: announce_list.append([tracker]) else: tier_list.append(ABBRS.get(item, item)) if tier_list: announce_list.append(tier_list) process_trackers([tier.split('^') for tier in value]) return announce_list CONTEXT_SETTINGS = dict(max_content_width=100, help_option_names=['-h', '--help']) @click.group(cls=DefaultGroup, default='torrent', context_settings=CONTEXT_SETTINGS) @click.version_option(__version__, '-V', '--version', prog_name=__title__, message="%(prog)s %(version)s") def thorod(): """Collection of torrent creation utilities.""" pass @thorod.command() @click.option('--show-files', is_flag=True, default=False, help="Show list of files in the torrent.") @click.argument('torrent_file', type=CustomPath(exists=True), callback=is_torrent_file) def info(show_files, torrent_file): """Output information about a torrent file.""" torrent_info = read_torrent_file(torrent_file) output_summary(torrent_info, torrent_file, show_files=show_files) @thorod.command() @click.argument('torrent_file', type=CustomPath(exists=True), callback=is_torrent_file) def magnet(torrent_file): """Generate a magnet link from a torrent file.""" torrent_info = read_torrent_file(torrent_file) magnet_link = generate_magnet_link(torrent_info, torrent_file) output = f"\nMagnet: {magnet_link}" click.echo(output) @thorod.command() @click.option( '--created-by', metavar='CREATOR', default=f'{__title__} {__version__}', help=f"Set created by field.\nDefaults to {__title__} {__version__}." ) @click.option('-c', '--comment', metavar='COMMENT', help="Set comment field.") @click.option('-s', '--source', metavar='SOURCE', help="Set source field.") @click.option('-p/-P', '--private/--public', is_flag=True, default=False, help="Set private flag.") @click.option( '--piece-size', metavar='SIZE', default='auto', type=click.Choice(PIECE_SIZE_STRINGS), help=f"Set piece size. Defaults to 'auto'.\n({', '.join(PIECE_SIZE_STRINGS)})" ) @click.option( '-o', '--output', metavar='NAME', help="Set name of torrent file.\nDefaults to input file or directory name." ) @click.option('--md5', is_flag=True, default=False, help="") @click.option( '--max-depth', metavar='DEPTH', type=int, help="Set maximum depth of recursion when scanning for files.\nDefault is infinite recursion." ) @click.option('--show-files', is_flag=True, default=False, help="Show list of files in the summary.") @click.option('--show-progress/--hide-progress', is_flag=True, default=True, help="Show/hide hashing progress bar.") @click.argument('input-path', type=CustomPath(exists=True), required=True) @click.argument('trackers', nargs=-1, callback=replace_abbreviations, required=True) def torrent( created_by, comment, source, private, piece_size, output, md5, max_depth, show_files, show_progress, input_path, trackers): """Create a torrent file. Tracker tiers are separated by a space. Trackers on the same tier should be quoted and separated with a carat (^) Example: 'tracker1^tracker2' tracker3 """ if max_depth is None: max_depth = float('inf') files = list(get_files(input_path, max_depth)) data_size = calculate_data_size(files) if piece_size == 'auto': piece_size = calculate_piece_size(data_size) else: piece_size = PIECE_SIZES[piece_size] torrent_info = {} if os.path.isdir(input_path): info_dict = create_dir_info_dict(files, data_size, piece_size, private, source, md5, show_progress=show_progress) elif os.path.isfile(input_path): info_dict = create_file_info_dict(files, data_size, piece_size, private, source, md5, show_progress=show_progress) torrent_info['info'] = info_dict torrent_info['announce'] = trackers[0][0] if len(trackers) > 1 or len(trackers[0]) > 1: torrent_info['announce-list'] = trackers if created_by: torrent_info['created by'] = created_by if comment: torrent_info['comment'] = comment torrent_info['creation date'] = pendulum.now('utc').int_timestamp torrent_info['encoding'] = 'UTF-8' if output: torrent_file = output else: torrent_file = os.path.basename(os.path.abspath(input_path)) torrent_file += '.torrent' write_torrent_file(torrent_file, torrent_info) output_summary(torrent_info, torrent_file, show_files=show_files) @thorod.command() @click.option( '--created-by', metavar='CREATOR', default=f'{__title__} {__version__}', help=f"Set created by field.\nDefaults to {__title__} {__version__}." ) @click.option('-c', '--comment', metavar='COMMENT', help="Set comment field.") @click.option('-s', '--source', metavar='SOURCE', help="Set source field.") @click.option('-p/-P', '--private/--public', is_flag=True, default=None, help="Set private flag.") @click.option( '-o', '--output', metavar='NAME', help="Set name of torrent file.\nDefaults to input file or directory name." ) @click.argument('torrent_file', type=CustomPath(exists=True), callback=is_torrent_file, required=True) @click.argument('trackers', nargs=-1, callback=replace_abbreviations, required=True) def xseed(created_by, comment, source, private, output, torrent_file, trackers): """Copy a torrent for cross-seeding. Tracker tiers are separated by a space. Trackers on the same tier should be quoted and separated with a carat (^) Example: 'tracker1^tracker2' tracker3 """ torrent_info = read_torrent_file(torrent_file) if not isinstance(torrent_info, dict) or 'info' not in torrent_info: raise ValueError(f"{torrent_file} is not a valid torrent file.") torrent_info['info'].pop('source', None) for k in ['announce-list', 'comment']: torrent_info.pop(k, None) torrent_info['info']['salt'] = generate_unique_string() if private is not None: torrent_info['info']['private'] = 1 if private else 0 if source: torrent_info['info']['source'] = source torrent_info['announce'] = trackers[0][0] if len(trackers) > 1 or len(trackers[0]) > 1: torrent_info['announce-list'] = trackers if created_by: torrent_info['created by'] = created_by if comment: torrent_info['comment'] = comment torrent_info['creation date'] = pendulum.now('utc').int_timestamp torrent_info['encoding'] = 'UTF-8' if output: xseed_torrent = output + '.torrent' else: xseed_torrent = os.path.basename(torrent_file).replace('.torrent', '-xseed.torrent') write_torrent_file(xseed_torrent, torrent_info) output_summary(torrent_info, torrent_file) @thorod.group(cls=DefaultGroup, default='list', default_if_no_args=True) def abbrs(): """List/Add/Remove tracker abbreviations.""" pass @abbrs.command('list') def list_abbreviations(): """List tracker abbreviations.""" conf = get_config() output_abbreviations(conf) @abbrs.command('add') @click.argument('abbreviation', callback=is_usable_abbr, required=True) @click.argument('tracker', required=True) def add_abbreviation(abbreviation, tracker): """Add tracker abbreviation.""" conf = get_config() conf['trackers'][abbreviation] = tracker write_config_file(conf) output_abbreviations(conf) @abbrs.command('rem') @click.argument('abbreviations', nargs=-1) def remove_abbreviations(abbreviations): """Remove tracker abbreviations.""" conf = get_config() for abbreviation in abbreviations: conf['trackers'].pop(abbreviation, None) write_config_file(conf) output_abbreviations(conf) PK!|kmoothorod/config.pyimport os import random from collections import ChainMap import appdirs import toml from sortedcontainers import SortedDict from .__about__ import __author__, __title__ CONFIG_DIR = appdirs.user_config_dir(__title__, __author__) CONFIG_FILE = os.path.join(CONFIG_DIR, 'thorod.toml') DEFAULT_ABBRS = SortedDict({ 'coppersurfer': 'udp://tracker.coppersurfer.tk:6969/announce', 'demonii': 'udp://open.demonii.com:1337', 'desync': 'udp://exodus.desync.com:6969', 'explodie': 'udp://explodie.org:6969', 'internetwarriors': 'udp://tracker.internetwarriors.net:1337/announce', 'leechers-paradise': 'udp://tracker.leechers-paradise.org:6969/announce', 'mgtracker': 'udp://mgtracker.org:6969/announce', 'opentrackr': 'udp://tracker.opentrackr.org:1337', 'pirateparty': 'udp://tracker.pirateparty.gr:6969/announce', 'sktorrent': 'udp://tracker.sktorrent.net:6969/announce', 'zer0day': 'udp://tracker.zer0day.to:1337/announce' }) default_trackers = list(DEFAULT_ABBRS.values()) random.shuffle(default_trackers) DEFAULT_ABBRS['open'] = default_trackers DEFAULT_ABBRS['random'] = random.choice(default_trackers) def get_config(): config = read_config_file() return config def read_config_file(): try: with open(CONFIG_FILE) as conf: config = toml.load(conf, SortedDict) except FileNotFoundError: config = SortedDict() if 'trackers' not in config: config['trackers'] = SortedDict() write_config_file(config) return config def write_config_file(config): os.makedirs(CONFIG_DIR, exist_ok=True) with open(CONFIG_FILE, 'w') as conf: toml.dump(config, conf) ABBRS = ChainMap(DEFAULT_ABBRS, get_config()['trackers']) PK!ZLLthorod/constants.pyimport re CYGPATH_RE = re.compile("^(?:/[^/]+)*/?$") """Regex pattern matching UNIX-style filepaths.""" B = 1024 ** 0 KIB = 1024 ** 1 MIB = 1024 ** 2 GIB = 1024 ** 3 TIB = 1024 ** 4 SYMBOLS = [ (TIB, 'TiB'), (GIB, 'GiB'), (MIB, 'MiB'), (KIB, 'KiB'), (B, 'B') ] PIECE_SIZE_VALUES = [ 16 * KIB, 32 * KIB, 64 * KIB, 128 * KIB, 256 * KIB, 512 * KIB, 1 * MIB, 2 * MIB, 4 * MIB, 8 * MIB, 16 * MIB, 32 * MIB ] PIECE_SIZE_STRINGS = ['16k', '32k', '64k', '128k', '256k', '512k', '1m', '2m', '4m', '8m', '16m', '32m', 'auto'] PIECE_SIZES = dict(zip(PIECE_SIZE_STRINGS, PIECE_SIZE_VALUES)) PK!&=֩thorod/core.pyimport functools import os import random from hashlib import md5, sha1 from tqdm import tqdm from . import bencode from .utils import ( calculate_torrent_size, generate_unique_string, get_file_path, hash_info_dict, humanize_size ) tqdm.format_sizeof = functools.partial(humanize_size, precision=2) def create_dir_info_dict(files, data_size, piece_size, private, source, include_md5, show_progress=True): base_path = os.path.commonpath(files) info_dict = {} file_infos = [] data = bytes() pieces = bytes() if show_progress: print("\n") progress_bar = tqdm( total=data_size, unit='', unit_scale=True, leave=True, dynamic_ncols=True, bar_format='{percentage:3.0f}% |{bar}| {n_fmt}/{total_fmt} [{remaining} {rate_fmt}]' ) for file in files: file_dict = {} length = 0 md5sum = md5() if include_md5 else None with open(file, 'rb') as f: while True: piece = f.read(piece_size) if not piece: break length += len(piece) data += piece if len(data) >= piece_size: pieces += sha1(data[:piece_size]).digest() data = data[piece_size:] if include_md5: md5sum.update(piece) if show_progress: progress_bar.update(len(piece)) file_dict['length'] = length file_dict['path'] = get_file_path(file, base_path) if include_md5: file_dict['md5sum'] = md5sum.hexdigest() file_infos.append(file_dict) if show_progress: progress_bar.close() if len(data) > 0: pieces += sha1(data).digest() info_dict['files'] = file_infos info_dict['name'] = os.path.basename(base_path) info_dict['pieces'] = pieces info_dict['piece length'] = piece_size info_dict['salt'] = generate_unique_string() info_dict['private'] = 1 if private else 0 if source: info_dict['source'] = source return info_dict def create_file_info_dict(files, data_size, piece_size, private, source, include_md5, show_progress=True): info_dict = {} pieces = bytes() length = 0 md5sum = md5() if include_md5 else None if show_progress: print("\n") progress_bar = tqdm( total=data_size, unit='', unit_scale=True, leave=True, dynamic_ncols=True, bar_format='{percentage:3.0f}% |{bar}| {n_fmt}/{total_fmt} [{remaining} {rate_fmt}]' ) with open(files[0], 'rb') as f: while True: piece = f.read(piece_size) if not piece: break length += len(piece) pieces += sha1(piece).digest() if include_md5: md5sum.update(piece) if show_progress: progress_bar.update(len(piece)) if show_progress: progress_bar.close() info_dict['name'] = os.path.basename(files[0]) info_dict['length'] = length info_dict['pieces'] = pieces info_dict['piece length'] = piece_size info_dict['salt'] = generate_unique_string() info_dict['private'] = 1 if private else 0 if source: info_dict['source'] = source if include_md5: info_dict['md5sum'] = md5sum.hexdigest() return info_dict def generate_magnet_link(torrent_info, torrent_file): torrent_name = torrent_file.replace('.torrent', '') info_hash = hash_info_dict(torrent_info['info']) data_size = calculate_torrent_size(torrent_info) magnet_link = f'magnet:?dn={torrent_name}&xt=urn:btih:{info_hash}&xl={data_size}' if 'announce-list' in torrent_info: for tier in torrent_info['announce-list']: magnet_link += f'&tr={random.choice(tier)}' elif 'announce' in torrent_info: magnet_link += f'&tr={torrent_info["announce"]}' return magnet_link def read_torrent_file(filepath): try: with open(filepath, 'rb') as f: torrent_info = bencode.load(f) except FileNotFoundError: raise FileNotFoundError(f"{filepath!r} not found.") except TypeError: raise TypeError(f"Could not parse {filepath!r}.") return torrent_info def write_torrent_file(filepath, torrent_info): with open(filepath, 'wb') as f: bencode.dump(torrent_info, f) PK!H~. thorod/utils.pyimport os import random import string import subprocess from hashlib import sha1 from . import bencode from .constants import PIECE_SIZE_VALUES, SYMBOLS def calculate_data_size(files): """Calculate the total size of the input data.""" return sum(os.path.getsize(f) for f in files) def calculate_piece_size(data_size): for piece_size in PIECE_SIZE_VALUES: if data_size / piece_size < 2000: break return piece_size def calculate_torrent_size(torrent_info): """Calculate the total size of the files in a torrent.""" files = torrent_info.get('info').get('files') or [torrent_info['info']] return sum(f['length'] for f in files) def convert_cygwin_path(path): """Convert Unix path string from Cygwin to Windows format. Parameters: path (str): A path string. Returns: str: A path string in Windows format. Raises: FileNotFoundError :exc:`subprocess.CalledProcessError` """ try: win_path = subprocess.check_output(["cygpath", "-aw", path], universal_newlines=True).strip() except (FileNotFoundError, subprocess.CalledProcessError): raise subprocess.CalledProcessError("Call to cygpath failed.") return win_path def generate_unique_string(): """Generate a random string to make a torrent's infohash unique.""" return ''.join(random.choice(string.ascii_letters + string.digits) for x in range(32)) def get_files(path, max_depth=float('inf')): """Create a list of files from given path.""" if os.path.isfile(path): yield path elif os.path.isdir(path): for root, _, files in walk_depth(path, max_depth=max_depth): for f in files: yield os.path.join(root, f) def get_file_path(file, basedir): """Get all parts of the file path relative to the base directory of the torrent.""" head = os.path.relpath(file, basedir) parts = [] while head: head, tail = os.path.split(head) parts.insert(0, tail) return parts def hash_info_dict(info_dict): return sha1(bencode.dumps(info_dict)).hexdigest() def humanize_size(size, precision=0, **kwargs): """Convert size in bytes to a binary size string. Parameters: size (int): Data size in bytes. precision (int): Number of decimal places to return. Returns: str: File size string with appropriate unit. """ for multiple, symbol in SYMBOLS: if size >= multiple: break return f'{size / multiple:.{precision}f} {symbol}' def walk_depth(path, max_depth=float('inf')): """Walk a directory tree with configurable depth. Parameters: path (str): A directory path to walk. max_depth (int): The depth in the directory tree to walk. A depth of '0' limits the walk to the top directory. Default: No limit. Yields: tuple: A 3-tuple ``(root, dirs, files)`` same as :func:`os.walk`. """ path = os.path.abspath(path) start_level = path.count(os.path.sep) for dir_entry in os.walk(path): root, dirs, _ = dir_entry level = root.count(os.path.sep) - start_level yield dir_entry if level >= max_depth: dirs[:] = [] PK!H1%,'thorod-1.1.0.dist-info/entry_points.txtN+I/N.,()*/OPz9V&PK!݅LLthorod-1.1.0.dist-info/LICENSEThe MIT License (MIT) Copyright (c) 2018 thebigmunch Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. PK!HW"TTthorod-1.1.0.dist-info/WHEEL A н#J."jm)Afb~ ڡ5 G7hiޅF4+-3ڦ/̖?XPK!H ^  thorod-1.1.0.dist-info/METADATAVSH_1[m,!y1ɮeDw- `俿 Z}IMϠO5@HiYqDq}c9m$IXTA.B@'* !4IBSqy`<3!GJerlLEky9tn˲?cyWm[;!L ϳ #9I5ȶVk׶:O-x*+\Ig[O1ntd8$СwgD{iYXZbW%%Qu8TnRZg|3HExDD/}`[v1 Uhv\E&+R0ޯqQ]UA$-x|]Wd{(/HdETK2& _1H4a2"zQ!pR a*+QX.3!IyV.T"aJ 센wZXO3F-W7xG|'}pZh! bJ0v mbN,KBKF b_5]ꇀ | ӍsM'UPe hsIZ%0OHsAp HC\~K"::ƺ"=iB7/o^9QM7.~7G/ãOVDn”yH5$H >A9FE!sf8 WkGyrkyko6j/4 O>˲cQ /q 3E̓'NV+t .m7nk;߆V ҄4&Z/Uc/K/'X>Ǥ^k[vNE@ýət]ZG`y)/W M,T(Wgh\{!3]l \{^/lďl6?;u6box:` K]mx^^qaxIKgl;?:Rm/p#]. ⠊o{v{0F~ި؝~qڵ{oב,:C;r'C=No4YPK!H~}thorod-1.1.0.dist-info/RECORDuɎ@{? 8 a,"Ȣ" RB!O&m;~/B Wܓ8]G+\<ަwP9AlnQLmlj>g>ȓB .-OomnW9rA #d[u_*,8^#jI oPn \20M,/mb1`c(%&)9K6Y֪/M1}eAUnĿʥzSn.?@X5:-ʅ1`91M&Ab9B]7ٽ[Rv) 7#džtU0^[kn|DYCWN=o9 bYe6I҇m# WZOk`L8 ۜ4=4>.ϔcR&S-J8CN1_EjuJk(ЄCA2R3_zPu<>PK!]&&thorod/__about__.pyPK!BZZWthorod/__init__.pyPK!)VVthorod/__main__.pyPK![7gthorod/bencode.pyPK!h^8080 * thorod/cli.pyPK!|kmoo<thorod/config.pyPK!ZLL*Cthorod/constants.pyPK!&=֩Ethorod/core.pyPK!H~. Tthorod/utils.pyPK!H1%,'`thorod-1.1.0.dist-info/entry_points.txtPK!݅LL`thorod-1.1.0.dist-info/LICENSEPK!HW"TT}ethorod-1.1.0.dist-info/WHEELPK!H ^   fthorod-1.1.0.dist-info/METADATAPK!H~}@kthorod-1.1.0.dist-info/RECORDPKn