diff --git a/.ds.baseline b/.ds.baseline index cec28396c..ec87d9c30 100644 --- a/.ds.baseline +++ b/.ds.baseline @@ -423,7 +423,7 @@ "filename": "app/templates/new/components/head.html", "hashed_secret": "ee5048791fc7ff45a1545e24f85bec3317371327", "is_verified": false, - "line_number": 35, + "line_number": 34, "is_secret": false } ], @@ -675,7 +675,7 @@ "filename": "tests/conftest.py", "hashed_secret": "f8377c90fcfd699f0ddbdcb30c2c9183d2d933ea", "is_verified": false, - "line_number": 3289, + "line_number": 3266, "is_secret": false } ], @@ -710,5 +710,5 @@ } ] }, - "generated_at": "2024-05-20T16:03:05Z" + "generated_at": "2024-06-05T22:01:56Z" } diff --git a/app/__init__.py b/app/__init__.py index 5c9283c79..e99a5ae51 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -18,6 +18,7 @@ from flask import ( ) from flask.globals import request_ctx from flask_login import LoginManager, current_user +from flask_socketio import SocketIO from flask_talisman import Talisman from flask_wtf import CSRFProtect from flask_wtf.csrf import CSRFError @@ -30,7 +31,7 @@ from werkzeug.local import LocalProxy from app import proxy_fix from app.asset_fingerprinter import asset_fingerprinter from app.config import configs -from app.extensions import redis_client, zendesk_client +from app.extensions import redis_client from app.formatters import ( convert_markdown_template, convert_to_boolean, @@ -118,6 +119,7 @@ from notifications_utils.recipients import format_phone_number_human_readable login_manager = LoginManager() csrf = CSRFProtect() talisman = Talisman() +socketio = SocketIO() # The current service attached to the request stack. @@ -175,6 +177,7 @@ def create_app(application): init_govuk_frontend(application) init_jinja(application) + socketio.init_app(application) for client in ( csrf, @@ -202,7 +205,6 @@ def create_app(application): user_api_client, # External API clients redis_client, - zendesk_client, ): client.init_app(application) diff --git a/app/assets/javascripts/chartDashboard.js b/app/assets/javascripts/chartDashboard.js deleted file mode 100644 index fc40936f8..000000000 --- a/app/assets/javascripts/chartDashboard.js +++ /dev/null @@ -1,24 +0,0 @@ -(function (window) { - - const ctx = document.getElementById('myChart'); - - new Chart(ctx, { - type: 'bar', - data: { - labels: ['Red', 'Blue', 'Yellow', 'Green', 'Purple', 'Orange'], - datasets: [{ - label: '# of Votes', - data: [12, 19, 3, 5, 2, 3], - borderWidth: 1 - }] - }, - options: { - scales: { - y: { - beginAtZero: true - } - } - } - }); - -})(window); diff --git a/app/assets/javascripts/sampleChartDashboard.js b/app/assets/javascripts/sampleChartDashboard.js new file mode 100644 index 000000000..d191abef1 --- /dev/null +++ b/app/assets/javascripts/sampleChartDashboard.js @@ -0,0 +1,66 @@ +(function (window) { + + function initializeChartAndSocket() { + var ctx = document.getElementById('myChart'); + if (!ctx) { + return; + } + + var myBarChart = new Chart(ctx.getContext('2d'), { + type: 'bar', + data: { + labels: [], + datasets: [ + { + label: 'Delivered', + data: [], + backgroundColor: '#0076d6', + stack: 'Stack 0' + }, + ] + }, + options: { + scales: { + y: { + beginAtZero: true + } + } + } + }); + + var socket = io(); + var serviceId = ctx.getAttribute('data-service-id'); + + socket.on('connect', function() { + socket.emit('fetch_daily_stats', serviceId); + }); + + socket.on('daily_stats_update', function(data) { + var labels = []; + var deliveredData = []; + + for (var date in data) { + labels.push(date); + deliveredData.push(data[date].sms.delivered); + } + + myBarChart.data.labels = labels; + myBarChart.data.datasets[0].data = deliveredData; + myBarChart.update(); + }); + + socket.on('error', function(data) { + console.log('Error:', data); + }); + + var sevenDaysButton = document.getElementById('sevenDaysButton'); + if (sevenDaysButton) { + sevenDaysButton.addEventListener('click', function() { + socket.emit('fetch_daily_stats', serviceId); + }); + } + } + + document.addEventListener('DOMContentLoaded', initializeChartAndSocket); + +})(window); diff --git a/app/extensions.py b/app/extensions.py index 8bbb874a3..e322e46d0 100644 --- a/app/extensions.py +++ b/app/extensions.py @@ -1,5 +1,3 @@ from notifications_utils.clients.redis.redis_client import RedisClient -from notifications_utils.clients.zendesk.zendesk_client import ZendeskClient -zendesk_client = ZendeskClient() redis_client = RedisClient() diff --git a/app/main/views/dashboard.py b/app/main/views/dashboard.py index 3bbf432b3..a64444dc2 100644 --- a/app/main/views/dashboard.py +++ b/app/main/views/dashboard.py @@ -6,6 +6,7 @@ from itertools import groupby from flask import Response, abort, jsonify, render_template, request, session, url_for from flask_login import current_user +from flask_socketio import emit from werkzeug.utils import redirect from app import ( @@ -14,6 +15,7 @@ from app import ( job_api_client, notification_api_client, service_api_client, + socketio, template_statistics_client, ) from app.formatters import format_date_numeric, format_datetime_numeric, get_time_left @@ -32,6 +34,18 @@ from app.utils.user import user_has_permissions from notifications_utils.recipients import format_phone_number_human_readable +@socketio.on("fetch_daily_stats") +def handle_fetch_daily_stats(service_id): + if service_id: + date_range = get_stats_date_range() + daily_stats = service_api_client.get_service_notification_statistics_by_day( + service_id, start_date=date_range["start_date"], days=date_range["days"] + ) + emit("daily_stats_update", daily_stats) + else: + emit("error", {"error": "No service_id provided"}) + + @main.route("/services//dashboard") @user_has_permissions("view_activity", "send_messages") def old_service_dashboard(service_id): @@ -84,6 +98,7 @@ def service_dashboard(service_id): partials=get_dashboard_partials(service_id), job_and_notifications=job_and_notifications, service_data_retention_days=service_data_retention_days, + service_id=service_id, ) @@ -434,6 +449,24 @@ def get_months_for_financial_year(year, time_format="%B"): return [month.strftime(time_format) for month in (get_months_for_year(1, 13, year))] +def get_current_month_for_financial_year(year): + current_month = datetime.now().month + return current_month + + +def get_stats_date_range(): + current_financial_year = get_current_financial_year() + current_month = get_current_month_for_financial_year(current_financial_year) + start_date = datetime.now().strftime("%Y-%m-%d") + days = 7 + return { + "current_financial_year": current_financial_year, + "current_month": current_month, + "start_date": start_date, + "days": days, + } + + def get_months_for_year(start, end, year): return [datetime(year, month, 1) for month in range(start, end)] diff --git a/app/main/views/register.py b/app/main/views/register.py index 7f50c6a19..df1282abc 100644 --- a/app/main/views/register.py +++ b/app/main/views/register.py @@ -251,10 +251,21 @@ def get_invited_user_email_address(invited_user_id): def invited_user_accept_invite(invited_user_id): invited_user = InvitedUser.by_id(invited_user_id) + if invited_user.status == "expired": current_app.logger.error("User invitation has expired") - flash("Your invitation has expired.") + flash( + "Your invitation has expired; please contact the person who invited you for additional help." + ) abort(401) + + if invited_user.status == "cancelled": + current_app.logger.error("User invitation has been cancelled") + flash( + "Your invitation is no longer valid; please contact the person who invited you for additional help." + ) + abort(401) + invited_user.accept_invite() diff --git a/app/main/views/send.py b/app/main/views/send.py index c17a8145d..6d0f4f928 100644 --- a/app/main/views/send.py +++ b/app/main/views/send.py @@ -3,7 +3,16 @@ import uuid from string import ascii_uppercase from zipfile import BadZipFile -from flask import abort, flash, redirect, render_template, request, session, url_for +from flask import ( + abort, + current_app, + flash, + redirect, + render_template, + request, + session, + url_for, +) from flask_login import current_user from markupsafe import Markup from notifications_python_client.errors import HTTPError @@ -31,12 +40,18 @@ from app.s3_client.s3_csv_client import ( s3upload, set_metadata_on_csv_upload, ) -from app.utils import PermanentRedirect, should_skip_template_page, unicode_truncate +from app.utils import ( + PermanentRedirect, + hilite, + should_skip_template_page, + unicode_truncate, +) from app.utils.csv import Spreadsheet, get_errors_for_csv from app.utils.templates import get_template from app.utils.user import user_has_permissions from notifications_utils import SMS_CHAR_COUNT_LIMIT from notifications_utils.insensitive_dict import InsensitiveDict +from notifications_utils.logging import scrub from notifications_utils.recipients import RecipientCSV, first_column_headings from notifications_utils.sanitise_text import SanitiseASCII @@ -938,6 +953,9 @@ def send_notification(service_id, template_id): ) ) + current_app.logger.info( + hilite(scrub(f"Recipient for the one-off will be {recipient}")) + ) keys = [] values = [] for k, v in session["placeholders"].items(): @@ -971,6 +989,19 @@ def send_notification(service_id, template_id): valid="True", ) + # Here we are attempting to cleverly link the job id to the one-off recipient + # If we know the partial phone number of the recipient, we can search + # on that initially and find this, which will give us the job_id + # And once we know the job_id, we can search on that and it might tell us something + # about report generation. + current_app.logger.info( + hilite( + scrub( + f"Created job to send one-off, recipient is {recipient}, job_id is {upload_id}" + ) + ) + ) + session.pop("recipient") session.pop("placeholders") diff --git a/app/notify_client/service_api_client.py b/app/notify_client/service_api_client.py index d34516b8b..42f54572f 100644 --- a/app/notify_client/service_api_client.py +++ b/app/notify_client/service_api_client.py @@ -43,6 +43,16 @@ class ServiceAPIClient(NotifyAdminAPIClient): params={"limit_days": limit_days}, )["data"] + def get_service_notification_statistics_by_day( + self, service_id, start_date=None, days=None + ): + if start_date is None: + start_date = datetime.now().strftime("%Y-%m-%d") + + return self.get( + "/service/{0}/statistics/{1}/{2}".format(service_id, start_date, days), + )["data"] + def get_services(self, params_dict=None): """ Retrieve a list of services. diff --git a/app/s3_client/__init__.py b/app/s3_client/__init__.py index e0933b464..7de3509d2 100644 --- a/app/s3_client/__init__.py +++ b/app/s3_client/__init__.py @@ -1,3 +1,5 @@ +import os + import botocore from boto3 import Session from botocore.config import Config @@ -29,6 +31,17 @@ def get_s3_object( ) s3 = session.resource("s3", config=AWS_CLIENT_CONFIG) obj = s3.Object(bucket_name, filename) + # This 'proves' that use of moto in the relevant tests in test_send.py + # mocks everything related to S3. What you will see in the logs is: + # Exception: CREATED AT + # + # raise Exception(f"CREATED AT {_s3.Bucket(bucket_name).creation_date}") + if os.getenv("NOTIFY_ENVIRONMENT") == "test": + teststr = str(s3.Bucket(bucket_name).creation_date).lower() + if "magicmock" not in teststr: + raise Exception( + "Test is not mocked, use @mock_aws or the relevant mocker.patch to avoid accessing S3" + ) return obj diff --git a/app/s3_client/s3_csv_client.py b/app/s3_client/s3_csv_client.py index 21c329887..752f054a4 100644 --- a/app/s3_client/s3_csv_client.py +++ b/app/s3_client/s3_csv_client.py @@ -28,6 +28,7 @@ def get_csv_upload(service_id, upload_id): def s3upload(service_id, filedata): + upload_id = str(uuid.uuid4()) bucket_name, file_location, access_key, secret_key, region = get_csv_location( service_id, upload_id diff --git a/app/templates/new/components/head.html b/app/templates/new/components/head.html index 51f3c4da3..dd7519cf4 100644 --- a/app/templates/new/components/head.html +++ b/app/templates/new/components/head.html @@ -31,7 +31,6 @@ {# google #} - {% if g.hide_from_search_engines %} diff --git a/app/templates/views/dashboard/dashboard.html b/app/templates/views/dashboard/dashboard.html index d46d94b95..c1ba46caf 100644 --- a/app/templates/views/dashboard/dashboard.html +++ b/app/templates/views/dashboard/dashboard.html @@ -22,6 +22,8 @@ Messages sent + {{ ajax_block(partials, updates_url, 'inbox') }} {{ ajax_block(partials, updates_url, 'totals') }} diff --git a/app/templates/views/message-status.html b/app/templates/views/message-status.html index 089a29f49..d9dae439f 100644 --- a/app/templates/views/message-status.html +++ b/app/templates/views/message-status.html @@ -9,9 +9,13 @@

Delivery status

-

Notify’s real-time dashboard lets you check the status of any message.

-

For security, this information is only available for seven days after a message has been sent. You can download a report, including a list of sent messages, for your own records.

-

This page describes the statuses you'll see when you're signed in to Notify.

+

Notify starts sending your text messages immediately. Each message is sent to its cell phone carrier, which attempts + delivery to the recipient. This process is often almost instantaneous, but can sometimes take a while if a phone is + unavailable. The carrier will continue to try to deliver the message for up to 72 hours.

+

The Notify dashboard provides a high-level view of the number of messages Sent, Pending, Delivered, and Failed. The + dashboard data starts to update about five minutes after a message is sent and will continue to update for four hours.

+

Delivery statuses reflect delivery to a specific cell phone number, not to a person. No status can confirm that a + specific person actually received or read the message.

Text messages

-
+
{% call mapping_table( caption='Message statuses – text messages', field_headings=['Status', 'Description'], @@ -53,12 +57,14 @@ caption_visible=False ) %} {% for message_status, description in [ - ('Total', 'The total number of messages that have been sent during the last seven days.'), - ('Pending', 'Notify has sent the message to the provider. The provider will try to deliver the message to the recipient for up to 72 hours. Notify is waiting for delivery information.'), + ('Scheduled', 'The total number of messages that have been scheduled to be sent at some future time.'), + ('Canceled', 'Messages that were created and scheduled, but canceled prior to Sending.'), + ('Total', 'The total number of messages that have been sent during the specified time.'), + ('Pending', 'Notify has sent the message to the provider. The provider will try to deliver the message to the recipient for up to 72 + hours. “Pending” indicates that Notify is waiting for delivery information.'), ('Delivered', 'The message was successfully delivered. Notify cannot tell you if a user has opened or read a message.'), - ('Failed', 'The provider could not deliver the message. This can happen if the phone number was wrong or if the network operator - rejects the message. If you’re sure that these phone numbers are correct, you should contact us. If not, you should remove them from your database. You’ll still be charged for text messages that - cannot be delivered.' | safe), + ('Failed', 'The message could not be delivered.'), + ('Process error / Delivery not attempted', 'If you receive a large number of process errors, please contact the Notify team.'), ] %} {% call row() %} {{ text_field(message_status) }} @@ -67,5 +73,21 @@ {% endfor %} {% endcall %}
+

About carrier statuses

+

Sometimes Notify receives more detailed information from the carriers on the status of messages, and these can be found + in the downloadable reports. Not all carriers provide the same level of detail regarding delivery and some delivery + statutes have a slight variation in word choice. Notify includes this information in the reports to provide you as much + detail as possible. Remember, for security purposes, detailed information is only available for seven days after a + message has been sent.

+ +

Opting out

+

A text recipient can opt out of receiving text messages from your phone number at any time by responding “STOP” or + “QUIT” or one of several other keywords. If they opt out,

+
    +
  • They may receive a response from their carrier stating that they will no longer receive text messages from that number.
  • +
  • Any subsequent messages sent to that number will not be delivered (unless the recipient opts back in by texting “START” + or “OPT-IN”).
  • +
+

Notify.gov does not yet have a way to pull opt-out status and make it available in the UI for agencies to download.

{% endblock %} diff --git a/app/templates/views/trial-mode.html b/app/templates/views/trial-mode.html index 7ca6d2b97..cb0dca0ec 100644 --- a/app/templates/views/trial-mode.html +++ b/app/templates/views/trial-mode.html @@ -21,7 +21,7 @@
  • A text message of 160-306 characters is two parts.
  • For more information on how message parts are calculated, see - Pricing.

    + Tracking usage.

    Before going Live

    Before you request to make your service live so you can send messages to clients:

    diff --git a/app/utils/csv.py b/app/utils/csv.py index c3c27ec18..5c5b794de 100644 --- a/app/utils/csv.py +++ b/app/utils/csv.py @@ -1,10 +1,13 @@ import datetime import pytz +from flask import current_app, json from flask_login import current_user from app.models.spreadsheet import Spreadsheet +from app.utils import hilite from app.utils.templates import get_sample_template +from notifications_utils.logging import scrub from notifications_utils.recipients import RecipientCSV @@ -71,7 +74,24 @@ def generate_notifications_csv(**kwargs): # This generates the "batch" csv report if kwargs.get("job_id"): + # The kwargs contain the job id, which is linked to the recipient's partial phone number in other debug + # Some unit tests are mocking the kwargs and turning them into a function instead of dict, + # hence the try/except. + try: + current_app.logger.info( + hilite(f"Setting up report with kwargs {scrub(json.dumps(kwargs))}") + ) + except TypeError: + pass + original_file_contents = s3download(kwargs["service_id"], kwargs["job_id"]) + # This will verify that the user actually did successfully upload a csv for a one-off. Limit the size + # we display to 999 characters, because we don't want to show the contents for reports with thousands of rows. + current_app.logger.info( + hilite( + f"Original csv for job_id {kwargs['job_id']}: {scrub(original_file_contents[0:999])}" + ) + ) original_upload = RecipientCSV( original_file_contents, template=get_sample_template(kwargs["template_type"]), diff --git a/gulpfile.js b/gulpfile.js index 98afbbacf..0e2c1eaac 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -96,7 +96,9 @@ const javascripts = () => { paths.npm + 'query-command-supported/dist/queryCommandSupported.min.js', paths.npm + 'timeago/jquery.timeago.js', paths.npm + 'textarea-caret/index.js', - paths.npm + 'cbor-js/cbor.js' + paths.npm + 'cbor-js/cbor.js', + paths.npm + 'socket.io-client/dist/socket.io.min.js', + paths.npm + 'chart.js/dist/chart.umd.js' ])); // JS local to this application @@ -125,7 +127,7 @@ const javascripts = () => { paths.src + 'javascripts/date.js', paths.src + 'javascripts/loginAlert.js', paths.src + 'javascripts/main.js', - paths.src + 'javascripts/chartDashboard.js', + paths.src + 'javascripts/sampleChartDashboard.js', ]) .pipe(plugins.prettyerror()) .pipe(plugins.babel({ diff --git a/notifications_utils/clients/antivirus/__init__.py b/notifications_utils/clients/antivirus/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/notifications_utils/clients/antivirus/antivirus_client.py b/notifications_utils/clients/antivirus/antivirus_client.py deleted file mode 100644 index affe8f27e..000000000 --- a/notifications_utils/clients/antivirus/antivirus_client.py +++ /dev/null @@ -1,55 +0,0 @@ -import requests -from flask import current_app - - -class AntivirusError(Exception): - def __init__(self, message=None, status_code=None): - self.message = message - self.status_code = status_code - - @classmethod - def from_exception(cls, e): - try: - message = e.response.json()["error"] - status_code = e.response.status_code - except (TypeError, ValueError, AttributeError, KeyError): - message = "connection error" - status_code = 503 - - return cls(message, status_code) - - -class AntivirusClient: - def __init__(self, api_host=None, auth_token=None): - self.api_host = api_host - self.auth_token = auth_token - - def init_app(self, app): - self.api_host = app.config["ANTIVIRUS_API_HOST"] - self.auth_token = app.config["ANTIVIRUS_API_KEY"] - - def scan(self, document_stream): - try: - response = requests.post( - "{}/scan".format(self.api_host), - headers={ - "Authorization": "Bearer {}".format(self.auth_token), - }, - files={"document": document_stream}, - ) - - response.raise_for_status() - - except requests.RequestException as e: - error = AntivirusError.from_exception(e) - current_app.logger.warning( - "Notify Antivirus API request failed with error: {}".format( - error.message - ) - ) - - raise error - finally: - document_stream.seek(0) - - return response.json()["ok"] diff --git a/notifications_utils/clients/encryption/__init__.py b/notifications_utils/clients/encryption/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/notifications_utils/clients/encryption/encryption_client.py b/notifications_utils/clients/encryption/encryption_client.py deleted file mode 100644 index cf5283208..000000000 --- a/notifications_utils/clients/encryption/encryption_client.py +++ /dev/null @@ -1,86 +0,0 @@ -from base64 import urlsafe_b64encode -from json import dumps, loads - -from cryptography.fernet import Fernet, InvalidToken -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC -from itsdangerous import BadSignature, URLSafeSerializer - - -class EncryptionError(Exception): - pass - - -class SaltLengthError(Exception): - pass - - -class Encryption: - def init_app(self, app): - self._serializer = URLSafeSerializer(app.config.get("SECRET_KEY")) - self._salt = app.config.get("DANGEROUS_SALT") - self._password = app.config.get("SECRET_KEY").encode() - - try: - self._shared_encryptor = Fernet(self._derive_key(self._salt)) - except SaltLengthError as reason: - raise EncryptionError( - "DANGEROUS_SALT must be at least 16 bytes" - ) from reason - - def encrypt(self, thing_to_encrypt, salt=None): - """Encrypt a string or object - - thing_to_encrypt must be serializable as JSON - Returns a UTF-8 string - """ - serialized_bytes = dumps(thing_to_encrypt).encode("utf-8") - encrypted_bytes = self._encryptor(salt).encrypt(serialized_bytes) - return encrypted_bytes.decode("utf-8") - - def decrypt(self, thing_to_decrypt, salt=None): - """Decrypt a UTF-8 string or bytes. - - Once decrypted, thing_to_decrypt must be deserializable from JSON. - """ - try: - return loads(self._encryptor(salt).decrypt(thing_to_decrypt)) - except InvalidToken as reason: - raise EncryptionError from reason - - def sign(self, thing_to_sign, salt=None): - return self._serializer.dumps(thing_to_sign, salt=(salt or self._salt)) - - def verify_signature(self, thing_to_verify, salt=None): - try: - return self._serializer.loads(thing_to_verify, salt=(salt or self._salt)) - except BadSignature as reason: - raise EncryptionError from reason - - def _encryptor(self, salt=None): - if salt is None: - return self._shared_encryptor - else: - try: - return Fernet(self._derive_key(salt)) - except SaltLengthError as reason: - raise EncryptionError( - "Custom salt value must be at least 16 bytes" - ) from reason - - def _derive_key(self, salt): - """Derive a key suitable for use within Fernet from the SECRET_KEY and salt - - * For the salt to be secure, it must be 16 bytes or longer and randomly generated. - * 600_000 was chosen for the iterations because it is what OWASP recommends as - * of [February 2023](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2) - * For more information, see https://cryptography.io/en/latest/hazmat/primitives/key-derivation-functions/#pbkdf2 - * and https://cryptography.io/en/latest/fernet/#using-passwords-with-fernet - """ - salt_bytes = salt.encode() - if len(salt_bytes) < 16: - raise SaltLengthError - kdf = PBKDF2HMAC( - algorithm=hashes.SHA256(), length=32, salt=salt_bytes, iterations=600_000 - ) - return urlsafe_b64encode(kdf.derive(self._password)) diff --git a/notifications_utils/clients/zendesk/__init__.py b/notifications_utils/clients/zendesk/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/notifications_utils/clients/zendesk/zendesk_client.py b/notifications_utils/clients/zendesk/zendesk_client.py deleted file mode 100644 index c5c2c5d02..000000000 --- a/notifications_utils/clients/zendesk/zendesk_client.py +++ /dev/null @@ -1,150 +0,0 @@ -import requests -from flask import current_app - - -class ZendeskError(Exception): - def __init__(self, response): - self.response = response - - -class ZendeskClient: - # the account used to authenticate with. If no requester is provided, the ticket will come from this account. - NOTIFY_ZENDESK_EMAIL = "zd-api-notify@digital.cabinet-office.gov.uk" - - ZENDESK_TICKET_URL = "https://govuk.zendesk.com/api/v2/tickets.json" - - def __init__(self): - self.api_key = None - - def init_app(self, app, *args, **kwargs): - self.api_key = app.config.get("ZENDESK_API_KEY") - - def send_ticket_to_zendesk(self, ticket): - response = requests.post( - self.ZENDESK_TICKET_URL, - json=ticket.request_data, - auth=(f"{self.NOTIFY_ZENDESK_EMAIL}/token", self.api_key), - ) - - if response.status_code != 201: - current_app.logger.error( - f"Zendesk create ticket request failed with {response.status_code} '{response.json()}'" - ) - raise ZendeskError(response) - - ticket_id = response.json()["ticket"]["id"] - - current_app.logger.info(f"Zendesk create ticket {ticket_id} succeeded") - - -class NotifySupportTicket: - PRIORITY_URGENT = "urgent" - PRIORITY_HIGH = "high" - PRIORITY_NORMAL = "normal" - PRIORITY_LOW = "low" - - TAGS_P2 = "govuk_notify_support" - TAGS_P1 = "govuk_notify_emergency" - - TYPE_PROBLEM = "problem" - TYPE_INCIDENT = "incident" - TYPE_QUESTION = "question" - TYPE_TASK = "task" - - # Group: 3rd Line--Notify Support - NOTIFY_GROUP_ID = 360000036529 - # Organization: GDS - NOTIFY_ORG_ID = 21891972 - NOTIFY_TICKET_FORM_ID = 1900000284794 - - def __init__( - self, - subject, - message, - ticket_type, - p1=False, - user_name=None, - user_email=None, - requester_sees_message_content=True, - technical_ticket=False, - ticket_categories=None, - org_id=None, - org_type=None, - service_id=None, - email_ccs=None, - ): - self.subject = subject - self.message = message - self.ticket_type = ticket_type - self.p1 = p1 - self.user_name = user_name - self.user_email = user_email - self.requester_sees_message_content = requester_sees_message_content - self.technical_ticket = technical_ticket - self.ticket_categories = ticket_categories or [] - self.org_id = org_id - self.org_type = org_type - self.service_id = service_id - self.email_ccs = email_ccs - - @property - def request_data(self): - data = { - "ticket": { - "subject": self.subject, - "comment": { - "body": self.message, - "public": self.requester_sees_message_content, - }, - "group_id": self.NOTIFY_GROUP_ID, - "organization_id": self.NOTIFY_ORG_ID, - "ticket_form_id": self.NOTIFY_TICKET_FORM_ID, - "priority": self.PRIORITY_URGENT if self.p1 else self.PRIORITY_NORMAL, - "tags": [self.TAGS_P1 if self.p1 else self.TAGS_P2], - "type": self.ticket_type, - "custom_fields": self._get_custom_fields(), - } - } - - if self.email_ccs: - data["ticket"]["email_ccs"] = [ - {"user_email": email, "action": "put"} for email in self.email_ccs - ] - - # if no requester provided, then the call came from within Notify 👻 - if self.user_email: - data["ticket"]["requester"] = { - "email": self.user_email, - "name": self.user_name or "(no name supplied)", - } - - return data - - def _get_custom_fields(self): - technical_ticket_tag = ( - f'notify_ticket_type_{"" if self.technical_ticket else "non_"}technical' - ) - org_type_tag = f"notify_org_type_{self.org_type}" if self.org_type else None - - return [ - { - "id": "1900000744994", - "value": technical_ticket_tag, - }, # Notify Ticket type field - { - "id": "360022836500", - "value": self.ticket_categories, - }, # Notify Ticket category field - { - "id": "360022943959", - "value": self.org_id, - }, # Notify Organisation ID field - { - "id": "360022943979", - "value": org_type_tag, - }, # Notify Organisation type field - { - "id": "1900000745014", - "value": self.service_id, - }, # Notify Service ID field - ] diff --git a/notifications_utils/logging.py b/notifications_utils/logging.py index 6a209cdd3..7c56a00ad 100644 --- a/notifications_utils/logging.py +++ b/notifications_utils/logging.py @@ -1,5 +1,6 @@ import logging import logging.handlers +import re import sys from itertools import product @@ -131,3 +132,15 @@ class JSONFormatter(BaseJSONFormatter): except (KeyError, IndexError) as e: logger.exception("failed to format log message: {} not found".format(e)) return log_record + + +def scrub(msg): + # Eventually we want to scrub all messages in all logs for phone numbers + # and email addresses, masking them. Ultimately this will probably get + # refactored into a 'SafeLogger' subclass or something, but let's start here + # with phones. + phones = re.findall("(?:\\+ *)?\\d[\\d\\- ]{7,}\\d", msg) + phones = [phone.replace("-", "").replace(" ", "") for phone in phones] + for phone in phones: + msg = msg.replace(phone, f"1XXXXX{phone[-5:]}") + return msg diff --git a/notifications_utils/s3.py b/notifications_utils/s3.py index cdcc70a5c..d33cbe25a 100644 --- a/notifications_utils/s3.py +++ b/notifications_utils/s3.py @@ -38,6 +38,17 @@ def s3upload( region_name=region, ) _s3 = session.resource("s3", config=AWS_CLIENT_CONFIG) + # This 'proves' that use of moto in the relevant tests in test_send.py + # mocks everything related to S3. What you will see in the logs is: + # Exception: CREATED AT + # + # raise Exception(f"CREATED AT {_s3.Bucket(bucket_name).creation_date}") + if os.getenv("NOTIFY_ENVIRONMENT") == "test": + teststr = str(_s3.Bucket(bucket_name).creation_date).lower() + if "magicmock" not in teststr: + raise Exception( + "Test is not mocked, use @mock_aws or the relevant mocker.patch to avoid accessing S3" + ) key = _s3.Object(bucket_name, file_location) @@ -82,6 +93,17 @@ def s3download( ) s3 = session.resource("s3", config=AWS_CLIENT_CONFIG) key = s3.Object(bucket_name, filename) + # This 'proves' that use of moto in the relevant tests in test_send.py + # mocks everything related to S3. What you will see in the logs is: + # Exception: CREATED AT + # + # raise Exception(f"CREATED AT {_s3.Bucket(bucket_name).creation_date}") + if os.getenv("NOTIFY_ENVIRONMENT") == "test": + teststr = str(s3.Bucket(bucket_name).creation_date).lower() + if "magicmock" not in teststr: + raise Exception( + "Test is not mocked, use @mock_aws or the relevant mocker.patch to avoid accessing S3" + ) return key.get()["Body"] except botocore.exceptions.ClientError as error: raise S3ObjectNotFound(error.response, error.operation_name) diff --git a/package-lock.json b/package-lock.json index c96f68f8a..30688bfde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "python": "^0.0.4", "query-command-supported": "1.0.0", "sass-embedded": "^1.69.5", + "socket.io-client": "^4.7.5", "textarea-caret": "3.1.0", "timeago": "1.6.7" }, @@ -2592,6 +2593,11 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -4644,7 +4650,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -5039,6 +5044,46 @@ "once": "^1.4.0" } }, + "node_modules/engine.io-client": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", + "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0", + "xmlhttprequest-ssl": "~2.0.0" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz", + "integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -10779,8 +10824,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/multipipe": { "version": "0.1.2", @@ -13127,6 +13171,32 @@ "urix": "^0.1.0" } }, + "node_modules/socket.io-client": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz", + "integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -14511,6 +14581,14 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz", diff --git a/package.json b/package.json index b60893b57..38cc83797 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "python": "^0.0.4", "query-command-supported": "1.0.0", "sass-embedded": "^1.69.5", + "socket.io-client": "^4.7.5", "textarea-caret": "3.1.0", "timeago": "1.6.7" }, diff --git a/poetry.lock b/poetry.lock index 0a403642d..5fa01c2b7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -171,17 +171,17 @@ files = [ [[package]] name = "boto3" -version = "1.34.114" +version = "1.34.119" description = "The AWS SDK for Python" optional = false python-versions = ">=3.8" files = [ - {file = "boto3-1.34.114-py3-none-any.whl", hash = "sha256:4460958d2b0c53bd2195b23ed5d45db2350e514486fe8caeb38b285b30742280"}, - {file = "boto3-1.34.114.tar.gz", hash = "sha256:eeb11bca9b19d12baf93436fb8a16b8b824f1f7e8b9bcc722607e862c46b1b08"}, + {file = "boto3-1.34.119-py3-none-any.whl", hash = "sha256:8f9c43c54b3dfaa36c4a0d7b42c417227a515bc7a2e163e62802780000a5a3e2"}, + {file = "boto3-1.34.119.tar.gz", hash = "sha256:cea2365a25b2b83a97e77f24ac6f922ef62e20636b42f9f6ee9f97188f9c1c03"}, ] [package.dependencies] -botocore = ">=1.34.114,<1.35.0" +botocore = ">=1.34.119,<1.35.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.10.0,<0.11.0" @@ -190,13 +190,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.34.115" +version = "1.34.119" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.8" files = [ - {file = "botocore-1.34.115-py3-none-any.whl", hash = "sha256:15b8ad1ee0e9cd57884fb0bcaf3a9551d2552e44a02c2ffb55ec583eebdb888e"}, - {file = "botocore-1.34.115.tar.gz", hash = "sha256:a5d5e28b9c847b17a1ecb7660b46b83d9512b125f671e03e93d14bf6f0b274c2"}, + {file = "botocore-1.34.119-py3-none-any.whl", hash = "sha256:4bdf7926a1290b2650d62899ceba65073dd2693e61c35f5cdeb3a286a0aaa27b"}, + {file = "botocore-1.34.119.tar.gz", hash = "sha256:b253f15b24b87b070e176af48e8ef146516090429d30a7d8b136a4c079b28008"}, ] [package.dependencies] @@ -241,13 +241,13 @@ files = [ [[package]] name = "certifi" -version = "2024.2.2" +version = "2024.6.2" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, - {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, + {file = "certifi-2024.6.2-py3-none-any.whl", hash = "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56"}, + {file = "certifi-2024.6.2.tar.gz", hash = "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516"}, ] [[package]] @@ -526,43 +526,43 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "42.0.7" +version = "42.0.8" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-42.0.7-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:a987f840718078212fdf4504d0fd4c6effe34a7e4740378e59d47696e8dfb477"}, - {file = "cryptography-42.0.7-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:bd13b5e9b543532453de08bcdc3cc7cebec6f9883e886fd20a92f26940fd3e7a"}, - {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a79165431551042cc9d1d90e6145d5d0d3ab0f2d66326c201d9b0e7f5bf43604"}, - {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a47787a5e3649008a1102d3df55424e86606c9bae6fb77ac59afe06d234605f8"}, - {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:02c0eee2d7133bdbbc5e24441258d5d2244beb31da5ed19fbb80315f4bbbff55"}, - {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5e44507bf8d14b36b8389b226665d597bc0f18ea035d75b4e53c7b1ea84583cc"}, - {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7f8b25fa616d8b846aef64b15c606bb0828dbc35faf90566eb139aa9cff67af2"}, - {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:93a3209f6bb2b33e725ed08ee0991b92976dfdcf4e8b38646540674fc7508e13"}, - {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e6b8f1881dac458c34778d0a424ae5769de30544fc678eac51c1c8bb2183e9da"}, - {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3de9a45d3b2b7d8088c3fbf1ed4395dfeff79d07842217b38df14ef09ce1d8d7"}, - {file = "cryptography-42.0.7-cp37-abi3-win32.whl", hash = "sha256:789caea816c6704f63f6241a519bfa347f72fbd67ba28d04636b7c6b7da94b0b"}, - {file = "cryptography-42.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:8cb8ce7c3347fcf9446f201dc30e2d5a3c898d009126010cbd1f443f28b52678"}, - {file = "cryptography-42.0.7-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:a3a5ac8b56fe37f3125e5b72b61dcde43283e5370827f5233893d461b7360cd4"}, - {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:779245e13b9a6638df14641d029add5dc17edbef6ec915688f3acb9e720a5858"}, - {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d563795db98b4cd57742a78a288cdbdc9daedac29f2239793071fe114f13785"}, - {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:31adb7d06fe4383226c3e963471f6837742889b3c4caa55aac20ad951bc8ffda"}, - {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:efd0bf5205240182e0f13bcaea41be4fdf5c22c5129fc7ced4a0282ac86998c9"}, - {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a9bc127cdc4ecf87a5ea22a2556cab6c7eda2923f84e4f3cc588e8470ce4e42e"}, - {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:3577d029bc3f4827dd5bf8bf7710cac13527b470bbf1820a3f394adb38ed7d5f"}, - {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2e47577f9b18723fa294b0ea9a17d5e53a227867a0a4904a1a076d1646d45ca1"}, - {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1a58839984d9cb34c855197043eaae2c187d930ca6d644612843b4fe8513c886"}, - {file = "cryptography-42.0.7-cp39-abi3-win32.whl", hash = "sha256:e6b79d0adb01aae87e8a44c2b64bc3f3fe59515280e00fb6d57a7267a2583cda"}, - {file = "cryptography-42.0.7-cp39-abi3-win_amd64.whl", hash = "sha256:16268d46086bb8ad5bf0a2b5544d8a9ed87a0e33f5e77dd3c3301e63d941a83b"}, - {file = "cryptography-42.0.7-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2954fccea107026512b15afb4aa664a5640cd0af630e2ee3962f2602693f0c82"}, - {file = "cryptography-42.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:362e7197754c231797ec45ee081f3088a27a47c6c01eff2ac83f60f85a50fe60"}, - {file = "cryptography-42.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4f698edacf9c9e0371112792558d2f705b5645076cc0aaae02f816a0171770fd"}, - {file = "cryptography-42.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5482e789294854c28237bba77c4c83be698be740e31a3ae5e879ee5444166582"}, - {file = "cryptography-42.0.7-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e9b2a6309f14c0497f348d08a065d52f3020656f675819fc405fb63bbcd26562"}, - {file = "cryptography-42.0.7-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d8e3098721b84392ee45af2dd554c947c32cc52f862b6a3ae982dbb90f577f14"}, - {file = "cryptography-42.0.7-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c65f96dad14f8528a447414125e1fc8feb2ad5a272b8f68477abbcc1ea7d94b9"}, - {file = "cryptography-42.0.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:36017400817987670037fbb0324d71489b6ead6231c9604f8fc1f7d008087c68"}, - {file = "cryptography-42.0.7.tar.gz", hash = "sha256:ecbfbc00bf55888edda9868a4cf927205de8499e7fabe6c050322298382953f2"}, + {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e"}, + {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7"}, + {file = "cryptography-42.0.8-cp37-abi3-win32.whl", hash = "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2"}, + {file = "cryptography-42.0.8-cp37-abi3-win_amd64.whl", hash = "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba"}, + {file = "cryptography-42.0.8-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14"}, + {file = "cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c"}, + {file = "cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5a94eccb2a81a309806027e1670a358b99b8fe8bfe9f8d329f27d72c094dde8c"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dec9b018df185f08483f294cae6ccac29e7a6e0678996587363dc352dc65c842"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:343728aac38decfdeecf55ecab3264b015be68fc2816ca800db649607aeee648"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:013629ae70b40af70c9a7a5db40abe5d9054e6f4380e50ce769947b73bf3caad"}, + {file = "cryptography-42.0.8.tar.gz", hash = "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2"}, ] [package.dependencies] @@ -580,19 +580,19 @@ test-randomorder = ["pytest-randomly"] [[package]] name = "cyclonedx-python-lib" -version = "6.4.4" +version = "7.4.0" description = "Python library for CycloneDX" optional = false -python-versions = ">=3.8,<4.0" +python-versions = "<4.0,>=3.8" files = [ - {file = "cyclonedx_python_lib-6.4.4-py3-none-any.whl", hash = "sha256:c366619cc4effd528675f1f7a7a00be30b6695ff03f49c64880ad15acbebc341"}, - {file = "cyclonedx_python_lib-6.4.4.tar.gz", hash = "sha256:1b6f9109b6b9e91636dff822c2de90a05c0c8af120317713c1b879dbfdebdff8"}, + {file = "cyclonedx_python_lib-7.4.0-py3-none-any.whl", hash = "sha256:fc423e7f46d772e5ded29a48cb0743233e692e5853c49b829efc0f59014efde1"}, + {file = "cyclonedx_python_lib-7.4.0.tar.gz", hash = "sha256:09b10736a7f440262578fa40f470b448de1ebf3c7a71e2ff0a4af0781d3a3b42"}, ] [package.dependencies] license-expression = ">=30,<31" packageurl-python = ">=0.11,<2" -py-serializable = ">=0.16,<2" +py-serializable = ">=1.0.3,<2" sortedcontainers = ">=2.4.0,<3.0.0" [package.extras] @@ -1225,166 +1225,149 @@ files = [ [[package]] name = "lxml" -version = "5.2.1" +version = "5.2.2" description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." optional = false python-versions = ">=3.6" files = [ - {file = "lxml-5.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1f7785f4f789fdb522729ae465adcaa099e2a3441519df750ebdccc481d961a1"}, - {file = "lxml-5.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6cc6ee342fb7fa2471bd9b6d6fdfc78925a697bf5c2bcd0a302e98b0d35bfad3"}, - {file = "lxml-5.2.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:794f04eec78f1d0e35d9e0c36cbbb22e42d370dda1609fb03bcd7aeb458c6377"}, - {file = "lxml-5.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817d420c60a5183953c783b0547d9eb43b7b344a2c46f69513d5952a78cddf3"}, - {file = "lxml-5.2.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2213afee476546a7f37c7a9b4ad4d74b1e112a6fafffc9185d6d21f043128c81"}, - {file = "lxml-5.2.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b070bbe8d3f0f6147689bed981d19bbb33070225373338df755a46893528104a"}, - {file = "lxml-5.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e02c5175f63effbd7c5e590399c118d5db6183bbfe8e0d118bdb5c2d1b48d937"}, - {file = "lxml-5.2.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:3dc773b2861b37b41a6136e0b72a1a44689a9c4c101e0cddb6b854016acc0aa8"}, - {file = "lxml-5.2.1-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:d7520db34088c96cc0e0a3ad51a4fd5b401f279ee112aa2b7f8f976d8582606d"}, - {file = "lxml-5.2.1-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:bcbf4af004f98793a95355980764b3d80d47117678118a44a80b721c9913436a"}, - {file = "lxml-5.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a2b44bec7adf3e9305ce6cbfa47a4395667e744097faed97abb4728748ba7d47"}, - {file = "lxml-5.2.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:1c5bb205e9212d0ebddf946bc07e73fa245c864a5f90f341d11ce7b0b854475d"}, - {file = "lxml-5.2.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2c9d147f754b1b0e723e6afb7ba1566ecb162fe4ea657f53d2139bbf894d050a"}, - {file = "lxml-5.2.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:3545039fa4779be2df51d6395e91a810f57122290864918b172d5dc7ca5bb433"}, - {file = "lxml-5.2.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a91481dbcddf1736c98a80b122afa0f7296eeb80b72344d7f45dc9f781551f56"}, - {file = "lxml-5.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2ddfe41ddc81f29a4c44c8ce239eda5ade4e7fc305fb7311759dd6229a080052"}, - {file = "lxml-5.2.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a7baf9ffc238e4bf401299f50e971a45bfcc10a785522541a6e3179c83eabf0a"}, - {file = "lxml-5.2.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:31e9a882013c2f6bd2f2c974241bf4ba68c85eba943648ce88936d23209a2e01"}, - {file = "lxml-5.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0a15438253b34e6362b2dc41475e7f80de76320f335e70c5528b7148cac253a1"}, - {file = "lxml-5.2.1-cp310-cp310-win32.whl", hash = "sha256:6992030d43b916407c9aa52e9673612ff39a575523c5f4cf72cdef75365709a5"}, - {file = "lxml-5.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:da052e7962ea2d5e5ef5bc0355d55007407087392cf465b7ad84ce5f3e25fe0f"}, - {file = "lxml-5.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:70ac664a48aa64e5e635ae5566f5227f2ab7f66a3990d67566d9907edcbbf867"}, - {file = "lxml-5.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1ae67b4e737cddc96c99461d2f75d218bdf7a0c3d3ad5604d1f5e7464a2f9ffe"}, - {file = "lxml-5.2.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f18a5a84e16886898e51ab4b1d43acb3083c39b14c8caeb3589aabff0ee0b270"}, - {file = "lxml-5.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6f2c8372b98208ce609c9e1d707f6918cc118fea4e2c754c9f0812c04ca116d"}, - {file = "lxml-5.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:394ed3924d7a01b5bd9a0d9d946136e1c2f7b3dc337196d99e61740ed4bc6fe1"}, - {file = "lxml-5.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d077bc40a1fe984e1a9931e801e42959a1e6598edc8a3223b061d30fbd26bbc"}, - {file = "lxml-5.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:764b521b75701f60683500d8621841bec41a65eb739b8466000c6fdbc256c240"}, - {file = "lxml-5.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:3a6b45da02336895da82b9d472cd274b22dc27a5cea1d4b793874eead23dd14f"}, - {file = "lxml-5.2.1-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:5ea7b6766ac2dfe4bcac8b8595107665a18ef01f8c8343f00710b85096d1b53a"}, - {file = "lxml-5.2.1-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:e196a4ff48310ba62e53a8e0f97ca2bca83cdd2fe2934d8b5cb0df0a841b193a"}, - {file = "lxml-5.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:200e63525948e325d6a13a76ba2911f927ad399ef64f57898cf7c74e69b71095"}, - {file = "lxml-5.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dae0ed02f6b075426accbf6b2863c3d0a7eacc1b41fb40f2251d931e50188dad"}, - {file = "lxml-5.2.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:ab31a88a651039a07a3ae327d68ebdd8bc589b16938c09ef3f32a4b809dc96ef"}, - {file = "lxml-5.2.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:df2e6f546c4df14bc81f9498bbc007fbb87669f1bb707c6138878c46b06f6510"}, - {file = "lxml-5.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5dd1537e7cc06efd81371f5d1a992bd5ab156b2b4f88834ca852de4a8ea523fa"}, - {file = "lxml-5.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9b9ec9c9978b708d488bec36b9e4c94d88fd12ccac3e62134a9d17ddba910ea9"}, - {file = "lxml-5.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8e77c69d5892cb5ba71703c4057091e31ccf534bd7f129307a4d084d90d014b8"}, - {file = "lxml-5.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a8d5c70e04aac1eda5c829a26d1f75c6e5286c74743133d9f742cda8e53b9c2f"}, - {file = "lxml-5.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c94e75445b00319c1fad60f3c98b09cd63fe1134a8a953dcd48989ef42318534"}, - {file = "lxml-5.2.1-cp311-cp311-win32.whl", hash = "sha256:4951e4f7a5680a2db62f7f4ab2f84617674d36d2d76a729b9a8be4b59b3659be"}, - {file = "lxml-5.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:5c670c0406bdc845b474b680b9a5456c561c65cf366f8db5a60154088c92d102"}, - {file = "lxml-5.2.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:abc25c3cab9ec7fcd299b9bcb3b8d4a1231877e425c650fa1c7576c5107ab851"}, - {file = "lxml-5.2.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6935bbf153f9a965f1e07c2649c0849d29832487c52bb4a5c5066031d8b44fd5"}, - {file = "lxml-5.2.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d793bebb202a6000390a5390078e945bbb49855c29c7e4d56a85901326c3b5d9"}, - {file = "lxml-5.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd5562927cdef7c4f5550374acbc117fd4ecc05b5007bdfa57cc5355864e0a4"}, - {file = "lxml-5.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0e7259016bc4345a31af861fdce942b77c99049d6c2107ca07dc2bba2435c1d9"}, - {file = "lxml-5.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:530e7c04f72002d2f334d5257c8a51bf409db0316feee7c87e4385043be136af"}, - {file = "lxml-5.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59689a75ba8d7ffca577aefd017d08d659d86ad4585ccc73e43edbfc7476781a"}, - {file = "lxml-5.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f9737bf36262046213a28e789cc82d82c6ef19c85a0cf05e75c670a33342ac2c"}, - {file = "lxml-5.2.1-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:3a74c4f27167cb95c1d4af1c0b59e88b7f3e0182138db2501c353555f7ec57f4"}, - {file = "lxml-5.2.1-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:68a2610dbe138fa8c5826b3f6d98a7cfc29707b850ddcc3e21910a6fe51f6ca0"}, - {file = "lxml-5.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f0a1bc63a465b6d72569a9bba9f2ef0334c4e03958e043da1920299100bc7c08"}, - {file = "lxml-5.2.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c2d35a1d047efd68027817b32ab1586c1169e60ca02c65d428ae815b593e65d4"}, - {file = "lxml-5.2.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:79bd05260359170f78b181b59ce871673ed01ba048deef4bf49a36ab3e72e80b"}, - {file = "lxml-5.2.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:865bad62df277c04beed9478fe665b9ef63eb28fe026d5dedcb89b537d2e2ea6"}, - {file = "lxml-5.2.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:44f6c7caff88d988db017b9b0e4ab04934f11e3e72d478031efc7edcac6c622f"}, - {file = "lxml-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71e97313406ccf55d32cc98a533ee05c61e15d11b99215b237346171c179c0b0"}, - {file = "lxml-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:057cdc6b86ab732cf361f8b4d8af87cf195a1f6dc5b0ff3de2dced242c2015e0"}, - {file = "lxml-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f3bbbc998d42f8e561f347e798b85513ba4da324c2b3f9b7969e9c45b10f6169"}, - {file = "lxml-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:491755202eb21a5e350dae00c6d9a17247769c64dcf62d8c788b5c135e179dc4"}, - {file = "lxml-5.2.1-cp312-cp312-win32.whl", hash = "sha256:8de8f9d6caa7f25b204fc861718815d41cbcf27ee8f028c89c882a0cf4ae4134"}, - {file = "lxml-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:f2a9efc53d5b714b8df2b4b3e992accf8ce5bbdfe544d74d5c6766c9e1146a3a"}, - {file = "lxml-5.2.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:70a9768e1b9d79edca17890175ba915654ee1725975d69ab64813dd785a2bd5c"}, - {file = "lxml-5.2.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c38d7b9a690b090de999835f0443d8aa93ce5f2064035dfc48f27f02b4afc3d0"}, - {file = "lxml-5.2.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5670fb70a828663cc37552a2a85bf2ac38475572b0e9b91283dc09efb52c41d1"}, - {file = "lxml-5.2.1-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:958244ad566c3ffc385f47dddde4145088a0ab893504b54b52c041987a8c1863"}, - {file = "lxml-5.2.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b6241d4eee5f89453307c2f2bfa03b50362052ca0af1efecf9fef9a41a22bb4f"}, - {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:2a66bf12fbd4666dd023b6f51223aed3d9f3b40fef06ce404cb75bafd3d89536"}, - {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:9123716666e25b7b71c4e1789ec829ed18663152008b58544d95b008ed9e21e9"}, - {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:0c3f67e2aeda739d1cc0b1102c9a9129f7dc83901226cc24dd72ba275ced4218"}, - {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:5d5792e9b3fb8d16a19f46aa8208987cfeafe082363ee2745ea8b643d9cc5b45"}, - {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:88e22fc0a6684337d25c994381ed8a1580a6f5ebebd5ad41f89f663ff4ec2885"}, - {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:21c2e6b09565ba5b45ae161b438e033a86ad1736b8c838c766146eff8ceffff9"}, - {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_2_s390x.whl", hash = "sha256:afbbdb120d1e78d2ba8064a68058001b871154cc57787031b645c9142b937a62"}, - {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:627402ad8dea044dde2eccde4370560a2b750ef894c9578e1d4f8ffd54000461"}, - {file = "lxml-5.2.1-cp36-cp36m-win32.whl", hash = "sha256:e89580a581bf478d8dcb97d9cd011d567768e8bc4095f8557b21c4d4c5fea7d0"}, - {file = "lxml-5.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:59565f10607c244bc4c05c0c5fa0c190c990996e0c719d05deec7030c2aa8289"}, - {file = "lxml-5.2.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:857500f88b17a6479202ff5fe5f580fc3404922cd02ab3716197adf1ef628029"}, - {file = "lxml-5.2.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56c22432809085b3f3ae04e6e7bdd36883d7258fcd90e53ba7b2e463efc7a6af"}, - {file = "lxml-5.2.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a55ee573116ba208932e2d1a037cc4b10d2c1cb264ced2184d00b18ce585b2c0"}, - {file = "lxml-5.2.1-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:6cf58416653c5901e12624e4013708b6e11142956e7f35e7a83f1ab02f3fe456"}, - {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:64c2baa7774bc22dd4474248ba16fe1a7f611c13ac6123408694d4cc93d66dbd"}, - {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:74b28c6334cca4dd704e8004cba1955af0b778cf449142e581e404bd211fb619"}, - {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:7221d49259aa1e5a8f00d3d28b1e0b76031655ca74bb287123ef56c3db92f213"}, - {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3dbe858ee582cbb2c6294dc85f55b5f19c918c2597855e950f34b660f1a5ede6"}, - {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:04ab5415bf6c86e0518d57240a96c4d1fcfc3cb370bb2ac2a732b67f579e5a04"}, - {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:6ab833e4735a7e5533711a6ea2df26459b96f9eec36d23f74cafe03631647c41"}, - {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f443cdef978430887ed55112b491f670bba6462cea7a7742ff8f14b7abb98d75"}, - {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:9e2addd2d1866fe112bc6f80117bcc6bc25191c5ed1bfbcf9f1386a884252ae8"}, - {file = "lxml-5.2.1-cp37-cp37m-win32.whl", hash = "sha256:f51969bac61441fd31f028d7b3b45962f3ecebf691a510495e5d2cd8c8092dbd"}, - {file = "lxml-5.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:b0b58fbfa1bf7367dde8a557994e3b1637294be6cf2169810375caf8571a085c"}, - {file = "lxml-5.2.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3e183c6e3298a2ed5af9d7a356ea823bccaab4ec2349dc9ed83999fd289d14d5"}, - {file = "lxml-5.2.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:804f74efe22b6a227306dd890eecc4f8c59ff25ca35f1f14e7482bbce96ef10b"}, - {file = "lxml-5.2.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:08802f0c56ed150cc6885ae0788a321b73505d2263ee56dad84d200cab11c07a"}, - {file = "lxml-5.2.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f8c09ed18ecb4ebf23e02b8e7a22a05d6411911e6fabef3a36e4f371f4f2585"}, - {file = "lxml-5.2.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3d30321949861404323c50aebeb1943461a67cd51d4200ab02babc58bd06a86"}, - {file = "lxml-5.2.1-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:b560e3aa4b1d49e0e6c847d72665384db35b2f5d45f8e6a5c0072e0283430533"}, - {file = "lxml-5.2.1-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:058a1308914f20784c9f4674036527e7c04f7be6fb60f5d61353545aa7fcb739"}, - {file = "lxml-5.2.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:adfb84ca6b87e06bc6b146dc7da7623395db1e31621c4785ad0658c5028b37d7"}, - {file = "lxml-5.2.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:417d14450f06d51f363e41cace6488519038f940676ce9664b34ebf5653433a5"}, - {file = "lxml-5.2.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a2dfe7e2473f9b59496247aad6e23b405ddf2e12ef0765677b0081c02d6c2c0b"}, - {file = "lxml-5.2.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bf2e2458345d9bffb0d9ec16557d8858c9c88d2d11fed53998512504cd9df49b"}, - {file = "lxml-5.2.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:58278b29cb89f3e43ff3e0c756abbd1518f3ee6adad9e35b51fb101c1c1daaec"}, - {file = "lxml-5.2.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:64641a6068a16201366476731301441ce93457eb8452056f570133a6ceb15fca"}, - {file = "lxml-5.2.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:78bfa756eab503673991bdcf464917ef7845a964903d3302c5f68417ecdc948c"}, - {file = "lxml-5.2.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:11a04306fcba10cd9637e669fd73aa274c1c09ca64af79c041aa820ea992b637"}, - {file = "lxml-5.2.1-cp38-cp38-win32.whl", hash = "sha256:66bc5eb8a323ed9894f8fa0ee6cb3e3fb2403d99aee635078fd19a8bc7a5a5da"}, - {file = "lxml-5.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:9676bfc686fa6a3fa10cd4ae6b76cae8be26eb5ec6811d2a325636c460da1806"}, - {file = "lxml-5.2.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cf22b41fdae514ee2f1691b6c3cdeae666d8b7fa9434de445f12bbeee0cf48dd"}, - {file = "lxml-5.2.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ec42088248c596dbd61d4ae8a5b004f97a4d91a9fd286f632e42e60b706718d7"}, - {file = "lxml-5.2.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd53553ddad4a9c2f1f022756ae64abe16da1feb497edf4d9f87f99ec7cf86bd"}, - {file = "lxml-5.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feaa45c0eae424d3e90d78823f3828e7dc42a42f21ed420db98da2c4ecf0a2cb"}, - {file = "lxml-5.2.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddc678fb4c7e30cf830a2b5a8d869538bc55b28d6c68544d09c7d0d8f17694dc"}, - {file = "lxml-5.2.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:853e074d4931dbcba7480d4dcab23d5c56bd9607f92825ab80ee2bd916edea53"}, - {file = "lxml-5.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc4691d60512798304acb9207987e7b2b7c44627ea88b9d77489bbe3e6cc3bd4"}, - {file = "lxml-5.2.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:beb72935a941965c52990f3a32d7f07ce869fe21c6af8b34bf6a277b33a345d3"}, - {file = "lxml-5.2.1-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:6588c459c5627fefa30139be4d2e28a2c2a1d0d1c265aad2ba1935a7863a4913"}, - {file = "lxml-5.2.1-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:588008b8497667f1ddca7c99f2f85ce8511f8f7871b4a06ceede68ab62dff64b"}, - {file = "lxml-5.2.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b6787b643356111dfd4032b5bffe26d2f8331556ecb79e15dacb9275da02866e"}, - {file = "lxml-5.2.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7c17b64b0a6ef4e5affae6a3724010a7a66bda48a62cfe0674dabd46642e8b54"}, - {file = "lxml-5.2.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:27aa20d45c2e0b8cd05da6d4759649170e8dfc4f4e5ef33a34d06f2d79075d57"}, - {file = "lxml-5.2.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:d4f2cc7060dc3646632d7f15fe68e2fa98f58e35dd5666cd525f3b35d3fed7f8"}, - {file = "lxml-5.2.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff46d772d5f6f73564979cd77a4fffe55c916a05f3cb70e7c9c0590059fb29ef"}, - {file = "lxml-5.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:96323338e6c14e958d775700ec8a88346014a85e5de73ac7967db0367582049b"}, - {file = "lxml-5.2.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:52421b41ac99e9d91934e4d0d0fe7da9f02bfa7536bb4431b4c05c906c8c6919"}, - {file = "lxml-5.2.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:7a7efd5b6d3e30d81ec68ab8a88252d7c7c6f13aaa875009fe3097eb4e30b84c"}, - {file = "lxml-5.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0ed777c1e8c99b63037b91f9d73a6aad20fd035d77ac84afcc205225f8f41188"}, - {file = "lxml-5.2.1-cp39-cp39-win32.whl", hash = "sha256:644df54d729ef810dcd0f7732e50e5ad1bd0a135278ed8d6bcb06f33b6b6f708"}, - {file = "lxml-5.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:9ca66b8e90daca431b7ca1408cae085d025326570e57749695d6a01454790e95"}, - {file = "lxml-5.2.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9b0ff53900566bc6325ecde9181d89afadc59c5ffa39bddf084aaedfe3b06a11"}, - {file = "lxml-5.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd6037392f2d57793ab98d9e26798f44b8b4da2f2464388588f48ac52c489ea1"}, - {file = "lxml-5.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b9c07e7a45bb64e21df4b6aa623cb8ba214dfb47d2027d90eac197329bb5e94"}, - {file = "lxml-5.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3249cc2989d9090eeac5467e50e9ec2d40704fea9ab72f36b034ea34ee65ca98"}, - {file = "lxml-5.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f42038016852ae51b4088b2862126535cc4fc85802bfe30dea3500fdfaf1864e"}, - {file = "lxml-5.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:533658f8fbf056b70e434dff7e7aa611bcacb33e01f75de7f821810e48d1bb66"}, - {file = "lxml-5.2.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:622020d4521e22fb371e15f580d153134bfb68d6a429d1342a25f051ec72df1c"}, - {file = "lxml-5.2.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efa7b51824aa0ee957ccd5a741c73e6851de55f40d807f08069eb4c5a26b2baa"}, - {file = "lxml-5.2.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c6ad0fbf105f6bcc9300c00010a2ffa44ea6f555df1a2ad95c88f5656104817"}, - {file = "lxml-5.2.1-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e233db59c8f76630c512ab4a4daf5a5986da5c3d5b44b8e9fc742f2a24dbd460"}, - {file = "lxml-5.2.1-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6a014510830df1475176466b6087fc0c08b47a36714823e58d8b8d7709132a96"}, - {file = "lxml-5.2.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:d38c8f50ecf57f0463399569aa388b232cf1a2ffb8f0a9a5412d0db57e054860"}, - {file = "lxml-5.2.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5aea8212fb823e006b995c4dda533edcf98a893d941f173f6c9506126188860d"}, - {file = "lxml-5.2.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff097ae562e637409b429a7ac958a20aab237a0378c42dabaa1e3abf2f896e5f"}, - {file = "lxml-5.2.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f5d65c39f16717a47c36c756af0fb36144069c4718824b7533f803ecdf91138"}, - {file = "lxml-5.2.1-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3d0c3dd24bb4605439bf91068598d00c6370684f8de4a67c2992683f6c309d6b"}, - {file = "lxml-5.2.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e32be23d538753a8adb6c85bd539f5fd3b15cb987404327c569dfc5fd8366e85"}, - {file = "lxml-5.2.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:cc518cea79fd1e2f6c90baafa28906d4309d24f3a63e801d855e7424c5b34144"}, - {file = "lxml-5.2.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a0af35bd8ebf84888373630f73f24e86bf016642fb8576fba49d3d6b560b7cbc"}, - {file = "lxml-5.2.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8aca2e3a72f37bfc7b14ba96d4056244001ddcc18382bd0daa087fd2e68a354"}, - {file = "lxml-5.2.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ca1e8188b26a819387b29c3895c47a5e618708fe6f787f3b1a471de2c4a94d9"}, - {file = "lxml-5.2.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c8ba129e6d3b0136a0f50345b2cb3db53f6bda5dd8c7f5d83fbccba97fb5dcb5"}, - {file = "lxml-5.2.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e998e304036198b4f6914e6a1e2b6f925208a20e2042563d9734881150c6c246"}, - {file = "lxml-5.2.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d3be9b2076112e51b323bdf6d5a7f8a798de55fb8d95fcb64bd179460cdc0704"}, - {file = "lxml-5.2.1.tar.gz", hash = "sha256:3f7765e69bbce0906a7c74d5fe46d2c7a7596147318dbc08e4a2431f3060e306"}, + {file = "lxml-5.2.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:364d03207f3e603922d0d3932ef363d55bbf48e3647395765f9bfcbdf6d23632"}, + {file = "lxml-5.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50127c186f191b8917ea2fb8b206fbebe87fd414a6084d15568c27d0a21d60db"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74e4f025ef3db1c6da4460dd27c118d8cd136d0391da4e387a15e48e5c975147"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:981a06a3076997adf7c743dcd0d7a0415582661e2517c7d961493572e909aa1d"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aef5474d913d3b05e613906ba4090433c515e13ea49c837aca18bde190853dff"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e275ea572389e41e8b039ac076a46cb87ee6b8542df3fff26f5baab43713bca"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5b65529bb2f21ac7861a0e94fdbf5dc0daab41497d18223b46ee8515e5ad297"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:bcc98f911f10278d1daf14b87d65325851a1d29153caaf146877ec37031d5f36"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:b47633251727c8fe279f34025844b3b3a3e40cd1b198356d003aa146258d13a2"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:fbc9d316552f9ef7bba39f4edfad4a734d3d6f93341232a9dddadec4f15d425f"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:13e69be35391ce72712184f69000cda04fc89689429179bc4c0ae5f0b7a8c21b"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3b6a30a9ab040b3f545b697cb3adbf3696c05a3a68aad172e3fd7ca73ab3c835"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:a233bb68625a85126ac9f1fc66d24337d6e8a0f9207b688eec2e7c880f012ec0"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:dfa7c241073d8f2b8e8dbc7803c434f57dbb83ae2a3d7892dd068d99e96efe2c"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a7aca7964ac4bb07680d5c9d63b9d7028cace3e2d43175cb50bba8c5ad33316"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ae4073a60ab98529ab8a72ebf429f2a8cc612619a8c04e08bed27450d52103c0"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ffb2be176fed4457e445fe540617f0252a72a8bc56208fd65a690fdb1f57660b"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e290d79a4107d7d794634ce3e985b9ae4f920380a813717adf61804904dc4393"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96e85aa09274955bb6bd483eaf5b12abadade01010478154b0ec70284c1b1526"}, + {file = "lxml-5.2.2-cp310-cp310-win32.whl", hash = "sha256:f956196ef61369f1685d14dad80611488d8dc1ef00be57c0c5a03064005b0f30"}, + {file = "lxml-5.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:875a3f90d7eb5c5d77e529080d95140eacb3c6d13ad5b616ee8095447b1d22e7"}, + {file = "lxml-5.2.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:45f9494613160d0405682f9eee781c7e6d1bf45f819654eb249f8f46a2c22545"}, + {file = "lxml-5.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b0b3f2df149efb242cee2ffdeb6674b7f30d23c9a7af26595099afaf46ef4e88"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d28cb356f119a437cc58a13f8135ab8a4c8ece18159eb9194b0d269ec4e28083"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:657a972f46bbefdbba2d4f14413c0d079f9ae243bd68193cb5061b9732fa54c1"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b74b9ea10063efb77a965a8d5f4182806fbf59ed068b3c3fd6f30d2ac7bee734"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07542787f86112d46d07d4f3c4e7c760282011b354d012dc4141cc12a68cef5f"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:303f540ad2dddd35b92415b74b900c749ec2010e703ab3bfd6660979d01fd4ed"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:2eb2227ce1ff998faf0cd7fe85bbf086aa41dfc5af3b1d80867ecfe75fb68df3"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:1d8a701774dfc42a2f0b8ccdfe7dbc140500d1049e0632a611985d943fcf12df"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:56793b7a1a091a7c286b5f4aa1fe4ae5d1446fe742d00cdf2ffb1077865db10d"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eb00b549b13bd6d884c863554566095bf6fa9c3cecb2e7b399c4bc7904cb33b5"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a2569a1f15ae6c8c64108a2cd2b4a858fc1e13d25846be0666fc144715e32ab"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:8cf85a6e40ff1f37fe0f25719aadf443686b1ac7652593dc53c7ef9b8492b115"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:d237ba6664b8e60fd90b8549a149a74fcc675272e0e95539a00522e4ca688b04"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0b3f5016e00ae7630a4b83d0868fca1e3d494c78a75b1c7252606a3a1c5fc2ad"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:23441e2b5339bc54dc949e9e675fa35efe858108404ef9aa92f0456929ef6fe8"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb0ba3e8566548d6c8e7dd82a8229ff47bd8fb8c2da237607ac8e5a1b8312e5"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:79d1fb9252e7e2cfe4de6e9a6610c7cbb99b9708e2c3e29057f487de5a9eaefa"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6dcc3d17eac1df7859ae01202e9bb11ffa8c98949dcbeb1069c8b9a75917e01b"}, + {file = "lxml-5.2.2-cp311-cp311-win32.whl", hash = "sha256:4c30a2f83677876465f44c018830f608fa3c6a8a466eb223535035fbc16f3438"}, + {file = "lxml-5.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:49095a38eb333aaf44c06052fd2ec3b8f23e19747ca7ec6f6c954ffea6dbf7be"}, + {file = "lxml-5.2.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7429e7faa1a60cad26ae4227f4dd0459efde239e494c7312624ce228e04f6391"}, + {file = "lxml-5.2.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:50ccb5d355961c0f12f6cf24b7187dbabd5433f29e15147a67995474f27d1776"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc911208b18842a3a57266d8e51fc3cfaccee90a5351b92079beed912a7914c2"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33ce9e786753743159799fdf8e92a5da351158c4bfb6f2db0bf31e7892a1feb5"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec87c44f619380878bd49ca109669c9f221d9ae6883a5bcb3616785fa8f94c97"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08ea0f606808354eb8f2dfaac095963cb25d9d28e27edcc375d7b30ab01abbf6"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75a9632f1d4f698b2e6e2e1ada40e71f369b15d69baddb8968dcc8e683839b18"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:74da9f97daec6928567b48c90ea2c82a106b2d500f397eeb8941e47d30b1ca85"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:0969e92af09c5687d769731e3f39ed62427cc72176cebb54b7a9d52cc4fa3b73"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:9164361769b6ca7769079f4d426a41df6164879f7f3568be9086e15baca61466"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d26a618ae1766279f2660aca0081b2220aca6bd1aa06b2cf73f07383faf48927"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab67ed772c584b7ef2379797bf14b82df9aa5f7438c5b9a09624dd834c1c1aaf"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:3d1e35572a56941b32c239774d7e9ad724074d37f90c7a7d499ab98761bd80cf"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:8268cbcd48c5375f46e000adb1390572c98879eb4f77910c6053d25cc3ac2c67"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e282aedd63c639c07c3857097fc0e236f984ceb4089a8b284da1c526491e3f3d"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfdc2bfe69e9adf0df4915949c22a25b39d175d599bf98e7ddf620a13678585"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4aefd911793b5d2d7a921233a54c90329bf3d4a6817dc465f12ffdfe4fc7b8fe"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8b8df03a9e995b6211dafa63b32f9d405881518ff1ddd775db4e7b98fb545e1c"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f11ae142f3a322d44513de1018b50f474f8f736bc3cd91d969f464b5bfef8836"}, + {file = "lxml-5.2.2-cp312-cp312-win32.whl", hash = "sha256:16a8326e51fcdffc886294c1e70b11ddccec836516a343f9ed0f82aac043c24a"}, + {file = "lxml-5.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:bbc4b80af581e18568ff07f6395c02114d05f4865c2812a1f02f2eaecf0bfd48"}, + {file = "lxml-5.2.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e3d9d13603410b72787579769469af730c38f2f25505573a5888a94b62b920f8"}, + {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38b67afb0a06b8575948641c1d6d68e41b83a3abeae2ca9eed2ac59892b36706"}, + {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c689d0d5381f56de7bd6966a4541bff6e08bf8d3871bbd89a0c6ab18aa699573"}, + {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:cf2a978c795b54c539f47964ec05e35c05bd045db5ca1e8366988c7f2fe6b3ce"}, + {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:739e36ef7412b2bd940f75b278749106e6d025e40027c0b94a17ef7968d55d56"}, + {file = "lxml-5.2.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:d8bbcd21769594dbba9c37d3c819e2d5847656ca99c747ddb31ac1701d0c0ed9"}, + {file = "lxml-5.2.2-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:2304d3c93f2258ccf2cf7a6ba8c761d76ef84948d87bf9664e14d203da2cd264"}, + {file = "lxml-5.2.2-cp36-cp36m-win32.whl", hash = "sha256:02437fb7308386867c8b7b0e5bc4cd4b04548b1c5d089ffb8e7b31009b961dc3"}, + {file = "lxml-5.2.2-cp36-cp36m-win_amd64.whl", hash = "sha256:edcfa83e03370032a489430215c1e7783128808fd3e2e0a3225deee278585196"}, + {file = "lxml-5.2.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:28bf95177400066596cdbcfc933312493799382879da504633d16cf60bba735b"}, + {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a745cc98d504d5bd2c19b10c79c61c7c3df9222629f1b6210c0368177589fb8"}, + {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b336b0416828022bfd5a2e3083e7f5ba54b96242159f83c7e3eebaec752f1716"}, + {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:4bc6cb140a7a0ad1f7bc37e018d0ed690b7b6520ade518285dc3171f7a117905"}, + {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:57f0a0bbc9868e10ebe874e9f129d2917750adf008fe7b9c1598c0fbbfdde6a6"}, + {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:60499fe961b21264e17a471ec296dcbf4365fbea611bf9e303ab69db7159ce61"}, + {file = "lxml-5.2.2-cp37-cp37m-win32.whl", hash = "sha256:d9b342c76003c6b9336a80efcc766748a333573abf9350f4094ee46b006ec18f"}, + {file = "lxml-5.2.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b16db2770517b8799c79aa80f4053cd6f8b716f21f8aca962725a9565ce3ee40"}, + {file = "lxml-5.2.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7ed07b3062b055d7a7f9d6557a251cc655eed0b3152b76de619516621c56f5d3"}, + {file = "lxml-5.2.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f60fdd125d85bf9c279ffb8e94c78c51b3b6a37711464e1f5f31078b45002421"}, + {file = "lxml-5.2.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a7e24cb69ee5f32e003f50e016d5fde438010c1022c96738b04fc2423e61706"}, + {file = "lxml-5.2.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23cfafd56887eaed93d07bc4547abd5e09d837a002b791e9767765492a75883f"}, + {file = "lxml-5.2.2-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:19b4e485cd07b7d83e3fe3b72132e7df70bfac22b14fe4bf7a23822c3a35bff5"}, + {file = "lxml-5.2.2-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:7ce7ad8abebe737ad6143d9d3bf94b88b93365ea30a5b81f6877ec9c0dee0a48"}, + {file = "lxml-5.2.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e49b052b768bb74f58c7dda4e0bdf7b79d43a9204ca584ffe1fb48a6f3c84c66"}, + {file = "lxml-5.2.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d14a0d029a4e176795cef99c056d58067c06195e0c7e2dbb293bf95c08f772a3"}, + {file = "lxml-5.2.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:be49ad33819d7dcc28a309b86d4ed98e1a65f3075c6acd3cd4fe32103235222b"}, + {file = "lxml-5.2.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a6d17e0370d2516d5bb9062c7b4cb731cff921fc875644c3d751ad857ba9c5b1"}, + {file = "lxml-5.2.2-cp38-cp38-win32.whl", hash = "sha256:5b8c041b6265e08eac8a724b74b655404070b636a8dd6d7a13c3adc07882ef30"}, + {file = "lxml-5.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:f61efaf4bed1cc0860e567d2ecb2363974d414f7f1f124b1df368bbf183453a6"}, + {file = "lxml-5.2.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:fb91819461b1b56d06fa4bcf86617fac795f6a99d12239fb0c68dbeba41a0a30"}, + {file = "lxml-5.2.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d4ed0c7cbecde7194cd3228c044e86bf73e30a23505af852857c09c24e77ec5d"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54401c77a63cc7d6dc4b4e173bb484f28a5607f3df71484709fe037c92d4f0ed"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:625e3ef310e7fa3a761d48ca7ea1f9d8718a32b1542e727d584d82f4453d5eeb"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:519895c99c815a1a24a926d5b60627ce5ea48e9f639a5cd328bda0515ea0f10c"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c7079d5eb1c1315a858bbf180000757db8ad904a89476653232db835c3114001"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:343ab62e9ca78094f2306aefed67dcfad61c4683f87eee48ff2fd74902447726"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:cd9e78285da6c9ba2d5c769628f43ef66d96ac3085e59b10ad4f3707980710d3"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:546cf886f6242dff9ec206331209db9c8e1643ae642dea5fdbecae2453cb50fd"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:02f6a8eb6512fdc2fd4ca10a49c341c4e109aa6e9448cc4859af5b949622715a"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:339ee4a4704bc724757cd5dd9dc8cf4d00980f5d3e6e06d5847c1b594ace68ab"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0a028b61a2e357ace98b1615fc03f76eb517cc028993964fe08ad514b1e8892d"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:f90e552ecbad426eab352e7b2933091f2be77115bb16f09f78404861c8322981"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:d83e2d94b69bf31ead2fa45f0acdef0757fa0458a129734f59f67f3d2eb7ef32"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a02d3c48f9bb1e10c7788d92c0c7db6f2002d024ab6e74d6f45ae33e3d0288a3"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6d68ce8e7b2075390e8ac1e1d3a99e8b6372c694bbe612632606d1d546794207"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:453d037e09a5176d92ec0fd282e934ed26d806331a8b70ab431a81e2fbabf56d"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:3b019d4ee84b683342af793b56bb35034bd749e4cbdd3d33f7d1107790f8c472"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb3942960f0beb9f46e2a71a3aca220d1ca32feb5a398656be934320804c0df9"}, + {file = "lxml-5.2.2-cp39-cp39-win32.whl", hash = "sha256:ac6540c9fff6e3813d29d0403ee7a81897f1d8ecc09a8ff84d2eea70ede1cdbf"}, + {file = "lxml-5.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:610b5c77428a50269f38a534057444c249976433f40f53e3b47e68349cca1425"}, + {file = "lxml-5.2.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b537bd04d7ccd7c6350cdaaaad911f6312cbd61e6e6045542f781c7f8b2e99d2"}, + {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4820c02195d6dfb7b8508ff276752f6b2ff8b64ae5d13ebe02e7667e035000b9"}, + {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a09f6184f17a80897172863a655467da2b11151ec98ba8d7af89f17bf63dae"}, + {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:76acba4c66c47d27c8365e7c10b3d8016a7da83d3191d053a58382311a8bf4e1"}, + {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b128092c927eaf485928cec0c28f6b8bead277e28acf56800e972aa2c2abd7a2"}, + {file = "lxml-5.2.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ae791f6bd43305aade8c0e22f816b34f3b72b6c820477aab4d18473a37e8090b"}, + {file = "lxml-5.2.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a2f6a1bc2460e643785a2cde17293bd7a8f990884b822f7bca47bee0a82fc66b"}, + {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e8d351ff44c1638cb6e980623d517abd9f580d2e53bfcd18d8941c052a5a009"}, + {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bec4bd9133420c5c52d562469c754f27c5c9e36ee06abc169612c959bd7dbb07"}, + {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:55ce6b6d803890bd3cc89975fca9de1dff39729b43b73cb15ddd933b8bc20484"}, + {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8ab6a358d1286498d80fe67bd3d69fcbc7d1359b45b41e74c4a26964ca99c3f8"}, + {file = "lxml-5.2.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:06668e39e1f3c065349c51ac27ae430719d7806c026fec462e5693b08b95696b"}, + {file = "lxml-5.2.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9cd5323344d8ebb9fb5e96da5de5ad4ebab993bbf51674259dbe9d7a18049525"}, + {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89feb82ca055af0fe797a2323ec9043b26bc371365847dbe83c7fd2e2f181c34"}, + {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e481bba1e11ba585fb06db666bfc23dbe181dbafc7b25776156120bf12e0d5a6"}, + {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d6c6ea6a11ca0ff9cd0390b885984ed31157c168565702959c25e2191674a14"}, + {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3d98de734abee23e61f6b8c2e08a88453ada7d6486dc7cdc82922a03968928db"}, + {file = "lxml-5.2.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:69ab77a1373f1e7563e0fb5a29a8440367dec051da6c7405333699d07444f511"}, + {file = "lxml-5.2.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:34e17913c431f5ae01d8658dbf792fdc457073dcdfbb31dc0cc6ab256e664a8d"}, + {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05f8757b03208c3f50097761be2dea0aba02e94f0dc7023ed73a7bb14ff11eb0"}, + {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a520b4f9974b0a0a6ed73c2154de57cdfd0c8800f4f15ab2b73238ffed0b36e"}, + {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5e097646944b66207023bc3c634827de858aebc226d5d4d6d16f0b77566ea182"}, + {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b5e4ef22ff25bfd4ede5f8fb30f7b24446345f3e79d9b7455aef2836437bc38a"}, + {file = "lxml-5.2.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ff69a9a0b4b17d78170c73abe2ab12084bdf1691550c5629ad1fe7849433f324"}, + {file = "lxml-5.2.2.tar.gz", hash = "sha256:bb2dc4898180bea79863d5487e5f9c7c34297414bad54bcd0f0852aee9cfdb87"}, ] [package.extras] @@ -1552,6 +1535,50 @@ files = [ {file = "mistune-0.8.4.tar.gz", hash = "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e"}, ] +[[package]] +name = "moto" +version = "5.0.9" +description = "" +optional = false +python-versions = ">=3.8" +files = [ + {file = "moto-5.0.9-py2.py3-none-any.whl", hash = "sha256:21a13e02f83d6a18cfcd99949c96abb2e889f4bd51c4c6a3ecc8b78765cb854e"}, + {file = "moto-5.0.9.tar.gz", hash = "sha256:eb71f1cba01c70fff1f16086acb24d6d9aeb32830d646d8989f98a29aeae24ba"}, +] + +[package.dependencies] +boto3 = ">=1.9.201" +botocore = ">=1.14.0" +cryptography = ">=3.3.1" +Jinja2 = ">=2.10.1" +python-dateutil = ">=2.1,<3.0.0" +requests = ">=2.5" +responses = ">=0.15.0" +werkzeug = ">=0.5,<2.2.0 || >2.2.0,<2.2.1 || >2.2.1" +xmltodict = "*" + +[package.extras] +all = ["PyYAML (>=5.1)", "antlr4-python3-runtime", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "graphql-core", "joserfc (>=0.9.0)", "jsondiff (>=1.1.2)", "jsonpath-ng", "multipart", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.5.5)", "pyparsing (>=3.0.7)", "setuptools"] +apigateway = ["PyYAML (>=5.1)", "joserfc (>=0.9.0)", "openapi-spec-validator (>=0.5.0)"] +apigatewayv2 = ["PyYAML (>=5.1)", "openapi-spec-validator (>=0.5.0)"] +appsync = ["graphql-core"] +awslambda = ["docker (>=3.0.0)"] +batch = ["docker (>=3.0.0)"] +cloudformation = ["PyYAML (>=5.1)", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "graphql-core", "joserfc (>=0.9.0)", "jsondiff (>=1.1.2)", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.5.5)", "pyparsing (>=3.0.7)", "setuptools"] +cognitoidp = ["joserfc (>=0.9.0)"] +dynamodb = ["docker (>=3.0.0)", "py-partiql-parser (==0.5.5)"] +dynamodbstreams = ["docker (>=3.0.0)", "py-partiql-parser (==0.5.5)"] +glue = ["pyparsing (>=3.0.7)"] +iotdata = ["jsondiff (>=1.1.2)"] +proxy = ["PyYAML (>=5.1)", "antlr4-python3-runtime", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=2.5.1)", "graphql-core", "joserfc (>=0.9.0)", "jsondiff (>=1.1.2)", "jsonpath-ng", "multipart", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.5.5)", "pyparsing (>=3.0.7)", "setuptools"] +resourcegroupstaggingapi = ["PyYAML (>=5.1)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "graphql-core", "joserfc (>=0.9.0)", "jsondiff (>=1.1.2)", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.5.5)", "pyparsing (>=3.0.7)"] +s3 = ["PyYAML (>=5.1)", "py-partiql-parser (==0.5.5)"] +s3crc32c = ["PyYAML (>=5.1)", "crc32c", "py-partiql-parser (==0.5.5)"] +server = ["PyYAML (>=5.1)", "antlr4-python3-runtime", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "flask (!=2.2.0,!=2.2.1)", "flask-cors", "graphql-core", "joserfc (>=0.9.0)", "jsondiff (>=1.1.2)", "jsonpath-ng", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.5.5)", "pyparsing (>=3.0.7)", "setuptools"] +ssm = ["PyYAML (>=5.1)"] +stepfunctions = ["antlr4-python3-runtime", "jsonpath-ng"] +xray = ["aws-xray-sdk (>=0.93,!=0.96)", "setuptools"] + [[package]] name = "msgpack" version = "1.0.8" @@ -1671,18 +1698,15 @@ infinite-tracing = ["grpcio", "protobuf"] [[package]] name = "nodeenv" -version = "1.8.0" +version = "1.9.1" description = "Node.js virtual environment builder" optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ - {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, - {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] -[package.dependencies] -setuptools = "*" - [[package]] name = "notifications-python-client" version = "9.1.0" @@ -1823,13 +1847,13 @@ files = [ [[package]] name = "phonenumbers" -version = "8.13.37" +version = "8.13.38" description = "Python version of Google's common library for parsing, formatting, storing and validating international phone numbers." optional = false python-versions = "*" files = [ - {file = "phonenumbers-8.13.37-py2.py3-none-any.whl", hash = "sha256:4ea00ef5012422c08c7955c21131e7ae5baa9a3ef52cf2d561e963f023006b80"}, - {file = "phonenumbers-8.13.37.tar.gz", hash = "sha256:bd315fed159aea0516f7c367231810fe8344d5bec26156b88fa18374c11d1cf2"}, + {file = "phonenumbers-8.13.38-py2.py3-none-any.whl", hash = "sha256:d22aa747fb591ef2a18afec13cab5a0e294ab20fce5a1560e4949e459e70eeef"}, + {file = "phonenumbers-8.13.38.tar.gz", hash = "sha256:2822c74ee9334e9d8ad792fc352cc8d21004307349b6b1bb61da12937fa2eaba"}, ] [[package]] @@ -1906,13 +1930,13 @@ testing = ["aboutcode-toolkit (>=6.0.0)", "black", "pytest (>=6,!=7.0.0)", "pyte [[package]] name = "platformdirs" -version = "4.2.1" +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.1-py3-none-any.whl", hash = "sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1"}, - {file = "platformdirs-4.2.1.tar.gz", hash = "sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf"}, + {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, + {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, ] [package.extras] @@ -1922,18 +1946,18 @@ type = ["mypy (>=1.8)"] [[package]] name = "playwright" -version = "1.43.0" +version = "1.44.0" description = "A high-level API to automate web browsers" optional = false python-versions = ">=3.8" files = [ - {file = "playwright-1.43.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:b03b12bd4da9c2cfb78dff820deac8b52892fe3c2f89a4d95d6f08c59e41deb9"}, - {file = "playwright-1.43.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e9ec21b141727392f630761c7f4dec46d80c98243614257cc501b64ff636d337"}, - {file = "playwright-1.43.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:e05a8d8fb2040c630429cca07e843c8fa33059717837c8f50c01b7d1fc651ce1"}, - {file = "playwright-1.43.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:50d9a5c07c76456945a2296d63f78fdf6eb11aed3e8d39bb5ccbda760a8d6d41"}, - {file = "playwright-1.43.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87191272c40b4c282cf2c9449ca3acaf705f38ac6e2372270f1617ce16b661b8"}, - {file = "playwright-1.43.0-py3-none-win32.whl", hash = "sha256:bd8b818904b17e2914be23e7bc2a340b203f57fe81678520b10f908485b056ea"}, - {file = "playwright-1.43.0-py3-none-win_amd64.whl", hash = "sha256:9b7bd707eeeaebee47f656b2de90aa9bd85e9ca2c6af7a08efd73896299e4d50"}, + {file = "playwright-1.44.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:c2317a80896796fdeb03d60f06cc229e775ff2e19b80c64b1bb9b29c8a59d992"}, + {file = "playwright-1.44.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:54d44fb634d870839301c2326e1e12a178a1be0de76d0caaec230ab075c2e077"}, + {file = "playwright-1.44.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:64b67194e73b47ae72acf25f1a9cfacfef38ca2b52e4bb8b0abd385c5deeaadf"}, + {file = "playwright-1.44.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:29161b1fae71f7c402df5b15f0bd3deaeecd8b3d1ecd9ff01271700c66210e7b"}, + {file = "playwright-1.44.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8c8a3bfea17576d3f94a2363eee195cbda8dbba86975588c7eaac7792b25eee"}, + {file = "playwright-1.44.0-py3-none-win32.whl", hash = "sha256:235e37832deaa9af8a629d09955396259ab757533cc1922f9b0308b4ee0d9cdf"}, + {file = "playwright-1.44.0-py3-none-win_amd64.whl", hash = "sha256:5b8a4a1d4d50f4ff99b47965576322a8c4e34631854b862a25c1feb824be22a8"}, ] [package.dependencies] @@ -2141,17 +2165,16 @@ files = [ [[package]] name = "pygments" -version = "2.17.2" +version = "2.18.0" description = "Pygments is a syntax highlighting package written in Python." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, - {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, ] [package.extras] -plugins = ["importlib-metadata"] windows-terminal = ["colorama (>=0.4.6)"] [[package]] @@ -2226,13 +2249,13 @@ certifi = "*" [[package]] name = "pytest" -version = "8.2.1" +version = "8.2.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.2.1-py3-none-any.whl", hash = "sha256:faccc5d332b8c3719f40283d0d44aa5cf101cec36f88cde9ed8f2bc0538612b1"}, - {file = "pytest-8.2.1.tar.gz", hash = "sha256:5046e5b46d8e4cac199c373041f26be56fdb81eb4e67dc11d4e10811fc3408fd"}, + {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"}, + {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"}, ] [package.dependencies] @@ -2618,6 +2641,25 @@ requests = ">=2.22,<3" [package.extras] fixture = ["fixtures"] +[[package]] +name = "responses" +version = "0.25.0" +description = "A utility library for mocking out the `requests` Python library." +optional = false +python-versions = ">=3.8" +files = [ + {file = "responses-0.25.0-py3-none-any.whl", hash = "sha256:2f0b9c2b6437db4b528619a77e5d565e4ec2a9532162ac1a131a83529db7be1a"}, + {file = "responses-0.25.0.tar.gz", hash = "sha256:01ae6a02b4f34e39bffceb0fc6786b67a25eae919c6368d05eabc8d9576c2a66"}, +] + +[package.dependencies] +pyyaml = "*" +requests = ">=2.30.0,<3.0" +urllib3 = ">=1.25.10,<3.0" + +[package.extras] +tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-PyYAML", "types-requests"] + [[package]] name = "rich" version = "13.7.1" @@ -2667,22 +2709,6 @@ botocore = ">=1.33.2,<2.0a.0" [package.extras] crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"] -[[package]] -name = "setuptools" -version = "69.5.1" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "setuptools-69.5.1-py3-none-any.whl", hash = "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"}, - {file = "setuptools-69.5.1.tar.gz", hash = "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] - [[package]] name = "shapely" version = "2.0.4" @@ -2832,13 +2858,13 @@ files = [ [[package]] name = "typing-extensions" -version = "4.11.0" +version = "4.12.1" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, - {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, + {file = "typing_extensions-4.12.1-py3-none-any.whl", hash = "sha256:6024b58b69089e5a89c347397254e35f1bf02a907728ec7fee9bf0fe837d203a"}, + {file = "typing_extensions-4.12.1.tar.gz", hash = "sha256:915f5e35ff76f56588223f15fdd5938f9a1cf9195c0de25130c627e4d597f6d1"}, ] [[package]] @@ -2860,13 +2886,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.26.1" +version = "20.26.2" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.26.1-py3-none-any.whl", hash = "sha256:7aa9982a728ae5892558bff6a2839c00b9ed145523ece2274fad6f414690ae75"}, - {file = "virtualenv-20.26.1.tar.gz", hash = "sha256:604bfdceaeece392802e6ae48e69cec49168b9c5f4a44e483963f9242eb0e78b"}, + {file = "virtualenv-20.26.2-py3-none-any.whl", hash = "sha256:a624db5e94f01ad993d476b9ee5346fdf7b9de43ccaee0e0197012dc838a0e9b"}, + {file = "virtualenv-20.26.2.tar.gz", hash = "sha256:82bf0f4eebbb78d36ddaee0283d43fe5736b53880b8a8cdcd37390a07ac3741c"}, ] [package.dependencies] @@ -2961,7 +2987,18 @@ files = [ {file = "xlwt-1.3.0.tar.gz", hash = "sha256:c59912717a9b28f1a3c2a98fd60741014b06b043936dcecbc113eaaada156c88"}, ] +[[package]] +name = "xmltodict" +version = "0.13.0" +description = "Makes working with XML feel like you are working with JSON" +optional = false +python-versions = ">=3.4" +files = [ + {file = "xmltodict-0.13.0-py2.py3-none-any.whl", hash = "sha256:aa89e8fd76320154a40d19a0df04a4695fb9dc5ba977cbb68ab3e4eb225e7852"}, + {file = "xmltodict-0.13.0.tar.gz", hash = "sha256:341595a488e3e01a85a9d8911d8912fd922ede5fecc4dce437eb4b6c8d037e56"}, +] + [metadata] lock-version = "2.0" python-versions = "^3.12.2" -content-hash = "dbfc0e66e41f6da3845785157b95ef70193bc66c91b53fe0afb0eac0a1ea5490" +content-hash = "e96b87c048826ecb23d826c45b516aee60532cd2677a22332dc86fef498a411d" diff --git a/pyproject.toml b/pyproject.toml index 59c1a23b5..07c768f1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,18 +39,18 @@ wtforms = "~=3.1" markdown = "^3.5.2" async-timeout = "^4.0.3" bleach = "^6.1.0" -boto3 = "^1.34.113" -botocore = "^1.34.115" +boto3 = "^1.34.119" +botocore = "^1.34.119" cachetools = "^5.3.3" cffi = "^1.16.0" -cryptography = "^42.0.7" +cryptography = "^42.0.8" flask-redis = "^0.4.0" geojson = "^3.1.0" jmespath = "^1.0.1" mistune = "0.8.4" numpy = "^1.26.4" ordered-set = "^4.1.0" -phonenumbers = "^8.13.37" +phonenumbers = "^8.13.38" pycparser = "^2.22" python-json-logger = "^2.0.7" redis = "^5.0.4" @@ -69,6 +69,7 @@ requests = "^2.32.3" six = "^1.16.0" urllib3 = "^2.2.1" webencodings = "^0.5.1" +flask-socketio = "^5.3.6" [tool.poetry.group.dev.dependencies] @@ -82,9 +83,10 @@ flake8-print = "^5.0.0" flake8-pytest-style = "^1.7.2" isort = "^5.13.2" jinja2-cli = {version = "==0.8.2", extras = ["yaml"]} +moto = "*" pip-audit = "*" pre-commit = "^3.7.1" -pytest = "^8.2.1" +pytest = "^8.2.2" pytest-env = "^1.1.3" pytest-mock = "^3.14.0" pytest-playwright = "^0.5.0" diff --git a/tests/app/main/views/test_dashboard.py b/tests/app/main/views/test_dashboard.py index 926163dbd..285444b1b 100644 --- a/tests/app/main/views/test_dashboard.py +++ b/tests/app/main/views/test_dashboard.py @@ -3,9 +3,11 @@ import json from datetime import datetime import pytest -from flask import url_for +from flask import Flask, url_for +from flask_socketio import SocketIOTestClient from freezegun import freeze_time +from app import create_app, socketio from app.main.views.dashboard import ( aggregate_notifications_stats, aggregate_status_types, @@ -23,6 +25,7 @@ from tests import ( from tests.conftest import ( ORGANISATION_ID, SERVICE_ONE_ID, + SERVICE_TWO_ID, create_active_caseworking_user, create_active_user_view_permissions, normalize_spaces, @@ -1875,3 +1878,76 @@ def test_service_dashboard_shows_batched_jobs( assert job_table_body is not None assert len(rows) == 1 + + +@pytest.fixture() +def app_with_socketio(): + app = Flask("app") + create_app(app) + return app, socketio + + +@pytest.mark.parametrize( + ("service_id", "date_range", "expected_call_args"), + [ + ( + SERVICE_ONE_ID, + {"start_date": "2024-01-01", "days": 7}, + {"service_id": SERVICE_ONE_ID, "start_date": "2024-01-01", "days": 7} + ), + ( + SERVICE_TWO_ID, + {"start_date": "2023-06-01", "days": 7}, + {"service_id": SERVICE_TWO_ID, "start_date": "2023-06-01", "days": 7} + ), + ] +) +def test_fetch_daily_stats( + app_with_socketio, mocker, + service_id, + date_range, + expected_call_args +): + app, socketio = app_with_socketio + + mocker.patch( + "app.main.views.dashboard.get_stats_date_range", + return_value=date_range + ) + + mock_service_api = mocker.patch( + "app.service_api_client.get_service_notification_statistics_by_day", + return_value={ + date_range["start_date"]: { + "email": {"delivered": 0, "failure": 0, "requested": 0}, + "sms": {"delivered": 0, "failure": 1, "requested": 1} + }, + } + ) + + client = SocketIOTestClient(app, socketio) + try: + connected = client.is_connected() + assert connected, "Client should be connected" + + client.emit('fetch_daily_stats', service_id) + + received = client.get_received() + assert received, "Should receive a response message" + assert received[0]['name'] == 'daily_stats_update' + assert received[0]['args'][0] == { + date_range["start_date"]: { + "email": {"delivered": 0, "failure": 0, "requested": 0}, + "sms": {"delivered": 0, "failure": 1, "requested": 1} + }, + } + + mock_service_api.assert_called_once_with( + service_id, + start_date=expected_call_args["start_date"], + days=expected_call_args["days"] + ) + finally: + client.disconnect() + disconnected = not client.is_connected() + assert disconnected, "Client should be disconnected" diff --git a/tests/app/main/views/test_index.py b/tests/app/main/views/test_index.py index 4ee5a817f..57bdfaa70 100644 --- a/tests/app/main/views/test_index.py +++ b/tests/app/main/views/test_index.py @@ -179,26 +179,6 @@ def test_old_static_pages_redirect(client_request, view, expected_view): ) -def test_message_status_page_contains_message_status_ids(client_request): - # The 'email-statuses' and 'sms-statuses' id are linked to when we display a message status, - # so this test ensures we don't accidentally remove them - page = client_request.get("main.message_status") - - # email-statuses is commented out in view - # assert page.find(id='email-statuses') - assert page.find(id="text-message-statuses") - - -def test_message_status_page_contains_link_to_support(client_request): - page = client_request.get("main.message_status") - sms_status_table = page.find(id="text-message-statuses").findNext("tbody") - - temp_fail_details_cell = sms_status_table.select_one( - "tr:nth-child(4) > td:nth-child(2)" - ) - assert temp_fail_details_cell.find("a").attrs["href"] == url_for("main.support") - - def test_old_using_notify_page(client_request): client_request.get("main.using_notify", _expected_status=410) diff --git a/tests/app/main/views/test_send.py b/tests/app/main/views/test_send.py index 449259d8d..24403e895 100644 --- a/tests/app/main/views/test_send.py +++ b/tests/app/main/views/test_send.py @@ -6,6 +6,7 @@ from io import BytesIO from itertools import repeat from os import path from random import randbytes +from unittest.mock import ANY from uuid import uuid4 from zipfile import BadZipFile @@ -17,7 +18,11 @@ from xlrd.xldate import XLDateAmbiguous, XLDateError, XLDateNegative, XLDateTooL from notifications_utils.recipients import RecipientCSV from notifications_utils.template import SMSPreviewTemplate -from tests import validate_route_permission, validate_route_permission_with_client +from tests import ( + sample_uuid, + validate_route_permission, + validate_route_permission_with_client, +) from tests.conftest import ( SERVICE_ONE_ID, create_active_caseworking_user, @@ -434,10 +439,15 @@ def test_upload_files_in_different_formats( service_one, mocker, mock_get_service_template, - mock_s3_set_metadata, - mock_s3_upload, fake_uuid, ): + + mock_s3_set_metadata = mocker.patch( + "app.main.views.send.set_metadata_on_csv_upload" + ) + + mock_s3_upload = mocker.patch("app.main.views.send.s3upload") + with open(filename, "rb") as uploaded: page = client_request.post( "main.send_messages", @@ -456,7 +466,7 @@ def test_upload_files_in_different_formats( "202 205 8823,Still Not Pete,Crimson,Pear" ) mock_s3_set_metadata.assert_called_once_with( - SERVICE_ONE_ID, fake_uuid, original_file_name=filename + SERVICE_ONE_ID, ANY, original_file_name=filename ) else: assert not mock_s3_upload.called @@ -470,13 +480,16 @@ def test_send_messages_sanitises_and_truncates_file_name_for_metadata( service_one, mocker, mock_get_service_template_with_placeholders, - mock_s3_set_metadata, - mock_s3_get_metadata, - mock_s3_upload, - mock_s3_download, mock_get_job_doesnt_exist, fake_uuid, ): + + mock_s3_set_metadata = mocker.patch( + "app.main.views.send.set_metadata_on_csv_upload" + ) + + mocker.patch("app.main.views.send.s3upload") + filename = f"😁{'a' * 2000}.csv" client_request.post( @@ -577,15 +590,20 @@ def test_upload_csv_file_with_errors_shows_check_page_with_errors( service_one, mocker, mock_get_service_template_with_placeholders, - mock_s3_set_metadata, - mock_s3_get_metadata, - mock_s3_upload, mock_get_users_by_service, mock_get_service_statistics, mock_get_job_doesnt_exist, mock_get_jobs, fake_uuid, ): + + mocker.patch("app.main.views.send.set_metadata_on_csv_upload") + + mocker.patch( + "app.main.views.send.get_csv_metadata", + return_value={"original_file_name": "example.csv"}, + ) + mocker.patch("app.main.views.send.s3upload", return_value=sample_uuid()) mocker.patch( "app.main.views.send.s3download", return_value=""" @@ -624,15 +642,21 @@ def test_upload_csv_file_with_empty_message_shows_check_page_with_errors( service_one, mocker, mock_get_empty_service_template_with_optional_placeholder, - mock_s3_set_metadata, - mock_s3_get_metadata, - mock_s3_upload, mock_get_users_by_service, mock_get_service_statistics, mock_get_job_doesnt_exist, mock_get_jobs, fake_uuid, ): + + mocker.patch("app.main.views.send.set_metadata_on_csv_upload") + + mocker.patch( + "app.main.views.send.get_csv_metadata", + return_value={"original_file_name": "example.csv"}, + ) + mocker.patch("app.main.views.send.s3upload", return_value=sample_uuid()) + mocker.patch( "app.main.views.send.s3download", return_value=""" @@ -676,15 +700,20 @@ def test_upload_csv_file_with_very_long_placeholder_shows_check_page_with_errors service_one, mocker, mock_get_service_template_with_placeholders, - mock_s3_set_metadata, - mock_s3_get_metadata, - mock_s3_upload, mock_get_users_by_service, mock_get_service_statistics, mock_get_job_doesnt_exist, mock_get_jobs, fake_uuid, ): + + mocker.patch("app.main.views.send.set_metadata_on_csv_upload") + + mocker.patch( + "app.main.views.send.get_csv_metadata", + return_value={"original_file_name": "example.csv"}, + ) + mocker.patch("app.main.views.send.s3upload", return_value=sample_uuid()) big_placeholder = " ".join(["not ok"] * 402) mocker.patch( "app.main.views.send.s3download", @@ -811,9 +840,6 @@ def test_upload_csv_file_with_missing_columns_shows_error( client_request, mocker, mock_get_service_template_with_placeholders, - mock_s3_set_metadata, - mock_s3_get_metadata, - mock_s3_upload, mock_get_users_by_service, mock_get_service_statistics, mock_get_job_doesnt_exist, @@ -823,6 +849,15 @@ def test_upload_csv_file_with_missing_columns_shows_error( file_contents, expected_error, ): + + mocker.patch("app.main.views.send.set_metadata_on_csv_upload") + + mocker.patch( + "app.main.views.send.get_csv_metadata", + return_value={"original_file_name": "example.csv"}, + ) + mocker.patch("app.main.views.send.s3upload", return_value=sample_uuid()) + mocker.patch("app.main.views.send.s3download", return_value=file_contents) page = client_request.post( @@ -885,10 +920,13 @@ def test_upload_csv_size_too_big( def test_upload_valid_csv_redirects_to_check_page( client_request, mock_get_service_template_with_placeholders, - mock_s3_upload, - mock_s3_set_metadata, fake_uuid, + mocker, ): + + mocker.patch("app.main.views.send.set_metadata_on_csv_upload") + + mocker.patch("app.main.views.send.s3upload", return_value=sample_uuid()) client_request.post( "main.send_messages", service_id=SERVICE_ONE_ID, @@ -937,13 +975,16 @@ def test_upload_valid_csv_shows_preview_and_table( mock_get_service_statistics, mock_get_job_doesnt_exist, mock_get_jobs, - mock_s3_get_metadata, - mock_s3_set_metadata, fake_uuid, extra_args, expected_link_in_first_row, expected_message, ): + + mocker.patch( + "app.main.views.send.get_csv_metadata", + return_value={"original_file_name": "example.csv"}, + ) with client_request.session_transaction() as session: session["file_uploads"] = {fake_uuid: {"template_id": fake_uuid}} @@ -1031,8 +1072,12 @@ def test_show_all_columns_if_there_are_duplicate_recipient_columns( mock_get_job_doesnt_exist, mock_get_jobs, fake_uuid, - mock_s3_get_metadata, ): + + mocker.patch( + "app.main.views.send.get_csv_metadata", + return_value={"original_file_name": "example.csv"}, + ) with client_request.session_transaction() as session: session["file_uploads"] = {fake_uuid: {"template_id": fake_uuid}} @@ -1080,12 +1125,17 @@ def test_404_for_previewing_a_row_out_of_range( mock_get_service_statistics, mock_get_job_doesnt_exist, mock_get_jobs, - mock_s3_get_metadata, - mock_s3_set_metadata, fake_uuid, row_index, expected_status, ): + + mocker.patch("app.main.views.send.set_metadata_on_csv_upload") + + mocker.patch( + "app.main.views.send.get_csv_metadata", + return_value={"original_file_name": "example.csv"}, + ) with client_request.session_transaction() as session: session["file_uploads"] = {fake_uuid: {"template_id": fake_uuid}} @@ -1523,7 +1573,6 @@ def test_send_one_off_redirects_to_start_if_you_skip_steps( client_request, service_one, fake_uuid, - mock_s3_upload, mock_get_users_by_service, mock_get_service_statistics, mock_has_no_jobs, @@ -1559,7 +1608,6 @@ def test_send_one_off_redirects_to_start_if_index_out_of_bounds_and_some_placeho service_one, fake_uuid, mock_get_service_email_template, - mock_s3_download, mock_get_users_by_service, mock_get_service_statistics, mock_has_no_jobs, @@ -1628,7 +1676,6 @@ def test_send_one_off_email_to_self_without_placeholders_redirects_to_check_page mocker, service_one, mock_get_service_email_template_without_placeholders, - mock_s3_upload, mock_get_users_by_service, mock_get_service_statistics, mock_has_no_jobs, @@ -1835,13 +1882,20 @@ def test_upload_csvfile_with_valid_phone_shows_all_numbers( mock_get_live_service, mock_get_job_doesnt_exist, mock_get_jobs, - mock_s3_get_metadata, - mock_s3_set_metadata, service_one, fake_uuid, - mock_s3_upload, mocker, ): + + mock_s3_set_metadata = mocker.patch( + "app.main.views.send.set_metadata_on_csv_upload" + ) + + mocker.patch( + "app.main.views.send.get_csv_metadata", + return_value={"original_file_name": "example.csv"}, + ) + mocker.patch("app.main.views.send.s3upload", return_value=sample_uuid()) mocker.patch( "app.main.views.send.s3download", return_value="\n".join( @@ -1895,9 +1949,6 @@ def test_upload_csvfile_with_international_validates( api_user_active, client_request, mock_get_service_template, - mock_s3_set_metadata, - mock_s3_get_metadata, - mock_s3_upload, mock_has_permissions, mock_get_users_by_service, mock_get_service_statistics, @@ -1908,6 +1959,14 @@ def test_upload_csvfile_with_international_validates( should_allow_international, service_one, ): + + mocker.patch("app.main.views.send.set_metadata_on_csv_upload") + + mocker.patch( + "app.main.views.send.get_csv_metadata", + return_value={"original_file_name": "example.csv"}, + ) + mocker.patch("app.main.views.send.s3upload", return_value=sample_uuid()) if international_sms_permission: service_one["permissions"] += ("sms", "international_sms") mocker.patch( @@ -1942,15 +2001,20 @@ def test_test_message_can_only_be_sent_now( mocker, service_one, mock_get_service_template, - mock_s3_download, mock_get_users_by_service, mock_get_service_statistics, mock_get_job_doesnt_exist, mock_get_jobs, - mock_s3_get_metadata, - mock_s3_set_metadata, + mock_s3_download, fake_uuid, ): + + mocker.patch("app.main.views.send.set_metadata_on_csv_upload") + + mocker.patch( + "app.main.views.send.get_csv_metadata", + return_value={"original_file_name": "example.csv"}, + ) content = client_request.get( "main.check_messages", service_id=service_one["id"], @@ -1972,8 +2036,12 @@ def test_preview_button_is_correctly_labelled( mock_get_job_doesnt_exist, mock_get_jobs, fake_uuid, - mock_s3_get_metadata, ): + + mocker.patch( + "app.main.views.send.get_csv_metadata", + return_value={"original_file_name": "example.csv"}, + ) mocker.patch( "app.main.views.send.s3download", return_value="\n".join(["phone_number"] + (["2028670123"] * 1000)), @@ -2058,7 +2126,6 @@ def test_route_permissions( mock_get_jobs, mock_get_notifications, mock_create_job, - mock_s3_upload, fake_uuid, route, response_code, @@ -2092,8 +2159,8 @@ def test_route_permissions_send_check_notifications( response_code, method, mock_create_job, - mock_s3_upload, ): + mocker.patch("app.main.views.send.s3upload", return_value=sample_uuid()) with client_request.session_transaction() as session: session["recipient"] = "2028675301" session["placeholders"] = {"name": "a"} @@ -2173,8 +2240,6 @@ def test_check_messages_back_link( mock_get_job_doesnt_exist, mock_get_jobs, mock_s3_download, - mock_s3_get_metadata, - mock_s3_set_metadata, fake_uuid, mocker, template_type, @@ -2182,6 +2247,13 @@ def test_check_messages_back_link( extra_args, expected_url, ): + + mocker.patch("app.main.views.send.set_metadata_on_csv_upload") + + mocker.patch( + "app.main.views.send.get_csv_metadata", + return_value={"original_file_name": "example.csv"}, + ) content = "Hi there ((name))" if has_placeholders else "Hi there" template_data = create_template( template_id=fake_uuid, template_type=template_type, content=content @@ -2236,8 +2308,12 @@ def test_check_messages_shows_too_many_messages_errors( fake_uuid, num_requested, expected_msg, - mock_s3_get_metadata, ): + + mocker.patch( + "app.main.views.send.get_csv_metadata", + return_value={"original_file_name": "example.csv"}, + ) # csv with 100 phone numbers mocker.patch( "app.main.views.send.s3download", @@ -2281,7 +2357,6 @@ def test_check_messages_shows_too_many_messages_errors( def test_check_messages_shows_trial_mode_error( client_request, - mock_s3_get_metadata, mock_get_users_by_service, mock_get_service_template, mock_has_permissions, @@ -2291,6 +2366,11 @@ def test_check_messages_shows_trial_mode_error( fake_uuid, mocker, ): + + mocker.patch( + "app.main.views.send.get_csv_metadata", + return_value={"original_file_name": "example.csv"}, + ) mocker.patch( "app.main.views.send.s3download", return_value=("phone number,\n2028675209"), # Not in team @@ -2426,8 +2506,12 @@ def test_check_messages_column_error_doesnt_show_optional_columns( mock_get_service_statistics, mock_get_job_doesnt_exist, mock_get_jobs, - mock_s3_get_metadata, ): + + mocker.patch( + "app.main.views.send.get_csv_metadata", + return_value={"original_file_name": "example.csv"}, + ) mocker.patch( "app.main.views.send.s3download", return_value="\n".join( @@ -2468,10 +2552,17 @@ def test_check_messages_adds_sender_id_in_session_to_metadata( mock_get_service_statistics, mock_get_job_doesnt_exist, mock_get_jobs, - mock_s3_get_metadata, - mock_s3_set_metadata, fake_uuid, ): + + mock_s3_set_metadata = mocker.patch( + "app.main.views.send.set_metadata_on_csv_upload" + ) + + mocker.patch( + "app.main.views.send.get_csv_metadata", + return_value={"original_file_name": "example.csv"}, + ) mocker.patch( "app.main.views.send.s3download", return_value=("phone number,\n2028675209") ) @@ -2508,11 +2599,15 @@ def test_check_messages_shows_over_max_row_error( mock_get_service_statistics, mock_get_job_doesnt_exist, mock_get_jobs, - mock_s3_get_metadata, mock_s3_download, fake_uuid, mocker, ): + + mocker.patch( + "app.main.views.send.get_csv_metadata", + return_value={"original_file_name": "example.csv"}, + ) mock_recipients = mocker.patch("app.main.views.send.RecipientCSV").return_value mock_recipients.max_rows = 11111 mock_recipients.__len__.return_value = 99999 @@ -2662,8 +2757,8 @@ def test_send_notification_submits_data( expected_personalisation, mocker, mock_create_job, - mock_s3_upload, ): + mocker.patch("app.main.views.send.s3upload", return_value=sample_uuid()) with client_request.session_transaction() as session: session["recipient"] = recipient session["placeholders"] = placeholders @@ -2690,8 +2785,8 @@ def test_send_notification_clears_session( mock_get_service_template, mocker, mock_create_job, - mock_s3_upload, ): + mocker.patch("app.main.views.send.s3upload", return_value=sample_uuid()) with client_request.session_transaction() as session: session["recipient"] = "2028675301" session["placeholders"] = {"a": "b"} @@ -2752,8 +2847,8 @@ def test_send_notification_redirects_to_view_page( extra_redirect_args, mocker, mock_create_job, - mock_s3_upload, ): + mocker.patch("app.main.views.send.s3upload", return_value=sample_uuid()) with client_request.session_transaction() as session: session["recipient"] = "2028675301" session["placeholders"] = {"a": "b"} @@ -2812,8 +2907,9 @@ def test_send_notification_shows_error_if_400( exception_msg, expected_h1, expected_err_details, - mock_s3_upload, ): + mocker.patch("app.main.views.send.s3upload", return_value=sample_uuid()) + class MockHTTPError(HTTPError): message = exception_msg @@ -2851,8 +2947,9 @@ def test_send_notification_shows_email_error_in_trial_mode( mocker, mock_get_service_email_template, mock_create_job, - mock_s3_upload, ): + mocker.patch("app.main.views.send.s3upload", return_value=sample_uuid()) + class MockHTTPError(HTTPError): message = TRIAL_MODE_MSG status_code = 400 @@ -2897,9 +2994,6 @@ def test_reply_to_is_previewed_if_chosen( client_request, mocker, mock_get_service_email_template, - mock_s3_download, - mock_s3_get_metadata, - mock_s3_set_metadata, mock_get_users_by_service, mock_get_service_statistics, mock_get_job_doesnt_exist, @@ -2910,6 +3004,8 @@ def test_reply_to_is_previewed_if_chosen( extra_args, reply_to_address, ): + mocker.patch("app.main.views.send.set_metadata_on_csv_upload") + mocker.patch( "app.main.views.send.s3download", return_value=""" @@ -2952,9 +3048,6 @@ def test_sms_sender_is_previewed( client_request, mocker, mock_get_service_template, - mock_s3_download, - mock_s3_get_metadata, - mock_s3_set_metadata, mock_get_users_by_service, mock_get_service_statistics, mock_get_job_doesnt_exist, @@ -2965,6 +3058,13 @@ def test_sms_sender_is_previewed( extra_args, sms_sender, ): + + mocker.patch("app.main.views.send.set_metadata_on_csv_upload") + + mocker.patch( + "app.main.views.send.get_csv_metadata", + return_value={"original_file_name": "example.csv"}, + ) mocker.patch( "app.main.views.send.s3download", return_value=""" diff --git a/tests/conftest.py b/tests/conftest.py index 00451bdf7..592ec9403 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1843,14 +1843,6 @@ def mock_get_users_by_service(mocker): ) -@pytest.fixture() -def mock_s3_upload(mocker): - def _upload(service_id, filedata): - return sample_uuid() - - return mocker.patch("app.main.views.send.s3upload", side_effect=_upload) - - @pytest.fixture() def mock_s3_download(mocker): def _download(service_id, upload_id): @@ -1863,21 +1855,6 @@ def mock_s3_download(mocker): return mocker.patch("app.main.views.send.s3download", side_effect=_download) -@pytest.fixture() -def mock_s3_get_metadata(mocker): - def _get_metadata(service_id, upload_id): - return {"original_file_name": "example.csv"} - - return mocker.patch( - "app.main.views.send.get_csv_metadata", side_effect=_get_metadata - ) - - -@pytest.fixture() -def mock_s3_set_metadata(mocker): - return mocker.patch("app.main.views.send.set_metadata_on_csv_upload") - - @pytest.fixture() def sample_invite(mocker, service_one): id_ = USER_ONE_ID diff --git a/tests/end_to_end/test_send_message_from_existing_template.py b/tests/end_to_end/test_send_message_from_existing_template.py index 535ad6c5f..56ced9b62 100644 --- a/tests/end_to_end/test_send_message_from_existing_template.py +++ b/tests/end_to_end/test_send_message_from_existing_template.py @@ -191,7 +191,7 @@ def handle_no_existing_template_case(page): in content ) assert "12025555555" in content - assert "one-off-e2e_test_user" in content + assert "one-off-" in content os.remove("download_test_file") diff --git a/tests/notifications_utils/clients/antivirus/test_antivirus_client.py b/tests/notifications_utils/clients/antivirus/test_antivirus_client.py deleted file mode 100644 index e19327e55..000000000 --- a/tests/notifications_utils/clients/antivirus/test_antivirus_client.py +++ /dev/null @@ -1,71 +0,0 @@ -import io - -import pytest -import requests - -from notifications_utils.clients.antivirus.antivirus_client import ( - AntivirusClient, - AntivirusError, -) - - -@pytest.fixture() -def antivirus(app, mocker): - client = AntivirusClient() - app.config["ANTIVIRUS_API_HOST"] = "https://antivirus" - app.config["ANTIVIRUS_API_KEY"] = "test-antivirus-key" - client.init_app(app) - return client - - -def test_scan_document(antivirus, rmock): - document = io.BytesIO(b"filecontents") - rmock.request( - "POST", - "https://antivirus/scan", - json={"ok": True}, - request_headers={ - "Authorization": "Bearer test-antivirus-key", - }, - status_code=200, - ) - - resp = antivirus.scan(document) - - assert resp - assert "filecontents" in rmock.last_request.text - assert document.tell() == 0 - - -def test_should_raise_for_status(antivirus, rmock): - with pytest.raises(AntivirusError) as excinfo: - _test_one_statement_for_status(antivirus, rmock) - - assert excinfo.value.message == "Antivirus error" - assert excinfo.value.status_code == 400 - - -def _test_one_statement_for_status(antivirus, rmock): - rmock.request( - "POST", - "https://antivirus/scan", - json={"error": "Antivirus error"}, - status_code=400, - ) - - antivirus.scan(io.BytesIO(b"document")) - - -def test_should_raise_for_connection_errors(antivirus, rmock): - with pytest.raises(AntivirusError) as excinfo: - _test_one_statement_for_connection_errors(antivirus, rmock) - - assert excinfo.value.message == "connection error" - assert excinfo.value.status_code == 503 - - -def _test_one_statement_for_connection_errors(antivirus, rmock): - rmock.request( - "POST", "https://antivirus/scan", exc=requests.exceptions.ConnectTimeout - ) - antivirus.scan(io.BytesIO(b"document")) diff --git a/tests/notifications_utils/clients/encryption/test_encryption_client.py b/tests/notifications_utils/clients/encryption/test_encryption_client.py deleted file mode 100644 index c392ba529..000000000 --- a/tests/notifications_utils/clients/encryption/test_encryption_client.py +++ /dev/null @@ -1,88 +0,0 @@ -import pytest - -from notifications_utils.clients.encryption.encryption_client import ( - Encryption, - EncryptionError, -) - - -@pytest.fixture() -def encryption_client(app): - client = Encryption() - - app.config["SECRET_KEY"] = "test-notify-secret-key" - app.config["DANGEROUS_SALT"] = "test-notify-salt" - - client.init_app(app) - - return client - - -def test_should_ensure_shared_salt_security(app): - client = Encryption() - app.config["SECRET_KEY"] = "test-notify-secret-key" - app.config["DANGEROUS_SALT"] = "too-short" - with pytest.raises(EncryptionError): - client.init_app(app) - - -def test_should_ensure_custom_salt_security(encryption_client): - with pytest.raises(EncryptionError): - encryption_client.encrypt("this", salt="too-short") - - -def test_should_encrypt_strings(encryption_client): - encrypted = encryption_client.encrypt("this") - assert encrypted != "this" - assert isinstance(encrypted, str) - - -def test_should_encrypt_dicts(encryption_client): - to_encrypt = {"hello": "world"} - encrypted = encryption_client.encrypt(to_encrypt) - assert encrypted != to_encrypt - assert encryption_client.decrypt(encrypted) == to_encrypt - - -def test_encryption_is_nondeterministic(encryption_client): - first_run = encryption_client.encrypt("this") - second_run = encryption_client.encrypt("this") - assert first_run != second_run - - -def test_should_decrypt_content(encryption_client): - encrypted = encryption_client.encrypt("this") - assert encryption_client.decrypt(encrypted) == "this" - - -def test_should_decrypt_content_with_custom_salt(encryption_client): - salt = "different-salt-value" - encrypted = encryption_client.encrypt("this", salt=salt) - assert encryption_client.decrypt(encrypted, salt=salt) == "this" - - -def test_should_verify_decryption(encryption_client): - encrypted = encryption_client.encrypt("this") - with pytest.raises(EncryptionError): - encryption_client.decrypt(encrypted, salt="different-salt-value") - - -def test_should_sign_and_serialize_string(encryption_client): - signed = encryption_client.sign("this") - assert signed != "this" - - -def test_should_verify_signature_and_deserialize_string(encryption_client): - signed = encryption_client.sign("this") - assert encryption_client.verify_signature(signed) == "this" - - -def test_should_raise_encryption_error_on_bad_salt(encryption_client): - signed = encryption_client.sign("this") - with pytest.raises(EncryptionError): - encryption_client.verify_signature(signed, salt="different-salt-value") - - -def test_should_sign_and_serialize_json(encryption_client): - signed = encryption_client.sign({"this": "that"}) - assert encryption_client.verify_signature(signed) == {"this": "that"} diff --git a/tests/notifications_utils/clients/zendesk/test_zendesk_client.py b/tests/notifications_utils/clients/zendesk/test_zendesk_client.py deleted file mode 100644 index d89bf466f..000000000 --- a/tests/notifications_utils/clients/zendesk/test_zendesk_client.py +++ /dev/null @@ -1,227 +0,0 @@ -from base64 import b64decode - -import pytest - -from notifications_utils.clients.zendesk.zendesk_client import ( - NotifySupportTicket, - ZendeskClient, - ZendeskError, -) - - -@pytest.fixture() -def zendesk_client(app): - client = ZendeskClient() - - app.config["ZENDESK_API_KEY"] = "testkey" - - client.init_app(app) - - return client - - -def test_zendesk_client_send_ticket_to_zendesk(zendesk_client, app, mocker, rmock): - rmock.request( - "POST", - ZendeskClient.ZENDESK_TICKET_URL, - status_code=201, - json={ - "ticket": { - "id": 12345, - "subject": "Something is wrong", - } - }, - ) - mock_logger = mocker.patch.object(app.logger, "info") - - ticket = NotifySupportTicket("subject", "message", "incident") - zendesk_client.send_ticket_to_zendesk(ticket) - - assert rmock.last_request.headers["Authorization"][:6] == "Basic " - b64_auth = rmock.last_request.headers["Authorization"][6:] - assert ( - b64decode(b64_auth.encode()).decode() - == "zd-api-notify@digital.cabinet-office.gov.uk/token:testkey" - ) - assert rmock.last_request.json() == ticket.request_data - mock_logger.assert_called_once_with("Zendesk create ticket 12345 succeeded") - - -def test_zendesk_client_send_ticket_to_zendesk_error( - zendesk_client, app, mocker, rmock -): - rmock.request( - "POST", ZendeskClient.ZENDESK_TICKET_URL, status_code=401, json={"foo": "bar"} - ) - - mock_logger = mocker.patch.object(app.logger, "error") - - ticket = NotifySupportTicket("subject", "message", "incident") - - with pytest.raises(ZendeskError): - zendesk_client.send_ticket_to_zendesk(ticket) - - mock_logger.assert_called_with( - "Zendesk create ticket request failed with 401 '{'foo': 'bar'}'" - ) - - -@pytest.mark.parametrize( - ("p1_arg", "expected_tags", "expected_priority"), - [ - ( - {}, - ["govuk_notify_support"], - "normal", - ), - ( - { - "p1": False, - }, - ["govuk_notify_support"], - "normal", - ), - ( - { - "p1": True, - }, - ["govuk_notify_emergency"], - "urgent", - ), - ], -) -def test_notify_support_ticket_request_data(p1_arg, expected_tags, expected_priority): - notify_ticket_form = NotifySupportTicket("subject", "message", "question", **p1_arg) - - assert notify_ticket_form.request_data == { - "ticket": { - "subject": "subject", - "comment": { - "body": "message", - "public": True, - }, - "group_id": NotifySupportTicket.NOTIFY_GROUP_ID, - "organization_id": NotifySupportTicket.NOTIFY_ORG_ID, - "ticket_form_id": NotifySupportTicket.NOTIFY_TICKET_FORM_ID, - "priority": expected_priority, - "tags": expected_tags, - "type": "question", - "custom_fields": [ - {"id": "1900000744994", "value": "notify_ticket_type_non_technical"}, - {"id": "360022836500", "value": []}, - {"id": "360022943959", "value": None}, - {"id": "360022943979", "value": None}, - {"id": "1900000745014", "value": None}, - ], - } - } - - -def test_notify_support_ticket_request_data_with_message_hidden_from_requester(): - notify_ticket_form = NotifySupportTicket( - "subject", "message", "problem", requester_sees_message_content=False - ) - - assert notify_ticket_form.request_data["ticket"]["comment"]["public"] is False - - -@pytest.mark.parametrize( - ("name", "zendesk_name"), [("Name", "Name"), (None, "(no name supplied)")] -) -def test_notify_support_ticket_request_data_with_user_name_and_email( - name, zendesk_name -): - notify_ticket_form = NotifySupportTicket( - "subject", "message", "question", user_name=name, user_email="user@example.com" - ) - - assert ( - notify_ticket_form.request_data["ticket"]["requester"]["email"] - == "user@example.com" - ) - assert ( - notify_ticket_form.request_data["ticket"]["requester"]["name"] == zendesk_name - ) - - -@pytest.mark.parametrize( - ( - "custom_fields", - "tech_ticket_tag", - "categories", - "org_id", - "org_type", - "service_id", - ), - [ - ( - {"technical_ticket": True}, - "notify_ticket_type_technical", - [], - None, - None, - None, - ), - ( - {"technical_ticket": False}, - "notify_ticket_type_non_technical", - [], - None, - None, - None, - ), - ( - {"ticket_categories": ["notify_billing", "notify_bug"]}, - "notify_ticket_type_non_technical", - ["notify_billing", "notify_bug"], - None, - None, - None, - ), - ( - {"org_id": "1234", "org_type": "local"}, - "notify_ticket_type_non_technical", - [], - "1234", - "notify_org_type_local", - None, - ), - ( - {"service_id": "abcd", "org_type": "nhs"}, - "notify_ticket_type_non_technical", - [], - None, - "notify_org_type_nhs", - "abcd", - ), - ], -) -def test_notify_support_ticket_request_data_custom_fields( - custom_fields, - tech_ticket_tag, - categories, - org_id, - org_type, - service_id, -): - notify_ticket_form = NotifySupportTicket( - "subject", "message", "question", **custom_fields - ) - - assert notify_ticket_form.request_data["ticket"]["custom_fields"] == [ - {"id": "1900000744994", "value": tech_ticket_tag}, - {"id": "360022836500", "value": categories}, - {"id": "360022943959", "value": org_id}, - {"id": "360022943979", "value": org_type}, - {"id": "1900000745014", "value": service_id}, - ] - - -def test_notify_support_ticket_request_data_email_ccs(): - notify_ticket_form = NotifySupportTicket( - "subject", "message", "question", email_ccs=["someone@example.com"] - ) - - assert notify_ticket_form.request_data["ticket"]["email_ccs"] == [ - {"user_email": "someone@example.com", "action": "put"}, - ] diff --git a/tests/notifications_utils/test_logging.py b/tests/notifications_utils/test_logging.py index cc7f5ee34..858b9352b 100644 --- a/tests/notifications_utils/test_logging.py +++ b/tests/notifications_utils/test_logging.py @@ -49,3 +49,13 @@ def test_base_json_formatter_contains_service_id(): == "message to log" ) assert service_id_filter.filter(record).service_id == "no-service-id" + + +def test_scrub(): + result = logging.scrub( + "This is a message with 17775554324, and also 18884449323 and also 17775554324" + ) + assert ( + result + == "This is a message with 1XXXXX54324, and also 1XXXXX49323 and also 1XXXXX54324" + ) diff --git a/tests/notifications_utils/test_s3.py b/tests/notifications_utils/test_s3.py index 46b863c4f..d05fa8fdc 100644 --- a/tests/notifications_utils/test_s3.py +++ b/tests/notifications_utils/test_s3.py @@ -2,6 +2,7 @@ from urllib.parse import parse_qs import botocore import pytest +from moto import mock_aws from notifications_utils.s3 import S3ObjectNotFound, s3download, s3upload @@ -12,6 +13,7 @@ location = "some_file_location" content_type = "binary/octet-stream" +@mock_aws def test_s3upload_save_file_to_bucket(mocker): mocked = mocker.patch("notifications_utils.s3.Session.resource") s3upload(