diff --git a/check-code.sh b/check-code.sh index 3c9f5b3..9e05123 100755 --- a/check-code.sh +++ b/check-code.sh @@ -13,5 +13,4 @@ pylint --py3k --reports no ${py_files} pep8 ${py_files} --max-line-length 120 # Run tests -# -> But not those that require the neocommon library. -py.test fetch test -m 'not with_neocommon' +py.test fetch test diff --git a/example-config.yaml b/example-config.yaml index 3f54a63..1559ba5 100755 --- a/example-config.yaml +++ b/example-config.yaml @@ -7,22 +7,9 @@ # The work directory (for log and lock files): directory: /data/fetch -# Notification settings (for errors): -notify: - email: ['jeremy.hooke@ga.gov.au'] - -# Message bus config, for announcing arrivals. Optional. -messaging: - host: rhe-neo-dev01.dev.lan - # virtual_host: - username: fetch - password: fetch - # Logging level for modules (any python module can be added here) log: fetch: DEBUG - neocommon: DEBUG - neocommon.files: INFO # Download rules: rules: @@ -190,7 +177,9 @@ rules: # Convert files to tiff (from netCDF) process: !shell command: '/usr/local/bin/gdal_translate -a_srs "+proj=latlong +datum=WGS84" {parent_dir}/{filename} {parent_dir}/{file_stem}.tif' - expect_file: '{parent_dir}/{file_stem}.tif' + upload_dir: '/data/water_vapour' + bucket: 'ard-processing-data' + prefix: 'ancillary/water_vapour' # #------------------------------- # Examples using the ECMWF API | diff --git a/fetch/_core.py b/fetch/_core.py index e2c3d84..d53dc03 100644 --- a/fetch/_core.py +++ b/fetch/_core.py @@ -7,20 +7,16 @@ import datetime import errno import logging -import multiprocessing import os import re -import smtplib -import socket import subprocess import tempfile -from email.mime.text import MIMEText -from email.header import Header from pathlib import Path from typing import Callable from .util import rsync, Uri +from . import s3 _log = logging.getLogger(__name__) @@ -468,76 +464,6 @@ def on_process_failure(self, process): pass -class TaskFailureEmailer(TaskFailureListener): - """ - Send failure information via email - """ - - def __init__(self, addresses): - """ - :type addresses: list of str - """ - self.addresses = addresses - - def on_file_failure(self, process_name, file_uri, summary, body_text): - """ - Send mail on a - :param process_name: - :param file_uri: - :param summary: - :param body_text: - :return: - """ - self._send_mail( - u'uri: {uri}\n{summary}\n\n{body}'.format( - uri=file_uri, - summary=summary, - body=body_text - ), - process_name - ) - - def on_process_failure(self, process): - """ - :type process: ScheduledProcess - """ - - # A negative exit code means it was killed via a signal. Probably by the user. - # Not worth emailing. - if process.exitcode < 0: - return - - with open(process.log_file, 'rt') as f: - msg = f.read() - - self._send_mail(msg, process.name) - - def _send_mail(self, body_text, process_name): - """ - :type body_text: str - :type process_name: str - """ - hostname = socket.getfqdn() - msg = MIMEText(body_text.encode('utf-8'), 'plain', 'utf-8') - msg['Subject'] = Header(u'{name} failure on {hostname}'.format( - name=process_name, - hostname=hostname - ).encode('utf-8'), 'utf-8') - from_address = 'fetch-{pid}@{hostname}'.format( - pid=multiprocessing.current_process().pid, - hostname=hostname - ) - msg['from'] = from_address - msg['to'] = ", ".join(self.addresses) - s = smtplib.SMTP('localhost') - s.sendmail( - from_address, - self.addresses, - msg.as_string() - ) - s.quit() - - class FileProcessor(SimpleObject): """ Any action that will process a file after retrieval. (base class) @@ -560,20 +486,21 @@ class ShellFileProcessor(FileProcessor): :type command: str """ - def __init__(self, command=None, expect_file=None, input_files=None): + def __init__(self, command, upload_dir, bucket, prefix): super(ShellFileProcessor, self).__init__() self.command = command - self.expect_file = expect_file - self.input_files = input_files + self.upload_dir = upload_dir + self.bucket = bucket + self.prefix = prefix - def _apply_file_pattern(self, pattern, file_path, **keywords): + def _apply_file_pattern(self, pattern, file_path): """ Format the given pattern. :type file_path: str :rtype: str - >>> p = ShellFileProcessor() + >>> p = ShellFileProcessor('run command on file {filename}', '/data/upload', 's3-bucket', 's3-prefix') >>> p._apply_file_pattern('{file_stem} extension {file_suffix}', '/tmp/something.txt') 'something extension .txt' >>> p._apply_file_pattern('{filename} in {parent_dir}', '/tmp/something.txt') @@ -582,8 +509,7 @@ def _apply_file_pattern(self, pattern, file_path, **keywords): '/tmp' >>> p._apply_file_pattern('{parent_dirs[1]}', '/tmp/something.txt') '/' - >>> p._apply_file_pattern('{base}.hdf', '/tmp/something.hdf',**{'base':'/tmp/something'}) - '/tmp/something.hdf' + """ path = Path(file_path) return pattern.format( @@ -598,8 +524,7 @@ def _apply_file_pattern(self, pattern, file_path, **keywords): parent_dirs=[str(p) for p in path.parents], # A more flexible alternative to the above. - path=path, - **keywords + path=path ) def process(self, file_path): @@ -609,33 +534,25 @@ def process(self, file_path): :raises: FileProcessError """ command = self.command - if self.input_files: - path_transform = RegexpOutputPathTransform(self.input_files[0]) - if not all([os.path.isfile(path_transform.transform_output_path(f, file_path)) - for f in self.input_files[1]]): - _log.info('Not all of the required_files are present.') - # This is used for reporting, so it is returning the file_path. - return file_path - else: - # format the path based on the group from - # transform output path - # command = path_transform.transform_output_path(command) - required_files_formating = path_transform.last_matched_groups - else: - required_files_formating = {} - command = self._apply_file_pattern(command, file_path, **required_files_formating) + command = self._apply_file_pattern(command, file_path) _log.info('Running %r', command) # Trigger command returned = subprocess.call(command, shell=True) if returned != 0: raise FileProcessError('Return code %r from command %r' % (returned, command)) + else: + _log.debug("Command completed successfully.") # Check that output exists - expected_path = self._apply_file_pattern(self.expect_file, file_path, **required_files_formating) + expect_file = self.upload_dir + '/{file_stem}.h5' + expected_path = self._apply_file_pattern(expect_file, file_path) if not os.path.exists(expected_path): raise FileProcessError('Expected output not found {!r} for command {!r}'.format(expected_path, command)) + s3.upload(expected_path, self.bucket, self.prefix) + _log.debug('File available %r', expected_path) + return expected_path diff --git a/fetch/auto.py b/fetch/auto.py index 7bdf393..6bf5b1c 100644 --- a/fetch/auto.py +++ b/fetch/auto.py @@ -25,7 +25,7 @@ from croniter import croniter from . import load -from ._core import ResultHandler, TaskFailureEmailer, RemoteFetchException, mkdirs +from ._core import ResultHandler, RemoteFetchException, mkdirs # setproctitle is only supported on some platforms (Linux). try: @@ -61,22 +61,7 @@ def _attempt_lock(lock_file): return True -def _redirect_output(log_file): - """ - Redirect all output to the given file. - - :type log_file: str - """ - output = open(log_file, 'w') - sys.stdout = output - sys.stderr = output - logging_clear() - handler = logging.StreamHandler(stream=output) - handler.setFormatter(_LOG_FORMATTER) - logging.getLogger().addHandler(handler) - - -def _run_item(reporter, item, scheduled_time, log_directory, lock_directory): +def _run_item(reporter, item, scheduled_time, lock_directory): """ Run the given module in a subprocess :type reporter: ResultHandler @@ -84,11 +69,11 @@ def _run_item(reporter, item, scheduled_time, log_directory, lock_directory): :rtype: ScheduledProcess """ p = ScheduledProcess( - reporter, item, scheduled_time, log_directory, lock_directory + reporter, item, scheduled_time, lock_directory ) _log.debug('Module info %r', item.module) - _log.info('Starting %r. Log %r, Lock %r', p.name, p.log_file, p.lock_file) + _log.info('Starting %r. Lock %r', p.name, p.lock_file) p.start() return p @@ -98,22 +83,21 @@ class ScheduledProcess(multiprocessing.Process): A subprocess to run a module. """ - def __init__(self, reporter, item, scheduled_time, log_directory, lock_directory, epoch_to_time=time.localtime): + def __init__(self, reporter, item, scheduled_time, lock_directory, epoch_to_time=time.localtime): """ :type reporter: fetch.ResultHandler :type item: fetch.load.ScheduledItem :type scheduled_time: float - :type log_directory: str :type lock_directory: str >>> from ._core import EmptySource >>> item = load.ScheduledItem('LS7 CPF', '* * * * *', EmptySource()) >>> # 04:36 UTC time >>> scheduled_time = 1416285412.541422 - >>> log, lock = '/tmp/test-log', '/tmp/test-lock' - >>> s = ScheduledProcess(None, item, scheduled_time, log, lock, epoch_to_time=time.gmtime) - >>> (s.name, s.log_file, s.lock_file) - ('fetch-0436-ls7-cpf', '/tmp/test-log/0436-ls7-cpf.log', '/tmp/test-lock/ls7-cpf.lck') + >>> lock = '/tmp/test-lock' + >>> s = ScheduledProcess(None, item, scheduled_time, lock, epoch_to_time=time.gmtime) + >>> (s.name, s.lock_file) + ('fetch-0436-ls7-cpf', '/tmp/test-lock/ls7-cpf.lck') """ super(ScheduledProcess, self).__init__() id_ = item.sanitized_name @@ -122,15 +106,7 @@ def __init__(self, reporter, item, scheduled_time, log_directory, lock_directory '{id}.lck'.format(id=id_) ) scheduled_time_st = time.strftime('%H%M', epoch_to_time(scheduled_time)) - log_file = os.path.join( - log_directory, - '{time}-{id}.log'.format( - id=id_, - time=scheduled_time_st - ) - ) - self.log_file = log_file self.lock_file = lock_file self.name = 'fetch-{}-{}'.format(scheduled_time_st, id_) self.scheduled_time = scheduled_time @@ -143,7 +119,6 @@ def run(self): Configure the environment and run our module. """ _init_signals() - _redirect_output(self.log_file) if not _attempt_lock(self.lock_file): _log.debug('Lock is activated. Skipping run. %r', self.name) @@ -235,8 +210,8 @@ def _on_child_finish(child, notifiers): if exit_code != 0: _log.error( - 'Error return code %s from %r. Output logged to %r', - exit_code, child.name, child.log_file + 'Error return code %s from %r.', + exit_code, child.name, ) for n in notifiers: @@ -263,30 +238,6 @@ def _filter_finished_children(running_children, notifiers): return still_running -def get_day_log_dir(log_directory, time_secs): - """ - Get log directory for this day. - :type log_directory: str - :type time_secs: float - :rtype: str - - >>> get_day_log_dir('/tmp/day-dir-test', 1416285412.541422) - '/tmp/day-dir-test/2014/11-18' - """ - # We use localtime because the cron scheduling uses localtime. - t = time.localtime(time_secs) - - day_log_dir = os.path.join( - log_directory, - time.strftime('%Y', t), - time.strftime('%m-%d', t) - ) - if not os.path.exists(day_log_dir): - mkdirs(day_log_dir) - - return day_log_dir - - def _on_shutdown(running_children, notifiers): """ :type running_children: set of ScheduledProcess @@ -359,14 +310,10 @@ def __init__(self, config_path): self.schedule = None # : type: str self.base_directory = None - # : type: str - self.log_directory = None #: type: str self.lock_directory = None #: :type: list of fetch.TaskFailureListener self.notifiers = [] - #: :type: dict of (str, str) - self.messaging_settings = None # Key-values are log names and levels. #: :type: dict of (str, str) self.log_levels = None @@ -379,14 +326,6 @@ def load(self): self.schedule = Schedule(config.rules) self.base_directory = config.directory - self.messaging_settings = config.messaging_settings - - _log.info('%s messaging configuration.', 'Loaded' if config.messaging_settings else 'No') - - self.notifiers = [] - if config.notify_addresses: - self.notifiers.append(TaskFailureEmailer(config.notify_addresses)) - _log.info('%s addresses for error notification: %s', len(config.notify_addresses), config.notify_addresses) if not os.path.exists(self.base_directory): raise ValueError('Configured base folder does not exist: %r' % self.base_directory) @@ -398,11 +337,6 @@ def load(self): if not os.path.exists(self.lock_directory): mkdirs(self.lock_directory) - self.log_directory = os.path.join(self.base_directory, 'log') - _log.info('Using log directory %s', self.log_directory) - if not os.path.exists(self.log_directory): - mkdirs(self.log_directory) - if config.log_levels != self.log_levels: _set_logging_levels(config.log_levels) self.log_levels = config.log_levels @@ -421,34 +355,6 @@ def __init__(self, config, job_id): self.config = config self.job_id = job_id - def _announce_files_complete(self, source_uri, paths, msg_metadata=None): - """ - Announce on the message bus that files are complete. - - No-op if there is no messaging configuration. - :type source_uri: str - :type paths: list of str - """ - md = msg_metadata or {} - md.update({ - 'source-uri': source_uri - }) - - _log.info('Completed %r -> %r', source_uri, paths) - if self.config.messaging_settings: - # Optional library. - #: pylint: disable=import-error - from neocommon import message, Uri as NeoUri - uris = [NeoUri.parse(path) for path in paths] - with message.NeoMessenger(message.MessengerConnection(**self.config.messaging_settings)) as msg: - msg.announce_ancillary( - message.AncillaryUpdate( - ancillary_type=self.job_id, - uris=uris, - properties=md - ) - ) - def files_complete(self, source_uri, paths, msg_metadata=None): """ Call on completion of multiple files. @@ -459,7 +365,7 @@ def files_complete(self, source_uri, paths, msg_metadata=None): :type msg_metadata: dict of (str, str) :return: """ - self._announce_files_complete(source_uri, paths, msg_metadata=msg_metadata) + _log.info('Completed %r -> %r', source_uri, paths) def file_complete(self, source_uri, path, msg_metadata=None): """ @@ -468,7 +374,7 @@ def file_complete(self, source_uri, path, msg_metadata=None): :type msg_metadata: dict of (str, str) :type path: str """ - self._announce_files_complete(source_uri, [path], msg_metadata=msg_metadata) + _log.info('Completed %r -> %r', source_uri, path) def file_error(self, uri, summary, body): """ @@ -541,8 +447,6 @@ def run_loop(o): reporter, scheduled_item, scheduled_time=scheduled_time, - # Use a unique log directory for each day - log_directory=get_day_log_dir(o.log_directory, scheduled_time), lock_directory=o.lock_directory ) running_children.add(p) @@ -602,8 +506,6 @@ def run_items(o, *item_names): NotifyResultHandler(o, chosen_item.sanitized_name), chosen_item, scheduled_time=scheduled_time, - # Use a unique log directory for each day - log_directory=get_day_log_dir(o.log_directory, scheduled_time), lock_directory=o.lock_directory ) running_children.add(p) @@ -662,6 +564,6 @@ def _set_logging_levels(levels): _log.info('Set log level %s to %s', name, level) -_LOG_HANDLER = logging.StreamHandler(stream=sys.stderr) +_LOG_HANDLER = logging.StreamHandler(sys.stdout) _LOG_FORMATTER = logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s") _LOG_HANDLER.setFormatter(_LOG_FORMATTER) diff --git a/fetch/ecmwf.py b/fetch/ecmwf.py index dfa20a5..6a5a145 100644 --- a/fetch/ecmwf.py +++ b/fetch/ecmwf.py @@ -40,7 +40,6 @@ def retrieve(self, req): c = APIRequest( self.url, "datasets/%s" % (dataset, ), - self.email, self.key, self.trace, verbose=self.verbose diff --git a/fetch/load.py b/fetch/load.py index c270a16..2c6d69d 100644 --- a/fetch/load.py +++ b/fetch/load.py @@ -10,7 +10,6 @@ import os import yaml -import yaml.resolver from croniter import croniter from . import ftp, http, ecmwf @@ -137,7 +136,7 @@ class Config(object): Configuration. """ - def __init__(self, directory, rules, notify_addresses, messaging_settings=None, log_levels=None): + def __init__(self, directory, rules, log_levels=None): """ :type directory: str :type rules: list of ScheduledItem @@ -150,15 +149,6 @@ def __init__(self, directory, rules, notify_addresses, messaging_settings=None, # Empty list of rules is ok: they may be added after startup (a config reload/SIGHUP). self.rules = rules - self.notify_addresses = notify_addresses - - if messaging_settings: - # Optional library. - #: pylint: disable=import-error - from neocommon.message import MessengerConnection - verify_can_construct(MessengerConnection, messaging_settings, identifier='messaging settings') - - self.messaging_settings = messaging_settings self.log_levels = log_levels @classmethod @@ -171,15 +161,8 @@ def from_dict(cls, config): """ directory = config.get('directory') - messaging_settings = config.get('messaging') log_levels = config.get('log') - notify_email_addresses = [] - if 'notify' in config: - notify_config = config['notify'] - if 'email' in notify_config: - notify_email_addresses = notify_config['email'] - rules = [] if 'rules' in config: for name, fields in config['rules'].items(): @@ -190,8 +173,6 @@ def from_dict(cls, config): return Config( directory, rules, - notify_email_addresses, - messaging_settings=messaging_settings, log_levels=log_levels ) @@ -202,11 +183,7 @@ def to_dict(self): """ return remove_nones({ 'directory': self.directory, - 'notify': { - 'email': self.notify_addresses - }, 'log': self.log_levels, - 'messaging': self.messaging_settings, 'rules': dict([ ( r.name, remove_nones({ diff --git a/fetch/s3.py b/fetch/s3.py new file mode 100644 index 0000000..8a40538 --- /dev/null +++ b/fetch/s3.py @@ -0,0 +1,41 @@ +import logging +import os +import boto3 + +_log = logging.getLogger(__name__) +_s3 = boto3.client("s3") + + +class FileUploadError(Exception): + """ + An error in file processing. + """ + pass + + +def upload(filepath: str, bucket: str, prefix: str): + """ + Upload all files in input_file_dir to S3. + + :raises: FileUploadError + """ + message = f"Uploading file {filepath} to s3://{bucket}/{prefix}" + _log.info(message) + + if not os.path.exists(filepath): + raise FileUploadError(f"File does not exist: {filepath}") + + directory, filename = os.path.split(filepath) + + s3_key = f"{prefix}/{filename}" + + try: + message = f"Uploading {filename} to s3://{bucket}/{s3_key}" + _log.debug(message) + _s3.upload_file(filepath, bucket, s3_key) + except Exception as e: + message = f"Failed to upload {filename} to s3://{bucket}/{s3_key}" + raise FileUploadError(message) from e + + message = f"Successfully Uploaded {filename} to s3://{bucket}/{s3_key}" + _log.info(message) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d98ee87 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = [ + "setuptools>=61", + "wheel", + "versioneer[toml]" +] +build-backend = "setuptools.build_meta" + +[project] +name = "fetch" +description = "Automatic data" +requires-python = ">=3.7" + +authors = [ + { name = "Jeremy Hooke", email = "jeremy.hooke@ga.gov.au" } +] + +dynamic = ["version"] + +dependencies = [ + "arrow", + "croniter", + "feedparser", + "lxml", + "pyyaml", + "requests>=2.21.0", + "boto3", + "pytest", + "mock", + "pylint", + "pep8", + "setproctitle; sys_platform == 'linux'", +] + +[project.optional-dependencies] +ecmwf = ["ecmwf-api-client"] + +[project.scripts] +fetch-service = "fetch.scripts.service:main" +fetch-now = "fetch.scripts.now:main" diff --git a/test/test_auto.py b/test/test_auto.py index 46422db..9b67698 100644 --- a/test/test_auto.py +++ b/test/test_auto.py @@ -12,7 +12,6 @@ def __init__(self, name='p', exitcode=None, pid=None): self.exitcode = exitcode self.name = name self.pid = pid - self.log_file = '/tmp/test.log' running_proc = MockProcess(exitcode=None) failed_proc = MockProcess(exitcode=1) diff --git a/test/test_ecmwf.py b/test/test_ecmwf.py index 9485f4c..0be53b7 100644 --- a/test/test_ecmwf.py +++ b/test/test_ecmwf.py @@ -134,9 +134,6 @@ def _make_ecmwf_config(ancillary_data_root='/tmp/anc'): """ schedule = { 'directory': '/tmp/anc-fetch', - 'notify': { - 'email': ['test@ga.gov.au'] - }, 'log': { 'fetch': 'DEBUG' }, diff --git a/test/test_load.py b/test/test_load.py index a211e45..19e3b29 100644 --- a/test/test_load.py +++ b/test/test_load.py @@ -3,13 +3,10 @@ import tempfile -import pytest from pathlib import Path from fetch import load -with_neocommon = pytest.mark.with_neocommon - def _fail_with_diff(reparsed_config, source_config): print('-' * 20) @@ -44,20 +41,6 @@ def test_dump_load_obj_full(): _check_load_dump_config(_make_config) -@with_neocommon -def test_dump_load_obj_with_messaging(): - def make_config_no_messaging(): - c = _make_config() - c['messaging'] = { - 'host': 'rhe-pma-test08.test.lan', - 'username': 'fetch', - 'password': 'fetch' - } - return c - - _check_load_dump_config(make_config_no_messaging) - - def print_simple_obj_diff(dict1, dict2): if type(dict2) in (int, float, str, text): print('- {!r}'.format(dict1)) @@ -116,9 +99,6 @@ def _make_config(): anc_data = '/tmp/anc' schedule = { 'directory': '/tmp/anc-fetch', - 'notify': { - 'email': ['jeremy.hooke@ga.gov.au'] - }, 'log': { 'fetch': 'DEBUG' }, @@ -193,7 +173,9 @@ def _make_config(): 'process': ShellFileProcessor( command='/usr/local/bin/gdal_translate -a_srs "+proj=latlong +datum=WGS84" ' '{parent_dir}/{file_stem}.nc {parent_dir}/{file_stem}.tif', - expect_file='{parent_dir}/{file_stem}.tif' + upload_dir='{parent_dir}', + bucket='ard-processing-data', + prefix='ancillary/water_vapour' ) }, 'NPP GDAS-forecast': { diff --git a/test/test_s3.py b/test/test_s3.py new file mode 100644 index 0000000..512979a --- /dev/null +++ b/test/test_s3.py @@ -0,0 +1,70 @@ +""" +A package for testing the code in s3.py +""" +from fetch import s3 +from fetch.s3 import upload, FileUploadError + +import pytest +from unittest.mock import patch + +# Constants for tests +filepath = "/tmp/test.txt" +bucket = "my-bucket" +prefix = "my/prefix" + + +def test_upload_success(): + # Set up mocks + with ( + patch("os.path.exists", return_value=True), + patch.object(s3, "_s3") as mock_s3, + patch.object(s3, "_log") as mock_log, + ): + # Call code + upload(filepath, bucket, prefix) + + # Expected logged messages + mock_log.info.assert_any_call("Uploading file /tmp/test.txt to s3://my-bucket/my/prefix") + mock_log.debug.assert_any_call("Uploading test.txt to s3://my-bucket/my/prefix/test.txt") + mock_log.info.assert_any_call("Successfully Uploaded test.txt to s3://my-bucket/my/prefix/test.txt") + + # expected write to S3 + mock_s3.upload_file.assert_called_once_with( + filepath, + bucket, + "my/prefix/test.txt", + ) + + +def test_given_file_not_exist(): + # Set up mocks + with ( + patch("os.path.exists", return_value=False), + patch.object(s3, "_s3") as mock_s3, + patch.object(s3, "_log") as mock_log, + ): + # Run code and confirm we get an expection back, checking type & message + with pytest.raises(FileUploadError, match="File does not exist: /tmp/test.txt"): + upload(filepath, bucket, prefix) + + # Expected logged messages + mock_log.info.assert_called_once_with("Uploading file /tmp/test.txt to s3://my-bucket/my/prefix") + mock_s3.upload_file.assert_not_called() + + +def test_write_to_s3_failed(): + # Set up mocks + with ( + patch("os.path.exists", return_value=True), + patch.object(s3, "_s3") as mock_s3, + patch.object(s3, "_log") as mock_log, + ): + mock_s3.upload_file.side_effect = Exception("poop") + + # Run code and confirm we get an expection back, checking type & message + with pytest.raises(FileUploadError, match="Failed to upload test.txt to s3://my-bucket/my/prefix/test.txt"): + upload(filepath, bucket, prefix) + + # Expected logged messages + mock_log.info.assert_called_once_with("Uploading file /tmp/test.txt to s3://my-bucket/my/prefix") + mock_log.debug.assert_called_once_with("Uploading test.txt to s3://my-bucket/my/prefix/test.txt") diff --git a/test/test_shellfileprocessor.py b/test/test_shellfileprocessor.py index db0b0ce..2335782 100644 --- a/test/test_shellfileprocessor.py +++ b/test/test_shellfileprocessor.py @@ -1,31 +1,104 @@ +""" +A package for testing the code in the ShellFileProcessor class, in _core.py +""" +import pytest +from unittest.mock import patch, call -import os -from pathlib import Path +from fetch import s3 +from fetch import _core +from fetch._core import ShellFileProcessor, FileProcessError -from fetch._core import ShellFileProcessor +# Test Constants +command = "run command on file {filename}" +upload_dir = "/data/upload" +bucket = "s3-bucket" +prefix = "s3-prefix" +input_file = "/data/staging/file.txt" -def test_shellfilepro_required_files_there(): - command = 'ls {base}.py' - # required_files = (r'^(?P.*test.+)\.py$',['{base}.py','{base}.py']) - required_files = ('^(?P.*test.+)\\.py$', ['{base}.py', '{base}.py']) - file_path = os.path.abspath(__file__) - expect_file = '{base}.py' - sfp = ShellFileProcessor(command=command, expect_file=expect_file, input_files=required_files) - results = sfp.process(file_path) +def test_apply_file_pattern_basic(): + p = ShellFileProcessor( + command=command, + upload_dir=upload_dir, + bucket=bucket, + prefix=prefix, + ) - assert results == Path(__file__).absolute().as_posix() + result = p._apply_file_pattern(command, "/tmp/something.txt") + assert result == "run command on file something.txt" -def test_shellfilepro_required_files_not_there(): - command = 'ls {base}.py' - required_files = (r'^(?P.*test.+)\.py$', ['{base}.py', '/this/is/not/here/please.py']) - file_path = os.path.abspath(__file__) - sfp = ShellFileProcessor(command=command, expect_file=file_path, input_files=required_files) - results = sfp.process(file_path) - # A rather toothless assert given required_files_there would return the same result... - assert file_path == results - # Future option is to ask for a tmp_path, and make your command touch {tmp_path / 'test_file.txt'}. - # Then if the file exists, you know the command was run. It could work in both tests. -# Add a test when no required_files parameter is supplied at all +def test_process_success(): + # Set up mocks + with ( + patch("subprocess.call", return_value=0), + patch("os.path.exists", return_value=True), + patch.object(s3, "_s3"), + patch.object(_core, "_log") as mock_log + ): + p = ShellFileProcessor( + command=command, + upload_dir=upload_dir, + bucket=bucket, + prefix=prefix, + ) + + # assert success using return value (and no raised errors) + assert p.process(input_file) == "/data/upload/file.h5" + + # confirm the correct messages are logged + mock_log.info.assert_called_once_with('Running %r', 'run command on file file.txt') + mock_log.debug.assert_has_calls([ + call("Command completed successfully."), + call('File available %r', '/data/upload/file.h5') + ]) + + +def test_process_command_failed(): + # Set up mocks + with ( + patch("subprocess.call", return_value=1), + patch("os.path.exists", return_value=True), + patch.object(s3, "_s3"), + patch.object(_core, "_log") as mock_log + ): + p = ShellFileProcessor( + command=command, + upload_dir=upload_dir, + bucket=bucket, + prefix=prefix, + ) + + # Run code and confirm we get an expection back, checking type & message + with pytest.raises(FileProcessError, match="Return code 1 from command 'run command on file file.txt'"): + p.process(input_file) + + # confirm the correct messages are logged + mock_log.info.assert_called_once_with('Running %r', 'run command on file file.txt') + mock_log.debug.assert_not_called() + + +def test_process_output_file_missing(): + # Set up mocks + with ( + patch("subprocess.call", return_value=0), + patch("os.path.exists", return_value=False), + patch.object(s3, "_s3"), + patch.object(_core, "_log") as mock_log + ): + p = ShellFileProcessor( + command=command, + upload_dir=upload_dir, + bucket=bucket, + prefix=prefix, + ) + + # Run code and confirm we get an expection back, checking type & message + message = "Expected output not found '/data/upload/file.h5' for command 'run command on file file.txt'" + with pytest.raises(FileProcessError, match=message): + p.process(input_file) + + # confirm the correct messages are logged + mock_log.info.assert_called_once_with('Running %r', 'run command on file file.txt') + mock_log.debug.assert_called_once_with("Command completed successfully.")