From 2ba86aece7e65e008cb32749fa16cff8c0360cca Mon Sep 17 00:00:00 2001
From: Maeva Pourpoint <maeva@passcal.nmt.edu>
Date: Thu, 16 Apr 2020 15:01:33 -0600
Subject: [PATCH] Updated and tested data2passcal to work with Python 2 and 3

---
 AUTHORS.rst                           |   2 +-
 CONTRIBUTING.rst                      |   3 +-
 HISTORY.rst                           |   8 +
 README.rst                            |  20 +--
 data2passcal/__init__.py              |   2 +-
 data2passcal/config.ini               |  22 +++
 data2passcal/config.py                |  31 ++++
 data2passcal/data2passcal.py          | 243 +++++++++++++++-----------
 setup.cfg                             |   2 +-
 setup.py                              |  10 +-
 tests/test_data/ST00.AB..BHZ.2007.160 | Bin 0 -> 1024 bytes
 tests/test_data/ST00.AB..BHZ.2007.161 | Bin 0 -> 1024 bytes
 tests/test_data/ST00.AB..BHZ.2007.162 | Bin 0 -> 1024 bytes
 tests/test_data/ST00.AB..BHZ.2007.163 | Bin 0 -> 1024 bytes
 tests/test_data/ST00.AB..BHZ.2007.164 | Bin 0 -> 1024 bytes
 tests/test_data2passcal.py            |  99 +++++++++--
 tox.ini                               |  13 +-
 17 files changed, 308 insertions(+), 147 deletions(-)
 create mode 100644 data2passcal/config.ini
 create mode 100644 data2passcal/config.py
 create mode 100644 tests/test_data/ST00.AB..BHZ.2007.160
 create mode 100644 tests/test_data/ST00.AB..BHZ.2007.161
 create mode 100644 tests/test_data/ST00.AB..BHZ.2007.162
 create mode 100644 tests/test_data/ST00.AB..BHZ.2007.163
 create mode 100644 tests/test_data/ST00.AB..BHZ.2007.164

diff --git a/AUTHORS.rst b/AUTHORS.rst
index 8029b24..207d6dd 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -10,4 +10,4 @@ Development Lead
 Contributors
 ------------
 
-None yet. Why not be the first?
+* Maeva Pourpoint <software-support@passcal.nmt.edu>
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index e8536cc..b262255 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -76,7 +76,7 @@ Ready to contribute? Here's how to set up `data2passcal` for local development.
 5. When you're done making changes, check that your changes pass the
    tests::
 
-    $ python setup.py test
+    $ python -m pytest
 6. Commit your changes and push your branch to GitHub::
 
     $ git add .
@@ -111,4 +111,3 @@ Then run::
 
 $ git push
 $ git push --tags
-
diff --git a/HISTORY.rst b/HISTORY.rst
index db916b9..d35c92d 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -17,3 +17,11 @@ History
 * Resources used: Python-Future project and  Futurize tool.
 * Changes tested using unittest module on individual test methods(tests.test_data2passcal)
 * Changes tested against real seismic dataset (AB.2002 - 2012)
+
+2020.107 (2020-04-16)
+------------------
+* Added some unit tests to ensure basic functionality of data2passcal
+* Created configuration file (.ini file) to store and access ftp and test related variables
+* Updated list of platform specific dependencies to be installed when installing data2passcal (see setup.py)
+* Installed and tested data2passcal against Python2.7 and Python3.6 using tox
+* Formatted Python code to conform to the PEP8 style guide (exception: E722, E712, E501)
diff --git a/README.rst b/README.rst
index efc03cf..a1160b9 100644
--- a/README.rst
+++ b/README.rst
@@ -3,23 +3,9 @@ data2passcal
 ============
 
 
-Prepare SEED data for shipment to PASSCAL.
-Only accept day-long MSEED files.
+Description: Send MSEED files ready for archival at the DMC to PASSCAL's QC system
+             Only accept day-long MSEED files.
+Usage:       data2passcal dir
 
 
 * Free software: GNU General Public License v3 (GPLv3)
-
-
-
-Features
---------
-
-* TODO
-
-Credits
--------
-
-This package was created with Cookiecutter_ and the `passoft/cookiecutter`_ project template.
-
-.. _Cookiecutter: https://github.com/audreyr/cookiecutter
-.. _`passoft/cookiecutter`: https://git.passcal.nmt.edu/passoft/cookiecutter
diff --git a/data2passcal/__init__.py b/data2passcal/__init__.py
index afe21e9..8f21cef 100644
--- a/data2passcal/__init__.py
+++ b/data2passcal/__init__.py
@@ -4,4 +4,4 @@
 
 __author__ = """IRIS PASSCAL"""
 __email__ = 'software-support@passcal.nmt.edu'
-__version__ = '2020.093'
+__version__ = '2020.107'
diff --git a/data2passcal/config.ini b/data2passcal/config.ini
new file mode 100644
index 0000000..6422e38
--- /dev/null
+++ b/data2passcal/config.ini
@@ -0,0 +1,22 @@
+[FTP_INFO]
+ftp_ip = 129.138.26.29
+ftp_user = ftp
+ftp_password = data2passcal
+ftp_send_attempts = 3
+ftp_blocksize = 8192
+ftp_timeout = 120
+ftp_dir = AUTO/MSEED
+ftp_reconnect_wait = 60
+ftp_debug_level = 0
+ftp_host = qc.passcal.nmt.edu
+
+[TEST]
+ftp_dir = AUTO/TEST
+ftp_reconnect_wait = 5
+mock_test = True
+testmode = False
+ftp_timeout = 5
+
+[FILES]
+logfile = data2passcal.log
+
diff --git a/data2passcal/config.py b/data2passcal/config.py
new file mode 100644
index 0000000..6f3ac69
--- /dev/null
+++ b/data2passcal/config.py
@@ -0,0 +1,31 @@
+#!/usr/bin/env python
+'''
+data2passcal
+
+Create configuration file
+'''
+
+import configparser
+
+VERSION = '2020.107'
+
+config = configparser.ConfigParser()
+config['FTP_INFO'] = {'FTP_IP': '129.138.26.29',
+                      'FTP_HOST': 'qc.passcal.nmt.edu',
+                      'FTP_USER': 'ftp',
+                      'FTP_PASSWORD': 'data2passcal',
+                      'FTP_DIR': 'AUTO/MSEED',
+                      'FTP_TIMEOUT': '120',
+                      'FTP_RECONNECT_WAIT': '60',
+                      'FTP_BLOCKSIZE': '8192',
+                      'FTP_SEND_ATTEMPTS': '3',
+                      'FTP_DEBUG_LEVEL': '0'}
+config['TEST'] = {'TESTMODE': 'False',
+                  'FTP_DIR': 'AUTO/TEST',
+                  'FTP_TIMEOUT': '5',
+                  'FTP_RECONNECT_WAIT': '5',
+                  'MOCK_TEST': 'True'}
+config['FILES'] = {'LOGFILE': 'data2passcal.log'}
+
+with open('config.ini', 'w') as configfile:
+    config.write(configfile)
diff --git a/data2passcal/data2passcal.py b/data2passcal/data2passcal.py
index a4afaaf..7cb8551 100644
--- a/data2passcal/data2passcal.py
+++ b/data2passcal/data2passcal.py
@@ -5,62 +5,77 @@ data2passcal
 Ship miniseed data to passcal via ftp for qc before archival at DMC DMS
 Lloyd Carothers IRIS/PASSCAL
 '''
-from __future__ import print_function, division
-from builtins import *
-VERSION = '2020.093'
+from __future__ import division, print_function
 
-import ftplib
-import sys, os, signal, struct, pickle, logging
-from time import time, sleep
+import configparser
 import datetime
+import ftplib
+import logging
+import os
+import pickle
+import re
+import signal
+import struct
+import sys
+from time import sleep, time
 
-#TESTMODE = True
-TESTMODE = False
-# Verbosity of console log from logging module from DEBUG to CRITICAL
-DEBUG =  logging.DEBUG
-# After transmission or on interrupt try to ftp logfile to PIC
-#SEND_LOGFILE = True
-
-#FTP related
-FTP_IP = '129.138.26.29'
-FTP_HOST = 'qc.passcal.nmt.edu'
-FTP_USER = 'ftp'
-FTP_PASSWORD = 'data2passcal'
-FTP_DIR = 'AUTO/MSEED'
-FTP_TIMEOUT = 120
-FTP_RECONNECT_WAIT = 60
+VERSION = '2020.107'
+
+
+# Cache the ftplib.FTP class so it will be available to test_FTP(FTP) when calling isinstance assert
+FTPCache = ftplib.FTP
+
+# Parse configuration file
+config = configparser.ConfigParser()
+print(config.read('data2passcal/config.ini'))
+
+
+# TEST related
+TESTMODE = config.getboolean('TEST', 'TESTMODE')
+
+# FTP related
+FTP_IP = config.get('FTP_INFO', 'FTP_IP')
+FTP_HOST = config.get('FTP_INFO', 'FTP_HOST')
+FTP_USER = config.get('FTP_INFO', 'FTP_USER')
+FTP_PASSWORD = config.get('FTP_INFO', 'FTP_PASSWORD')
+FTP_DIR = config.get('FTP_INFO', 'FTP_DIR')
+FTP_TIMEOUT = config.getint('FTP_INFO', 'FTP_TIMEOUT')
+FTP_RECONNECT_WAIT = config.getint('FTP_INFO', 'FTP_RECONNECT_WAIT')
 if TESTMODE:
-    FTP_DIR = 'AUTO/TEST'
-    FTP_TIMEOUT = 5
-    FTP_RECONNECT_WAIT = 5
-FTP_BLOCKSIZE = 8192
-#number of time to try to open the ftp connection
-#FTP_CONNECT_ATTEMPTS = 3
-# testing will retry for a week. Why not?
-FTP_CONNECT_ATTEMPTS = 60*60*24*7  / FTP_RECONNECT_WAIT
-#number of times a single file with try to be sent
-FTP_SEND_ATTEMPTS = 3
+    FTP_DIR = config.get('TEST', 'FTP_DIR')
+    FTP_TIMEOUT = config.getint('TEST', 'FTP_TIMEOUT')
+    FTP_RECONNECT_WAIT = config.getint('TEST', 'FTP_RECONNECT_WAIT')
+FTP_BLOCKSIZE = config.getint('FTP_INFO', 'FTP_BLOCKSIZE')
+# number of time to try to open the ftp connection - testing will retry for a week. Why not?
+FTP_CONNECT_ATTEMPTS = 60 * 60 * 24 * 7 / FTP_RECONNECT_WAIT
+# number of times a single file with try to be sent
+FTP_SEND_ATTEMPTS = config.getint('FTP_INFO', 'FTP_SEND_ATTEMPTS')
 # debug level of ftplib, 0-3
-FTP_DEBUG_LEVEL = 0
-#store the files sent in ~/data2passcal.sent
-SENTFILE = os.path.join( os.path.expanduser('~'), '.data2passcal.sent')
+FTP_DEBUG_LEVEL = config.getint('FTP_INFO', 'FTP_DEBUG_LEVEL')
+
+# Files related
+LOGFILE = config.get('FILES', 'LOGFILE')
+# store the files sent in ~/data2passcal.sent
+SENTFILE = os.path.join(os.path.expanduser('~'), '.data2passcal.sent')
 # If this file exists open it, incorporate, and save to new name, and delete old
-SENTFILE_OLD = os.path.join( os.path.expanduser('~'), '.send2passcal.sent')
-LOGFILE = 'data2passcal.log'
+SENTFILE_OLD = os.path.join(os.path.expanduser('~'), '.send2passcal.sent')
 
 HELP = '''
 data2passcal
 VERSION: %s
 Usage: data2passcal dir
-  data2passcal is a utility that sends MSEED files ready for archival at the DMC to PASSCAL's QC system by:
-  Scans all files below directory dir.
-  Filters out non-miniseed files, by inspecting the first blockette of each file.
-  Sends the files to the automated system for ingestion into the QC system via ftp.
+  data2passcal is a utility that sends day-long MSEED files ready for archival at the DMC to PASSCAL's QC system by:
+  Scanning all files below directory dir.
+  Filtering out non-miniseed files, by inspecting the first blockette of each file.
+  Sending the files to the automated system for ingestion into the QC system via ftp.
   You can send a SIGTERM (ctl-c) to data2passcal and it will shutdown cleanly.
   A list of sent files is kept in ~/.data2passcal.sent. Subsequent runs of send2passcal will not send files already sent.
   A log is stored in data2passcal.log in the current directory.
 ''' % VERSION
 
+# Verbosity of console log from logging module from DEBUG to CRITICAL
+DEBUG = logging.DEBUG
+
 # Configure logging file and stdout
 # todo if sending a file do we want to send all previous logs even if already sent
 logger = logging.getLogger('__name__')
@@ -73,16 +88,17 @@ logger.addHandler(logfh)
 # Log to stdout
 logconsole = logging.StreamHandler()
 logconsole.setLevel(logging.INFO)
-logconsole.setFormatter(logging.Formatter('%(message)s' ))
+logconsole.setFormatter(logging.Formatter('%(message)s'))
 logger.addHandler(logconsole)
 logger.debug('Program starting.')
-logger.debug('%s - v%s - Python:%s' % (sys.argv, VERSION, sys.version) )
+logger.debug('%s - v%s - Python:%s' % (sys.argv, VERSION, sys.version))
 logger.debug('CWD: %s' % os.getcwd())
-logger.info('Version: '+ VERSION)
+logger.info('Version: ' + VERSION)
 logger.info('TIMEOUT: %d' % FTP_TIMEOUT)
 logger.info('FTP RECONNECT WAIT: %d' % FTP_RECONNECT_WAIT)
 # -- End logging
 
+
 def scan_dir(dir):
     '''Returns a list of absolute file names found below root dir'''
     rootdir = dir
@@ -99,17 +115,18 @@ def scan_dir(dir):
             try:
                 filesize += os.path.getsize(f)
             except OSError:
-                logger.debug('Can not stat %s'% f)
+                logger.debug('Can not stat %s' % f)
             else:
                 filelist.append(f)
-    logger.info('Total Size = %s'% format_size(filesize) )
-    logger.info('Total Files = %s'% len(filelist) )
-    logger.info('Total Dirs = %s'% foldercount)
-    logger.info('Scan time = %0.2fs'% (time() - starttime))
+    logger.info('Total Size = %s' % format_size(filesize))
+    logger.info('Total Files = %s' % len(filelist))
+    logger.info('Total Dirs = %s' % foldercount)
+    logger.info('Scan time = %0.2fs' % (time() - starttime))
     print()
 
     return filelist
 
+
 def sendable(file):
     '''Filters files scanned returning a new list of files to send i.e. miniseed'''
     if os.path.basename(file).startswith('.'):
@@ -118,11 +135,12 @@ def sendable(file):
         return True
     return False
 
-#Basic Miniseed file metadata extracted from filename
-import re
-#This includes .p files which we may accept in the future but won't h
+
+# Basic Miniseed file metadata extracted from filename
+# This includes .p files which we may accept in the future but won't h
 MseedRE = re.compile(r'\A(.*)\.([A-Z0-9][A-Z0-9])\.(.*)\.([A-Z][A-Z]\w)\.([0-9]{4})\.([0-9]{3})(?:\.p)*')
 
+
 def filename_qc_format(file):
     '''Used to filter filename if correct for qc system'''
     return MseedRE.match(os.path.basename(file))
@@ -130,15 +148,16 @@ def filename_qc_format(file):
 
 def format_size(num):
     '''Format bytes into human readble with suffix'''
-    for suffix in [ 'bytes', 'KB', 'MB', 'GB'  ]:
+    for suffix in ['bytes', 'KB', 'MB', 'GB']:
         if num < 1024.0:
             return '%3.3f %s' % (num, suffix)
         num /= 1024.0
     return '%3.3f %s' % (num, 'TB')
 
+
 def ismseed(file):
     try:
-        ms = open(file,'rb')
+        ms = open(file, 'rb')
     except:
         return None
     order = ByteOrder(ms)
@@ -150,24 +169,26 @@ def ismseed(file):
         return False
 
 #########################################################
-#needs import sys,struct
-#Taken from Bruces LibTrace
-def ByteOrder(infile, seekval=20) :
+# needs import sys,struct
+# Taken from Bruces LibTrace
+
+
+def ByteOrder(infile, seekval=20):
     """
     read file as if it is mseed just pulling time info
     from fixed header and determine if it makes sense unpacked
     as big endian or little endian
     """
     Order = "unknown"
-    try :
-        #seek to timeblock and read
+    try:
+        # seek to timeblock and read
         infile.seek(seekval)
-        timeblock=infile.read(10)
+        timeblock = infile.read(10)
 
-        #assume big endian
-        (Year, Day, Hour, Min, Sec, junk, Micro)=\
-         struct.unpack('>HHBBBBH',timeblock)
-        #test if big endian read makes sense
+        # assume big endian
+        (Year, Day, Hour, Min, Sec, junk, Micro) =\
+            struct.unpack('>HHBBBBH', timeblock)
+        # test if big endian read makes sense
         if 1950 <= Year <= 2050 and \
            1 <= Day <= 366 and \
            0 <= Hour <= 23 and \
@@ -175,23 +196,24 @@ def ByteOrder(infile, seekval=20) :
            0 <= Sec <= 59:
             Order = "big"
         else:
-            #try little endian read
-            (Year, Day, Hour, Min, Sec, junk, Micro)=\
-             struct.unpack('<HHBBBBH',timeblock)
-            #test if little endian read makes sense
+            # try little endian read
+            (Year, Day, Hour, Min, Sec, junk, Micro) =\
+                struct.unpack('<HHBBBBH', timeblock)
+            # test if little endian read makes sense
             if 1950 <= Year <= 2050 and \
                1 <= Day <= 366 and \
                0 <= Hour <= 23 and \
                0 <= Min <= 59 and \
                0 <= Sec <= 59:
                 Order = "little"
-    except Exception as e:
+    except Exception:
         pass
 
     return Order
 #########################################################
 
-def get_sent_file_list(sentfile = SENTFILE):
+
+def get_sent_file_list(sentfile=SENTFILE):
     sentlist = []
     if os.path.isfile(sentfile):
         logger.debug('Using sentfile %s' % sentfile)
@@ -205,7 +227,8 @@ def get_sent_file_list(sentfile = SENTFILE):
                 sentlist = pickle.load(f)
     return sentlist
 
-def write_sent_file_list(sentlist , sentfile = SENTFILE ):
+
+def write_sent_file_list(sentlist, sentfile=SENTFILE):
     logger.info('Saving list of files sent to passcal')
     with open(sentfile, 'wb+') as f:
         pickle.dump(sentlist, f, protocol=2)
@@ -218,8 +241,8 @@ def get_FTP():
         trys += 1
         try:
             import socket
-            logger.info('Connecting to FTP host %s from %s. Attempt %d of %d' % ( FTP_HOST, socket.gethostbyname(socket.gethostname()), trys, FTP_CONNECT_ATTEMPTS))
-            FTP =  ftplib.FTP(host=FTP_HOST, user=FTP_USER, passwd=FTP_PASSWORD, timeout=FTP_TIMEOUT)
+            logger.info('Connecting to FTP host %s from %s. Attempt %d of %d' % (FTP_HOST, socket.gethostbyname(socket.gethostname()), trys, FTP_CONNECT_ATTEMPTS))
+            FTP = ftplib.FTP(host=FTP_HOST, user=FTP_USER, passwd=FTP_PASSWORD, timeout=FTP_TIMEOUT)
             FTP.set_debuglevel(FTP_DEBUG_LEVEL)
             FTP.cwd(FTP_DIR)
             FTP.set_pasv(True)
@@ -235,29 +258,36 @@ def get_FTP():
     logger.error('Giving up.')
     return None
 
+
 def test_network():
     passcal_http_reachable()
     google_http_reachable()
     passcal_ftp_reachable()
 
+
 def passcal_http_reachable():
     '''download and time passcal home page'''
     url = 'http://www.passcal.nmt.edu/'
     return url_reachable(url=url)
 
+
 def google_http_reachable():
     '''download and time passcal home page'''
     url = 'http://www.google.com/'
     return url_reachable(url=url)
 
+
 def passcal_ftp_reachable():
     '''Download a small file from passcals general ftp'''
     url = 'ftp://ftp.passcal.nmt.edu/download/public/test.dat'
     return url_reachable(url=url)
 
-def url_reachable(url = 'http://www.passcal.nmt.edu/'):
+
+def url_reachable(url='http://www.passcal.nmt.edu/'):
     '''fetches a url returns True or False'''
-    import urllib.request, urllib.error, urllib.parse
+    import urllib.request
+    import urllib.error
+    import urllib.parse
     start = time()
     try:
         f = urllib.request.urlopen(url)
@@ -265,20 +295,20 @@ def url_reachable(url = 'http://www.passcal.nmt.edu/'):
         logger.error("Failed to open connection to %s" % url)
         logger.error(e)
         return False
-    logger.info('connection made to %s in %f sec' % ( url, time() - start) )
+    logger.info('connection made to %s in %f sec' % (url, time() - start))
     data = f.read()
     runtime = time() - start
     size = len(data)
-    rate = size/runtime
-    logger.info('%d B/sec in %f sec' % (rate, runtime) )
+    rate = size / runtime
+    logger.info('%d B/sec in %f sec' % (rate, runtime))
     return True
 
 
 def test_FTP(FTP):
     try:
-        assert isinstance(FTP, ftplib.FTP)
+        assert isinstance(FTP, FTPCache)
         FTP.voidcmd('NOOP')
-    except ftplib.all_errors as e :
+    except ftplib.all_errors as e:
         logger.error(e)
         return False
     except AssertionError as e:
@@ -294,11 +324,11 @@ def send2passcal(mslist, sentlist=None):
     # Handle SIGINT while in this function: to gracefully close connection and save sentlist to disk file
     def signal_handler(signum, frame):
         print()
-        logger.info('Caught interrupt while FTPing. Aborting transfer %s' % current_file )
-        logger.info('Sent %d of %d' % (num_sent, num_to_send) )
-        logger.info('Sent %s of %s' % (format_size(size_sent), format_size(size_to_send)) )
-        logger.info('%s /sec' % ( format_size(size_sent / (time() - starttime )) ) )
-        logger.info('Ran for %f sec' % (time()-starttime) )
+        logger.info('Caught interrupt while FTPing. Aborting transfer %s' % current_file)
+        logger.info('Sent %d of %d' % (num_sent, num_to_send))
+        logger.info('Sent %s of %s' % (format_size(size_sent), format_size(size_to_send)))
+        logger.info('%s /sec' % (format_size(size_sent / (time() - starttime))))
+        logger.info('Ran for %f sec' % (time() - starttime))
         write_sent_file_list(sentlist)
         try:
             if FTP:
@@ -312,7 +342,7 @@ def send2passcal(mslist, sentlist=None):
         '''Updates the terminal display.'''
         signal.signal(signal.SIGINT, signal_handler)
         update.bytes_sent += len(data)
-        print('\r' + str(PB) + ' %s /sec ' % ( format_size(size_sent / (time() - starttime )) ), end=' ')
+        print('\r' + str(PB) + ' %s /sec ' % (format_size(size_sent / (time() - starttime))), end=' ')
         '''
         print '%s %0.2f%%. %0.10d / %0.10d.' % ( current_file.center(20),
                                                 (update.bytes_sent / update.file_size)*100,
@@ -320,8 +350,8 @@ def send2passcal(mslist, sentlist=None):
                                                 file_size,
                                                 ) ,
         '''
-        ETA_sec = ((time() - starttime) / size_sent ) * (size_to_send - size_sent)
-        print('ETA %s %s %s' % (  str(datetime.timedelta(seconds=(ETA_sec))), current_file.center(20), ' '*20 ), end=' ')
+        ETA_sec = ((time() - starttime) / size_sent) * (size_to_send - size_sent)
+        print('ETA %s %s %s' % (str(datetime.timedelta(seconds=(ETA_sec))), current_file.center(20), ' ' * 20), end=' ')
         sys.stdout.flush()
 
     if sentlist is None:
@@ -334,7 +364,7 @@ def send2passcal(mslist, sentlist=None):
     size_sent = 1
     current_file = ''
     signal.signal(signal.SIGINT, signal_handler)
-    logger.info('Sending %d, %s files to PASSCAL' % (num_to_send, format_size (size_to_send)))
+    logger.info('Sending %d, %s files to PASSCAL' % (num_to_send, format_size(size_to_send)))
     FTP = get_FTP()
     starttime = time()
     PB = ProgressBar(num_to_send)
@@ -349,19 +379,19 @@ def send2passcal(mslist, sentlist=None):
                 fh = open(f, 'rb')
                 file_size = os.path.getsize(f)
                 update.file_size = float(file_size)
-                FTP.storbinary('STOR %s' % current_file , fh, blocksize=FTP_BLOCKSIZE, callback=update)
+                FTP.storbinary('STOR %s' % current_file, fh, blocksize=FTP_BLOCKSIZE, callback=update)
             except ftplib.error_perm as e:
                 # This is permission and the error when 550 for the .in file already exists so we should just continue with the next file
                 # todo create a list of failed files and resend those at the end instead of requiring a rerun
-                logger.error( 'Failed to send file %s, permission error. Skipping...' % current_file)
+                logger.error('Failed to send file %s, permission error. Skipping...' % current_file)
                 logger.error(e)
                 break
-            except (ftplib.all_errors, AttributeError) as e :
-                #since we can restore with how we have proftp setup. There is nothing more we can do with this file
-                #Until the server rms the .in.file
-                logger.error('Failed to send file %s.' % ( current_file) ) #, trys, FTP_SEND_ATTEMPTS)
+            except (ftplib.all_errors, AttributeError) as e:
+                # since we can restore with how we have proftp setup. There is nothing more we can do with this file
+                # Until the server rms the .in.file
+                logger.error('Failed to send file %s.' % (current_file))  # , trys, FTP_SEND_ATTEMPTS)
                 logger.error(e)
-                #if DEBUG: print "Waiting %d..." % FTP_TIMEOUT; sleep(FTP_TIMEOUT)
+                # if DEBUG: print "Waiting %d..." % FTP_TIMEOUT; sleep(FTP_TIMEOUT)
                 try:
                     if FTP:
                         FTP.abort()
@@ -380,15 +410,17 @@ def send2passcal(mslist, sentlist=None):
                 break
 
     print()
-    logger.info( 'Sent %d of %d' % (num_sent, num_to_send) )
-    logger.info( 'Sent %s of %s' % (format_size(size_sent), format_size(size_to_send)) )
-    logger.info( '%s /sec' % ( format_size(size_sent / (time() - starttime )) ) )
-    logger.info( 'Ran for %f sec' % (time()-starttime) )
+    logger.info('Sent %d of %d' % (num_sent, num_to_send))
+    logger.info('Sent %s of %s' % (format_size(size_sent), format_size(size_to_send)))
+    logger.info('%s /sec' % (format_size(size_sent / (time() - starttime))))
+    logger.info('Ran for %f sec' % (time() - starttime))
     if FTP and test_FTP(FTP):
         FTP.quit()
 
 ########################################################
 # From progressbar 3rd party class will eventually dump not awesome
+
+
 class ProgressBar(object):
     def __init__(self, duration):
         self.duration = duration
@@ -409,7 +441,7 @@ class ProgressBar(object):
 
     def update_time(self, elapsed_secs):
         self.__update_amount((elapsed_secs / float(self.duration)) * 100.0)
-        #self.prog_bar += '  %d/%s files' % (elapsed_secs, self.duration)
+        # self.prog_bar += '  %d/%s files' % (elapsed_secs, self.duration)
 
     def __update_amount(self, new_amount):
         percent_done = int(round((new_amount / 100.0) * 100.0))
@@ -426,8 +458,10 @@ class ProgressBar(object):
 
 # End progressbar
 ########################################################
+
+
 def main():
-    if len(sys.argv) < 2 or sys.argv[1] in [ '-h', '--help', '-?']:
+    if len(sys.argv) < 2 or sys.argv[1] in ['-h', '--help', '-?']:
         print(HELP)
         sys.exit()
     # find all files below dir
@@ -436,19 +470,20 @@ def main():
     logger.info('Removing improperly named files.')
     msnamedlist = list(filter(filename_qc_format, filelist))
     logger.info('Properly named files files: %d' % len(msnamedlist))
-    logger.info('Other files: %d'% (len(filelist) - len(msnamedlist)))
+    logger.info('Other files: %d' % (len(filelist) - len(msnamedlist)))
     print()
     logger.info('Removing files that are not Miniseed.')
     mslist = list(filter(sendable, msnamedlist))
     logger.info('MiniSEED files: %d' % len(mslist))
-    logger.info('Properly named but not miniseed files: %d'% (len(msnamedlist) - len(mslist)))
+    logger.info('Properly named but not miniseed files: %d' % (len(msnamedlist) - len(mslist)))
     print()
     logger.info('Removing files already sent to PASSCAL')
     sentlist = get_sent_file_list()
     unsentms = [f for f in mslist if f not in sentlist]
-    logger.info('%d miniSEED files have already been sent, not resending.' % (len( mslist) - len(unsentms)))
+    logger.info('%d miniSEED files have already been sent, not resending.' % (len(mslist) - len(unsentms)))
     send2passcal(unsentms, sentlist)
     write_sent_file_list(sentlist)
 
+
 if __name__ == '__main__':
     main()
diff --git a/setup.cfg b/setup.cfg
index ac124ca..a6afa20 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 2020.093
+current_version = 2020.107
 commit = True
 tag = True
 
diff --git a/setup.py b/setup.py
index 6afb3d2..9f29d0f 100644
--- a/setup.py
+++ b/setup.py
@@ -29,8 +29,11 @@ setup(
             'data2passcal=data2passcal.data2passcal:main',
         ],
     },
-    install_requires=[],
-    setup_requires = [],
+    install_requires=[
+        "mock;python_version<'3.3'",
+        "configparser;python_version<'3.2'"
+    ],
+    setup_requires=[],
     extras_require={
         'dev': [
             'pip',
@@ -42,6 +45,7 @@ setup(
             'coverage',
             'Sphinx',
             'twine',
+            'pytest'
         ]
     },
     license="GNU General Public License v3",
@@ -52,6 +56,6 @@ setup(
     packages=find_packages(include=['data2passcal']),
     test_suite='tests',
     url='https://git.passcal.nmt.edu/passoft/data2passcal',
-    version='2020.093',
+    version='2020.107',
     zip_safe=False,
 )
diff --git a/tests/test_data/ST00.AB..BHZ.2007.160 b/tests/test_data/ST00.AB..BHZ.2007.160
new file mode 100644
index 0000000000000000000000000000000000000000..668ccee282506f7101f4f6de9e5175a3103d0043
GIT binary patch
literal 1024
zcmW;KOK;p%6bJC@YtQ&OevRkp$t0OH2_=LksFgre6e=W;*a1-`wruziAhBf2o&^g8
z9{`Y8bkS7=qKig^ypuNb$ari|?6Ez5+qZF!bk1LA@!K4ot*x7S=kuj6zTDnkT3UMi
z=~o{;CJ({S05kyiJ^pR=2lNvF0J~`bJ_K911^|OGw`(!5ot>SV_yr;K4;Zy~&DE#U
zdw*58o>n&xtMpNKz9$Ug)xOX?XNdtv+YFqAuR^4yiHvJ(IjP-voGk}|kG*`=f{`$@
zK^*&dgT>Hddmqq$>(?fKVXoQ!f|_^_;Qu6H<l>pDj=&l^iVA;EJFWSaCu~Rg9j;WB
z$TUP|$PT%>*Tl<NV()!CHy#Y$-QE4nNwZJXKi2MC20b^gm~U%^KY7UOr8oQ&y3EC>
z9mH~(AZ!Bshhrmx$c6&jS7Jf*7>DFody18$XO=RNk$_w(DE!LAUKvHC46?i8MB^Mn
zKixRq3j(NxB|H$bi+ddl>DhSHnNFmfuRBmp_*E_I%z<2Z*7TG}<^oP*8#W`gWGsqm
zQ}_sK?WWNxo9`dH^<STazkT<+`{>7S(4SwQ#~;`+(g8Z43NLB`^2F0ib)qdeK9g+X
zC(D^rw@LFvC3S~e7+5IWrcIuqQrI^n?ImELsHASb7#4oNf?p{$H}xho=_f%`=~wun
z7+fHtwtsOyyxvr?dKgp>EO=0k&RTWH>8hr(?lEshc;OdJr>p2@trHrh0CbJ(O-83k
zmY$|kl5$w!Q2Dr{=xig6t5L3)EOx{z@hlSZfGIqMyM#2WK(9UY;3XfDim|d_B;AIY
zv&UD&@FHMUMp(yEHMvd@IhZ0Nw&>Ql%ssaXPn_Uh*c{e)Ll_O6NboVzYThl2#0($r
z5&W2t2{>2DF`HMZ%lhm&mknj=Fld|MTlK;RS;<VBwnb)s2}8?07IM@q0xlw_X)6i3
z3|x+}WkB{Mhvan=Ph*^}P+dIRz~aJ7c(+nn*S(GL3p&%PTl45)<S$O(rM~>kuKe4O
zKu=K7tSw@}JfF<d7ryE;O2kzX|Mq{qG#o(xti%w&Vgx4|PWd!VQtPNcoChUlTwgK?
zs5Rj^G3uvNCvN~3&ax<QM&;Z4A)Jrg7MSwnoGp3e%#(}>oyHwiy1xiT49(5lp?Z0x
Tr^qbM2#^SU30`H$uF2eMGq>Ji

literal 0
HcmV?d00001

diff --git a/tests/test_data/ST00.AB..BHZ.2007.161 b/tests/test_data/ST00.AB..BHZ.2007.161
new file mode 100644
index 0000000000000000000000000000000000000000..f3fa1ece76f3655d59e123b5d92c235b4f5e9e4a
GIT binary patch
literal 1024
zcmW;I&2QUu7zgm5?fC6yJBgDxiSxdsO<!PbItG^sRw{u&;xy=C)BX@Z;>3ju{{a$Q
zIe_580ae<V*wA30uHD)~k~S|(^KLti?bvaC32`6kdGz}oKKkl;N~LAJaVPijCp$a2
zTyF1!d++Yir{FsPHUPCtd>;O4@eKd~TQ&f@phR>4fEc{`dIQ_v-(M^i?d3C@k)nB-
zq-<weC3pw=APHARJiwYmb=ZZxmi5h2CT8%w9z_w>q(`&%SXz3c!T=B-<H<e|?a+27
zIchQ0LmV$N@{kiJ4nq$dx89)96O!qL8)mX&NHaYt@hPk6dBVMz!PZQi#Mc-?fI~%Z
z*oz6zPsV^HNC9U;M37Z@)S|{c0|^!sP#$a3&S>h)J&z#nEF28kk(iJ5ha)r$lE9*<
z=VW{$Ax^EWAU5k0F(E(%&;CWHDq+xxd_c}bX~px<>ZXad(eF0w3}RAvM)pxN_wurj
z0*d8ob50_-1R0`DKk6;87lUxUzc6AxLMEl4=R<;N9F_5=xz3Xb4A0UhmhiHoTcnDI
z$s|2QqF@5pza+Hbtd4IkOm5s86WV|Qqq^sL39O2dO&gQ@nrjMfJ6iA&d7^S1o@L?k
z;G#T8r|p{zwnAOBi+^|Wr#yzIE(88^db;#?YXP6-I4MjK2xob*0M%KcPSOjVD-T^|
zJPP_vfhnWJv+i4VILQ?IrN{Q2#eMM6==(3e_VV8e*FXKKbgi5>B-K*?bE>1HyIuU_
zHmqejv4e;3!W=SI@HLkYggf)X@eK9kv#Drol5_oP|Lt=YRYU3o_HYiEx?uFAXs0QR
zn{wnqtxzS_`hFSlVQos5d7vZ>q>Qe;Qj#pF+deP95+{tvu|#Xz7f>=H4SC+$);VYw
zt7b5By#m-*e)oI}n-(A3u(8s&pUr;%@aR45uuyRmSH77OjqprREO{canxCWF)N0pq
zbZH&;?<)N2WBO)8iS(+*TrC!>dAvTSURTTlb&O|QUOtEEMr@V91BoAD{>fgwT6$Pd
zg@2Rof9dQVyq~<p8vLQ1JLoVin7F9HkuQ#A*;<x6ktTi5i@5b@ZT-(YvJ&aobAGP!
zzk>8%W0(`Qjd;)CT(sVXTmM*5<)u>!6<F<7TVMGhj!w28#XO%JZ4Kn~EEZxgZ6Fh&
t!n1h3dqdSxO;$wyNaWNF=Jf4x@Agx2{B-A+=B=OMo7%c1W?u-y{{d2SzWM+F

literal 0
HcmV?d00001

diff --git a/tests/test_data/ST00.AB..BHZ.2007.162 b/tests/test_data/ST00.AB..BHZ.2007.162
new file mode 100644
index 0000000000000000000000000000000000000000..75c29514caed9cc89925cc00f5edca42c11a3950
GIT binary patch
literal 1024
zcmW;HYiL__6aesZpSgM6<R(p<q^3<%`!EfC*jd`?>N-u4+RZV|m{no>FouGPAC!R)
zhJwxy>tI-msM`=K(y46dD06;sv&@dIUAu+8QqxBsZS&5}y*JPMH}SyvAI{<Y_;Wft
z%dzK({pDA>y6krQffq&w4k#AkGQeJFIz#ju&!X=D04pcJe&{5&0aTdk|F-gA9*?J7
zD1^{YA^l9s5sfO_i_vyINQG0%93A&^Vo7<cRyEa=^tR2{U=N<#ia)<X^o6q3R6>v}
zM}M45S?)W)i9SB1|Kh{%(eT*Ye`uZK$45)B7hQle$cwJSSLal3T!s_qCJ^u?ItV90
zfsVo~`rJ9`87a>u&`B6YKcG0gk50f8I*Zof)AHUs>99@5EO-C1Kf3wiy@r^pxID73
zcKpUNTtRLag3p|j6CQa7bD`VlF!mZ6L{~5loks6rRp?c84pSi`48kX<<Ac_5XP+sc
zIsCiJeSTnNYG{E_Oj|LHseFHLyh-;LtC<%XmYVv1ZSE?xs4bGb@qqLxn|wy+Vx4KG
zK__T;YG4x;Y^#22%v76-S8*$PS*rN%WT40!jH*n@NE>Ml8IZ;Fzgw+BSSu<SHHT8r
zObaDnB)cIOWX(p7iU<S=e302*wN|`DIs{JIu8VBSl4d=`h7tzZXF`ACF+U>^d978F
zcpWRMaa}<ml65$f2ps%5<Gy+%ml&$}--fxl-XN3DaN-u_nm4`V<y>O=eox}kTe<0$
z+m4X$qY2xa*M{#hgXfP$=bxR%V>&+E{;+eK>#hFAGxm@BkyYUz`5KM^jxNE)s~yc_
zZ~=L9!``mD*_JyhP9mSgdirj@?j_+6I(_I)V&CWhkLR(W*~<UV$h5-$?l`;W+uj?d
z=;q+XQ^30)?z_%eXX=Cas>!xQ)X>xUv|R0N6pD@4G_k3gM@c)X;wYiwEqL0nC~k$c
zGf;}u8`?F6+Fd~@)3_jE$?ZTgAEPv4*wHV_EKB%{xhzX0*Nav%t5fSMo{egi5jwe+
zmect<Ro2>WQP*N?yUC4UK`7}NbHn2niKT)hRRROCYcBff)nHvNrD$x^nk$~t;0rlA
zwUnv%b5GakQ}z0}Y&@Dv6I4l|=aG(dkyLjeZx7;+*|0gxm?FfOPMI-4++3)5zg+OI
zieic(46VwnT8uL>?|MPJm#yN>P8*9G^N7f#cMOS$Vu5Pn8I~tiq|PGZ)xLPeiz{MK
zd<sFE+{WxU#ZWQxcdTKBRu($+cC%QWQ@P_arrld5Kc)*lk#G)7H?y-O!H12>c-Md5
CAKgU&

literal 0
HcmV?d00001

diff --git a/tests/test_data/ST00.AB..BHZ.2007.163 b/tests/test_data/ST00.AB..BHZ.2007.163
new file mode 100644
index 0000000000000000000000000000000000000000..907b07ca7097a791aa3c346e26df10f9c7788851
GIT binary patch
literal 1024
zcmW;K$!_Cx00!{C?Koay$Bwf(PBL{nNw;B|l!2Z)Q)Z-*kU$zyrWX!80gu1~@DLn0
zbLIdi1XMzz8cJtcwUcIFn$6DQ*s=5XMm?YO9e?^RU%7mrPmkrVU+?eBvi#!nZ=Sy(
zuE9?L;Xty9eINh9{}BLy+!uh)Kp7hVKqEAGDZe~EeswG(CnqPq?|*mSFHe={?Z}aU
zEfVGHwzuMc{)entZTAV5%)zJmuF$Gg{|?{5ZH)BTmHv>8(G-%r_t$o(Wf?m@U9{s8
zs1r#nx@1O%vZVfah0MP_`$U@TXx>$-3rlAQ0>2KAE|MGXo(w4bufxE@%44KXQ}4(f
zR5(P<hrhmG{^p~ZH1<FjO?dcbnQo-VZtCsKTCx2;Bd|<Lw)NdiCyL$#p7ZW_kB&|b
zq~-$xh9{nFsudS@L}6CUVUj+Q+=?-{&?w(pvPA|Bn*>s=E$z;GYY}*zw^7+6O5|eI
zn~lfw^S;uNJ*~t*6qUM)c&lNRMYBs7OrA<nHZj$V-eS~P0v{Ix|4dd32XFyRLZA8O
zWJEx(fIWJq3fXI&TUHyoZcyuaF`bvK^!5Z}3{}?~6lW(g9vXNlluY5#kU%ha&qYvF
zvk4N*meYJ<V&wWg<a`Et<ZKuXN3+p<!VFDL-&!qogYW^C2|OD9h<614>c*V6J346j
z8+iJwZC?`1f6uF@T|4B*zSs#B)<f2~el_$IN3z|m1e8T9<^=w-HdF?qVMubhkM}oY
z4DM=|=k01^<#X{kj_zjXHtYj!$n@C~mY$=<scC4Akz;REDe%+IRs!F`wWvL0y)KIl
zVEv6fZ#g6OFr$%=P&73*=+Q41oK1;Bnl3QYXl!lO5X_z%4!jEsq!9;`zRoAY>1b6M
zuh4ncL2_g)o<w=VEi%)=T=@^<mkC4V@a&#I5%ufaPXEtX;LFp^-Gx%~C@qY+P=!Ee
z)-cLw`eSF0ab@hbrz})00vMXhj?ircqXxIlwx1#a<^|po*NJ3Sa8%23m8vI-F^`Ko
zD?b-8$wkvHN=uWYO<N5;W`!L%H`~>&A1iESHaDyf|Bu{xu<K!r#UI&C%L-c#&k^$o
z8F>mX@vu^vsv{KAt0v**_xi~a2|9>#W13S5*4BdkM6!g3=vJkBWyE-^jjQ!AyDlX`
iZ*g`O(yDZPkdSo2^o2##(1yS3YK=|JS^yD5uJk{l&E=f{

literal 0
HcmV?d00001

diff --git a/tests/test_data/ST00.AB..BHZ.2007.164 b/tests/test_data/ST00.AB..BHZ.2007.164
new file mode 100644
index 0000000000000000000000000000000000000000..b925f35dace4e038f204d9b993113d9318f277e5
GIT binary patch
literal 1024
zcmW;J%W~Uf00rP*_lqUly83=iC?upa2~Y-DkupFxumG~*S$F^*f<>Q!CBw2)rYvBl
zohHG#Dvl*vvSnF!$@-;6oSC!y<}A+P;X^+CVff=uM@Pfq@aeOkkDpR!;5k4>p!OX5
zMf$b)4FG^V6o4<mAvOnqMOg5~@cZw+K7RZya&mG~6vbB$wWB$^@ZF+VFcz`bfU8QS
z=bb9v>1+~+{pRS|QG-~SGu*pgod;3<^IE-8MU~Ps9!gf4bs<i{hOP?S>h=Qtc*Kh4
zjmRs9T)pjN#bttPalf6SO>SFN{3vm3;(elB`hMQfEq;OOFF5iXNZqGmG}8XKdSh?H
z(a>MwJVBu#y3h(y3~QY^Y<YBbiWN&^Vy{cjc83a;y$bvB)W|)HSGePEGJ${Tu7CHp
zXX8hCN0nKuk<+a1-2J@{?<>vNi7OHSj|QbDr}zBE;ZlcW>?RlP5BNNTx(h)>Kp!<D
zg_QT26r1CiZW_r5hpp7?3W4mb2TAD{5NwQ$Ri&7r=1_`mmT#Ag(G%h)Ld<6OIj=7X
zbOl=7ZX$!f{KP}Ks-7`dgHK$g^i*D*OK|Eh4+LG^m@L_D?e-lvPh(dP@nz*bR+JGz
zfKBv;4&AucUYp3cV{%8W+ga&9f-i;iDkmP(A-7=%6&=Xui)}Q<-g@YQ`H=4_=zzm6
z-c+w|e0*py_3b{FZ6kO&BdO98h@9QwMy#HaN0ZZmVqs#IF;N)YRHYEk5XI-QtPV))
zHRk*oP;+5lB>K&arc=u=eRjQKQ=h9VLLy2gLQbH1;Sra#09Aq65(=cT|JKMoT1|KN
zL^SUg7UFIo$A&oKmc9a$(FhIESi9VG3Jr41zs3`e>Zx_M6NeSp0~&?f9$8l;g3g^x
zl(#92Ol@6A|NqxhdK{r7xpz%kf#i<%Q@SIguIFj3o!969D~zbt;SfGdRd14dZr}-F
zlG&|bJ}An4ZHvrqAr%IjHI{i+EENbAxw<*M-Jq0*s`$-jSJ%{(!g5kW>JEa3DV632
z5@-^oN8NZkvGt-g702$lOVixYM)FAchwNX9DqxeS!gbYEt7D$s4kF{TZXgVebmxvx
z{c-7wSGC<~dv{jpXNHWeb^@>Jhelwz2N-#fHd+@BO~lsRCx8_nHiZvjQnpdi3>v}A
Feg_Dm+`#|<

literal 0
HcmV?d00001

diff --git a/tests/test_data2passcal.py b/tests/test_data2passcal.py
index a82fa89..02d5c33 100644
--- a/tests/test_data2passcal.py
+++ b/tests/test_data2passcal.py
@@ -3,25 +3,102 @@
 
 """Tests for `data2passcal` package."""
 
+from __future__ import division, print_function
+
+import configparser
+import ftplib
+import os
 import unittest
-import sys
+
+from data2passcal.data2passcal import get_FTP, ismseed, scan_dir, send2passcal
+
+VERSION = '2020.107'
+
 
 try:
-    import data2passcal
+    from unittest.mock import patch
 except ImportError:
-     pass
+    from mock import patch
+
+config = configparser.ConfigParser()
+config.read('data2passcal/config.ini')
+FTP_HOST = config.get('FTP_INFO', 'FTP_HOST')
+FTP_USER = config.get('FTP_INFO', 'FTP_USER')
+FTP_PASSWORD = config.get('FTP_INFO', 'FTP_PASSWORD')
+FTP_DIR = config.get('FTP_INFO', 'FTP_DIR')
+FTP_TIMEOUT = config.getint('FTP_INFO', 'FTP_TIMEOUT')
+FTP_RECONNECT_WAIT = config.getint('FTP_INFO', 'FTP_RECONNECT_WAIT')
+FTP_CONNECT_ATTEMPTS = 60 * 60 * 24 * 7 / FTP_RECONNECT_WAIT
+FTP_SEND_ATTEMPTS = config.getint('FTP_INFO', 'FTP_SEND_ATTEMPTS')
+MOCK_TEST = config.getboolean('TEST', 'MOCK_TEST')
+
 
 class TestData2passcal(unittest.TestCase):
     """Tests for `data2passcal` package."""
 
     def setUp(self):
-        """Set up test fixtures, if any."""
+        """Set up test fixtures, if any"""
+        dir_testdata = os.path.dirname(os.path.realpath(__file__)) + '/test_data'
+        filelist = [x for x in scan_dir(dir_testdata) if not os.path.basename(x).startswith('.') and not os.path.basename(x).endswith('.log')]
+        self.dir_testdata = dir_testdata
+        self.filelist = filelist
+
+    def test_scan_dir(self):
+        """
+        Test basic functionality of scan_dir function
+        Only 5 files available under ./test_data directory
+        Not taking into account .DS_STORE file
+        """
+        self.assertEqual(len(self.filelist), 5, 'Incorrect number of files')
+
+    def test_ismseed(self):
+        """Test basic functionality of ismseed function"""
+        for f in self.filelist:
+            self.assertTrue(ismseed(f), '{} is not a miniseed file'.format(os.path.basename(f)))
+
+    @patch('data2passcal.data2passcal.ftplib.FTP', autospec=True)
+    def test_get_FTP_mock(self, mock_ftp_constructor):
+        """Mock test creating ftp connection to PASSCAL"""
+        mock_ftp = mock_ftp_constructor.return_value
+        get_FTP()
+        mock_ftp_constructor.assert_called_with(host=FTP_HOST, user=FTP_USER, passwd=FTP_PASSWORD, timeout=FTP_TIMEOUT)
+        mock_ftp.cwd.assert_called_with(FTP_DIR)
+        self.assertLess(mock_ftp_constructor.call_count, FTP_CONNECT_ATTEMPTS, 'Number of ftp connection attempts exceeeds {}'.format(FTP_CONNECT_ATTEMPTS))
+        mock_ftp.quit()
+
+    @patch('data2passcal.data2passcal.ftplib.FTP', autospec=True)
+    def test_send_data_mock(self, mock_ftp_constructor):
+        """Mock test sending MSEED files (test data) to PASSCAL's QC system"""
+        mock_ftp = mock_ftp_constructor.return_value
+        send2passcal(self.filelist)
+        self.assertTrue(mock_ftp.storbinary.called, 'No data sent')
+        self.assertEqual(mock_ftp.storbinary.call_count, len(self.filelist), 'Failed to send all files - Sent {0} of {1}'.format(mock_ftp.storbinary.call_count, len(self.filelist)))
+        files_sent = []
+        for x in mock_ftp.storbinary.call_args_list:
+            args, kwargs = x
+            files_sent.append(args[0].split(' ')[1])
+        for f in self.filelist:
+            f = os.path.basename(f)
+            self.assertLess(files_sent.count(f), FTP_SEND_ATTEMPTS, 'Attempted to send file {0} more than {1} times'.format(f, FTP_SEND_ATTEMPTS))
+
+    @unittest.skipIf(MOCK_TEST == True, "skipping real send2passcal test")
+    def test_send_data(self):
+        """Test sending MSEED files (test data) to PASSCAL's QC system - Optional"""
+        ftp = get_FTP()
+        send2passcal(self.filelist)
+        wdir = ftp.pwd()
+        try:
+            files_sent = [os.path.basename(x) for x in ftp.nlst(wdir)]
+        except ftplib.error_perm() as resp:
+            if str(resp) == "550 No files found":
+                print("No files found in this directory")
+            else:
+                raise
+        for f in self.filelist:
+            f_ = os.path.basename(f)
+            self.assertIn(f_, files_sent, 'File {} was not sent to PASSCAL'.format(f_))
+        ftp.quit()
 
-    def tearDown(self):
-        """Tear down test fixtures, if any."""
 
-    def test_import(self):
-        if 'data2passcal' in sys.modules:
-            self.assertTrue(True, "data2passcal loaded")
-        else:
-            self.fail()
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tox.ini b/tox.ini
index 60d2b1b..f6954a3 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = py27, py36 flake8
+envlist = py27, py36, flake8
 
 [travis]
 python =
@@ -9,11 +9,10 @@ python =
 [testenv:flake8]
 basepython = python
 deps = flake8
-commands = flake8 data2passcal
+commands = flake8 --ignore=E722,E712,E501 data2passcal
+           flake8 --ignore=E722,E712,E501 tests
 
 [testenv]
-setenv =
-    PYTHONPATH = {toxinidir}
-commands=python setup.py test
-
-
+deps = pytest
+setenv = PYTHONPATH = {toxinidir}
+commands = pytest
-- 
GitLab