Repository: killerbat00/opalescence

Issue:
Get rid of the log_and_raise antipattern
Previously, I handled logging and raising events through a utility function, log_and_raise. This really just makes it less clear where in the code an exception might be raised.

Discussion:

Patch:
diff --git a/.editorconfig b/.editorconfig
index d4a2c44..552a4f3 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -1,5 +1,3 @@
-# http://editorconfig.org
-
 root = true
 
 [*]
@@ -17,5 +15,3 @@ end_of_line = crlf
 [LICENSE]
 insert_final_newline = false
 
-[Makefile]
-indent_style = tab
diff --git a/.gitignore b/.gitignore
index 6301ec1..5006da6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,12 +1,12 @@
-.idea/*
 *.torrent
 *.txt
+*.pyc
+# Setuptools distribution folder.
+/dist/
+# Python egg metadata
+/*.egg-info
 test_torrent_dir/*
 /tests/test_torrent_data/
 .idea/
 __pycache__/
-config.pyc
-opalescence/__pycache__/*
-opalescence/btlib/__pycache__/*
-opalescence/btlib/protocol/__pycache__/*
-tests/__pycache__/*
+
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..139a411
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,3 @@
+include LICENSE
+include VERSION
+include README.md
diff --git a/README.md b/README.md
index a9d5b5d..50817b2 100644
--- a/README.md
+++ b/README.md
@@ -17,8 +17,8 @@ install using pip
 ## using opalescence
 download a torrent
 
-`python3 <path-to-opalescence>/main.py download <.torrent-file> <destination>`
+`python3 <path-to-opalescence>/opl.py download <.torrent-file> <destination>`
 
 ## testing opalescence
-`python3 <path-to-opalescence>/main.py test`
+`python3 <path-to-opalescence>/opl.py test`
 
diff --git a/VERSION b/VERSION
deleted file mode 100644
index 0c62199..0000000
--- a/VERSION
+++ /dev/null
@@ -1,1 +0,0 @@
-0.2.1
diff --git a/config.ini b/config.ini
deleted file mode 100644
index ab109a1..0000000
--- a/config.ini
+++ /dev/null
@@ -1,1 +0,0 @@
-[default]
diff --git a/config/logging.ini b/config/logging.ini
deleted file mode 100644
index f4174d6..0000000
--- a/config/logging.ini
+++ /dev/null
@@ -1,28 +0,0 @@
-[loggers]
-keys = root,opalescence
-
-[handlers]
-keys = stdout
-
-[formatters]
-keys = basic
-
-[logger_root]
-level = DEBUG
-handlers = stdout
-
-[logger_opalescence]
-level = DEBUG
-handlers = stdout
-qualname = opalescence
-propagate = 0
-
-[handler_stdout]
-class = StreamHandler
-level = DEBUG
-formatter = basic
-args = (sys.stdout,)
-
-[formatter_basic]
-format = %(asctime)s : %(name)s : [%(levelname)s] %(message)s
-datefmt =
diff --git a/config/opalescence.ini b/config/opalescence.ini
deleted file mode 100644
index e69de29..0000000
diff --git a/main.py b/main.py
deleted file mode 100644
index 9ac7217..0000000
--- a/main.py
+++ /dev/null
@@ -1,101 +0,0 @@
-# !/usr/bin/env python
-# -*- coding: utf-8 -*-
-
-"""
-Opalescence is a simple torrent client.
-"""
-
-import argparse
-import asyncio
-import logging
-import logging.config
-import os
-import unittest
-
-from opalescence.btlib.client import Client
-from opalescence.btlib.torrent import Torrent
-
-
-def create_logger():
-    """
-    Creates and configures the root logger
-    Configuration is pulled from config/logging.ini
-    """
-    full_path = os.path.realpath(__file__)
-    dirname = os.path.dirname(full_path)
-    log_conf_path = os.path.join(dirname, "config", "logging.ini")
-    logging.config.fileConfig(log_conf_path)
-    logging.info("Initialized logging")
-
-
-def create_argparser() -> argparse.ArgumentParser:
-    """
-    Initializes the root argument parser and all relevant subparsers for supported commands.
-    :return:    argparse.ArgumentParser instance that's ready to make things happen
-    """
-    parser = argparse.ArgumentParser()
-    subparsers = parser.add_subparsers()
-    test_parser = subparsers.add_parser("test", help="Run the test suite")
-    test_parser.set_defaults(func=run_tests)
-    download_parser = subparsers.add_parser("download", help="Download a .torrent file")
-    download_parser.add_argument('torrent_file')
-    download_parser.add_argument('destination')
-    download_parser.set_defaults(func=download_file)
-    return parser
-
-
-def main():
-    """
-    Main entry-point into Opalescence.
-    """
-    create_logger()
-    logging.info("Initializing argument parser and subparsers")
-    argparser = create_argparser()
-
-    try:
-        args = argparser.parse_args()
-        args.func(args)
-    except AttributeError:
-        logging.debug("Program invoked with no arguments")
-        argparser.print_help()
-
-
-def run_tests(_) -> None:
-    """
-    Runs the test suite found in the tests/ directory
-    :param _: unused
-    """
-    logging.debug("Running the test suite")
-
-    loader = unittest.defaultTestLoader
-    runner = unittest.TextTestRunner()
-    suite = loader.discover(os.path.abspath(os.path.join(os.path.dirname(__file__), "tests")))
-    runner.run(suite)
-
-
-def download_file(file_path) -> None:
-    """
-    Downloads a .torrent file
-    :param file_path: .torrent filepath argparse.Namespace object
-    """
-    logging.debug(f"Downloading {file_path}")
-    logging.debug(f"Downloading {file_path.torrent_file}\n"
-                  f"to {file_path.destination}")
-
-    loop = asyncio.get_event_loop()
-    loop.set_debug(True)
-    torrent = Torrent.from_file(file_path.torrent_file)
-    client = Client()
-    client.download(torrent)
-
-    try:
-        loop.run_forever()
-        # loop.run_until_complete(task)
-    except asyncio.CancelledError:
-        logging.warning("Event loop was cancelled")
-    finally:
-        loop.close()
-
-if __name__ == '__main__':
-    main()
-    logging.shutdown()
diff --git a/opalescence/__init__.py b/opalescence/__init__.py
index 6630122..8dde347 100644
--- a/opalescence/__init__.py
+++ b/opalescence/__init__.py
@@ -1,12 +1,9 @@
 # -*- coding: utf-8 -*-
 
 """
-Package containing the main opalescence logic
+Package containing the main opalescence application logic.
 """
 
-from .btlib import *
-
 __author__ = """Brian Houston Morrow"""
-___email___ = "bhm@brianmorrow.net"
-__version__ = "0.1.2"
-
+__email__ = "bhm@brianmorrow.net"
+__version__ = "0.2.1"
diff --git a/opalescence/btlib/__init__.py b/opalescence/btlib/__init__.py
index a2d8f0d..228f37c 100644
--- a/opalescence/btlib/__init__.py
+++ b/opalescence/btlib/__init__.py
@@ -1,26 +1,6 @@
 # -*- coding: utf-8 -*-
 
 """
-Module containing library code for the BitTorrent protocol
+Library code for the bittorrent protocol.
 """
-import logging
 
-__all__ = ["bencode", "protocol", "torrent", "tracker", "client"]
-
-
-def log_and_raise(msg: str, log: logging.Logger, exc: Exception, from_e: Exception = None) -> None:
-    """
-    Logs a message, then raises the specified exception, optionally from another exception.
-
-    :param msg:    message to log on the module's logger
-    :param log:    logger on which to log
-    :param exc:    exception to raise
-    :param from_e: exception from which to raise
-    :raises exc:
-    """
-    if from_e:
-        log.exception(msg)
-        raise exc(msg) from from_e
-
-    log.error(msg)
-    raise exc(msg)
diff --git a/opalescence/btlib/bencode.py b/opalescence/btlib/bencode.py
index e28e4a2..69b07ef 100644
--- a/opalescence/btlib/bencode.py
+++ b/opalescence/btlib/bencode.py
@@ -3,22 +3,12 @@
 """
 Provides support for decoding a bencoded string into a python OrderedDict,
 bencoding a decoded OrderedDict, and pretty printing said OrderedDict.
-
-public classes:
-    Encoder()
-    Decoder()
-
-public Exceptions:
-    BencodeRecursionError()
-    DecodeError()
-    EncodeError()
 """
+
 import logging
 from collections import OrderedDict
 from io import BytesIO
-from typing import Union
-
-from . import log_and_raise
+from typing import Union, Optional
 
 logger = logging.getLogger(__name__)
 
@@ -43,7 +33,7 @@ class EncodeError(Exception):
 
 class BencodeDelimiters:
     """
-    Delimiters used for bencoding
+    Delimiters used in the bencoding spec.
     """
     dict_start = b'd'
     end = b'e'
@@ -51,27 +41,31 @@ class BencodeDelimiters:
     num_start = b'i'
     divider = b':'
     digits = [b'0', b'1', b'2', b'3', b'4', b'5', b'6', b'7', b'8', b'9']
-    eof = b"!"
+    eof = b"!"  # custom eof marker used to break out of empty containers
 
 
 class Decoder:
     """
-    Decodes a bencoded bytestring, returning it's equivalent python object representation.
+    Decodes a bencoded bytestring, returning its equivalent python
+    representation.
     """
 
-    def __init__(self, data: bytes, recursion_limit: int = 99999):
+    def __init__(self, data: bytes, *, recursion_limit: int = 99999):
         """
         Creates a new Decoder object.
 
         :param data:            the bencoded bytes to decode
-        :param recursion_limit: the number of times we'll recursively call into the decoding methods
+        :param recursion_limit: recursion limit for decoding methods
         :raises DecodeError:    if recursion limit is < 0 or no data received
         """
         if recursion_limit <= 0:
-            log_and_raise("Recursion limit should be greater than 0.", logger, DecodeError)
+            logger.error(f"Cannot decode. Recursion limit should be greater "
+                         f"than 0.")
+            raise DecodeError
 
         if not data:
-            log_and_raise("No data received.", logger, DecodeError)
+            logger.error(f"Cannot decode. No data received.")
+            raise DecodeError
 
         self._recursion_limit = recursion_limit
         self._current_iter = 0
@@ -80,7 +74,8 @@ class Decoder:
     def _set_data(self, data: bytes) -> None:
         """
         Sets the data used by the decoder.
-        Warning: _set_data does not check if the data passed in as an argument exists.
+        Warning: _set_data does not check if the data passed in as an argument
+        exists.
         calling decode() after setting no data will return None.
 
         :param data: bytes of data to decode
@@ -89,7 +84,9 @@ class Decoder:
             self.data = BytesIO(data)
             self._current_iter = 0
         except TypeError as te:
-            log_and_raise(f"Cannot set data. Invalid type of {type(data)}", logger, DecodeError, te)
+            logger.error(f"Expected bytes, received {type(data)}")
+            logger.info(te, exc_info=True)
+            raise DecodeError from te
 
     def decode(self) -> Union[OrderedDict, list, bytes, int, None]:
         """
@@ -111,17 +108,18 @@ class Decoder:
         :return: torrent info decoded into a python object
         """
         if self._current_iter > self._recursion_limit:
-            log_and_raise("Recursion limit reached.", logger, BencodeRecursionError)
+            logger.error(f"Recursion limit reached.")
+            raise BencodeRecursionError
         else:
             self._current_iter += 1
 
         char = self.data.read(1)
 
         if not char:
-            # ends the recursive madness. eof is used to signal we've decoded as far as we can go
+            # eof is used to signal we've decoded as far as we can go
             return BencodeDelimiters.eof
         if char == BencodeDelimiters.end:
-            # extraneous end delimiters are ignored -> d3:num3:valee = {"num", "val"}
+            # extra end delimiters are ignored -> d3:num3:valee = {"num", "val"}
             return BencodeDelimiters.eof
         elif char == BencodeDelimiters.num_start:
             return self._decode_int()
@@ -132,7 +130,8 @@ class Decoder:
         elif char == BencodeDelimiters.list_start:
             return self._decode_list()
         else:
-            log_and_raise(f"Unable to bdecode {char}. Invalid bencoding key.", logger, DecodeError)
+            logger.error(f"Unable to bdecode {char}. Invalid bencoding key.")
+            raise DecodeError
 
     def _decode_dict(self) -> OrderedDict:
         """
@@ -151,21 +150,25 @@ class Decoder:
             if key == BencodeDelimiters.eof:
                 break
             if not isinstance(key, bytes):
-                log_and_raise(f"Dictionary key must be bytes. Not {type(key)}.", logger, DecodeError)
+                logger.error(f"Dictionary key must be bytes. Not {type(key)}")
+                raise DecodeError
+
             val = self._decode()
 
             decoded_dict.setdefault(key, val)
             keys.append(key)
 
         if keys != sorted(keys):
-            log_and_raise(f"Invalid dictionary. Keys {keys} are not sorted.", logger, DecodeError)
+            logger.error(f"Invalid dictionary. Keys {keys} not sorted.")
+            raise DecodeError
 
         return decoded_dict
 
     def _decode_list(self) -> list:
         """
         Decodes a bencoded list into a python list
-        lists can contain any other bencoded types (bytestring, integer, list, dictionary)
+        lists can contain any other bencoded types:
+            (bytestring, integer, list, dictionary)
 
         :return: list of decoded data
         """
@@ -188,24 +191,26 @@ class Decoder:
     def _decode_bytestr(self) -> bytes:
         """
         decodes a bencoded string from the BytesIO buffer.
-        whitespace only strings are allowed if there is the proper number of whitespace characters to read.
+        whitespace only strings are allowed if there is
+        the proper number of whitespace characters to read.
 
         :raises DecodeError: if we are unable to read enough data for the string
         :return:             decoded string
         """
-        # we've already consumed the byte that will start the string length, go back and get it
+        # we've already consumed the string length, go back and get it
         self.data.seek(-1, 1)
         string_len = self._parse_num(BencodeDelimiters.divider)
         string_val = self.data.read(string_len)
 
         if len(string_val) != string_len:
-            log_and_raise(f"Unable to read specified string length {string_len}", logger, DecodeError)
+            logger.error(f"Unable to read specified string {string_len}")
+            raise DecodeError
 
         return string_val
 
     def _parse_num(self, delimiter: bytes) -> int:
         """
-        parses an bencoded integer up to specified delimiter from the BytesIO buffer.
+        parses an bencoded integer up to specified delimiter from the buffer.
 
         :param delimiter:    delimiter do indicate the end of the number
         :raises DecodeError: when an invalid character occurs
@@ -214,20 +219,25 @@ class Decoder:
         parsed_num = bytes()
         while True:
             char = self.data.read(1)
-            if char in BencodeDelimiters.digits + [b"-"]:  # allow negative integers
+            # allow negative integers
+            if char in BencodeDelimiters.digits + [b"-"]:
                 parsed_num += char
             else:
                 if char != delimiter:
-                    log_and_raise(f"Invalid character while parsing int {char}. Expected {delimiter}", logger,
-                                  DecodeError)
+                    logger.error(f"Invalid character while parsing int"
+                                 f"{char}. Expected {delimiter}")
+                    raise DecodeError
                 break
 
         num_str = parsed_num.decode("UTF-8")
 
         if len(num_str) == 0:
-            log_and_raise("Empty strings are not allowed for int keys.", logger, DecodeError)
+            logger.error("Empty strings are not allowed for int keys.")
+            raise DecodeError
         elif len(num_str) > 1 and (num_str[0] == '0' or num_str[:2] == '-0'):
-            log_and_raise("Leading or negative zeros are not allowed for int keys.", logger, DecodeError)
+            logger.error("Leading or negative zeros are not allowed for int "
+                         "keys.")
+            raise DecodeError
 
         return int(num_str)
 
@@ -245,7 +255,8 @@ class Encoder:
         :raises EncodeError: when null data received
         """
         if not data:
-            log_and_raise("No data received.", logger, EncodeError)
+            logger.error("Cannot encode. No data received.")
+            raise EncodeError
 
         self._set_data(data)
 
@@ -258,7 +269,7 @@ class Encoder:
         """
         self.data = data
 
-    def encode(self) -> Union[bytes, None]:
+    def encode(self) -> Optional[bytes]:
         """
         Bencodes a python object and returns the bencoded string.
 
@@ -285,16 +296,18 @@ class Encoder:
         elif isinstance(obj, bytes):
             return self._encode_bytestr(obj)
         elif isinstance(obj, bool):
-            log_and_raise("Boolean values not supported.", logger, EncodeError)
+            logger.error("Boolean values not supported.")
+            raise EncodeError
         elif isinstance(obj, int):
             return self._encode_int(obj)
         else:
-            log_and_raise(f"Unexpected object found {obj}", logger, EncodeError)
+            logger.error(f"Unexpected object found {obj}")
+            raise EncodeError
 
     def _encode_dict(self, obj: dict) -> bytes:
         """
-        bencodes a python dictionary.
-        Keys may only be bytestrings and they must be in ascending order according to their bytes.
+        bencodes a python dictionary. Keys may only be bytestrings and they
+        must be in ascending order according to their bytes.
 
         :param obj: dictionary to encode
         :raises EncodeError:
@@ -304,14 +317,16 @@ class Encoder:
         keys = []
         for k, v in obj.items():
             if not isinstance(k, bytes):
-                log_and_raise(f"Dictionary keys must be bytes. Not {type(k)}", logger, EncodeError)
+                logger.error(f"Dictionary keys must be bytes. Not {type(k)}")
+                raise EncodeError
             keys.append(k)
             key = self._encode_bytestr(k)
             contents += key
             contents += self._encode(v)
         contents += BencodeDelimiters.end
         if keys != sorted(keys):
-            log_and_raise(f"Invalid dictionary. Keys {keys} not sorted.", logger, EncodeError)
+            logger.error(f"Invalid dictionary. Keys {keys} are not sorted.")
+            raise EncodeError
         return contents
 
     def _encode_list(self, obj: list) -> bytes:
diff --git a/opalescence/btlib/client.py b/opalescence/btlib/client.py
index 5e60aaa..031beb2 100644
--- a/opalescence/btlib/client.py
+++ b/opalescence/btlib/client.py
@@ -7,10 +7,9 @@ The client is responsible for orchestrating communication with the tracker and b
 import asyncio
 import logging
 
-from . import log_and_raise
+from .metainfo import MetaInfoFile
 from .protocol.peer import PeerError, Peer
 from .protocol.piece_handler import Requester
-from .torrent import Torrent
 from .tracker import Tracker, TrackerError
 
 logger = logging.getLogger(__name__)
@@ -27,7 +26,8 @@ class ClientTorrent:
     A torrent currently being handled by the client. This wraps the tracker, requester, and peers into a single
     API.
     """
-    def __init__(self, torrent: Torrent):
+
+    def __init__(self, torrent: MetaInfoFile):
         self.torrent = torrent
         self.tracker = Tracker(self.torrent)
         self.requester = Requester(self.torrent)
@@ -66,8 +66,9 @@ class ClientTorrent:
                 self.peer_list = p
 
         except TrackerError as te:
-            log_and_raise(f"Unable to make announce call to {self.tracker} for {self.torrent.name}", logger,
-                          ClientError, te)
+            logger.error(f"Unable to announce to {self.tracker}.")
+            logger.info(te, exc_info=True)
+            raise ClientError from te
 
     def assign_peers(self) -> None:
         """
@@ -107,7 +108,7 @@ class Client:
         self.tasks = []
         self.torrents = {}
 
-    def download(self, torrent: Torrent):
+    def download(self, torrent: MetaInfoFile):
         """
         Starts downloading the torrent. Multiple torrents can be downloaded simultaneously.
         :param torrent: Torrent to download.
@@ -115,7 +116,7 @@ class Client:
         if torrent not in self.torrents:
             self.torrents[torrent] = ClientTorrent(torrent)
 
-    async def stop(self, torrent: Torrent = None):
+    async def stop(self, torrent: MetaInfoFile = None):
         """
         Stops downloading the specified torrent, or all torrents if none specified.
         :param torrent: torrent to stop downloading. Default = None = ALL torrents
diff --git a/opalescence/btlib/torrent.py b/opalescence/btlib/metainfo.py
similarity index 55%
rename from opalescence/btlib/torrent.py
rename to opalescence/btlib/metainfo.py
index ff8c01e..13205f2 100644
--- a/opalescence/btlib/torrent.py
+++ b/opalescence/btlib/metainfo.py
@@ -1,8 +1,8 @@
 # -*- coding: utf-8 -*-
 
 """
-Support for representing a .torrent file as a python class and creating a Torrent class (or .torrent file) from
-a specified file or directory.
+Support for representing a .torrent file as a python class and
+creating a Torrent class (or .torrent file) from a specified file or directory.
 """
 
 import hashlib
@@ -11,7 +11,6 @@ import os
 from collections import OrderedDict
 from typing import NamedTuple, Union
 
-from . import log_and_raise
 from .bencode import Decoder, Encoder, DecodeError, EncodeError
 
 logger = logging.getLogger(__name__)
@@ -21,32 +20,33 @@ class CreationError(Exception):
     """
     Raised when we encounter problems creating a torrent
     """
-    pass
 
 
-"""
-An individual file within a torrent
-"""
-FileItem = NamedTuple("FileItem", [("path", str), ("size", int)])
+class FileItem(NamedTuple):
+    """
+    An individual file within a torrent.
+    """
+    path: str
+    size: int
 
 
 def _pc(piece_string: bytes, *, length: int = 20, start: int = 0):
     """
-    pieces a bytestring into pieces of specified length.
-    by default pieces into 20byte (160 bit) pieces
-    typically used to piece the hashlist contained within the torrent info's pieces key
+    Pieces a bytestring into pieces of specified length.
 
     :param piece_string: string to piece
     :param length:       piece length
-    :return:             generator expression yielding pieces of specified length
+    :return:             generator expression yielding pieces
     """
-    return (piece_string[0 + i:length + i] for i in range(start, len(piece_string), length))
+    return (piece_string[0 + i:length + i] for i in
+            range(start, len(piece_string), length))
 
 
 def _validate_torrent_dict(decoded_dict: OrderedDict) -> bool:
     """
-    Verifies a given decoded dictionary contains valid keys to describe a torrent we can do something with.
-    Currently only checks for the minimum required torrent keys for torrents describing files and directories.
+    Verifies a given decoded dictionary contains valid keys.
+
+    Currently only checks for the minimum required torrent keys.
     If a dictionary contains all valid keys + extra keys, it will be validated.
 
     :param decoded_dict: dict representing bencoded .torrent file
@@ -57,26 +57,34 @@ def _validate_torrent_dict(decoded_dict: OrderedDict) -> bool:
     min_info_req_keys = [b"piece length", b"pieces", b"name"]
     min_files_req_keys = [b"length", b"path"]
 
-    logger.debug("Validating torrent metainfo dictionary {d}".format(d=decoded_dict))
+    logger.debug(
+        "Validating torrent metainfo dictionary {d}".format(d=decoded_dict))
 
     dict_keys = list(decoded_dict.keys())
 
     if not dict_keys:
-        log_and_raise("No valid keys in dictionary.", logger, CreationError)
+        logger.error("No valid keys in dictionary.")
+        raise CreationError
+
     for key in min_req_keys:
         if key not in dict_keys:
-            log_and_raise(f"Required key not found: {key}", logger, CreationError)
+            logger.error(f"Required key not found: {key}")
+            raise CreationError
 
     info_keys = list(decoded_dict[b"info"].keys())
 
     if not info_keys:
-        log_and_raise("No valid keys in info dictionary.", logger, CreationError)
+        logger.error("No valid keys in info dictionary.")
+        raise CreationError
+
     for key in min_info_req_keys:
         if key not in info_keys:
-            log_and_raise(f"Required key not found: {key}", logger, CreationError)
+            logger.error(f"Required key not found: {key}")
+            raise CreationError
 
     if len(decoded_dict[b"info"][b"pieces"]) % 20 != 0:
-        log_and_raise("Piece length not a multiple of 20.", logger, CreationError)
+        logger.error("Piece length not a multiple of 20.")
+        raise CreationError
 
     multiple_files = b"files" in info_keys
 
@@ -84,19 +92,24 @@ def _validate_torrent_dict(decoded_dict: OrderedDict) -> bool:
         file_list = decoded_dict[b"info"][b"files"]
 
         if not file_list:
-            log_and_raise("No file list.", logger, CreationError)
+            logger.error("No file list.")
+            raise CreationError
+
         for f in file_list:
             for key in min_files_req_keys:
                 if key not in f.keys():
-                    log_and_raise(f"Required key not found: {key}", logger, CreationError)
+                    logger.error(f"Required key not found: {key}")
+                    raise CreationError
     else:
         if b"length" not in info_keys:
-            log_and_raise("Required key not found: b'length'", logger, CreationError)
+            logger.error("Required key not found: b'length'")
+            raise CreationError
+
     # we made it!
     return True
 
 
-class Torrent:
+class MetaInfoFile:
     """
     Wrapper around the torrent's metainfo. Doesn't include any download state.
     Torrents are created from files.
@@ -106,17 +119,13 @@ class Torrent:
     """
 
     def __init__(self):
-        """
-        Creates the lightweight representation of the torrent's metainfo and validates it
-        """
-        self.filename = None
         self.files = []
         self.meta_info = None
         self.info_hash = None
         self.pieces = []
 
     @classmethod
-    def from_file(cls, filename: str) -> "Torrent":
+    def from_file(cls, filename: str) -> "MetaInfoFile":
         """
         Class method to create a torrent object from a .torrent metainfo file
 
@@ -125,19 +134,21 @@ class Torrent:
         :return: Torrent instance
         """
         torrent = cls()
-        torrent.filename = filename
 
-        if not os.path.exists(torrent.filename):
-            log_and_raise(f"Path does not exist {filename}", logger, CreationError)
+        if not os.path.exists(filename):
+            logger.error(f"Path does not exist {filename}")
+            raise CreationError
 
         try:
-            with open(torrent.filename, 'rb') as f:
+            with open(filename, 'rb') as f:
                 data = f.read()
                 torrent.meta_info = Decoder(data).decode()
                 _validate_torrent_dict(torrent.meta_info)
                 info = Encoder(torrent.meta_info[b"info"]).encode()
                 torrent.info_hash = hashlib.sha1(info).digest()
         except (EncodeError, DecodeError, IOError) as e:
+            logger.error(f"Encountered error creating MetaInfoFile.")
+            logger.info(e, exc_info=True)
             raise CreationError from e
 
         torrent._gather_files()
@@ -147,7 +158,8 @@ class Torrent:
 
     @classmethod
     def from_path(cls, path: str, trackers: list, *,
-                  comment: str = "", piece_size: int = 16384, private: bool = False) -> "Torrent":
+                  comment: str = "", piece_size: int = 16384,
+                  private: bool = False) -> "MetaInfoFile":
         """
         Class method to create a torrent object from a specified filepath. The path can be
         a single file or a directory
@@ -169,38 +181,6 @@ class Torrent:
         :raises CreationError:
         """
         raise NotImplementedError
-        # if not self.base_location:
-        #    logger.error("Unable to create_torrent torrent. No base path specified. This is a programmer error.")
-        #    raise CreationError
-
-        # base_path = self.base_location
-        # left_in_piece = 0
-        # next_pc = b""
-
-        ## how can I make this better? it'd be nice to have a generator that
-        ## abstracts away the file handling and just gives me the
-        ## sha1 digest of a self.piece_length chunk of the file
-        # for file_itm in self.files:
-        #    with open(os.path.join(base_path, file_itm.path[0]), mode="rb") as f:
-        #        current_pos = 0
-        #        if left_in_piece > 0:
-        #            next_pc += f.read(left_in_piece)
-        #            self.pieces.append(hashlib.sha1(next_pc).digest().decode("ISO-8859-1"))
-        #            current_pos = left_in_piece
-        #            next_pc = b""
-        #            left_in_piece = 0
-
-        #        while True:
-        #            if current_pos + self.piece_length <= file_itm.size:
-        #                self.pieces.append(hashlib.sha1(f.read(self.piece_length)).digest().decode("ISO-8859-1"))
-        #                current_pos += self.piece_length
-        #            else:
-        #                remainder_to_read = file_itm.size - current_pos
-        #                if remainder_to_read < 0:
-        #                    break
-        #                next_pc += f.read(remainder_to_read)
-        #                left_in_piece = self.piece_length - remainder_to_read
-        #                break
 
     @classmethod
     def from_path(cls, path: str, trackers: list, *, comment: str = "",
@@ -217,37 +197,6 @@ class Torrent:
         :return:           Torrent object
         """
         raise NotImplementedError
-        # files = []
-
-        # if not os.path.exists(path):
-        #    logger.error("Path does not exist {path}".format(path=path))
-        #    raise CreationError
-
-        ## gather files
-        # if os.path.isfile(path):
-        #    base_path = os.path.dirname(path)
-        #    name = os.path.basename(path)
-        #    size = os.path.getsize(path)
-        #    files.append(FileItem(name, size))
-        # elif os.path.isdir(path):
-        #    base_path = path
-        #    name = os.path.basename(path)
-
-        #    # os.listdir returns paths in arbitrary order - possible danger here
-        #    for f in os.listdir(path):
-        #        size = os.path.getsize(os.path.join(path, f))
-        #        fi = FileItem(f, size)
-        #        fi.path = [fi.path]
-        #        files.append(fi)
-        # else:
-        #    logger.error("Error creating Torrent instance. Invalid file keys in metainfo dictionary.")
-        #    raise CreationError
-
-        # logger.debug("Creating Torrent instance from path {path}".format(path=path))
-        # torrent = cls(trackers, files, name, location=base_path, comment=comment, private=private,
-        #              piece_length=piece_size)
-        # logger.debug("Created Torrent instance from path {path} {torrent}".format(path=path, torrent=torrent.info_hash))
-        # return torrent
 
     def to_file(self, output_filename: str):
         """
@@ -257,13 +206,16 @@ class Torrent:
         :raises CreationError:
         """
         if not output_filename:
-            log_and_raise("Torrent must have an output filename.", logger, CreationError)
+            logger.error("No output filename provided.")
+            raise CreationError
 
         with open(output_filename, 'wb+') as f:
             try:
                 data = Encoder(self.meta_info).encode()
                 f.write(data)
             except EncodeError as ee:
+                logger.error("Unable to write metainfo file {output_filename}")
+                logger.info(ee, exc_info=True)
                 raise CreationError from ee
 
         logger.debug(f"Wrote .torrent file: {output_filename}")
@@ -272,7 +224,7 @@ class Torrent:
         """
         Gathers the files located in the torrent
         """
-        # TODO: filepaths are a list containing string elements that represent the path and filename
+        # TODO: filepaths are a list containing string elements
         if b"files" in self.meta_info[b"info"]:
             for f in self.meta_info[b"info"][b"files"]:
                 path = None
@@ -284,19 +236,23 @@ class Torrent:
 
         else:
             self.files.append(
-                FileItem(self.meta_info[b"info"][b"name"].decode("UTF-8"), self.meta_info[b"info"][b"length"]))
+                FileItem(self.meta_info[b"info"][b"name"].decode("UTF-8"),
+                         self.meta_info[b"info"][b"length"]))
 
     @property
     def multi_file(self) -> bool:
         """
         TODO: proper handling of multi-file torrents.
-        For a single file torrent, the meta_info["info"]["name"] is the torrent's content's file basename and
-                                       meta_info["info"]["length"] is its size
+        For a single file torrent,
+            the meta_info["info"]["name"] is the torrent's
+            content's file basename and meta_info["info"]["length"] is its size
 
-        For multiple file torrents, the meta_info["info"]["name"] is the torrent's content's directory name and
-                                        meta_info["info"]["files"] contains the content's file basename
-                                        meta_info["info"]["files"]["length"] is the file's size
-                                        meta_info["info"]["length"] doesn't contribute anything here
+        For multiple file torrents,
+            the meta_info["info"]["name"] is the torrent's
+            content's directory name and
+            meta_info["info"]["files"] contains the content's file basename
+            meta_info["info"]["files"]["length"] is the file's size
+            meta_info["info"]["length"] doesn't contribute anything here
         """
         return b"files" in self.meta_info[b"info"]
 
@@ -304,8 +260,8 @@ class Torrent:
     def announce_urls(self) -> list:
         """
         The announce URL of the tracker.
-        According to BEP 0012 (http://bittorrent.org/beps/bep_0012.html), if announce-list is present, it is used
-        instead of announce.
+        According to BEP 0012 (http://bittorrent.org/beps/bep_0012.html),
+        if announce-list is present, it is used instead of announce.
         :return: a list of announce URLs for the tracker
         """
         urls = []
@@ -371,14 +327,13 @@ class Torrent:
     @property
     def name(self) -> int:
         """
-        :return: the torrent's name; either the single filename or the directory name.
+        :return: the torrent's name; either the single filename or the directory
+        name.
         """
         return self.meta_info[b"info"][b"name"].decode("UTF-8")
 
     def __str__(self):
-        """
-        Returns a readable string representing the torrent
+        return f"{self.name}:{self.info_hash}"
 
-        :return: readable string of data
-        """
-        return "<Torrent object: {name} : {info_hash}>{".format(name=self.name, info_hash=self.info_hash)
+    def __repr__(self):
+        return f"<Torrent: {self.name}:{self.info_hash}"
diff --git a/opalescence/btlib/protocol/peer.py b/opalescence/btlib/protocol/peer.py
index f618ca2..ba4d966 100644
--- a/opalescence/btlib/protocol/peer.py
+++ b/opalescence/btlib/protocol/peer.py
@@ -7,12 +7,9 @@ The coordination with peers is handled in ../client.py
 
 No data is currently sent to the remote protocol.
 """
-import asyncio
 import logging
 
-from opalescence.btlib import log_and_raise
-from .messages import Handshake, KeepAlive, Choke, Unchoke, Interested, NotInterested, Have, Bitfield, \
-    Request, Block, Cancel, MessageReader
+from .messages import *
 
 logger = logging.getLogger(__name__)
 
@@ -113,7 +110,8 @@ class Peer:
                     else:
                         message = self.requester.next_request(self.peer_id)
                         if not message:
-                            logger.debug(f"{self}: No requests available. Closing connection.")
+                            logger.debug(
+                                f"{self}: No requests available. Closing connection.")
                             self.cancel()
                             return
 
@@ -126,12 +124,9 @@ class Peer:
                             logger.debug(
                                 f"Cancelling piece {message.index}:{message.begin}:{message.length} from {self}")
 
-        # TODO: Narrow down exceptions that are safely consumed
-        # Eat exceptions here so we'll move to the next protocol.
-        # We'll eventually try this protocol again anyway if the number of peers is low
-        except Exception as e:
-            logger.debug(f"{self}: Unable to open connection.\n{e}")
-            raise PeerError from e
+        except OSError as oe:
+            logger.debug(f"{self}: Exception with connection.\n{oe}")
+            raise PeerError from oe
 
     async def handshake(self) -> bytes:
         """
@@ -149,14 +144,14 @@ class Peer:
         while len(data) < Handshake.msg_len:
             data = await self.reader.read(MessageReader.CHUNK_SIZE)
             if not data:
-                log_and_raise(f"{self}: Unable to initiate handshake", logger,
-                              PeerError)
+                logger.error(f"{self}: Unable to initiate handshake")
+                raise PeerError
 
         rcvd = Handshake.decode(data[:Handshake.msg_len])
 
         if rcvd.info_hash != self.info_hash:
-            log_and_raise(f"{self}: Incorrect info hash received.", logger,
-                          PeerError)
+            logger.error(f"{self}: Incorrect info hash received.")
+            raise PeerError
 
         logger.debug(f"{self}: Successfully negotiated handshake.")
         return data[Handshake.msg_len:]
diff --git a/opalescence/btlib/protocol/piece_handler.py b/opalescence/btlib/protocol/piece_handler.py
index 986f206..4ac0446 100644
--- a/opalescence/btlib/protocol/piece_handler.py
+++ b/opalescence/btlib/protocol/piece_handler.py
@@ -12,8 +12,8 @@ from typing import Union, Dict, List
 
 import bitstring as bitstring
 
-from .messages import Request, Block, Piece, Cancel
-from ..torrent import Torrent
+from .messages import Request, Block, Piece
+from ..metainfo import MetaInfoFile
 
 logger = logging.getLogger(__name__)
 
@@ -24,7 +24,7 @@ class Writer:
     Will eventually flush data to the disk.
     """
 
-    def __init__(self, torrent: Torrent):
+    def __init__(self, torrent: MetaInfoFile):
         self.filename = os.path.join(os.path.abspath(os.path.dirname(__file__)), torrent.name)
         self.piece_length = torrent.piece_length
         self.buffer = io.BytesIO()
@@ -54,7 +54,7 @@ class Requester:
     We currently use a naive sequential strategy.
     """
 
-    def __init__(self, torrent: Torrent):
+    def __init__(self, torrent: MetaInfoFile):
         self.torrent = torrent
         self.piece_length = torrent.piece_length
         self.piece_writer = Writer(torrent)
@@ -62,7 +62,6 @@ class Requester:
         self.downloaded_pieces: Dict(int, Piece) = {}
         self.downloading_pieces: Dict(int, Union(Piece, None)) = {i: None for i in range(len(self.torrent.pieces))}
         self.pending_requests: List(Request) = []
-        self.cancelling_requests: List(Cancel) = []
 
     def add_available_piece(self, peer_id: str, index: int) -> None:
         """
@@ -108,7 +107,6 @@ class Requester:
         r = Request(block.index, block.begin)
         if r in self.pending_requests:
             index = self.pending_requests.index(r)
-            self.cancelling_requests.append(self.pending_requests[index])
             del self.pending_requests[index]
 
         piece = self.downloading_pieces.get(block.index)
@@ -183,13 +181,6 @@ class Requester:
         :param peer_id: The remote peer who's asking for a new request's id
         :return: A new request, or None if that isn't possible.
         """
-        # First, check if we need to cancel any requests to the peer
-        indices = [self.cancelling_requests.index(r) for r in self.cancelling_requests if r.peer_id == peer_id]
-        if indices:
-            r = self.cancelling_requests[indices[0]]
-            del self.cancelling_requests[indices[0]]
-            return Cancel.from_request(r)
-
         # Find the next piece index for which the peer has an available piece
         piece_index = self._next_piece_index_for_peer(peer_id)
         if piece_index is None:
diff --git a/opalescence/btlib/tracker.py b/opalescence/btlib/tracker.py
index 6a0ea8e..5d20348 100644
--- a/opalescence/btlib/tracker.py
+++ b/opalescence/btlib/tracker.py
@@ -6,17 +6,16 @@ Support for communication with an external tracker.
 
 import asyncio
 import logging
-import random
 import socket
 import struct
-from typing import Union
+from random import randint
+from typing import Union, Optional
 from urllib.parse import urlencode
 
 import aiohttp
 
 from opalescence.btlib import bencode
-from . import log_and_raise
-from .torrent import Torrent
+from .metainfo import MetaInfoFile
 
 logger = logging.getLogger(__name__)
 
@@ -31,7 +30,8 @@ class TrackerError(Exception):
 class Tracker:
     """
     Communication with the tracker.
-    Does not currently support the announce-list extension from BEP 0012: http://bittorrent.org/beps/bep_0012.html
+    Does not currently support the announce-list extension from
+    BEP 0012: http://bittorrent.org/beps/bep_0012.html
     Does not support the scrape convention.
     """
 
@@ -39,10 +39,11 @@ class Tracker:
     # TODO: implement scrape convention support.
     DEFAULT_INTERVAL = 60  # 1 minute
 
-    def __init__(self, torrent: Torrent):
+    def __init__(self, torrent: MetaInfoFile):
         self.torrent = torrent
         self.http_client = aiohttp.ClientSession(loop=asyncio.get_event_loop())
-        self.peer_id = ("-OP0001-" + ''.join([str(random.randint(0, 9)) for _ in range(12)])).encode("UTF-8")
+        self.peer_id = ("-OP0001-" + ''.join(str(randint(0, 9)) for _ in
+                                             range(12))).encode("UTF-8")
         self.tracker_id = None
         self.port = 6881
         self.uploaded = 0
@@ -54,31 +55,37 @@ class Tracker:
         """
         Makes an announce request to the tracker.
 
-        :raises TrackerError: if the tracker's HTTP code is not 200, the tracker sent a failure, or we
+        :raises TrackerError: if the tracker's HTTP code is not 200,
+                              the tracker sent a failure, or we
                               are unable to bdecode the tracker's response.
         :returns: Response object representing the tracker's response
         """
         url = self._make_url()
-        decoded_data = None
 
-        logger.debug(f"Making {self.event} announce to: {url}")
         async with self.http_client.get(url) as r:
             data = await r.read()
             if r.status != 200:
-                log_and_raise(f"Unable to connect to the tracker.\n{data}", logger, TrackerError)
+                logger.error(f"{url}: Unable to connect to tracker.")
+                raise TrackerError
 
-            try:
-                decoded_data = bencode.Decoder(data).decode()
-            except bencode.DecodeError as e:
-                log_and_raise(f"Unable to decode tracker response.\n{data}", logger, TrackerError, e)
+        try:
+            decoded_data = bencode.Decoder(data).decode()
+        except bencode.DecodeError as e:
+            logger.error(f"{url}: Unable to decode tracker response.")
+            logger.info(e, exc_info=True)
+            raise TrackerError
 
-            tr = Response(decoded_data)
-            if tr.failed:
-                log_and_raise(f"Failed announce call to tracker {url}\n{tr.failure_reason}", logger, TrackerError)
+        tr = Response(decoded_data)
+        if tr.failed:
+            logger.error(f"{url}: Failed announce call to tracker.")
+            raise TrackerError
 
-            if self.event:
-                self.event = ""
-            return Response(decoded_data)
+        logger.debug(f"Made {self.event} announce to: {url}")
+
+        if self.event:
+            self.event = ""
+
+        return Response(decoded_data)
 
     async def cancel(self) -> None:
         """
@@ -87,7 +94,6 @@ class Tracker:
         """
         self.event = "stopped"
         await self.announce()
-        return
 
     async def completed(self) -> None:
         """
@@ -96,7 +102,6 @@ class Tracker:
         """
         self.event = "completed"
         await self.announce()
-        return
 
     def _make_url(self) -> str:
         """
@@ -104,14 +109,15 @@ class Tracker:
         Currently only uses the announce key
 
         :raises TrackerError:
-        :return: tracker's announce url with correctly escaped and encoded parameters
+        :return: tracker's announce url with urlencoded parameters
         """
         # TODO: implement proper announce-list handling
-        return self.torrent.meta_info[b"announce"].decode("UTF-8") + "?" + urlencode(self._make_params())
+        return self.torrent.meta_info[b"announce"].decode("UTF-8") + \
+               "?" + urlencode(self._make_params())
 
     def _make_params(self) -> dict:
         """
-        Builds the parameter dictionary the tracker expects for announce requests.
+        Builds the parameters the tracker expects for announce requests.
 
         :return: dictionary of properly encoded parameters
         """
@@ -161,12 +167,12 @@ class Response:
     @property
     def min_interval(self) -> int:
         """
-        :return: the minimum interval, if specified we can't make requests more frequently than this
+        :return: the minimum interval
         """
         return self.data.get(b"min interval", 0)
 
     @property
-    def tracker_id(self) -> Union[str, None]:  # or maybe bytes?
+    def tracker_id(self) -> Optional[str]:  # or maybe bytes?
         """
         :return: the tracker id
         """
@@ -190,10 +196,11 @@ class Response:
         return self.data.get(b"incomplete", 0)
 
     @property
-    def peers(self) -> Union[list, None]:
+    def peers(self) -> Optional[list]:
         """
         :raises TrackerError:
-        :return: the list of peers. The response can be given as a list of dictionaries about the peers, or a string
+        :return: the list of peers. The response can be given as a
+        list of dictionaries about the peers, or a string
         encoding the ip address and ports for the peers
         """
         peers = self.data.get(b"peers")
@@ -202,11 +209,15 @@ class Response:
             return
 
         if isinstance(peers, bytes):
-            logger.debug("Decoding binary model peers.")
             split_peers = [peers[i:i + 6] for i in range(0, len(peers), 6)]
-            return [(socket.inet_ntoa(p[:4]), struct.unpack(">H", p[4:])[0]) for p in split_peers]
+            p = [(socket.inet_ntoa(p[:4]), struct.unpack(">H", p[4:])[0]) for
+                 p in split_peers]
+            logger.debug("Decoded binary model peers.")
+            return p
         elif isinstance(peers, list):
-            logger.debug("Decoding dictionary model peers.")
-            return [(p[b"ip"].decode("UTF-8"), p[b"port"]) for p in peers]
+            p = [(p[b"ip"].decode("UTF-8"), p[b"port"]) for p in peers]
+            logger.debug("Decoded dictionary model peers.")
+            return p
         else:
-            log_and_raise(f"Unable to decode peers {peers}", logger, TrackerError)
+            logger.error(f"Unable to decode peers {peers}")
+            raise TrackerError
diff --git a/opalescence/ui/__init__.py b/opalescence/ui/__init__.py
new file mode 100644
index 0000000..7e97c83
--- /dev/null
+++ b/opalescence/ui/__init__.py
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+
+"""
+The UI for opalescence is implemented in this package.
+Currently, only a cli is provided.
+"""
diff --git a/opalescence/ui/cli.py b/opalescence/ui/cli.py
new file mode 100644
index 0000000..93e11ab
--- /dev/null
+++ b/opalescence/ui/cli.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+Command Line Interface for Opalescence (Clifo)
+"""
+
+import argparse
+import asyncio
+import logging
+import logging.config
+import os
+import unittest
+
+import opalescence
+from ..btlib.client import Client
+from ..btlib.metainfo import MetaInfoFile
+
+_LoggingConfig = {
+    "version": 1,
+    "formatters": {
+        "basic": {
+            "format": "%(asctime)s : %(name)s : [%(levelname)s] %(message)s"
+        }
+    },
+    "handlers": {
+        "stdout": {
+            "class": "logging.StreamHandler",
+            "level": "DEBUG",
+            "formatter": "basic",
+            "stream": "ext://sys.stdout"
+
+        }
+    },
+    "loggers": {
+        "opalescence": {
+            "level": "DEBUG",
+            "handlers": ["stdout"]
+        }
+    },
+    "root": {
+        "level": "DEBUG",
+        "handlers": ["stdout"]
+    }
+}
+
+logger = None
+
+
+def main():
+    """
+    CLI entry point
+    """
+    global logger
+    argparser = create_argparser()
+
+    try:
+        args = argparser.parse_args()
+        _LoggingConfig["root"]["level"] = args.loglevel
+        logging.config.dictConfig(_LoggingConfig)
+        logger = logging.getLogger("opalescence")
+        args.func(args)
+    except AttributeError:
+        argparser.print_help()
+
+
+def create_argparser() -> argparse.ArgumentParser:
+    """
+    Initializes the root argument parser and any necessary
+    subparsers for supported subcommands.
+    :return:    argparse.ArgumentParser instance
+    """
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--version", action="version",
+                        version=opalescence.__version__)
+    parser.add_argument("-d", "--debug", help="Print debug-level output.",
+                        action="store_const", dest="loglevel",
+                        const=logging.DEBUG, default=logging.WARNING)
+    parser.add_argument("-v", "--verbose", help="Print verbose output (but "
+                                                "still less verbose than "
+                                                "debug-level.",
+                        action="store_const", dest="loglevel",
+                        const=logging.INFO)
+
+    subparsers = parser.add_subparsers()
+    test_parser = subparsers.add_parser("test", help="Run the test suite")
+    test_parser.set_defaults(func=test)
+    download_parser = subparsers.add_parser("download",
+                                            help="Download a .torrent file.")
+    download_parser.add_argument('torrent_file',
+                                 help="Path to the .torrent file to download.")
+    download_parser.add_argument('destination',
+                                 help="File destination path.")
+    download_parser.set_defaults(func=download)
+    return parser
+
+
+def test(_) -> None:
+    """
+    Runs the test suite found in the tests/ directory
+    :param _: unused
+    """
+    logger.info(f"Running the test suite on the files in development.")
+
+    loader = unittest.defaultTestLoader()
+    runner = unittest.TextTestRunner()
+    suite = loader.discover(
+        os.path.abspath(os.path.join(os.path.dirname(__file__), "tests")))
+    runner.run(suite)
+
+
+def download(file_path) -> None:
+    """
+    Downloads a .torrent file
+    :param file_path: .torrent filepath argparse.Namespace object
+    """
+    logger.info(f"Downloading {file_path.torrent_file} to "
+                f"{file_path.destination}")
+
+    loop = asyncio.get_event_loop()
+    loop.set_debug(True)
+    torrent = MetaInfoFile.from_file(file_path.torrent_file)
+    client = Client()
+    client.download(torrent)
+
+    try:
+        loop.run_forever()
+    except asyncio.CancelledError:
+        logger.warning("Event loop was cancelled")
+    except KeyboardInterrupt:
+        logger.warning("Keyboard Interrupt received.")
+    finally:
+        loop.close()
diff --git a/opl.py b/opl.py
new file mode 100644
index 0000000..28c2e3a
--- /dev/null
+++ b/opl.py
@@ -0,0 +1,10 @@
+# !/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Opalescence is a simple torrent client written using Python3.6.
+"""
+from opalescence.ui import cli
+
+if __name__ == "__main__":
+    cli.main()
diff --git a/setup.py b/setup.py
index e552900..52ee3fb 100644
--- a/setup.py
+++ b/setup.py
@@ -4,31 +4,26 @@
 """
 setuptools script for opalescence
 """
-
 from setuptools import setup
 
+import opalescence
+
 with open("README.md") as readme_file:
     readme = readme_file.read()
 
-with open("VERSION") as version_file:
-    version = version_file.read()
-
 requirements = [
-    "requests", 'aiohttp', 'bitstring'
+    "requests", "aiohttp", "bitstring"
 ]
 
 setup(
     name="opalescence",
-    version=version,
-    description="Torrent client offering basic functionality.",
+    version=opalescence.__version__,
+    description="A torrent client written using Python 3.6 and asyncio",
     long_description=readme,
     author="brian houston morrow",
     author_email="bhm@brianmorrow.net",
     url="https://github.com/killerbat00/opalescence",
-    packages=[
-        "opalescence"
-    ],
-    package_dir={"opalescence": "opalescence"},
+    packages=["opalescence"],
     install_requires=requirements,
     license="MIT license",
     zip_safe=False,
@@ -39,4 +34,5 @@ setup(
         "Programming Language :: Python :: 3.6"
     ],
     test_suite="tests",
+    include_package_data=True,
 )


Test patch:
diff --git a/tests/context.py b/tests/context.py
index fc82054..16de67c 100644
--- a/tests/context.py
+++ b/tests/context.py
@@ -12,7 +12,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')
 # noinspection PyUnresolvedReferences
 from opalescence.btlib import bencode
 # noinspection PyUnresolvedReferences
-from opalescence.btlib import torrent
+from opalescence.btlib import metainfo
 # noinspection PyUnresolvedReferences
 from opalescence.btlib import tracker
 # noinspection PyUnresolvedReferences
diff --git a/tests/test_peer.py b/tests/test_peer.py
index 9d458e3..a7604da 100644
--- a/tests/test_peer.py
+++ b/tests/test_peer.py
@@ -9,7 +9,7 @@ from unittest import TestCase
 from requests import get
 
 import opalescence.btlib.protocol.peer
-from tests.context import torrent, tracker
+from tests.context import metainfo, tracker
 from tests.utils import async_run
 
 
@@ -33,7 +33,7 @@ class TestPeer(TestCase):
                 file_data = r.content
                 with open(cls.external_torrent_path, "wb+") as f:
                     f.write(file_data)
-        cls.torrent = torrent.Torrent.from_file(cls.external_torrent_path)
+        cls.torrent = metainfo.MetaInfoFile.from_file(cls.external_torrent_path)
         cls.tracker = tracker.Tracker(cls.torrent)
 
     @classmethod
diff --git a/tests/test_torrent.py b/tests/test_torrent.py
index 781b90f..01d23fd 100644
--- a/tests/test_torrent.py
+++ b/tests/test_torrent.py
@@ -12,7 +12,7 @@ from unittest import TestCase
 
 from requests import get
 
-from tests.context import torrent, bencode
+from tests.context import metainfo, bencode
 
 
 class TestTorrent(TestCase):
@@ -41,15 +41,17 @@ class TestTorrent(TestCase):
         """
         invalid_path = "Doesn't exist"
         with self.subTest(msg="Invalid path"):
-            with self.assertRaises(torrent.CreationError):
-                torrent.Torrent.from_file(invalid_path)
+            with self.assertRaises(metainfo.CreationError):
+                metainfo.MetaInfoFile.from_file(invalid_path)
 
     def test_valid_path(self):
         """
         Test that we get a torrent object from a valid path.
         """
         with self.subTest(msg="Valid path"):
-            self.assertIsInstance(torrent.Torrent.from_file(self.external_torrent_path), torrent.Torrent)
+            self.assertIsInstance(
+                metainfo.MetaInfoFile.from_file(self.external_torrent_path),
+                metainfo.MetaInfoFile)
 
     def test_invalid_torrent_metainfo(self):
         """
@@ -64,8 +66,8 @@ class TestTorrent(TestCase):
             f.truncate(file_size // 2)
 
         # the metainfo dictionary is entirely corrupted now, so we should expect a CreationError
-        with self.assertRaises(torrent.CreationError):
-            torrent.Torrent.from_file(copy_file_name)
+        with self.assertRaises(metainfo.CreationError):
+            metainfo.MetaInfoFile.from_file(copy_file_name)
 
         os.remove(copy_file_name)
 
@@ -73,7 +75,8 @@ class TestTorrent(TestCase):
         """
         Test that we gathered files appropriately.
         """
-        external_torrent = torrent.Torrent.from_file(self.external_torrent_path)
+        external_torrent = metainfo.MetaInfoFile.from_file(
+            self.external_torrent_path)
         filename = ".".join(os.path.basename(self.external_torrent_path).split(".")[:-1])
         for f in external_torrent.files:
             self.assertEqual(f.path, filename)
@@ -83,7 +86,7 @@ class TestTorrent(TestCase):
         Tests the properties of the torrent metainfo file.
         """
         announce_urls = [["http://torrent.ubuntu.com:6969/announce"], ["http://ipv6.torrent.ubuntu.com:6969/announce"]]
-        t = torrent.Torrent.from_file(self.external_torrent_path)
+        t = metainfo.MetaInfoFile.from_file(self.external_torrent_path)
         for f in announce_urls:
             self.assertIn(f, t.announce_urls)
         comment = "Ubuntu CD releases.ubuntu.com"
@@ -101,7 +104,7 @@ class TestTorrent(TestCase):
         Tests that the torrent's info hash property returns the correct info hash.
         """
         infohash_digest = b"\xdaw^J\xafV5\xefrX:9\x19w\xe5\xedo\x14a~"
-        t = torrent.Torrent.from_file(self.external_torrent_path)
+        t = metainfo.MetaInfoFile.from_file(self.external_torrent_path)
         self.assertEqual(infohash_digest, t.info_hash)
 
     def test_decode_recode_compare(self):
@@ -129,7 +132,8 @@ class TestTorrent(TestCase):
         Tests that we can open an externally created .torrent file, decode it, create a torrent instance,
         then rewrite it into another file. The resulting two files should be equal.
         """
-        external_torrent = torrent.Torrent.from_file(self.external_torrent_path)
+        external_torrent = metainfo.MetaInfoFile.from_file(
+            self.external_torrent_path)
         file_copy = os.path.abspath(os.path.join(os.path.dirname(__file__), "copy.torrent"))
         external_torrent.to_file(file_copy)
         self.assertTrue(cmp(self.external_torrent_path, file_copy))
@@ -140,11 +144,13 @@ class TestTorrent(TestCase):
         Decodes a torrent file created using an external program, reencodes that file to a .torrent,
         decodes the resulting torrent and compares its dictionary with the original decoded dictionary.
         """
-        external_torrent = torrent.Torrent.from_file(self.external_torrent_path)
+        external_torrent = metainfo.MetaInfoFile.from_file(
+            self.external_torrent_path)
         original_data = external_torrent.meta_info
         temp_output_filename = os.path.abspath(os.path.join(os.path.dirname(__file__), "copy.torrent"))
         external_torrent.to_file(temp_output_filename)
-        new_data = torrent.Torrent.from_file(temp_output_filename).meta_info
+        new_data = metainfo.MetaInfoFile.from_file(
+            temp_output_filename).meta_info
         self.assertEqual(original_data, new_data)
         os.remove(temp_output_filename)
 
@@ -172,5 +178,5 @@ class TestTorrent(TestCase):
                     invalid_file_list]
         for b in bad_data:
             with self.subTest(b=b):
-                with self.assertRaises(torrent.CreationError):
-                    torrent._validate_torrent_dict(b)
+                with self.assertRaises(metainfo.CreationError):
+                    metainfo._validate_torrent_dict(b)
diff --git a/tests/test_tracker.py b/tests/test_tracker.py
index 87febe2..a2da6cc 100644
--- a/tests/test_tracker.py
+++ b/tests/test_tracker.py
@@ -12,7 +12,7 @@ from urllib.parse import urlencode
 
 from requests import get
 
-from tests.context import torrent
+from tests.context import metainfo
 from tests.context import tracker
 from tests.utils import async_run, create_async_mock
 
@@ -36,7 +36,7 @@ class TestTracker(TestCase):
                 file_data = r.content
                 with open(cls.external_torrent_path, "wb+") as f:
                     f.write(file_data)
-        cls.torrent = torrent.Torrent.from_file(cls.external_torrent_path)
+        cls.torrent = metainfo.MetaInfoFile.from_file(cls.external_torrent_path)
 
     def test_creation(self):
         """