From a9649c68f3dd96e4844e8f8215517a94e6e402a5 Mon Sep 17 00:00:00 2001 From: Chiemeka Alim Date: Thu, 1 Aug 2024 22:36:31 +0200 Subject: [PATCH 1/8] Add 'search by pattern' functionality and tests using unittest --- .gitignore | 3 ++ .python-version | 1 + gistapi/gistapi.py | 57 +++++++++++----------- requirements.txt | 4 +- tests/test_gistapi.py | 110 ++++++++++++++++++++++++++++++++++++++++++ utils.py | 39 +++++++++++++++ 6 files changed, 185 insertions(+), 29 deletions(-) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 tests/test_gistapi.py create mode 100644 utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..760f1cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea/ +.venv/ +__pycache__/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..dd6a220 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12.4 \ No newline at end of file diff --git a/gistapi/gistapi.py b/gistapi/gistapi.py index 9cc3d6c..f8ca6ab 100644 --- a/gistapi/gistapi.py +++ b/gistapi/gistapi.py @@ -7,10 +7,12 @@ endpoint to verify the server is up and responding and a search endpoint providing a search across all public Gists for a given Github account. """ +import re import requests from flask import Flask, jsonify, request +from utils import gists_for_user, gist_content app = Flask(__name__) @@ -21,25 +23,6 @@ def ping(): return "pong" -def gists_for_user(username: str): - """Provides the list of gist metadata for a given user. - - This abstracts the /users/:username/gist endpoint from the Github API. - See https://developer.github.com/v3/gists/#list-a-users-gists for - more information. - - Args: - username (string): the user to query gists for - - Returns: - The dict parsed from the json response from the Github API. See - the above URL for details of the expected structure. - """ - gists_url = 'https://api.github.com/users/{username}/gists'.format(username=username) - response = requests.get(gists_url) - return response.json() - - @app.route("/api/v1/search", methods=['POST']) def search(): """Provides matches for a single pattern across a single users gists. @@ -57,17 +40,37 @@ def search(): username = post_data['username'] pattern = post_data['pattern'] - result = {} + result = { + 'status': 'success', + 'username': username, + 'pattern': pattern, + 'matches': [] + } + + # Compile the pattern for better performance in loop + regex = re.compile(pattern) + gists = gists_for_user(username) + if gists is None: + result['status'] = 'error' + result['message'] = 'Failed to fetch gists. Please check the username and try again.' + return jsonify(result), 400 for gist in gists: - # TODO: Fetch each gist and check for the pattern - pass - - result['status'] = 'success' - result['username'] = username - result['pattern'] = pattern - result['matches'] = [] + gist_details = gist_content(gist['url']) + if gist_details is None: + continue + + for filename, file_info in gist_details['files'].items(): + file_content = requests.get(file_info['raw_url']).text + + if regex.search(file_content): + match_info = { + 'gist_id': gist['id'], + 'filename': filename, + 'url': gist['html_url'] + } + result['matches'].append(match_info) return jsonify(result) diff --git a/requirements.txt b/requirements.txt index 85fea28..76d9083 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -Flask==2.2.2 -requests==2.28.1 +Flask==3.0.3 +requests==2.32.3 diff --git a/tests/test_gistapi.py b/tests/test_gistapi.py new file mode 100644 index 0000000..c62ada4 --- /dev/null +++ b/tests/test_gistapi.py @@ -0,0 +1,110 @@ +import unittest +from unittest.mock import patch, MagicMock + +from gistapi import app + + +class GistAPITestCase(unittest.TestCase): + def setUp(self): + self.app = app.test_client() + self.app.testing = True + + def test_ping(self): + response = self.app.get('/ping') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data.decode('utf-8'), 'pong') + + @patch('gistapi.requests.get') + def test_search_valid_data(self, mock_get): + mock_gists_for_user = MagicMock() + mock_gists_for_user.status_code = 200 + mock_gists_for_user.json.return_value = [ + { + 'url': 'https://api.github.com/gists/test_gist', + 'id': 'test_gist', + 'html_url': 'https://gist.github.com/test_gist' + } + ] + + mock_gist_content = MagicMock() + mock_gist_content.status_code = 200 + mock_gist_content.json.return_value = { + 'files': { + 'file1.py': { + 'raw_url': 'https://gist.githubusercontent.com/raw/file1.py' + } + }, + 'id': 'test_gist', + 'html_url': 'https://gist.github.com/test_gist' + } + + mock_raw_content = MagicMock() + mock_raw_content.status_code = 200 + mock_raw_content.text = 'import requests' + + mock_get.side_effect = [ + mock_gists_for_user, + mock_gist_content, + mock_raw_content + ] + + with app.test_request_context(): + data = {'username': 'testuser', 'pattern': 'import requests'} + response = self.app.post('/api/v1/search', json=data) + self.assertEqual(response.status_code, 200) + + json_resp = response.get_json() + self.assertEqual(json_resp['status'], 'success') + self.assertGreater(len(json_resp['matches']), 0) + + @patch('gistapi.requests.get') + def test_search_invalid_username(self, mock_get): + mock_gists_for_user = MagicMock() + mock_gists_for_user.status_code = 404 + mock_get.return_value = mock_gists_for_user + + with app.test_request_context(): + data = {'username': 'invaliduser', 'pattern': 'import requests'} + response = self.app.post('/api/v1/search', json=data) + self.assertEqual(response.status_code, 400) + + json_resp = response.get_json() + self.assertEqual(json_resp['status'], 'error') + + @patch('gistapi.requests.get') + def test_search_no_matches(self, mock_get): + mock_gists_for_user = MagicMock() + mock_gists_for_user.status_code = 200 + mock_gists_for_user.json.return_value = [ + {'url': 'https://api.github.com/gists/test_gist', 'id': 'test_gist', + 'html_url': 'https://gist.github.com/test_gist'} + ] + mock_gist_content = MagicMock() + mock_gist_content.status_code = 200 + mock_gist_content.json.return_value = { + 'files': { + 'file1.py': { + 'raw_url': 'https://gist.githubusercontent.com/raw/file1.py' + } + }, + 'id': 'test_gist', + 'html_url': 'https://gist.github.com/test_gist' + } + mock_raw_content = MagicMock() + mock_raw_content.status_code = 200 + mock_raw_content.text = 'print("Hello, world!")' + + mock_get.side_effect = [ + mock_gists_for_user, + mock_gist_content, + mock_raw_content + ] + + with app.test_request_context(): + data = {'username': 'testuser', 'pattern': 'import requests'} + response = self.app.post('/api/v1/search', json=data) + self.assertEqual(response.status_code, 200) + + json_resp = response.get_json() + self.assertEqual(json_resp['status'], 'success') + self.assertEqual(len(json_resp['matches']), 0) diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..b01fe31 --- /dev/null +++ b/utils.py @@ -0,0 +1,39 @@ +import requests + + +def gists_for_user(username: str): + """Provides the list of gist metadata for a given user. + + This abstracts the /users/:username/gist endpoint from the Github API. + See https://developer.github.com/v3/gists/#list-a-users-gists for + more information. + + Args: + username (string): the user to query gists for + + Returns: + The dict parsed from the json response from the Github API. See + the above URL for details of the expected structure. + """ + gists_url = 'https://api.github.com/users/{username}/gists'.format(username=username) + response = requests.get(gists_url) + if response.status_code != 200: + return None + + return response.json() + + +def gist_content(gist_url: str): + """Fetches the content of a gist by its URL. + + Args: + gist_url (string): the URL of the gist to fetch content for + + Returns: + The dict parsed from the json response from the GitHub API containing the gist details. + """ + response = requests.get(gist_url) + if response.status_code != 200: + return None + + return response.json() From 1716d782340dbfb5a93d6d0fa43f7ad970ef716e Mon Sep 17 00:00:00 2001 From: Chiemeka Alim Date: Thu, 1 Aug 2024 22:42:11 +0200 Subject: [PATCH 2/8] Add error handling and data validation --- gistapi/gistapi.py | 51 ++++++++++++++++++++++++++++--------------- tests/test_gistapi.py | 18 +++++++++++++++ 2 files changed, 51 insertions(+), 18 deletions(-) diff --git a/gistapi/gistapi.py b/gistapi/gistapi.py index f8ca6ab..8ad226e 100644 --- a/gistapi/gistapi.py +++ b/gistapi/gistapi.py @@ -36,9 +36,17 @@ def search(): indicating any failure conditions. """ post_data = request.get_json() - - username = post_data['username'] - pattern = post_data['pattern'] + # Validate input data + if not post_data: + return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + + username = post_data.get('username') + pattern = post_data.get('pattern') + # Validate username and pattern + if not username or not isinstance(username, str): + return jsonify({'status': 'error', 'message': 'Invalid or missing username'}), 400 + if not pattern or not isinstance(pattern, str): + return jsonify({'status': 'error', 'message': 'Invalid or missing pattern'}), 400 result = { 'status': 'success', @@ -50,27 +58,34 @@ def search(): # Compile the pattern for better performance in loop regex = re.compile(pattern) - gists = gists_for_user(username) + try: + gists = gists_for_user(username) + except requests.RequestException as e: + return jsonify({'status': 'error', 'message': f'Error fetching gists: {e}'}), 500 + if gists is None: result['status'] = 'error' result['message'] = 'Failed to fetch gists. Please check the username and try again.' return jsonify(result), 400 for gist in gists: - gist_details = gist_content(gist['url']) - if gist_details is None: - continue - - for filename, file_info in gist_details['files'].items(): - file_content = requests.get(file_info['raw_url']).text - - if regex.search(file_content): - match_info = { - 'gist_id': gist['id'], - 'filename': filename, - 'url': gist['html_url'] - } - result['matches'].append(match_info) + try: + gist_details = gist_content(gist['url']) + if gist_details is None: + continue + + for filename, file_info in gist_details['files'].items(): + file_content = requests.get(file_info['raw_url']).text + + if regex.search(file_content): + match_info = { + 'gist_id': gist['id'], + 'filename': filename, + 'url': gist['html_url'] + } + result['matches'].append(match_info) + except requests.RequestException as e: + return jsonify({'status': 'error', 'message': f'Error fetching gist content: {e}'}), 500 return jsonify(result) diff --git a/tests/test_gistapi.py b/tests/test_gistapi.py index c62ada4..b809b2b 100644 --- a/tests/test_gistapi.py +++ b/tests/test_gistapi.py @@ -108,3 +108,21 @@ def test_search_no_matches(self, mock_get): json_resp = response.get_json() self.assertEqual(json_resp['status'], 'success') self.assertEqual(len(json_resp['matches']), 0) + + def test_search_missing_username(self): + data = {'pattern': 'import requests'} + response = self.app.post('/api/v1/search', json=data) + self.assertEqual(response.status_code, 400) + + json_resp = response.get_json() + self.assertEqual(json_resp['status'], 'error') + self.assertEqual(json_resp['message'], 'Invalid or missing username') + + def test_search_missing_pattern(self): + data = {'username': 'testuser'} + response = self.app.post('/api/v1/search', json=data) + self.assertEqual(response.status_code, 400) + json_resp = response.get_json() + + self.assertEqual(json_resp['status'], 'error') + self.assertEqual(json_resp['message'], 'Invalid or missing pattern') From 3472f238151fadf4d9c7178cf5f764f86586c78b Mon Sep 17 00:00:00 2001 From: Chiemeka Alim Date: Thu, 1 Aug 2024 23:02:32 +0200 Subject: [PATCH 3/8] Implement handling of huge gists and pagination. Update tests --- gistapi/gistapi.py | 23 +++++------- tests/test_gistapi.py | 86 +++++++++++++++++++++++++++++++++++++++++-- utils.py | 53 +++++++++++++++++++++----- 3 files changed, 136 insertions(+), 26 deletions(-) diff --git a/gistapi/gistapi.py b/gistapi/gistapi.py index 8ad226e..3c0981c 100644 --- a/gistapi/gistapi.py +++ b/gistapi/gistapi.py @@ -12,7 +12,7 @@ import requests from flask import Flask, jsonify, request -from utils import gists_for_user, gist_content +from utils import gists_for_user, gist_content, is_pattern_present app = Flask(__name__) @@ -55,9 +55,6 @@ def search(): 'matches': [] } - # Compile the pattern for better performance in loop - regex = re.compile(pattern) - try: gists = gists_for_user(username) except requests.RequestException as e: @@ -74,16 +71,14 @@ def search(): if gist_details is None: continue - for filename, file_info in gist_details['files'].items(): - file_content = requests.get(file_info['raw_url']).text - - if regex.search(file_content): - match_info = { - 'gist_id': gist['id'], - 'filename': filename, - 'url': gist['html_url'] - } - result['matches'].append(match_info) + for file_name, file_info in gist_details['files'].items(): + if is_pattern_present(file_info['raw_url'], pattern): + result['matches'].append({ + 'gist_id': gist_details['id'], + 'gist_url': gist_details['html_url'], + 'file_name': file_name, + 'file_url': file_info['raw_url'] + }) except requests.RequestException as e: return jsonify({'status': 'error', 'message': f'Error fetching gist content: {e}'}), 500 diff --git a/tests/test_gistapi.py b/tests/test_gistapi.py index b809b2b..e3c117b 100644 --- a/tests/test_gistapi.py +++ b/tests/test_gistapi.py @@ -40,7 +40,7 @@ def test_search_valid_data(self, mock_get): mock_raw_content = MagicMock() mock_raw_content.status_code = 200 - mock_raw_content.text = 'import requests' + mock_raw_content.iter_content = MagicMock(return_value=[b'import requests']) mock_get.side_effect = [ mock_gists_for_user, @@ -64,7 +64,7 @@ def test_search_invalid_username(self, mock_get): mock_get.return_value = mock_gists_for_user with app.test_request_context(): - data = {'username': 'invaliduser', 'pattern': 'import requests'} + data = {'username': None, 'pattern': 'import requests'} response = self.app.post('/api/v1/search', json=data) self.assertEqual(response.status_code, 400) @@ -79,6 +79,7 @@ def test_search_no_matches(self, mock_get): {'url': 'https://api.github.com/gists/test_gist', 'id': 'test_gist', 'html_url': 'https://gist.github.com/test_gist'} ] + mock_gist_content = MagicMock() mock_gist_content.status_code = 200 mock_gist_content.json.return_value = { @@ -90,9 +91,10 @@ def test_search_no_matches(self, mock_get): 'id': 'test_gist', 'html_url': 'https://gist.github.com/test_gist' } + mock_raw_content = MagicMock() mock_raw_content.status_code = 200 - mock_raw_content.text = 'print("Hello, world!")' + mock_raw_content.text = MagicMock(return_value=[b'Text not matching']) mock_get.side_effect = [ mock_gists_for_user, @@ -126,3 +128,81 @@ def test_search_missing_pattern(self): self.assertEqual(json_resp['status'], 'error') self.assertEqual(json_resp['message'], 'Invalid or missing pattern') + + @patch('gistapi.requests.get') + def test_search_with_pagination(self, mock_get): + mock_gists_for_user_page1 = MagicMock() + mock_gists_for_user_page1.status_code = 200 + mock_gists_for_user_page1.json.return_value = [ + {'url': 'https://api.github.com/gists/test_gist1', 'id': 'test_gist1', + 'html_url': 'https://gist.github.com/test_gist1'} + ] + mock_gists_for_user_page1.headers = { + 'Link': '; rel="next"' + } + + mock_gists_for_user_page2 = MagicMock() + mock_gists_for_user_page2.status_code = 200 + mock_gists_for_user_page2.json.return_value = [ + {'url': 'https://api.github.com/gists/test_gist2', 'id': 'test_gist2', + 'html_url': 'https://gist.github.com/test_gist2'} + ] + mock_gists_for_user_page2.headers = { + 'Link': '; rel="next"' + } + + mock_gists_for_user_page3 = MagicMock() + mock_gists_for_user_page3.status_code = 200 + mock_gists_for_user_page3.json.return_value = [] + mock_gists_for_user_page3.headers = {} + + mock_gist_content_response1 = MagicMock() + mock_gist_content_response1.status_code = 200 + mock_gist_content_response1.json.return_value = { + 'files': { + 'file1.py': { + 'raw_url': 'https://gist.githubusercontent.com/raw/file1.py' + } + }, + 'id': 'test_gist1', + 'html_url': 'https://gist.github.com/test_gist1' + } + + mock_gist_content_response2 = MagicMock() + mock_gist_content_response2.status_code = 200 + mock_gist_content_response2.json.return_value = { + 'files': { + 'file2.py': { + 'raw_url': 'https://gist.githubusercontent.com/raw/file2.py' + } + }, + 'id': 'test_gist2', + 'html_url': 'https://gist.github.com/test_gist2' + } + + mock_raw_content_response1 = MagicMock() + mock_raw_content_response1.status_code = 200 + mock_raw_content_response1.iter_content = MagicMock(return_value=[b'import requests']) + mock_raw_content_response2 = MagicMock() + mock_raw_content_response2.status_code = 200 + mock_raw_content_response2.iter_content = MagicMock(return_value=[b'import requests']) + + # Order of requests.get calls + mock_get.side_effect = [ + mock_gists_for_user_page1, + mock_gists_for_user_page2, + mock_gists_for_user_page3, + mock_gist_content_response1, + mock_raw_content_response1, + mock_gist_content_response2, + mock_raw_content_response2, + ] + + with app.test_request_context(): + data = {'username': 'testuser', 'pattern': 'import requests'} + response = self.app.post('/api/v1/search', json=data) + self.assertEqual(response.status_code, 200) + + json_resp = response.get_json() + self.assertEqual(json_resp['status'], 'success') + self.assertEqual(len(json_resp['matches']), 2) diff --git a/utils.py b/utils.py index b01fe31..96e6941 100644 --- a/utils.py +++ b/utils.py @@ -1,10 +1,12 @@ +import re + import requests def gists_for_user(username: str): - """Provides the list of gist metadata for a given user. + """Provides the list of gist metadata for a given user, handling pagination. - This abstracts the /users/:username/gist endpoint from the Github API. + This abstracts the /users/:username/gists endpoint from the Github API. See https://developer.github.com/v3/gists/#list-a-users-gists for more information. @@ -12,15 +14,27 @@ def gists_for_user(username: str): username (string): the user to query gists for Returns: - The dict parsed from the json response from the Github API. See - the above URL for details of the expected structure. + A list of all gists for the given user. """ - gists_url = 'https://api.github.com/users/{username}/gists'.format(username=username) - response = requests.get(gists_url) - if response.status_code != 200: - return None + gists_url = f'https://api.github.com/users/{username}/gists' + headers = {'Accept': 'application/vnd.github.v3+json'} + gists = [] - return response.json() + while gists_url: + response = requests.get(gists_url, headers=headers) + if response.status_code != 200: + break + gists.extend(response.json()) + gists_url = None + + if 'Link' in response.headers: + links = response.headers['Link'].split(',') + for link in links: + if 'rel="next"' in link: + gists_url = link[link.find('<') + 1:link.find('>')] + break + + return gists def gist_content(gist_url: str): @@ -37,3 +51,24 @@ def gist_content(gist_url: str): return None return response.json() + + +def is_pattern_present(raw_url: str, pattern: str): + """Fetches the content of a file in a gist and checks for a pattern. + + Args: + raw_url (string): the raw URL of the file + pattern (string): the pattern to search for + + Returns: + True if the pattern is found in the file, otherwise False. + """ + response = requests.get(raw_url, stream=True) + if response.status_code == 200: + # Compile the pattern for better performance in loop + pattern_compiled = re.compile(pattern) + + for chunk in response.iter_content(chunk_size=1024): + if pattern_compiled.search(chunk.decode('utf-8')): + return True + return False From 44f3acb655c59e1b08b2f3b59c9d0541068541ab Mon Sep 17 00:00:00 2001 From: Chiemeka Alim Date: Thu, 1 Aug 2024 23:41:42 +0200 Subject: [PATCH 4/8] Migrate to pyproject.toml using Poetry --- poetry.lock | 337 +++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 17 +++ requirements.txt | 2 - 3 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 poetry.lock create mode 100644 pyproject.toml delete mode 100644 requirements.txt diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..7aacee3 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,337 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "blinker" +version = "1.8.2" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.8" +files = [ + {file = "blinker-1.8.2-py3-none-any.whl", hash = "sha256:1779309f71bf239144b9399d06ae925637cf6634cf6bd131104184531bf67c01"}, + {file = "blinker-1.8.2.tar.gz", hash = "sha256:8f77b09d3bf7c795e969e9486f39c2c5e9c39d4ee07424be2bc594ece9642d83"}, +] + +[[package]] +name = "certifi" +version = "2024.7.4" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "flask" +version = "3.0.3" +description = "A simple framework for building complex web applications." +optional = false +python-versions = ">=3.8" +files = [ + {file = "flask-3.0.3-py3-none-any.whl", hash = "sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3"}, + {file = "flask-3.0.3.tar.gz", hash = "sha256:ceb27b0af3823ea2737928a4d99d125a06175b8512c445cbd9a9ce200ef76842"}, +] + +[package.dependencies] +blinker = ">=1.6.2" +click = ">=8.1.3" +itsdangerous = ">=2.1.2" +Jinja2 = ">=3.1.2" +Werkzeug = ">=3.0.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + +[[package]] +name = "idna" +version = "3.7" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + +[[package]] +name = "jinja2" +version = "3.1.4" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, + {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markupsafe" +version = "2.1.5" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, +] + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "urllib3" +version = "2.2.2" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, + {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "werkzeug" +version = "3.0.3" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.8" +files = [ + {file = "werkzeug-3.0.3-py3-none-any.whl", hash = "sha256:fc9645dc43e03e4d630d23143a04a7f947a9a3b5727cd535fdfe155a17cc48c8"}, + {file = "werkzeug-3.0.3.tar.gz", hash = "sha256:097e5bfda9f0aba8da6b8545146def481d06aa7d3266e7448e2cccf67dd8bd18"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "ed1c1542236d80b6296afa939184ffbdda149aa23e96fb58365898fe2b6ba63d" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dc30bb1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[tool.poetry] +name = "backend-coding-challenge" +version = "0.1.0" +description = "" +authors = ["Chiemeka Alim "] +readme = "README.md" +package-mode = false + +[tool.poetry.dependencies] +python = "^3.12" +flask = "3.0.3" +requests = "2.32.3" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 76d9083..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -Flask==3.0.3 -requests==2.32.3 From 7617e51c697bdd18b47697c9cf7718d53a693610 Mon Sep 17 00:00:00 2001 From: Chiemeka Alim Date: Thu, 1 Aug 2024 23:45:36 +0200 Subject: [PATCH 5/8] Implement a Dockerfile --- Dockerfile | 26 ++++++++++++++++++++++++++ pyproject.toml | 1 - 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5435a0e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Use the official Python runtime as a parent image +FROM python:3.12.4-slim-bullseye + +# Set the working directory +WORKDIR /app + +# Install Poetry +RUN pip install poetry==1.7.1 + +# Copy only the dependency files first to leverage Docker cache +COPY pyproject.toml poetry.lock /app/ + +# Install dependencies using Poetry +RUN poetry config virtualenvs.create false && poetry install --no-dev + +# Set the FLASK_APP environment variable +ENV FLASK_APP=gistapi/gistapi.py + +# Copy the rest of the application code +COPY . /app/ + +# Expose the port the app runs on +EXPOSE 5000 + +# Run the Flask app using Poetry +CMD ["poetry", "run", "flask", "run", "--host=0.0.0.0", "--port=5000"] diff --git a/pyproject.toml b/pyproject.toml index dc30bb1..560097c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,6 @@ version = "0.1.0" description = "" authors = ["Chiemeka Alim "] readme = "README.md" -package-mode = false [tool.poetry.dependencies] python = "^3.12" From 4a7298b33b9a2fbbcb8eab0332e6a74091246831 Mon Sep 17 00:00:00 2001 From: Chiemeka Alim Date: Thu, 1 Aug 2024 23:51:00 +0200 Subject: [PATCH 6/8] Install 'code quality check' tools: black, flake8 --- gistapi/gistapi.py | 69 ++++++++++++-------- poetry.lock | 144 ++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 4 ++ tests/test_gistapi.py | 145 +++++++++++++++++++++++------------------- utils.py | 12 ++-- 5 files changed, 276 insertions(+), 98 deletions(-) diff --git a/gistapi/gistapi.py b/gistapi/gistapi.py index 3c0981c..3729420 100644 --- a/gistapi/gistapi.py +++ b/gistapi/gistapi.py @@ -7,6 +7,7 @@ endpoint to verify the server is up and responding and a search endpoint providing a search across all public Gists for a given Github account. """ + import re import requests @@ -23,7 +24,7 @@ def ping(): return "pong" -@app.route("/api/v1/search", methods=['POST']) +@app.route("/api/v1/search", methods=["POST"]) def search(): """Provides matches for a single pattern across a single users gists. @@ -38,52 +39,70 @@ def search(): post_data = request.get_json() # Validate input data if not post_data: - return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + return jsonify({"status": "error", "message": "No data provided"}), 400 - username = post_data.get('username') - pattern = post_data.get('pattern') + username = post_data.get("username") + pattern = post_data.get("pattern") # Validate username and pattern if not username or not isinstance(username, str): - return jsonify({'status': 'error', 'message': 'Invalid or missing username'}), 400 + return ( + jsonify({"status": "error", "message": "Invalid or missing username"}), + 400, + ) if not pattern or not isinstance(pattern, str): - return jsonify({'status': 'error', 'message': 'Invalid or missing pattern'}), 400 + return ( + jsonify({"status": "error", "message": "Invalid or missing pattern"}), + 400, + ) result = { - 'status': 'success', - 'username': username, - 'pattern': pattern, - 'matches': [] + "status": "success", + "username": username, + "pattern": pattern, + "matches": [], } try: gists = gists_for_user(username) except requests.RequestException as e: - return jsonify({'status': 'error', 'message': f'Error fetching gists: {e}'}), 500 + return ( + jsonify({"status": "error", "message": f"Error fetching gists: {e}"}), + 500, + ) if gists is None: - result['status'] = 'error' - result['message'] = 'Failed to fetch gists. Please check the username and try again.' + result["status"] = "error" + result["message"] = ( + "Failed to fetch gists. Please check the username and try again." + ) return jsonify(result), 400 for gist in gists: try: - gist_details = gist_content(gist['url']) + gist_details = gist_content(gist["url"]) if gist_details is None: continue - for file_name, file_info in gist_details['files'].items(): - if is_pattern_present(file_info['raw_url'], pattern): - result['matches'].append({ - 'gist_id': gist_details['id'], - 'gist_url': gist_details['html_url'], - 'file_name': file_name, - 'file_url': file_info['raw_url'] - }) + for file_name, file_info in gist_details["files"].items(): + if is_pattern_present(file_info["raw_url"], pattern): + result["matches"].append( + { + "gist_id": gist_details["id"], + "gist_url": gist_details["html_url"], + "file_name": file_name, + "file_url": file_info["raw_url"], + } + ) except requests.RequestException as e: - return jsonify({'status': 'error', 'message': f'Error fetching gist content: {e}'}), 500 + return ( + jsonify( + {"status": "error", "message": f"Error fetching gist content: {e}"} + ), + 500, + ) return jsonify(result) -if __name__ == '__main__': - app.run(debug=True, host='0.0.0.0', port=9876) +if __name__ == "__main__": + app.run(debug=True, host="0.0.0.0", port=9876) diff --git a/poetry.lock b/poetry.lock index 7aacee3..85d254c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,49 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "black" +version = "24.4.2" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.8" +files = [ + {file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"}, + {file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"}, + {file = "black-24.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063"}, + {file = "black-24.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96"}, + {file = "black-24.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474"}, + {file = "black-24.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c"}, + {file = "black-24.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb"}, + {file = "black-24.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1"}, + {file = "black-24.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d"}, + {file = "black-24.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04"}, + {file = "black-24.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc"}, + {file = "black-24.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0"}, + {file = "black-24.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7"}, + {file = "black-24.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94"}, + {file = "black-24.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8"}, + {file = "black-24.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c"}, + {file = "black-24.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1"}, + {file = "black-24.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741"}, + {file = "black-24.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e"}, + {file = "black-24.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7"}, + {file = "black-24.4.2-py3-none-any.whl", hash = "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c"}, + {file = "black-24.4.2.tar.gz", hash = "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + [[package]] name = "blinker" version = "1.8.2" @@ -146,6 +190,22 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "flake8" +version = "7.1.0" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.8.1" +files = [ + {file = "flake8-7.1.0-py2.py3-none-any.whl", hash = "sha256:2e416edcc62471a64cea09353f4e7bdba32aeb079b6e360554c659a122b1bc6a"}, + {file = "flake8-7.1.0.tar.gz", hash = "sha256:48a07b626b55236e0fb4784ee69a465fbf59d79eec1f5b4785c3d3bc57d17aa5"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.12.0,<2.13.0" +pyflakes = ">=3.2.0,<3.3.0" + [[package]] name = "flask" version = "3.0.3" @@ -276,6 +336,88 @@ files = [ {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, ] +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.2.2" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, + {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +type = ["mypy (>=1.8)"] + +[[package]] +name = "pycodestyle" +version = "2.12.0" +description = "Python style guide checker" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycodestyle-2.12.0-py2.py3-none-any.whl", hash = "sha256:949a39f6b86c3e1515ba1787c2022131d165a8ad271b11370a8819aa070269e4"}, + {file = "pycodestyle-2.12.0.tar.gz", hash = "sha256:442f950141b4f43df752dd303511ffded3a04c2b6fb7f65980574f0c31e6e79c"}, +] + +[[package]] +name = "pyflakes" +version = "3.2.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, + {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, +] + [[package]] name = "requests" version = "2.32.3" @@ -334,4 +476,4 @@ watchdog = ["watchdog (>=2.3)"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "ed1c1542236d80b6296afa939184ffbdda149aa23e96fb58365898fe2b6ba63d" +content-hash = "f41cd420ede054878755c729d311629f8053d7140e6a48ccffe568df459c6827" diff --git a/pyproject.toml b/pyproject.toml index 560097c..d5203a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,10 @@ flask = "3.0.3" requests = "2.32.3" +[tool.poetry.group.dev.dependencies] +black = "^24.4.2" +flake8 = "^7.1.0" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/tests/test_gistapi.py b/tests/test_gistapi.py index e3c117b..d6b44b0 100644 --- a/tests/test_gistapi.py +++ b/tests/test_gistapi.py @@ -10,145 +10,154 @@ def setUp(self): self.app.testing = True def test_ping(self): - response = self.app.get('/ping') + response = self.app.get("/ping") self.assertEqual(response.status_code, 200) - self.assertEqual(response.data.decode('utf-8'), 'pong') + self.assertEqual(response.data.decode("utf-8"), "pong") - @patch('gistapi.requests.get') + @patch("gistapi.requests.get") def test_search_valid_data(self, mock_get): mock_gists_for_user = MagicMock() mock_gists_for_user.status_code = 200 mock_gists_for_user.json.return_value = [ { - 'url': 'https://api.github.com/gists/test_gist', - 'id': 'test_gist', - 'html_url': 'https://gist.github.com/test_gist' + "url": "https://api.github.com/gists/test_gist", + "id": "test_gist", + "html_url": "https://gist.github.com/test_gist", } ] mock_gist_content = MagicMock() mock_gist_content.status_code = 200 mock_gist_content.json.return_value = { - 'files': { - 'file1.py': { - 'raw_url': 'https://gist.githubusercontent.com/raw/file1.py' + "files": { + "file1.py": { + "raw_url": "https://gist.githubusercontent.com/raw/file1.py" } }, - 'id': 'test_gist', - 'html_url': 'https://gist.github.com/test_gist' + "id": "test_gist", + "html_url": "https://gist.github.com/test_gist", } mock_raw_content = MagicMock() mock_raw_content.status_code = 200 - mock_raw_content.iter_content = MagicMock(return_value=[b'import requests']) + mock_raw_content.iter_content = MagicMock(return_value=[b"import requests"]) mock_get.side_effect = [ mock_gists_for_user, mock_gist_content, - mock_raw_content + mock_raw_content, ] with app.test_request_context(): - data = {'username': 'testuser', 'pattern': 'import requests'} - response = self.app.post('/api/v1/search', json=data) + data = {"username": "testuser", "pattern": "import requests"} + response = self.app.post("/api/v1/search", json=data) self.assertEqual(response.status_code, 200) json_resp = response.get_json() - self.assertEqual(json_resp['status'], 'success') - self.assertGreater(len(json_resp['matches']), 0) + self.assertEqual(json_resp["status"], "success") + self.assertGreater(len(json_resp["matches"]), 0) - @patch('gistapi.requests.get') + @patch("gistapi.requests.get") def test_search_invalid_username(self, mock_get): mock_gists_for_user = MagicMock() mock_gists_for_user.status_code = 404 mock_get.return_value = mock_gists_for_user with app.test_request_context(): - data = {'username': None, 'pattern': 'import requests'} - response = self.app.post('/api/v1/search', json=data) + data = {"username": None, "pattern": "import requests"} + response = self.app.post("/api/v1/search", json=data) self.assertEqual(response.status_code, 400) json_resp = response.get_json() - self.assertEqual(json_resp['status'], 'error') + self.assertEqual(json_resp["status"], "error") - @patch('gistapi.requests.get') + @patch("gistapi.requests.get") def test_search_no_matches(self, mock_get): mock_gists_for_user = MagicMock() mock_gists_for_user.status_code = 200 mock_gists_for_user.json.return_value = [ - {'url': 'https://api.github.com/gists/test_gist', 'id': 'test_gist', - 'html_url': 'https://gist.github.com/test_gist'} + { + "url": "https://api.github.com/gists/test_gist", + "id": "test_gist", + "html_url": "https://gist.github.com/test_gist", + } ] mock_gist_content = MagicMock() mock_gist_content.status_code = 200 mock_gist_content.json.return_value = { - 'files': { - 'file1.py': { - 'raw_url': 'https://gist.githubusercontent.com/raw/file1.py' + "files": { + "file1.py": { + "raw_url": "https://gist.githubusercontent.com/raw/file1.py" } }, - 'id': 'test_gist', - 'html_url': 'https://gist.github.com/test_gist' + "id": "test_gist", + "html_url": "https://gist.github.com/test_gist", } mock_raw_content = MagicMock() mock_raw_content.status_code = 200 - mock_raw_content.text = MagicMock(return_value=[b'Text not matching']) + mock_raw_content.text = MagicMock(return_value=[b"Text not matching"]) mock_get.side_effect = [ mock_gists_for_user, mock_gist_content, - mock_raw_content + mock_raw_content, ] with app.test_request_context(): - data = {'username': 'testuser', 'pattern': 'import requests'} - response = self.app.post('/api/v1/search', json=data) + data = {"username": "testuser", "pattern": "import requests"} + response = self.app.post("/api/v1/search", json=data) self.assertEqual(response.status_code, 200) json_resp = response.get_json() - self.assertEqual(json_resp['status'], 'success') - self.assertEqual(len(json_resp['matches']), 0) + self.assertEqual(json_resp["status"], "success") + self.assertEqual(len(json_resp["matches"]), 0) def test_search_missing_username(self): - data = {'pattern': 'import requests'} - response = self.app.post('/api/v1/search', json=data) + data = {"pattern": "import requests"} + response = self.app.post("/api/v1/search", json=data) self.assertEqual(response.status_code, 400) json_resp = response.get_json() - self.assertEqual(json_resp['status'], 'error') - self.assertEqual(json_resp['message'], 'Invalid or missing username') + self.assertEqual(json_resp["status"], "error") + self.assertEqual(json_resp["message"], "Invalid or missing username") def test_search_missing_pattern(self): - data = {'username': 'testuser'} - response = self.app.post('/api/v1/search', json=data) + data = {"username": "testuser"} + response = self.app.post("/api/v1/search", json=data) self.assertEqual(response.status_code, 400) json_resp = response.get_json() - self.assertEqual(json_resp['status'], 'error') - self.assertEqual(json_resp['message'], 'Invalid or missing pattern') + self.assertEqual(json_resp["status"], "error") + self.assertEqual(json_resp["message"], "Invalid or missing pattern") - @patch('gistapi.requests.get') + @patch("gistapi.requests.get") def test_search_with_pagination(self, mock_get): mock_gists_for_user_page1 = MagicMock() mock_gists_for_user_page1.status_code = 200 mock_gists_for_user_page1.json.return_value = [ - {'url': 'https://api.github.com/gists/test_gist1', 'id': 'test_gist1', - 'html_url': 'https://gist.github.com/test_gist1'} + { + "url": "https://api.github.com/gists/test_gist1", + "id": "test_gist1", + "html_url": "https://gist.github.com/test_gist1", + } ] mock_gists_for_user_page1.headers = { - 'Link': '; rel="next"' + "Link": '; rel="next"' } mock_gists_for_user_page2 = MagicMock() mock_gists_for_user_page2.status_code = 200 mock_gists_for_user_page2.json.return_value = [ - {'url': 'https://api.github.com/gists/test_gist2', 'id': 'test_gist2', - 'html_url': 'https://gist.github.com/test_gist2'} + { + "url": "https://api.github.com/gists/test_gist2", + "id": "test_gist2", + "html_url": "https://gist.github.com/test_gist2", + } ] mock_gists_for_user_page2.headers = { - 'Link': '; rel="next"' + "Link": '; rel="next"' } mock_gists_for_user_page3 = MagicMock() @@ -159,33 +168,37 @@ def test_search_with_pagination(self, mock_get): mock_gist_content_response1 = MagicMock() mock_gist_content_response1.status_code = 200 mock_gist_content_response1.json.return_value = { - 'files': { - 'file1.py': { - 'raw_url': 'https://gist.githubusercontent.com/raw/file1.py' + "files": { + "file1.py": { + "raw_url": "https://gist.githubusercontent.com/raw/file1.py" } }, - 'id': 'test_gist1', - 'html_url': 'https://gist.github.com/test_gist1' + "id": "test_gist1", + "html_url": "https://gist.github.com/test_gist1", } mock_gist_content_response2 = MagicMock() mock_gist_content_response2.status_code = 200 mock_gist_content_response2.json.return_value = { - 'files': { - 'file2.py': { - 'raw_url': 'https://gist.githubusercontent.com/raw/file2.py' + "files": { + "file2.py": { + "raw_url": "https://gist.githubusercontent.com/raw/file2.py" } }, - 'id': 'test_gist2', - 'html_url': 'https://gist.github.com/test_gist2' + "id": "test_gist2", + "html_url": "https://gist.github.com/test_gist2", } mock_raw_content_response1 = MagicMock() mock_raw_content_response1.status_code = 200 - mock_raw_content_response1.iter_content = MagicMock(return_value=[b'import requests']) + mock_raw_content_response1.iter_content = MagicMock( + return_value=[b"import requests"] + ) mock_raw_content_response2 = MagicMock() mock_raw_content_response2.status_code = 200 - mock_raw_content_response2.iter_content = MagicMock(return_value=[b'import requests']) + mock_raw_content_response2.iter_content = MagicMock( + return_value=[b"import requests"] + ) # Order of requests.get calls mock_get.side_effect = [ @@ -199,10 +212,10 @@ def test_search_with_pagination(self, mock_get): ] with app.test_request_context(): - data = {'username': 'testuser', 'pattern': 'import requests'} - response = self.app.post('/api/v1/search', json=data) + data = {"username": "testuser", "pattern": "import requests"} + response = self.app.post("/api/v1/search", json=data) self.assertEqual(response.status_code, 200) json_resp = response.get_json() - self.assertEqual(json_resp['status'], 'success') - self.assertEqual(len(json_resp['matches']), 2) + self.assertEqual(json_resp["status"], "success") + self.assertEqual(len(json_resp["matches"]), 2) diff --git a/utils.py b/utils.py index 96e6941..84629e1 100644 --- a/utils.py +++ b/utils.py @@ -16,8 +16,8 @@ def gists_for_user(username: str): Returns: A list of all gists for the given user. """ - gists_url = f'https://api.github.com/users/{username}/gists' - headers = {'Accept': 'application/vnd.github.v3+json'} + gists_url = f"https://api.github.com/users/{username}/gists" + headers = {"Accept": "application/vnd.github.v3+json"} gists = [] while gists_url: @@ -27,11 +27,11 @@ def gists_for_user(username: str): gists.extend(response.json()) gists_url = None - if 'Link' in response.headers: - links = response.headers['Link'].split(',') + if "Link" in response.headers: + links = response.headers["Link"].split(",") for link in links: if 'rel="next"' in link: - gists_url = link[link.find('<') + 1:link.find('>')] + gists_url = link[link.find("<") + 1 : link.find(">")] break return gists @@ -69,6 +69,6 @@ def is_pattern_present(raw_url: str, pattern: str): pattern_compiled = re.compile(pattern) for chunk in response.iter_content(chunk_size=1024): - if pattern_compiled.search(chunk.decode('utf-8')): + if pattern_compiled.search(chunk.decode("utf-8")): return True return False From 9d4d9e4710cdce12ba3dafbe6754dffe05aa276d Mon Sep 17 00:00:00 2001 From: Chiemeka Alim Date: Thu, 1 Aug 2024 23:57:41 +0200 Subject: [PATCH 7/8] Add Makefile and DOCUMENTATION.md to document starting, running application, tests and code quality checks --- DOCUMENTATION.md | 30 ++++++++++++++++++++++++++++++ Makefile | 29 +++++++++++++++++++++++++++++ gistapi/gistapi.py | 2 -- 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 DOCUMENTATION.md create mode 100644 Makefile diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 0000000..7139de1 --- /dev/null +++ b/DOCUMENTATION.md @@ -0,0 +1,30 @@ +# Coding Challenge + +Solution to GistAPI challenge: A simple HTTP API server implemented in Flask for searching a user's public Github Gists. + +### To run the application +A Makefile has been used to simplify setup/running the application. + +```commandline +make setup +``` + +#### Run locally +```commandline +make runlocal +``` + +#### Build and run docker image (using port 5001) +```commandline +make rundocker +``` + +#### Run tests +```commandline +make runtests +``` + +#### Run code quality checkers +```commandline +make runcheckers +``` diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..dc17f41 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +VENV := .venv + +rm_venv: + source deactivate || true + rm -rf $(VENV) + +$(VENV): rm_venv + command -v deactivate && source deactivate || true + python -m venv $(VENV) + source $(VENV)/bin/activate && pip install --upgrade pipx pip-tools + +setup: + test -r $(VENV) || make $(VENV) + source $(VENV)/bin/activate && pipx install poetry \ + && poetry install --no-root + +runlocal: + $(VENV)/bin/flask --app gistapi/gistapi.py run + +rundocker: + docker build --no-cache -t backend-coding-challenge . + docker run -it -p 5001:5000 backend-coding-challenge + +runchecks: + $(VENV)/bin/python -m black gistapi/gistapi.py + $(VENV)/bin/python -m flake8 gistapi/gistapi.py + +runtests: + $(VENV)/bin/python -m unittest discover tests diff --git a/gistapi/gistapi.py b/gistapi/gistapi.py index 3729420..ac1277c 100644 --- a/gistapi/gistapi.py +++ b/gistapi/gistapi.py @@ -8,8 +8,6 @@ providing a search across all public Gists for a given Github account. """ -import re - import requests from flask import Flask, jsonify, request From 2eae6becadadc071ffd8ea447de01d0f2541ee68 Mon Sep 17 00:00:00 2001 From: Chiemeka Alim Date: Thu, 1 Aug 2024 23:59:12 +0200 Subject: [PATCH 8/8] Add a TODO.md file with further possible improvements to the architecture --- TODO.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..9fa2bd2 --- /dev/null +++ b/TODO.md @@ -0,0 +1,70 @@ +# Questions and Improvements + +#### 1. Can we use a database? What for? SQL or NoSQL? +Yes, we can use a database. +- For repeated queries, we could implement a caching layer using Redis +(or an in-memory cache) instead of using a full database. +- We could store previously fetched gists in a proper database, minimizing +calls to an external, third-party API. Of course this would still mean +making a database call over a network, but should be "faster" for users +who have run previous queries. +- If the application allows users save gists or manage the results, then +a database could be necessary. +- If we need to run some analysis on the results over time, a database +would be necessary. + +The choice of SQL or NoSQL depends on the data being saved. + +##### SQL (PostgreSQL, MySQL): +- Structured data (e.g. Gist attributes like title, description, URL, etc). +- If there's complex querying or relationships (e.g. user profiles, gist metadata). + +##### NoSQL (MongoDB): +- Unstructured, or semi-structured data +- If we need a flexible schema (e.g. users gist content structure can be very different). + +#### 2. How can we protect the api from abusing it? +There are a couple of ways we can protect the API from abuse: + +- **Rate Limit**: Impose a limit on the number of requests a user can make over a period +of time. +- **API Key**: Make users required to provide an API key when making requests to identify +the user. +- **Implement CORS**: Specify domains that are allowed to access the API. +- **Input Validation**: Validate user input to prevent certain attacks like SQL-injection +- **Use HTTPS**: Using this encrypts the data when in transit, avoiding man-in-the-middle +attacks. +- **Logging and Monitoring**: This can help detect patterns that might indicate something +fishy. + +#### 3. How can we deploy the application in a cloud environment? +The application can be deployed to cloud environments like Heroku, AWS (Elastic Beanstalk), +or Google Cloud Platform + +To deploy to AWS ElasticBeanstalk: + +- Install AWS CLI and EB CLI (Elastic Beanstalk CLI) +- Run aws configure and follow the prompt +- We would need a Procfile (and likely a requirements.txt file) +- Initialize elastic beanstalk using `eb init` +- Create an environment using `eb create` +- Deploy the application using `eb deploy` + +Environment variables would need to be added on the platform (e.g. API keys). Some platforms +(e.g. AWS) also offer monitoring (Cloud watch). + +#### 4. How can we be sure the application is alive and works as expected when deployed into a cloud environment? +To be sure the application is alive and works as expected we can implement monitoring and testing + +- We already have a `/ping` endpoint that can act as a healthcheck endpoint. The provider can use +this endpoint for health checks (Elastic Beanstalk settings has a means to configure this) +- We can implement logging to capture logs (errors, warnings and other important events) +- There are other monitoring tools like Datadog, Sentry, New relic, etc; that help track application +performance, error rates, availability, etc. We can setup alerts when certain thresholds are crossed. +- Automated testing. Tests have been added, but we can also setup CI/CD pipelines to run tests during +deployments and roll-back deployments if tests fail + +#### 5. Any other topics you may find interesting and/or important to cover +- API Documentation: Use tools like Swagger to document the API endpoints +- Code coverage. Ensure a robust testing of the codebase, and maintain high code coverage +- Pre-commit/Post-commit hook(s) to run code quality checks and tests before merging code \ No newline at end of file