From 30acfdd3f12c5709189b2c302ed3861497f16ba9 Mon Sep 17 00:00:00 2001 From: Jonas Winkler <jonas.winkler@jpwinkler.de> Date: Thu, 26 Nov 2020 14:18:10 +0100 Subject: [PATCH 1/3] tests for the classifier and fixes for edge cases with minimal data. --- src/documents/classifier.py | 45 +++++-- src/documents/tests/test_classifier.py | 155 ++++++++++++++++++++++++- 2 files changed, 189 insertions(+), 11 deletions(-) diff --git a/src/documents/classifier.py b/src/documents/classifier.py index 6e0d6f946..b0d7d87bb 100755 --- a/src/documents/classifier.py +++ b/src/documents/classifier.py @@ -6,7 +6,8 @@ import re from sklearn.feature_extraction.text import CountVectorizer from sklearn.neural_network import MLPClassifier -from sklearn.preprocessing import MultiLabelBinarizer +from sklearn.preprocessing import MultiLabelBinarizer, LabelBinarizer +from sklearn.utils.multiclass import type_of_target from documents.models import Document, MatchingModel from paperless import settings @@ -27,7 +28,7 @@ def preprocess_content(content): class DocumentClassifier(object): - FORMAT_VERSION = 5 + FORMAT_VERSION = 6 def __init__(self): # mtime of the model file on disk. used to prevent reloading when @@ -54,6 +55,8 @@ class DocumentClassifier(object): "Cannor load classifier, incompatible versions.") else: if self.classifier_version > 0: + # Don't be confused by this check. It's simply here + # so that we wont log anything on initial reload. logger.info("Classifier updated on disk, " "reloading classifier models") self.data_hash = pickle.load(f) @@ -122,9 +125,14 @@ class DocumentClassifier(object): labels_tags_unique = set([tag for tags in labels_tags for tag in tags]) num_tags = len(labels_tags_unique) + # substract 1 since -1 (null) is also part of the classes. - num_correspondents = len(set(labels_correspondent)) - 1 - num_document_types = len(set(labels_document_type)) - 1 + + # union with {-1} accounts for cases where all documents have + # correspondents and types assigned, so -1 isnt part of labels_x, which + # it usually is. + num_correspondents = len(set(labels_correspondent) | {-1}) - 1 + num_document_types = len(set(labels_document_type) | {-1}) - 1 logging.getLogger(__name__).debug( "{} documents, {} tag(s), {} correspondent(s), " @@ -145,12 +153,23 @@ class DocumentClassifier(object): ) data_vectorized = self.data_vectorizer.fit_transform(data) - self.tags_binarizer = MultiLabelBinarizer() - labels_tags_vectorized = self.tags_binarizer.fit_transform(labels_tags) - # Step 3: train the classifiers if num_tags > 0: logging.getLogger(__name__).debug("Training tags classifier...") + + if num_tags == 1: + # Special case where only one tag has auto: + # Fallback to binary classification. + labels_tags = [label[0] if len(label) == 1 else -1 + for label in labels_tags] + self.tags_binarizer = LabelBinarizer() + labels_tags_vectorized = self.tags_binarizer.fit_transform( + labels_tags).ravel() + else: + self.tags_binarizer = MultiLabelBinarizer() + labels_tags_vectorized = self.tags_binarizer.fit_transform( + labels_tags) + self.tags_classifier = MLPClassifier(tol=0.01) self.tags_classifier.fit(data_vectorized, labels_tags_vectorized) else: @@ -222,6 +241,16 @@ class DocumentClassifier(object): X = self.data_vectorizer.transform([preprocess_content(content)]) y = self.tags_classifier.predict(X) tags_ids = self.tags_binarizer.inverse_transform(y)[0] - return tags_ids + if type_of_target(y).startswith('multilabel'): + # the usual case when there are multiple tags. + return list(tags_ids) + elif type_of_target(y) == 'binary' and tags_ids != -1: + # This is for when we have binary classification with only one + # tag and the result is to assign this tag. + return [tags_ids] + else: + # Usually binary as well with -1 as the result, but we're + # going to catch everything else here as well. + return [] else: return [] diff --git a/src/documents/tests/test_classifier.py b/src/documents/tests/test_classifier.py index 4ae672ac2..0f421bb32 100644 --- a/src/documents/tests/test_classifier.py +++ b/src/documents/tests/test_classifier.py @@ -1,8 +1,10 @@ import tempfile +from time import sleep +from unittest import mock from django.test import TestCase, override_settings -from documents.classifier import DocumentClassifier +from documents.classifier import DocumentClassifier, IncompatibleClassifierVersionError from documents.models import Correspondent, Document, Tag, DocumentType @@ -15,10 +17,12 @@ class TestClassifier(TestCase): def generate_test_data(self): self.c1 = Correspondent.objects.create(name="c1", matching_algorithm=Correspondent.MATCH_AUTO) self.c2 = Correspondent.objects.create(name="c2") + self.c3 = Correspondent.objects.create(name="c3", matching_algorithm=Correspondent.MATCH_AUTO) self.t1 = Tag.objects.create(name="t1", matching_algorithm=Tag.MATCH_AUTO, pk=12) self.t2 = Tag.objects.create(name="t2", matching_algorithm=Tag.MATCH_ANY, pk=34, is_inbox_tag=True) self.t3 = Tag.objects.create(name="t3", matching_algorithm=Tag.MATCH_AUTO, pk=45) self.dt = DocumentType.objects.create(name="dt", matching_algorithm=DocumentType.MATCH_AUTO) + self.dt2 = DocumentType.objects.create(name="dt2", matching_algorithm=DocumentType.MATCH_AUTO) self.doc1 = Document.objects.create(title="doc1", content="this is a document from c1", correspondent=self.c1, checksum="A", document_type=self.dt) self.doc2 = Document.objects.create(title="doc1", content="this is another document, but from c2", correspondent=self.c2, checksum="B") @@ -59,8 +63,8 @@ class TestClassifier(TestCase): self.classifier.train() self.assertEqual(self.classifier.predict_correspondent(self.doc1.content), self.c1.pk) self.assertEqual(self.classifier.predict_correspondent(self.doc2.content), None) - self.assertTupleEqual(self.classifier.predict_tags(self.doc1.content), (self.t1.pk,)) - self.assertTupleEqual(self.classifier.predict_tags(self.doc2.content), (self.t1.pk, self.t3.pk)) + self.assertListEqual(self.classifier.predict_tags(self.doc1.content), [self.t1.pk]) + self.assertListEqual(self.classifier.predict_tags(self.doc2.content), [self.t1.pk, self.t3.pk]) self.assertEqual(self.classifier.predict_document_type(self.doc1.content), self.dt.pk) self.assertEqual(self.classifier.predict_document_type(self.doc2.content), None) @@ -71,6 +75,42 @@ class TestClassifier(TestCase): self.assertTrue(self.classifier.train()) self.assertFalse(self.classifier.train()) + def testVersionIncreased(self): + + self.generate_test_data() + self.assertTrue(self.classifier.train()) + self.assertFalse(self.classifier.train()) + + classifier2 = DocumentClassifier() + + current_ver = DocumentClassifier.FORMAT_VERSION + with mock.patch("documents.classifier.DocumentClassifier.FORMAT_VERSION", current_ver+1): + # assure that we won't load old classifiers. + self.assertRaises(IncompatibleClassifierVersionError, self.classifier.reload) + + self.classifier.save_classifier() + + # assure that we can load the classifier after saving it. + classifier2.reload() + + def testReload(self): + + self.generate_test_data() + self.assertTrue(self.classifier.train()) + self.classifier.save_classifier() + + classifier2 = DocumentClassifier() + classifier2.reload() + v1 = classifier2.classifier_version + + # change the classifier after some time. + sleep(1) + self.classifier.save_classifier() + + classifier2.reload() + v2 = classifier2.classifier_version + self.assertNotEqual(v1, v2) + @override_settings(DATA_DIR=tempfile.mkdtemp()) def testSaveClassifier(self): @@ -83,3 +123,112 @@ class TestClassifier(TestCase): new_classifier = DocumentClassifier() new_classifier.reload() self.assertFalse(new_classifier.train()) + + def test_one_correspondent_predict(self): + c1 = Correspondent.objects.create(name="c1", matching_algorithm=Correspondent.MATCH_AUTO) + doc1 = Document.objects.create(title="doc1", content="this is a document from c1", correspondent=c1, checksum="A") + + self.classifier.train() + self.assertEqual(self.classifier.predict_correspondent(doc1.content), c1.pk) + + def test_one_correspondent_predict_manydocs(self): + c1 = Correspondent.objects.create(name="c1", matching_algorithm=Correspondent.MATCH_AUTO) + doc1 = Document.objects.create(title="doc1", content="this is a document from c1", correspondent=c1, checksum="A") + doc2 = Document.objects.create(title="doc2", content="this is a document from noone", checksum="B") + + self.classifier.train() + self.assertEqual(self.classifier.predict_correspondent(doc1.content), c1.pk) + self.assertIsNone(self.classifier.predict_correspondent(doc2.content)) + + def test_one_type_predict(self): + dt = DocumentType.objects.create(name="dt", matching_algorithm=DocumentType.MATCH_AUTO) + + doc1 = Document.objects.create(title="doc1", content="this is a document from c1", + checksum="A", document_type=dt) + + self.classifier.train() + self.assertEqual(self.classifier.predict_document_type(doc1.content), dt.pk) + + def test_one_type_predict_manydocs(self): + dt = DocumentType.objects.create(name="dt", matching_algorithm=DocumentType.MATCH_AUTO) + + doc1 = Document.objects.create(title="doc1", content="this is a document from c1", + checksum="A", document_type=dt) + + doc2 = Document.objects.create(title="doc1", content="this is a document from c2", + checksum="B") + + self.classifier.train() + self.assertEqual(self.classifier.predict_document_type(doc1.content), dt.pk) + self.assertIsNone(self.classifier.predict_document_type(doc2.content)) + + def test_one_tag_predict(self): + t1 = Tag.objects.create(name="t1", matching_algorithm=Tag.MATCH_AUTO, pk=12) + + doc1 = Document.objects.create(title="doc1", content="this is a document from c1", checksum="A") + + doc1.tags.add(t1) + self.classifier.train() + self.assertListEqual(self.classifier.predict_tags(doc1.content), [t1.pk]) + + def test_one_tag_predict_unassigned(self): + t1 = Tag.objects.create(name="t1", matching_algorithm=Tag.MATCH_AUTO, pk=12) + + doc1 = Document.objects.create(title="doc1", content="this is a document from c1", checksum="A") + + self.classifier.train() + self.assertListEqual(self.classifier.predict_tags(doc1.content), []) + + def test_two_tags_predict_singledoc(self): + t1 = Tag.objects.create(name="t1", matching_algorithm=Tag.MATCH_AUTO, pk=12) + t2 = Tag.objects.create(name="t2", matching_algorithm=Tag.MATCH_AUTO, pk=121) + + doc4 = Document.objects.create(title="doc1", content="this is a document from c4", checksum="D") + + doc4.tags.add(t1) + doc4.tags.add(t2) + self.classifier.train() + self.assertListEqual(self.classifier.predict_tags(doc4.content), [t1.pk, t2.pk]) + + def test_two_tags_predict(self): + t1 = Tag.objects.create(name="t1", matching_algorithm=Tag.MATCH_AUTO, pk=12) + t2 = Tag.objects.create(name="t2", matching_algorithm=Tag.MATCH_AUTO, pk=121) + + doc1 = Document.objects.create(title="doc1", content="this is a document from c1", checksum="A") + doc2 = Document.objects.create(title="doc1", content="this is a document from c2", checksum="B") + doc3 = Document.objects.create(title="doc1", content="this is a document from c3", checksum="C") + doc4 = Document.objects.create(title="doc1", content="this is a document from c4", checksum="D") + + doc1.tags.add(t1) + doc2.tags.add(t2) + + doc4.tags.add(t1) + doc4.tags.add(t2) + self.classifier.train() + self.assertListEqual(self.classifier.predict_tags(doc1.content), [t1.pk]) + self.assertListEqual(self.classifier.predict_tags(doc2.content), [t2.pk]) + self.assertListEqual(self.classifier.predict_tags(doc3.content), []) + self.assertListEqual(self.classifier.predict_tags(doc4.content), [t1.pk, t2.pk]) + + def test_one_tag_predict_multi(self): + t1 = Tag.objects.create(name="t1", matching_algorithm=Tag.MATCH_AUTO, pk=12) + + doc1 = Document.objects.create(title="doc1", content="this is a document from c1", checksum="A") + doc2 = Document.objects.create(title="doc2", content="this is a document from c2", checksum="B") + + doc1.tags.add(t1) + doc2.tags.add(t1) + self.classifier.train() + self.assertListEqual(self.classifier.predict_tags(doc1.content), [t1.pk]) + self.assertListEqual(self.classifier.predict_tags(doc2.content), [t1.pk]) + + def test_one_tag_predict_multi_2(self): + t1 = Tag.objects.create(name="t1", matching_algorithm=Tag.MATCH_AUTO, pk=12) + + doc1 = Document.objects.create(title="doc1", content="this is a document from c1", checksum="A") + doc2 = Document.objects.create(title="doc2", content="this is a document from c2", checksum="B") + + doc1.tags.add(t1) + self.classifier.train() + self.assertListEqual(self.classifier.predict_tags(doc1.content), [t1.pk]) + self.assertListEqual(self.classifier.predict_tags(doc2.content), []) From 43b473dc531d22e5de7ee62f0a98430630b64f31 Mon Sep 17 00:00:00 2001 From: Jonas Winkler <jonas.winkler@jpwinkler.de> Date: Thu, 26 Nov 2020 15:55:13 +0100 Subject: [PATCH 2/3] Test cases for the API --- src/documents/tests/samples/simple.pdf | Bin 0 -> 22926 bytes src/documents/tests/samples/simple.zip | Bin 0 -> 17396 bytes src/documents/tests/test_api.py | 39 +++++++++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 src/documents/tests/samples/simple.pdf create mode 100644 src/documents/tests/samples/simple.zip diff --git a/src/documents/tests/samples/simple.pdf b/src/documents/tests/samples/simple.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e450de48269ce43785b8344c63e233a1794abae6 GIT binary patch literal 22926 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 literal 0 HcmV?d00001 diff --git a/src/documents/tests/samples/simple.zip b/src/documents/tests/samples/simple.zip new file mode 100644 index 0000000000000000000000000000000000000000..e96270508e35b295d4d833c33e3582e84472ab25 GIT binary patch literal 17396 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 G3=9C@cO}gL literal 0 HcmV?d00001 diff --git a/src/documents/tests/test_api.py b/src/documents/tests/test_api.py index b0318d2b3..c7e31e280 100644 --- a/src/documents/tests/test_api.py +++ b/src/documents/tests/test_api.py @@ -5,6 +5,7 @@ from unittest import mock from django.contrib.auth.models import User from django.test import override_settings +from pathvalidate import ValidationError from rest_framework.test import APITestCase from documents.models import Document, Correspondent, DocumentType, Tag @@ -215,3 +216,41 @@ class DocumentApiTest(APITestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.data['documents_total'], 3) self.assertEqual(response.data['documents_inbox'], 1) + + @mock.patch("documents.forms.async_task") + def test_upload(self, m): + + with open(os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"), "rb") as f: + response = self.client.post("/api/documents/post_document/", {"document": f}) + + self.assertEqual(response.status_code, 200) + + m.assert_called_once() + + self.assertEqual(m.call_args.kwargs['override_filename'], "simple.pdf") + + @mock.patch("documents.forms.async_task") + def test_upload_invalid_form(self, m): + + with open(os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"), "rb") as f: + response = self.client.post("/api/documents/post_document/", {"documenst": f}) + self.assertEqual(response.status_code, 400) + m.assert_not_called() + + @mock.patch("documents.forms.async_task") + def test_upload_invalid_file(self, m): + + with open(os.path.join(os.path.dirname(__file__), "samples", "simple.zip"), "rb") as f: + response = self.client.post("/api/documents/post_document/", {"document": f}) + self.assertEqual(response.status_code, 400) + m.assert_not_called() + + @mock.patch("documents.forms.async_task") + @mock.patch("documents.forms.validate_filename") + def test_upload_invalid_filename(self, validate_filename, async_task): + validate_filename.side_effect = ValidationError() + with open(os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"), "rb") as f: + response = self.client.post("/api/documents/post_document/", {"document": f}) + self.assertEqual(response.status_code, 400) + + async_task.assert_not_called() From 75390693b900f51ffd53283f2c5a07b0e58f668a Mon Sep 17 00:00:00 2001 From: jonaswinkler <jonas.winkler@jpwinkler.de> Date: Thu, 26 Nov 2020 17:41:50 +0100 Subject: [PATCH 3/3] Apparently there was a very good reason to use inotify. fixes #46 complete with test cases for inotify and polling. --- Pipfile | 1 + Pipfile.lock | 22 +- .../management/commands/document_consumer.py | 124 ++++++++---- .../tests/test_management_consumer.py | 188 ++++++++++++++++++ 4 files changed, 293 insertions(+), 42 deletions(-) create mode 100644 src/documents/tests/test_management_consumer.py diff --git a/Pipfile b/Pipfile index ad60e0905..a6169a2ba 100644 --- a/Pipfile +++ b/Pipfile @@ -35,6 +35,7 @@ scikit-learn="~=0.23.2" whitenoise = "~=5.2.0" watchdog = "*" whoosh="~=2.7.4" +inotify-simple = "*" [dev-packages] coveralls = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 6ecca3c34..b10c414ed 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "ae2643b9cf0cf5741ae149fb6bc0c480de41329ce48e773eb4b5d760bc5e2244" + "sha256": "e9792119f687757dd388e73827ddd4216910327d5b65a8b950d4b202679c36eb" }, "pipfile-spec": 6, "requires": {}, @@ -129,6 +129,14 @@ "index": "pypi", "version": "==0.32.0" }, + "inotify-simple": { + "hashes": [ + "sha256:8440ffe49c4ae81a8df57c1ae1eb4b6bfa7acb830099bfb3e305b383005cc128", + "sha256:854f9ac752cc1fcff6ca34e9d3d875c9a94c9b7d6eb377f63be2d481a566c6ee" + ], + "index": "pypi", + "version": "==1.3.5" + }, "joblib": { "hashes": [ "sha256:698c311779f347cf6b7e6b8a39bb682277b8ee4aba8cf9507bc0cf4cd4737b72", @@ -663,11 +671,11 @@ }, "faker": { "hashes": [ - "sha256:3f5d379e4b5ce92a8afe3c2ce59d7c43886370dd3bf9495a936b91888debfc81", - "sha256:8c0e8a06acef4b9312902e2ce18becabe62badd3a6632180bd0680c6ee111473" + "sha256:5398268e1d751ffdb3ed36b8a790ed98659200599b368eec38a02eed15bce997", + "sha256:d4183b8f57316de3be27cd6c3b40e9f9343d27c95c96179f027316c58c2c239e" ], "markers": "python_version >= '3.5'", - "version": "==4.17.0" + "version": "==4.17.1" }, "filelock": { "hashes": [ @@ -999,11 +1007,11 @@ }, "virtualenv": { "hashes": [ - "sha256:b0011228208944ce71052987437d3843e05690b2f23d1c7da4263fde104c97a2", - "sha256:b8d6110f493af256a40d65e29846c69340a947669eec8ce784fcf3dd3af28380" + "sha256:07cff122e9d343140366055f31be4dcd61fd598c69d11cd33a9d9c8df4546dd7", + "sha256:e0aac7525e880a429764cefd3aaaff54afb5d9f25c82627563603f5d7de5a6e5" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.1.0" + "version": "==20.2.1" } } } diff --git a/src/documents/management/commands/document_consumer.py b/src/documents/management/commands/document_consumer.py index 05711ebd8..4bfd78e8f 100644 --- a/src/documents/management/commands/document_consumer.py +++ b/src/documents/management/commands/document_consumer.py @@ -1,11 +1,11 @@ import logging import os +from time import sleep from django.conf import settings from django.core.management.base import BaseCommand from django_q.tasks import async_task from watchdog.events import FileSystemEventHandler -from watchdog.observers import Observer from watchdog.observers.polling import PollingObserver try: @@ -13,25 +13,54 @@ try: except ImportError: INotify = flags = None +logger = logging.getLogger(__name__) + + +def _consume(file): + try: + if os.path.isfile(file): + async_task("documents.tasks.consume_file", + file, + task_name=os.path.basename(file)[:100]) + else: + logger.debug( + f"Not consuming file {file}: File has moved.") + + except Exception as e: + # Catch all so that the consumer won't crash. + # This is also what the test case is listening for to check for + # errors. + logger.error( + "Error while consuming document: {}".format(e)) + + +def _consume_wait_unmodified(file, num_tries=20, wait_time=1): + mtime = -1 + current_try = 0 + while current_try < num_tries: + try: + new_mtime = os.stat(file).st_mtime + except FileNotFoundError: + logger.debug(f"File {file} moved while waiting for it to remain " + f"unmodified.") + return + if new_mtime == mtime: + _consume(file) + return + mtime = new_mtime + sleep(wait_time) + current_try += 1 + + logger.error(f"Timeout while waiting on file {file} to remain unmodified.") + class Handler(FileSystemEventHandler): - def _consume(self, file): - if os.path.isfile(file): - try: - async_task("documents.tasks.consume_file", - file, - task_name=os.path.basename(file)[:100]) - except Exception as e: - # Catch all so that the consumer won't crash. - logging.getLogger(__name__).error( - "Error while consuming document: {}".format(e)) - def on_created(self, event): - self._consume(event.src_path) + _consume_wait_unmodified(event.src_path) def on_moved(self, event): - self._consume(event.src_path) + _consume_wait_unmodified(event.dest_path) class Command(BaseCommand): @@ -40,12 +69,15 @@ class Command(BaseCommand): consumption directory. """ + # This is here primarily for the tests and is irrelevant in production. + stop_flag = False + def __init__(self, *args, **kwargs): - self.verbosity = 0 self.logger = logging.getLogger(__name__) BaseCommand.__init__(self, *args, **kwargs) + self.observer = None def add_arguments(self, parser): parser.add_argument( @@ -54,38 +86,60 @@ class Command(BaseCommand): nargs="?", help="The consumption directory." ) + parser.add_argument( + "--oneshot", + action="store_true", + help="Run only once." + ) def handle(self, *args, **options): - - self.verbosity = options["verbosity"] directory = options["directory"] logging.getLogger(__name__).info( - "Starting document consumer at {}".format( - directory - ) - ) + f"Starting document consumer at {directory}") - # Consume all files as this is not done initially by the watchdog for entry in os.scandir(directory): if entry.is_file(): async_task("documents.tasks.consume_file", entry.path, task_name=os.path.basename(entry.path)[:100]) - # Start the watchdog. Woof! - if settings.CONSUMER_POLLING > 0: - logging.getLogger(__name__).info( - "Using polling instead of file system notifications.") - observer = PollingObserver(timeout=settings.CONSUMER_POLLING) + if options["oneshot"]: + return + + if settings.CONSUMER_POLLING == 0 and INotify: + self.handle_inotify(directory) else: - observer = Observer() - event_handler = Handler() - observer.schedule(event_handler, directory, recursive=True) - observer.start() + self.handle_polling(directory) + + logger.debug("Consumer exiting.") + + def handle_polling(self, directory): + logging.getLogger(__name__).info( + f"Polling directory for changes: {directory}") + self.observer = PollingObserver(timeout=settings.CONSUMER_POLLING) + self.observer.schedule(Handler(), directory, recursive=False) + self.observer.start() try: - while observer.is_alive(): - observer.join(1) + while self.observer.is_alive(): + self.observer.join(1) + if self.stop_flag: + self.observer.stop() except KeyboardInterrupt: - observer.stop() - observer.join() + self.observer.stop() + self.observer.join() + + def handle_inotify(self, directory): + logging.getLogger(__name__).info( + f"Using inotify to watch directory for changes: {directory}") + + inotify = INotify() + inotify.add_watch(directory, flags.CLOSE_WRITE | flags.MOVED_TO) + try: + while not self.stop_flag: + for event in inotify.read(timeout=1000, read_delay=1000): + file = os.path.join(directory, event.name) + if os.path.isfile(file): + _consume(file) + except KeyboardInterrupt: + pass diff --git a/src/documents/tests/test_management_consumer.py b/src/documents/tests/test_management_consumer.py new file mode 100644 index 000000000..bfb7520ee --- /dev/null +++ b/src/documents/tests/test_management_consumer.py @@ -0,0 +1,188 @@ +import filecmp +import os +import shutil +import tempfile +from threading import Thread +from time import sleep +from unittest import mock + +from django.conf import settings +from django.test import TestCase, override_settings + +from documents.consumer import ConsumerError +from documents.management.commands import document_consumer + + +class ConsumerThread(Thread): + + def __init__(self): + super().__init__() + self.cmd = document_consumer.Command() + + def run(self) -> None: + self.cmd.handle(directory=settings.CONSUMPTION_DIR, oneshot=False) + + def stop(self): + # Consumer checks this every second. + self.cmd.stop_flag = True + + +def chunked(size, source): + for i in range(0, len(source), size): + yield source[i:i+size] + + +class TestConsumer(TestCase): + + sample_file = os.path.join(os.path.dirname(__file__), "samples", "simple.pdf") + + def setUp(self) -> None: + patcher = mock.patch("documents.management.commands.document_consumer.async_task") + self.task_mock = patcher.start() + self.addCleanup(patcher.stop) + + self.consume_dir = tempfile.mkdtemp() + + override_settings(CONSUMPTION_DIR=self.consume_dir).enable() + + def t_start(self): + self.t = ConsumerThread() + self.t.start() + # give the consumer some time to do initial work + sleep(1) + + def tearDown(self) -> None: + if self.t: + self.t.stop() + + def wait_for_task_mock_call(self): + n = 0 + while n < 100: + if self.task_mock.call_count > 0: + # give task_mock some time to finish and raise errors + sleep(1) + return + n += 1 + sleep(0.1) + self.fail("async_task was never called") + + # A bogus async_task that will simply check the file for + # completeness and raise an exception otherwise. + def bogus_task(self, func, filename, **kwargs): + eq = filecmp.cmp(filename, self.sample_file, shallow=False) + if not eq: + print("Consumed an INVALID file.") + raise ConsumerError("Incomplete File READ FAILED") + else: + print("Consumed a perfectly valid file.") + + def slow_write_file(self, target, incomplete=False): + with open(self.sample_file, 'rb') as f: + pdf_bytes = f.read() + + if incomplete: + pdf_bytes = pdf_bytes[:len(pdf_bytes) - 100] + + with open(target, 'wb') as f: + # this will take 2 seconds, since the file is about 20k. + print("Start writing file.") + for b in chunked(1000, pdf_bytes): + f.write(b) + sleep(0.1) + print("file completed.") + + def test_consume_file(self): + self.t_start() + + f = os.path.join(self.consume_dir, "my_file.pdf") + shutil.copy(self.sample_file, f) + + self.wait_for_task_mock_call() + + self.task_mock.assert_called_once() + self.assertEqual(self.task_mock.call_args.args[1], f) + + @override_settings(CONSUMER_POLLING=1) + def test_consume_file_polling(self): + self.test_consume_file() + + def test_consume_existing_file(self): + f = os.path.join(self.consume_dir, "my_file.pdf") + shutil.copy(self.sample_file, f) + + self.t_start() + self.task_mock.assert_called_once() + self.assertEqual(self.task_mock.call_args.args[1], f) + + @override_settings(CONSUMER_POLLING=1) + def test_consume_existing_file_polling(self): + self.test_consume_existing_file() + + @mock.patch("documents.management.commands.document_consumer.logger.error") + def test_slow_write_pdf(self, error_logger): + + self.task_mock.side_effect = self.bogus_task + + self.t_start() + + fname = os.path.join(self.consume_dir, "my_file.pdf") + + self.slow_write_file(fname) + + self.wait_for_task_mock_call() + + error_logger.assert_not_called() + + self.task_mock.assert_called_once() + + self.assertEqual(self.task_mock.call_args.args[1], fname) + + @override_settings(CONSUMER_POLLING=1) + def test_slow_write_pdf_polling(self): + self.test_slow_write_pdf() + + @mock.patch("documents.management.commands.document_consumer.logger.error") + def test_slow_write_and_move(self, error_logger): + + self.task_mock.side_effect = self.bogus_task + + self.t_start() + + fname = os.path.join(self.consume_dir, "my_file.~df") + fname2 = os.path.join(self.consume_dir, "my_file.pdf") + + self.slow_write_file(fname) + shutil.move(fname, fname2) + + self.wait_for_task_mock_call() + + self.task_mock.assert_called_once() + self.assertEqual(self.task_mock.call_args.args[1], fname2) + + error_logger.assert_not_called() + + @override_settings(CONSUMER_POLLING=1) + def test_slow_write_and_move_polling(self): + self.test_slow_write_and_move() + + @mock.patch("documents.management.commands.document_consumer.logger.error") + def test_slow_write_incomplete(self, error_logger): + + self.task_mock.side_effect = self.bogus_task + + self.t_start() + + fname = os.path.join(self.consume_dir, "my_file.pdf") + self.slow_write_file(fname, incomplete=True) + + self.wait_for_task_mock_call() + + self.task_mock.assert_called_once() + self.assertEqual(self.task_mock.call_args.args[1], fname) + + # assert that we have an error logged with this invalid file. + error_logger.assert_called_once() + + @override_settings(CONSUMER_POLLING=1) + def test_slow_write_incomplete_polling(self): + self.test_slow_write_incomplete()