From ce380e58d567ff1966eed4a5c144b29f80759773 Mon Sep 17 00:00:00 2001 From: Kurt Wheeler Date: Fri, 2 Jun 2017 16:02:47 -0400 Subject: [PATCH] Improve metadata (#12) * Improves metadata about batches. * Supports multiple batches per downloader/processor job. * Adds organisms model for retrieving NCBI taxonmy ids. --- .gitignore | 1 + README.md | 16 +- .../migrations/0001_initial.py | 58 ++- data_models/data_refinery_models/models.py | 138 ------- .../data_refinery_models/models/__init__.py | 13 + .../models/base_models.py | 20 + .../data_refinery_models/models/batches.py | 64 ++++ .../data_refinery_models/models/jobs.py | 73 ++++ .../data_refinery_models/models/organism.py | 129 +++++++ .../data_refinery_models/models/surveys.py | 40 ++ .../models/test_organisms.py | 239 ++++++++++++ .../test_time_tracked_models.py} | 0 data_models/requirements.in | 1 + data_models/requirements.txt | 1 + .../surveyor/array_express.py | 154 +++++--- .../surveyor/external_source.py | 92 +++-- .../surveyor/management/commands/start.py | 7 - .../surveyor/management/commands/survey.py | 31 ++ .../surveyor/surveyor.py | 18 +- .../surveyor/test_array_express.py | 349 +++++++++++++----- .../surveyor/test_surveyor.py | 2 +- foreman/requirements.in | 1 + foreman/requirements.txt | 9 +- foreman/run_surveyor.sh | 12 +- requirements.in | 2 + requirements.txt | 6 +- run_rabbitmq.sh | 3 + run_shell.sh | 13 +- schema.png | Bin 0 -> 115378 bytes setup.cfg | 5 + workers/Dockerfile | 2 +- workers/Dockerfile.tests | 23 ++ .../downloaders/array_express.py | 116 ++++-- .../management/commands/queue_downloader.py | 26 +- .../management/commands/queue_task.py | 47 --- .../management/commands/queue_test_task.py | 48 --- .../downloaders/test_array_express.py | 117 ++++++ .../downloaders/utils.py | 43 ++- .../processors/array_express.py | 39 +- .../management/commands/queue_processor.py | 36 +- .../processors/test_utils.py | 180 +++++++++ .../data_refinery_workers/processors/utils.py | 81 ++-- workers/run_tests.sh | 21 ++ workers/tester.sh | 2 +- workers/worker.sh | 2 +- 45 files changed, 1683 insertions(+), 597 deletions(-) delete mode 100644 data_models/data_refinery_models/models.py create mode 100644 data_models/data_refinery_models/models/__init__.py create mode 100644 data_models/data_refinery_models/models/base_models.py create mode 100644 data_models/data_refinery_models/models/batches.py create mode 100644 data_models/data_refinery_models/models/jobs.py create mode 100644 data_models/data_refinery_models/models/organism.py create mode 100644 data_models/data_refinery_models/models/surveys.py create mode 100644 data_models/data_refinery_models/models/test_organisms.py rename data_models/data_refinery_models/{tests.py => models/test_time_tracked_models.py} (100%) delete mode 100644 foreman/data_refinery_foreman/surveyor/management/commands/start.py create mode 100644 foreman/data_refinery_foreman/surveyor/management/commands/survey.py create mode 100755 run_rabbitmq.sh create mode 100644 schema.png create mode 100644 setup.cfg create mode 100644 workers/Dockerfile.tests delete mode 100644 workers/data_refinery_workers/downloaders/management/commands/queue_task.py delete mode 100644 workers/data_refinery_workers/downloaders/management/commands/queue_test_task.py create mode 100644 workers/data_refinery_workers/downloaders/test_array_express.py create mode 100644 workers/data_refinery_workers/processors/test_utils.py create mode 100755 workers/run_tests.sh diff --git a/.gitignore b/.gitignore index 5fbeb8b24..5073009a8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Project specific files workers/volume +foreman/volume # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/README.md b/README.md index 8fce14b0e..7305e1a17 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,9 @@ supported by Greene Lab. ## Getting Started -Note: The following steps assume you have already installed PostgreSQL (>=9.4) and Python (Most versions should work, but this has been tested with Python 3.5) on Ubuntu (Tested with 16.04. It should be possible to use other versions or even a Mac though). +Note: The following steps assume you have already installed PostgreSQL (>=9.4) +and Python (>=3.5) on Ubuntu (Tested with 16.04. It should be possible to use +other versions or even a Mac though). Run `./install.sh` to set up the virtualenv. It will activate the `dr_env` for you the first time. This virtualenv is valid for the entire data_refinery @@ -18,6 +20,18 @@ instructions on doing so. ## Development +R files in this repo follow +[Google's R Style Guide](https://google.github.io/styleguide/Rguide.xml). +Python Files in this repo follow +[PEP 8](https://www.python.org/dev/peps/pep-0008/). All files (including +python and R) have a line limit of 100 characters. + +A `setup.cfg` file has been included in the root of this repo which specifies +the line length limit for the autopep8 and flake8 linters. If you run either +of those programs from anywhere within the project's directory tree they will +enforce a limit of 100 instead of 80. This will also be true for editors which +rely on them. + It can be useful to have an interactive python interpreter running within the context of the Docker container. The `run_shell.sh` script has been provided for this purpose. It is in the top level directory so that if you wish to diff --git a/data_models/data_refinery_models/migrations/0001_initial.py b/data_models/data_refinery_models/migrations/0001_initial.py index 78cc7d03c..92850c9fb 100644 --- a/data_models/data_refinery_models/migrations/0001_initial.py +++ b/data_models/data_refinery_models/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11 on 2017-05-02 18:50 +# Generated by Django 1.10.6 on 2017-05-26 15:12 from __future__ import unicode_literals from django.db import migrations, models @@ -22,14 +22,20 @@ class Migration(migrations.Migration): ('updated_at', models.DateTimeField()), ('source_type', models.CharField(max_length=256)), ('size_in_bytes', models.IntegerField()), - ('download_url', models.CharField(max_length=2048)), + ('download_url', models.CharField(max_length=4096)), ('raw_format', models.CharField(max_length=256, null=True)), ('processed_format', models.CharField(max_length=256, null=True)), ('pipeline_required', models.CharField(max_length=256)), - ('accession_code', models.CharField(max_length=32)), + ('platform_accession_code', models.CharField(max_length=32)), + ('experiment_accession_code', models.CharField(max_length=32)), + ('experiment_title', models.CharField(max_length=256)), ('status', models.CharField(max_length=20)), + ('release_date', models.DateField()), + ('last_uploaded_date', models.DateField()), + ('name', models.CharField(max_length=1024)), ('internal_location', models.CharField(max_length=256, null=True)), - ('organism', models.IntegerField()), + ('organism_id', models.IntegerField()), + ('organism_name', models.CharField(max_length=256)), ], options={ 'db_table': 'batches', @@ -60,12 +66,38 @@ class Migration(migrations.Migration): ('success', models.NullBooleanField()), ('num_retries', models.IntegerField(default=0)), ('worker_id', models.CharField(max_length=256, null=True)), - ('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='data_refinery_models.Batch')), ], options={ 'db_table': 'downloader_jobs', }, ), + migrations.CreateModel( + name='DownloaderJobsToBatches', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(editable=False)), + ('updated_at', models.DateTimeField()), + ('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='data_refinery_models.Batch')), + ('downloader_job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='data_refinery_models.DownloaderJob')), + ], + options={ + 'db_table': 'downloader_jobs_to_batches', + }, + ), + migrations.CreateModel( + name='Organism', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(editable=False)), + ('updated_at', models.DateTimeField()), + ('name', models.CharField(max_length=256)), + ('taxonomy_id', models.IntegerField()), + ('is_scientific_name', models.BooleanField(default=False)), + ], + options={ + 'db_table': 'organisms', + }, + ), migrations.CreateModel( name='ProcessorJob', fields=[ @@ -78,12 +110,24 @@ class Migration(migrations.Migration): ('pipeline_applied', models.CharField(max_length=256)), ('num_retries', models.IntegerField(default=0)), ('worker_id', models.CharField(max_length=256, null=True)), - ('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='data_refinery_models.Batch')), ], options={ 'db_table': 'processor_jobs', }, ), + migrations.CreateModel( + name='ProcessorJobsToBatches', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(editable=False)), + ('updated_at', models.DateTimeField()), + ('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='data_refinery_models.Batch')), + ('processor_job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='data_refinery_models.ProcessorJob')), + ], + options={ + 'db_table': 'processor_jobs_to_batches', + }, + ), migrations.CreateModel( name='SurveyJob', fields=[ @@ -118,6 +162,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='batch', name='survey_job', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='data_refinery_models.SurveyJob'), + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='data_refinery_models.SurveyJob'), ), ] diff --git a/data_models/data_refinery_models/models.py b/data_models/data_refinery_models/models.py deleted file mode 100644 index 4111afb1e..000000000 --- a/data_models/data_refinery_models/models.py +++ /dev/null @@ -1,138 +0,0 @@ -from django.db import models -from django.utils import timezone -from enum import Enum - - -class TimeTrackedModel(models.Model): - created_at = models.DateTimeField(editable=False) - updated_at = models.DateTimeField() - - def save(self, *args, **kwargs): - """ On save, update timestamps """ - current_time = timezone.now() - if not self.id: - self.created_at = current_time - self.updated_at = current_time - return super(TimeTrackedModel, self).save(*args, **kwargs) - - class Meta: - abstract = True - - -class SurveyJob(TimeTrackedModel): - source_type = models.CharField(max_length=256) - success = models.NullBooleanField(null=True) - - # The start time of the query used to replicate - replication_started_at = models.DateTimeField(null=True) - - # The end time of the query used to replicate - replication_ended_at = models.DateTimeField(null=True) - - # The start time of the job - start_time = models.DateTimeField(null=True) - - # The end time of the job - end_time = models.DateTimeField(null=True) - - class Meta: - db_table = "survey_jobs" - - -class SurveyJobKeyValue(TimeTrackedModel): - """ - This table is used for tracking fields onto a SurveyJob record that - would be sparsely populated if it was its own column. - I.e. one source may have an extra field or two that are worth - tracking but are specific to that source. - """ - survey_job = models.ForeignKey(SurveyJob, on_delete=models.CASCADE) - key = models.CharField(max_length=256) - value = models.CharField(max_length=256) - - class Meta: - db_table = "survey_job_key_values" - - -class BatchStatuses(Enum): - NEW = "NEW" - DOWNLOADED = "DOWNLOADED" - PROCESSED = "PROCESSED" - - -class Batch(TimeTrackedModel): - survey_job = models.ForeignKey(SurveyJob) - source_type = models.CharField(max_length=256) - size_in_bytes = models.IntegerField() - download_url = models.CharField(max_length=2048) - raw_format = models.CharField(max_length=256, null=True) - processed_format = models.CharField(max_length=256, null=True) - pipeline_required = models.CharField(max_length=256) - accession_code = models.CharField(max_length=32) - status = models.CharField(max_length=20) - - # This field will denote where in our system the file can be found - internal_location = models.CharField(max_length=256, null=True) - - # This will utilize the organism taxonomy ID from NCBI - organism = models.IntegerField() - - class Meta: - db_table = "batches" - - -class BatchKeyValue(TimeTrackedModel): - """ - This table is used for tracking fields onto a Batch record that would - be sparsely populated if it was its own column. - I.e. one source may have an extra field or two that are worth tracking - but are specific to that source. - """ - batch = models.ForeignKey(Batch, on_delete=models.CASCADE) - key = models.CharField(max_length=256) - value = models.CharField(max_length=256) - - class Meta: - db_table = "batch_key_values" - - -class ProcessorJob(TimeTrackedModel): - batch = models.ForeignKey(Batch, on_delete=models.CASCADE) - start_time = models.DateTimeField(null=True) - end_time = models.DateTimeField(null=True) - success = models.NullBooleanField(null=True) - - # This field will contain an enumerated value specifying which processor - # pipeline was applied during the processor job. - pipeline_applied = models.CharField(max_length=256) - - # This field represents how many times this job has been retried. It starts - # at 0 and each time the job has to be retried it will be incremented. - # At some point there will probably be some code like: - # if job.num_retries >= 3: - # # do a bunch of logging - # else: - # # retry the job - num_retries = models.IntegerField(default=0) - - # This point of this field is to identify which worker ran the job. - # A few fields may actually be required or something other than just an id. - worker_id = models.CharField(max_length=256, null=True) - - class Meta: - db_table = "processor_jobs" - - -class DownloaderJob(TimeTrackedModel): - batch = models.ForeignKey(Batch, on_delete=models.CASCADE) - start_time = models.DateTimeField(null=True) - end_time = models.DateTimeField(null=True) - success = models.NullBooleanField(null=True) - - # These two fields are analagous to the fields with the same names - # in ProcessorJob, see their descriptions for more information - num_retries = models.IntegerField(default=0) - worker_id = models.CharField(max_length=256, null=True) - - class Meta: - db_table = "downloader_jobs" diff --git a/data_models/data_refinery_models/models/__init__.py b/data_models/data_refinery_models/models/__init__.py new file mode 100644 index 000000000..5f4af8c27 --- /dev/null +++ b/data_models/data_refinery_models/models/__init__.py @@ -0,0 +1,13 @@ +from data_refinery_models.models.surveys import SurveyJob, SurveyJobKeyValue +from data_refinery_models.models.batches import ( + BatchStatuses, + Batch, + BatchKeyValue +) +from data_refinery_models.models.jobs import ( + DownloaderJob, + ProcessorJob, + DownloaderJobsToBatches, + ProcessorJobsToBatches +) +from data_refinery_models.models.organism import Organism diff --git a/data_models/data_refinery_models/models/base_models.py b/data_models/data_refinery_models/models/base_models.py new file mode 100644 index 000000000..cf9df7afc --- /dev/null +++ b/data_models/data_refinery_models/models/base_models.py @@ -0,0 +1,20 @@ +from django.db import models +from django.utils import timezone + + +class TimeTrackedModel(models.Model): + """Base model with auto created_at and updated_at fields.""" + + created_at = models.DateTimeField(editable=False) + updated_at = models.DateTimeField() + + def save(self, *args, **kwargs): + """ On save, update timestamps """ + current_time = timezone.now() + if not self.id: + self.created_at = current_time + self.updated_at = current_time + return super(TimeTrackedModel, self).save(*args, **kwargs) + + class Meta: + abstract = True diff --git a/data_models/data_refinery_models/models/batches.py b/data_models/data_refinery_models/models/batches.py new file mode 100644 index 000000000..2c7c0563f --- /dev/null +++ b/data_models/data_refinery_models/models/batches.py @@ -0,0 +1,64 @@ +from enum import Enum +from django.db import models +from data_refinery_models.models.base_models import TimeTrackedModel +from data_refinery_models.models.surveys import SurveyJob + + +class BatchStatuses(Enum): + """Valid values for the status field of the Batch model.""" + + NEW = "NEW" + DOWNLOADED = "DOWNLOADED" + PROCESSED = "PROCESSED" + + +class Batch(TimeTrackedModel): + """Represents a batch of data. + + The definition of a Batch is intentionally that vague. What a batch + is will vary from source to source. It could be a single file, or + a group of files with some kind of logical grouping such as an + experiment. + """ + + survey_job = models.ForeignKey(SurveyJob, on_delete=models.PROTECT) + source_type = models.CharField(max_length=256) + size_in_bytes = models.IntegerField() + download_url = models.CharField(max_length=4096) + raw_format = models.CharField(max_length=256, null=True) + processed_format = models.CharField(max_length=256, null=True) + pipeline_required = models.CharField(max_length=256) + platform_accession_code = models.CharField(max_length=32) + experiment_accession_code = models.CharField(max_length=32) + experiment_title = models.CharField(max_length=256) + status = models.CharField(max_length=20) + release_date = models.DateField() + last_uploaded_date = models.DateField() + name = models.CharField(max_length=1024) + + # This field will denote where in our system the file can be found. + internal_location = models.CharField(max_length=256, null=True) + + # This corresponds to the organism taxonomy ID from NCBI. + organism_id = models.IntegerField() + # This is the organism name as it appeared in the experiment. + organism_name = models.CharField(max_length=256) + + class Meta: + db_table = "batches" + + +class BatchKeyValue(TimeTrackedModel): + """Tracks additional fields for Batches. + + Useful for fields that would be sparsely populated if they were + their own columns. I.e. one source may have an extra field or two + that are worth tracking but are specific to that source. + """ + + batch = models.ForeignKey(Batch, on_delete=models.CASCADE) + key = models.CharField(max_length=256) + value = models.CharField(max_length=256) + + class Meta: + db_table = "batch_key_values" diff --git a/data_models/data_refinery_models/models/jobs.py b/data_models/data_refinery_models/models/jobs.py new file mode 100644 index 000000000..f6827ffc3 --- /dev/null +++ b/data_models/data_refinery_models/models/jobs.py @@ -0,0 +1,73 @@ +from django.db import models +from data_refinery_models.models.base_models import TimeTrackedModel +from data_refinery_models.models.batches import Batch + + +class ProcessorJob(TimeTrackedModel): + """Records information about running a processor.""" + + start_time = models.DateTimeField(null=True) + end_time = models.DateTimeField(null=True) + success = models.NullBooleanField(null=True) + + # This field will contain an enumerated value specifying which processor + # pipeline was applied during the processor job. + pipeline_applied = models.CharField(max_length=256) + + # This field represents how many times this job has been retried. It starts + # at 0 and each time the job has to be retried it will be incremented. + # At some point there will probably be some code like: + # if job.num_retries >= 3: + # # do a bunch of logging + # else: + # # retry the job + num_retries = models.IntegerField(default=0) + + # This point of this field is to identify which worker ran the job. + # A few fields may actually be required or something other than just an id. + worker_id = models.CharField(max_length=256, null=True) + + class Meta: + db_table = "processor_jobs" + + +class ProcessorJobsToBatches(TimeTrackedModel): + """Represents a many to many relationship. + + Maps between ProcessorJobs and Batches. + """ + + batch = models.ForeignKey(Batch, on_delete=models.CASCADE) + processor_job = models.ForeignKey(ProcessorJob, on_delete=models.CASCADE) + + class Meta: + db_table = "processor_jobs_to_batches" + + +class DownloaderJob(TimeTrackedModel): + """Records information about running a Downloader.""" + + start_time = models.DateTimeField(null=True) + end_time = models.DateTimeField(null=True) + success = models.NullBooleanField(null=True) + + # These two fields are analagous to the fields with the same names + # in ProcessorJob, see their descriptions for more information + num_retries = models.IntegerField(default=0) + worker_id = models.CharField(max_length=256, null=True) + + class Meta: + db_table = "downloader_jobs" + + +class DownloaderJobsToBatches(TimeTrackedModel): + """Represents a many to many relationship. + + Maps between DownloaderJobs and Batches. + """ + + batch = models.ForeignKey(Batch, on_delete=models.CASCADE) + downloader_job = models.ForeignKey(DownloaderJob, on_delete=models.CASCADE) + + class Meta: + db_table = "downloader_jobs_to_batches" diff --git a/data_models/data_refinery_models/models/organism.py b/data_models/data_refinery_models/models/organism.py new file mode 100644 index 000000000..a81d7921a --- /dev/null +++ b/data_models/data_refinery_models/models/organism.py @@ -0,0 +1,129 @@ +import requests +from xml.etree import ElementTree +from django.db import models +from data_refinery_models.models.base_models import TimeTrackedModel + +# Import and set logger +import logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +NCBI_ROOT_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/" +ESEARCH_URL = NCBI_ROOT_URL + "esearch.fcgi" +EFETCH_URL = NCBI_ROOT_URL + "efetch.fcgi" +TAXONOMY_DATABASE = "taxonomy" + + +class UnscientificNameError(BaseException): + pass + + +class InvalidNCBITaxonomyId(BaseException): + pass + + +def get_scientific_name(taxonomy_id: int) -> str: + parameters = {"db": TAXONOMY_DATABASE, "id": str(taxonomy_id)} + response = requests.get(EFETCH_URL, parameters) + + root = ElementTree.fromstring(response.text) + taxon_list = root.findall("Taxon") + + if len(taxon_list) == 0: + logger.error("No names returned by ncbi.nlm.nih.gov for organism " + + "with taxonomy ID %d.", + taxonomy_id) + raise InvalidNCBITaxonomyId + + return taxon_list[0].find("ScientificName").text.upper() + + +def get_taxonomy_id(organism_name: str) -> int: + parameters = {"db": TAXONOMY_DATABASE, "term": organism_name} + response = requests.get(ESEARCH_URL, parameters) + + root = ElementTree.fromstring(response.text) + id_list = root.find("IdList").findall("Id") + + if len(id_list) == 0: + logger.error("Unable to retrieve NCBI taxonomy ID number for organism " + + "with name: %s", + organism_name) + return 0 + elif len(id_list) > 1: + logger.warn("Organism with name %s returned multiple NCBI taxonomy ID " + + "numbers.", + organism_name) + + return int(id_list[0].text) + + +def get_taxonomy_id_scientific(organism_name: str) -> int: + parameters = {"db": TAXONOMY_DATABASE, "field": "scin", "term": organism_name} + response = requests.get(ESEARCH_URL, parameters) + + root = ElementTree.fromstring(response.text) + id_list = root.find("IdList").findall("Id") + + if len(id_list) == 0: + raise UnscientificNameError + elif len(id_list) > 1: + logger.warn("Organism with name %s returned multiple NCBI taxonomy ID " + + "numbers.", + organism_name) + + return int(id_list[0].text) + + +class Organism(TimeTrackedModel): + """Provides a lookup between organism name and taxonomy ids. + + Should only be used via the two class methods get_name_for_id and + get_id_for_name. These methods will populate the database table + with any missing values by accessing the NCBI API. + """ + + name = models.CharField(max_length=256) + taxonomy_id = models.IntegerField() + is_scientific_name = models.BooleanField(default=False) + + @classmethod + def get_name_for_id(cls, taxonomy_id: int) -> str: + try: + organism = (cls.objects + .filter(taxonomy_id=taxonomy_id) + .order_by("-is_scientific_name") + [0]) + except IndexError: + name = get_scientific_name(taxonomy_id).upper() + organism = Organism(name=name, + taxonomy_id=taxonomy_id, + is_scientific_name=True) + organism.save() + + return organism.name + + @classmethod + def get_id_for_name(cls, name: str) -> id: + name = name.upper() + try: + organism = (cls.objects + .filter(name=name) + [0]) + except IndexError: + is_scientific_name = False + try: + taxonomy_id = get_taxonomy_id_scientific(name) + is_scientific_name = True + except UnscientificNameError: + taxonomy_id = get_taxonomy_id(name) + + organism = Organism(name=name, + taxonomy_id=taxonomy_id, + is_scientific_name=is_scientific_name) + organism.save() + + return organism.taxonomy_id + + class Meta: + db_table = "organisms" diff --git a/data_models/data_refinery_models/models/surveys.py b/data_models/data_refinery_models/models/surveys.py new file mode 100644 index 000000000..80e5d8f74 --- /dev/null +++ b/data_models/data_refinery_models/models/surveys.py @@ -0,0 +1,40 @@ +from django.db import models +from data_refinery_models.models.base_models import TimeTrackedModel + + +class SurveyJob(TimeTrackedModel): + """Records information about a Surveyor Job.""" + + source_type = models.CharField(max_length=256) + success = models.NullBooleanField(null=True) + + # The start time of the query used to replicate + replication_started_at = models.DateTimeField(null=True) + + # The end time of the query used to replicate + replication_ended_at = models.DateTimeField(null=True) + + # The start time of the job + start_time = models.DateTimeField(null=True) + + # The end time of the job + end_time = models.DateTimeField(null=True) + + class Meta: + db_table = "survey_jobs" + + +class SurveyJobKeyValue(TimeTrackedModel): + """Tracks additional fields for SurveyJobs. + + Useful for fields that would be sparsely populated if they were + their own columns. I.e. one source may have an extra field or two + that are worth tracking but are specific to that source. + """ + + survey_job = models.ForeignKey(SurveyJob, on_delete=models.CASCADE) + key = models.CharField(max_length=256) + value = models.CharField(max_length=256) + + class Meta: + db_table = "survey_job_key_values" diff --git a/data_models/data_refinery_models/models/test_organisms.py b/data_models/data_refinery_models/models/test_organisms.py new file mode 100644 index 000000000..f48216393 --- /dev/null +++ b/data_models/data_refinery_models/models/test_organisms.py @@ -0,0 +1,239 @@ +from unittest.mock import Mock, patch, call +from django.test import TestCase +from data_refinery_models.models.organism import ( + Organism, + ESEARCH_URL, + EFETCH_URL, + InvalidNCBITaxonomyId, +) + +ESEARCH_RESPONSE_XML = """ + + + 1 + 1 + 0 + + 9606 + + + + + homo sapiens[Scientific Name] + Scientific Name + 1 + N + + GROUP + + homo sapiens[Scientific Name] +""" + +ESEARCH_NOT_FOUND_XML = """ + + + 0 + 0 + 0 + + + (man[Scientific Name]) + + blah + + + No items found. + +""" + +EFETCH_RESPONSE_XML = """ + + + 9606 + Homo sapiens + + human + man + + authority + Homo sapiens Linnaeus, 1758 + + + 9605 + species + Primates + + 1 + Standard + + + 2 + Vertebrate Mitochondrial + + cellular organisms + + + 131567 + cellular organisms + no rank + + + 1995/02/27 09:24:00 + 2017/02/28 16:38:58 + 1992/05/26 01:00:00 + + +""" + +EFETCH_NOT_FOUND_XML = """ + + +ID list is empty! Possibly it has no correct IDs. +""" + + +def mocked_requests_get(url, parameters): + mock = Mock(ok=True) + if url is not ESEARCH_URL: + mock.text = "This is wrong." + else: + try: + if parameters["field"] is "scin": + mock.text = ESEARCH_NOT_FOUND_XML + else: + mock.text = "This is also wrong." + except KeyError: + mock.text = ESEARCH_RESPONSE_XML + + return mock + + +class OrganismModelTestCase(TestCase): + def tearDown(self): + Organism.objects.all().delete() + + @patch('data_refinery_models.models.organism.requests.get') + def test_cached_names_are_found(self, mock_get): + Organism.objects.create(name="HOMO SAPIENS", + taxonomy_id=9606, + is_scientific_name=True) + + name = Organism.get_name_for_id(9606) + + self.assertEqual(name, "HOMO SAPIENS") + mock_get.assert_not_called() + + @patch('data_refinery_models.models.organism.requests.get') + def test_cached_ids_are_found(self, mock_get): + Organism.objects.create(name="HOMO SAPIENS", + taxonomy_id=9606, + is_scientific_name=True) + + id = Organism.get_id_for_name("Homo Sapiens") + + self.assertEqual(id, 9606) + mock_get.assert_not_called() + + @patch('data_refinery_models.models.organism.requests.get') + def test_uncached_scientific_names_are_found(self, mock_get): + mock_get.return_value = Mock(ok=True) + mock_get.return_value.text = ESEARCH_RESPONSE_XML + + taxonomy_id = Organism.get_id_for_name("Homo Sapiens") + + self.assertEqual(taxonomy_id, 9606) + mock_get.assert_called_once_with( + ESEARCH_URL, + {"db": "taxonomy", "field": "scin", "term": "HOMO%20SAPIENS"} + ) + + # The first call should have stored the organism record in the + # database so this call should not make a request. + mock_get.reset_mock() + new_id = Organism.get_id_for_name("Homo Sapiens") + + self.assertEqual(new_id, 9606) + mock_get.assert_not_called() + + @patch('data_refinery_models.models.organism.requests.get') + def test_uncached_other_names_are_found(self, mock_get): + mock_get.side_effect = mocked_requests_get + + taxonomy_id = Organism.get_id_for_name("Human") + + self.assertEqual(taxonomy_id, 9606) + mock_get.assert_has_calls([ + call(ESEARCH_URL, + {"db": "taxonomy", "field": "scin", "term": "HUMAN"}), + call(ESEARCH_URL, + {"db": "taxonomy", "term": "HUMAN"})]) + + # The first call should have stored the organism record in the + # database so this call should not make a request. + mock_get.reset_mock() + new_id = Organism.get_id_for_name("Human") + + self.assertEqual(new_id, 9606) + mock_get.assert_not_called() + + @patch('data_refinery_models.models.organism.requests.get') + def test_uncached_ids_are_found(self, mock_get): + mock_get.return_value = Mock(ok=True) + mock_get.return_value.text = EFETCH_RESPONSE_XML + + organism_name = Organism.get_name_for_id(9606) + + self.assertEqual(organism_name, "HOMO SAPIENS") + mock_get.assert_called_once_with( + EFETCH_URL, + {"db": "taxonomy", "id": "9606"} + ) + + # The first call should have stored the organism record in the + # database so this call should not make a request. + mock_get.reset_mock() + new_name = Organism.get_name_for_id(9606) + + self.assertEqual(new_name, "HOMO SAPIENS") + mock_get.assert_not_called() + + @patch('data_refinery_models.models.organism.requests.get') + def test_invalid_ids_cause_exceptions(self, mock_get): + mock_get.return_value = Mock(ok=True) + mock_get.return_value.text = EFETCH_NOT_FOUND_XML + + with self.assertRaises(InvalidNCBITaxonomyId): + Organism.get_name_for_id(0) + + @patch('data_refinery_models.models.organism.requests.get') + def test_unfound_names_return_0(self, mock_get): + """If we can't find an NCBI taxonomy ID for an organism name + we can keep things moving for a while without it. + get_taxonomy_id will log an error message which will prompt + a developer to investigate what the organism name that was + unable to be found is. Therefore setting the ID to 0 is the + right thing to do in this case despite not seeming like it. + """ + mock_get.return_value = Mock(ok=True) + mock_get.return_value.text = ESEARCH_NOT_FOUND_XML + + taxonomy_id = Organism.get_id_for_name("blah") + + self.assertEqual(taxonomy_id, 0) + mock_get.assert_has_calls([ + call(ESEARCH_URL, + {"db": "taxonomy", "field": "scin", "term": "BLAH"}), + call(ESEARCH_URL, + {"db": "taxonomy", "term": "BLAH"})]) + + # The first call should have stored the organism record in the + # database so this call should not make a request. + mock_get.reset_mock() + new_id = Organism.get_id_for_name("BLAH") + + self.assertEqual(new_id, 0) + mock_get.assert_not_called() diff --git a/data_models/data_refinery_models/tests.py b/data_models/data_refinery_models/models/test_time_tracked_models.py similarity index 100% rename from data_models/data_refinery_models/tests.py rename to data_models/data_refinery_models/models/test_time_tracked_models.py diff --git a/data_models/requirements.in b/data_models/requirements.in index 08088127e..48852bf73 100644 --- a/data_models/requirements.in +++ b/data_models/requirements.in @@ -1,2 +1,3 @@ django psycopg2 +requests diff --git a/data_models/requirements.txt b/data_models/requirements.txt index 380d9d088..a30db3f5a 100644 --- a/data_models/requirements.txt +++ b/data_models/requirements.txt @@ -6,3 +6,4 @@ # django==1.10.6 psycopg2==2.7.1 +requests==2.13.0 diff --git a/foreman/data_refinery_foreman/surveyor/array_express.py b/foreman/data_refinery_foreman/surveyor/array_express.py index 5cebee15d..7f32ca60e 100644 --- a/foreman/data_refinery_foreman/surveyor/array_express.py +++ b/foreman/data_refinery_foreman/surveyor/array_express.py @@ -1,10 +1,11 @@ import requests from typing import List + from data_refinery_models.models import ( Batch, BatchKeyValue, - SurveyJob, - SurveyJobKeyValue + SurveyJobKeyValue, + Organism ) from data_refinery_foreman.surveyor.external_source import ( ExternalSourceSurveyor, @@ -16,68 +17,111 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +EXPERIMENTS_URL = "https://www.ebi.ac.uk/arrayexpress/json/v3/experiments/" +SAMPLES_URL = EXPERIMENTS_URL + "{}/samples" -class ArrayExpressSurveyor(ExternalSourceSurveyor): - # Files API endpoint for ArrayExpress - FILES_URL = "http://www.ebi.ac.uk/arrayexpress/json/v2/files" - DOWNLOADER_TASK = "data_refinery_workers.downloaders.array_express.download_array_express" +class ArrayExpressSurveyor(ExternalSourceSurveyor): def source_type(self): return "ARRAY_EXPRESS" def downloader_task(self): - return self.DOWNLOADER_TASK + return "data_refinery_workers.downloaders.array_express.download_array_express" def determine_pipeline(self, batch: Batch, key_values: List[BatchKeyValue] = []): return ProcessorPipeline.AFFY_TO_PCL - def survey(self, survey_job: SurveyJob): - accession_code = (SurveyJobKeyValue - .objects - .filter(survey_job_id=survey_job.id, - key__exact="accession_code") - [:1] - .get() - .value) - parameters = {"raw": "true", "array": accession_code} - - r = requests.get(self.FILES_URL, params=parameters) - response_dictionary = r.json() - - try: - experiments = response_dictionary["files"]["experiment"] - except KeyError: # If the platform does not exist or has no files... - logger.info( - "No files were found with this platform accession code: %s", - accession_code - ) - return True - - logger.info("Found %d new experiments for Survey Job #%d.", - len(experiments), - survey_job.id) - - for experiment in experiments: - data_files = experiment["file"] - - # If there is only one file object in data_files, - # ArrayExpress does not put it in a list of size 1 - if (type(data_files) != list): - data_files = [data_files] - - for data_file in data_files: - if (data_file["kind"] == "raw"): - url = data_file["url"].replace("\\", "") - # This is another place where this is still a POC. - # More work will need to be done to determine some - # of these additional metadata fields. - self.handle_batch(Batch(size_in_bytes=data_file["size"], - download_url=url, - raw_format="MICRO_ARRAY", - processed_format="PCL", - accession_code=accession_code, - organism=1)) - - return True + @staticmethod + def get_experiment_metadata(experiment_accession_code): + experiment_request = requests.get(EXPERIMENTS_URL + experiment_accession_code) + parsed_json = experiment_request.json()["experiments"]["experiment"][0] + + experiment = {} + + experiment["name"] = parsed_json["name"] + experiment["experiment_accession_code"] = experiment_accession_code + + # If there is more than one arraydesign listed in the experiment + # then there is no other way to determine which array was used + # for which sample other than looking at the header of the CEL + # file. That obviously cannot happen until the CEL file has been + # downloaded so we can just mark it as UNKNOWN and let the + # downloader inspect the downloaded file to determine the + # array then. + if len(parsed_json["arraydesign"]) == 0: + logger.warn("Experiment %s has no arraydesign listed.", experiment_accession_code) + experiment["platform_accession_code"] = "UNKNOWN" + elif len(parsed_json["arraydesign"]) > 1: + experiment["platform_accession_code"] = "UNKNOWN" + else: + experiment["platform_accession_code"] = \ + parsed_json["arraydesign"][0]["accession"] + + experiment["release_date"] = parsed_json["releasedate"] + + if "lastupdatedate" in parsed_json: + experiment["last_update_date"] = parsed_json["lastupdatedate"] + else: + experiment["last_update_date"] = parsed_json["releasedate"] + + return experiment + + def survey(self): + experiment_accession_code = ( + SurveyJobKeyValue + .objects + .get(survey_job_id=self.survey_job.id, + key__exact="experiment_accession_code") + .value + ) + + logger.info("Surveying experiment with accession code: %s.", experiment_accession_code) + + experiment = self.get_experiment_metadata(experiment_accession_code) + + r = requests.get(SAMPLES_URL.format(experiment_accession_code)) + samples = r.json()["experiment"]["sample"] + + batches = [] + for sample in samples: + if "file" not in sample: + continue + + organism_name = "UNKNOWN" + for characteristic in sample["characteristic"]: + if characteristic["category"].upper() == "ORGANISM": + organism_name = characteristic["value"].upper() + + if organism_name == "UNKNOWN": + logger.error("Sample from experiment %s did not specify the organism name.", + experiment_accession_code) + organism_id = 0 + else: + organism_id = Organism.get_id_for_name(organism_name) + + for sample_file in sample["file"]: + if sample_file["type"] != "data": + continue + + batches.append(Batch( + size_in_bytes=-1, # Will have to be determined later + download_url=sample_file["comment"]["value"], + raw_format=sample_file["name"].split(".")[-1], + processed_format="PCL", + platform_accession_code=experiment["platform_accession_code"], + experiment_accession_code=experiment_accession_code, + organism_id=organism_id, + organism_name=organism_name, + experiment_title=experiment["name"], + release_date=experiment["release_date"], + last_uploaded_date=experiment["last_update_date"], + name=sample_file["name"] + )) + + # Group batches based on their download URL and handle each group. + download_urls = {batch.download_url for batch in batches} + for url in download_urls: + batches_with_url = [batch for batch in batches if batch.download_url == url] + self.handle_batches(batches_with_url) diff --git a/foreman/data_refinery_foreman/surveyor/external_source.py b/foreman/data_refinery_foreman/surveyor/external_source.py index 62bca47cb..0be944435 100644 --- a/foreman/data_refinery_foreman/surveyor/external_source.py +++ b/foreman/data_refinery_foreman/surveyor/external_source.py @@ -3,13 +3,16 @@ from enum import Enum from typing import List from retrying import retry +from django.db import transaction from data_refinery_models.models import ( Batch, BatchStatuses, - BatchKeyValue, DownloaderJob, + DownloaderJobsToBatches, SurveyJob ) +from data_refinery_foreman.surveyor.message_queue import app + # Import and set logger import logging @@ -25,7 +28,8 @@ class PipelineEnums(Enum): """An abstract class to enumerate valid processor pipelines. Enumerations which extend this class are valid values for the - pipeline_required field of the Batches table.""" + pipeline_required field of the Batches table. + """ pass @@ -35,8 +39,7 @@ class ProcessorPipeline(PipelineEnums): class DiscoveryPipeline(PipelineEnums): - """Pipelines which discover what kind of processing is appropriate - for the data.""" + """Pipelines which discover appropriate processing for the data.""" pass @@ -52,56 +55,69 @@ def source_type(self): @abc.abstractproperty def downloader_task(self): - """This property should return the Celery Downloader Task name - from the data_refinery_workers project which should be queued - to download Batches discovered by this surveyor.""" + """Abstract property representing the downloader task. + + Should return the Celery Downloader Task name from the + data_refinery_workers project which should be queued to + download Batches discovered by this surveyor. + """ return @abc.abstractmethod def determine_pipeline(self, - batch: Batch, - key_values: List[BatchKeyValue] = []): - """Determines the appropriate processor pipeline for the batch - and returns a string that represents a processor pipeline. - Must return a member of PipelineEnums.""" + batch: Batch): + """Determines the appropriate pipeline for the batch. + + Returns a string that represents a processor pipeline. + Must return a member of PipelineEnums. + """ return - def handle_batch(self, batch: Batch, key_values: BatchKeyValue = None): - batch.survey_job = self.survey_job - batch.source_type = self.source_type() - batch.status = BatchStatuses.NEW.value + def handle_batches(self, batches: List[Batch]): + for batch in batches: + batch.survey_job = self.survey_job + batch.source_type = self.source_type() + batch.status = BatchStatuses.NEW.value - pipeline_required = self.determine_pipeline(batch, key_values) - if (pipeline_required is DiscoveryPipeline) or batch.processed_format: - batch.pipeline_required = pipeline_required.value - else: - message = ("Batches must have the processed_format field set " - "unless the pipeline returned by determine_pipeline " - "is of the type DiscoveryPipeline.") - raise InvalidProcessedFormatError(message) + pipeline_required = self.determine_pipeline(batch) + if (pipeline_required is DiscoveryPipeline) or batch.processed_format: + batch.pipeline_required = pipeline_required.value + else: + message = ("Batches must have the processed_format field set " + "unless the pipeline returned by determine_pipeline " + "is of the type DiscoveryPipeline.") + raise InvalidProcessedFormatError(message) - batch.internal_location = os.path.join(batch.accession_code, - batch.pipeline_required) + batch.internal_location = os.path.join(batch.platform_accession_code, + batch.pipeline_required) @retry(stop_max_attempt_number=3) - def save_batch_start_job(): - batch.save() - downloader_job = DownloaderJob(batch=batch) + @transaction.atomic + def save_batches_start_job(): + downloader_job = DownloaderJob() downloader_job.save() - self.downloader_task().delay(downloader_job.id) + + for batch in batches: + batch.save() + downloader_job_to_batch = DownloaderJobsToBatches(batch=batch, + downloader_job=downloader_job) + downloader_job_to_batch.save() + + app.send_task(self.downloader_task(), args=[downloader_job.id]) try: - save_batch_start_job() - except Exception as e: - logger.error("Failed to save batch to database three times " - + "because error: %s. Terminating survey job #%d.", - type(e).__name__, - self.survey_job.id) + save_batches_start_job() + except Exception: + logger.exception(("Failed to save batches to database three times. " + "Terminating survey job #%d."), + self.survey_job.id) raise @abc.abstractmethod - def survey(self, survey_job: SurveyJob): - """Implementations of this function should do the following: + def survey(self): + """Abstract method to survey a source. + + Implementations of this method should do the following: 1. Query the external source to discover batches that should be downloaded. 2. Create a Batch object for each discovered batch and optionally diff --git a/foreman/data_refinery_foreman/surveyor/management/commands/start.py b/foreman/data_refinery_foreman/surveyor/management/commands/start.py deleted file mode 100644 index 7bc5e4a89..000000000 --- a/foreman/data_refinery_foreman/surveyor/management/commands/start.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.core.management.base import BaseCommand -from data_refinery_foreman.surveyor import surveyor - - -class Command(BaseCommand): - def handle(self, *args, **options): - surveyor.test() diff --git a/foreman/data_refinery_foreman/surveyor/management/commands/survey.py b/foreman/data_refinery_foreman/surveyor/management/commands/survey.py new file mode 100644 index 000000000..42de9d798 --- /dev/null +++ b/foreman/data_refinery_foreman/surveyor/management/commands/survey.py @@ -0,0 +1,31 @@ +""" +This command will create and run survey jobs for each experiment in the +experiment_list. experiment list should be a file containing one +experiment accession code per line. +""" + +from django.core.management.base import BaseCommand +from data_refinery_foreman.surveyor import surveyor + +# Import and set logger +import logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument( + "experiment_list", + help=("A file containing a list of experiment accession codes to " + "survey, download, and process. These should be listed one " + "per line. Should be a path relative to the foreman " + "directory.")) + + def handle(self, *args, **options): + if options["experiment_list"] is None: + logger.error("You must specify an experiment list.") + return 1 + else: + surveyor.survey_experiments(options["experiment_list"]) + return 0 diff --git a/foreman/data_refinery_foreman/surveyor/surveyor.py b/foreman/data_refinery_foreman/surveyor/surveyor.py index af7b4a49f..5823da04a 100644 --- a/foreman/data_refinery_foreman/surveyor/surveyor.py +++ b/foreman/data_refinery_foreman/surveyor/surveyor.py @@ -57,7 +57,7 @@ def run_job(survey_job: SurveyJob): return survey_job try: - job_success = surveyor.survey(survey_job) + job_success = surveyor.survey() except Exception as e: logger.error("Exception caught while running job #%d with message: %s", survey_job.id, @@ -73,8 +73,20 @@ def test(): survey_job = SurveyJob(source_type="ARRAY_EXPRESS") survey_job.save() key_value_pair = SurveyJobKeyValue(survey_job=survey_job, - key="accession_code", - value="A-AFFY-1") + key="experiment_accession_code", + value="E-MTAB-3050") key_value_pair.save() run_job(survey_job) return + + +def survey_experiments(experiments_list_file): + with open(experiments_list_file, "r") as experiments: + for experiment in experiments: + survey_job = SurveyJob(source_type="ARRAY_EXPRESS") + survey_job.save() + key_value_pair = SurveyJobKeyValue(survey_job=survey_job, + key="experiment_accession_code", + value=experiment.rstrip()) + key_value_pair.save() + run_job(survey_job) diff --git a/foreman/data_refinery_foreman/surveyor/test_array_express.py b/foreman/data_refinery_foreman/surveyor/test_array_express.py index 8c7f28d7d..7cb26d9cb 100644 --- a/foreman/data_refinery_foreman/surveyor/test_array_express.py +++ b/foreman/data_refinery_foreman/surveyor/test_array_express.py @@ -1,151 +1,298 @@ import json +import datetime from unittest.mock import Mock, patch from django.test import TestCase from data_refinery_models.models import ( Batch, + DownloaderJob, SurveyJob, - SurveyJobKeyValue + SurveyJobKeyValue, + Organism +) +from data_refinery_foreman.surveyor.array_express import ( + ArrayExpressSurveyor, + EXPERIMENTS_URL, ) -from data_refinery_foreman.surveyor.array_express import ArrayExpressSurveyor - - -class MockTask(): - def delay(self, id): - return True -class SurveyTestCase(TestCase): - experiments_json = """ - { - "files": { +EXPERIMENTS_JSON = """ +{ + "experiments": { "api-revision": "091015", - "api-version": 2, + "api-version": 3, "experiment": [ { "accession": "E-MTAB-3050", + "arraydesign": [ + { + "accession": "A-AFFY-1", + "count": 5, + "id": 11048, + "legacy_id": 5728564, + "name": "Affymetrix GeneChip Human U95Av2 [HG_U95Av2]" + } + ], + "bioassaydatagroup": [ + { + "arraydesignprovider": null, + "bioassaydatacubes": 5, + "bioassays": 5, + "dataformat": "rawData", + "id": null, + "isderived": 0, + "name": "rawData" + } + ], + "description": [ + { + "id": null, + "text": "description tex" + } + ], + "experimentalvariable": [ + { + "name": "cell type", + "value": [ + "differentiated", + "expanded", + "freshly isolated" + ] + } + ], + "experimentdesign": [ + "cell type comparison design", + "development or differentiation design" + ], + "experimenttype": [ + "transcription profiling by array" + ], + "id": 511696, + "lastupdatedate": "2014-10-30", + "name": "Microarray analysis of in vitro differentiation", + "organism": [ + "Homo sapiens" + ], + "protocol": [ + { + "accession": "P-MTAB-41859", + "id": 1092859 + } + ], + "provider": [ + { + "contact": "Joel Habener", + "email": "jhabener@partners.org", + "role": "submitter" + } + ], + "releasedate": "2014-10-31", + "samplecharacteristic": [ + { + "category": "age", + "value": [ + "38 year", + "54 year" + ] + } + ] + } + ], + "revision": "091015", + "total": 1, + "total-assays": 5, + "total-samples": 2, + "version": 3.0 + } +} """ + +SAMPLES_JSON = """ +{ + "experiment": { + "accession": "E-MTAB-3050", + "api-revision": "091015", + "api-version": 3, + "revision": "091015", + "sample": [ + { + "assay": { + "name": "1007409-C30057" + }, + "characteristic": [ + { + "category": "organism", + "value": "Homo sapiens" + } + ], + "extract": { + "name": "donor A islets RNA" + }, "file": [ { - "extension": "zip", - "kind": "raw", - "lastmodified": "2014-10-30T10:15:00", - "location": "E-MTAB-3050.raw.1.zip", - "name": "E-MTAB-3050.raw.1.zip", - "size": 14876114, - "url": - "http://www.ebi.ac.uk/arrayexpress/files/E-MTAB-3050/E-MTAB-3050.raw.1.zip" + "comment": { + "name": "ArrayExpress FTP file", + "value": "ftp://ftp.ebi.ac.uk/pub/databases/microarray/data/experiment/MTAB/E-MTAB-3050/E-MTAB-3050.raw.1.zip" + }, + "name": "C30057.CEL", + "type": "data", + "url": "ftp://ftp.ebi.ac.uk/pub/databases/microarray/data/experiment/MTAB/E-MTAB-3050/E-MTAB-3050.raw.1.zip/C30057.CEL" }, { - "extension": "xls", - "kind": "adf", - "lastmodified": "2010-03-14T02:31:00", - "location": "A-AFFY-1.adf.xls", - "name": "A-AFFY-1.adf.xls", - "size": 2040084, - "url": - "http://www.ebi.ac.uk/arrayexpress/files/A-AFFY-1/A-AFFY-1.adf.xls" + "comment": { + "name": "Derived ArrayExpress FTP file", + "value": "ftp://ftp.ebi.ac.uk/pub/databases/microarray/data/experiment/MTAB/E-MTAB-3050/E-MTAB-3050.processed.1.zip" + }, + "name": "C30057.txt", + "type": "derived data", + "url": "ftp://ftp.ebi.ac.uk/pub/databases/microarray/data/experiment/MTAB/E-MTAB-3050/E-MTAB-3050.processed.1.zip/C30057.txt" + } + ], + "labeled-extract": { + "label": "biotin", + "name": "donor A islets LEX" + }, + "source": { + "name": "donor A islets" + }, + "variable": [ + { + "name": "cell type", + "value": "freshly isolated" } ] }, { - "accession": "E-MTAB-3042", + "assay": { + "name": "1007409-C30058" + }, + "characteristic": [ + { + "category": "organism", + "value": "Homo sapiens" + } + ], + "extract": { + "name": "donor A expanded cells RNA" + }, "file": [ { - "extension": "txt", - "kind": "idf", - "lastmodified": "2014-10-28T10:15:00", - "location": "E-MTAB-3042.idf.txt", - "name": "E-MTAB-3042.idf.txt", - "size": 5874, - "url": - "http://www.ebi.ac.uk/arrayexpress/files/E-MTAB-3042/E-MTAB-3042.idf.txt" + "comment": { + "name": "ArrayExpress FTP file", + "value": "ftp://ftp.ebi.ac.uk/pub/databases/microarray/data/experiment/MTAB/E-MTAB-3050/E-MTAB-3050.raw.1.zip" + }, + "name": "C30058.CEL", + "type": "data", + "url": "ftp://ftp.ebi.ac.uk/pub/databases/microarray/data/experiment/MTAB/E-MTAB-3050/E-MTAB-3050.raw.1.zip/C30058.CEL" }, { - "extension": "zip", - "kind": "raw", - "lastmodified": "2014-10-28T10:15:00", - "location": "E-MTAB-3042.raw.1.zip", - "name": "E-MTAB-3042.raw.1.zip", - "size": 5525709, - "url": - "http://www.ebi.ac.uk/arrayexpress/files/E-MTAB-3042/E-MTAB-3042.raw.1.zip" + "comment": { + "name": "Derived ArrayExpress FTP file", + "value": "ftp://ftp.ebi.ac.uk/pub/databases/microarray/data/experiment/MTAB/E-MTAB-3050/E-MTAB-3050.processed.1.zip" + }, + "name": "C30058.txt", + "type": "derived data", + "url": "ftp://ftp.ebi.ac.uk/pub/databases/microarray/data/experiment/MTAB/E-MTAB-3050/E-MTAB-3050.processed.1.zip/C30058.txt" + } + ], + "labeled-extract": { + "label": "biotin", + "name": "donor A expanded cells LEX" + }, + "source": { + "name": "donor A islets" + }, + "variable": [ + { + "name": "cell type", + "value": "expanded" } ] } ], - "revision": 130311, - "total-experiments": 108, - "version": 1.2 + "version": 1.0 } -} -""" +}""" # noqa + + +def mocked_requests_get(url): + mock = Mock(ok=True) + if url == (EXPERIMENTS_URL + "E-MTAB-3050"): + mock.json.return_value = json.loads(EXPERIMENTS_JSON) + else: + mock.json.return_value = json.loads(SAMPLES_JSON) + return mock + + +class SurveyTestCase(TestCase): def setUp(self): survey_job = SurveyJob(source_type="ARRAY_EXPRESS") survey_job.save() self.survey_job = survey_job key_value_pair = SurveyJobKeyValue(survey_job=survey_job, - key="accession_code", - value="A-AFFY-1") + key="experiment_accession_code", + value="E-MTAB-3050") key_value_pair.save() + # Insert the organism into the database so the model doesn't call the + # taxonomy API to populate it. + organism = Organism(name="HOMO SAPIENS", + taxonomy_id=9606, + is_scientific_name=True) + organism.save() + def tearDown(self): SurveyJob.objects.all().delete() SurveyJobKeyValue.objects.all().delete() Batch.objects.all().delete - @patch("data_refinery_foreman.surveyor.array_express.requests.get") - @patch("data_refinery_foreman.surveyor.array_express.ArrayExpressSurveyor.downloader_task") # noqa - def test_multiple_experiements(self, mock_task, mock_get): - """Multiple experiments are turned into multiple batches""" + @patch('data_refinery_foreman.surveyor.array_express.requests.get') + def test_experiment_object(self, mock_get): + """The get_experiment_metadata function extracts all experiment metadata + from the experiments API.""" mock_get.return_value = Mock(ok=True) - mock_get.return_value.json.return_value = json.loads( - self.experiments_json) - mock_task.return_value = MockTask() + mock_get.return_value.json.return_value = json.loads(EXPERIMENTS_JSON) - ae_surveyor = ArrayExpressSurveyor(self.survey_job) - self.assertTrue(ae_surveyor.survey(self.survey_job)) - self.assertEqual(2, Batch.objects.all().count()) - - # Note that this json has the `file` key mapped to a dictionary - # instead of a list. This is behavior exhibited by the API - # and is being tested on purpose here. - experiment_json = """ - { - "files": { - "api-revision": "091015", - "api-version": 2, - "experiment": [ - { - "accession": "E-MTAB-3050", - "file": { - "extension": "zip", - "kind": "raw", - "lastmodified": "2014-10-30T10:15:00", - "location": "E-MTAB-3050.raw.1.zip", - "name": "E-MTAB-3050.raw.1.zip", - "size": 14876114, - "url": - "http://www.ebi.ac.uk/arrayexpress/files/E-MTAB-3050/E-MTAB-3050.raw.1.zip" - } - } - ], - "revision": 130311, - "total-experiments": 108, - "version": 1.2 - } -} -""" + experiment = ArrayExpressSurveyor.get_experiment_metadata("E-MTAB-3050") + self.assertEqual("Microarray analysis of in vitro differentiation", experiment["name"]) + self.assertEqual("E-MTAB-3050", experiment["experiment_accession_code"]) + self.assertEqual("A-AFFY-1", experiment["platform_accession_code"]) + self.assertEqual("2014-10-31", experiment["release_date"]) + self.assertEqual("2014-10-30", experiment["last_update_date"]) @patch('data_refinery_foreman.surveyor.array_express.requests.get') - @patch("data_refinery_foreman.surveyor.array_express.ArrayExpressSurveyor.downloader_task") # noqa - def test_single_experiment(self, mock_task, mock_get): - """A single experiment is turned into a single batch.""" - mock_get.return_value = Mock(ok=True) - mock_get.return_value.json.return_value = json.loads( - self.experiment_json) - mock_task.return_value = MockTask() + @patch('data_refinery_foreman.surveyor.message_queue.app.send_task') + def test_survey(self, mock_send_task, mock_get): + """survey generates one Batch per sample with all possible fields populated. + This test also tests the handle_batches method of ExternalSourceSurveyor + which isn't tested on its own because it is an abstract class.""" + mock_send_task.return_value = Mock(ok=True) + mock_get.side_effect = mocked_requests_get ae_surveyor = ArrayExpressSurveyor(self.survey_job) - self.assertTrue(ae_surveyor.survey(self.survey_job)) - self.assertEqual(1, Batch.objects.all().count()) + ae_surveyor.survey() + + self.assertEqual(2, len(mock_send_task.mock_calls)) + batches = Batch.objects.all() + self.assertEqual(2, len(batches)) + downloader_jobs = DownloaderJob.objects.all() + self.assertEqual(2, len(downloader_jobs)) + + batch = batches[0] + self.assertEqual(batch.survey_job.id, self.survey_job.id) + self.assertEqual(batch.source_type, "ARRAY_EXPRESS") + self.assertEqual(batch.size_in_bytes, -1) + self.assertEqual(batch.download_url, "ftp://ftp.ebi.ac.uk/pub/databases/microarray/data/experiment/MTAB/E-MTAB-3050/E-MTAB-3050.raw.1.zip/C30057.CEL") # noqa + self.assertEqual(batch.raw_format, "CEL") + self.assertEqual(batch.processed_format, "PCL") + self.assertEqual(batch.pipeline_required, "AFFY_TO_PCL") + self.assertEqual(batch.platform_accession_code, "A-AFFY-1") + self.assertEqual(batch.experiment_accession_code, "E-MTAB-3050") + self.assertEqual(batch.experiment_title, "Microarray analysis of in vitro differentiation") + self.assertEqual(batch.status, "NEW") + self.assertEqual(batch.release_date, datetime.date(2014, 10, 31)) + self.assertEqual(batch.last_uploaded_date, datetime.date(2014, 10, 30)) + self.assertEqual(batch.name, "C30057.CEL") + self.assertEqual(batch.internal_location, "A-AFFY-1/AFFY_TO_PCL/") + self.assertEqual(batch.organism_id, 9606) + self.assertEqual(batch.organism_name, "HOMO SAPIENS") diff --git a/foreman/data_refinery_foreman/surveyor/test_surveyor.py b/foreman/data_refinery_foreman/surveyor/test_surveyor.py index 359496b8d..4662a03b8 100644 --- a/foreman/data_refinery_foreman/surveyor/test_surveyor.py +++ b/foreman/data_refinery_foreman/surveyor/test_surveyor.py @@ -26,7 +26,7 @@ def test_calls_survey(self, survey_method): job.save() surveyor.run_job(job) - survey_method.assert_called_with(job) + survey_method.assert_called() self.assertIsInstance(job.replication_ended_at, datetime.datetime) self.assertIsInstance(job.start_time, datetime.datetime) self.assertIsInstance(job.end_time, datetime.datetime) diff --git a/foreman/requirements.in b/foreman/requirements.in index 4095eb400..61c1f238e 100644 --- a/foreman/requirements.in +++ b/foreman/requirements.in @@ -2,4 +2,5 @@ django celery psycopg2 requests +GEOparse retrying diff --git a/foreman/requirements.txt b/foreman/requirements.txt index 60a1060f3..40caa0728 100644 --- a/foreman/requirements.txt +++ b/foreman/requirements.txt @@ -8,10 +8,15 @@ amqp==2.1.4 # via kombu billiard==3.5.0.2 # via celery celery==4.0.2 django==1.10.6 +GEOparse==0.1.10 kombu==4.0.2 # via celery +numpy==1.12.1 # via geoparse, pandas +pandas==0.19.2 # via geoparse psycopg2==2.7.1 -pytz==2016.10 # via celery +python-dateutil==2.6.0 # via pandas +pytz==2017.2 # via celery, django, pandas requests==2.13.0 retrying==1.3.3 -six==1.10.0 # via retrying +six==1.10.0 # via python-dateutil, retrying vine==1.1.3 # via amqp +wgetter==0.6 # via geoparse diff --git a/foreman/run_surveyor.sh b/foreman/run_surveyor.sh index d941d228f..e88b381f0 100755 --- a/foreman/run_surveyor.sh +++ b/foreman/run_surveyor.sh @@ -11,12 +11,20 @@ cd $script_directory # move up a level cd .. +# Set up the data volume directory if it does not already exist +volume_directory="$script_directory/volume" +if [ ! -d "$volume_directory" ]; then + mkdir $volume_directory + chmod 775 $volume_directory +fi + docker build -t dr_foreman -f foreman/Dockerfile . HOST_IP=$(ip route get 8.8.8.8 | awk '{print $NF; exit}') docker run \ - --link some-rabbit:rabbit \ + --link message-queue:rabbit \ --add-host=database:$HOST_IP \ --env-file foreman/environments/dev \ - dr_foreman + --volume $volume_directory:/home/user/data_store \ + dr_foreman survey "$@" diff --git a/requirements.in b/requirements.in index bf848a288..71a4ddceb 100644 --- a/requirements.in +++ b/requirements.in @@ -2,3 +2,5 @@ django psycopg2 pip-tools autopep8 +flake8 +requests diff --git a/requirements.txt b/requirements.txt index a5296e1de..6d68f38b4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,11 @@ autopep8==1.3.1 click==6.7 # via pip-tools django==1.10.6 first==2.0.1 # via pip-tools +flake8==3.3.0 +mccabe==0.6.1 # via flake8 pip-tools==1.9.0 psycopg2==2.7.1 -pycodestyle==2.3.1 # via autopep8 +pycodestyle==2.3.1 # via autopep8, flake8 +pyflakes==1.5.0 # via flake8 +requests==2.13.0 six==1.10.0 # via pip-tools diff --git a/run_rabbitmq.sh b/run_rabbitmq.sh new file mode 100755 index 000000000..7477b963d --- /dev/null +++ b/run_rabbitmq.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker run -d --hostname rabbit-queue --name message-queue rabbitmq:3 diff --git a/run_shell.sh b/run_shell.sh index 3e45fdf24..557a50703 100755 --- a/run_shell.sh +++ b/run_shell.sh @@ -5,21 +5,30 @@ # By default the Docker container will be for the foreman project. # This can be changed by modifying the --env-file command line arg, # changing foreman/Dockerfile to the appropriate Dockerfile, +# changing the volume_directory path, # and by modifying the Dockerfile.shell file appropriately. # This script should always run as if it were being called from # the directory it lives in. -script_directory=`dirname "${BASH_SOURCE[0]}"` +script_directory=`cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd` cd $script_directory +# Set up the data volume directory if it does not already exist +volume_directory="$script_directory/foreman/volume" +if [ ! -d "$volume_directory" ]; then + mkdir $volume_directory + chmod 775 $volume_directory +fi + docker build -t dr_shell -f foreman/Dockerfile . HOST_IP=$(ip route get 8.8.8.8 | awk '{print $NF; exit}') docker run \ - --link some-rabbit:rabbit \ + --link message-queue:rabbit \ --add-host=database:$HOST_IP \ --env-file foreman/environments/dev \ --volume /tmp:/tmp \ + --volume $volume_directory:/home/user/data_store \ --entrypoint ./manage.py \ --interactive dr_shell shell diff --git a/schema.png b/schema.png new file mode 100644 index 0000000000000000000000000000000000000000..d4e22c16beec1f104548b65a8699bab6b0cc0674 GIT binary patch literal 115378 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%0AK+bP__Yhx930ux?@{=kY)F~5_NTF)pR8e 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�n& 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 0: + target_file_path = utils.prepare_destination(batches[0]) + download_url = batches[0].download_url + else: + logger.error("No batches found for job #%d.", + job_id) + success = False - logger.info("Downloading file from %s to %s. (Batch #%d, Job #%d)", - batch.download_url, - target_file_path, - batch.id, - job_id) + if success: + try: + _verify_batch_grouping(batches, job_id) - try: - target_file = open(target_file_path, "wb") - request = requests.get(batch.download_url, stream=True) - - for chunk in request.iter_content(CHUNK_SIZE): - if chunk: - target_file.write(chunk) - target_file.flush() - except Exception as e: - success = False - logger.error("Exception caught while running Job #%d for Batch #%d " - + "with message: %s", - job_id, - batch.id, - e) - finally: - target_file.close() - request.close() + # The files for all of the batches in the grouping are + # contained within the same zip file. Therefore only + # download the one. + _download_file(download_url, target_file_path, job_id) + _extract_file(target_file_path, job_id) + except Exception: + # Exceptions are already logged and handled. + # Just need to mark the job as failed. + success = False if success: - logger.info("File %s (Batch #%d) downloaded successfully in Job #%d.", - batch.download_url, - batch.id, - job_id) + logger.debug("File %s downloaded and extracted successfully in Job #%d.", + download_url, + job_id) - utils.end_job(job, batch, success) + utils.end_job(job, batches, success) diff --git a/workers/data_refinery_workers/downloaders/management/commands/queue_downloader.py b/workers/data_refinery_workers/downloaders/management/commands/queue_downloader.py index 856956197..61c20cac0 100644 --- a/workers/data_refinery_workers/downloaders/management/commands/queue_downloader.py +++ b/workers/data_refinery_workers/downloaders/management/commands/queue_downloader.py @@ -8,7 +8,8 @@ SurveyJob, Batch, BatchStatuses, - DownloaderJob + DownloaderJob, + DownloaderJobsToBatches ) from data_refinery_workers.downloaders.array_express \ import download_array_express @@ -33,18 +34,27 @@ def handle(self, *args, **options): survey_job=survey_job, source_type="ARRAY_EXPRESS", size_in_bytes=0, - download_url="http://www.ebi.ac.uk/arrayexpress/files/E-MTAB-3050/E-MTAB-3050.raw.1.zip", # noqa - raw_format="MICRO_ARRAY", + download_url="ftp://ftp.ebi.ac.uk/pub/databases/microarray/data/experiment/GEOD/E-GEOD-59071/E-GEOD-59071.raw.3.zip", # noqa + raw_format="CEL", processed_format="PCL", - pipeline_required="MICRO_ARRAY_TO_PCL", - accession_code="A-AFFY-1", - internal_location="A-AFFY-1/MICRO_ARRAY_TO_PCL/", - organism=1, + pipeline_required="AFFY_TO_PCL", + platform_accession_code="A-AFFY-141", + experiment_accession_code="E-GEOD-59071", + experiment_title="It doesn't really matter.", + name="GSM1426072_CD_colon_active_2.CEL", + internal_location="A-AFFY-141/AFFY_TO_PCL/", + organism_id=9606, + organism_name="HOMO SAPIENS", + release_date="2017-05-05", + last_uploaded_date="2017-05-05", status=BatchStatuses.NEW.value ) batch.save() - downloader_job = DownloaderJob(batch=batch) + downloader_job = DownloaderJob() downloader_job.save() + downloader_job_to_batch = DownloaderJobsToBatches(batch=batch, + downloader_job=downloader_job) + downloader_job_to_batch.save() logger.info("Queuing a task.") download_array_express.delay(downloader_job.id) diff --git a/workers/data_refinery_workers/downloaders/management/commands/queue_task.py b/workers/data_refinery_workers/downloaders/management/commands/queue_task.py deleted file mode 100644 index aedb718ac..000000000 --- a/workers/data_refinery_workers/downloaders/management/commands/queue_task.py +++ /dev/null @@ -1,47 +0,0 @@ -from django.core.management.base import BaseCommand -from data_refinery_models.models import ( - SurveyJob, - Batch, - BatchStatuses, - DownloaderJob -) -from data_refinery_workers.downloaders.array_express \ - import download_array_express - - -# Import and set logger -import logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -# Just a temporary way to queue a celery task -# without running the surveyor. -class Command(BaseCommand): - def handle(self, *args, **options): - # Create all the dummy data that would have been created - # before a downloader job could have been generated. - survey_job = SurveyJob( - source_type="ARRAY_EXPRESS" - ) - survey_job.save() - - batch = Batch( - survey_job=survey_job, - source_type="ARRAY_EXPRESS", - size_in_bytes=0, - download_url="http://www.ebi.ac.uk/arrayexpress/files/E-MTAB-3050/E-MTAB-3050.raw.1.zip", # noqa - raw_format="MICRO_ARRAY", - processed_format="PCL", - pipeline_required="MICRO_ARRAY_TO_PCL", - accession_code="A-AFFY-1", - internal_location="expression_data/array_express/A-AFFY-1/", - organism=1, - status=BatchStatuses.NEW.value - ) - batch.save() - - downloader_job = DownloaderJob(batch=batch) - downloader_job.save() - logger.info("Queuing a task.") - download_array_express.delay(downloader_job.id) diff --git a/workers/data_refinery_workers/downloaders/management/commands/queue_test_task.py b/workers/data_refinery_workers/downloaders/management/commands/queue_test_task.py deleted file mode 100644 index 9befd38b3..000000000 --- a/workers/data_refinery_workers/downloaders/management/commands/queue_test_task.py +++ /dev/null @@ -1,48 +0,0 @@ -from django.core.management.base import BaseCommand -from data_refinery_models.models import ( - SurveyJob, - Batch, - BatchStatuses, - DownloaderJob -) -from data_refinery_workers.downloaders.array_express \ - import download_array_express - - -# Import and set logger -import logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -class Command(BaseCommand): - """Just a temporary way to queue a celery task without running - the surveyor.""" - - def handle(self, *args, **options): - # Create all the dummy data that would have been created - # before a downloader job could have been generated. - survey_job = SurveyJob( - source_type="ARRAY_EXPRESS" - ) - survey_job.save() - - batch = Batch( - survey_job=survey_job, - source_type="ARRAY_EXPRESS", - size_in_bytes=0, - download_url="http://www.ebi.ac.uk/arrayexpress/files/E-MTAB-3050/E-MTAB-3050.raw.1.zip", # noqa - raw_format="MICRO_ARRAY", - processed_format="PCL", - pipeline_required="MICRO_ARRAY_TO_PCL", - accession_code="A-AFFY-1", - internal_location="expression_data/array_express/A-AFFY-1/", - organism=1, - status=BatchStatuses.NEW.value - ) - batch.save() - - downloader_job = DownloaderJob(batch=batch) - downloader_job.save() - logger.info("Queuing a test task.") - download_array_express.delay(downloader_job.id) diff --git a/workers/data_refinery_workers/downloaders/test_array_express.py b/workers/data_refinery_workers/downloaders/test_array_express.py new file mode 100644 index 000000000..e2e825469 --- /dev/null +++ b/workers/data_refinery_workers/downloaders/test_array_express.py @@ -0,0 +1,117 @@ +import copy +from unittest.mock import patch, MagicMock +from django.test import TestCase +from data_refinery_models.models import ( + SurveyJob, + Batch, + BatchStatuses, + DownloaderJob, + DownloaderJobsToBatches, + ProcessorJob, + ProcessorJobsToBatches +) +from data_refinery_workers.downloaders import array_express + + +class DownloadArrayExpressTestCase(TestCase): + def test_good_batch_grouping(self): + """Returns true if all batches have the same download_url.""" + batches = [Batch(download_url="https://example.com"), + Batch(download_url="https://example.com"), + Batch(download_url="https://example.com")] + job_id = 1 + self.assertIsNone(array_express._verify_batch_grouping(batches, job_id)) + + def test_bad_batch_grouping(self): + """Raises exception if all batches don't have the same download_url.""" + batches = [Batch(download_url="https://example.com"), + Batch(download_url="https://example.com"), + Batch(download_url="https://wompwomp.com")] + job_id = 1 + with self.assertRaises(ValueError): + array_express._verify_batch_grouping(batches, job_id) + + @patch("data_refinery_workers.downloaders.array_express.utils.processor_pipeline_registry") + @patch("data_refinery_workers.downloaders.array_express._verify_batch_grouping") + @patch("data_refinery_workers.downloaders.array_express._download_file") + @patch("data_refinery_workers.downloaders.array_express._extract_file") + @patch("data_refinery_workers.downloaders.array_express.utils.prepare_destination") + def test_download(self, + prepare_destination, + _extract_file, + _download_file, + _verify_batch_grouping, + pipeline_registry): + # Set up mocks: + mock_processor_task = MagicMock() + mock_processor_task.delay = MagicMock() + mock_processor_task.delay.return_value = None + pipeline_registry.__getitem__ = MagicMock() + pipeline_registry.__getitem__.return_value = mock_processor_task + target_file_path = "target_file_path" + prepare_destination.return_value = "target_file_path" + + # Set up database records: + survey_job = SurveyJob( + source_type="ARRAY_EXPRESS" + ) + survey_job.save() + + download_url = "ftp://ftp.ebi.ac.uk/pub/databases/microarray/data/experiment/GEOD/E-GEOD-59071/E-GEOD-59071.raw.3.zip/GSM1426072_CD_colon_active_2.CEL" # noqa + batch = Batch( + survey_job=survey_job, + source_type="ARRAY_EXPRESS", + size_in_bytes=0, + download_url=download_url, + raw_format="CEL", + processed_format="PCL", + pipeline_required="AFFY_TO_PCL", + platform_accession_code="A-AFFY-1", + experiment_accession_code="E-MTAB-3050", + experiment_title="It doesn't really matter.", + name="CE1234", + internal_location="A-AFFY-1/MICRO_ARRAY_TO_PCL/", + organism_id=9606, + organism_name="HOMO SAPIENS", + release_date="2017-05-05", + last_uploaded_date="2017-05-05", + status=BatchStatuses.NEW.value + ) + batch2 = copy.deepcopy(batch) + batch2.name = "CE2345" + batch.save() + batch2.save() + + downloader_job = DownloaderJob() + downloader_job.save() + downloader_job_to_batch = DownloaderJobsToBatches(batch=batch, + downloader_job=downloader_job) + downloader_job_to_batch.save() + downloader_job_to_batch2 = DownloaderJobsToBatches(batch=batch2, + downloader_job=downloader_job) + downloader_job_to_batch2.save() + + # Call the task we're testing: + array_express.download_array_express.apply(args=(downloader_job.id,)).get() + + # Verify that all expected functionality is run: + prepare_destination.assert_called_once() + _verify_batch_grouping.assert_called_once() + _download_file.assert_called_with(download_url, target_file_path, downloader_job.id) + _extract_file.assert_called_with(target_file_path, downloader_job.id) + + mock_processor_task.delay.assert_called() + + # Verify that the database has been updated correctly: + batches = Batch.objects.all() + for batch in batches: + self.assertEqual(batch.status, BatchStatuses.DOWNLOADED.value) + + downloader_job = DownloaderJob.objects.get() + self.assertTrue(downloader_job.success) + self.assertIsNotNone(downloader_job.end_time) + + processor_jobs = ProcessorJob.objects.all() + self.assertEqual(len(processor_jobs), 2) + processor_jobs_to_batches = ProcessorJobsToBatches.objects.all() + self.assertEqual(len(processor_jobs_to_batches), 2) diff --git a/workers/data_refinery_workers/downloaders/utils.py b/workers/data_refinery_workers/downloaders/utils.py index baa054e4d..4a70d4073 100644 --- a/workers/data_refinery_workers/downloaders/utils.py +++ b/workers/data_refinery_workers/downloaders/utils.py @@ -2,11 +2,13 @@ import urllib from retrying import retry from django.utils import timezone +from django.db import transaction from data_refinery_models.models import ( Batch, BatchStatuses, DownloaderJob, - ProcessorJob + ProcessorJob, + ProcessorJobsToBatches ) from data_refinery_workers.processors.processor_registry \ import processor_pipeline_registry @@ -27,21 +29,23 @@ def start_job(job: DownloaderJob): job.save() -def end_job(job: DownloaderJob, batch: Batch, success): - """Record in the database that this job has completed, - create a processor job, and queue a processor task.""" - job.success = success - job.end_time = timezone.now() - job.save() +def end_job(job: DownloaderJob, batches: Batch, success): + """Record in the database that this job has completed. + Create a processor job and queue a processor task for each batch + if the job was successful. + """ @retry(stop_max_attempt_number=3) - def save_batch_create_job(): + def save_batch_create_job(batch): batch.status = BatchStatuses.DOWNLOADED.value batch.save() - logger.info("Creating processor job for batch #%d.", batch.id) - processor_job = ProcessorJob(batch=batch) + logger.debug("Creating processor job for batch #%d.", batch.id) + processor_job = ProcessorJob() processor_job.save() + processor_job_to_batch = ProcessorJobsToBatches(batch=batch, + processor_job=processor_job) + processor_job_to_batch.save() return processor_job @retry(stop_max_attempt_number=3) @@ -49,14 +53,23 @@ def queue_task(processor_job): processor_task = processor_pipeline_registry[batch.pipeline_required] processor_task.delay(processor_job.id) - if batch is not None: - processor_job = save_batch_create_job() - queue_task(processor_job) + if success: + for batch in batches: + with transaction.atomic(): + processor_job = save_batch_create_job(batch) + queue_task(processor_job) + + job.success = success + job.end_time = timezone.now() + job.save() def prepare_destination(batch: Batch): - """Prepare the destination directory and return the full - path the Batch's file should be downloaded to.""" + """Prepare the destination directory for the batch. + + Also returns the full path the Batch's file should be downloaded + to. + """ target_directory = os.path.join(ROOT_URI, batch.internal_location) os.makedirs(target_directory, exist_ok=True) diff --git a/workers/data_refinery_workers/processors/array_express.py b/workers/data_refinery_workers/processors/array_express.py index 492eebd8b..0836864e9 100644 --- a/workers/data_refinery_workers/processors/array_express.py +++ b/workers/data_refinery_workers/processors/array_express.py @@ -1,6 +1,15 @@ +"""This processor is currently out of date. It is designed to process +multiple CEL files at a time, but that is not how we are going to +process Array Express files. I have rewritten the Array Express +surveyor/downloader to support this, but we don't have the new +processor yet. This will run, which is good enough for testing +the system, however since it will change so much the processor +itself is not yet tested. +""" + + from __future__ import absolute_import, unicode_literals import os -import zipfile from typing import Dict import rpy2.robjects as ro from celery import shared_task @@ -11,33 +20,18 @@ def cel_to_pcl(kwargs: Dict): - batch = kwargs["batch"] + # Array Express processor jobs have one batch per job. + batch = kwargs["batches"][0] - temp_directory = utils.ROOT_URI + "temp/" + batch.internal_location + from_directory = utils.ROOT_URI + "raw/" + batch.internal_location target_directory = utils.ROOT_URI + "processed/" + batch.internal_location - os.makedirs(temp_directory, exist_ok=True) os.makedirs(target_directory, exist_ok=True) - - raw_file_name = batch.download_url.split('/')[-1] - zip_location = (utils.ROOT_URI + "raw/" + batch.internal_location - + raw_file_name) - - if os.path.isfile(zip_location): - zip_ref = zipfile.ZipFile(zip_location, 'r') - zip_ref.extractall(temp_directory) - zip_ref.close() - else: - logger.error("Missing file: %s", zip_location) - return {"success": False} - - # Experiment code should be added to the batches data model - experiment_code = raw_file_name.split('/')[0] - new_name = experiment_code + ".pcl" + new_name = batch.name + "." + batch.processed_format ro.r('source("/home/user/r_processors/process_cel_to_pcl.R")') ro.r['ProcessCelFiles']( - temp_directory, - "Hs", # temporary until organism discovery is working + from_directory, + "Hs", # temporary until organism handling is more defined target_directory + new_name) return kwargs @@ -48,5 +42,4 @@ def affy_to_pcl(job_id): utils.run_pipeline({"job_id": job_id}, [utils.start_job, cel_to_pcl, - utils.cleanup_temp_data, utils.end_job]) diff --git a/workers/data_refinery_workers/processors/management/commands/queue_processor.py b/workers/data_refinery_workers/processors/management/commands/queue_processor.py index eae9bbade..a75f3c3bb 100644 --- a/workers/data_refinery_workers/processors/management/commands/queue_processor.py +++ b/workers/data_refinery_workers/processors/management/commands/queue_processor.py @@ -1,20 +1,21 @@ """This command is intended for development purposes. It creates the database records necessary for a processor job to run and queues one. It assumes that the file -/home/user/data_store/raw/A-AFFY-1/MICRO_ARRAY_TO_PCL/E-MTAB-3050.raw.1.zip +/home/user/data_store/raw/A-AFFY-141/AFFY_TO_PCL/GSM1426072_CD_colon_active_2.CEL exists. The easiest way to run this is with the tester.sh script. -(Changing queue_downloader to queue_processor.)""" +(Changing queue_downloader to queue_processor.) +""" from django.core.management.base import BaseCommand from data_refinery_models.models import ( SurveyJob, Batch, BatchStatuses, - ProcessorJob + ProcessorJob, + ProcessorJobsToBatches ) -from data_refinery_workers.processors.array_express \ - import process_array_express +from data_refinery_workers.processors.array_express import affy_to_pcl # Import and set logger @@ -36,18 +37,27 @@ def handle(self, *args, **options): survey_job=survey_job, source_type="ARRAY_EXPRESS", size_in_bytes=0, - download_url="http://www.ebi.ac.uk/arrayexpress/files/E-MTAB-3050/E-MTAB-3050.raw.1.zip", # noqa - raw_format="MICRO_ARRAY", + download_url="ftp://ftp.ebi.ac.uk/pub/databases/microarray/data/experiment/GEOD/E-GEOD-59071/E-GEOD-59071.raw.3.zip", # noqa + raw_format="CEL", processed_format="PCL", - pipeline_required="MICRO_ARRAY_TO_PCL", - accession_code="A-AFFY-1", - internal_location="A-AFFY-1/MICRO_ARRAY_TO_PCL/", - organism=1, + pipeline_required="AFFY_TO_PCL", + platform_accession_code="A-AFFY-141", + experiment_accession_code="E-GEOD-59071", + experiment_title="It doesn't really matter.", + name="GSM1426072_CD_colon_active_2.CEL", + internal_location="A-AFFY-141/AFFY_TO_PCL/", + organism_id=9606, + organism_name="HOMO SAPIENS", + release_date="2017-05-05", + last_uploaded_date="2017-05-05", status=BatchStatuses.NEW.value ) batch.save() - processor_job = ProcessorJob(batch=batch) + processor_job = ProcessorJob() processor_job.save() + downloader_job_to_batch = ProcessorJobsToBatches(batch=batch, + processor_job=processor_job) + downloader_job_to_batch.save() logger.info("Queuing a processor job.") - process_array_express.delay(processor_job.id) + affy_to_pcl.delay(processor_job.id) diff --git a/workers/data_refinery_workers/processors/test_utils.py b/workers/data_refinery_workers/processors/test_utils.py new file mode 100644 index 000000000..c88fe3400 --- /dev/null +++ b/workers/data_refinery_workers/processors/test_utils.py @@ -0,0 +1,180 @@ +import copy +from unittest.mock import patch, MagicMock +from django.test import TestCase +from data_refinery_models.models import ( + SurveyJob, + Batch, + BatchStatuses, + DownloaderJob, + DownloaderJobsToBatches, + ProcessorJob, + ProcessorJobsToBatches +) +from data_refinery_workers.processors import utils + + +def init_batch(): + survey_job = SurveyJob( + source_type="ARRAY_EXPRESS" + ) + survey_job.save() + + return Batch( + survey_job=survey_job, + source_type="ARRAY_EXPRESS", + size_in_bytes=0, + download_url="ftp://ftp.ebi.ac.uk/pub/databases/microarray/data/experiment/GEOD/E-GEOD-59071/E-GEOD-59071.raw.3.zip/GSM1426072_CD_colon_active_2.CEL", # noqa + raw_format="CEL", + processed_format="PCL", + pipeline_required="AFFY_TO_PCL", + platform_accession_code="A-AFFY-1", + experiment_accession_code="E-MTAB-3050", + experiment_title="It doesn't really matter.", + name="CE1234", + internal_location="A-AFFY-1/MICRO_ARRAY_TO_PCL/", + organism_id=9606, + organism_name="HOMO SAPIENS", + release_date="2017-05-05", + last_uploaded_date="2017-05-05", + status=BatchStatuses.DOWNLOADED.value + ) + + +class StartJobTestCase(TestCase): + def test_success(self): + batch = init_batch() + batch2 = copy.deepcopy(batch) + batch2.name = "CE2345" + batch.save() + batch2.save() + + processor_job = ProcessorJob() + processor_job.save() + processor_job_to_batch = ProcessorJobsToBatches(batch=batch, + processor_job=processor_job) + processor_job_to_batch.save() + processor_job_to_batch2 = ProcessorJobsToBatches(batch=batch2, + processor_job=processor_job) + processor_job_to_batch2.save() + + kwargs = utils.start_job({"job": processor_job}) + # start_job preserves the "job" key + self.assertEqual(kwargs["job"], processor_job) + + # start_job finds the batches and returns them + self.assertEqual(len(kwargs["batches"]), 2) + + def test_failure(self): + """Fails because there are no batches for the job.""" + processor_job = ProcessorJob() + processor_job.save() + + kwargs = utils.start_job({"job": processor_job}) + self.assertFalse(kwargs["success"]) + + +class EndJobTestCase(TestCase): + def test_success(self): + batch = init_batch() + batch2 = copy.deepcopy(batch) + batch2.name = "CE2345" + batch.save() + batch2.save() + + processor_job = ProcessorJob() + processor_job.save() + + utils.end_job({"job": processor_job, + "batches": [batch, batch2]}) + + processor_job.refresh_from_db() + self.assertTrue(processor_job.success) + self.assertIsNotNone(processor_job.end_time) + + batches = Batch.objects.all() + for batch in batches: + self.assertEqual(batch.status, BatchStatuses.PROCESSED.value) + + def test_failure(self): + batch = init_batch() + batch2 = copy.deepcopy(batch) + batch2.name = "CE2345" + batch.save() + batch2.save() + + processor_job = ProcessorJob() + processor_job.save() + + utils.end_job({"success": False, + "job": processor_job, + "batches": [batch, batch2]}) + + processor_job.refresh_from_db() + self.assertFalse(processor_job.success) + self.assertIsNotNone(processor_job.end_time) + + batches = Batch.objects.all() + for batch in batches: + self.assertEqual(batch.status, BatchStatuses.DOWNLOADED.value) + + +class RunPipelineTestCase(TestCase): + def test_no_job(self): + mock_processor = MagicMock() + utils.run_pipeline({"job_id": 100}, [mock_processor]) + mock_processor.assert_not_called() + + def test_processor_failure(self): + processor_job = ProcessorJob() + processor_job.save() + job_dict = {"job_id": processor_job.id, + "job": processor_job} + + mock_processor = MagicMock() + mock_processor.__name__ = "Fake processor." + return_dict = copy.copy(job_dict) + return_dict["success"] = False + mock_processor.return_value = return_dict + + utils.run_pipeline(job_dict, [mock_processor]) + mock_processor.assert_called_once() + processor_job.refresh_from_db() + self.assertFalse(processor_job.success) + self.assertIsNotNone(processor_job.end_time) + + def test_value_passing(self): + """The keys added to kwargs and returned by processors will be + passed through to other processors. + """ + batch = init_batch() + batch.save() + processor_job = ProcessorJob() + processor_job.save() + processor_jobs_to_batches = ProcessorJobsToBatches(batch=batch, + processor_job=processor_job) + processor_jobs_to_batches.save() + + mock_processor = MagicMock() + mock_dict = {"something_to_pass_along": True, + "job": processor_job, + "batches": [batch]} + mock_processor.return_value = mock_dict + + def processor_function(kwargs): + self.assertTrue(kwargs["something_to_pass_along"]) + return kwargs + + test_processor = MagicMock(side_effect=processor_function) + + utils.run_pipeline({"job_id": processor_job.id}, + [utils.start_job, + mock_processor, + test_processor, + utils.end_job]) + + processor_job.refresh_from_db() + self.assertTrue(processor_job.success) + self.assertIsNotNone(processor_job.end_time) + + batch.refresh_from_db() + self.assertEqual(batch.status, BatchStatuses.PROCESSED.value) diff --git a/workers/data_refinery_workers/processors/utils.py b/workers/data_refinery_workers/processors/utils.py index be402223f..b803056f8 100644 --- a/workers/data_refinery_workers/processors/utils.py +++ b/workers/data_refinery_workers/processors/utils.py @@ -1,9 +1,6 @@ -import os -import urllib -import shutil from django.utils import timezone from typing import List, Dict, Callable -from data_refinery_models.models import Batch, BatchStatuses, ProcessorJob +from data_refinery_models.models import BatchStatuses, ProcessorJob, ProcessorJobsToBatches # Import and set logger import logging @@ -15,27 +12,34 @@ def start_job(kwargs: Dict): - """Record in the database that this job is being started and - retrieves the job's batch from the database and - adds it to the dictionary passed in with the key 'batch'.""" + """A processor function to start jobs. + + Record in the database that this job is being started and + retrieves the job's batches from the database and adds them to the + dictionary passed in with the key 'batches'. + """ job = kwargs["job"] job.worker_id = "For now there's only one. For now..." job.start_time = timezone.now() job.save() - try: - batch = Batch.objects.get(id=job.batch_id) - except Batch.DoesNotExist: - logger.error("Cannot find batch record with ID %d.", job.batch_id) + batch_relations = ProcessorJobsToBatches.objects.filter(processor_job_id=job.id) + batches = [br.batch for br in batch_relations] + + if len(batches) == 0: + logger.error("No batches found for job #%d.", job.id) return {"success": False} - kwargs["batch"] = batch + kwargs["batches"] = batches return kwargs def end_job(kwargs: Dict): - """Record in the database that this job has completed and that - the batch has been processed if successful.""" + """A processor function to end jobs. + + Record in the database that this job has completed and that + the batch has been processed if successful. + """ job = kwargs["job"] if "success" in kwargs: @@ -48,51 +52,34 @@ def end_job(kwargs: Dict): job.save() if job.success: - batch = kwargs["batch"] - batch.status = BatchStatuses.PROCESSED.value - batch.save() + batches = kwargs["batches"] + for batch in batches: + batch.status = BatchStatuses.PROCESSED.value + batch.save() # Every processor returns a dict, however end_job is always called # last so it doesn't need to contain anything. return {} -def cleanup_temp_data(kwargs: Dict): - """Removes data from raw/ and temp/ directories related to the batch.""" - batch = kwargs["batch"] - - path = urllib.parse.urlparse(batch.download_url).path - raw_file_name = os.path.basename(path) - raw_file_location = os.path.join(ROOT_URI, - "raw", - batch.internal_location, - raw_file_name) - temp_directory = os.path.join(ROOT_URI, "temp", batch.internal_location) - os.remove(raw_file_location) - shutil.rmtree(temp_directory) - - return kwargs - - def run_pipeline(start_value: Dict, pipeline: List[Callable]): """Runs a pipeline of processor functions. - start_value must contain a key 'job_id' which is a valid id - for a ProcessorJob record. + start_value must contain a key 'job_id' which is a valid id for a + ProcessorJob record. Each processor fuction must accept a dictionary and return a dictionary. - Any processor function which returns a dictionary - containing a key of 'success' with a value of False will cause - the pipeline to terminate with a call to utils.end_job. + Any processor function which returns a dictionary containing a key + of 'success' with a value of False will cause the pipeline to + terminate with a call to utils.end_job. - The key 'job' is reserved for the ProcessorJob currently being run. - The key 'batch' is reserved for the Batch that is currently being - processed. - It is required that the dictionary returned by each processor - function preserve the mappings for 'job' and 'batch' that were - passed into it. + The key 'job' is reserved for the ProcessorJob currently being + run. The key 'batches' is reserved for the Batches that are + currently being processed. It is required that the dictionary + returned by each processor function preserve the mappings for + 'job' and 'batches' that were passed into it. """ job_id = start_value["job_id"] @@ -102,6 +89,10 @@ def run_pipeline(start_value: Dict, pipeline: List[Callable]): logger.error("Cannot find processor job record with ID %d.", job_id) return + if len(pipeline) == 0: + logger.error("Empty pipeline specified for job #%d.", + job_id) + last_result = start_value last_result["job"] = job for processor in pipeline: diff --git a/workers/run_tests.sh b/workers/run_tests.sh new file mode 100755 index 000000000..6691e1beb --- /dev/null +++ b/workers/run_tests.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# Script for executing Django PyUnit tests within a Docker container. + +# This script should always run as if it were being called from +# the directory it lives in. +script_directory=`dirname "${BASH_SOURCE[0]}" | xargs realpath` +cd $script_directory + +# However in order to give Docker access to all the code we have to +# move up a level +cd .. + +docker build -t dr_worker -f workers/Dockerfile.tests . + +HOST_IP=$(ip route get 8.8.8.8 | awk '{print $NF; exit}') + +docker run \ + --add-host=database:$HOST_IP \ + --env-file workers/environments/test \ + -i dr_worker test --no-input "$@" diff --git a/workers/tester.sh b/workers/tester.sh index 69af5cfb4..f2f78abef 100755 --- a/workers/tester.sh +++ b/workers/tester.sh @@ -16,7 +16,7 @@ docker build -t test_master -f workers/Dockerfile . HOST_IP=$(ip route get 8.8.8.8 | awk '{print $NF; exit}') docker run \ - --link some-rabbit:rabbit \ + --link message-queue:rabbit \ --add-host=database:$HOST_IP \ --env-file workers/environments/dev \ --entrypoint ./manage.py \ diff --git a/workers/worker.sh b/workers/worker.sh index b628ff007..ee5e49937 100755 --- a/workers/worker.sh +++ b/workers/worker.sh @@ -23,7 +23,7 @@ docker build -t dr_worker -f workers/Dockerfile . HOST_IP=$(ip route get 8.8.8.8 | awk '{print $NF; exit}') docker run \ - --link some-rabbit:rabbit \ + --link message-queue:rabbit \ --name worker1 \ --add-host=database:$HOST_IP \ --env-file workers/environments/dev \