diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6524a3fd7cb85e8093c49e99c381fdbc7c0230c9..d74e7c03214c69162c040c133e954dd3f759604c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,17 +20,71 @@ stages: before_script: - pip install -e .[dev] +linting_python2: + image: python:2.7 + tags: + - passoft + stage: test + script: + - flake8 --ignore=E722,E712 data2passcal + - flake8 --ignore=E722,E712 tests -python2: +linting_python3: + image: python:3.6 + tags: + - passoft + stage: test + script: + - flake8 --ignore=E722,E712 data2passcal + - flake8 --ignore=E722,E712 tests + +python2.7: image: python:2.7 tags: - passoft stage: test - script: tox -e py27 + script: + - cd tests/ + - pwd + - ls + - python -m unittest test_data2passcal -python3: +python3.5: + image: python:3.5 + tags: + - passoft + stage: test + script: + - pwd + - ls + - python -m unittest + +python3.6: image: python:3.6 tags: - passoft stage: test - script: tox -e py36 + script: + - pwd + - ls + - python -m unittest + +python3.7: + image: python:3.7 + tags: + - passoft + stage: test + script: + - pwd + - ls + - python -m unittest + +python3.8: + image: python:3.8 + tags: + - passoft + stage: test + script: + - pwd + - ls + - python -m unittest diff --git a/AUTHORS.rst b/AUTHORS.rst index 8029b2489bd99e83141f2a6e55f9aeb1f9c6f30c..207d6dd429fa08cf357717a87a587314d3012b29 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 e8536cc925d7042cd527fed67034e6b6781936cf..69b8d731421c58d03df0b213f114b991d723a89d 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -45,7 +45,8 @@ articles, and such. Submit Feedback ~~~~~~~~~~~~~~~ -The best way to send feedback is to file an issue at https://git.passcal.nmt.edu/passoft/data2passcal/issues. +The best way to send feedback is to file an issue at +https://git.passcal.nmt.edu/passoft/data2passcal/issues. If you are proposing a feature: @@ -59,31 +60,32 @@ Get Started! Ready to contribute? Here's how to set up `data2passcal` for local development. -1. Cone the `data2passcal` repo:: +1. Clone the `data2passcal` repo: $ git clone https://git.passcal.nmt.edu/passoft/data2passcal.git -3. Install your local copy:: +2. Install your local copy: $ pip install -e .[dev] -4. Create a branch for local development:: +3. Create a branch for local development: $ git checkout -b name-of-your-bugfix-or-feature Now you can make your changes locally. -5. When you're done making changes, check that your changes pass the - tests:: +4. When you're done making changes, check that your changes pass the + tests: - $ python setup.py test -6. Commit your changes and push your branch to GitHub:: + $ python -m unittest (or python -m unittest tests.test_data2passcal under Python2) + +5. Commit your changes and push your branch to GitHub: $ git add . $ git commit -m "Your detailed description of your changes." $ git push origin name-of-your-bugfix-or-feature -7. Submit a merge request through the Gitlab website. +6. Submit a merge request through the Gitlab website. Pull Request Guidelines ----------------------- @@ -94,7 +96,7 @@ Before you submit a merge request, check that it meets these guidelines: 2. If the merge request adds functionality, the docs should be updated. Put your new functionality into a function with a docstring, and add the feature to the list in README.rst. -3. The pull request should work for Python 2.7 +3. The pull request should work for Python 2.7 and Python 3.[5,6,7,8] Tips ---- @@ -111,4 +113,3 @@ Then run:: $ git push $ git push --tags - diff --git a/HISTORY.rst b/HISTORY.rst index a94f3900674ff45112c0d5377c4db9759781243d..bc694a413f909aeeab250acdc5351d3444f184ed 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -10,3 +10,39 @@ History 2018.228 (2018-08-16) ------------------ * Updated to work with python 2 and 3 + +2020.093 (2020-04-02) +------------------ +* Updated to work with python 2 and 3. +* 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 in dev mode (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) + +2020.114 (2020-04-23) +------------------ +* Created conda packages for "data2passcal" that can run on Python2.7 and Python3.6 +=> To create package: + - clone the "data2passcal" repo + - "cd" in "data2passcal" directory + - run "conda-build ." +=> To install locally with dependencies: + - run "conda install -c ${CONDA_PREFIX}/conda-bld/ data2passcal" + +2020.119 (2020-04-28) +------------------ +* Created conda packages for "data2passcal" that can run on Python3.[5,7,8] +* Tested data2passcal against Python3.[5,7,8] using tox +* Updated .gitlab-ci.yml to run a linter and unit tests for Python2 and Python3.[5,6,7,8] in GitLab CI pipeline + +2020.213 (2020-07-31) +------------------ +* Updated meta.yaml to build architecture and platform independent package +* Updated mock unit tests diff --git a/MANIFEST.in b/MANIFEST.in index 965b2dda7db7c49f68857dc3aea9af37e30a745e..2387d6e279b924229fa0c8c485eeeebf49784996 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,7 +5,5 @@ include LICENSE include README.rst recursive-include tests * -recursive-exclude * __pycache__ -recursive-exclude * *.py[co] -recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif +recursive-include docs *.rst conf.py diff --git a/Makefile b/Makefile deleted file mode 100644 index 11b99c413bc5977bb09368c418fbdb440c9fd4fe..0000000000000000000000000000000000000000 --- a/Makefile +++ /dev/null @@ -1,89 +0,0 @@ -.PHONY: clean clean-test clean-pyc clean-build docs help -.DEFAULT_GOAL := help - -define BROWSER_PYSCRIPT -import os, webbrowser, sys - -try: - from urllib import pathname2url -except: - from urllib.request import pathname2url - -webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) -endef -export BROWSER_PYSCRIPT - -define PRINT_HELP_PYSCRIPT -import re, sys - -for line in sys.stdin: - match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) - if match: - target, help = match.groups() - print("%-20s %s" % (target, help)) -endef -export PRINT_HELP_PYSCRIPT - -BROWSER := python -c "$$BROWSER_PYSCRIPT" - -help: - @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) - -clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts - -clean-build: ## remove build artifacts - rm -fr build/ - rm -fr dist/ - rm -fr .eggs/ - find . -name '*.egg-info' -exec rm -fr {} + - find . -name '*.egg' -exec rm -f {} + - -clean-pyc: ## remove Python file artifacts - find . -name '*.pyc' -exec rm -f {} + - find . -name '*.pyo' -exec rm -f {} + - find . -name '*~' -exec rm -f {} + - find . -name '__pycache__' -exec rm -fr {} + - -clean-test: ## remove test and coverage artifacts - rm -fr .tox/ - rm -f .coverage - rm -fr htmlcov/ - rm -fr .pytest_cache - -lint: ## check style with flake8 - flake8 data2passcal tests - -test: ## run tests quickly with the default Python - python setup.py test - - -test-all: ## run tests on every Python version with tox - tox - -coverage: ## check code coverage quickly with the default Python - coverage run --source data2passcal setup.py test - coverage report -m - coverage html - $(BROWSER) htmlcov/index.html - -docs: ## generate Sphinx HTML documentation, including API docs - rm -f docs/data2passcal.rst - rm -f docs/modules.rst - sphinx-apidoc -o docs/ data2passcal - $(MAKE) -C docs clean - $(MAKE) -C docs html - $(BROWSER) docs/_build/html/index.html - -servedocs: docs ## compile the docs watching for changes - watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . - -release: dist ## package and upload a release - twine upload dist/* - -dist: clean ## builds source and wheel package - python setup.py sdist - python setup.py bdist_wheel - ls -l dist - -install: clean ## install the package to the active Python's site-packages - python setup.py install diff --git a/README.rst b/README.rst index 1d6dee089e41341f1618e01f5a91e7ee72a5c3e0..0b4fbe637614c42f65ae7fa28b8969d817cc5251 100644 --- a/README.rst +++ b/README.rst @@ -2,23 +2,9 @@ data2passcal ============ +* Description: Send MSEED files ready for archival at the DMC to PASSCAL's QC system + Only accept day-long MSEED files. -Prepare SEED data for shipment to PASSCAL. - +* 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/conda.recipe/meta.yaml b/conda.recipe/meta.yaml index 260432777941989967c8ee02178c86241ee4d03c..f3415cb81340c77cf2e5185a76631b57e2ab1dd9 100644 --- a/conda.recipe/meta.yaml +++ b/conda.recipe/meta.yaml @@ -1,24 +1,32 @@ package: name: data2passcal - version: 2014.125 + version: 2020.213 source: - path: .. + path: ../ build: - # If the installation is complex, or different between Unix and Windows, use - # separate bld.bat and build.sh files instead of this key. Add the line - # "skip: True # [py<35]" (for example) to limit to Python 3.5 and newer, or - # "skip: True # [not win]" to limit to Windows. - script: python setup.py install --single-version-externally-managed --record=record.txt + noarch: python + number: 1 + script: {{ PYTHON }} -m pip install . --no-deps -vv requirements: build: - - python - - setuptools + - python >=2.7 + - pip run: - - python + - python >=2.7 + +test: + requires: + - mock # [py<33] + source_files: + - tests + commands: + - python -m unittest tests.test_data2passcal about: home: https://git.passcal.nmt.edu/passoft/data2passcal - summary: Prepare SEED data for shipment to PASSCAL. + license: GPLv3 + license_file: LICENSE + summary: Send MSEED files ready for archival at the DMC to PASSCAL's QC system diff --git a/data2passcal/__init__.py b/data2passcal/__init__.py index 1636f688fe53d37abbe1eb0fb8f667675ef3b664..a49f05ebad00ac286528b632fcd8b03962ed1726 100644 --- a/data2passcal/__init__.py +++ b/data2passcal/__init__.py @@ -4,4 +4,4 @@ __author__ = """IRIS PASSCAL""" __email__ = 'software-support@passcal.nmt.edu' -__version__ = '2018.228' +__version__ = '2020.213' diff --git a/data2passcal/data2passcal.py b/data2passcal/data2passcal.py index 7632678c2c3cef869cf1a7490bd7f69fbbd63b97..4b97b5c2f5dddde2916c4efd92e8755847d4e8a5 100644 --- a/data2passcal/data2passcal.py +++ b/data2passcal/data2passcal.py @@ -4,23 +4,40 @@ data2passcal Ship miniseed data to passcal via ftp for qc before archival at DMC DMS Lloyd Carothers IRIS/PASSCAL + +Maeva Pourpoint +July 2020 +Updates to work under Python 2 and 3. +Unit tests to ensure basic functionality. +Code cleanup to conform to the PEP8 style guide. +Directory cleanup (remove unused files introduced by Cookiecutter). +Packaged with conda. ''' -from __future__ import print_function -VERSION = '2018.228' +from __future__ import division, print_function -import ftplib -import sys, os, signal, struct, pickle, logging -from time import time, sleep import datetime +import ftplib +import logging +import os +import pickle +import re +import signal +import socket +import struct +import sys -#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 +from io import open +from time import sleep, time -#FTP related +try: + from urllib.request import urlopen +except ImportError: + from urllib2 import urlopen + +VERSION = '2020.213' + + +# FTP related FTP_IP = '129.138.26.29' FTP_HOST = 'qc.passcal.nmt.edu' FTP_USER = 'ftp' @@ -28,64 +45,70 @@ FTP_PASSWORD = 'data2passcal' FTP_DIR = 'AUTO/MSEED' FTP_TIMEOUT = 120 FTP_RECONNECT_WAIT = 60 -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 +# number of time to try to open the ftp connection +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 # 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') -# 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') + +# Files related LOGFILE = 'data2passcal.log' +# 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') 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 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 +# todo if sending a file do we want to send all previous logs even if already +# sent logger = logging.getLogger('__name__') logger.setLevel(logging.DEBUG) # Log to a file with debug level logfh = logging.FileHandler(LOGFILE) logfh.setLevel(logging.DEBUG) -logfh.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s -\t%(message)s', datefmt='%Y-%m-%d %H:%M:%S %z')) +logfh.setFormatter(logging.Formatter( + '%(asctime)s - %(levelname)s -\t%(message)s', + datefmt='%Y-%m-%d %H:%M:%S %z')) 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 - print() logger.info('Scanning: %s' % os.path.abspath(dir)) filelist = [] filesize = 0 @@ -98,29 +121,30 @@ def scan_dir(dir): try: filesize += os.path.getsize(f) except OSError: - logger.debug('Can not stat %s'% f) + logger.exception('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)) - print() - + 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)) return filelist + def sendable(file): - '''Filters files scanned returning a new list of files to send i.e. miniseed''' + '''Filters files scanned returning a new list of files to send''' if os.path.basename(file).startswith('.'): return False if ismseed(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 -MseedRE = re.compile(r'\A(.*)\.([A-Z0-9][A-Z0-9])\.(.*)\.([A-Z][A-Z]\w)\.([0-9]{4})\.([0-9]{3})(?:\.p)*') + +# Basic Miniseed file metadata extracted from filename +# This includes .p files which we may accept in the future +MseedRE = re.compile(r'\A(.*)\.([A-Z0-9][A-Z0-9])\.(.*)\.' + r'([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''' @@ -129,16 +153,19 @@ 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') - except: + ms = open(file, 'rb') + except Exception: + logger.exception('Failed to open file %s in binary mode' + % os.path.basename(file)) return None order = ByteOrder(ms) ms.close() @@ -149,24 +176,25 @@ def ismseed(file): return False ######################################################### -#needs import sys,struct -#Taken from Bruces LibTrace -def ByteOrder(infile, seekval=20) : +# 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 \ @@ -174,23 +202,25 @@ 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: + logger.exception('Failed to read time info from fixed header') 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) @@ -204,29 +234,42 @@ 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) def get_FTP(): - '''returns a FTP connection or None if a connection could not be made after attempts allowed''' + ''' + returns a FTP connection or None if a connection could not be made + after number of attempts allowed + ''' trys = 0 while trys < FTP_CONNECT_ATTEMPTS: 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) + local_ip = socket.gethostbyname(socket.gethostname()) + except socket.gaierror: + logger.exception('Valid address-to-host mapping does not exist') + local_ip = '' + logger.info('Connecting to FTP host %s from %s. Attempt %d of %d' + % (FTP_HOST, + local_ip, + trys, + FTP_CONNECT_ATTEMPTS)) + try: + 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) - except ftplib.all_errors as e: + except (ftplib.all_errors + (AttributeError,)): logger.error('Failed to open FTP connection to %s' % FTP_HOST) - logger.error(e) test_network() - logger.info('Waiting %d seconds before trying to reconnect...' % FTP_RECONNECT_WAIT) + logger.info('Waiting %.2f seconds before trying to reconnect...' + % FTP_RECONNECT_WAIT) sleep(FTP_RECONNECT_WAIT) else: logger.info('Success: Connected to PASSCAL FTP') @@ -234,54 +277,58 @@ 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''' + '''download and time google 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' + url = 'ftp://ftp.passcal.nmt.edu/download/public/DO_NOT_DELETE.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 start = time() try: - f = urllib.request.urlopen(url) - except Exception as e: - logger.error("Failed to open connection to %s" % url) - logger.error(e) + f = urlopen(url) + except Exception: + logger.exception("Failed to open connection to %s" % url) 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) FTP.voidcmd('NOOP') - except ftplib.all_errors as e : - logger.error(e) + except ftplib.all_errors: + logger.error("Failed to send a simple command string to the server" + "and handle the response") return False - except AssertionError as e: - logger.error(e) + except AssertionError: + logger.error("Failed to send a simple command string to the server" + "and handle the response") return False else: return True @@ -290,42 +337,48 @@ def test_FTP(FTP): def send2passcal(mslist, sentlist=None): '''Send the list of files in mslist to passcal via FTP''' - # Handle SIGINT while in this function: to gracefully close connection and save sentlist to disk file + # 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: FTP.abort() FTP.quit() - except Exception as e: - logger.debug(e) + except Exception: + logger.error("Failed to abort file transfer and close FTP " + "connection") os._exit(1) def update(data): '''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, - update.bytes_sent, - file_size, - ) , + print '%s %0.2f%%. %0.10d / %0.10d.' + % (current_file.center(20), + (update.bytes_sent / update.file_size) * 100, + update.bytes_sent, + 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: sentlist = [] - print() logger.info('Sending MSEED files to PASSCAL') num_to_send = len(mslist) num_sent = 0 @@ -333,7 +386,8 @@ 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) @@ -344,30 +398,39 @@ def send2passcal(mslist, sentlist=None): while trys < FTP_SEND_ATTEMPTS: trys += 1 update.bytes_sent = 0 + fh = open(f, 'rb') + file_size = os.path.getsize(f) + update.file_size = float(file_size) try: - 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) - 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(e) + FTP.storbinary('STOR %s' % current_file, fh, + blocksize=FTP_BLOCKSIZE, callback=update) + except ftplib.error_perm: + # 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.' + % current_file) + fh.close() 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) - logger.error(e) - #if DEBUG: print "Waiting %d..." % FTP_TIMEOUT; sleep(FTP_TIMEOUT) + except (ftplib.all_errors + (AttributeError,)): + # 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 + # , trys, FTP_SEND_ATTEMPTS) + logger.error('Failed to send file %s.' % (current_file)) + # if DEBUG: + # print "Waiting %d..." %FTP_TIMEOUT; sleep(FTP_TIMEOUT) try: if FTP: FTP.abort() FTP.quit() - except: + except Exception: + logger.exception("Failed to abort file transfer and close" + "FTP connection") pass FTP = get_FTP() + fh.close() if FTP is None: signal_handler(None, None) else: @@ -378,17 +441,19 @@ def send2passcal(mslist, sentlist=None): sentlist.append(f) 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: + + +class ProgressBar(object): def __init__(self, duration): self.duration = duration self.prog_bar = '[]' @@ -408,13 +473,14 @@ class ProgressBar: 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)) all_full = self.width - 2 num_hashes = int(round((percent_done / 100.0) * all_full)) - self.prog_bar = '[' + self.fill_char * num_hashes + ' ' * (all_full - num_hashes) + ']' + self.prog_bar = '[' + self.fill_char * \ + num_hashes + ' ' * (all_full - num_hashes) + ']' pct_place = int((len(self.prog_bar) / 2) - len(str(percent_done))) pct_string = '%d%%' % percent_done self.prog_bar = self.prog_bar[0:pct_place] + \ @@ -425,8 +491,10 @@ class ProgressBar: # 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 @@ -435,19 +503,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))) - print() + logger.info('Other files: %d' % (len(filelist) - len(msnamedlist))) 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))) - print() + logger.info('Properly named but not miniseed files: %d' + % (len(msnamedlist) - len(mslist))) 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 bd5e695214d2220c9df4bb5f53418783cc3037b2..3e16c9aa4de92883ce48eebabea3688a4ead6f10 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,22 +1,5 @@ -[bumpversion] -current_version = 2018.228 -commit = True -tag = True - -[bumpversion:file:setup.py] -search = version='{current_version}' -replace = version='{new_version}' - -[bumpversion:file:data2passcal/__init__.py] -search = __version__ = '{current_version}' -replace = __version__ = '{new_version}' - -[bdist_wheel] -universal = 1 - [flake8] exclude = docs [aliases] # Define setup.py command aliases here - diff --git a/setup.py b/setup.py index 3c7b133727cf7718141b7ce8e55399c30d670526..f5ef9304bcbf2900e1a70d4b2bedb65501384efd 100644 --- a/setup.py +++ b/setup.py @@ -5,10 +5,10 @@ from setuptools import setup, find_packages -with open('README.rst') as readme_file: +with open('README.rst', 'rt') as readme_file: readme = readme_file.read() -with open('HISTORY.rst') as history_file: +with open('HISTORY.rst', 'rt') as history_file: history = history_file.read() @@ -21,7 +21,10 @@ setup( 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 'Natural Language :: English', 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', ], description="Prepare SEED data for shipment to PASSCAL.", entry_points={ @@ -30,18 +33,12 @@ setup( ], }, install_requires=[], - setup_requires = [], + setup_requires=[], extras_require={ 'dev': [ - 'pip', - 'bumpversion', - 'wheel', - 'watchdog', 'flake8', 'tox', - 'coverage', - 'Sphinx', - 'twine', + "mock;python_version<'3.3'" ] }, license="GNU General Public License v3", @@ -50,8 +47,7 @@ setup( keywords='data2passcal', name='data2passcal', packages=find_packages(include=['data2passcal']), - test_suite='tests', url='https://git.passcal.nmt.edu/passoft/data2passcal', - version='2018.228', + version='2020.213', 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 Binary files /dev/null and b/tests/test_data/ST00.AB..BHZ.2007.160 differ 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 Binary files /dev/null and b/tests/test_data/ST00.AB..BHZ.2007.161 differ 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 Binary files /dev/null and b/tests/test_data/ST00.AB..BHZ.2007.162 differ 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 Binary files /dev/null and b/tests/test_data/ST00.AB..BHZ.2007.163 differ 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 Binary files /dev/null and b/tests/test_data/ST00.AB..BHZ.2007.164 differ diff --git a/tests/test_data2passcal.py b/tests/test_data2passcal.py index e102d31f6b7762155c53b59bb37b4039d921533f..6515358cf0f180243f86bfc0c3c28b8b0fa2d25b 100644 --- a/tests/test_data2passcal.py +++ b/tests/test_data2passcal.py @@ -3,26 +3,137 @@ """Tests for `data2passcal` package.""" -import unittest +from __future__ import division, print_function + +import ftplib +import os import sys +import unittest +import data2passcal.data2passcal as d2p + +if sys.version_info < (3, 3): + from mock import patch +else: + from unittest.mock import patch + +VERSION = '2020.213' + +SEND4REAL = os.environ.get('SEND4REAL', 'False') +print("SEND4REAL=False by default. If one wants to test sending data to " + "PASSCAL for 'real', set SEND4REAL=True as environment variable. " + "ex: SEND4REAL=True python -m unittest test_data2passcal") + +TEST_DIR = os.path.dirname(os.path.realpath(__file__)) + '/test_data' +MS_FILELIST = ['ST00.AB..BHZ.2007.160', 'ST00.AB..BHZ.2007.161', + 'ST00.AB..BHZ.2007.162', 'ST00.AB..BHZ.2007.163', + 'ST00.AB..BHZ.2007.164'] + +d2p.FTP_TIMEOUT = 1 +d2p.FTP_RECONNECT_WAIT = 0.01 +d2p.FTP_CONNECT_ATTEMPTS = 2 +d2p.FTP_SEND_ATTEMPTS = 2 -try: - import data2passcal -except ImportError: - pass class TestData2passcal(unittest.TestCase): """Tests for `data2passcal` package.""" - def setUp(self): - """Set up test fixtures, if any.""" + def test_scan_dir(self): + """ + Test basic functionality of scan_dir function + """ + filelist = [os.path.join(TEST_DIR, f) for f in MS_FILELIST] + if sys.version_info < (3, 2): + self.assertItemsEqual(filelist, d2p.scan_dir(TEST_DIR), + 'scan_dir did not find the correct file(s)') + else: + self.assertCountEqual(filelist, d2p.scan_dir(TEST_DIR), + 'scan_dir did not find the correct file(s)') - def tearDown(self): - """Tear down test fixtures, if any.""" + def test_ismseed(self): + """Test basic functionality of ismseed function""" + filelist = [os.path.join(TEST_DIR, f) for f in MS_FILELIST] + for f in filelist: + self.assertTrue(d2p.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 + d2p.get_FTP() + self.assertLess(mock_ftp_constructor.call_count, + d2p.FTP_CONNECT_ATTEMPTS, + 'Number of ftp connection attempts exceeeds {}' + .format(d2p.FTP_CONNECT_ATTEMPTS)) + mock_ftp.quit() + + @patch('data2passcal.data2passcal.urlopen', autospec=True) + @patch('data2passcal.data2passcal.ftplib.FTP', autospec=True) + def test_get_FTP_failure_mock(self, mock_ftp_constructor, mock_urlopen): + """ + Mock test failure to create ftp connection to PASSCAL and exercise + get_FTP() + """ + mock_ftp_constructor.return_value = ftplib.error_temp + d2p.get_FTP() + self.assertGreater(mock_ftp_constructor.call_count, 1, + "ftplib.FTP() called only once - get_FTP() not " + "fully exercised!") + + @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 + filelist = [os.path.join(TEST_DIR, f) for f in MS_FILELIST[0:2]] + d2p.send2passcal(filelist) + self.assertTrue(mock_ftp.storbinary.called, 'No data sent') + self.assertEqual(mock_ftp.storbinary.call_count, len(filelist), + 'Failed to send all files - Sent {0} of {1}' + .format(mock_ftp.storbinary.call_count, + len(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 MS_FILELIST[0:2]: + self.assertLess(files_sent.count(f), d2p.FTP_SEND_ATTEMPTS, + 'Attempted to send file {0} more than {1} times' + .format(f, d2p.FTP_SEND_ATTEMPTS)) + + @patch('data2passcal.data2passcal.os._exit', autospec=True) + @patch('data2passcal.data2passcal.urlopen', autospec=True) + @patch('data2passcal.data2passcal.ftplib.FTP', autospec=True) + def test_send_data_failure_mock(self, mock_ftp_constructor, + mock_urlopen, mock_exit): + """ + Mock test failure to create ftp connection to PASSCAL and exercise + send_data() + """ + mock_ftp_constructor.return_value = ftplib.error_temp + filelist = [os.path.join(TEST_DIR, f) for f in MS_FILELIST[0:2]] + d2p.send2passcal(filelist) + self.assertTrue(mock_exit.called, "os._exit(1) never called - " + "send_data() not fully exercised!") + + @unittest.skipIf(SEND4REAL == 'False', "skipping real send2passcal test") + def test_send_data(self): + """Test sending MSEED files (test data) to PASSCAL's QC system""" + ftp = d2p.get_FTP() + filelist = [os.path.join(TEST_DIR, f) for f in MS_FILELIST[0:2]] + d2p.send2passcal(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 MS_FILELIST[0:2]: + self.assertIn(f, files_sent, 'File {} was not sent to PASSCAL' + .format(f)) + ftp.quit() - def test_import(self): - if 'data2passcal' in sys.modules: - self.assert_(True, "data2passcal loaded") - else: - self.fail() +if __name__ == '__main__': + unittest.main() diff --git a/tox.ini b/tox.ini index 60d2b1bb52fdc8539b7f39176385af7599226b5a..954a1f07c917bc9587afdaf4ecffe26723fe41ff 100644 --- a/tox.ini +++ b/tox.ini @@ -1,19 +1,21 @@ [tox] -envlist = py27, py36 flake8 - -[travis] -python = - 2.7: py27 - 3.6: py36 +envlist = py27, py35, py36, py37, py38, flake8 [testenv:flake8] basepython = python deps = flake8 -commands = flake8 data2passcal - -[testenv] -setenv = - PYTHONPATH = {toxinidir} -commands=python setup.py test +commands = flake8 --ignore=E722,E712 data2passcal + flake8 --ignore=E722,E712 tests +[testenv:py27] +changedir = tests +passenv = SEND4REAL +deps = + mock + timeout-decorator +commands = python -m unittest test_data2passcal +[testenv] +passenv = SEND4REAL +deps = timeout-decorator +commands = python -m unittest