From b8cef6d6e38cd734a46bca3bd0db2e0352466abf Mon Sep 17 00:00:00 2001 From: Preetham Kamidi Date: Wed, 3 Jul 2019 22:57:44 +0530 Subject: [PATCH 01/10] Update: Version bump Signed-off-by: Preetham Kamidi --- verifytweet/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/verifytweet/__init__.py b/verifytweet/__init__.py index 7601049..420488e 100644 --- a/verifytweet/__init__.py +++ b/verifytweet/__init__.py @@ -16,4 +16,4 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -__version__ = "0.4.1" \ No newline at end of file +__version__ = "0.5.0" \ No newline at end of file From 60fb6f249de019dd67b81fe255c2870c63b0335a Mon Sep 17 00:00:00 2001 From: Preetham Kamidi Date: Thu, 4 Jul 2019 12:29:40 +0530 Subject: [PATCH 02/10] Update: Dependent package installation Signed-off-by: Preetham Kamidi --- Pipfile | 2 + Pipfile.lock | 125 +++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 98 insertions(+), 29 deletions(-) diff --git a/Pipfile b/Pipfile index ea149f9..fbf4e67 100644 --- a/Pipfile +++ b/Pipfile @@ -9,6 +9,8 @@ yapf = "*" sphinx = "*" pytest = "*" twine = "*" +bandit = "*" +hypothesis = "*" [packages] certifi = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 48b5dee..19d4877 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b2689adf5ee4eeef3aa7a0642af1af465111e5286ea5721d236e971d476984fc" + "sha256": "ae28f6c49a24e8caaccc5a386912b4019df2b76a9444fef9b26baad4199a7ed8" }, "pipfile-spec": 6, "requires": { @@ -498,35 +498,35 @@ }, "pillow": { "hashes": [ - "sha256:15c056bfa284c30a7f265a41ac4cbbc93bdbfc0dfe0613b9cb8a8581b51a9e55", - "sha256:1a4e06ba4f74494ea0c58c24de2bb752818e9d504474ec95b0aa94f6b0a7e479", - "sha256:1c3c707c76be43c9e99cb7e3d5f1bee1c8e5be8b8a2a5eeee665efbf8ddde91a", - "sha256:1fd0b290203e3b0882d9605d807b03c0f47e3440f97824586c173eca0aadd99d", - "sha256:24114e4a6e1870c5a24b1da8f60d0ba77a0b4027907860188ea82bd3508c80eb", - "sha256:258d886a49b6b058cd7abb0ab4b2b85ce78669a857398e83e8b8e28b317b5abb", - "sha256:33c79b6dd6bc7f65079ab9ca5bebffb5f5d1141c689c9c6a7855776d1b09b7e8", - "sha256:367385fc797b2c31564c427430c7a8630db1a00bd040555dfc1d5c52e39fcd72", - "sha256:3c1884ff078fb8bf5f63d7d86921838b82ed4a7d0c027add773c2f38b3168754", - "sha256:44e5240e8f4f8861d748f2a58b3f04daadab5e22bfec896bf5434745f788f33f", - "sha256:46aa988e15f3ea72dddd81afe3839437b755fffddb5e173886f11460be909dce", - "sha256:74d90d499c9c736d52dd6d9b7221af5665b9c04f1767e35f5dd8694324bd4601", - "sha256:809c0a2ce9032cbcd7b5313f71af4bdc5c8c771cb86eb7559afd954cab82ebb5", - "sha256:85d1ef2cdafd5507c4221d201aaf62fc9276f8b0f71bd3933363e62a33abc734", - "sha256:8c3889c7681af77ecfa4431cd42a2885d093ecb811e81fbe5e203abc07e0995b", - "sha256:9218d81b9fca98d2c47d35d688a0cea0c42fd473159dfd5612dcb0483c63e40b", - "sha256:9aa4f3827992288edd37c9df345783a69ef58bd20cc02e64b36e44bcd157bbf1", - "sha256:9d80f44137a70b6f84c750d11019a3419f409c944526a95219bea0ac31f4dd91", - "sha256:b7ebd36128a2fe93991293f997e44be9286503c7530ace6a55b938b20be288d8", - "sha256:c4c78e2c71c257c136cdd43869fd3d5e34fc2162dc22e4a5406b0ebe86958239", - "sha256:c6a842537f887be1fe115d8abb5daa9bc8cc124e455ff995830cc785624a97af", - "sha256:cf0a2e040fdf5a6d95f4c286c6ef1df6b36c218b528c8a9158ec2452a804b9b8", - "sha256:cfd28aad6fc61f7a5d4ee556a997dc6e5555d9381d1390c00ecaf984d57e4232", - "sha256:dca5660e25932771460d4688ccbb515677caaf8595f3f3240ec16c117deff89a", - "sha256:de7aedc85918c2f887886442e50f52c1b93545606317956d65f342bd81cb4fc3", - "sha256:e6c0bbf8e277b74196e3140c35f9a1ae3eafd818f7f2d3a15819c49135d6c062" + "sha256:0804f77cb1e9b6dbd37601cee11283bba39a8d44b9ddb053400c58e0c0d7d9de", + "sha256:0ab7c5b5d04691bcbd570658667dd1e21ca311c62dcfd315ad2255b1cd37f64f", + "sha256:0b3e6cf3ea1f8cecd625f1420b931c83ce74f00c29a0ff1ce4385f99900ac7c4", + "sha256:365c06a45712cd723ec16fa4ceb32ce46ad201eb7bbf6d3c16b063c72b61a3ed", + "sha256:38301fbc0af865baa4752ddae1bb3cbb24b3d8f221bf2850aad96b243306fa03", + "sha256:3aef1af1a91798536bbab35d70d35750bd2884f0832c88aeb2499aa2d1ed4992", + "sha256:3fe0ab49537d9330c9bba7f16a5f8b02da615b5c809cdf7124f356a0f182eccd", + "sha256:45a619d5c1915957449264c81c008934452e3fd3604e36809212300b2a4dab68", + "sha256:49f90f147883a0c3778fd29d3eb169d56416f25758d0f66775db9184debc8010", + "sha256:571b5a758baf1cb6a04233fb23d6cf1ca60b31f9f641b1700bfaab1194020555", + "sha256:5ac381e8b1259925287ccc5a87d9cf6322a2dc88ae28a97fe3e196385288413f", + "sha256:6153db744a743c0c8c91b8e3b9d40e0b13a5d31dbf8a12748c6d9bfd3ddc01ad", + "sha256:6fd63afd14a16f5d6b408f623cc2142917a1f92855f0df997e09a49f0341be8a", + "sha256:70acbcaba2a638923c2d337e0edea210505708d7859b87c2bd81e8f9902ae826", + "sha256:70b1594d56ed32d56ed21a7fbb2a5c6fd7446cdb7b21e749c9791eac3a64d9e4", + "sha256:76638865c83b1bb33bcac2a61ce4d13c17dba2204969dedb9ab60ef62bede686", + "sha256:7b2ec162c87fc496aa568258ac88631a2ce0acfe681a9af40842fc55deaedc99", + "sha256:7cee2cef07c8d76894ebefc54e4bb707dfc7f258ad155bd61d87f6cd487a70ff", + "sha256:7d16d4498f8b374fc625c4037742fbdd7f9ac383fd50b06f4df00c81ef60e829", + "sha256:b50bc1780681b127e28f0075dfb81d6135c3a293e0c1d0211133c75e2179b6c0", + "sha256:bd0582f831ad5bcad6ca001deba4568573a4675437db17c4031939156ff339fa", + "sha256:cfd40d8a4b59f7567620410f966bb1f32dc555b2b19f82a91b147fac296f645c", + "sha256:e3ae410089de680e8f84c68b755b42bc42c0ceb8c03dbea88a5099747091d38e", + "sha256:e9046e559c299b395b39ac7dbf16005308821c2f24a63cae2ab173bd6aa11616", + "sha256:ef6be704ae2bc8ad0ebc5cb850ee9139493b0fc4e81abcc240fb392a63ebc808", + "sha256:f8dc19d92896558f9c4317ee365729ead9d7bbcf2052a9a19a3ef17abbb8ac5b" ], "index": "pypi", - "version": "==6.0.0" + "version": "==6.1.0" }, "pycares": { "hashes": [ @@ -725,7 +725,7 @@ "twint": { "editable": true, "git": "https://github.com/twintproject/twint.git", - "ref": "c5c6f1d60554cd0ee64ba223850b070553a17e74" + "ref": "ad27650fbc0bf8c3f2c78449088a5ede7239f53a" }, "typing": { "hashes": [ @@ -822,6 +822,14 @@ ], "version": "==2.7.0" }, + "bandit": { + "hashes": [ + "sha256:336620e220cf2d3115877685e264477ff9d9abaeb0afe3dc7264f55fa17a3952", + "sha256:41e75315853507aa145d62a78a2a6c5e3240fe14ee7c601459d0df9418196065" + ], + "index": "pypi", + "version": "==1.6.2" + }, "bleach": { "hashes": [ "sha256:213336e49e102af26d9cde77dd2d0397afabc5a6bf2fed985dc35b5d1e285a16", @@ -853,6 +861,28 @@ ], "version": "==0.14" }, + "gitdb2": { + "hashes": [ + "sha256:83361131a1836661a155172932a13c08bda2db3674e4caa32368aa6eb02f38c2", + "sha256:e3a0141c5f2a3f635c7209d56c496ebe1ad35da82fe4d3ec4aaa36278d70648a" + ], + "version": "==2.0.5" + }, + "gitpython": { + "hashes": [ + "sha256:563221e5a44369c6b79172f455584c9ebbb122a13368cc82cb4b5addff788f82", + "sha256:8237dc5bfd6f1366abeee5624111b9d6879393d84745a507de0fda86043b65a8" + ], + "version": "==2.1.11" + }, + "hypothesis": { + "hashes": [ + "sha256:2374139e469966ec1fc358a348a35935fc68f0bfc92e4be49e2f37d242ba4a50", + "sha256:a41ad18deb65b19ea171245759cb616c1598fff1f6d8c56fc4eb7e53f3553697" + ], + "index": "pypi", + "version": "==4.25.1" + }, "idna": { "hashes": [ "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", @@ -931,6 +961,13 @@ ], "version": "==19.0" }, + "pbr": { + "hashes": [ + "sha256:9181e2a34d80f07a359ff1d0504fad3a47e00e1cf2c475b0aa7dcb030af54c40", + "sha256:94bdc84da376b3dd5061aa0c3b6faffe943ee2e56fa4ff9bd63e1643932f34fc" + ], + "version": "==5.3.1" + }, "pkginfo": { "hashes": [ "sha256:7424f2c8511c186cd5424bbf31045b77435b37a8d604990b79d4e70d741148bb", @@ -991,6 +1028,22 @@ "index": "pypi", "version": "==2019.1" }, + "pyyaml": { + "hashes": [ + "sha256:57acc1d8533cbe51f6662a55434f0dbecfa2b9eaf115bede8f6fd00115a0c0d3", + "sha256:588c94b3d16b76cfed8e0be54932e5729cc185caffaa5a451e7ad2f7ed8b4043", + "sha256:68c8dd247f29f9a0d09375c9c6b8fdc64b60810ebf07ba4cdd64ceee3a58c7b7", + "sha256:70d9818f1c9cd5c48bb87804f2efc8692f1023dac7f1a1a5c61d454043c1d265", + "sha256:86a93cccd50f8c125286e637328ff4eef108400dd7089b46a7be3445eecfa391", + "sha256:a0f329125a926876f647c9fa0ef32801587a12328b4a3c741270464e3e4fa778", + "sha256:a3c252ab0fa1bb0d5a3f6449a4826732f3eb6c0270925548cac342bc9b22c225", + "sha256:b4bb4d3f5e232425e25dda21c070ce05168a786ac9eda43768ab7f3ac2770955", + "sha256:cd0618c5ba5bda5f4039b9398bb7fb6a317bb8298218c3de25c47c4740e4b95e", + "sha256:ceacb9e5f8474dcf45b940578591c7f3d960e82f926c707788a570b51ba59190", + "sha256:fe6a88094b64132c4bb3b631412e90032e8cfe9745a58370462240b8cb7553cd" + ], + "version": "==5.1.1" + }, "readme-renderer": { "hashes": [ "sha256:bb16f55b259f27f75f640acf5e00cf897845a8b3e4731b5c1a436e4b8529202f", @@ -1021,6 +1074,13 @@ "index": "pypi", "version": "==1.12.0" }, + "smmap2": { + "hashes": [ + "sha256:0555a7bf4df71d1ef4218e4807bbf9b201f910174e6e08af2e138d4e517b4dde", + "sha256:29a9ffa0497e7f2be94ca0ed1ca1aa3cd4cf25a1f6b4f5f87f74b46ed91d609a" + ], + "version": "==2.0.5" + }, "snowballstemmer": { "hashes": [ "sha256:9f3b9ffe0809d174f7047e121431acf99c89a7040f0ca84f94ba53a498e6d0c9" @@ -1077,6 +1137,13 @@ ], "version": "==1.1.3" }, + "stevedore": { + "hashes": [ + "sha256:7be098ff53d87f23d798a7ce7ae5c31f094f3deb92ba18059b1aeb1ca9fec0a0", + "sha256:7d1ce610a87d26f53c087da61f06f9b7f7e552efad2a7f6d2322632b5f932ea2" + ], + "version": "==1.30.1" + }, "tqdm": { "hashes": [ "sha256:14a285392c32b6f8222ecfbcd217838f88e11630affe9006cd0e94c7eff3cb61", From 7e2f323d1048894ec7958c5b505cd6f43117cddf Mon Sep 17 00:00:00 2001 From: Preetham Kamidi Date: Thu, 4 Jul 2019 19:50:24 +0530 Subject: [PATCH 03/10] WIP: Unit tests for modules Signed-off-by: Preetham Kamidi --- .circleci/config.yml | 34 +++++++------ ext/CurrentDataFlow.svg | 2 - temp_profile | 2 - tests/__init__.py | 22 +++++++++ tests/test_date_checker.py | 82 ++++++++++++++++++++++++++++++++ verifytweet/__init__.py | 12 +++++ verifytweet/app.py | 30 ++++-------- verifytweet/config/__init__.py | 0 verifytweet/services/__init__.py | 0 verifytweet/util/__init__.py | 0 verifytweet/util/date_checker.py | 8 +++- 11 files changed, 151 insertions(+), 41 deletions(-) delete mode 100644 ext/CurrentDataFlow.svg delete mode 100644 temp_profile create mode 100644 tests/__init__.py create mode 100644 tests/test_date_checker.py delete mode 100644 verifytweet/config/__init__.py delete mode 100644 verifytweet/services/__init__.py delete mode 100644 verifytweet/util/__init__.py diff --git a/.circleci/config.yml b/.circleci/config.yml index 0ae446e..131146e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,28 +1,32 @@ version: 2 jobs: - build: - machine: - image: ubuntu-1604:201903-01 - docker_layer_caching: true + test: + docker: + - image: circleci/python:3.6.5 + environment: + PIPENV_VENV_IN_PROJECT: true steps: - checkout + - restore_cache: + key: v1-py-cache-{{ .Branch }}-{{ checksum "Pipfile.lock" }} - run: - name: Install Dependencies + name: Install requirements command: | - echo 'export TAG=$(grep -oE "\"(.*?)\"" verifytweet/__init__.py)' >> $BASH_ENV - echo 'export TAG=${TAG:1:5}' >> $BASH_ENV - echo 'export IMAGE_NAME=verifytweet' >> $BASH_ENV + pipenv install + - save_cache: + name: Save Python dependencies cache + key: v1-py-cache-{{ .Branch }}-{{ checksum "Pipfile.lock" }} + paths: + - ~/.venv - run: - name: Build and push Docker image - command: | - docker build -t preethamkamidi/$IMAGE_NAME:$TAG . - echo $DOCKER_PWD | docker login -u $DOCKER_USER --password-stdin - docker push preethamkamidi/$IMAGE_NAME:$TAG + name: Run tests + command: pipenv run pytest + workflows: version: 2 - build_and_push: + build_and_test: jobs: - - build: + - test: filters: tags: only: /^v.*/ diff --git a/ext/CurrentDataFlow.svg b/ext/CurrentDataFlow.svg deleted file mode 100644 index aef6d90..0000000 --- a/ext/CurrentDataFlow.svg +++ /dev/null @@ -1,2 +0,0 @@ - -
Gunicorn+Flask
Gunicorn+Flask
POST request
Form Data:
data: fileobj/string
type: image/link

[Not supported by viewer]
Tesseract + pytesseract
(Image processor service)
[Not supported by viewer]
Save to Disk
Save to Disk
Regex parsing
(NLP service)
[Not supported by viewer]
Extracted
Text from Image
[Not supported by viewer]
Search Service
Search Service
Parsed tweet text, username, datetime
Parsed tweet text, username, datetime
Scikit cosine similarity
(NLP service)
[Not supported by viewer]
Same day tweets
Same day tweets
Result
Result

Current Data Flow

<h1><span style="font-weight: normal"><font style="font-size: 55px">Current Data Flow</font></span></h1>
\ No newline at end of file diff --git a/temp_profile b/temp_profile deleted file mode 100644 index 6687b81..0000000 --- a/temp_profile +++ /dev/null @@ -1,2 +0,0 @@ -export TAG=$(grep -oE "\"(.*?)\"" verifytweet/__init__.py) -export TAG=${TAG:1:5} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..9ea8087 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,22 @@ +# Verify Tweet verifies tweets of a public user +# from tweet screenshots: real or generated from +# tweet generators. +# Copyright (C) 2019 Preetham Kamidi + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import os +import sys +sys.path.insert(0, + os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) diff --git a/tests/test_date_checker.py b/tests/test_date_checker.py new file mode 100644 index 0000000..9326e0b --- /dev/null +++ b/tests/test_date_checker.py @@ -0,0 +1,82 @@ +# Verify Tweet verifies tweets of a public user +# from tweet screenshots: real or generated from +# tweet generators. +# Copyright (C) 2019 Preetham Kamidi + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pytest +import datetime + +from verifytweet import date_checker + + +def test_valid_date_empty_input(): + """Test for empty input in valid_date + """ + with pytest.raises(TypeError): + date_checker.valid_date() + + +def test_valid_date_invalid_type_input(): + """Test for invalid type input in valid_date + """ + assert date_checker.valid_date(None) == False + assert date_checker.valid_date('2018/02/23') == False + assert date_checker.valid_date(2018) == False + + +def test_valid_date_invalid_input(): + """Test for invalid input in valid_date + """ + test_date_str = 'Jan 01 1970 7:40AM' + test_date_obj = datetime.datetime.strptime( + test_date_str, + '%b %d %Y %I:%M%p').replace(tzinfo=datetime.timezone.utc) + assert date_checker.valid_date(test_date_obj) == False + + +def test_valid_date_valid_input(): + """Test for valid date in valid_date + """ + test_date_obj = datetime.datetime.now(datetime.timezone.utc) + assert date_checker.valid_date(test_date_obj) == True + + +def test_format_for_date_empty_input(): + """Test for empty input in format_for_date + """ + with pytest.raises(TypeError): + date_checker.format_for_date() + + +def test_format_for_date_invalid_type_input(): + """Test for invalid type input in format_for_date + """ + with pytest.raises(TypeError): + date_checker.format_for_date(None) + date_checker.valid_date('2018/02/23') + date_checker.valid_date(2018) + + +def test_format_for_date_valid_input(): + """Test for valid input in format_for_date + """ + test_date_obj = datetime.datetime.now(datetime.timezone.utc) + test_date_str = date_checker.format_for_date(test_date_obj) + formatted_date_obj = datetime.datetime.strptime( + test_date_str, '%Y-%m-%d').replace(tzinfo=datetime.timezone.utc) + assert test_date_obj.year == formatted_date_obj.year + assert test_date_obj.month == formatted_date_obj.month + assert test_date_obj.day == formatted_date_obj.day diff --git a/verifytweet/__init__.py b/verifytweet/__init__.py index 420488e..6dfd0f0 100644 --- a/verifytweet/__init__.py +++ b/verifytweet/__init__.py @@ -16,4 +16,16 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from .config import settings +from .services import controller +from .services import image +from .services import search +from .services import text +from .util import date_checker +from .util import logging +from .util import object_mapper +from .util import result +from .util import uploader +from .util import validator + __version__ = "0.5.0" \ No newline at end of file diff --git a/verifytweet/app.py b/verifytweet/app.py index 69f8d61..6ad2553 100644 --- a/verifytweet/app.py +++ b/verifytweet/app.py @@ -21,14 +21,13 @@ from flask import Flask, jsonify, request from flask_cors import CORS -import verifytweet.services.controller as controller -import verifytweet.services.image as image_service -import verifytweet.util.uploader as image_uploader -import verifytweet.util.object_mapper as object_mapper - -from verifytweet.util.logging import logger -from verifytweet.config.settings import app_config -from verifytweet.util.result import ResultStatus +from .services import controller +from .services import image as image_service +from .util import uploader as image_uploader +from .util import object_mapper +from .util.logging import logger +from .config.settings import app_config +from .util.result import ResultStatus router = Flask(__name__, static_folder=app_config.FILE_DIRECTORY) router.config['MAX_CONTENT_LENGTH'] = app_config.MAX_CONTENT_LENGTH @@ -83,17 +82,8 @@ def verify_tweet(): }) result, controller_status = rest_controller.exec() if controller_status != ResultStatus.ALL_OKAY: - return jsonify({ - 'status': controller_status.value, - 'result': result - }) + return jsonify({'status': controller_status.value, 'result': result}) tweet_dict, mapper_status = object_mapper.map_keys(result) if mapper_status != ResultStatus.ALL_OKAY: - return jsonify({ - 'status': mapper_status.value, - 'result': tweet_dict - }) - return jsonify({ - 'status': mapper_status.value, - 'result': tweet_dict - }) + return jsonify({'status': mapper_status.value, 'result': tweet_dict}) + return jsonify({'status': mapper_status.value, 'result': tweet_dict}) diff --git a/verifytweet/config/__init__.py b/verifytweet/config/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/verifytweet/services/__init__.py b/verifytweet/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/verifytweet/util/__init__.py b/verifytweet/util/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/verifytweet/util/date_checker.py b/verifytweet/util/date_checker.py index bb7ea23..73ea735 100644 --- a/verifytweet/util/date_checker.py +++ b/verifytweet/util/date_checker.py @@ -31,14 +31,18 @@ def valid_date(processed_date): Returns: A Boolean indicating if tweet can be futher processed or not. """ - if not processed_date: + if not processed_date or not isinstance(processed_date, datetime): return False curr_date = datetime.now(timezone.utc) datetime_diff = curr_date - processed_date - if datetime_diff.days > app_config.TWEET_MAX_OLD: + if datetime_diff.days > 7: return False return True def format_for_date(tweet_datetime: datetime): + if not isinstance(tweet_datetime, datetime): + raise TypeError('Tweet date has to be type datetime') + if not tweet_datetime: + raise ValueError('Tweet date has to be a valid datetime object') return tweet_datetime.strftime('%Y-%m-%d') From b13f7579639a649c87b9ef23a931200bb853c5c0 Mon Sep 17 00:00:00 2001 From: Preetham Kamidi Date: Fri, 5 Jul 2019 10:01:39 +0530 Subject: [PATCH 04/10] WIP: Remove state from classes Signed-off-by: Preetham Kamidi --- Pipfile.lock | 24 ++++---- tests/test_date_checker.py | 4 +- tests/test_object_mapper.py | 86 +++++++++++++++++++++++++++ verifytweet/app.py | 4 +- verifytweet/cli.py | 4 +- verifytweet/services/controller.py | 94 ++++++++++-------------------- verifytweet/services/image.py | 14 ++--- verifytweet/services/search.py | 25 ++++---- verifytweet/services/text.py | 74 ++++++++++++----------- verifytweet/util/common.py | 64 ++++++++++++++++++++ verifytweet/util/object_mapper.py | 9 ++- 11 files changed, 264 insertions(+), 138 deletions(-) create mode 100644 tests/test_object_mapper.py create mode 100644 verifytweet/util/common.py diff --git a/Pipfile.lock b/Pipfile.lock index 19d4877..7a0ca14 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "ae28f6c49a24e8caaccc5a386912b4019df2b76a9444fef9b26baad4199a7ed8" + "sha256": "61ec6f5c7a3511a046ba747d4255bb99a2624b774cd129294cbddebcafcadc6b" }, "pipfile-spec": 6, "requires": { @@ -211,11 +211,11 @@ }, "flask": { "hashes": [ - "sha256:ad7c6d841e64296b962296c2c2dabc6543752985727af86a975072dea984b6f3", - "sha256:e7d32475d1de5facaa55e3958bc4ec66d3762076b074296aa50ef8fdc5b9df61" + "sha256:a31adc27de06034c657a8dc091cc5fcb0227f2474798409bff0e9674de31a026", + "sha256:b5ae63812021cb04174fcff05d560a98387a44d9cccd4652a2bfa131ba4e4c9b" ], "index": "pypi", - "version": "==1.0.3" + "version": "==1.1.0" }, "flask-cors": { "hashes": [ @@ -437,10 +437,10 @@ }, "nltk": { "hashes": [ - "sha256:12d7129aea0972840419499411d3aa815c6ad66336a51131e120d35a25d953b2" + "sha256:764c20a5f8532a681c261af3c7d1a54768a35df6f3603df75e615cbd34e47cb5" ], "index": "pypi", - "version": "==3.4.3" + "version": "==3.4.4" }, "numpy": { "hashes": [ @@ -877,11 +877,11 @@ }, "hypothesis": { "hashes": [ - "sha256:2374139e469966ec1fc358a348a35935fc68f0bfc92e4be49e2f37d242ba4a50", - "sha256:a41ad18deb65b19ea171245759cb616c1598fff1f6d8c56fc4eb7e53f3553697" + "sha256:22d2bfb030baea313ca3f31d41ba0f0038d5794752d3947e2188ed67185471b2", + "sha256:adbd7cdb12d8c3f41f95d63b9fb0b91b2e11f636079793a49135dff5d0ee1bd0" ], "index": "pypi", - "version": "==4.25.1" + "version": "==4.26.2" }, "idna": { "hashes": [ @@ -963,10 +963,10 @@ }, "pbr": { "hashes": [ - "sha256:9181e2a34d80f07a359ff1d0504fad3a47e00e1cf2c475b0aa7dcb030af54c40", - "sha256:94bdc84da376b3dd5061aa0c3b6faffe943ee2e56fa4ff9bd63e1643932f34fc" + "sha256:36ebd78196e8c9588c972f5571230a059ff83783fabbbbedecc07be263ccd7e6", + "sha256:5a03f59455ad54f01a94c15829b8b70065462b7bd8d5d7e983306b59127fc841" ], - "version": "==5.3.1" + "version": "==5.4.0" }, "pkginfo": { "hashes": [ diff --git a/tests/test_date_checker.py b/tests/test_date_checker.py index 9326e0b..a010c74 100644 --- a/tests/test_date_checker.py +++ b/tests/test_date_checker.py @@ -66,8 +66,8 @@ def test_format_for_date_invalid_type_input(): """ with pytest.raises(TypeError): date_checker.format_for_date(None) - date_checker.valid_date('2018/02/23') - date_checker.valid_date(2018) + date_checker.format_for_date('2018/02/23') + date_checker.format_for_date(2018) def test_format_for_date_valid_input(): diff --git a/tests/test_object_mapper.py b/tests/test_object_mapper.py new file mode 100644 index 0000000..a5f044c --- /dev/null +++ b/tests/test_object_mapper.py @@ -0,0 +1,86 @@ +# Verify Tweet verifies tweets of a public user +# from tweet screenshots: real or generated from +# tweet generators. +# Copyright (C) 2019 Preetham Kamidi + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pytest +import datetime + +from twint.tweet import tweet + +from verifytweet import object_mapper + +def test_map_keys_empty_input(): + """Test for empty input in map_keys + """ + with pytest.raises(TypeError): + object_mapper.map_keys() + +def test_map_keys_invalid_type_input(): + """Test for invalid type input in map_keys + """ + with pytest.raises(TypeError): + object_mapper.map_keys(None) + object_mapper.map_keys(dict({ + "id": "dummy", + "tweet": "dummy" + })) + object_mapper.map_keys(["id", "dummy"]) + +def test_map_keys_invalid_input(): + """Test for valid type, invalid input in map_keys + """ + test_tweet_obj = tweet() + with pytest.raises(ValueError): + object_mapper.map_keys(test_tweet_obj) + +def test_map_keys_valid_input(): + """Test for valid type, valid input in map_keys + """ + test_tweet_obj = tweet() + test_id = "1234" + test_username = "twitter" + test_tweet = "Hello World!" + test_result = dict({ + "id": test_id, + "username": test_username, + "tweet": test_tweet + }) + test_tweet_obj.id = test_id + test_tweet_obj.conversation_id = str() + test_tweet_obj.username = test_username + test_tweet_obj.datetime = datetime.datetime.now() + test_tweet_obj.datestamp = str() + test_tweet_obj.timestamp = str() + test_tweet_obj.user_id = str() + test_tweet_obj.name = str() + test_tweet_obj.place = None + test_tweet_obj.timezone = str() + test_tweet_obj.mentions = list() + test_tweet_obj.urls = list() + test_tweet_obj.photos = list() + test_tweet_obj.video = list() + test_tweet_obj.tweet = test_tweet + test_tweet_obj.hashtags = list() + test_tweet_obj.replies_count = str() + test_tweet_obj.retweets_count = str() + test_tweet_obj.likes_count = str() + test_tweet_obj.link = str() + test_tweet_obj.retweet = str() + result, module_status = object_mapper.map_keys(test_tweet_obj) + assert result["id"] == test_result["id"] + assert result["username"] == test_result["username"] + assert result["tweet"] == test_result["tweet"] diff --git a/verifytweet/app.py b/verifytweet/app.py index 6ad2553..9490793 100644 --- a/verifytweet/app.py +++ b/verifytweet/app.py @@ -73,14 +73,14 @@ def verify_tweet(): return "Missing form fields", 400 try: file_path = image_uploader.save_to_disk(request_image) - rest_controller = controller.NonAPIApproach(file_path) + rest_controller = controller.NonAPIApproach() + result, controller_status = rest_controller.exec(file_path) except Exception as e: logger.exception(e) return jsonify({ 'status': ResultStatus.MODULE_FAILURE.value, 'result': None }) - result, controller_status = rest_controller.exec() if controller_status != ResultStatus.ALL_OKAY: return jsonify({'status': controller_status.value, 'result': result}) tweet_dict, mapper_status = object_mapper.map_keys(result) diff --git a/verifytweet/cli.py b/verifytweet/cli.py index 2aecf86..b4e9538 100644 --- a/verifytweet/cli.py +++ b/verifytweet/cli.py @@ -53,10 +53,10 @@ def run_as_command(filepath): """ try: - verify_controller = controller.NonAPIApproach(filepath) + verify_controller = controller.NonAPIApproach() + tweet_obj, controller_status = verify_controller.exec(filepath) except Exception as e: logger.exception(e) - tweet_obj, controller_status = verify_controller.exec() if controller_status == ResultStatus.MODULE_FAILURE: print(f"Something went wrong, Please try again!") elif controller_status == ResultStatus.NO_RESULT: diff --git a/verifytweet/services/controller.py b/verifytweet/services/controller.py index 94aa861..7f1134d 100644 --- a/verifytweet/services/controller.py +++ b/verifytweet/services/controller.py @@ -21,6 +21,7 @@ import verifytweet.services.search as search_service import verifytweet.util.date_checker as date_checker import verifytweet.util.validator as validator +import verifytweet.util.common as common from verifytweet.util.logging import logger from verifytweet.config.settings import app_config @@ -29,19 +30,12 @@ class APIApproach(object): """Use Twitter API to verify tweet - - Attributes: - file_path: A string denoting a twitter username. """ - def __init__(self, file_path: str): - if not isinstance(file_path, str): - raise TypeError('File path must be type str') - if not file_path: - raise ValueError('File path must be a valid string') - self.file_path = file_path + def __init__(self): + pass - def exec(self): + def exec(self, file_path: str): """Executes controller flow Controller uses image service to extract text from @@ -50,32 +44,39 @@ def exec(self): to retrieve same day tweets, text service to find similar tweet and finally verifying the tweet. + Attributes: + file_path: A string denoting a twitter username. + Returns: valid_tweet: A tweet object status: Enum ResultStatus representing result status """ - entities, preprocess_status = preprocess(self.file_path) + if not isinstance(file_path, str): + raise TypeError('File path must be type str') + if not file_path: + raise ValueError('File path must be a valid string') + entities, preprocess_status = common.extract_and_parse(file_path) if preprocess_status != ResultStatus.ALL_OKAY: return (None, ResultStatus.MODULE_FAILURE) try: - search_controller = search_service.TwitterAPISearch( + search_controller = search_service.TwitterAPISearch() + same_day_tweets, search_status = search_controller.aggregate_tweets( entities['user_id'], entities['date']) except Exception as e: logger.exception(e) return (None, ResultStatus.MODULE_FAILURE) - same_day_tweets, search_status = search_controller.aggregate_tweets() if search_status != ResultStatus.ALL_OKAY: return (None, search_status) try: - text_processor = text_service.TextProcessor( + text_processor = text_service.TextProcessor() + similarity_matrix, processor_status = text_processor.get_similarity( entities['tweet'], same_day_tweets) except Exception as e: logger.exception(e) return (None, ResultStatus.MODULE_FAILURE) - similarity_matrix, processor_status = text_processor.get_similarity() if processor_status != ResultStatus.ALL_OKAY: return (None, processor_status) @@ -93,21 +94,17 @@ def exec(self): class NonAPIApproach(object): """Use a non-api approach to verify tweet - - Attributes: - file_path: A string denoting a twitter username. """ - def __init__(self, file_path: str): - if not isinstance(file_path, str): - raise TypeError('File path must be type str') - if not file_path: - raise ValueError('File path must be a valid string') - self.file_path = file_path + def __init__(self): + pass - def exec(self): + def exec(self, file_path): """Executes controller flow + Attributes: + file_path: A string denoting a twitter username. + Controller uses image service to extract text from image, passes text to text service to parse entities such as username, tweet as well as date, uses search service @@ -118,16 +115,21 @@ def exec(self): status: Enum ResultStatus representing result status """ - entities, preprocess_status = preprocess(self.file_path) + if not isinstance(file_path, str): + raise TypeError('File path must be type str') + if not file_path: + raise ValueError('File path must be a valid string') + entities, preprocess_status = common.extract_and_parse(file_path) if preprocess_status != ResultStatus.ALL_OKAY: return (None, ResultStatus.MODULE_FAILURE) try: - text_processor = text_service.DataParser(entities['tweet']) + text_processor = text_service.DataParser() + tweet_snippet, text_processor_status = text_processor.clean_text( + entities['tweet']) except Exception as e: logger.exception(e) return (None, ResultStatus.MODULE_FAILURE) - tweet_snippet, text_processor_status = text_processor.clean_text() if text_processor_status != ResultStatus.ALL_OKAY: return (None, text_processor_status) @@ -142,39 +144,3 @@ def exec(self): return (None, search_status) return (search_results[0], ResultStatus.ALL_OKAY) - - -def preprocess(file_path): - """Preprocesses text - - Extracts text from image using image service, - parses entities from text using text service. - - Args: - file_path: represents path of the image file. - - Returns: - entities: Entities parsed from text such as tweet, user_id and date. - status: Enum ResultStatus representing result status - - """ - try: - text_extractor = image_service.Extractor(file_path) - except Exception as e: - logger.exception(e) - return (None, ResultStatus.MODULE_FAILURE) - extracted_text, extractor_status = text_extractor.get_text() - if extractor_status != ResultStatus.ALL_OKAY: - return (None, extractor_status) - logger.debug('Processed text: ' + extracted_text) - - try: - entity_parser = text_service.DataParser(extracted_text) - except Exception as e: - logger.exception(e) - return (None, ResultStatus.MODULE_FAILURE) - entities, parser_status = entity_parser.get_entities() - if parser_status != ResultStatus.ALL_OKAY: - return (None, parser_status) - logger.debug('Entities: ' + str(entities)) - return (entities, parser_status) diff --git a/verifytweet/services/image.py b/verifytweet/services/image.py index b8ce23f..e450b61 100644 --- a/verifytweet/services/image.py +++ b/verifytweet/services/image.py @@ -35,18 +35,18 @@ class Extractor(object): file_path: A string indicating file path where the image is stored. """ - def __init__(self, file_path: str): + def __init__(self): + pass + + def get_text(self, file_path: str): + """Extracts text from image + """ if not isinstance(file_path, str): raise TypeError('File path must be type string') if not file_path: raise ValueError('File path cannot be empty') - self.file_path = file_path - - def get_text(self): - """Extracts text from image - """ logger.info('Processing Image...') - new_file_path = self.rescale(self.file_path) + new_file_path = self.rescale(file_path) logger.info('Extracting text from rescaled image...') try: img = PIL.Image.open(new_file_path) diff --git a/verifytweet/services/search.py b/verifytweet/services/search.py index 3f7623c..b541df7 100644 --- a/verifytweet/services/search.py +++ b/verifytweet/services/search.py @@ -40,18 +40,10 @@ class TwitterAPISearch(object): date: A datetime object representing the date in question. """ - def __init__(self, user_id: str, date: datetime.datetime): - if not isinstance(user_id, str) or not isinstance( - date, datetime.datetime): - raise TypeError( - 'User ID must be type string and date must be type datetime.datetime' - ) - if not user_id or not date: - raise ValueError('User ID or Date cannot be empty') - self.user_id = user_id - self.date = date + def __init__(self): + pass - def aggregate_tweets(self): + def aggregate_tweets(self, user_id: str, date: datetime.datetime): """Aggregates tweets from a single day. Retrieves tweets pertaining to the given username and date using Twitter Search API. @@ -70,9 +62,16 @@ def aggregate_tweets(self): } """ + if not isinstance(user_id, str) or not isinstance( + date, datetime.datetime): + raise TypeError( + 'User ID must be type string and date must be type datetime.datetime' + ) + if not user_id or not date: + raise ValueError('User ID or Date cannot be empty') logger.info('Searching for tweet using Twitter API...') querystring = dict({ - app_config.TWEET_USERNAME_KEY: self.user_id, + app_config.TWEET_USERNAME_KEY: user_id, app_config.TWEET_COUNT_KEY: app_config.TWEET_COUNT }) try: @@ -87,7 +86,7 @@ def aggregate_tweets(self): tweet_date = date_parser.parse(entry[app_config.TWEET_DATE_KEY]) if date_checker.format_for_date( tweet_date) == date_checker.format_for_date( - self.date) and date_checker.valid_date(tweet_date): + date) and date_checker.valid_date(tweet_date): logger.debug('Tweet found...: ' + str(entry[app_config.TWEET_TEXT_KEY])) same_day_tweets.append(entry[app_config.TWEET_TEXT_KEY]) diff --git a/verifytweet/services/text.py b/verifytweet/services/text.py index 4dea408..cfe166e 100644 --- a/verifytweet/services/text.py +++ b/verifytweet/services/text.py @@ -38,23 +38,19 @@ class DataParser(object): """Parses data from extracted text - - Attributes: - extracted_text: A string denoting extracted text from image. """ - def __init__(self, extracted_text: str): - if not isinstance(extracted_text, str): - raise TypeError('Extracted text must be type string') - if not extracted_text: - raise ValueError('Extracted text cannot be empty') - self.text = extracted_text + def __init__(self): + pass - def get_entities(self): + def get_entities(self, extracted_text: str): """Parses entities from extracted text. Parses username (denoted by user_id), tweet as well as date from extracted text. + Attributes: + extracted_text: A string denoting extracted text from image. + Returns: A tuple contaning a dictionary: a mapping of user_id, tweet and date as well as Enum ResultStatus which gives out result status. @@ -67,11 +63,15 @@ def get_entities(self): } """ + if not isinstance(extracted_text, str): + raise TypeError('Extracted text must be type string') + if not extracted_text: + raise ValueError('Extracted text cannot be empty') logger.info('Parsing data out of extracted text...') - username_match = re.search(r'@(\w{1,15})\b', self.text) + username_match = re.search(r'@(\w{1,15})\b', extracted_text) datetime_match = re.search( r'((1[0-2]|0?[1-9]):([0-5][0-9]) ?([AaPp][Mm]))\s-\s\d{1,2}\s\w+\s\d{4}', - self.text) + extracted_text) if not username_match or not datetime_match: return (dict({ 'user_id': None, @@ -84,23 +84,30 @@ def get_entities(self): tzinfo=datetime.timezone.utc) username_end_index = username_match.end() date_start_index = datetime_match.start() - tweet = self.text[username_end_index + 5:date_start_index].strip() + tweet = extracted_text[username_end_index + 5:date_start_index].strip() return (dict({ 'user_id': user_id, 'tweet': tweet, 'date': processed_datetime }), ResultStatus.ALL_OKAY) - def clean_text(self): + def clean_text(self, extracted_text: str): """Removes stop words and samples words out of tweet to create a snippet. + Attributes: + extracted_text: A string denoting extracted text from image. + Returns: A tuple contaning a tweet snippet as well as Enum ResultStatus which gives out result status. """ + if not isinstance(extracted_text, str): + raise TypeError('Extracted text must be type string') + if not extracted_text: + raise ValueError('Extracted text cannot be empty') try: - non_punc_tweet = self.text.translate( + non_punc_tweet = extracted_text.translate( str.maketrans('', '', string.punctuation)) word_tokens = nltk.tokenize.word_tokenize(non_punc_tweet) except Exception as e: @@ -117,32 +124,22 @@ def clean_text(self): class TextProcessor(object): """Processes extracted tweet and aggregated tweets - - Attributes: - extracted_tweet: A string denoting extracted tweet from image. - same_day_tweets: A list contaning tweets of target date """ - def __init__(self, extracted_tweet: str, same_day_tweets: list): - if not isinstance(extracted_tweet, str) or not isinstance( - same_day_tweets, list): - raise TypeError( - 'Extracted tweet must be type str and Same day tweets must be type list' - ) - if not extracted_tweet or not same_day_tweets: - raise ValueError( - 'Extracted tweet must be a valid string and same day tweets must be a valid list' - ) - self.extracted_tweet = extracted_tweet - self.same_day_tweets = same_day_tweets + def __init__(self): + pass - def get_similarity(self): + def get_similarity(self, extracted_tweet: str, same_day_tweets: list): """Calculates a similarity matrix. Calculates a similarity matrix of the corpus containing extracted tweet and tweets aggregated from Twitter Search API using consine similarity approach. + Attributes: + extracted_tweet: A string denoting extracted tweet from image. + same_day_tweets: A list contaning tweets of target date + Returns: A tuple contaning a similarity matrix, which is a numpy array as well as Enum ResultStatus which gives out result status. @@ -153,10 +150,19 @@ def get_similarity(self): """ + if not isinstance(extracted_tweet, str) or not isinstance( + same_day_tweets, list): + raise TypeError( + 'Extracted tweet must be type str and Same day tweets must be type list' + ) + if not extracted_tweet or not same_day_tweets: + raise ValueError( + 'Extracted tweet must be a valid string and same day tweets must be a valid list' + ) logger.info('Processing similarity of two tweets...') corpus = list() - corpus.append(self.extracted_tweet) - corpus.extend(self.same_day_tweets) + corpus.append(extracted_tweet) + corpus.extend(same_day_tweets) logger.info('Corpus: ' + str(corpus)) try: sparse_matrix = count_vectorizer.fit_transform(corpus) diff --git a/verifytweet/util/common.py b/verifytweet/util/common.py new file mode 100644 index 0000000..9ebd934 --- /dev/null +++ b/verifytweet/util/common.py @@ -0,0 +1,64 @@ +# Verify Tweet verifies tweets of a public user +# from tweet screenshots: real or generated from +# tweet generators. +# Copyright (C) 2019 Preetham Kamidi + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import verifytweet.services.image as image_service +import verifytweet.services.text as text_service + +from verifytweet.util.logging import logger +from verifytweet.util.result import ResultStatus + + +def extract_and_parse(file_path: str): + """Preprocess text from image + + Extracts text from image using image service, + parses entities from text using text service. + + Args: + file_path: represents path of the image file. + + Returns: + entities: Entities parsed from text such as tweet, user_id and date. + status: Enum ResultStatus representing result status + + """ + if not isinstance(file_path, str): + raise TypeError('File path must be type string') + if not file_path: + raise ValueError('File path must be a valid path') + try: + text_extractor = image_service.Extractor() + extracted_text, extractor_status = text_extractor.get_text(file_path) + except Exception as e: + logger.exception(e) + return (None, ResultStatus.MODULE_FAILURE) + if extractor_status != ResultStatus.ALL_OKAY: + return (None, extractor_status) + logger.debug('Processed text: ' + extracted_text) + + try: + entity_parser = text_service.DataParser() + entities, parser_status = entity_parser.get_entities(extracted_text) + except Exception as e: + logger.exception(e) + return (None, ResultStatus.MODULE_FAILURE) + if parser_status != ResultStatus.ALL_OKAY: + return (None, parser_status) + logger.debug('Entities: ' + str(entities)) + return (entities, parser_status) diff --git a/verifytweet/util/object_mapper.py b/verifytweet/util/object_mapper.py index 6c1a756..265aa7f 100644 --- a/verifytweet/util/object_mapper.py +++ b/verifytweet/util/object_mapper.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from twint.tweet import tweet from verifytweet.config.settings import app_config from verifytweet.util.logging import logger from verifytweet.util.result import ResultStatus @@ -29,8 +30,12 @@ def map_keys(tweet_obj): Returns: A dictionary contaning a mapping of members of tweet object """ - if not tweet_obj: - return (None, ResultStatus.MODULE_FAILURE) + if not isinstance(tweet_obj, tweet): + raise TypeError('Tweet object must be of type twint.tweet') + try: + id = tweet_obj.id + except AttributeError: + raise ValueError('Tweet object must be valid') return (dict({ "id": tweet_obj.id, "conversation_id": tweet_obj.conversation_id, From 1df4fca286cb06ff0d047143bac4e9b99fb02c28 Mon Sep 17 00:00:00 2001 From: Preetham Kamidi Date: Sat, 6 Jul 2019 02:32:12 +0530 Subject: [PATCH 05/10] WIP: Unit tests for util and text service modules Signed-off-by: Preetham Kamidi --- .circleci/config.yml | 6 ++- tests/conftest.py | 25 ++++++++++ tests/static/real-tweet.png | Bin 0 -> 63875 bytes tests/test_common.py | 55 ++++++++++++++++++++++ tests/test_image_service.py | 0 tests/test_search_service.py | 0 tests/test_text_service.py | 81 +++++++++++++++++++++++++++++++++ tests/test_uploader.py | 57 +++++++++++++++++++++++ tests/test_validator.py | 68 +++++++++++++++++++++++++++ verifytweet/__init__.py | 1 + verifytweet/config/settings.py | 2 +- verifytweet/services/image.py | 11 +++-- verifytweet/services/text.py | 10 ++-- verifytweet/util/logging.py | 4 +- verifytweet/util/uploader.py | 2 +- verifytweet/util/validator.py | 10 ++-- 16 files changed, 316 insertions(+), 16 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/static/real-tweet.png create mode 100644 tests/test_common.py create mode 100644 tests/test_image_service.py create mode 100644 tests/test_search_service.py create mode 100644 tests/test_text_service.py create mode 100644 tests/test_uploader.py create mode 100644 tests/test_validator.py diff --git a/.circleci/config.yml b/.circleci/config.yml index 131146e..ef9200a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,7 +4,7 @@ jobs: docker: - image: circleci/python:3.6.5 environment: - PIPENV_VENV_IN_PROJECT: true + PIPENV_VENV_IN_PROJECT: "true" steps: - checkout - restore_cache: @@ -30,3 +30,7 @@ workflows: filters: tags: only: /^v.*/ + branches: + only: + - master + - develop diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..339e8cb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +# Verify Tweet verifies tweets of a public user +# from tweet screenshots: real or generated from +# tweet generators. +# Copyright (C) 2019 Preetham Kamidi + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pytest +import os + + +@pytest.fixture +def file_path(): + return os.path.abspath('./tests/static/real-tweet.png') diff --git a/tests/static/real-tweet.png b/tests/static/real-tweet.png new file mode 100644 index 0000000000000000000000000000000000000000..be58c8e44604768bc858066e5f23d8b244ee99fa GIT binary patch literal 63875 zcmeFZc|29$_c(l|QYk`Gh)`|_k$E_pg*b6Z8KXgDo`-V{p+YoV^K?>lx)d^Ip64kp zDf7%VyQX`MzvKP>e4f|u^*qmizt{8E^YsVotiAV|_TKBPv-aA8?rNzXJHmMcgTWkA zyM03kgW+_=U>MctPy{9{9qWMp*>7=G^C|}O<1OQs*#Y$V?4#Q{ni!0yAO_QQHaK1IPBvKwUy9@edZdfH!yVl!EDLs(_#DD51jt(>2is#|FUBX ziezwB)4a(r%5dO>40gV%oExQvQM++f*S%+UyQm}QjwkX~2gfFS`iR7rH%t<*&f;#p zioPCm{at>~)U&%eNl(7r-@X1T>FANuk}gLsJg(%g;l$n*EqR4O3IjMGDt3kYwl_X>@uEv)JJLvya|4v|^1s zJ&RfVd!GZMbrsMJW&hB++qDN&U0vY8K<^k!H@iIOhDes}hD;c)k|koXf;a|J5b$iG z8~S|9Z9m3ZYAOuld#s=Dy=ej)2H@@I(>D4qV2lFZbiYa%Fuc-*>zIe1udef8taDD; z?8k7ODQu#9_*vCW9%Fs<)Gup^DCR5^{_BUudFvNy|%CQ*g*I{(T9`Fps{0r=1ocCz#^;!8ZaP+^x zUT0qrvnWxC#r)X==wQsl{QxQmw9l7$kE1jS{KNX#e}O%Y251x49`Ik*{~0?(|G$s@ zpAP@p@PF*^KX&+^eX-|T{tx{_#R3U@bGfGLJpB;#2hSP z%n<sL&Xj#CcX`TN?vXh#I6aNaJ5>ExIJxDvsxEc)5KXhXzi*?6w{iKCF<2+qNU zg--4m0P`z6d7Bn;sN6D>CRGhp9y>wh`2$%!0)`JBftamEKJaYsW2U57#Eiv03XQsz8c;uSw}zdB^K7ilZ-n{MYg%v3Ro z+pIf=Tti;NmRDEVU`kp@%zbQgMp9!`#dh3O5gO*r?|m&Z_`-<~ud9&oMj1BEfTb_mx~GJfDmueyMvfpNlH(*#`l+SA76 zCbS_+H*b0?%O<7NS{&lQCM1fo9{q3?B8G0WRmC zuoK-?#lddUCEU}X*nkI6;sQTlhFga_WxPKX1y=zn%(mZJMgIyGf(kL-l8Dyno9jK8B2<7DXWwUMYai%Xa#DY`|= zE|O2wI%VKqlI%V*ZH9j%84iwiqqvuZw6&GqH=h9;cQ;zX!jdxFnC7(dydwd6ZUEyS zICmnVY;2_;^M1B^oQ4zvC&t$L6Y!^0rg8ZdNU-8EW-Jsc=%LqJGD%n_RIYXXn5rJ`G6%ayB*Q<(-m!XhhVLwvJsEF-j1HH z^6X@&IVlfaR+Zi?;2Q7Y7AC9K$q9<rGep-g6`{h>LZ;{arsdF~$g&*gKNvCu4--IZ+ znewa<`^RuU_<91=?eXe8!gBm;^XPJl(1y{x3*c0$cC!UIwMRKkYsl?4uw!Q#hw`_k}1f}Aj=@*mO z86Yzd6d#VAFCi!c6S(YlL@9TlD>Zzr1Qz@%y-IGcLZW_|O3&EhM_eW(V43OgKMpZH zA8p{1*u=H^?usU@X`Dnk+Q(RrI2UwgVsaz=UefS$N#0&$F+uxM-GI&p_jm4Ll7?LN z1H*VB3Lh*3v})o%_8x5G1M)!QMRl?8Q7`u&6w4Q&I3IZ~NBy57MpXN8iK3}TUGc1K66{yLJboRp zIm7dw`cO1ixfkdXnk*B8SU(?719`2@e$ERpVqK$oDa77cT+H$3?zit^dXePsJ(G9+ZT9)vx&K{{%o#l6+Gy&X(rmVUQmf44sboqAj^IO^^ESA&~lHUaC zBs@`Xz7^bFU;o^uagbF1jeD)ZLMPL2kJH>Gl`+!O1b$@8Qzq#*FlBF(^jqU_v&)}v zZJ%_oqN{nayWASww8*U)&&3?9v&9*%fwN>b(I7_ zQL5n3$CK|ePPh4ar$L_e2l(BW-Oo|Nag zvLtEp_lW`QYOC}Yxp)%nB8PmtM@udZNRsAk_PAb?PYrMXE|`SAPesnpjhB5cxR5NL z1Vm(IGc$j5Kb`)qJ-JOI)4m#+s}iW3?5{x#;1iteckqWl>jT7Iz7IKzL{>nLKdcjqI2F@bf^ zO_8jV@PaeNcgwr>+pn+beV&wzV4~FNXW!VKv9$^P(R3R>L-q)Q=WwTATda}Szp!+x z$KLbo5@x*Pc8!ZdzLg)_bvm3s1eA4rE_46PNbWf97;IARfxN~{CUC!{c0TPDdXdrv zM36cXYuc0FdoWUy+b&ch&;v)ru;u;Ecb#MbfOyV{KsQ3t9E^>?&iRQC7aK-nCi{!e zED;;>aQjDM;&v9Ar|;ytxO{lF;+jjTh+l}jqx3p3_fK$NpC09oaI?`qmqqg9)){j~ zdy~l#7I^B3j+ZAQU2?Odb@+(p;T8T&w7_;ho7$}L? z;8jU*cU2l8F(tfM4@@Qx)>{(wLJM1U1-oKWU7*64?}g28R49t-H zW;v*=tNEx@DUc7t6pteF550r_2mHW{q@p|@3%QvU_i{wJYW>A_-96QRtq@t%f&cZ59901m)7yAo`rhjHw^0aZ6m-lkV4$X z$hv8(`mQginfI#l?S&}d%^Q)M(5n()3gK&oneh)qPl4EFdlFplpLL{lZzAYjlTJwx1HI0L>Y^*#Z z+kZ~uD$l5~-0qTSoz76{N3N6vf5{NMZX0-9^NSQepjnH=9hEpCy+rIXBCV>h;6GR&?j6-qOjN zY0|%NUZQ}gVesHc+2;k6QZDBX_8j}=g~M?1Li_E&)ba1xCxH^*kXKv@vO`lgkgy=_ z6Je1kJ$c-$?PJD(ur#CmDzVE=jdJ_zmw<>21s3>V_sKcj^_S?>XthqKhb^xGb6iYZ zoX68tKd`}^Y`WA=QBk8!=eU*Lg@h%d#K|=sh1I-QOF{9~`+E?XxFM3pGclMa2blDx z+CZ@!Z7*)a^+ePMbse}*{L2f$xA(EY^OVl0&GRv@Mm`{z#h~|TsC1G3+ki_k5F{xA z->!R6T=-nFD_fuVpq>b}d^^;i8l{4Vt(;f|$Zf>6$3I!1k+F?|4#Gk1gasVACONir zk=R~h{dn4^4S_ncvroZO5%FQ2%R%uQcYf_fJ|%~!;aTG|kkw6k)8j2=T8l&h!_}D6 z4z{$hFqsZud|CB(GY&(ouCvTou8}9cT7uvs;)4Av7FSbikeAUG5UY4q9p0+p-o|&K zZfu4ZxT?EzgNANC)>>5fmhZ_*0x{cwv?9K-Bw7hp?3AFe|hZxqhna4>g* zvRpG($XC6qu=oBMGMDtvkS;|$<*hCqJl$+W2!cT<)dJ>BN(__Ir=9qr6d9GgLK17a zy?mvEU%EA!N z`_I-=Q^(4RFDVy{cjQl(Wpvm`sn@9WU=@86k^2F@Qo@dLgMAhC1N~35OJ=6Gg2j;#C{%rg4udJpgvAd2|*dtJCpTXW*46`lIN6&3ftxeBLMvn0Zx| zD1Fv7AM`TqK9p>+*;&8DG97y-?6=nlPM}Uz=~;4Murogp>V0xdO)c~XyRHSQBL+^S z7{H!RyIB?3EWKY?Y45i0e}BizYs#cuPTkdzBR>h!YjyDam#CC3UiA@8w=p*o+`bH} zb6O_xCroXE-ch#^Jiq=Ai_V?=yJZy*32PK6yef` z+%`K)%aL1~Hxf&27K5}{k1q)^wmf+VkZse=m)V4O4c+~R$1Rj`-ioj=nZOh=O9IbM zem;$=`w4K_Nr`to1eENfhUW}xIm!z6BKP7gorlMJwW<+d2q%&(^6hq1nN>G3P!^q` z*IM{<^`R^_^rrD-yhK3}-pXVKhfB?kgM`1|jymEDZ3K<7h&20ipKm;D??AI|ntU>1 zjJH}fXhT%{ki0B@(Fxu}@DYzlb3%A(EQZ5wv9R_5wt*3%XfY>(_YMsrzqWCcwQ2Z9 zBe*g*^9s*o;@x%ADz4&mhu@4`MfgS=Vpd8!N)tVGr@%B9y@2jC29~0btPV( zs;dEiWAc9t&ztNml_la)_Akqlhv8H5lkIJnwaAZ)%hB!EvPSG3$H5~6&-N;MpyUKK z{9REu)j6&=uPb^Kc)#Se%z@aMBnLK6F~I{5eVa3`ub*`LysEgFv$=|tC;5B^J*tuB zkeujQTI4WEI{vcr{AuJUKcoRm%BCnaAdR6p(bn*niaOAqD)y zBo`?=2U$;bv4Iw>M47+QQrogm2gGmewwoj%Dp-dYsA%q#nJ^8Jn%rM?9|k>>>@&bk zQ>@XF5Dw5YuNiFFeXvfX;H`w&EhWgqIXzepTo3z9iXZr<96^d(vF5cS5tF15Es2|W zR)19mem9vna6IhfGOY^VnJeZL4#)L)kG z)8i5D{ly_OM0)Q2wrA@JK6RhNIN@2>h?fN|3;M--^Bf`IosPgJjf%xP&KycJqe_IUm$X{46SQ#2g*@-j!Bh7dk=Tu7FFM7|T(3a{+GkXbOiGC_ zswJu$oPOM#1D;+b@&hgT>Xm4aH_F%8u%=c^`Q`t%ILfy~De>;AX_XL%L7V(|*76a! z&s+$8Oj?j6_3LZpf!`@=ObyxiSoS1;Y_OUQ;wgQ@D7o$WKG@_COb27ElG7H|Z2JEI zWV<>pc&x)ZSFUR&Z3kT$WP#SxYdPHry}|(%4g%NPgMSk*kB~YL>@D^uzl~T4rDEY0 z5j|_fY`j&q%J&%@J#4~&h8|pH;Ihi{&FHD(lQyN%V{CtKW*G_RUwp7n4iL>YGXQe) zK=aiPFI-Bx3q2rwiw`q$bXIHV&D9vYFbvoUr1Rs+c&oYZ0pRBkS{pY^n(Hl3_HoEug5IK@D4c`(I2Mq2v_o2IC}d0( zoWohzorgHXolC(Q0}o1f_tyH}du1Q&*a51J^13sweJ&a4+N6hzru5t7FBdkto=|mX=%2t5% z;1(pQIanW?)^!+&!5#G&EYU_p#&OSWth(sOSJ`)OqHcnl(jXeS*dT>}bc^M7+DDCv z)kZ9SxM+H;<8DI0BIo*&v{$%_fy?v)7BCL`53l8#=3+UkDITK1FIX( zDNu)_!sZ{Xb3XVQAC?Bd2RUBTaU96$VZo4-pDyUM&+F^211*HGoT(ME zdfT<93DvW~mx@aV%7PxlD?t5RNb6xM+6~=Z?jFo$IcN?NqaX-=#k9>L)}w`s4Z<=o5^j>*1-EO87=je(jgU@ zd0Nhp-A~n`6;Kb> z8Gmw^2>p70a_mm{`?RWyeg56=ZqxBLOl~U7<7(|$ylcJgE8?xP1>|b7c(Dz_=R^%J zEKX_axk>S(ak6Oq^<4||upV6#jhE@QarH5b+@l}CT%5#r*rhBVz@3g@B%bE#=E*vLq#1%$@13Ocm>0S$kf^eE;xp$`wD-gIpr z+)Qaz(3ZNETzhf$wa*0oZ)eU+-AJqnEl}ilp~?P#UE>n5Bcyp!@rhv9^Mc>~+9{R7 ztM%!3-w)ggV`MY@sXLa;@aH!CYPSYRSi0(~0XYVb%WOZqIZ5gpaCvvp%kjw<{(i^8 z!wpPf`{2Z7R(M3y=k7vg)Z+%vCjIvlTM<_g{X--tlM#}-rJ|kjdh#Q~n+CQQ^knT8V?B4wJ5=*_Wp*s<^gFt;4E-u#PU?T-2n;on9W zY7TMi4Z!Xt$(l0gbc{Cp<>>5L{GYxMygKjgpRg>2_*gXhO-@fvyKgVn+m)*OD`n9V z2HBriJWdwRxU%@h2H2L(5GqG3m1%XEi!GyaZ%!Jnxoy8Rha6SL!Hdqmn3^rNOq*8BP#p^IK&>UVtJ(S}+0nGT~ zP>|iJV&b;L<_gp73bCzuvLP3=GAo-kTpC=tYK{hRUL;tchScZw5CKDb{5r9@nTAZ| z{aI+f*wtFxas&Tz{`LAp82l8ZM;IrVT!5^ic4ZngspyVhw7IhOVY=yny~HNOirdez z*ZxP0FV3_Exejk0-3WK$_||r*XG;0cB;jKIo{h|~x1}pB*|S)9n(*K@#OVQL-8%gRbePx&mxugyDM1YG@0mqo6eqD6 zzLEK)AL-a?ufUdh50Shwo3N5~#HEIdv&m`@;F6P@v)FI|C}Bjy=rK@|A%6h}>^6pi z+@tFXJ^Q(+WBcHNy=CC}nS|#T87445o*Xi?@a^?GOpfeIU%{t*WuS72Ro`x56##E& zZj>V#PvbyJa_37(l5vx!GVi z;$Prc#^9>@P1lJ>wg>8qsJipbKH*U} zj<{wvAqHy-kJ0&C6ofeC@SL+hes;B1nv4+Q9N9sM_!&{B0_sugh zzTch%B%GURb2c++a@InedRaI`$>)ZRpqkOv916g9DLlWBlA%qh_B*IhHr<}RU# z|BwMhM1r1X+ibU+M@bU2$F>T!^l$Bk&_*xl19L>rJMjA?E{1bA7EG{>tn#aqT|8WT z$`)1_#^oJVDQwUmB#oFZH#mP-XGqK`$1a-xfj%%sF)B47!qPj^aPtGA;$rfFe`SC^ zAfG;h$BMuSx=sKaX3Yp+k&G*4csGR0Ko{^wO;0C-T^`^0Ksiba9d*A+vnxDbjZ{*E zU~X7a3+55#+Em@Q$083uh_69*zJfFTLmPCR@Baqx>B0a_cSEV1CU7VHSAn-Ms)4i@ zG%bLB^O~jMQ%MW1zm>_4>uTTEfv5(tbAa+|g@PsGGPHry`rn51;)My2aNMkHGZ3*{ z*8iRZzh{a#$n&esJ$z$S;z|t?+=5-jSi!8Udol{bM{+g)AdZp%yXIO{JfOL;`H5km z)*l?Iv8E7Dl#LOedA#6SA(}!PaW%*oaub9;NSyIxVMk3uLf-24PvDd{b-&2&E(L)U zy3VAxPbyIjQO6Qw3gTmV?{+<)v0cP(P%MBThP$tLR4TBGR$;Vom35(2XG$}oh5wxY z$Milb$(@b5@aEM4(#~sIb1pU9Zi#r`Se)m|8PBc#aCzG4jzLwQX@ZH|5qNqQ>=18D zd~zc`rttc5GfL3hWSoR2zp1A}tjr(RPAB=I!WWk#*EV!Tfs|XjQUa7^IaNXb02DpD zA_V`gtqKOo!F)f6?J6zxk_QHVc--BafVRS}qvC61OVvjLProQHQV#=u`q^~|vY?__ ziMa0A#hlv4!3GjRI##Zaw5=HczsF3}PUR)bkySFbVEWG}7S*Cf0&ZN~V%daJ6A#p< zK6(oejBJ8K-7nj*%BFwPAmO4%u>1>fs4Lm$En(waR}f9t!ZB;y87{Q=(u~S}ILQxf zoLwg>cOwOM3;)v8Y8hioD1%Hkmb*uA5vR77gRCsn-r`@{!5}@fuVHpE$XmT?m55IO zos$2xu;bchXFw_P`(jd$K2=K9b83kV+9z%SgY66W-{~Nc<~~niHFik;wzrZJ06mex z&nE%ppLm0f1C0Vt0#Om9h;GzcEMLpirY;euAJyO?PBO%8N{wZXQzYUKqQsuQ1)JK; zwCgBDT{ms961|R}fiB0V0=>xC&fFw`=5i;`181g(8Q@wTn3G1# zQCziraDN85E51ksdgM^!qf6cD3C`m#Y|!By09Hl*?o?VQ%2W5|Ad2uS>_8hd-CQ}| zd=64Sd6tiNuHv6f5U&gfxlp9xNR$|UnhEgkA(IZFd(y^=&1(W@cXED-=shscRJ!f_ z9;_47u6XsM4x6IILLo+*OyOKI_q30c$qi+NJI~xy!}Bvn4bnO)68d6U(?e=MFN9U0 zOEs~~?HnIKJb6zWuCJ^?%$J0~7FG2k#SQBd|E>--nTVi`8z;pyGl$lNhGXp`@PKCT znHQj`28BW9E4bRBGq!oU5$RbbYOM=dnnX`L>@BWFIyo_A`->o}$Vf+cOoMAfH-b1n zY<;+S4j0F1XzjJP9ViUXzoITns2T|~8h8c=S6eG>8XaTYn=xY`sI=HI3w8cob=^a3 z(1y$hl|xtXAK1ddjU7v}JBiVa8op!vf*yT0zXZ=ljJ@ppg%C9~-fQH7xdvY$s}2S! z)DLOAgOa*_)l!#V*?(mhzuMnbsd%g}nfcl6-T5gxu_d7&o)sBavp`XNY)=}!or>EP z=MguHu-fmCjR1}ABDAk;Kbd!b=G3(~mrZfojn7s|41y^ou&WjHIMGm4nWZ==2 zJCsaB0A=SmFdCRYW;S2Pimve*GzcokzSG)1$yP0G0i9$+=b0W(MAKk?x*xEQXLkKybZ4DO_3m%&0BzI*$|G-X;L&wkc4%8s$$@*|FEalQoV<7LXY9_IfqDST zwnxTE>li3EdCnsc9CqNvyIgaqV2^j2p#(VDj<{}gW^Z#l_z?WKm# z(Xw3mc6rBk<&!q9ur3VaT&@W#;uTgZ+TO`F9e7-t8$3UWS1o!8~*6W!a_OvkMZ>jy9Jg*vw5`Rq_4$&xS}Ai%jAt5-AG9CTK8rS9FYUKRY)V zMcZo`noM#dgVk7%Qjz>|^EIL$xWEi~?#l-g?q~ntIy8weCwchuJ)Kl~>%j)c!cL^0 z1=sfh_(RB@uGIi1LD8a0r2N4%8h674>KmDdkD2_DBgas*_Snp^1dEySu`7gbdDHD<_IcX{ zFI@a*MYbq^^TZibptvGh;%vdrcpiaAXknE&yh=`Y%ob%+@v>}w^jy5G8pDYmk$Y_l z_1CY=P1kxQu2XI4-L>$vnsqh#KQp){GGV@HHvi0xArfN0?fZvda=M@r&Dh;pe_}h^ z834kStMpW@X_mh;xVjmFQO;5l7qoFgYuY%(=^pc_AvM`9PKN1-9|&J_Tg3zoCu1Ml z4U;HGi6wU4VN5V^fVvVO0MKIY>&7bg-m{{v2pIvRln1B(%#hw6rWV)7ZnB!mv-g(r zF~C4zwidH?tHM>(7H`J`^pARElhauM{EB8su`yQs%k>~6|t|* z(1cbG{VwnL!<3SrWeRhDo61jFzWYVWrr$jNMl;M|2 zKaTNfdZNVax8+fEmEEx)GwwL1BKCTwu5PQN!yUG1aZy;#a3C@{{I+U(5(g{x{Y7@J zzR<*%I$K{1xuFn;U8T?XSm5egP1OWiszMb;No^^HX>H~SROyQaC%WBS_$YXR1(g;B zu?k+Ny4tMU#k*3lYMN6j>o@x)-_N9t>uVd%LxFA!ki=%^#WaeU_Hz%zA4b1hni!=k zL8JZM1$RoNfQE<5rZG~t`?PcauC)zeyv2CVIvDtDdr@=xn2z-zDvx{i70|6(nq>Et zj!)p-*zLSbf1e<5p15z(tKv^jEpYd(wrp5fBM!_F^t1jVuv6m&yj37LUHWY4YH>ZE zmQrN~uWb*OeReWK>wJ5I(DoX%e%wCWWm@=Yp&f--6r?3TN3x6H8Bua}f*MyBuZ|Or ztS#%O85u#W`5u$H=kJ@%bm}$7|86SkMPB|zdL3J2#Q-7w&PR`=E2c(Bk_Y<(9zZ%3 zofMBjQ6R{6dpf>wzr%meW8+XY#KnM!jV{xek{X6_oDkm18rHouJu*U&dz+veb+*-g zPVd?_+!-lnd0h_&g-`Y8U7k$i|8Zx?#mFnd-e4MCs>bm? zJv6SnwTPpRn`I#6pud@%=q-ejEu0&{u>k@vNb++>Sw@PpM{VJr{Z6cSt9f&ozE-NP zb4+oi*TqNYBWEfc9>^Sm=LMF?*yvMy5_X0%8b50gg`!6R)rP-}JR&Z|yRN(Xfhk-O zOGF)@fkkzcyKrZzV#)GB?_D_feDt3@1ZWvv#7ukUc`F7+?Bob$_99{J$SZf{8mcoK zz|-VWcpexx&lxw)CLm|na!k=Orc~ir&N<=%rAMAD-5aKWQz}EExb3RF#j-OL&Zaa+N z&d-!_aNuQ!Ngwg7b#Vr?Wd8=6sIF|#{Le|AsZzQqlk-vw3#1*6kHue~!eear$)t03 zd^HyuYmg$eD)hyehNw0yxU4oautwf!@UAFcPS+>~zF!^_g&OIB&|1V}+LBDVPLmSv zMwe<+N#rSSXAa}L8 zNc>QxYA{MT@>@)w+;LO-4qE&`&%uO~Zy(R6kOlDu^F}q~TVp?Bt>$oP_~{7nTD0rw z&3xyv3vj?QG@-}x&2d0FsxGKIc5-vfZ8-pMbq}8^=n--riuI4-N>f{bL!4q1H2j{+ zXMp9S)ur!H>v$*qS|=l)E;k|L+@z_)Z=Pz$j)&c*CVQUB0R^L1h>@uLLnOstChM3~ z#f*#{QS?Z7>EO^TE+syCA>lPBJ56xcb~{SbZC0^?WxR9giv!rWa+`vL;RS-JR2y<_ z?3+53YJd?~`g3aBhH zWWaQ_GU0Xx+qE$Df(B3`&8dKx-T&wUX_ z%R0{E8zaE{^PQ(wEI$Gf)d&kZT`hV=Ns2$PV+l~|@)KcTnlSs50EohjXMwIt?CCpH zsUXp0Fw67|P(nAScV@x@p!F<}1~_Dd#RaDcJjzmr=S(+Ytbh@Zm?+Ri2RmP?!^$s9 zLY@q*#j{y*%h#3@pp62}X(M=lB{K0N-su`Ze3%ICqplo0%HgsYYT+6Xzd+z+TOyV` zhhG{4{f`HRNWBhLMS}6uOWyEWDQ3J*SLL8I%l%H|$(!m&I~RRpHO*PyM)3pei{!bV z&qnfgnq@Q4!1PzwHN2o&Ro zeC#zKscb7-F4y8pdwdV0V>uGphK$qM5cK@1f(Fv!DFAymIO@18r)7(@V;Lmwx*gu^xtEnI(8_MEr{d<&cQtN#f9wyUgmP^&pgYKuolGH zW0aqfrc1oC%#k>;nU)C(M^ED9{qmE_hSZ10q|hQDJB%rN@a;R=i4h6ZQ}e7(ml}-h z+5o)}kgz~tnxN@w4%wU%o3gZNQ^xWvS`PYtzH&n)c*|&JJAS^=gPJ%_AnthF{Xnt2 zvreu~-48aj4)U!Q)OBX#kGL#u8C7=ug`2xyG)etf!5G zy4gM%`*OsXo_Z~ClO^TBy`A&h+_fVF@)Q$Iw+d`kCtbPe`W`BxEYidYTsa&)4UbQD@W#VJM zf)RhvgHVC027S{e_NNX{JuNm*C@DQDpb*hE5*wTqjTQ`ff>{K+kF~R7Vgmv|ml4qQ z7A85a8G0;ICI$(o=c1ze1HmNP#qTyE)RizPtm&eQ1$-PWbemliV!kQ09%%8G_p$LN zu)Q!24&%7i$v!hS=nHYUQ}}?x#OOo}xyxcO6Rm9PAI7l+fAwgdd?H=uyTjwVMobR} zTORba-^$3_dUCwc_A0(N2j?Lq6Q)CCj?UFVV>Pmhsc5<^o zIC>|MvlOJJOIajJOk@u?DC?jWeDjqQ8?^4cy6)8S>WdHSW(Hc_7i&KFS*&ki(pA#P z+cpJVk1^Pyp~VZq$)g)?D4MXPRm`Oxn!sbDk`_ryg(} zkdDI#p8(I?;#?{%TC`xfxe}%y0x~PpU&SU~6M{X@d^7eu9@SEKCpd+N@*@ACCByg( ziN#Hzt06`4FT&5QE!t$P0SEtmU$phsz3Df_k{lE*iN0e6jjTgKSyn}Nh90WRuIiW3 z6wgad+<-L2Hv$!`+WLnZ+21_@Zxp#U@=NA%jOVK3n%cpp@Q~2gSH!9=vfABrYdvgb zR8Z#5huzezOa_{Ro|HB+J@`YMob&t@>i_XGX#R@T= zbJT4GCR(S>8Zr204)<-{^$IXC0BCyC9XgvW0EqfX^&o0n9#OqaF>97Qwa#B(`zEx6 zPGGwJ1}b*`$zy)^`o=ksFLbuJY30Ywt-_H0chBKYKpW^1j_%0|2c(5hxpU?52D&-z zDD|S*dEg5FNrD1b1Ytbj#1mqq0VJT#?Ku4oWX`sELJMenUd%p8(@ikRL#v#5&axqbYKfL!a;@m&pRGK z)1hyNjQ`Na$LGQ)qvoPP>2A8~13}~b8fESCLE{?7n~)lU>h}RWYelL@@4Xb#6VGnr zyBQAy@s|hf1j{}?tAng$A~Rpt-e9_5vnE?8bCTCq^1FElE<(I1SK$ZoyUY4)Y| zT#Ja+oPaxT&t>PrWRB@*&~PT@b!|@0=AByxF`3JhS4KvhaCN57Ueq;kl9Z}O>wG*V z=A)ujFd~w1;jaX&kjF1G@w%fGX1HrMIbL9-B(ANR;Nj6_1?y(QW#Lf?^L=I`6Y=y1 zJ**;Aw`0dg1np!zYkPI*Mk0 ze{io6HP?i|upd1z7j=L7SSDzk1>SW0uLS7Dm;p1B^A%WLXoU(djyBEQCQ0Jn-6neaAjTupJqDK-&~BjeoVL(`I;2MHF&N zj{E1+nMKiK_UF-U_2rfvkNu}MkSL9*BL+`w)(YY)CgVy^mT8DwRmd?-4w*pCK0+cKc3}XC2 zwKEWU&+fp`emO{eYN%?Qhk8T~3U*uEmiJsoFWL>`I8;P(<&@D&bydieNFg(^=a|1h zxm)=#ZY84fS|V&a)lWZ27Xl751$`op#r6)G8sEWa`PCo~UsXGXc1OdQt&L3TiP?Ac zlhVg{CSKUR*lW7M_ZijO>*12nA3kld_FWF@{?bD{aBMW#@6#UTjgDEQcHY8QP90j* zM4=jJx&_2|z_My?$D8vEbWyP3VHh4K6}mxwdI#Fb7o9*R-pPgR= zHvhW$boI!N_@W#K#9{=KH!?xZpn2uRoAxxR0DP*5v#PK7izX~` z{hFw)@P8iq`Ie^ATH`+y9{Frm!BH_mDE2eBV^z-x?%MxzOHl?MQw*eSIq?)Qlyg*0 z;w+$jLmI=&mepzgLo>t6YVjY{pSRyNHU==Q0qw~6Xo&~ zx^A{gjKOc9ml^k$Kgrx!aca=l+I4q%kNJ7j7N)RNnO3Y<&XU|`B;r`8Ceip<+cVI< zLB@QLi>>JlbCMvrgIXt}zq z{y9TDu`RRV9sCRK`s7ZNf@7PwjJD0;JR`$|eNaz1!-lhizZVhg=4e9(XK^krks0)W z%{Ujn=hv+vQrCk&)vL$EyvErlS8JC4PT-hfAZUiGubb9cfjAG5?hTQI7s=BXtv{6( zS5zQ+|BToVDV(9^_<_shje`vEJwH(2!i)_o_46rSi3Kt^02n6(Y8IDPgo2PCm{yLY zWoHI;%l;fzJF;lWPIY!UDdP|frd1J#|3i9Y_1OGhdueLU5vWjsG0qo6=wTTQY?LU? z`~ScVk!p|p_?O`#8I|wg5P=cuxz8u_kC6L-1SY5l13CJETq4y6{}H4dA)!ycpxC12 zqrgA^NOPF#tgvWFzgI9}^X)j-KO)h=S{89&w9f*nD@vx-6;5wm3M|KRP;-7xDKfwP{`QrN{SbDrD{*I{TkK7&@S!90_ILcj!NKul$E6X5YW^>_7e=S$$F6 z_Vl3J|Nk+G!A)mhkPD5%^1s_iKOl>7UN^<@ZW&2G;M)n3{#o&D>gIi~W#QPgf#9rV zDE0dkH+*eXHXXd$eIWR(g@i*$_uTI^wI0Xs2HsXHf+7KtKbD2_P1PEiMa^AF$8z4h z9g7A>^BNehJ7sU@e5_==?=^{W`TcscGx}SniKI&0AaY8jK)A(pVEAV?qvd92ASK&7 z^7P!ww^)a~qX#rUg9@SP_nM4C1NHenmod*=ybyZyjKW`8+X?TO2b*CusfFBQ48P!D zxAnnduVU__-al{UeS3yGbBCT2)j{M~c6Ro!!n)TUE@s;?y&@vm%7-ztD}9nV!y_)e z)7=d#%N~orQ*SO9^I`{_tW=xtuzh2xC&&ea}Ov#}fwgdDU zfvR|`789GQIMvwhjt#ZsFhH`K&Df68Yr!J_N!9v+rK{*gkDr@0jQceo5i25;lrm19 zRvq66FDUB!UiPpcQTOiGCy?MP$YAy9eK0?xz;4S9PW0sX(ZP zI{&A>Y7b^xo-0zy1($DjNM$xBjT>rp-f6W|z^AaAh3hldTD1h#%F z2^xwXg_flvZ3~S;ptyA zjqR{b8NnRKQVHkdQp2t0ZaqJ9!ZS_g&etw0?L^%?Fc-(Awf)!a+GnjRVkb6JokE_> z?6z|+S>~iB<)#*(b?6DX1eZ(5(!lkWuj`!gW_O#qSkgmnc$9V;Zk>0U*-VaoN|gBs zxVVO#z`pn`E+~|cDphEZi^UZ`yPIJBBrD^0w}M61!6&r}DAS$p)SF&92EgBGWUM9jlKr`^^SelYoUWVk(Aa>SN{`0f#@Gy_dcv+-#2}kb!{8k z7s+>jAEbW2h89GJ_VY7D+{}+w;Bqszbk0@J?WhSk*flIdNN$WhoO;BJiM3GX7Ms;~ zgByi&S33nz+ z^%8EKSyHnhY%(;zV@2M-Dd>1)9WSXV-QBbj z7R4n7eqe8*9wi*D#i` z4u+W#eNON1_dodl_PM^-)eo+#GtYCL`}6sD+#k2cofG#KfG6-v%pbtLrmqqBx1=SjZk;-$A>n}pjR*coO`Jaw}91#@~4Ex9rs{)C!eWe&{y_HsW5k49Q? z^_hgN@Veliz4Kbr8#q8Yk@(-80YtHWX>EnsVRBBq&@x*K{Y367De+LS#XJsip_;9xf7!{?5)cMbDXQ(Un6{4Qtf^O> zMfqX9s+rEh!W4p`A+jp7ZrU+E-u28VI`h(s$rZHx;gPb$8=eh_Vp4~Nh+7!b-gJkH zPRhK$tRl*>bCa7nea`eIq$?@!fp_H^q=#|(E{w3tHZcPTv{hYYMSz+{RAdxsi+E|_ zzSZK0q|xW1m}tsAKWyO+Dz&!1fpE*vD4ic_d6oX`AQoN7>2dFzM+@PqWhez<#9Ma} z2vmEX7Ek}WxD{^dprD-a1Y0(PyqItTLi4|2YMrmhQ?ogQl^y0=_*fNoVf*GBFDb3n zZzfXa%TP=Jms_vjOtTZqI=Jh5erhHS3;Ev9wunbRldj;tSk9-v4EtVs;jB8CxnNJ19!s~rrrh?1THe2Db3tex(l(Uk= zCa+CmK;Cx-vFJG&)z+BaJ$xpG`l=t)vIx3%e)3@3nAOAP~DL#o)GP?vH{WdlWnU&2h> zEC!WlD>dcg0!NTl^nNH{^EnIee(xCMkbTu;T}dNn&=nD?-ZkMM9Gg{Kx$yjIW9hKR z18Li=a)aU9-@b5U&Uvard83D372Z0^>%oZN!qhsUgw1w@ly03Chv4nA6rDdMmlz z$U{}_?U1dRpT4bg@qo$2#=#TMB#Uo84E60&rAolIWmAQaKW_%$h`y4_n^2{D2Lk=w zYvrr7;rLi7rU=Y}(7*Z8{eP7EOZbQQdahBMC$dUA2v=iYe*vGJF~mS9uVc2SHs?1> zP$KieRU>)oG#v7?tdRy>>{qzR_!{lJ44>hrSPjp8E)rfkhHoPJ5dmBN|B90+)mcVZ z9xRI4_7lnpoA@nqGM2KV4b4p;)tp6=$oJS|(&Z`Q#zMwv=s4ta@ciFf<)c;yP)FNB zGJ^}+U`MR+kim04fE_SiDgL6PCbRpa?|NJqV>t%j|-h*ZET zE?tMv6>f_Q>M>BGwsC5Z{XmCI*q^ghej$<)%6AIw=0Gt~mnKIG~V zO=DTf3MI$dcy+hp7N*!xwO*fVM#V#c+a!JpR}-aTVqUOB9=RG+ z<(Y6U^8N8Bo%5uh0KQ<>UYpmDaBiJ=A?xCPH$(-gXskqFR_bA?6oV+{;6p<-=1b~( z`a-@#QBMr@ZwH?xQHv|1_|;LUS&3IkV}iM^kB94>VTEZ=RrMMT?AOR@V|;PTQfllu z>npLVX!t8?DiO@ejY1b8>|I8(haK$)t_H4t04XU3LIU#Z4D($JljyNb%V)pigJ>;j7`4c$1!j3r>(aXM|SdQM_6$+PP6RGvrT)4oReNt zkIaL?EvN1IX1?7KOx9S;!1T<@+`XHEUQjw`br=m?Y&^ZBEwrkmCe5FA&!@YYhrsPT ze4_ibjbJ}QDJPqhFa7*&Uxo62=C7;KJ!j|m7K;-tWHw^mTv30o%%9A7y@VvL-^28f zfc*2B4jAbwEKM%x2R<*Y?N6SsJ}!FU(gU{)>JeqZ8CkocXG%e@%O68;Nwxp=Z1)4l zRk)hToi_0Tng-GV0HedV)S zcTuH@lB#a|#yf|pl_wI}#m$FIbJKIcO&4M*O1rk=Vy!?Gwuh0H_e_}M1;D>4Bj-^I zBnAv|6|c8^NRnPg649DN%%28PmM%RWk#{bi5G+;pR{`edeBa3vbkQ@#v>^%AGd~P; z*WMf|wawkoox)YNi3z-*+Si;fEVgl4Hc7J}guRD1=9RUo8iQ^7*4`VyJZFzUNVdbY zOTmiA;1W(bKS$JR?n(}k(fYnG=L{~H=$9GXa&zkV?|WuR-+k?(Yqf24Q@RgDgq0R_ z9@Tlzq~N7olTs}2Iu)7d#}B1>xtCmZ_Z~~ zNo5JCKh9U|9Y#snV1uR}!RH@dp*VAGiJWfIN4tsHqG1G*i}q`SLU3heIUTz0)i_^i zq%+dK%=IAPJ#y0i~)EaZfxqj}tEh?| z{?WFU`M6_i(uk^xL`T~!?I|A~fl15NH-~zCb8{KDKk0xQD|*ro^k8v*%#kV-r#hi; zI3(I?jKzR8A#y>>2!1VT?L6-`=70X%_PH7ee| z#nDvP8xy?5ctasF@*OM?;8{_jBUa(w(kzsLGF@ zt_OW{_JHTTKmNYelVSLbW^#)3TfbwZ6eX1z5c|nT&JAvIiJBJ-HTz4qEo2VI8gLM? zp3UqIfcP$G66xuh{qV?VtZc=#u2ue*0LiQSa^0Nm&#eoEt7js{qxi4oKhGR<~UbKG-aS~xk5Y`W~Cv<7;0;2{yxV$=#~fc!VyiRkg|ao?uTx) z!VjRmp9^7fBSs6`Bd84UFRw)0^u9dUO7Ql1Y?J}cZ9Gm6QGd&vKB(Xf#dFF%GF)t#A9iQFUs+6g5Bg%0U|~T|M@BYqTN3=q!kfsE42Acp(T&Y*bbjF~pm= z&O>?M(pX-iX;yM9l!1h7MRKD0KF%md--JhacOmW_}Dx7 zY3Tx4{J=D?2gSotcx1ZdG*0=)>!f$MBt~>R8tqAQheyi6(USc@v}||h<7ULk)*vCk z(p#A+y=TOwf%?#$^u7ZUw_0DA-^VJrySj0 zfT)G`V*}l)b&BA`C#HReoUvZ#@ANbvP=kv?s2iXmb5Iy)vG5Q|L4N-B94~|xyrcY` zaH9lmV)I~1h=Z$v&=+i(C8;oj)JQ+V(yHX%56ojJ-#l)5*J!R8ROMhGX9K}0-X)7K zJ!r89^h->84)Z6I`th73%4N29Lfg>ZKI>-TpP)KAC$x1D*ug!H^{ zJ9EtDJ@{T|Gu&FFH8DIq7mhAc=|KR|(m67lq|&x;Rlt`U1sih_(EfU zF9-*v?8$pRoo2|&qr8&XQVH=Y$toc6Li?(I4tsQA&=Ry4u+m&#vPcn(%5~5fAdH}T zx;YU*9Qp(-1~cD0Qw4hRED1yQqC+&Y2YSeA1VIQUTo(uk#mzV0rYZ9k{LqXe8f|}X z+u?<1dUc7yYvQY}rV1XbB(F2jv2u`FluAcom%=C5vHFFpB$eZbWZXyAh5`IUuDKdr z2Qf2_Dlx@0A&@smzn(hgdN&Khelf*+aS?evi)nxAI5c7ZAV^ME&v|5_2HiX+}5?*edzovswxkE=(0!DYIR;*B=MC{6$_mdd8E zJB((VDG%alI4kd>gn4Nmip}{U4=#H6@;I8aOK+#*+^TqgD8+H6TvMuLX|*&U!UL9G zqE8T4>i3R#4-k6Z%gi9FCUJUHW61byq3r11FEpfvPvdX0+QZO~Sl2(rD~EngBJH$q z^0n<7Cp4+Xl)~50w`_ZEn~&I{q?&$jO`kJoA(Uj>dr&@)f{2SF01A7d{J{@3sn7II zLVw)$`3PihMO5YmFC(d0!UNQ2Y*Ape;7Y6bGP#iay_}2)7LcDwd-d?)YsW7En_JS* zz9_{gX35RG2>IRv&FaF06o9tDyu9G-|R!+>Bi0EJ160~r0_gPfiOR?-@i2#>=Z)Q z{3OI2EG<7(QIuOhofZOb8ls(D8#_Mc*wJvGQ70bp;zK(X>)F z;LpUYSu`PF+iD@Y05P|_p{;$Zrr}*$1UvTQlSHhMDWBtmc0}(fCYJM@^e2{B;huxT zaFxF5nySj^JZ4Szk2^wt;_0L>@NK{F?MdGPepiwOON;ycA*_-4k!{~%GI50a)Fkda zh4>fEn!Wy7>T(ytWL2{fw$5^(y&NcC139lGaxAP6!Q2Z$Z(jAw0%ts-jqa||=dN}# z54TA8h?j$eJ}=OTMImfY+o(rrKoXK7L%O7BgBr2MN!(}k09nrMYu{+NPFH0tkPnp3 z%*`@FX`6Qe16!o!1kOb)3uVummZzbEc%kf6@Y#?*hL(t3Tkxs^|F=$JY3+5^qjRG8 z++Xi7MP9-B%D#fIcVIdGO!j*DRdcoL_F5Wl~N<%OO4fwd>zNF`qD#oL9q$!riiM`ImBZ{1>*? zjP-i%E6eA`ZO^fQLOXzuCn%O+IOS;pYs7L)5n*);lbdY}CHX?t?BQkHzot}#B#f*) z<_wXl3V(%x(x9;Il;jU!m`;X2JPtSKA7O&7GJHiPg`k$J;vcba#O@jmZkEhT(`3TB z1oS8A`d!&>qp_ce9^L5}A1u1kqHH^M8!&%i#+eu6x<?*MI5*S|u>T1Am_j2pr-toW z!7sq9@UUS5NjEg<93zB>MpclMegJM6=kK1+9a6w&ai#TE+qllu)oE|k?Vaz`+ z1GnCQ{y$}W9a`HHazESzDj4NI5#|5uOMYbgKdlcO!DRc!9){Lx`&W$5oyu;h>kYgR zoL%zs93y*Kx{s!$b@E}av=Q3i#k-{s9p`-84TwXI5NuzA@MXgDJ~yHtVM40tC7I^Y=d25p z2Sw!tcApGP{5cV_v$XDVcxGxp9Af(0jc6%tjD{@+-In@{2)&YR9L~by&b0{`B8#^J%VBPaeOr`IgVCm?Q%_an;A6>x&($ z|iw?9i$T3k)-Z6Hp}qAjZ=h z0qXYwPd9*=QMo}n%1;l^ENLdV25Evrx8Z(G^-EbqUc%-|K`KbTNu(Rk+R=^ED#4^- z@!O|NKJ<+nK+gZ$K8z38RpE7rn)2+{T<({ZEr{L=oT@yfz4;i(GtJr8mcTYX137j( zNDTm^@3)P$6fZE!`w_jepH}kAXZQg>NdPZ{k|!`Jge8%$az3g&GOMJIi8q#!r^5Rw zb~@PHg7Wq1n(0M6!}TjNf&b(nwPcs`6Ak-AH!a9`2MXbz|pM<_k zVqE(%t|kTW5v+b|&q*u|+Rl9c6~7y@tjZKy(Yi0E=I&uzf^BU{74w7+T7_<7d;WDJ zgIK}H8&tCu1U2Ph(126x1kzFBSudf*V!!9k3Fyc8wlOIPn1=`X;)V+~E&>7Q z({!uUI^d=@I12+nhaLK!ZAD_(%Ch{95dto;7hymr;d|@qU(~t|WgM$AiSvX{nRTGa zs&3~mkM;S9EyxHfxDQZp1Hue2M}RfJ(EyaVXxs=>6?j!ER_W<6;$Yx@Ac%Wj1mF1D-0*C6+#M5cK`+ym>0 z0GBSGl(U-!02dMHb=nsD=<*iyxF9u8w#x53MB`0)Q+Un%WBaGUDP&DhqB{m!?rTK{ z&A?&b=Nh;zw_Jv1a5Z*K2`oxTBB*}x-S@TmY`1%NV0)+8WSB_>y%Mh57O`XT=<|r> zF?lFkW>eg#qLqMlAkRo)X)qQ@0eXc6@NZo-0fz?oAY<{a803jA#u#@0+C4jot34@G z&>GnvCjKM|teHYCVYq-sSWK&YmEWjvn*s-MJi&uMH!qpPpoy*8;Ne9aZ3B805o_#`)ikpQeM0*O_ z4G>feO$t`U1xNtHWe5h5+{d8i%Y@EgXmvvbL#njudN{bV7h=Qk%CSzu2y^4~9BwFC zi?H_PPdjX-%&%K&o*=g#-vl2Of|2XSIY#Os5@NpLy=Gx-akq}zG_QD}zk-l&tNP=i zghV^mR_3u8@~o|nfI9{D^c591eI{1(ncBV=!!Iu?_Fj95ZT&$`egLtJA&ES+$qm(@ z?aDlEmi$|XM)e8?fmSbJRyIivJ*FZ$4x7yL=I2LL9zn_d%A=(7R5r}Iz6dQkj;bhU zgl+?jd7yXq=jSh=%Kn#qF__7+=$>%Yb=R^_PW0 zIyT+!1S7nG6FQJWMLGwGvfn;xA#_(4XemAb9OSc>0IG`nlKi=$4?h5Y75B0kT+2^u zXhRVW@FmTNP9w{I5{5{*OUP4eI*Vu&Bnd>q|NfmrH`$aPtC5}xzK)Eb=e%)n(@Nu@l`ID;8Lh;HH98l6f#1Ku%5HMgfMlz}U}wM&k>(!D}#pc;^vDVxBF& zjN>jJB@DR;UK-u30Q<@)h=sQ<_clpyL2l1$zpuhJh*PRqqeB`>A(DB0$Rlbz_ovQl z5*d3TW2-F?BcY=`!Rt}HZY_Lr%-$PIo7@6@n+KNKqf{1gy}U*}9z^$A@b-**Fv8`b znch55Q6nn}TYIEt1m%DZSE>r9i8c)>iZP0=?6r01)xEZeyK%CBskOcrGAet=;!Aqp z7}Lx&&VflG63#{2Sk4;T6dDe2e2t+km1V9O>7DYpQY-zi`ai?+fntzaGRa#oSpb+5 zC82z)Ps2{i2chgmH!MxID$m^XXLZ7DfWx(ecFtpJhZ+xd3<9IhvY)}AVd+7H5EPRc zko%np51w~ew^DGuOVjC2Ty!OkNx3oVYFhfxt~!W{T!@tpg>RB;U*W5SZ5UW`<~~%d zdxV0@1%e?wI0Brdql$EH}BJp~?+Y%_viy}%DS5c8*#!u{YY zX?!F1>(_;+@%JGB#*_cJTa zwtPO5;_$l`@LFXC9cNmOBE?{1*fN1TEYi8W8(D88ttxf9xA#N=IYqr6ahuV(*d85l=$p;`%`0Q~6i zXO@;l(}%F6&bwcS5k9ceE$O)#0@f&$P;m;U3UT~r81ovoaDeIj-?Fps$-k%@ASvC_W(xva2J)+lrtbG3nRxtAdG zDJpDCtRL@ZCdkl{&H_d|(rw=|DT&^L22exLU%K|kz;j130u7eXNV11?7^) zD!Kkwe)k)2!gnPS>L!#LS%L-yrrbrH(I1%ENUjyrqY$5ezTM(Um3M@hK5 zmV{gt?Bltc6xb<=axFyNOWIE-7N?scL6PA4R&ZFbg6UXaW15NTR)@>0YcO5u&P9y0zLY_}#vsUyc|ai{t?URkW!^H$ zZuy^g*j&HYex)=KOmR&rAfrO#@gC2CdFz^>I#9i%K0-upPPkO#qn#Qv-{E(QNHYP7 zRJBT%hu}T*zYnb9w*uU>=1uQV+V>HOg9|&-!JQ4xrKedcDN*6cc^lH_O-^RR4S5ez zfHz}!I06Y7fI|t9ICR8vsSQg%9@%91&28D$E$Nny5u2AEodKrAb+?W^yjtz&m|(Gg zH2+*u?Y(I2+x?>>`T0!wx^nwTpW#taVM@Biy7+dXf1|-zv}Y;GcyL#@AZiKC`bdFI zV6a}zXK;x;XVRzHjtI6nfI-)C=wmn)Z~Rn*lHCWdn~4#o-#2;s;?*Kvl?@p~i@FvJ zQi08Wx(f>rexF z=jvQ-N96tf_FBD4%~)bI!aueFL#x*F*dTIJVN19|;0NjEONcO?fMPJMkc$cEhm?fu z;8na^O$=6DB3E~XMdD7bk%n9vtHle3w$qV0}&g>4x56$9IUjGD? zOS7W<3TmA4UFrT0XB$ooP@~y$e!rN5hhcF?1W)Mb&Qq||W*k`^5Wq=EDkXE4zHBm< zuG9SA_ql~ua5cM@$A_YhJC*AEbd9d~74WzFAN&RSVP*X`;F zeeer~1wo^-YxBi0LVTfbTGRQ}YYsOXzBUmzLfm@H7tmm8XpUS`R;6O2NJGwD_-ywT zW&hieS^;jYPB!Zrat8YMo$#z z1@`gibM$#iC*0{XAV1FKiU9izhL@2wMO9a`e31KsOCo#c@@@RX%UBzS|25~_o;#Q# z;fu4(;rafv@u zhU%GGMwPw8Fvr3_p;wqQNs^uWjGC_V;&c8;{>?2NDn(h~rs;E|7_3AK`s9PDLl+Og zMO-bgUcU^>+Ri{nqreLDb7N|qp7Cw>URbV&FP3a`Lvyhmd^wA%zo2;wPOV6{7Z!|k1^=cz3!mb_t3QSN z9W@+mv$;KOwygPN>n4K~QJPJE_ol^Qc#2oQ0*!VkeccmVA7n%;=tKliJKA&`5R?D? zp4AYLIZ0^?dgLFL>pO`Y%V(CDN{v}1+F0Dl+`T81c;l4<YT=V{VaXL)?ApjLqEoOOPu}j+iZ|mB0 zb^`mWRo*w}g8#Ib=T?b+u11DV(I0h<+$t=!Pb831xwOKhpYY$RSzt= zyY-wNJ$Z>K|Af^2^DSW6@cg7L5UuUi(K107U3%Dd62u>fWR45G!udx0@Uc?iwh&*PpJpb!R(go(=%Vvc?7c!_AXbv$(+%0bWM})r!!QSNgD=a7YC8DOh7xA*bI?XriEq@%THF zD{sKdS*}swQtPtF()U`g{W zybVN3W=D$3E?w-W zm~bS*A6^;(!XNG!N=7-`m7c5=a!wiLrMt*lz#h2renT(!rKS6BrbT1JibqHOs{P4_ z7q^k=d&0|QU|Om{rzBF{H0+*!K7dxr{w^ulC}t4wc6@b|o#FQ@*S27LV_KV@abpsB zHkSjnt@uoIePZ!bA}EoCd2e3~20^p9C{Lx!LrH&#R+M4KXNa#^P+TJD`mw6!hq$%; zm9$Q0^)k7aMyDNNjNyB{TW@+mN5_!(u5`dcRG&uSq|Mg9-i#9k82LoR!HRUT^wInS4&}MKobIU&YyB=?bKc$bD=4 zWb^3F4miQw*&DgO^s3e&FX0z8&Y6BgPlN zipw`Bs(2eUGNpSzdtYUho7SlyIUgsnoV0RbJij9co*bt_34EPi>UwcKyI1T@UE3&o zeQZp;MMttY9qr}aHb132jmvr68hOn>@yz}zSQ+IOYnQNpYG?)yKhR*8dThrXobL|8 z@mU0Z%i_?#-x8rBr%JcXVzFk#qo>zzJI`K9B_r+nOsFn8XP>{_lerQfWfp0@J z23!lw+#;B}Bz!+&bR;jY_-f5Q3%RqI7So-?mbla-iC+7TEgZ@#b^Thm{vVa{Ins%t zWIm2LM)n{!2h1db88$~dh8|~cO4jPAcJNejQ<6fY%tnJUmiqktsvfoDW5tY*+Rdmn zzT?o>)0uIt@i)tJ7*hxt)V3*FCT@V(A&Xz-umDuOW27L=vEW(cv(bbndzRn#22?rP z)r4Fc%r7Nf-jOiYkv-s=R_x{><;+>BwG^}j2SuH(KDD?b^g!M1b{AGKG(4~7C%~fA z)nE86(4B2l?QxiWWTQ^Y<_S!CyII>$;0Bg16;v%tJ_GB3!d2q8N<**KEjHx`p{Aa2 z#b$|IXq?Q5DQZ&sLAWx$fLwRucj93B3a`%h&+-FtW&$*QmNi7P$-5z0Pgs8nmoWke zofFu&zaI+0TK^{ZIs}>3wi2S_Q+E{(Mj`5#$m`Ll@isHobgdvKY5t z2bdqTBN_9TB8K(4zh{G0rYIe~mx$Z&ZI9-7=1pXkKS)_RAw$HaeMKC>3row298^dP&-kcBq^wMi~0n$Gix1Y zsX%i|{JD8@zTEA=Zw7(CwiPjSx7I6uI12Vn{toV3{P&3c32kOcf-uks|Lr+5TYfCY zag+E0d<@s8`yS;dVShKa4Ubi|q4KgKG><}?J@tee<5XlpRnW&p_+7Hw8+QUe4YL49 zm@R-A`nd-(WdhL4JA<`_53yp&fC5#?O5AG}HlkU&_~wGWd7&6W z_6=Z$FCEPT_k6>cuRs5P@~Z!T%zgZSO=LE{{ITVpJx_Rbv^0$W|2Fx*du+fS*pN4& zuk#JxC2iPOH4nz^t2ba`WMBE3yA+%$u0lOFYje$r*Iy}c_5_WVi*yP0BW`V!rM(lj zg>dq{4+|W(v+v|YDx!P!FJ*sJ9HpdpdXB{)0!bxz)U${ij^b*t?m@;;&YW2`Oa%P^Z9)^zS08tW%Kwi5PTSX>C1uV^; zCxWH5TIZY-?Cu8L{q_Bj!Q_9T6+=%#vL()E?_T{=SSF?Kxa^?&^~wx#>Sk|J;3OGj znl^0Wi0ULg8(V3)gHXN8D$97|9k`Cm>INDq4FpZZS(upUv96P?)VwL`a)%9|@!hs9 zwl6sLPB;)i*Xn3jmU>6o$^F!(3VL5KHaGN?l0keQ7Vfz9)p~0d#Tm+lN!hr8d0Z+P zkgB6q9O3cVznqUe#t`o(!A7n2Rk0d(DW3at{&XTM+($k`9v%I)%Kzw7GuR@m0lkXP z%k%fQKcfdc1m=aSW&tTD0fOo>9t=>JEW?Ah*Y13{hy>K8L-#yUdyNqc2g5vve1=PH zvjEbo(U(}kEP+c_-y)k;Uq;0GcjLJ0MC5jIC$w$1s|k!r}Sh6=)u~^DFp-gfjRPp?Rn11 zRXpf3k;T^*s>RL-I$*jGv3E6yn{C`;bH6y{CAePnYGfamx%C2lJb?dG8{#pL+lcMY z?2LkS3^DM?Vw_uyxH(vFckCplat_{);L-nk4>pBgoSl2h8DNpjIiy^=jy#>Jv zv`Ah+YFG+TL%B_=Pa?%-HPT@szcK7CKMB2MISmjNvxQ4Lf%)@~pa$kzjC3Y23g=x^ zxt&Ej9>pqgKLph)pQ96_?WWKQtdIsmRplQyIjqQ8k@dTWA&JU%#=JH841;z_lX+n5 zIm|k2B4j6rcIpR?66W#XBzCJ+w}~!ZTzlpMfK#n{xuE8)d>7Li0X}a;4Av2_f(ho; z(_f6KWT{D{6s9PF1KV0~tpVX8irFUshw_N)d_F}PB3z`A(;o;iblSb_W!ACmVc1~; z6T5us3D@V4OWUyiq~Z7wTX@|i@bp4gx%>9UDj=Y8zhn{#f4lg(H68x~s;dsHC$H|? zY9KhCsorQOpz#TV(%K618jErF6ye+xH}JRN#Fy&!?jy4EMGNHrw#BTcv05+^#eaDb zR|}#gP?|XX6OiTX-X|9iVS2h8kCtlb*-G~nuwxrBNeIAe-#D@uPC8-k3=f2Aj%HZa z!YqkcLs~7Y;Z5LlShRv#?`N|}fTQaXJWx9KLO}|q)v-t8tq(D3tEO=0bm{Gci;aXI zkVc@Q`CG~$K>B@;d123u7yALytXjtV{~kB2yBz6rh8q6FT!)(K`w!d(^vgiIcEl(F z#*m#kw@Sj7*^E(gSX;}c*Ayt=pVJwDzBmxao5Jbn_-0#7M*cVNjTg58lg;N;={p@f z3v6Tk9)jzyaF1Qg!~z&hXn7Nr^6 zs>Qc@B>DSAsG1{;C0svxwSZC+zeZG_A+NQ;_(89SbT|#o1mAn+bVy11DS&@nrHf#z zvv@blwU_X6oA43(_GiRd;cpo>W;_%ghJ4&i<0?Q(#BsP3?kb2bGyIb?aV73BW zYk#PZTR|Ryw~upl66?*(nnS>2Njb+)AS)&@@vz7;?#XTm0N92PZi>rch2Mjb%r7#S zeFX1%M0w=q+V<17t1uKVE9AMd zZiA5XhV5OI%Va(wo{!xmQ>_CamNo_WrO)zAMb?X7V~epj;`3_Xv*rK7EZMbjIJX&` zt0Z0u<|D*ET>sxJ-Nvp(9OnsqRiT>p7?I33^*!_^Dk(7dWBLlOW9YnpV(|Lej=HPR z5Vaj9>MHv=z^(W*1cKwb?kfCz9KX}={sR1af7hnD+X?1^CfqpzxvE6In??Rtd1R0E zeh93=c#56vzxA>-Fi=brKE<})`EtIJ&=ILBc^vcy`{nv1d(H~b{LMC!6)gDE`plT|F>WVd)-GEF(vaEKKMGo40l4|fSlfdYyOFw|{@AIAZ zl3f_1%dfFXjM|?FZ|-d@+Pcfbj}V|{Gr|H)eh@axHt?^vMH||XQ;HB}J@*Ufb7*`& zyyO12PQu*qJ#+4Xq*dhyFpxsHWDtC7Q4Xrq(=V}%xe*Kc{NaFK>D_=>0N0z4N|4MZ zi?T@9!NX7yYc6;ZT>L9sx>LD*Pnhl-B|ZW3%TLV)qA zy?w8;RW(!nTwq~SKOz3u6*IuW6HY_sf@5q^B);BIdQcC4IgEgC)cWcL&$!%~fYcvT zZ9-7xYqnb>L4(2lsusNRxb`$p{kwq`&FAV@QT#r2_+XERvA4(LEsJG;+|k#WB>6B;gxE+&&EICC0)n0 z|J@mcfLx_9MB{^8<(P%n&Q<_%0=+U!=|qfRva{Zy(hZm6aw0O0CG&s@83}FRA2-70 zpl3+9=|9QIER#W10o2l}%du_a@bh_qiJ8eYrUW-rE6lsjDGzHjM3QN}x>Oww0~EJN zi6e0DDpX;M!mgdq5lU#R+<%KDF9BtnK#O#3aahP$&VUb;LAy1&_A9iYgX7=#LTJe( z&0Si;j2Ko`WLcqibM7{@S+HbtXoSZ_#Yvt?xaGqY7^@1M;AyJh^-`A)%G_reRB^V&MAO{%( zu^?@qoYDh@f1@7$21xaHXy_c7AS z12cm!Qh>u=NEo*m>{a9GrnYc|IzfCI7l_0Y(4XIh+Je8ySRjN!jfF8b?_6!J~_F5eWZO zGgMVWBx70;JsfaQc(I}?(EA>=sz*5e5Zhzj=m}Nkfa`1Rmha;xn=wmeqXf-d`PT46 zh-r)`b(Ml~N@?f!6iz+`vzXVR4=oeIk8Akh1W7=tB)~w|QZ2Kh7kDhYhA4ZXHxi|* z#E0;^yQFry4fj;Pffr7^Lg;ZgGL5a_m4`uP^S)#Ie#}=&5?)gm`LT!xv;L%RYhhaF ziu}!@gg;(l9jHHYZi{FI zbmQ}=Z66I&JgK$d&Rei^>qs#Dm+N~~iopsNpI>*;V;MJLP>RBgv(MjsoTPbug3^z&W23K*b~MdwSB$kW_f&IALmPK}nyEhb z9nq#Lo(v*L{v-bERfvI7!sA|ZMaM_b#%$26@*alMkqB@L9^kkMhR*xYYv)tbc+xY$ z$t3AXWI+c03`5V(b?7UMh)u59^&Xyvhf!tar|$vvQ%E=#aqht18MEb34PKg`yy=AzVXNep;N6s`kK~@}JFo6zYsY=S@8BN}z-msO?#PtW7 z-#r7AGSRf!HF0hHx#eS?mqRoF@emo{el&oJM!IGPW>Zxne04@34ii`*n7{nrk?QK_iI-aQ2|jz z5Gj!^T|gEJD2BF>(5p%l5Rl#yuuud9h0xm)DIuXrmnNe04x#tnYiLPmXZhRb+JD17 z=i2Y`VluPJteJUcp8IpJ-~H}Mfdj(=;X_t&Ryp|g_N+M(7rwWBox%QmNc7#I&J@%d z!0yiKr0*J*DwH0>4<|P?VQ~Xf&y3%TqMK3RR5b4#%4~=AQhgiO?p!_`E$kFCaX)f( z75^wS`3@vCJY?-7;n@+VMSiR+aR~1{7cf!j_}EpVvtILBU2un#>3$TkQ1H^5gFRB@ zW3QxMqi^&%*Ot@r<=o*b<%+}?cl%$+mgyzdmgQ>R#Hbqo zLSJ^*gai|{V#@i?ta@cyoTM%1_-(ziYwb^c7MUcvo=wqElE=9()IF|Dyx2a?2;|Sv ziM*qm8pWKPz`pzc_x8&K{eS4+i2w75hDtE<^TjFP#P4(Hlke!rr~hNyxbPqFf9lo# zf9oRQXz3m8|9zzYw`=`xs&09#a7)3I{x_&9(qhk9G&Mvw54AYF`z=hS`5(dst`vN3buXwH!^3q_8aAxszHE$oJ!n z+pbIvdxZqRDQ%wVHtnm8Umcn{{iV||m#gw~-CPS7;037gLm!r=|*y5Dl^BQA6s zAay!YF{laR#UE-s94XoKpq9+C(-*V%kJ}ah^#eTM(vw{;! z#ygrL+xqg?$R7UXUf2KUDUh0wB}02GJ!jB;*$Os~8|t_86@ z4@NX5qx+moi;k>(kMnx3a9BlDt)gzP32a9k4BWa0mr43xW#*FV!ho(M47IfP$|}jirQ0-G@it6i`oB5&Ffc)v( zZ$9ce`DDH*Z2M_wAi3WYWy}5mn=JqZG}hF~*d)q0p5fRCy1hYPPW|5wBPoRg`ImpW-OEV1M&~j-Q(q;6#9jVZ zRCDfg#f;(vy`-_`^m*ecvaUS4@QdJkq*96kvh38zxc1d1+NDU zho&14O*bBHoxZgsv1TA2t#e zj13|%nuRIhQBE?)9#>RJ3JF~3SKM!uXZ>iB&m7bn!yPPFY&>l7Bcfu02WD9!%g?^4 zGU|Wd_yOtjI#3EO>#;dCn&GG58T#*0sr4@8RD9#9L46;8+C4``ga=J1+$(q+QD?i` z5%L~vg*!F}OS9d`r1NO_<*m7SU#fd&N~f;Y4$!=rcIiE;$UY#>t1Kad|k`Wod_{yUUE(LDLe@%{-Y_JV2U!wC7Z&_cijs9+@%{-RD$Vlp+EY|4)6LlwyzM#Dr;pL7%g!<4w#) zP>e9%u~vT8?FYA7y9>3sGPUcp+G6*=U9__iDkPqW8LJW%a;?hB3q$>syD<;!RkF$q z4Gh(M^DHlBeatAbrs+>UupIG!obYNtGJ3`ec=rLUti4>4&d6(-MC`tsvRhgNy*ZF` zD*3^lXpYTzyV^9)=;PEA`KPB{K%mKq`e=e)DF36~OaJb^!@(izJ8s%PE7ECOqK|(a zBuDwUB!jN;VB9=vfzivRIVn;`py`nx?AkcRHHTuoJWDoT4;j#&w1=qKH*kwk%y`nL z23h^ui0o{Q_g|#_MYJoP*eZnAMYQ7RP6M0Wip&ihEK8EMZ4z)R`;DU(bsB2NcYTGt ze+{}SZ)0eDEPwME-1@{HKcnjEFdB47dR41&v+ObAOr(?n&3-62j2);ob*9 zAJB`hknOOi&m!?gbA(;tMVVhvA=?8!(KQBx zIuW_xv1F{tkAvjIO-?z--&fJE($#HPa$VhtyS((x>%;e=K6!FddULmDS#etI4y$az z{QG<|1P}M)axeu4hJn13^_$jDUXr(WYBAsYwSR$Lf1CYCm~Kf8&xbqY!m-)ZvSWn< z)dkEeeI;A?kf-ZF4weqBMKNH;D4@_YxTvg*AEC>Ox#+_O5fYxOmdWd zN)4V1Jz(|8y?rd*;``y4i?-Y1`AIY8R;UA05EId;!*Em^4RPdW{Dz58>YO(Z{_eXH*yWzyEB*GRD>3_68X? zQp~2*+~sMd+|<(h3pO1y#o{quy9&?XY|-#Tt8 z>o6aEhc8LH15H4oyKi5s={bdn2+E*tLB3JG+ohfza+Z+tHOo2#9}*|`{qk7HA-z<2 ztm0Fpc5$eAaT?CV#2Wg#*?=X~@2M#Fw%%Geo1fueOmU^Z9)9Am?gF zeRG?*)PPs%rBWWT+a?Q~DSotEze?J_2S46Z+#kCGLw zXyl}T93CTewuTeX(|L^nbDJ`o>#43kR}RZ(|5z*UCf}j3s9M|7XJ9%_SKr$Y6(=n7 zvf8NUi_5w4oD3}w>z~9@SM5={2T8oIbFUT6wHa;&JzcH@Z`hM-5nlP{p}UvH^qQ3g zb=&?N4S@(-{De^5>(#9;^^Y@!l5HRSgkz4k@^&9d!m0v7{7+GWPs`gepJv!xCthCM z{45mkmY(W8O;g3%n*B6C-dq^N0$ZY2bRr$ zI)5u5elGV}MO6LsdWF$8c~p9hx3IYUcN)&I+|oaTIL#;Hk&hvr0tbY(MKK-Qd)9hf zB7g2ADz<%4OFT}eG`XI@(EHhBBes1Pzc&_kC!NtV@$c(AIao|yqkHz6ZjL0~yq%}B z1_bdkj*L81Uk3D%ePUV0WEv?+fU!M85q@!Zxje040PY(?UO_a$W1!m7MU*h0@^W0- zWo-B=XG^*X`6M)yw!7ee7m%U@j9IJ3AGr!^#l9Vj&N6_Fl#WU{3B_wjrO7a(_!7_i zkEiHI`!=lwn%-7A&{l(~_IJNO=?uxy5DyL7J5IUjvMp^}T?|*eWR>b}=Uoo^U+(>x z1=AJ{xn*XqI9Q97{1*ZiA54#bmf<$d8pR&*Y?`{@TQt1PYH{@jKL{J=E`O4pK$$s6 z>*alJQ`0Ir&-m@4F7d0!VSi4~8UdtT?eWe5+mxs8xnz2^;kvT~gO~1i92U8@eC|c; z1pPE46|bR$>+VB8GcMHygHJRg*dE-8?m)Qqzv7P&_#Sbh{4Gpnxv96@P=c}f4#XJ` ze|-aIJLHLlJ!D;yNT){nTb{ob8-25B zqz%^y=x_0W%A`~0Vz>6q`6GJuWB*IBSsK7Jzk=PCC_N6dsH+~*{b$X1YUSQ@lv>BZ z6&$l7A+zY*>!2xGRl6r=AO=$P`H_GL!*!W&SH4v%(-9G-}5Mr;Sc$Z zTRbs5(JnG>P{3&@V8sQpw@q;x$8q0islu$GuC~3fviWtnXz<4$VaUf%pKuKrOdl?r zy|Rlejt)Gsc#4_dl51b^kJ%TCN4t6T?_PyiVF3PWw!zL~#;xdf&jFmFteT3oN+7c4;ovu5Q@7ls6DXOm4Bd z7O|D_?O!$7Ar$jJf$J^-o)444zvm!Wq4Gu_>kLL{Erk&zjL|TAt-1}E9bZM6t%sOU&H1}?R01@B;x!}KB2o=DRej=wd-+vvxpaf) z;aB(pUTaxT-e>T+Bt5b+d30R3^D6ire0q>bx|NM&(qy=E6fo!j17H4NYBTqR#9VOOy}A zm)V?MM7^xO#z%1V2m+~lkKFyDz-g>3e##%PfXQsD;-O4&6-q0kulhxc+iJHvg`WJw z-&d9)FE`^}99Pa_2X0SU!N0TU_0<63q-O4MlDnyLubcv(vneH%`c>Podidx zi0hNEF{_Tk%Zo2HaqfO3>yde)feg5X@0D@#xCI?~jpDjYDyN7ON{>A|Pt3Z^^--p) zqO|q#|DYK!j^RCN;k?Yg2K9n&tIqM~{c@qYWUV(Oyu~ga)^5V4i_HYxg4#$yb zn%rdP1Em+n(KD!2cXOCMAu{Gb{ z!G}_{mTwPcP}3~uSh2|hxgn&^3u)BII36{{sD-mj;c0Ar^?>3T3FeT@q;fY06ueH#gow5@rfH?9Yf! z>hv>^sG55XPdm47Zdv<`tCKldb1(5~#1uIUV!GXB>ol)B{c0R7!r%2d(?#EHdVuWf zKHYnwWe0ztTk%M&DK|DincSmue4P;T4s`j#&}Z5*ek|SJYHCSGo*{F7$GG_{DH8E9 z(N!+#!MFL!LzzVu)-p63pB(rIprEDGQ2Zxwi!o>e*_zQqm>cD;Lbt6E)hKB(E89WY zhwG%{c)40MwG=*J-E>~L3OoUj3|rPe3SM^VXK^GN7f_%BM6z0Tt&bVI|#0Xt8kTOz)8GlC6Id)Mjv+;8gUa^xiUDaV65UiXa+ zTi9~!b?u5Avu?+&Vf}ZTJberg6Gk6DrZaP;IG<(eu-boXtOr~bx(jF*ST>*imp4-i zgH?F~4l4T!o?TL0>UrbhUe-%ekrlT2xXRUps}a-nwmLJ`!8rbNvA1MRDUu^QKBsqyv zS%;&;O}ya_^0Lm?aZVi2w3YC=>S}cleJb~W$+NaE7l6>+cA8c;;aDldtfs2*;7#*q zKZkr|Bg>>shJGjyhI(g#DwD^bhe*z9cTQ86W5M=D<7JL+Ilgss2^wha>TVP3ocfq1 zMOT>STl(Kbz~Mt`Kg<}z*wmecb;H=>q%?`Bu(EETx29h4dL3q|82pQZys!>-LkLE!tOLnRxkF1ojf| zo4N{F6U8WoNrL4pIpe-J(_7=eDOar_gtCyqsO z>*9qim2&gSu6eaak^vO_1Ei#ZV%@l*O{9`!vuQW3Yqap>X3r}m?hCcY2BPACV(u^0 z2IA!lFk-UriQ)CU*FSYl^j}K^A^<7*>sx+@gc`Ynd(m|zACtkXFEc}lyG-3`FcWmk zIr}WY6A#y4kfG_S%YVE}7#}|WisF)rxS1ZfZGB!xG>Nm?(8RUv6y=}^nXrUP-Dhw` z%y0qtmHzO??Av8iSZ7ik-%I8ZsTW4j6tW%j^w5hgbQLu_H*G(^gezH=tL`^lMjad? z4vU=@QLAkY1?03}HXQqVHtds5;jSp!qZGb_6zU%JMGI>aU=wp9%JU9Xyt|0#uGZVL zzJL?DxN1dxo*Wl(UD+~;?1CCTU=oC)xjwRT=eOWqegS=rHmyA)_NR&C=0|R9FbT$H z7$-P^5*qJtoxg|zW>rqV0>w+f(SvVBNeez%4vDNP>rbbPSHf4`Ho$8VueeJE3@?4E zmx!@J>OoRp3Kn5gf>vGJII(z#Kl058ocFi%=Z?eK+c02A^e&aIbl>Gmb!G}d1c$ko z&g*Tl-xI&HL;9mVh{1uYB!dD_DBhoo*cnqJ}qthoi{#vUG9c(rmVU zhhjkzZ|x?&FVADX8?k9T&j=~Hdvd4>^)N0C0*6vK@Px3*K zTnYontM5SQRNoCCE|zA;5Up2OvX@&vhPB#L9z(jyhX+$Rg-YippM7^v?Z*AEwlDQh zq8G8TfWP2nT^59JKfF5=`mk4BevE4#9(T&$B-vbW{f(<0TBl#tu3S{3vE>eRfy^Ca zvIVDK9;PVef=&g^KSAn@MO9XDsiR39Eb4WIc#C~|G`BFJ$|b%3a(u&{u3m>~JC`)u z8#F^|T;Uc;v+pVVF!`BYrqIagYu>(ANQ=C%h$PeEmp}|}jK(5xI2%x3RfpIKaL%d+ z*S4%Vfnt*gSER#Wkr5xzl#q1`ttrg7wD$<3>3SCv;jU1MWF^g#`!$m{p01**FNIv8 zOKIkW6m>^X|AG?&GLeUA-q2CUTjEaiFs@qv;vDx;Eh1^j)V%aKXUlN4a*V!l-BUnj zoK16)oR-XT7IO_(>Q**ZL2Q|GqTV-|#**jYeNSb<9-$#Zmb9ba7P@)o{I-0Unt|ZP zXV44=qW>JU2Kn~{cu+(yO;`6xI9BQ_f2hb}d_jnxc zm4IH{kBc{+Vk_xXR7afxHNLh@V80pis%od0-1~f`%HvgTLd7;7`_Ga6<3U6ub*B~A zzOo5H76Z7aE3KM|cpc2O(b+#sn@n4ad(qcNuR__LW>zg{PH>=H;=WTrhXI*r=RO!*(!i?sY7a+o&AAD; zfm!>N_xb2#(78c9RR+%w5f;CQ#3zWF{7eKs$B@N>F+)SH@&;8)-)%(}i9a6FP}-c5 zj0rrksW4>*`wP2V=Bfmba+vl$k`E(#(j>T&LE^DP z{E_Se(Jc9vPOURcy!_XM$WAzIL1{MK!$vb-+x>0HZd^23tjY8bj$7|J1lTpmb28k>zxAH=wRssybE1> z2hoB{3sN>D-fo>jvFaIix>wy>1`b7~mgdznO7}T^w{2<)Qm|8`0F~c}?(whG${)~Bv_@nh;Sm4xgw3^5& z$`VcE`pm_KJKZxnRb&Olr}QXls@b6p2Pe6GYRj6WRFr)`+lRTb>@K-Lck4_v zb4Mh{RvWi#?!lHRJGMEi%~hdWye1Ra1Dd#4tpBAbu`MM%yC|yGdDj?mHv)nHd-0F?_1cR^0Jyf90U90yp2X%lz z+*;}WHeK3*x^Uxn1%OulyPNr32;ng#9pP8}QVMfJXag?{sKF+c(#8L6;;V!J*}gT1 zTN=V&Oa&?C6tUc5gyt#AlFF^`e(I4nJZ^9kd3l9dv_zD+axpK%`SPei`w2poTr{GG56obB5^U+^xhB__T{$4~!s99-o zG#Nt%OaCan-RNtEIyIN_DQUop<{SlPTcy;6+NefJ%*iT*G{!oUc1Lx=N2~vtccjH4i@3gt_ zjv!?J3b+olj-OU3>mKCwE`}&ZYr=5DHGoYgjB4I*tb`p|M@uGp=sOSmM&wkBS? ziOISJSd_~d?YwWE-U$XJr-&VKO`D4;S#pvv9zOfymloXq3rgDhaB@47&H*0|d>_l& z(t>Vdhj@Jp>CqzNcYa{X+bWD#i>pfl+JEC-9qAT7TqI6VdIW%Q1e#P`2&%+LdD?zO zD=WY%&3rx~L5S6CuF6)No6=J`fRp(MzHbYo^l($O@EEugkIy?4;{ANBWS0U#{{vmx zR+B9r-Qnku^ehTbyytpA=>1=Hka$q0C+~~FAy0*zuJJMpF#5OO`|vT8C@%&}c|NR; z?I8U+!I(&JtG{adPj$4YFgY82JW*@+~UR`)cPs6wNp!@lLEAH-BDXtLbdqb;4ww*lofi**`Y5Bl(fOc!R zry5a{cq)QzJ>l;#j0@%_Rc07$KG76}q?qrsow#(ba7~ETXdE0flDBfg6=wQA~Qvc`>xJnz14BMlMMO4RY zR5&E&u(?fY10(cam*H`+jx+abFSh>&h-t+^b)~{JxT9-OJtx$6&tk3r`If-B2Bp{= zBegO@>${yx`pU#ih5^~P$MCmpzQZ3?+>3MCwf3yU&6&mB z1!zw|%JUf74xENrxaS7EouMcb71d0v8n#%DEFcE~XWGHLN;NURlQH7>O`T%*Ub(lW zA67Q;pVH^et1xpYof^b#6nmDH^|h|~vO|${-INk~fVRba6C?479T8=8bKCmT=WxND zeZR5fffX!EP`{^xL?n^d8fK8n!;Hg5(!O`-Q^PYN{Xf+&k(|Tej-UW|?ie>eeHGQ+ zLX8JL<$=F`D9nevUq>@ zE0}6w84Q+1`E3T7qm$JUwu?_en}b7mLpMDev4|D@l}O#b56p&uRY&n_-4qVrw(&ee z^fkzbmaMfv(R-tt0pl2^9%FczzNFuKA(iuI1+~7fbyUTFYzfsLuVvqCLX^WiqS5S3 zlE(7+$QFV0B**=c?Q{TNbEzArUQAqo&1*NUJEueISMpmG2eHXZ7T0(-`E<7gbnjZo zAZU|(!D60KUyUrvA5PBC{GaBDchCl;0lAq9Cx;2t4oFZuG6@k!*N{sUS>gua)${Ej znI>E-ulE3AeT;+*t!8V$XjZ`ZS;j4-k7+6b7-fQvbRPWdG27R$6sLNN`#&3ON2Z?T zP7ppL>CC8^Md6=ScZPV*V#?#BQr1Z=;Q9nMm(m85356A#C*uQi>%~_BeU5Fok^&6^ z6&~B(!x=97Zy=Kbia^*G?^5OZFEVE}uFnRlRj7IV@28F}WV4DA)GTrC&ZI=MwcM|G zdfUr6qQhdqH0Pus>f>>>*5ilJ(IeK-5;D5UU0a_P2J8$R7Tc;Bz)PIs8aQ8svH_9E^>4r1^E~Y;^kho zi#!js&(UC;rdUGTMz!4t<1g3^Jo}bEV@tJ)3}Zz}-@057AiapCvchq*g=jynOk36R zn(ibDX8_M!z1}`{Knrr);~q>Axg3ko6k;XVgF(`sGTW%0rix( z@r~LVMB|WZW0p|K;s&W|o7?E9=)1!>6|xI4iLRLCB9CQGD`Jv(2eP(?qLY|OL9L3z zEj-=*Xy!f)AqL6OBe`I@Ml|)ukm6&q8@XM+^u$@-Y))K8rOwxu%xCwkxjG*`?x`zksKMuR5Mh= z-ZZ@PqB~KwI6zirORGy`TV<^l=LUfmV$O;|pV1M;V!g2|M3H!%Qop-B(tY=DeNENa z4%l?|NCI1$DnxQdc9HiUqiy)1+Gpv3_ZM-^x1q$ov|I9J2XPCi?R{pfhqYNm-V=g} zH#a2DVRR@-TNx1vQOWTjtYHAii(~<45m>PY4nc}WQIMw z;}V>n6ESuJ;+85aTQD)?XP%YWD%);){j94Z&FIK{!}{iL-4K#&8nUghs$v}XsL*cR z?B*hnJbY-!wZj`PC4PmUP1FQBu&6+L^{D^2-9|F_i4n<}g%vSAZw#8LP$Y;&UrTIB zd6LA+$u}nSnObCSzQ2>ac#HyEhbTUq_}FV`FH7Yp2HqI`Dms6T1qbuF3fOHmYvwX6 zhfY%zA6-W+U>41xXN0I2|F%$)jmbPJcR7P$QUHp+NJzefMFCBcc6^>;Ur{X4Bq!Uh zw=M~)4sM?gMCUi*7Z5#}zim8uze6?1zua{@Pu_B;0fAUx`vkrR9D6_f#h$nUx!BD% z<7~P&xp^k8mH!!;M!8&%>6r8f7Rz~_c)(k19qI824H-_6qsEHHncg?-o@d3-+S@X( zERC;WjhUfgHRTbYQaa4IKRfx@m0M4>0WDi#guj?|{<4Qf^(TT<+WJ z07USxKb>rxepxT!Wb^aqX^jcDW_uGSQCxovVUbZHRWUJ5>#0pId$Mhmlhieknfx_X zo2Ok#AI3~yUL3-ikhibc2*uro)<=_o@aKWGxM974B=fzlZPNgCdERJ|uZ72V=(Sa# zHM^9{icz_x7{H&mo)lCWGiMoLM5LOs1%S1Pjy-NV{AN^UUA&vXGoBY}EtzqLY*x#Jn)`X7k!bV3)o1{9NQ}Dj|*sROV*E6*^otRD<*9=g=c!9cIR6ic3 zOVu*WNhF)cXMTPm0t4YJ;?)O%o-J&?Gv(_x*n&HT=(Oqo; z*GsSx@8%EP`VwxNbnZg&pEQvN`#lj$i4E*Mp4Gel;Ws|UTNPfp`BQR>FDe%VlcA`D z2p5bKtNLiMftvt@%K?~>$HZdf)^?nn)?iBu>QY-0O`K?3*ri<91BBH@P zydVdluhJIFOrH5i>lHQC$idCsvwpbO^m%g4rA4H=_2Q7{Jt51@xy>IU4MlR_d;RfR zmXNzdvB*#7NVSci${)h`OVM(YDyhXDyLd*X_jMV@;)HNV(vxXQha3!bi6fNuQ?_Gq z3Fn_$;wW#IxW^-BWPZaiePuy&+9m0H46|s#WZIagCahzY&X1#u6ObI9E3)kBtaF6Q zzkvkGcx$E5lsN(MO8ajgS}Mr+Voo0?^MvubsFqMoNDq ziz~i?GEucu?|WV<~CBTph3FusV(1W>mwX>1j z2hOTwxy|G$ap;J50`;7!e#dC;`|H}!XB08f6lP7`ye&94+G>q>P6y(D?o`H>Oqq#r zP}!n|MR}?9A3~bL$DvNw=gpW*n|AbxN+#d<+hy5T=rsSceGHUT z5!g-X7w5OaX*%|b!!ML+ZWf=I)UyixG+U0qJ3%9hF-HxM~&f8!oiBR@pc88`5E^dp?pVDB-U1Qv$_+p zysg{xVx!4yR^9bsUSP(qCUfort4*?Gmr64wbup7kl&sReho>z2)#s1-E&RPB%okc>1wZ{jjLq8d z!^4hU?peuVEohxctw|qao#t3sArpn26il;i|?rYhpZh66fE|=WGN4)Q=NOw(6;1^r{%(8H0 zmq+@S5su14ntmiFV5GXc=EE`J4?H)VL`C}T{m$bx@No(@(}X=rQ59mf_2KV2%{K*w z!20{cqUh9wZ)%)3vv7tRY>`)pBB9cgNy z$L)Sq(kcByH!GqrI(^mYd@n9C+ z^w{>(SD}A_&~<5;r#P|s1Qu>|V|ZWB6fMsB4DDtG0xJ8zT&W5FIRT}5G^`zbBe`qs zuumh?Lp4}9banL1sDc_So(Rwcw0RjQPSJv2$5|D!LSBqrhYHj|);nYSKLqhQ`>5pW zzl1m_`dilGO?#Ca`m;a4zZGExOM*WS>?4?!D(0}4XWI&tB>DQgFH0)qQ^pC;j@$QN zO&0l7QHwUhz->})EYs(jgFLP{Ikm0Ti4A;t`IX1z7X_LADWd#!TEZ9SZrm@Y4Q@7| zSoeF)Vz?Z9U#b}v7H#_qzYL>j>G^u$QiWrWy||hVtm7Td1%kRU?5gfHvwz~jGfn#M zs$o5J)Vq?(W^g$B5)|`<;G3-f*LMEX5s>KjcME$c3T5UXkv3VrJY^O+ zp04Oh17}92;9gU3L#JQ887C(!;wEx022^-?hy<1V4gaIheh*&%Te17c=zish^wz=4 z1ke|b7If@3Tr1yp!{ZK3;foSKa#8nBXnnRzJ-P-*+P>`(`xi(eEALB`UvxWX*Mll@ zT-jI+a(>6=TbjMG}T457fb41A&9TPC|0!p?YU8fSG6ZfZ%yZ&B!H9!JK=6p$n9 z%-&WKMwffh2;DpR?1N`k^qJ5%3!E;uI`n>(iBuMx!x$h*Dwy)}^H-@C+Y|a4_-rd2 zwNf-d^mkX4TP71ze1@husKe2YB!e>k*XAKGm9p0TMpm1(N&8DEL$Yeb8TQqBZ!|qJ z$`{AWV3RW_;TH0}OSVt5{ZEBf02#Au=tL2303%kWZVsCy%y?)@UtB{Xe=Uc5g-!!i zd}f}H<9l!tLl1P~HD1aWmw;)WXVtIoO63L5ukPvdNak5&90gt|6nW?1Z6~o-pK}(-R7hjpX!Q^7Tf}A8}{RX!*zavPp2lUv=N;0D}yi z@~I{h1~F2X_z#qO(uB9W>bq)(H7w)@PmvEOEBpq0(GMdfXR!xU@AuHQ236N`GIFle zsldyte_v9+0;T{|C|X?GY?2O(G5O;bS>tVxza-?alzQ>RdOHcPs!TFoZ|#2vX6pev z`So4(!&fd?(0wYt%cpzIXNm}o_5`k;1k{i>Rs3c=kY65{a&ujPez6kG*HXZeXWf|K zOUikbeye&Pbd{wq2!CvkJ(GKjjxuxUtKKGFSy=>pwlxaZ9joK(E@eu0QAyN8Su!9Q*aiaFZue0J_WG=6(vuf3>e@XoZxogUa;UO1S6r z5O-j=qI%x1r9)}Y=kI2j#O{6qLvMdFFT1k*k@AK2An0!>tZ~`>h8mLQyT*IeS||NS zGObPqQgL5Q^k{xG775Dbc&g(rdJe*a?1zNBOi<@%<{#+6$az?@Vo3WVL@dmM%~72r z70{IwPCUzr;(Rr>3v7aWdPzR4qKcW*~ zGcz|kjI2Ed=w{TEo`)8WsohInh~uuPE7TaVn2dLqJtUv(LFTQm&B1ang|)-iHfr^&TJ_O!h-m6xC3&FnJM zQlyD9tD**zT+2^kmKUHneKSJjdd3AGI?fqXawI4EWX8)5-z(7Cq=q0PZSO7Nm8ZJ( zSXyqxePE}{qdx7kzx7B)j}+j#3w6NKhe_{^pqss~V?Oe2E?&Jf`_ljJDbAIU@69R_ zvje&Gm|9FcT)RMd)tx0&T;vSmvg8M_wW#PZwosY%Qt{XuC60iq$FmvIxk@wjT@6z~ zkd$ul0Z1}k_v&CdO|!qw%hQDZAK_7XS39xGljAglaoPnb+U}w%MCfq!IlN9m^r0YY zCbBuUPR(J-Po(Tx`ubnW7mAiD+j;cVlZ1%=Xb=`S_2C#j-l3?I5S=6q;T5M2>eN}% zIJZS>d)in}Q|#!Vwer=~vqe1rOM67zeH8C$;UBzXEme9c)J8jrap_mIO)xlSRFv|_ z_Gaor(L<Wtj?4W-Lp-hr)pI8IXFZMdDN;TnTphE)ZRX0w6rj(AuOl?K0* zRzYR_lskriMF-zIh+Dk|Y|5+RKY?8wrD`t&m(m`F`IN;F#yM5wY@bFkLIG)?2hRv< zbB~iqE2y@T*Ca4wnTHra+5H{8AS_7?A|hE4rZ{bg;Bpc0;wnQr^9edyox zUo_LKKYuE&dQiZ^cK&HylDDuQxv>G<2)bq099qIqx+pOzp*5Itwu?G)R@WZ#;vg^YA`cfjyhdK-q#pYQpEx`sk{ zI+?ZYNx9^vnVid|1JYb(ZrKHIt8|qyT)MGSr%7zNA!sVBLKH3Elsh;VA0Rm3S7{hv zu{i$FVdU58_gP%4DAOa(<4@!sPK(xHai(goyhy5t=GQ4Q;Lj+S0iFhgQ`Pd(yh$39 z-fzJVFH|myt@&kUuN717=3ItO45fY6&tDRLZqKt5W%QDfizI2Co^pObB!eSG8?qj2XD^l%J(?byLkU0h89E|%DCWSIxRdMS$!9x^<4!#O_J?hVf0AC3AbS9I zYuI;bT=&04P@!QgaBjm@`d-?aA0uh(1{QHzd(2`XOEZV@fZ0p`Z#yysTD9G(NQK)r z3hB3Op3xMK;`Tj4C}J60NV6zw+RgrripufmNuZqNfENPYSGF zR}&HMv1`o$95~cU)IwArqiOQC(WH;!@0;(b59+SKs#@l0)J@r6fonHe^M5A$D|;u~ z%4@q0j#qpFVU1_7uweyD(d>=8PG=d8I@g?Fd9VtakQyqp`gMfMUwIqZqA&5pcbx;E zwMKS-i0q0AWvsaoF8a0rE-VWdh5;!OiJE=m`Fa-_AB6fx^x)nXwCx+V;)Df|ZA;X? zJ^Y5knSV9Ig*(38QWU^6pGrZWk&k(xpqcsm4C)>dQ@}T@gor#1j`dxVafF z`fVf7ZM_D^^a9O0w6HmORKYSW>|M~F*DPDHV3vMGv zIZf3FdEYJ%OQZH3ryvx< z-w8C+z8{$!ZqYiId=Lw&xA%20f@o#|h^lAtbG|>qJuvYc+Uo=%)FK27D&_Hfpcli? z+$HF?pK|GDAt5wms!=~Xy4Y~fx+%6@9U40Kr-ZK^<_)}K-zTOU6e^W=vnH82F^2Fh z{9PA&+u?XsEJkj3<-AvL+U>rp3gA2)Z&C?CXK)rrmz8uamOIX! z4*GxEyUw_#vTZMxK`ihTlq(A8%qXJtB2rGUqaqxgfPe^y2pH6ebP~D+N5zZ-80vwM za_GHRFAFUTd%Q-)pb6PtM*2-odUJ zuv~8H>m!9H?uBzW@<3~EB9vs2Mic7!8HXo~wmk3*G!5-vwn{4`IqcNx3=r`TrH=MG zNJuk`Z{H^rfL7gCcLWp5Dds^}C;yCS%mnOx3Qwjf%QDmk%|gm3+I^^m9ymi9OWH2{ zX@Wet(5#Lq)X@fEJfRk+b=HSZQuGCuCH z8Apzzx54Jat8{FS%G46pI8cBi*&3pMv(SgCETFi)YJpR1R@-iZL zX6wT1vfstVz48aoff2HRlnH^ z7^Qlo1ooiQnY8ks`?@7jZ;Wmet=tE=?OR#7H{M1Q0()L?l=n^id4zMu*?m73dbD-3 z@J{&0@o_*~skMSVqWz6nLvh^_pY$H;b5yAd9<6+iSGbjoHZe^PFDrrS_-~^FOcDlK zX?ilcX5(ha&od?O41Pa9F)S?cU>~j14YEMG}UhMi8%x`H$-r z@f>VTiWE;_v!LIhGhmNzM6oy_dl&vrQ?gy;n^8>>a6Uz|;o?=Wu#!#vaNv)eHunb% zx)OIqhqO73_ua8TrWgHZ6wa%_|2IY7=4>TcU-R*@!x6NN$Se7-4Ursi@I?nTWe!~0cX+RUk?6JIZ|IEgRM$$nWG-iTj1DUP<+#ag~y zsDAZKG=<;d&s-~Zx#To;1s-kWrjVhibm)41rpeb0@xveRr$Kk~EbgmC-1ng8Llhwe z&W1N5CTF)BEbQfVzj7olhzvd&P6l3}>a^<(oEc|Vz21+zeCjHgZ?&rB3>FT%ClMl% z{Zha6YRr(rwcYG(3rYzAq7RyC2M!<1f&EXPuYi>Fu|OIx;CErzt8yEw-V>!iIn1kD zxdMeksgAqJu7ZkAh0ym6<=3CAD2&=a)?jn=|H^e(Pae_?Dm-ziSJP;%`GVU4kdPM{rZ3$4ezot1g(`B)=W;H$rFzFhe-h}lNRl#}{XHLPPQPv|CJ zo4V!4JX!Mjfp*GCM@+x)@d)8j)Zk>i31`%*+~!KIoit!LkPu2)bFfzYR9|dFU$?@H z(7XqiM^vxy7%KS38LtC)uj)BgyjpQ>g{nbH>^K>4YVgQS!R5+bzh7zS;i3&x@}K+x zRi)9?Ud9SaK86$#4pCnNhNIin@?v93zPd>cmS1wH6&e7%ke^xCz<4Ufdq5fZ=TsSrwkx{XKpKc2&@H*$5GQgA${Adx zaJJ|+5hDjHPltCY5ibm8HH<*zb3{DOsB8bxjY*B5A z#!zxL!jo)-@O0*7B?mnDc(~a6W=AL67*Af{7PhUt$b!4YaeRziK_Zj~*gw4i#Pqnx z>f0`*NVkeFVETk{s;K+UZ$~i}cSjh$jvX%%&gQ<@Q4Oo)0(J)UA{0!UA{2LXo$%ga z#=uI56yPs`iYy3@d6;bFzg#hX&K-esEW$s7HxlweCGEYZQ`X`+nY3IP5OVY>&EQ9QBY3#GC-j#0@PwIr%U2*#*P_U$H33f-Py@g)T6=j_>Dpwq| z0Yhie@0e$$(e^zhsom!CeUY`vr@j=90{}pW(=M_ z_RpcBEBQ8frwe7KgB^nDbDZU zL1V>NS8ygzjc_o)ln~bZdcx)^D4tbAxN7+}_wgNE**umWzryVM$+5|*VBxD7G}!&i zmQHy)O20L}E+Mf#VZEnqMh|%(@ya_ta*w6M9}D68f>f#SeJCQl|H%YLwu7}qLt_fN zUgK}bZB?)YIlpX?25&+FKSOlw4|Y5|fP)O*$SSxv!-BlM+k3RwH3K>OFNL`HuDN!) zFl+?|HQcrK^eWQ5$tS%lWy}{xxzaPad+IMWi`!s4zuO0^yC8V&RLM+z}Y+XJ_ zY9$~#4FSV#X0K?~FThOH>6ZCE7+>6w*lYy^|Df-TI7e+hhfIPO!FuIq zkDpVhOTX` zhr8U=1DSNz>aN7Lbja~W_cEPi`o>Y^%e_+S`F!D55j;~6v-z}*d_E6@ zH@T5{9oQ+7O2YLyr%8}-PV417=RX3gU9T^3CuCW<6aD(6UHrJH2q=Y9-9T5b_p}5@ zBo-eS>J#Qi_S7Fr4ajZ60Cs* z5Sp|?}Fa$bzmeH3Df#%a!Bu z1^^6KPLiWG=MxA}B~a4+@4-UVD2Oxg?H2?9>1u71pT*H#9-ZuPXN03Kn`(?U?6ALEuh2|meeGk~SOzZjK>t24t3 zsrSfaUQgw^Ye$eBi~ZEi)(PvL@ohXj<+@h=O=JxZp!35EVUOW@BnjNiuW-y8C7mjO zO!DD-f;?!kCDdvN*&n0;4w!H6vZec%e-#ojlJ!(O%(^Zi_e^@u4?0}r14l?(%+#p)=sfKxqd3j~LRb42L-dl_G+SdlA= zJCgoMLRL+jClqfBDhkXHiy>36mPcWl&_-5bH5HgEzJKs3pFg>`hD?hp4`9qs?ssGS!-kUyP&ld|C9_g~E4J+&PL%wmK4n z&)`Nljy52k!F4th8r;nOmLKiVh+C?Ygb=%gZ#)kNBtsmP2^`QgKyN$-Paq@c3Ezf; zt5Y`fqN4F>_(k#h7)fT=cmD+!tf3<6=M=+KQmU;H$IvmOgtMTCE=hA9q_p0%s@WHQ zw@278;sj<5%2@E@vP+;vYBPaAW#N^YxRiUFkXk40uPCzFXZZ#s4Oi|lRdGbI+ueR# z=f2PyM4Omw{v0+&z%3f1tiarDOnTW;;4G303a1w$Rfb|MYLlVYvQj(bj9K)r1~R-G zfxLg|;p<}^*f6-3gT(2$mh~izK>dY)V|b>g4Y(9SD6VtHB0ny#1iEe+;RX)($F@o1 zZh6UH`@`pwuF`!BPQ*@-_k;ur1Zu|FN`AVFGZ0V8I}P*45fzbPfS z>P(dLUAE+DXhOK`;oy+;Xwh_C0rsa)1Z!=sT>4HC;d@)q)9FxFJ9je*DK@Txtqf9w z$#{+)5M$v@Cf?uCS=}ZRfy1A8F2K*nf)p+)qDZRI{rtJjkD6t_8ELh1%qyYuFb zsnZpua2U;Cxo}^PzhrUgl!%TQ$uCFdDMUNlG7^N6h}fjA3L1s%x{Y#=*64uDQt40! zwZ+biqrSh{r|~rn?FLa9?QT{z9J=?XzLcE{nDORXHNv%E+-2LVAobYuSi)Q>9MF(C zX|mc~vof=~?w_6_GUE;V5je~zNAXr#u7GxP)vuvGg?{vd@Sg+KdMX(7dpCu%kPor_ zyU?KNwLjSHTZ@~XJhZ{rkV6b!(?WwPKRkt$lEk4CYRm{*Bpa8Oe8+S4UyOpVfksbP zKF!`evqF27_ZVuhKKTwd*dnMH(ar^h*WN-)dw%n@)-(c~Xh)_g=tl0k`lzpdL0i+y zxK$Ql2|~IwR&4z-a}HWWr8nOIY9Agf#4Xi_gDPjYRTQ!i*dd+H8*Z%0Pv)X}PbW#g0uWb}9 ztSdi1z{pM7`(c4NX8xHCC57FZ>YEsLXS#xla}H&DnP2bz!)>|y2ye|nG`;HFx6bHQ z9=wGLvESJPR7%uI(e(F7tjI|4cbU(RP1|7B2C2)X}F&4D=T(AO9#;)MfrhHKkTn(L}&=8T0ss>{Y5bxMy z54We?JT$C9n#pH3_DyY;LB`ZAP=x9AvZj=D8!4TTlSC(Ohf|FHxZdU)4IL|nmO1Z@ znzI>vvsb}~Jw*SORROms{Fe5xE-`<(s|<>1_NT8MrB#a-#ayTG-Ct(r@4tGK=2h!F z6<}lp?sLv&9*FO};22!CQniB{ldnTTTY=#XpCK;_OQJzQVQKM$oiFoQ@ zktlHQlF5yJQ(t@kvGQYAYO5bbb;DZr$FpLO{~E2|HD?!)3Oe2Y2jTk^vI<6lKo6d#Alb13dGM6_u&V0Jg6zqhW(CyXc>k(gMlT5N4e2yIEOwMK+KXuDKum7CdZowdLdw4`PftE451w&cziKUi{AcSYP+ulpebM`g*yHsj*Hm-G z8BJaJm&Utrmt(VG6OQ+X*&^g&EeTxF`EasSYxW$nvXcaL)R1;1>+i`+KnmLZw@>J* z^>OUxuMwV485#iRRbCIVI{J0x%&p)SZD#I8`C4pPUoU#&ufTcK=zNpdBjw2KBs^nn zW!v?8MZMGkq#}F=-(yWSlEsq``bi)!b#rHe{BSts)Ov=;R|pL%4o_5Vjk~5O_!jG5 z>Gt7w*5CyzR?n{u5t;C@*^5K($EJpM{Bi+UzC&9*{`H)&+By%J%wIrS=evMF{Wo28 z+nal0o`i`3OosKHaLtS7L7A(3g}|U&pREBaBUJ7Al%b$|-6ouK0r#5cN4tWgu7Si) z+U1S{h1GYeN`qJ4sFf)%7Sq+Yyn~9~xXjkUITp{dlYb5#^Z9D0Fl?>jiwlyu1_rYR z9fknIH`UsO+>n>qV?}pUP{Qdl|b*h<(B+bfs0FkiA|ZG4W4 z&Hv0q7I^2*un)K8An|+xp#0ZXZm!k0bBV77Tf%-vAS;)mAj47kx^?$?y={s+n+i+?|MSKFzaPc@|9>25GrYFx+|zIV-s0WcHf-2%*zdf(-yK&!cM~@scjRM(o{p}u zrjC)Oj-id7j)|eZiN3*c9UT)LotgT2>;J*<;NcyNNAUm75I4JN5n(vGE&=1_X5#kH zJJ<)~dDm~l+5dbyc-q_hzWc#TST`+=gX@y*Ee!_|9>xFg+;R7H^TBxgVIE>PoWuBf Y-**jG+#g?qkZibg-uhh8nOjl+4a7x^e*gdg literal 0 HcmV?d00001 diff --git a/tests/test_common.py b/tests/test_common.py new file mode 100644 index 0000000..7c84b29 --- /dev/null +++ b/tests/test_common.py @@ -0,0 +1,55 @@ +# Verify Tweet verifies tweets of a public user +# from tweet screenshots: real or generated from +# tweet generators. +# Copyright (C) 2019 Preetham Kamidi + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pytest + +from verifytweet import common +from verifytweet import result + +def test_extract_and_parse_empty_input(): + """Test extract_and_parse for empty input + """ + with pytest.raises(TypeError): + common.extract_and_parse() + + +def test_extract_and_parse_invalid_type_input(): + """Test extract_and_parse for invalid input type + """ + with pytest.raises(TypeError): + common.extract_and_parse(1234) + common.extract_and_parse(None) + + +def test_extract_and_parse_invalid_input(): + """Test extract_and_parse for invalid file path + """ + with pytest.raises(ValueError): + common.extract_and_parse('') + module_result, result_status = common.extract_and_parse('123') + assert result_status == result.ResultStatus.MODULE_FAILURE + + +def test_extract_and_parse_valid_input(file_path): + """Test extract_and_parse for valid file path + """ + module_result, result_status = common.extract_and_parse(file_path) + assert result_status == result.ResultStatus.ALL_OKAY + assert isinstance(module_result, dict) + assert module_result['user_id'] == 'pewdiepie' + assert module_result['tweet'] == 'ey send me stolen pdp wave designs' diff --git a/tests/test_image_service.py b/tests/test_image_service.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_search_service.py b/tests/test_search_service.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_text_service.py b/tests/test_text_service.py new file mode 100644 index 0000000..43bf40f --- /dev/null +++ b/tests/test_text_service.py @@ -0,0 +1,81 @@ +# Verify Tweet verifies tweets of a public user +# from tweet screenshots: real or generated from +# tweet generators. +# Copyright (C) 2019 Preetham Kamidi + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pytest +import datetime + +from verifytweet import text as text_service +from verifytweet import result + +data_parser = text_service.DataParser() +text_processor = text_service.TextProcessor() + +def test_get_entities_empty_input(): + """Test get entities for empty input + """ + with pytest.raises(TypeError): + data_parser.get_entities() + + +def test_get_entities_invalid_type_input(): + """Test get entities for invalid type input + """ + with pytest.raises(TypeError): + data_parser.get_entities({1}) + data_parser.get_entities(None) + data_parser.get_entities(['123']) + + +def test_get_entities_invalid_input(): + """Test get entities for valid type invalid input + """ + with pytest.raises(ValueError): + data_parser.get_entities('') + + +def test_get_entities_valid_input(): + """Test get entities for valid type valid extracted string + """ + test_extracted_text = """ + + Elon Musk @ + © @elonmusk CC ¥ + Ms. Tree caught the Falcon fairing!! + + 1:21 AM - 25 Jun 2019 + + + + 2,174 Retweets 42,613 Likes oO ome C wo + + © 10K fT) 22K © 48K M4 + + """ + test_result_user_id = 'elonmusk' + test_result_tweet = 'Ms. Tree caught the Falcon fairing!!' + test_result_datetime = datetime.datetime(2019, + 6, + 25, + 1, + 21, + tzinfo=datetime.timezone.utc) + module_result, module_status = data_parser.get_entities(test_extracted_text) + assert module_status == result.ResultStatus.ALL_OKAY + assert test_result_user_id == module_result['user_id'] + assert test_result_tweet in module_result['tweet'] + assert test_result_datetime == module_result['date'] diff --git a/tests/test_uploader.py b/tests/test_uploader.py new file mode 100644 index 0000000..018a171 --- /dev/null +++ b/tests/test_uploader.py @@ -0,0 +1,57 @@ +# Verify Tweet verifies tweets of a public user +# from tweet screenshots: real or generated from +# tweet generators. +# Copyright (C) 2019 Preetham Kamidi + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pytest + +from werkzeug.datastructures import FileStorage + +from verifytweet import uploader + + +def test_save_to_disk_empty_input(): + """Test save to disk for empty input + """ + with pytest.raises(TypeError): + uploader.save_to_disk() + + +def test_save_to_disk_invalid_type_input(): + """Test save to disk for invalid input type + """ + with pytest.raises(TypeError): + uploader.save_to_disk('') + uploader.save_to_disk('123') + uploader.save_to_disk(None) + uploader.save_to_disk(123) + + +def test_save_to_disk_invalid_input(): + """Test save to disk for invalid input of valid type + """ + test_file_obj = FileStorage('123') + with pytest.raises(ValueError): + uploader.save_to_disk(test_file_obj) + + +def test_save_to_disk_valid_input(file_path): + """Test save to disk for valid file object + """ + with open(file_path, 'rb') as f: + test_file_obj = FileStorage(f) + test_file_name = uploader.save_to_disk(test_file_obj) + assert isinstance(test_file_name, str) diff --git a/tests/test_validator.py b/tests/test_validator.py new file mode 100644 index 0000000..efe08d1 --- /dev/null +++ b/tests/test_validator.py @@ -0,0 +1,68 @@ +# Verify Tweet verifies tweets of a public user +# from tweet screenshots: real or generated from +# tweet generators. +# Copyright (C) 2019 Preetham Kamidi + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pytest +import numpy + +from verifytweet import validator +from verifytweet import settings +from verifytweet import result + +app_config = settings.app_config +app_config.SIMILARITY_THRESHOLD = 0.6 + +def test_validator_empty_input(): + """Test verify validity for empty input + """ + with pytest.raises(TypeError): + validator.verify_validity() + + +def test_validator_invalid_type_input(): + """Test verify validity for invalid type input + """ + with pytest.raises(TypeError): + validator.verify_validity(None) + validator.verify_validity(list()) + validator.verify_validity([[]]) + + +def test_validator_invalid_input(): + """Test verify validity for invalid input + """ + test_numpy_array = numpy.array([[None, None], [None, None]]) + with pytest.raises(ValueError): + validator.verify_validity(test_numpy_array) + + +def test_validator_valid_similarity_matrix(): + """Test verfiy validity for valid similarity matrix + """ + test_numpy_array = numpy.array([[0.7, 0.6], [0.5, 0.1]]) + module_result, result_status = validator.verify_validity(test_numpy_array) + assert result_status == result.ResultStatus.ALL_OKAY + assert module_result == True + + +def test_validator_invalid_similarity_matrix(): + """Test verfiy validity for valid similarity matrix + """ + test_numpy_array = numpy.array([[0.1, 0.1], [0.1, 0.1]]) + module_result, result_status = validator.verify_validity(test_numpy_array) + assert result_status == result.ResultStatus.ALL_OKAY + assert module_result == False diff --git a/verifytweet/__init__.py b/verifytweet/__init__.py index 6dfd0f0..ef8a8f8 100644 --- a/verifytweet/__init__.py +++ b/verifytweet/__init__.py @@ -27,5 +27,6 @@ from .util import result from .util import uploader from .util import validator +from .util import common __version__ = "0.5.0" \ No newline at end of file diff --git a/verifytweet/config/settings.py b/verifytweet/config/settings.py index edb8dbc..e364a84 100644 --- a/verifytweet/config/settings.py +++ b/verifytweet/config/settings.py @@ -43,7 +43,7 @@ class Config(object): FILE_DIRECTORY = tempfile.mkdtemp() TWEET_MAX_STORE = 150 RUN_METHOD = "cli" - LOG_LEVEL = logging.INFO + LOG_LEVEL = logging.DEBUG if os.getenv('VERBOSE_LOGS') else logging.INFO class TwitterAPIConfig(Config): diff --git a/verifytweet/services/image.py b/verifytweet/services/image.py index e450b61..ac54e17 100644 --- a/verifytweet/services/image.py +++ b/verifytweet/services/image.py @@ -46,9 +46,9 @@ def get_text(self, file_path: str): if not file_path: raise ValueError('File path cannot be empty') logger.info('Processing Image...') - new_file_path = self.rescale(file_path) - logger.info('Extracting text from rescaled image...') try: + new_file_path = self.rescale(file_path) + logger.info('Extracting text from rescaled image...') img = PIL.Image.open(new_file_path) text = pytesseract.image_to_string(image=img) if not text: @@ -60,6 +60,10 @@ def get_text(self, file_path: str): @staticmethod def rescale(file_path): + if not isinstance(file_path, str): + raise TypeError('File path must be type string') + if not file_path: + raise ValueError('File path cannot be empty') logger.info('Rescaling Image to 300 dpi...') new_file_path = file_path.rsplit('.', 1)[0] + '.png' cmd = [ @@ -67,5 +71,6 @@ def rescale(file_path): '-alpha', 'off', '-colorspace', 'Gray', '-threshold', '75%', new_file_path ] - subprocess.run(cmd) + completed_process = subprocess.run(cmd) + completed_process.check_returncode() return new_file_path diff --git a/verifytweet/services/text.py b/verifytweet/services/text.py index cfe166e..0768aa5 100644 --- a/verifytweet/services/text.py +++ b/verifytweet/services/text.py @@ -35,6 +35,8 @@ count_vectorizer = CountVectorizer() stopwords = set(nltk.corpus.stopwords.words('english')) +USERNAME_REGEX = r'@(\w{1,15})\b' +DATETIME_REGEX = r'((1[0-2]|0?[1-9]):([0-5][0-9]) ?([AaPp][Mm]))\s-\s\d{1,2}\s\w+\s\d{4}' class DataParser(object): """Parses data from extracted text @@ -68,10 +70,8 @@ def get_entities(self, extracted_text: str): if not extracted_text: raise ValueError('Extracted text cannot be empty') logger.info('Parsing data out of extracted text...') - username_match = re.search(r'@(\w{1,15})\b', extracted_text) - datetime_match = re.search( - r'((1[0-2]|0?[1-9]):([0-5][0-9]) ?([AaPp][Mm]))\s-\s\d{1,2}\s\w+\s\d{4}', - extracted_text) + username_match = re.search(USERNAME_REGEX, extracted_text) + datetime_match = re.search(DATETIME_REGEX, extracted_text) if not username_match or not datetime_match: return (dict({ 'user_id': None, @@ -84,7 +84,7 @@ def get_entities(self, extracted_text: str): tzinfo=datetime.timezone.utc) username_end_index = username_match.end() date_start_index = datetime_match.start() - tweet = extracted_text[username_end_index + 5:date_start_index].strip() + tweet = extracted_text[username_end_index:date_start_index].strip() return (dict({ 'user_id': user_id, 'tweet': tweet, diff --git a/verifytweet/util/logging.py b/verifytweet/util/logging.py index 8fa08b8..63d51ab 100644 --- a/verifytweet/util/logging.py +++ b/verifytweet/util/logging.py @@ -22,10 +22,10 @@ from verifytweet.config.settings import app_config logger = logging.getLogger() -logger.setLevel(logging.INFO) +logger.setLevel(app_config.LOG_LEVEL) handler = logging.StreamHandler(sys.stdout) -handler.setLevel(logging.INFO) +handler.setLevel(app_config.LOG_LEVEL) web_formatter = logging.Formatter(u'%(asctime)s -- %(levelname)s -- %(message)s') cli_formatter = logging.Formatter(u'%(message)s') diff --git a/verifytweet/util/uploader.py b/verifytweet/util/uploader.py index 37a17b8..aaca7ff 100644 --- a/verifytweet/util/uploader.py +++ b/verifytweet/util/uploader.py @@ -37,7 +37,7 @@ def save_to_disk(file_obj): raise ValueError('file obj cannot be empty') filename = secure_filename(file_obj.filename) if file_obj and allowed_file(filename): - saved_file_name = str(uuid.uuid4()) + '.' + \ + saved_file_name = str(uuid.uuid1()) + '.' + \ filename.rsplit('.', 1)[1].lower() saved_file_path = os.path.join(app_config.FILE_DIRECTORY, saved_file_name) diff --git a/verifytweet/util/validator.py b/verifytweet/util/validator.py index 95f9c8c..c2eb457 100644 --- a/verifytweet/util/validator.py +++ b/verifytweet/util/validator.py @@ -16,12 +16,12 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import numpy +from numpy import ndarray from verifytweet.config.settings import app_config from verifytweet.util.result import ResultStatus -def verify_validity(similarity_matrix): +def verify_validity(similarity_matrix: ndarray): """Verifies validity of a tweet in similarity matrix. Verifies validity of a tweet in similarity matrix, if it crosses @@ -33,8 +33,12 @@ def verify_validity(similarity_matrix): Returns: A Boolean representing validity of the tweet. """ + if not isinstance(similarity_matrix, ndarray): + raise TypeError('Similarity matrix must type numpy.ndarray') + if not similarity_matrix.all(): + raise ValueError('Similarity matrix must be a valid numpy array') for row in similarity_matrix: for column in row: if column > app_config.SIMILARITY_THRESHOLD: return (True, ResultStatus.ALL_OKAY) - return (False, ResultStatus.ALL_OKAY) \ No newline at end of file + return (False, ResultStatus.ALL_OKAY) From b9d4e6b17c449cdde9e9147624f2480bf5f00ad1 Mon Sep 17 00:00:00 2001 From: Preetham Kamidi Date: Mon, 8 Jul 2019 16:34:05 +0530 Subject: [PATCH 06/10] Update: Tests for image, text and search services Signed-off-by: Preetham Kamidi --- .circleci/config.yml | 15 +++--- Pipfile.lock | 20 ++++---- requirements-dev.txt | 62 ++++++++++++++++++++++ requirements.txt | 61 ++++++---------------- tests/conftest.py | 13 +++++ tests/static/real-tweet.png | Bin 63875 -> 63875 bytes tests/static/tweets.csv | 6 +++ tests/test_image_service.py | 58 +++++++++++++++++++++ tests/test_search_service.py | 68 +++++++++++++++++++++++++ tests/test_text_service.py | 96 +++++++++++++++++++++++++++++++---- verifytweet/services/text.py | 2 +- 11 files changed, 328 insertions(+), 73 deletions(-) create mode 100644 requirements-dev.txt create mode 100644 tests/static/tweets.csv diff --git a/.circleci/config.yml b/.circleci/config.yml index ef9200a..94b9f0e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,24 +3,25 @@ jobs: test: docker: - image: circleci/python:3.6.5 - environment: - PIPENV_VENV_IN_PROJECT: "true" steps: - checkout - restore_cache: - key: v1-py-cache-{{ .Branch }}-{{ checksum "Pipfile.lock" }} + key: v1-py-cache-{{ .Branch }}-{{ checksum "requirements-dev.txt" }}-{{ checksum "requirements.txt" }} - run: - name: Install requirements + name: Activate venv and install requirements command: | - pipenv install + python3 -m virtualenv ~/.venv + echo ". ~/.venv/bin/activate" >> $BASH_ENV + source $BASH_ENV + pip install -r requirements.txt - save_cache: name: Save Python dependencies cache - key: v1-py-cache-{{ .Branch }}-{{ checksum "Pipfile.lock" }} + key: v1-py-cache-{{ .Branch }}-{{ checksum "requirements-dev.txt" }}-{{ checksum "requirements.txt" }} paths: - ~/.venv - run: name: Run tests - command: pipenv run pytest + command: pytest workflows: version: 2 diff --git a/Pipfile.lock b/Pipfile.lock index 7a0ca14..5560164 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "61ec6f5c7a3511a046ba747d4255bb99a2624b774cd129294cbddebcafcadc6b" + "sha256": "ae28f6c49a24e8caaccc5a386912b4019df2b76a9444fef9b26baad4199a7ed8" }, "pipfile-spec": 6, "requires": { @@ -877,11 +877,11 @@ }, "hypothesis": { "hashes": [ - "sha256:22d2bfb030baea313ca3f31d41ba0f0038d5794752d3947e2188ed67185471b2", - "sha256:adbd7cdb12d8c3f41f95d63b9fb0b91b2e11f636079793a49135dff5d0ee1bd0" + "sha256:936cdfd8c4db60c0d86bd57c9381e59c3c2b73bc00796f13d2e29af71513d77c", + "sha256:ad2797130be83ff374c1ed2781fb591b4152ae28abda28dd57b2a84a3fc1f5d4" ], "index": "pypi", - "version": "==4.26.2" + "version": "==4.26.4" }, "idna": { "hashes": [ @@ -1014,11 +1014,11 @@ }, "pytest": { "hashes": [ - "sha256:2878de8ae1c79a62c012da6186b88ff0562ea96ce29c4208d2a9b11d9f607df1", - "sha256:95b700cf21ed5b7e91bce7a6b5a573b2e3ef7b3643d00f681d8f9c4672f9fbdf" + "sha256:6ef6d06de77ce2961156013e9dff62f1b2688aa04d0dc244299fe7d67e09370d", + "sha256:a736fed91c12681a7b34617c8fcefe39ea04599ca72c608751c31d89579a3f77" ], "index": "pypi", - "version": "==5.0.0" + "version": "==5.0.1" }, "pytz": { "hashes": [ @@ -1191,10 +1191,10 @@ }, "zipp": { "hashes": [ - "sha256:8c1019c6aad13642199fbe458275ad6a84907634cc9f0989877ccc4a2840139d", - "sha256:ca943a7e809cc12257001ccfb99e3563da9af99d52f261725e96dfe0f9275bc3" + "sha256:4970c3758f4e89a7857a973b1e2a5d75bcdc47794442f2e2dd4fe8e0466e809a", + "sha256:8a5712cfd3bb4248015eb3b0b3c54a5f6ee3f2425963ef2a0125b8bc40aafaec" ], - "version": "==0.5.1" + "version": "==0.5.2" } } } diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..3e9c88b --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,62 @@ +-i https://pypi.org/simple +-e git+https://github.com/twintproject/twint.git@ad27650fbc0bf8c3f2c78449088a5ede7239f53a#egg=twint +aiodns==2.0.0 +aiohttp-socks==0.2.2 +aiohttp==3.5.4 +async-timeout==3.0.1 +attrs==19.1.0 +beautifulsoup4==4.7.1 +cchardet==2.1.4 +certifi==2019.6.16 +cffi==1.12.3 +chardet==3.0.4 +click==7.0 +cycler==0.10.0 +decorator==4.4.0 +dnspython==1.16.0 +elasticsearch==7.0.2 +eventlet==0.25.0 +fake-useragent==0.1.11 +flask-cors==3.0.8 +flask==1.1.0 +geographiclib==1.49 +geopy==1.20.0 +greenlet==0.4.15 +gunicorn==19.9.0 +idna-ssl==1.1.0 ; python_version < '3.7' +idna==2.8 +imageio==2.5.0 +itsdangerous==1.1.0 +jinja2==2.10.1 +joblib==0.13.2 +kiwisolver==1.1.0 +markupsafe==1.1.1 +monotonic==1.5 +multidict==4.5.2 +networkx==2.3 +nltk==3.4.4 +numpy==1.16.4 +pandas==0.24.2 +pillow==6.1.0 +pycares==3.0.0 +pycodestyle==2.5.0 +pycparser==2.19 +pyparsing==2.4.0 +pysocks==1.7.0 +pytesseract==0.2.7 +python-dateutil==2.8.0 +pytz==2019.1 +pywavelets==1.0.3 +regex==2019.6.8 +requests==2.22.0 +schedule==0.6.0 +scikit-learn==0.21.2 +scipy==1.3.0 +six==1.12.0 +soupsieve==1.9.2 +typing-extensions==3.7.4 ; python_version < '3.7' +typing==3.7.4 ; python_version < '3.7' +urllib3==1.25.3 +werkzeug==0.15.4 +yapf==0.27.0 +yarl==1.3.0 diff --git a/requirements.txt b/requirements.txt index 05a741d..3e9c88b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,91 +1,62 @@ +-i https://pypi.org/simple +-e git+https://github.com/twintproject/twint.git@ad27650fbc0bf8c3f2c78449088a5ede7239f53a#egg=twint aiodns==2.0.0 -aiohttp==3.5.4 aiohttp-socks==0.2.2 -alabaster==0.7.12 +aiohttp==3.5.4 async-timeout==3.0.1 -atomicwrites==1.3.0 attrs==19.1.0 -autopep8==1.4.4 -Babel==2.7.0 beautifulsoup4==4.7.1 -bleach==3.1.0 cchardet==2.1.4 certifi==2019.6.16 cffi==1.12.3 chardet==3.0.4 -Click==7.0 +click==7.0 cycler==0.10.0 decorator==4.4.0 dnspython==1.16.0 -docutils==0.14 elasticsearch==7.0.2 eventlet==0.25.0 fake-useragent==0.1.11 -Flask==1.0.3 -Flask-Cors==3.0.8 +flask-cors==3.0.8 +flask==1.1.0 geographiclib==1.49 geopy==1.20.0 greenlet==0.4.15 gunicorn==19.9.0 +idna-ssl==1.1.0 ; python_version < '3.7' idna==2.8 -idna-ssl==1.1.0 imageio==2.5.0 -imagesize==1.1.0 -importlib-metadata==0.18 itsdangerous==1.1.0 -Jinja2==2.10.1 +jinja2==2.10.1 joblib==0.13.2 kiwisolver==1.1.0 -MarkupSafe==1.1.1 +markupsafe==1.1.1 monotonic==1.5 -more-itertools==7.1.0 multidict==4.5.2 networkx==2.3 -nltk==3.4.3 +nltk==3.4.4 numpy==1.16.4 -packaging==19.0 pandas==0.24.2 -Pillow==6.0.0 -pkginfo==1.5.0.1 -pluggy==0.12.0 -py==1.8.0 +pillow==6.1.0 pycares==3.0.0 pycodestyle==2.5.0 pycparser==2.19 -Pygments==2.4.2 pyparsing==2.4.0 -PySocks==1.7.0 +pysocks==1.7.0 pytesseract==0.2.7 -pytest==5.0.0 python-dateutil==2.8.0 pytz==2019.1 -PyWavelets==1.0.3 -readme-renderer==24.0 +pywavelets==1.0.3 regex==2019.6.8 requests==2.22.0 -requests-toolbelt==0.9.1 schedule==0.6.0 scikit-learn==0.21.2 scipy==1.3.0 six==1.12.0 -snowballstemmer==1.9.0 soupsieve==1.9.2 -Sphinx==2.1.2 -sphinxcontrib-applehelp==1.0.1 -sphinxcontrib-devhelp==1.0.1 -sphinxcontrib-htmlhelp==1.0.2 -sphinxcontrib-jsmath==1.0.1 -sphinxcontrib-qthelp==1.0.2 -sphinxcontrib-serializinghtml==1.1.3 -tqdm==4.32.2 -twine==1.13.0 --e git+https://github.com/twintproject/twint.git@c5c6f1d60554cd0ee64ba223850b070553a17e74#egg=twint -typing==3.7.4 -typing-extensions==3.7.4 +typing-extensions==3.7.4 ; python_version < '3.7' +typing==3.7.4 ; python_version < '3.7' urllib3==1.25.3 -wcwidth==0.1.7 -webencodings==0.5.1 -Werkzeug==0.15.4 +werkzeug==0.15.4 yapf==0.27.0 yarl==1.3.0 -zipp==0.5.1 diff --git a/tests/conftest.py b/tests/conftest.py index 339e8cb..df0d479 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,3 +23,16 @@ @pytest.fixture def file_path(): return os.path.abspath('./tests/static/real-tweet.png') + + +@pytest.fixture +def tweet_data(): + import csv + + tweet_list = list() + with open(os.path.abspath('./tests/static/tweets.csv'), + newline='') as csvfile: + csvreader = csv.reader(csvfile) + for row in csvreader: + tweet_list.append(row[10]) + return tweet_list[1:] diff --git a/tests/static/real-tweet.png b/tests/static/real-tweet.png index be58c8e44604768bc858066e5f23d8b244ee99fa..7847f7f8be95e843f19c07238038db856034a2e3 100644 GIT binary patch delta 71 zcmZqv%-sB$c|tb_hk!IQdncdr#>pN(L@YuK&8&>ftqhE{4a}?z4C7; NOnJuqX|lr4WB|W=8Yut( diff --git a/tests/static/tweets.csv b/tests/static/tweets.csv new file mode 100644 index 0000000..84b8a59 --- /dev/null +++ b/tests/static/tweets.csv @@ -0,0 +1,6 @@ +id,conversation_id,created_at,date,time,timezone,user_id,username,name,place,tweet,mentions,urls,photos,replies_count,retweets_count,likes_count,hashtags,cashtags,link,retweet,quote_url,video,user_rt_id,near,geo +1147438570430296064,1147437584450301952,1562405648000,2019-07-06,15:04:08,IST,44196397,elonmusk,Elon Musk,,Sharknado is real,['discovermag'],[],[],178,708,9985,[],[],https://twitter.com/elonmusk/status/1147438570430296064,,,0,,, +1147436241501085698,1147434181082800129,1562405092000,2019-07-06,14:54:52,IST,44196397,elonmusk,Elon Musk,,When sheets come loose all dignity is lost,['daddydiaz3'],[],[],74,265,6624,[],[],https://twitter.com/elonmusk/status/1147436241501085698,,,0,,, +1147434859217870848,1147434859217870848,1562404763000,2019-07-06,14:49:23,IST,44196397,elonmusk,Elon Musk,,Model X as it should be shown https://www.instagram.com/p/BzhWjnZgQSj/?igshid=ly4ynqxne44p …,[],['https://www.instagram.com/p/BzhWjnZgQSj/?igshid=ly4ynqxne44p'],[],709,2039,23236,[],[],https://twitter.com/elonmusk/status/1147434859217870848,,,0,,, +1147434181082800129,1147434181082800129,1562404601000,2019-07-06,14:46:41,IST,44196397,elonmusk,Elon Musk,,What ants must feel like pic.twitter.com/NSsBZXnEvp,[],[],['https://pbs.twimg.com/media/D-yBTguUcAAjOCm.jpg'],1139,11287,107724,[],[],https://twitter.com/elonmusk/status/1147434181082800129,,,0,,, +1147433167860592640,1147405286262628353,1562404360000,2019-07-06,14:42:40,IST,44196397,elonmusk,Elon Musk,,🤣🤣,"['mundanemun', 'cryosphear']",[],[],16,14,507,[],[],https://twitter.com/elonmusk/status/1147433167860592640,,,0,,, diff --git a/tests/test_image_service.py b/tests/test_image_service.py index e69de29..d9b687d 100644 --- a/tests/test_image_service.py +++ b/tests/test_image_service.py @@ -0,0 +1,58 @@ +# Verify Tweet verifies tweets of a public user +# from tweet screenshots: real or generated from +# tweet generators. +# Copyright (C) 2019 Preetham Kamidi + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pytest + +from verifytweet import image as image_service +from verifytweet import result + +extractor = image_service.Extractor() + +def test_get_text_empty_input(): + """Test get_text for empty input + """ + with pytest.raises(TypeError): + extractor.get_text() + + +def test_get_text_invalid_type_input(): + """Test get_text for invalid type input + """ + with pytest.raises(TypeError): + extractor.get_text(None) + extractor.get_text(123) + extractor.get_text(['123']) + + +def test_get_text_invalid_input(): + """Test get_text for valid type invalid input + """ + with pytest.raises(ValueError): + extractor.get_text('') + assert extractor.get_text('123')[1] == result.ResultStatus.MODULE_FAILURE + assert extractor.get_text('/home')[1] == result.ResultStatus.MODULE_FAILURE + assert extractor.get_text('tmp.')[1] == result.ResultStatus.MODULE_FAILURE + + +def test_get_text_valid_input(file_path): + """Test get_text for valid input + """ + test_result = "ey send me stolen pdp wave designs" + module_result, module_status = extractor.get_text(file_path) + assert module_status == result.ResultStatus.ALL_OKAY + assert test_result in module_result diff --git a/tests/test_search_service.py b/tests/test_search_service.py index e69de29..32a0187 100644 --- a/tests/test_search_service.py +++ b/tests/test_search_service.py @@ -0,0 +1,68 @@ +# Verify Tweet verifies tweets of a public user +# from tweet screenshots: real or generated from +# tweet generators. +# Copyright (C) 2019 Preetham Kamidi + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pytest +import datetime + +from verifytweet import search as search_service +from verifytweet import result + +twitter_api_search = search_service.TwitterAPISearch() +twint_search = search_service.TwintSearch() + + +def test_search_empty_input(): + """Test search for empty input + """ + with pytest.raises(TypeError): + twint_search.search() + + +def test_search_invalid_type_input(): + """Test search for invalid type input + """ + with pytest.raises(TypeError): + twint_search.search(123) + twint_search.search('123') + twint_search.search(None, None, None) + twint_search.search(['123'], [123], ['123']) + twint_search.search('123', None, None) + twint_search.search('123', '2019-07-06', 123) + + +def test_search_invalid_input(): + """Test search for valid type invalid input + """ + with pytest.raises(ValueError): + twint_search.search('', datetime.datetime.now(), '') + + +def test_search_valid_input(): + """Test search for valid input + """ + test_user_id = 'elonmusk' + test_datetime = datetime.datetime.strptime('2019-07-06', '%Y-%m-%d') + test_tweet_snippet = 'Sharknado' + test_tweet = 'Sharknado is real' + module_result, module_status = twint_search.search(test_user_id, + test_datetime, + test_tweet_snippet) + assert module_status == result.ResultStatus.ALL_OKAY + assert len(module_result) > 0 + assert isinstance(module_result[0].tweet, str) + assert test_tweet in module_result[0].tweet diff --git a/tests/test_text_service.py b/tests/test_text_service.py index 43bf40f..36ce3f3 100644 --- a/tests/test_text_service.py +++ b/tests/test_text_service.py @@ -25,15 +25,16 @@ data_parser = text_service.DataParser() text_processor = text_service.TextProcessor() + def test_get_entities_empty_input(): - """Test get entities for empty input + """Test get_entities for empty input """ with pytest.raises(TypeError): data_parser.get_entities() def test_get_entities_invalid_type_input(): - """Test get entities for invalid type input + """Test get_entities for invalid type input """ with pytest.raises(TypeError): data_parser.get_entities({1}) @@ -42,14 +43,14 @@ def test_get_entities_invalid_type_input(): def test_get_entities_invalid_input(): - """Test get entities for valid type invalid input + """Test get_entities for valid type invalid input """ with pytest.raises(ValueError): data_parser.get_entities('') def test_get_entities_valid_input(): - """Test get entities for valid type valid extracted string + """Test get_entities for valid type valid extracted string """ test_extracted_text = """ @@ -69,13 +70,88 @@ def test_get_entities_valid_input(): test_result_user_id = 'elonmusk' test_result_tweet = 'Ms. Tree caught the Falcon fairing!!' test_result_datetime = datetime.datetime(2019, - 6, - 25, - 1, - 21, - tzinfo=datetime.timezone.utc) - module_result, module_status = data_parser.get_entities(test_extracted_text) + 6, + 25, + 1, + 21, + tzinfo=datetime.timezone.utc) + module_result, module_status = data_parser.get_entities( + test_extracted_text) assert module_status == result.ResultStatus.ALL_OKAY assert test_result_user_id == module_result['user_id'] assert test_result_tweet in module_result['tweet'] assert test_result_datetime == module_result['date'] + + +def test_clean_text_empty_input(): + """Test clean_text for empty input + """ + with pytest.raises(TypeError): + data_parser.clean_text() + + +def test_clean_text_invalid_type_input(): + """Test clean_text for invalid type input + """ + with pytest.raises(TypeError): + data_parser.clean_text(None) + data_parser.clean_text(123) + data_parser.clean_text([1, '2', '3']) + + +def test_clean_text_invalid_input(): + """Test clean_text for valid type invalid input + """ + with pytest.raises(ValueError): + data_parser.clean_text('') + + +def test_clean_text_valid_input(): + """Test clean_text for valid input + """ + test_str = "Ms. Tree caught the Falcon fairing!!" + module_result, module_status = data_parser.clean_text(test_str) + assert module_status == result.ResultStatus.ALL_OKAY + assert module_result == "Ms Tree caught Falcon" + + +def test_get_similarity_empty_input(): + """Test get_similarity for empty input + """ + with pytest.raises(TypeError): + text_processor.get_similarity() + + +def test_get_similarity_invalid_type_input(): + """Test get_similarity for invalid type input + """ + with pytest.raises(TypeError): + text_processor.get_similarity(123, 123) + text_processor.get_similarity(None, None) + text_processor.get_similarity(None, 123) + text_processor.get_similarity([], '') + + +def test_get_similarity_invalid_input(): + """Test get_similarity for valid type invalid input + """ + with pytest.raises(ValueError): + text_processor.get_similarity('', []) + + +def test_get_similarity_valid_input(tweet_data): + """Test get_similarity for valid input + """ + from numpy import allclose, array + + test_extracted_text = "What ants must feel like pic.twitter.com/NSsBZXnEvp" + test_result = array([[1., 0., 0., 0.09245003, 1., 0.], + [0., 1., 0.20412415, 0., 0., 0.], + [0., 0.20412415, 1., 0., 0., 0.], + [0.09245003, 0., 0., 1., 0.09245003, 0.], + [1., 0., 0., 0.09245003, 1., 0.], + [0., 0., 0., 0., 0., 0.]]) + module_result, module_status = text_processor.get_similarity( + test_extracted_text, tweet_data) + assert module_status == result.ResultStatus.ALL_OKAY + assert allclose(module_result, test_result) diff --git a/verifytweet/services/text.py b/verifytweet/services/text.py index 0768aa5..aeef507 100644 --- a/verifytweet/services/text.py +++ b/verifytweet/services/text.py @@ -163,7 +163,7 @@ def get_similarity(self, extracted_tweet: str, same_day_tweets: list): corpus = list() corpus.append(extracted_tweet) corpus.extend(same_day_tweets) - logger.info('Corpus: ' + str(corpus)) + logger.debug('Corpus: ' + str(corpus)) try: sparse_matrix = count_vectorizer.fit_transform(corpus) similarity_matrix = cosine_similarity(sparse_matrix, sparse_matrix) From 7c4b3c1af454fdccb6c45eea819407e2d66fd130 Mon Sep 17 00:00:00 2001 From: Preetham Kamidi Date: Mon, 8 Jul 2019 18:55:45 +0530 Subject: [PATCH 07/10] Update: Base image for circleci Signed-off-by: Preetham Kamidi --- .circleci/config.yml | 8 ++-- Pipfile | 2 + Pipfile.lock | 47 ++++++++++++++++++- requirements-dev.txt | 88 ++++++++++++++++-------------------- tests/static/real-tweet.png | Bin 63875 -> 63875 bytes 5 files changed, 92 insertions(+), 53 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 94b9f0e..e341f0e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,18 +2,18 @@ version: 2 jobs: test: docker: - - image: circleci/python:3.6.5 + - image: preethamkamidi/verifytweet-base:latest steps: - checkout - restore_cache: key: v1-py-cache-{{ .Branch }}-{{ checksum "requirements-dev.txt" }}-{{ checksum "requirements.txt" }} - run: - name: Activate venv and install requirements + name: Setup venv and install requirements command: | - python3 -m virtualenv ~/.venv + python3 -m venv ~/.venv echo ". ~/.venv/bin/activate" >> $BASH_ENV source $BASH_ENV - pip install -r requirements.txt + pip install -r requirements.txt -r requirements-dev.txt - save_cache: name: Save Python dependencies cache key: v1-py-cache-{{ .Branch }}-{{ checksum "requirements-dev.txt" }}-{{ checksum "requirements.txt" }} diff --git a/Pipfile b/Pipfile index fbf4e67..4b04c7d 100644 --- a/Pipfile +++ b/Pipfile @@ -11,6 +11,8 @@ pytest = "*" twine = "*" bandit = "*" hypothesis = "*" +coverage = "*" +pytest-cov = "*" [packages] certifi = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 5560164..b9498ce 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "ae28f6c49a24e8caaccc5a386912b4019df2b76a9444fef9b26baad4199a7ed8" + "sha256": "903fdfd11ad5a79834909d89a568183647da3179b13bf03ab550b2425e4513ed" }, "pipfile-spec": 6, "requires": { @@ -853,6 +853,43 @@ "index": "pypi", "version": "==3.0.4" }, + "coverage": { + "hashes": [ + "sha256:3684fabf6b87a369017756b551cef29e505cb155ddb892a7a29277b978da88b9", + "sha256:39e088da9b284f1bd17c750ac672103779f7954ce6125fd4382134ac8d152d74", + "sha256:3c205bc11cc4fcc57b761c2da73b9b72a59f8d5ca89979afb0c1c6f9e53c7390", + "sha256:465ce53a8c0f3a7950dfb836438442f833cf6663d407f37d8c52fe7b6e56d7e8", + "sha256:48020e343fc40f72a442c8a1334284620f81295256a6b6ca6d8aa1350c763bbe", + "sha256:5296fc86ab612ec12394565c500b412a43b328b3907c0d14358950d06fd83baf", + "sha256:5f61bed2f7d9b6a9ab935150a6b23d7f84b8055524e7be7715b6513f3328138e", + "sha256:68a43a9f9f83693ce0414d17e019daee7ab3f7113a70c79a3dd4c2f704e4d741", + "sha256:6b8033d47fe22506856fe450470ccb1d8ba1ffb8463494a15cfc96392a288c09", + "sha256:7ad7536066b28863e5835e8cfeaa794b7fe352d99a8cded9f43d1161be8e9fbd", + "sha256:7bacb89ccf4bedb30b277e96e4cc68cd1369ca6841bde7b005191b54d3dd1034", + "sha256:839dc7c36501254e14331bcb98b27002aa415e4af7ea039d9009409b9d2d5420", + "sha256:8f9a95b66969cdea53ec992ecea5406c5bd99c9221f539bca1e8406b200ae98c", + "sha256:932c03d2d565f75961ba1d3cec41ddde00e162c5b46d03f7423edcb807734eab", + "sha256:988529edadc49039d205e0aa6ce049c5ccda4acb2d6c3c5c550c17e8c02c05ba", + "sha256:998d7e73548fe395eeb294495a04d38942edb66d1fa61eb70418871bc621227e", + "sha256:9de60893fb447d1e797f6bf08fdf0dbcda0c1e34c1b06c92bd3a363c0ea8c609", + "sha256:9e80d45d0c7fcee54e22771db7f1b0b126fb4a6c0a2e5afa72f66827207ff2f2", + "sha256:a545a3dfe5082dc8e8c3eb7f8a2cf4f2870902ff1860bd99b6198cfd1f9d1f49", + "sha256:a5d8f29e5ec661143621a8f4de51adfb300d7a476224156a39a392254f70687b", + "sha256:aca06bfba4759bbdb09bf52ebb15ae20268ee1f6747417837926fae990ebc41d", + "sha256:bb23b7a6fd666e551a3094ab896a57809e010059540ad20acbeec03a154224ce", + "sha256:bfd1d0ae7e292105f29d7deaa9d8f2916ed8553ab9d5f39ec65bcf5deadff3f9", + "sha256:c62ca0a38958f541a73cf86acdab020c2091631c137bd359c4f5bddde7b75fd4", + "sha256:c709d8bda72cf4cd348ccec2a4881f2c5848fd72903c185f363d361b2737f773", + "sha256:c968a6aa7e0b56ecbd28531ddf439c2ec103610d3e2bf3b75b813304f8cb7723", + "sha256:df785d8cb80539d0b55fd47183264b7002077859028dfe3070cf6359bf8b2d9c", + "sha256:f406628ca51e0ae90ae76ea8398677a921b36f0bd71aab2099dfed08abd0322f", + "sha256:f46087bbd95ebae244a0eda01a618aff11ec7a069b15a3ef8f6b520db523dcf1", + "sha256:f8019c5279eb32360ca03e9fac40a12667715546eed5c5eb59eb381f2f501260", + "sha256:fc5f4d209733750afd2714e9109816a29500718b32dd9a5db01c0cb3a019b96a" + ], + "index": "pypi", + "version": "==4.5.3" + }, "docutils": { "hashes": [ "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6", @@ -1020,6 +1057,14 @@ "index": "pypi", "version": "==5.0.1" }, + "pytest-cov": { + "hashes": [ + "sha256:2b097cde81a302e1047331b48cadacf23577e431b61e9c6f49a1170bbe3d3da6", + "sha256:e00ea4fdde970725482f1f35630d12f074e121a23801aabf2ae154ec6bdd343a" + ], + "index": "pypi", + "version": "==2.7.1" + }, "pytz": { "hashes": [ "sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda", diff --git a/requirements-dev.txt b/requirements-dev.txt index 3e9c88b..3db5e22 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,62 +1,54 @@ -i https://pypi.org/simple --e git+https://github.com/twintproject/twint.git@ad27650fbc0bf8c3f2c78449088a5ede7239f53a#egg=twint -aiodns==2.0.0 -aiohttp-socks==0.2.2 -aiohttp==3.5.4 -async-timeout==3.0.1 +alabaster==0.7.12 +atomicwrites==1.3.0 attrs==19.1.0 -beautifulsoup4==4.7.1 -cchardet==2.1.4 +autopep8==1.4.4 +babel==2.7.0 +bandit==1.6.2 +bleach==3.1.0 certifi==2019.6.16 -cffi==1.12.3 chardet==3.0.4 -click==7.0 -cycler==0.10.0 -decorator==4.4.0 -dnspython==1.16.0 -elasticsearch==7.0.2 -eventlet==0.25.0 -fake-useragent==0.1.11 -flask-cors==3.0.8 -flask==1.1.0 -geographiclib==1.49 -geopy==1.20.0 -greenlet==0.4.15 -gunicorn==19.9.0 -idna-ssl==1.1.0 ; python_version < '3.7' +coverage==4.5.3 +docutils==0.14 +gitdb2==2.0.5 +gitpython==2.1.11 +hypothesis==4.26.4 idna==2.8 -imageio==2.5.0 -itsdangerous==1.1.0 +imagesize==1.1.0 +importlib-metadata==0.18 jinja2==2.10.1 -joblib==0.13.2 -kiwisolver==1.1.0 markupsafe==1.1.1 -monotonic==1.5 -multidict==4.5.2 -networkx==2.3 -nltk==3.4.4 -numpy==1.16.4 -pandas==0.24.2 -pillow==6.1.0 -pycares==3.0.0 +more-itertools==7.1.0 +packaging==19.0 +pbr==5.4.0 +pkginfo==1.5.0.1 +pluggy==0.12.0 +py==1.8.0 pycodestyle==2.5.0 -pycparser==2.19 +pygments==2.4.2 pyparsing==2.4.0 -pysocks==1.7.0 -pytesseract==0.2.7 -python-dateutil==2.8.0 +pytest-cov==2.7.1 +pytest==5.0.1 pytz==2019.1 -pywavelets==1.0.3 -regex==2019.6.8 +pyyaml==5.1.1 +readme-renderer==24.0 +requests-toolbelt==0.9.1 requests==2.22.0 -schedule==0.6.0 -scikit-learn==0.21.2 -scipy==1.3.0 six==1.12.0 -soupsieve==1.9.2 -typing-extensions==3.7.4 ; python_version < '3.7' -typing==3.7.4 ; python_version < '3.7' +smmap2==2.0.5 +snowballstemmer==1.9.0 +sphinx==2.1.2 +sphinxcontrib-applehelp==1.0.1 +sphinxcontrib-devhelp==1.0.1 +sphinxcontrib-htmlhelp==1.0.2 +sphinxcontrib-jsmath==1.0.1 +sphinxcontrib-qthelp==1.0.2 +sphinxcontrib-serializinghtml==1.1.3 +stevedore==1.30.1 +tqdm==4.32.2 +twine==1.13.0 urllib3==1.25.3 -werkzeug==0.15.4 +wcwidth==0.1.7 +webencodings==0.5.1 yapf==0.27.0 -yarl==1.3.0 +zipp==0.5.2 diff --git a/tests/static/real-tweet.png b/tests/static/real-tweet.png index 7847f7f8be95e843f19c07238038db856034a2e3..6c8a2ca2d8fa76f613e22134847f57ab952a09bf 100644 GIT binary patch delta 64 zcmZqv%-sB$c|s4npqBWpGuv-(ob3HW(A>(z$jZoA+rZSyz~FDq0*}e>e<+}e_9#kx JnXK?L830397)$^F delta 64 zcmZqv%-sB$c|s4nfHX6EC!g}h$=*K%&8&>ftqhE{4a}?z4C Date: Tue, 9 Jul 2019 04:10:58 +0530 Subject: [PATCH 08/10] Update: Add tests for controller Signed-off-by: Preetham Kamidi --- setup.py | 2 +- tests/static/real-tweet.png | Bin 63875 -> 63875 bytes tests/test_controller.py | 67 +++++++++++++++++++++++++++++++++ tests/test_uploader.py | 4 ++ verifytweet/cli.py | 3 -- verifytweet/config/settings.py | 4 +- verifytweet/services/search.py | 3 +- verifytweet/util/logging.py | 2 +- wsgi.py | 2 + 9 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 tests/test_controller.py diff --git a/setup.py b/setup.py index 758caec..8899294 100644 --- a/setup.py +++ b/setup.py @@ -61,7 +61,7 @@ install_requires=[ "click>=5.1", "Pillow==6.0.0", "pytesseract==0.2.6", "requests==2.22.0", "scikit-learn==0.21.2", "nltk>=3.4.3", - "python-dateutil==2.8.0", + "python-dateutil==2.8.0", "werkzeug==0.15.4", "twint @ git+https://github.com/twintproject/twint.git" ], entry_points={ diff --git a/tests/static/real-tweet.png b/tests/static/real-tweet.png index 6c8a2ca2d8fa76f613e22134847f57ab952a09bf..9a3eaafd3a32f7281d205a4376e0dc144eea3240 100644 GIT binary patch delta 66 zcmZqv%-sB$c|s4nn6$C#T_@X(lf8Zj85vm_nOm6{Ya19?85ktXpEH{L{)ar8u#i(( J-(-cK$pH7`6~O=i delta 66 zcmZqv%-sB$c|s4npqBWpGuv-(ob2^O$k5!%#K_9XSlhtV%D~`n%>s|f?|;an3HK;U Ke3`89GZ_GELl~|A diff --git a/tests/test_controller.py b/tests/test_controller.py new file mode 100644 index 0000000..bf23146 --- /dev/null +++ b/tests/test_controller.py @@ -0,0 +1,67 @@ +# Verify Tweet verifies tweets of a public user +# from tweet screenshots: real or generated from +# tweet generators. +# Copyright (C) 2019 Preetham Kamidi + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pytest + +from verifytweet import controller +from verifytweet import result + +non_api_approach = controller.NonAPIApproach() + + +def test_exec_empty_input(): + """Test exec for empty input + """ + with pytest.raises(TypeError): + non_api_approach.exec() + + +def test_exec_invalid_type_input(): + """Test exec for invalid type input + """ + with pytest.raises(TypeError): + non_api_approach.exec(None) + non_api_approach.exec(123) + non_api_approach.exec(['/home/']) + + +def test_exec_invalid_input(): + """Test exec for valid type invalid input + """ + with pytest.raises(ValueError): + non_api_approach.exec('') + assert non_api_approach.exec( + '123')[1] == result.ResultStatus.MODULE_FAILURE + assert non_api_approach.exec( + '/home')[1] == result.ResultStatus.MODULE_FAILURE + assert non_api_approach.exec( + 'tmp.png')[1] == result.ResultStatus.MODULE_FAILURE + + +def test_exec_valid_input(file_path): + """Test exec for valid input + """ + from twint.tweet import tweet + + test_result_tweet = 'ey send me stolen pdp wave designs' + test_result_username = 'pewdiepie' + module_result, module_status = non_api_approach.exec(file_path) + assert module_status == result.ResultStatus.ALL_OKAY + assert isinstance(module_result, tweet) + assert test_result_tweet in module_result.tweet + assert test_result_username == module_result.username diff --git a/tests/test_uploader.py b/tests/test_uploader.py index 018a171..b3c3fe5 100644 --- a/tests/test_uploader.py +++ b/tests/test_uploader.py @@ -21,6 +21,7 @@ from werkzeug.datastructures import FileStorage from verifytweet import uploader +from verifytweet import settings def test_save_to_disk_empty_input(): @@ -51,6 +52,9 @@ def test_save_to_disk_invalid_input(): def test_save_to_disk_valid_input(file_path): """Test save to disk for valid file object """ + app_config = settings.app_config + app_config.ALLOWED_EXTENSIONS = set(["png", "jpg", "jpeg"]) + with open(file_path, 'rb') as f: test_file_obj = FileStorage(f) test_file_name = uploader.save_to_disk(test_file_obj) diff --git a/verifytweet/cli.py b/verifytweet/cli.py index b4e9538..eebd571 100644 --- a/verifytweet/cli.py +++ b/verifytweet/cli.py @@ -17,11 +17,8 @@ # along with this program. If not, see . import os - import click -os.environ["VERIFYTWEET_RUN_FROM_CLI"] = "true" - from .services import controller from .config.settings import app_config from .util.logging import logger diff --git a/verifytweet/config/settings.py b/verifytweet/config/settings.py index e364a84..721523f 100644 --- a/verifytweet/config/settings.py +++ b/verifytweet/config/settings.py @@ -43,7 +43,7 @@ class Config(object): FILE_DIRECTORY = tempfile.mkdtemp() TWEET_MAX_STORE = 150 RUN_METHOD = "cli" - LOG_LEVEL = logging.DEBUG if os.getenv('VERBOSE_LOGS') else logging.INFO + LOG_LEVEL = logging.DEBUG if os.getenv('DEBUG') else logging.INFO class TwitterAPIConfig(Config): @@ -77,7 +77,7 @@ class WebConfig(Config): ALLOWED_EXTENSIONS = set(["png", "jpg", "jpeg"]) -run_method = "cli" if "VERIFYTWEET_RUN_FROM_CLI" in os.environ else "web" +run_method = "web" if "VERIFYTWEET_RUN_FOR_WEB" in os.environ else "cli" Config.RUN_METHOD = run_method configurations = {"web": WebConfig, "cli": Config} diff --git a/verifytweet/services/search.py b/verifytweet/services/search.py index b541df7..15f3b25 100644 --- a/verifytweet/services/search.py +++ b/verifytweet/services/search.py @@ -152,18 +152,19 @@ def search(self, user_id: str, date: datetime.datetime, ) if not user_id or not date or not tweet_snippet: raise ValueError('User ID, Tweet or Date cannot be empty') + results = list() twint_config = twint.Config() twint_config.Username = user_id twint_config.Search = tweet_snippet twint_config.Since = date_checker.format_for_date(date) twint_config.Limit = app_config.TWEET_MAX_STORE twint_config.Store_object = True + twint_config.Store_object_tweets_list = results try: twint.run.Search(twint_config) except Exception as e: logger.exception(e) return (None, ResultStatus.MODULE_FAILURE) - results = twint.output.tweets_object if not results: return (results, ResultStatus.NO_RESULT) logger.debug(f'Search results: {results}\n') diff --git a/verifytweet/util/logging.py b/verifytweet/util/logging.py index 63d51ab..ef0b7e1 100644 --- a/verifytweet/util/logging.py +++ b/verifytweet/util/logging.py @@ -21,7 +21,7 @@ from verifytweet.config.settings import app_config -logger = logging.getLogger() +logger = logging.getLogger('verify_logger') logger.setLevel(app_config.LOG_LEVEL) handler = logging.StreamHandler(sys.stdout) diff --git a/wsgi.py b/wsgi.py index 627cdf2..7fa6e39 100644 --- a/wsgi.py +++ b/wsgi.py @@ -24,6 +24,8 @@ import gunicorn.app.base from gunicorn.six import iteritems +os.environ["VERIFYTWEET_RUN_FOR_WEB"] = "true" + from verifytweet.config.settings import app_config from verifytweet.app import router From 211cd56b98dcf4c213e8ae9735bcadc7f6210d1d Mon Sep 17 00:00:00 2001 From: Preetham Kamidi Date: Tue, 9 Jul 2019 08:40:09 +0530 Subject: [PATCH 09/10] Update: Search without date Signed-off-by: Preetham Kamidi --- tests/test_search_service.py | 9 ++++--- tests/test_text_service.py | 2 +- tests/test_validator.py | 6 ++--- verifytweet/cli.py | 18 +++++++------- verifytweet/config/settings.py | 2 +- verifytweet/services/controller.py | 39 ++++++++++++------------------ verifytweet/services/image.py | 9 ++++++- verifytweet/services/search.py | 18 ++++++++------ verifytweet/services/text.py | 20 +++++++++++---- verifytweet/util/common.py | 39 ++++++++++++++++++++++++++++++ verifytweet/util/logging.py | 2 +- verifytweet/util/validator.py | 10 ++++---- 12 files changed, 114 insertions(+), 60 deletions(-) diff --git a/tests/test_search_service.py b/tests/test_search_service.py index 32a0187..a54ee72 100644 --- a/tests/test_search_service.py +++ b/tests/test_search_service.py @@ -49,7 +49,10 @@ def test_search_invalid_input(): """Test search for valid type invalid input """ with pytest.raises(ValueError): - twint_search.search('', datetime.datetime.now(), '') + twint_search.search( + '', + '', + datetime.datetime.now()) def test_search_valid_input(): @@ -60,8 +63,8 @@ def test_search_valid_input(): test_tweet_snippet = 'Sharknado' test_tweet = 'Sharknado is real' module_result, module_status = twint_search.search(test_user_id, - test_datetime, - test_tweet_snippet) + test_tweet_snippet, + test_datetime) assert module_status == result.ResultStatus.ALL_OKAY assert len(module_result) > 0 assert isinstance(module_result[0].tweet, str) diff --git a/tests/test_text_service.py b/tests/test_text_service.py index 36ce3f3..d45062f 100644 --- a/tests/test_text_service.py +++ b/tests/test_text_service.py @@ -112,7 +112,7 @@ def test_clean_text_valid_input(): test_str = "Ms. Tree caught the Falcon fairing!!" module_result, module_status = data_parser.clean_text(test_str) assert module_status == result.ResultStatus.ALL_OKAY - assert module_result == "Ms Tree caught Falcon" + assert module_result == "caught Falcon fairing" def test_get_similarity_empty_input(): diff --git a/tests/test_validator.py b/tests/test_validator.py index efe08d1..58324ad 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -53,8 +53,8 @@ def test_validator_invalid_input(): def test_validator_valid_similarity_matrix(): """Test verfiy validity for valid similarity matrix """ - test_numpy_array = numpy.array([[0.7, 0.6], [0.5, 0.1]]) - module_result, result_status = validator.verify_validity(test_numpy_array) + test_numpy_array = numpy.array([[1., 0.7, 0.6], [0.5, 0.1, 1.]]) + module_result, match_index, result_status = validator.verify_validity(test_numpy_array) assert result_status == result.ResultStatus.ALL_OKAY assert module_result == True @@ -63,6 +63,6 @@ def test_validator_invalid_similarity_matrix(): """Test verfiy validity for valid similarity matrix """ test_numpy_array = numpy.array([[0.1, 0.1], [0.1, 0.1]]) - module_result, result_status = validator.verify_validity(test_numpy_array) + module_result, match_index, result_status = validator.verify_validity(test_numpy_array) assert result_status == result.ResultStatus.ALL_OKAY assert module_result == False diff --git a/verifytweet/cli.py b/verifytweet/cli.py index eebd571..46d329a 100644 --- a/verifytweet/cli.py +++ b/verifytweet/cli.py @@ -52,14 +52,14 @@ def run_as_command(filepath): try: verify_controller = controller.NonAPIApproach() tweet_obj, controller_status = verify_controller.exec(filepath) + if controller_status == ResultStatus.MODULE_FAILURE: + print(f"Something went wrong, Please try again!") + elif controller_status == ResultStatus.NO_RESULT: + print(f"Fake Tweet!") + else: + print(f"\nVerified Tweet!") + print( + f"**** Username: {tweet_obj.username} ****\n**** Tweet: {tweet_obj.tweet} ****\n**** Likes: {tweet_obj.likes_count} ****\n**** Retweets: {tweet_obj.retweets_count} ****\n**** Link: {tweet_obj.link} ****" + ) except Exception as e: logger.exception(e) - if controller_status == ResultStatus.MODULE_FAILURE: - print(f"Something went wrong, Please try again!") - elif controller_status == ResultStatus.NO_RESULT: - print(f"Fake Tweet!") - else: - print(f"\nVerified Tweet!") - print( - f"**** Username: {tweet_obj.username} ****\n**** Tweet: {tweet_obj.tweet} ****\n**** Likes: {tweet_obj.likes_count} ****\n**** Retweets: {tweet_obj.retweets_count} ****\n**** Link: {tweet_obj.link} ****" - ) diff --git a/verifytweet/config/settings.py b/verifytweet/config/settings.py index 721523f..3d4dcde 100644 --- a/verifytweet/config/settings.py +++ b/verifytweet/config/settings.py @@ -44,6 +44,7 @@ class Config(object): TWEET_MAX_STORE = 150 RUN_METHOD = "cli" LOG_LEVEL = logging.DEBUG if os.getenv('DEBUG') else logging.INFO + SIMILARITY_THRESHOLD = 0.6 class TwitterAPIConfig(Config): @@ -60,7 +61,6 @@ class TwitterAPIConfig(Config): TWEET_COUNT_KEY = "count" TWEET_MAX_OLD = 7 TWEET_TEXT_KEY = "text" - SIMILARITY_THRESHOLD = 0.6 class WebConfig(Config): diff --git a/verifytweet/services/controller.py b/verifytweet/services/controller.py index 7f1134d..0266fa2 100644 --- a/verifytweet/services/controller.py +++ b/verifytweet/services/controller.py @@ -16,11 +16,12 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import os + import verifytweet.services.image as image_service import verifytweet.services.text as text_service import verifytweet.services.search as search_service import verifytweet.util.date_checker as date_checker -import verifytweet.util.validator as validator import verifytweet.util.common as common from verifytweet.util.logging import logger @@ -69,27 +70,11 @@ def exec(self, file_path: str): return (None, ResultStatus.MODULE_FAILURE) if search_status != ResultStatus.ALL_OKAY: return (None, search_status) - - try: - text_processor = text_service.TextProcessor() - similarity_matrix, processor_status = text_processor.get_similarity( - entities['tweet'], same_day_tweets) - except Exception as e: - logger.exception(e) - return (None, ResultStatus.MODULE_FAILURE) - if processor_status != ResultStatus.ALL_OKAY: - return (None, processor_status) - - try: - valid_tweet, validator_status = validator.verify_validity( - similarity_matrix) - except Exception as e: - logger.exception(e) - return (None, ResultStatus.MODULE_FAILURE) + validity, match_index, validator_status = common.calculate_and_validate( + entities=entities, same_day_tweets=same_day_tweets) if validator_status != ResultStatus.ALL_OKAY: - return (None, validator_status) - logger.info('Tweet Validity: ' + str(valid_tweet)) - return (valid_tweet, ResultStatus.ALL_OKAY) + return (None, ResultStatus.MODULE_FAILURE) + return (same_day_tweets[match_index], ResultStatus.ALL_OKAY) class NonAPIApproach(object): @@ -136,11 +121,19 @@ def exec(self, file_path): try: search_controller = search_service.TwintSearch() search_results, search_status = search_controller.search( - entities['user_id'], entities['date'], tweet_snippet) + entities['user_id'], tweet_snippet, entities['date']) except Exception as e: logger.exception(e) return (None, ResultStatus.MODULE_FAILURE) if search_status != ResultStatus.ALL_OKAY: return (None, search_status) - + if not entities['date']: + same_day_tweets = list() + for tweet_obj in search_results: + same_day_tweets.append(tweet_obj.tweet) + validity, match_index, validator_status = common.calculate_and_validate( + entities=entities, same_day_tweets=same_day_tweets) + if validator_status != ResultStatus.ALL_OKAY: + return (None, ResultStatus.MODULE_FAILURE) + return (search_results[match_index], ResultStatus.ALL_OKAY) return (search_results[0], ResultStatus.ALL_OKAY) diff --git a/verifytweet/services/image.py b/verifytweet/services/image.py index ac54e17..ccf098c 100644 --- a/verifytweet/services/image.py +++ b/verifytweet/services/image.py @@ -16,7 +16,9 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import os import subprocess +import uuid import PIL import pytesseract @@ -51,6 +53,10 @@ def get_text(self, file_path: str): logger.info('Extracting text from rescaled image...') img = PIL.Image.open(new_file_path) text = pytesseract.image_to_string(image=img) + try: + os.remove(new_file_path) + except Exception as e: + logger.exception(e) if not text: return (None, ResultStatus.NO_RESULT) return (text, ResultStatus.ALL_OKAY) @@ -65,7 +71,8 @@ def rescale(file_path): if not file_path: raise ValueError('File path cannot be empty') logger.info('Rescaling Image to 300 dpi...') - new_file_path = file_path.rsplit('.', 1)[0] + '.png' + new_file_path = os.path.join(app_config.FILE_DIRECTORY, + str(uuid.uuid1()) + '.png') cmd = [ 'convert', file_path, '-resample', app_config.UPSCALE_RESOLUTION, '-alpha', 'off', '-colorspace', 'Gray', '-threshold', '75%', diff --git a/verifytweet/services/search.py b/verifytweet/services/search.py index 15f3b25..8db6f8c 100644 --- a/verifytweet/services/search.py +++ b/verifytweet/services/search.py @@ -89,7 +89,7 @@ def aggregate_tweets(self, user_id: str, date: datetime.datetime): date) and date_checker.valid_date(tweet_date): logger.debug('Tweet found...: ' + str(entry[app_config.TWEET_TEXT_KEY])) - same_day_tweets.append(entry[app_config.TWEET_TEXT_KEY]) + same_day_tweets.append(entry) if not same_day_tweets: return (same_day_tweets, ResultStatus.NO_RESULT) return (same_day_tweets, ResultStatus.ALL_OKAY) @@ -130,8 +130,8 @@ class TwintSearch(object): def __init__(self): pass - def search(self, user_id: str, date: datetime.datetime, - tweet_snippet: str): + def search(self, user_id: str, tweet_snippet: str, + date: datetime.datetime = None): """Searches for tweets Retrieves tweets of given username, date as well as tweet snippet using Twint. @@ -145,18 +145,20 @@ def search(self, user_id: str, date: datetime.datetime, ([], ResultStatus.ALL_OKAY) """ - if not isinstance(user_id, str) or not isinstance( - date, datetime.datetime) or not (tweet_snippet, str): + if not isinstance(user_id, str) or not (tweet_snippet, str): raise TypeError( 'User ID and tweet_snippet must be type string, date must be type datetime.datetime' ) - if not user_id or not date or not tweet_snippet: + if not user_id or not tweet_snippet: raise ValueError('User ID, Tweet or Date cannot be empty') results = list() twint_config = twint.Config() twint_config.Username = user_id - twint_config.Search = tweet_snippet - twint_config.Since = date_checker.format_for_date(date) + if date: + twint_config.Since = date_checker.format_for_date(date) + twint_config.Until = date_checker.format_for_date(date + datetime.timedelta(days=1)) + else: + twint_config.Search = tweet_snippet twint_config.Limit = app_config.TWEET_MAX_STORE twint_config.Store_object = True twint_config.Store_object_tweets_list = results diff --git a/verifytweet/services/text.py b/verifytweet/services/text.py index aeef507..07ddcde 100644 --- a/verifytweet/services/text.py +++ b/verifytweet/services/text.py @@ -37,6 +37,8 @@ USERNAME_REGEX = r'@(\w{1,15})\b' DATETIME_REGEX = r'((1[0-2]|0?[1-9]):([0-5][0-9]) ?([AaPp][Mm]))\s-\s\d{1,2}\s\w+\s\d{4}' +ALPHANUM_REGEX = r'[^A-Za-z0-9]+' + class DataParser(object): """Parses data from extracted text @@ -72,19 +74,27 @@ def get_entities(self, extracted_text: str): logger.info('Parsing data out of extracted text...') username_match = re.search(USERNAME_REGEX, extracted_text) datetime_match = re.search(DATETIME_REGEX, extracted_text) - if not username_match or not datetime_match: + if not username_match: return (dict({ 'user_id': None, 'tweet': None, 'datetime': None }), ResultStatus.NO_RESULT) user_id = username_match.group()[1:] + tweet_start_index = username_match.end() + tweet_end_index = len( + extracted_text + ) - 1 if not datetime_match else datetime_match.start() + tweet = extracted_text[tweet_start_index:tweet_end_index].strip() + if not datetime_match: + return (dict({ + 'user_id': user_id, + 'tweet': tweet, + 'date': None + }), ResultStatus.ALL_OKAY) date_str = datetime_match.group().replace('-', '') processed_datetime = date_parser.parse(date_str).replace( tzinfo=datetime.timezone.utc) - username_end_index = username_match.end() - date_start_index = datetime_match.start() - tweet = extracted_text[username_end_index:date_start_index].strip() return (dict({ 'user_id': user_id, 'tweet': tweet, @@ -114,7 +124,7 @@ def clean_text(self, extracted_text: str): logger.exception(e) return (None, ResultStatus.MODULE_FAILURE) filtered_sentence = [w for w in word_tokens if not w in stopwords] - picked_words = filtered_sentence[0:min([len(filtered_sentence), 4])] + picked_words = filtered_sentence[2:min([len(filtered_sentence), 6])] tweet_snippet = " ".join(picked_words) if not tweet_snippet: return (tweet_snippet, ResultStatus.NO_RESULT) diff --git a/verifytweet/util/common.py b/verifytweet/util/common.py index 9ebd934..b5c5f80 100644 --- a/verifytweet/util/common.py +++ b/verifytweet/util/common.py @@ -19,6 +19,7 @@ import verifytweet.services.image as image_service import verifytweet.services.text as text_service +import verifytweet.util.validator as validator from verifytweet.util.logging import logger from verifytweet.util.result import ResultStatus @@ -62,3 +63,41 @@ def extract_and_parse(file_path: str): return (None, parser_status) logger.debug('Entities: ' + str(entities)) return (entities, parser_status) + + +def calculate_and_validate(entities: dict, same_day_tweets: list): + """Calculates similarity matrix and validates tweet + + Calculates a similarity matrix from same day tweet + corpus using text service and validates tweet + using validator + + Args: + entities: represents dictionary of entities extracted from text + same_day_tweets: list of strings representing same day tweets + + Returns: + valid_tweet: Validity status of tweet + status: Enum ResultStatus representing result status + + """ + try: + text_processor = text_service.TextProcessor() + similarity_matrix, processor_status = text_processor.get_similarity( + entities['tweet'], same_day_tweets) + except Exception as e: + logger.exception(e) + return (None, None, ResultStatus.MODULE_FAILURE) + if processor_status != ResultStatus.ALL_OKAY: + return (None, None, processor_status) + + try: + valid_tweet, match_index, validator_status = validator.verify_validity( + similarity_matrix) + except Exception as e: + logger.exception(e) + return (None, None, ResultStatus.MODULE_FAILURE) + if validator_status != ResultStatus.ALL_OKAY: + return (None, None, validator_status) + logger.debug('Tweet Validity: ' + str(valid_tweet)) + return (valid_tweet, match_index-1, ResultStatus.ALL_OKAY) diff --git a/verifytweet/util/logging.py b/verifytweet/util/logging.py index ef0b7e1..63d51ab 100644 --- a/verifytweet/util/logging.py +++ b/verifytweet/util/logging.py @@ -21,7 +21,7 @@ from verifytweet.config.settings import app_config -logger = logging.getLogger('verify_logger') +logger = logging.getLogger() logger.setLevel(app_config.LOG_LEVEL) handler = logging.StreamHandler(sys.stdout) diff --git a/verifytweet/util/validator.py b/verifytweet/util/validator.py index c2eb457..5473234 100644 --- a/verifytweet/util/validator.py +++ b/verifytweet/util/validator.py @@ -37,8 +37,8 @@ def verify_validity(similarity_matrix: ndarray): raise TypeError('Similarity matrix must type numpy.ndarray') if not similarity_matrix.all(): raise ValueError('Similarity matrix must be a valid numpy array') - for row in similarity_matrix: - for column in row: - if column > app_config.SIMILARITY_THRESHOLD: - return (True, ResultStatus.ALL_OKAY) - return (False, ResultStatus.ALL_OKAY) + row = similarity_matrix[0] + for column_index in range(1, row.shape[0]): + if row[column_index] > app_config.SIMILARITY_THRESHOLD: + return (True, column_index, ResultStatus.ALL_OKAY) + return (False, None, ResultStatus.ALL_OKAY) From 3786c0766de9c79260058e8a66b76f3a9a19d676 Mon Sep 17 00:00:00 2001 From: Preetham Kamidi Date: Wed, 10 Jul 2019 15:27:14 +0530 Subject: [PATCH 10/10] Update: Version bump Signed-off-by: Preetham Kamidi --- verifytweet/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/verifytweet/__init__.py b/verifytweet/__init__.py index ef8a8f8..94fd4c3 100644 --- a/verifytweet/__init__.py +++ b/verifytweet/__init__.py @@ -29,4 +29,4 @@ from .util import validator from .util import common -__version__ = "0.5.0" \ No newline at end of file +__version__ = "0.5.1" \ No newline at end of file