From 30093673d0dd146bc7a9bec7e80a9d115cd2703d Mon Sep 17 00:00:00 2001 From: Kenneth Kehl <@kkehl@flexion.us> Date: Tue, 10 Jun 2025 11:40:14 -0700 Subject: [PATCH 1/7] inline notifications-python-client --- .ds.baseline | 4 +- app/__init__.py | 2 +- app/main/views/add_service.py | 2 +- app/main/views/conversation.py | 2 +- app/main/views/find_users.py | 2 +- app/main/views/forgot_password.py | 2 +- app/main/views/manage_users.py | 2 +- app/main/views/organizations.py | 2 +- app/main/views/platform_admin.py | 2 +- app/main/views/send.py | 2 +- app/main/views/service_settings.py | 2 +- app/main/views/templates.py | 2 +- app/models/user.py | 2 +- app/notify_client/__init__.py | 4 +- app/notify_client/organizations_api_client.py | 3 +- app/notify_client/user_api_client.py | 2 +- app/status/views/healthcheck.py | 2 +- notifications_python_client/__init__.py | 17 ++ notifications_python_client/authentication.py | 153 +++++++++++++++++ notifications_python_client/base.py | 126 ++++++++++++++ notifications_python_client/errors.py | 90 ++++++++++ notifications_python_client/notifications.py | 154 ++++++++++++++++++ notifications_python_client/py.typed | 0 notifications_python_client/utils.py | 21 +++ pyproject.toml | 1 - tests/app/main/test_errorhandlers.py | 1 + .../views/organizations/test_organizations.py | 2 +- .../service_settings/test_service_settings.py | 2 +- tests/app/main/views/test_accept_invite.py | 2 +- tests/app/main/views/test_add_service.py | 2 +- tests/app/main/views/test_conversation.py | 2 +- tests/app/main/views/test_find_users.py | 2 +- tests/app/main/views/test_forgot_password.py | 2 +- tests/app/main/views/test_send.py | 2 +- tests/app/main/views/test_template_folders.py | 2 +- tests/app/main/views/test_templates.py | 2 +- tests/app/main/views/test_verify.py | 2 +- tests/conftest.py | 2 +- 38 files changed, 593 insertions(+), 33 deletions(-) create mode 100644 notifications_python_client/__init__.py create mode 100644 notifications_python_client/authentication.py create mode 100644 notifications_python_client/base.py create mode 100644 notifications_python_client/errors.py create mode 100644 notifications_python_client/notifications.py create mode 100644 notifications_python_client/py.typed create mode 100644 notifications_python_client/utils.py diff --git a/.ds.baseline b/.ds.baseline index 96056ad7b..0619ba06a 100644 --- a/.ds.baseline +++ b/.ds.baseline @@ -469,7 +469,7 @@ "filename": "tests/app/main/test_errorhandlers.py", "hashed_secret": "005fa73b3f2be8f0d71d361c1f0a9d787cd09b4e", "is_verified": false, - "line_number": 33, + "line_number": 34, "is_secret": false } ], @@ -634,5 +634,5 @@ } ] }, - "generated_at": "2025-06-04T16:12:20Z" + "generated_at": "2025-06-10T18:39:51Z" } diff --git a/app/__init__.py b/app/__init__.py index a6a4a615b..e47a263d8 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -24,7 +24,6 @@ from flask_talisman import Talisman from flask_wtf import CSRFProtect from flask_wtf.csrf import CSRFError from itsdangerous import BadSignature -from notifications_python_client.errors import HTTPError from werkzeug.exceptions import HTTPException as WerkzeugHTTPException from werkzeug.exceptions import abort from werkzeug.local import LocalProxy @@ -111,6 +110,7 @@ from app.notify_client.user_api_client import user_api_client from app.url_converters import SimpleDateTypeConverter, TemplateTypeConverter from app.utils.api_health import is_api_down from app.utils.govuk_frontend_jinja.flask_ext import init_govuk_frontend +from notifications_python_client.errors import HTTPError from notifications_utils import logging, request_helper from notifications_utils.formatters import ( formatted_list, diff --git a/app/main/views/add_service.py b/app/main/views/add_service.py index 339fd9e7c..f6ec2fe50 100644 --- a/app/main/views/add_service.py +++ b/app/main/views/add_service.py @@ -1,12 +1,12 @@ from flask import current_app, redirect, render_template, session, url_for from flask_login import current_user -from notifications_python_client.errors import HTTPError from app import service_api_client from app.formatters import email_safe from app.main import main from app.main.forms import CreateServiceForm from app.utils.user import user_is_gov_user, user_is_logged_in +from notifications_python_client.errors import HTTPError def _create_service(service_name, organization_type, email_from, form): diff --git a/app/main/views/conversation.py b/app/main/views/conversation.py index a3ac47da7..35d82fe09 100644 --- a/app/main/views/conversation.py +++ b/app/main/views/conversation.py @@ -1,12 +1,12 @@ from flask import jsonify, redirect, render_template, session, url_for from flask_login import current_user -from notifications_python_client.errors import HTTPError from app import current_service, notification_api_client, service_api_client from app.main import main from app.main.forms import SearchByNameForm from app.models.template_list import TemplateList from app.utils.user import user_has_permissions +from notifications_python_client.errors import HTTPError from notifications_utils.recipients import format_phone_number_human_readable from notifications_utils.template import SMSPreviewTemplate diff --git a/app/main/views/find_users.py b/app/main/views/find_users.py index 19ec5dce7..ef55ad2a4 100644 --- a/app/main/views/find_users.py +++ b/app/main/views/find_users.py @@ -1,6 +1,5 @@ from flask import flash, redirect, render_template, request, url_for from flask_login import current_user -from notifications_python_client.errors import HTTPError from app import user_api_client from app.event_handlers import create_archive_user_event @@ -8,6 +7,7 @@ from app.main import main from app.main.forms import AdminSearchUsersByEmailForm, AuthTypeForm from app.models.user import User from app.utils.user import user_is_platform_admin +from notifications_python_client.errors import HTTPError @main.route("/find-users-by-email", methods=["GET", "POST"]) diff --git a/app/main/views/forgot_password.py b/app/main/views/forgot_password.py index 0aa72e0de..360d495b5 100644 --- a/app/main/views/forgot_password.py +++ b/app/main/views/forgot_password.py @@ -1,9 +1,9 @@ from flask import render_template, request -from notifications_python_client.errors import HTTPError from app import user_api_client from app.main import main from app.main.forms import ForgotPasswordForm +from notifications_python_client.errors import HTTPError @main.route("/forgot-password", methods=["GET", "POST"]) diff --git a/app/main/views/manage_users.py b/app/main/views/manage_users.py index b55341e08..66e00c48e 100644 --- a/app/main/views/manage_users.py +++ b/app/main/views/manage_users.py @@ -1,6 +1,5 @@ from flask import abort, flash, redirect, render_template, request, session, url_for from flask_login import current_user -from notifications_python_client.errors import HTTPError from app import current_service, service_api_client from app.event_handlers import ( @@ -24,6 +23,7 @@ from app.main.forms import ( from app.models.user import InvitedUser, User from app.utils.user import is_gov_user, user_has_permissions from app.utils.user_permissions import permission_options +from notifications_python_client.errors import HTTPError @main.route("/services//users") diff --git a/app/main/views/organizations.py b/app/main/views/organizations.py index 14ccc9de2..5ad8756fa 100644 --- a/app/main/views/organizations.py +++ b/app/main/views/organizations.py @@ -4,7 +4,6 @@ from functools import partial from flask import flash, redirect, render_template, request, url_for from flask_login import current_user -from notifications_python_client.errors import HTTPError from app import current_organization, org_invite_api_client, organizations_client from app.main import main @@ -27,6 +26,7 @@ from app.models.organization import AllOrganizations, Organization from app.models.user import InvitedOrgUser, User from app.utils.csv import Spreadsheet from app.utils.user import user_has_permissions, user_is_platform_admin +from notifications_python_client.errors import HTTPError @main.route("/organizations", methods=["GET"]) diff --git a/app/main/views/platform_admin.py b/app/main/views/platform_admin.py index bfd3749f3..fb9ea7fbe 100644 --- a/app/main/views/platform_admin.py +++ b/app/main/views/platform_admin.py @@ -15,7 +15,6 @@ from flask import ( session, url_for, ) -from notifications_python_client.errors import HTTPError from app import ( billing_api_client, @@ -46,6 +45,7 @@ from app.utils.pagination import ( get_page_from_request, ) from app.utils.user import user_is_platform_admin +from notifications_python_client.errors import HTTPError COMPLAINT_THRESHOLD = 0.02 FAILURE_THRESHOLD = 3 diff --git a/app/main/views/send.py b/app/main/views/send.py index b1b393a90..8de4663fe 100644 --- a/app/main/views/send.py +++ b/app/main/views/send.py @@ -17,7 +17,6 @@ from flask import ( ) from flask_login import current_user from markupsafe import Markup -from notifications_python_client.errors import HTTPError from xlrd.biffh import XLRDError from xlrd.xldate import XLDateError @@ -52,6 +51,7 @@ from app.utils import ( 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_python_client.errors import HTTPError from notifications_utils import SMS_CHAR_COUNT_LIMIT from notifications_utils.insensitive_dict import InsensitiveDict from notifications_utils.recipients import RecipientCSV, first_column_headings diff --git a/app/main/views/service_settings.py b/app/main/views/service_settings.py index 628ac59e5..fd15b1854 100644 --- a/app/main/views/service_settings.py +++ b/app/main/views/service_settings.py @@ -12,7 +12,6 @@ from flask import ( url_for, ) from flask_login import current_user -from notifications_python_client.errors import HTTPError from app import ( billing_api_client, @@ -52,6 +51,7 @@ from app.main.forms import ( from app.utils import DELIVERED_STATUSES, FAILURE_STATUSES, SENDING_STATUSES from app.utils.time import parse_naive_dt from app.utils.user import user_has_permissions, user_is_platform_admin +from notifications_python_client.errors import HTTPError PLATFORM_ADMIN_SERVICE_PERMISSIONS = OrderedDict( [ diff --git a/app/main/views/templates.py b/app/main/views/templates.py index 4ac898cb4..bb82c6d38 100644 --- a/app/main/views/templates.py +++ b/app/main/views/templates.py @@ -3,7 +3,6 @@ from functools import partial from flask import abort, flash, jsonify, redirect, render_template, request, url_for from flask_login import current_user from markupsafe import Markup -from notifications_python_client.errors import HTTPError from app import ( current_service, @@ -29,6 +28,7 @@ from app.models.template_list import TemplateList, TemplateLists from app.utils import NOTIFICATION_TYPES, should_skip_template_page from app.utils.templates import get_template from app.utils.user import user_has_permissions +from notifications_python_client.errors import HTTPError from notifications_utils import SMS_CHAR_COUNT_LIMIT form_objects = { diff --git a/app/models/user.py b/app/models/user.py index 468208d6e..88babe2fa 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -3,7 +3,6 @@ from datetime import datetime from flask import abort, current_app, request, session from flask_login import AnonymousUserMixin, UserMixin, login_user, logout_user -from notifications_python_client.errors import HTTPError from werkzeug.utils import cached_property from app.event_handlers import ( @@ -22,6 +21,7 @@ from app.utils.user_permissions import ( all_ui_permissions, translate_permissions_from_db_to_ui, ) +from notifications_python_client.errors import HTTPError def _get_service_id_from_view_args(): diff --git a/app/notify_client/__init__.py b/app/notify_client/__init__.py index 8db3425f2..e05a6124a 100644 --- a/app/notify_client/__init__.py +++ b/app/notify_client/__init__.py @@ -2,10 +2,10 @@ import os from flask import abort, current_app, has_request_context, request from flask_login import current_user -from notifications_python_client import __version__ -from notifications_python_client.base import BaseAPIClient from app.extensions import redis_client +from notifications_python_client import __version__ +from notifications_python_client.base import BaseAPIClient from notifications_utils.clients.redis import RequestCache cache = RequestCache(redis_client) diff --git a/app/notify_client/organizations_api_client.py b/app/notify_client/organizations_api_client.py index b37acda74..10d121a41 100644 --- a/app/notify_client/organizations_api_client.py +++ b/app/notify_client/organizations_api_client.py @@ -1,9 +1,8 @@ from itertools import chain -from notifications_python_client.errors import HTTPError - from app.extensions import redis_client from app.notify_client import NotifyAdminAPIClient, cache +from notifications_python_client.errors import HTTPError class OrganizationsClient(NotifyAdminAPIClient): diff --git a/app/notify_client/user_api_client.py b/app/notify_client/user_api_client.py index 1aab20a90..2a96d96db 100644 --- a/app/notify_client/user_api_client.py +++ b/app/notify_client/user_api_client.py @@ -1,9 +1,9 @@ from flask import current_app -from notifications_python_client.errors import HTTPError from app.notify_client import NotifyAdminAPIClient, cache from app.utils import hilite from app.utils.user_permissions import translate_permissions_from_ui_to_db +from notifications_python_client.errors import HTTPError ALLOWED_ATTRIBUTES = { "name", diff --git a/app/status/views/healthcheck.py b/app/status/views/healthcheck.py index b0c11091a..6bb62cf03 100644 --- a/app/status/views/healthcheck.py +++ b/app/status/views/healthcheck.py @@ -2,12 +2,12 @@ import time import traceback from flask import current_app, jsonify, request -from notifications_python_client.errors import HTTPError from redis import RedisError from app import status_api_client, version from app.extensions import redis_client from app.status import status +from notifications_python_client.errors import HTTPError @status.route("/_status", methods=["GET"]) diff --git a/notifications_python_client/__init__.py b/notifications_python_client/__init__.py new file mode 100644 index 000000000..436df44f5 --- /dev/null +++ b/notifications_python_client/__init__.py @@ -0,0 +1,17 @@ +# Version numbering follows Semantic Versionning: +# +# Given a version number MAJOR.MINOR.PATCH, increment the: +# - MAJOR version when you make incompatible API changes, +# - MINOR version when you add functionality in a backwards-compatible manner, and +# - PATCH version when you make backwards-compatible bug fixes. +# +# -- http://semver.org/ + +__version__ = "10.0.1" + +from notifications_python_client.errors import ( # noqa + REQUEST_ERROR_MESSAGE, + REQUEST_ERROR_STATUS_CODE, +) +from notifications_python_client.notifications import NotificationsAPIClient # noqa +from notifications_python_client.utils import prepare_upload # noqa diff --git a/notifications_python_client/authentication.py b/notifications_python_client/authentication.py new file mode 100644 index 000000000..7e700e7db --- /dev/null +++ b/notifications_python_client/authentication.py @@ -0,0 +1,153 @@ +import calendar +import time + +import jwt + +from notifications_python_client.errors import ( + TokenAlgorithmError, + TokenDecodeError, + TokenError, + TokenExpiredError, + TokenIssuedAtError, + TokenIssuerError, +) + +__algorithm__ = "HS256" +__type__ = "JWT" +__bound__ = 30 + +INVALID_FUTURE_TOKEN_ERROR_MESSAGE = "Token can not be in the future" + + +def create_jwt_token(secret, client_id): + """ + Create JWT token for GOV.UK Notify + + Tokens have standard header: + { + "typ": "JWT", + "alg": "HS256" + } + + Claims consist of: + iss: identifier for the client + iat: issued at in epoch seconds (UTC) + + :param secret: Application signing secret + :param client_id: Identifier for the client + :return: JWT token for this request + """ + assert secret, "Missing secret key" + assert client_id, "Missing client id" + + headers = {"typ": __type__, "alg": __algorithm__} + + claims = {"iss": client_id, "iat": epoch_seconds()} + t = jwt.encode(payload=claims, key=secret, headers=headers) + if isinstance(t, str): + return t + else: + return t.decode() + + +def get_token_issuer(token): + """ + Issuer of a token is the identifier used to recover the secret + Need to extract this from token to ensure we can proceed to the signature validation stage + Does not check validity of the token + :param token: signed JWT token + :return issuer: iss field of the JWT token + :raises TokenIssuerError: if iss field not present + :raises TokenDecodeError: if token does not conform to JWT spec + """ + try: + unverified = decode_token(token) + + if "iss" not in unverified: + raise TokenIssuerError + + return unverified.get("iss") + except jwt.DecodeError as e: + raise TokenDecodeError from e + + +def decode_jwt_token(token, secret): + """ + Validates and decodes the JWT token + Token checked for + - signature of JWT token + - token issued date is valid + + :param token: jwt token + :param secret: client specific secret + :return boolean: True if valid token, False otherwise + :raises TokenIssuerError: if iss field not present + :raises TokenIssuedAtError: if iat field not present + :raises TokenExpiredError: If the iat value expires this token + :raises TokenDecodeError: If the token cannot be decoded because it failed validation + :raises TokenAlgorithmError: If the algorithm is not recognised + :raises TokenError: If any other type of jwt exception is raised when trying jwt.decode + """ + try: + # check signature of the token + decoded_token = jwt.decode( + token, + key=secret, + options={"verify_signature": True}, + algorithms=[__algorithm__], + leeway=__bound__, + ) + return validate_jwt_token(decoded_token) + except jwt.InvalidIssuedAtError as e: + raise TokenExpiredError( + "Token has invalid iat field", decode_token(token) + ) from e + except jwt.ImmatureSignatureError as e: + raise TokenExpiredError( + INVALID_FUTURE_TOKEN_ERROR_MESSAGE, decode_token(token) + ) from e + except jwt.DecodeError as e: + raise TokenDecodeError from e + except jwt.InvalidAlgorithmError as e: + raise TokenAlgorithmError from e + except jwt.InvalidTokenError as e: + # At this point, we have not caught a specific exception we care about enough to show + # a precise error message (ie something to do with the iat, iss or alg fields). + # If there is a different reason our token is invalid we will throw a generic error as we + # don't wish to provide exact messages for every type of error that jwt might encounter. + # https://github.com/jpadilla/pyjwt/blob/master/jwt/exceptions.py + # https://pyjwt.readthedocs.io/en/latest/api.html#exceptions + raise TokenError from e + + +def validate_jwt_token(decoded_token): + # token has all the required fields + if "iss" not in decoded_token: + raise TokenIssuerError + if "iat" not in decoded_token: + raise TokenIssuedAtError + + # check iat time is within bounds + now = epoch_seconds() + iat = int(decoded_token["iat"]) + if now > (iat + __bound__): + raise TokenExpiredError("Token has expired", decoded_token) + if iat > (now + __bound__): + raise TokenExpiredError(INVALID_FUTURE_TOKEN_ERROR_MESSAGE, decoded_token) + + return True + + +def decode_token(token): + """ + Decode token but don;t check the signature + :param token: + :return decoded token: + """ + return jwt.decode( + token, options={"verify_signature": False}, algorithms=[__algorithm__] + ) + + +def epoch_seconds(): + return calendar.timegm(time.gmtime()) diff --git a/notifications_python_client/base.py b/notifications_python_client/base.py new file mode 100644 index 000000000..946dea209 --- /dev/null +++ b/notifications_python_client/base.py @@ -0,0 +1,126 @@ +import json +import logging +import time +import urllib.parse + +import requests + +from notifications_python_client import __version__ +from notifications_python_client.authentication import create_jwt_token +from notifications_python_client.errors import HTTPError, InvalidResponse + +logger = logging.getLogger(__name__) + + +class BaseAPIClient: + """ + Base class for GOV.UK Notify API client. + + This class is not thread-safe. + """ + + def __init__( + self, api_key, base_url="https://api.notifications.service.gov.uk", timeout=30 + ): + """ + Initialise the client + Error if either of base_url or secret missing + :param base_url - base URL of GOV.UK Notify API: + :param secret - application secret - used to sign the request: + :param timeout - request timeout on the client + :return: + """ + service_id = api_key[-73:-37] + api_key = api_key[-36:] + + assert base_url, "Missing base url" + assert service_id, "Missing service ID" + assert api_key, "Missing API key" + self.base_url = base_url + self.service_id = service_id + self.api_key = api_key + self.timeout = timeout + self.request_session = requests.Session() + + def put(self, url, data): + return self.request("PUT", url, data=data) + + def get(self, url, params=None): + return self.request("GET", url, params=params) + + def post(self, url, data): + return self.request("POST", url, data=data) + + def delete(self, url, data=None): + return self.request("DELETE", url, data=data) + + def generate_headers(self, api_token): + return { + "Content-type": "application/json", + "Authorization": f"Bearer {api_token}", + "User-agent": f"NOTIFY-API-PYTHON-CLIENT/{__version__}", + } + + def request(self, method, url, data=None, params=None): + logger.debug("API request %s %s", method, url) + url, kwargs = self._create_request_objects(url, data, params) + + response = self._perform_request(method, url, kwargs) + + return self._process_json_response(response) + + def _create_request_objects(self, url, data, params): + api_token = create_jwt_token(self.api_key, self.service_id) + + kwargs = {"headers": self.generate_headers(api_token), "timeout": self.timeout} + + if data is not None: + kwargs.update(data=self._serialize_data(data)) + + if params is not None: + kwargs.update(params=params) + + url = urllib.parse.urljoin(str(self.base_url), str(url)) + + return url, kwargs + + def _serialize_data(self, data): + return json.dumps(data, default=self._extended_json_encoder) + + def _extended_json_encoder(self, obj): + if isinstance(obj, set): + return list(obj) + + raise TypeError + + def _perform_request(self, method, url, kwargs): + start_time = time.monotonic() + try: + response = self.request_session.request(method, url, **kwargs) + response.raise_for_status() + return response + except requests.RequestException as e: + api_error = HTTPError.create(e) + logger.warning( + "API %s request on %s failed with %s '%s'", + method, + url, + api_error.status_code, + api_error.message, + ) + raise api_error from e + finally: + elapsed_time = time.monotonic() - start_time + logger.debug( + "API %s request on %s finished in %s", method, url, elapsed_time + ) + + def _process_json_response(self, response): + try: + if response.status_code == 204: + return + return response.json() + except ValueError as e: + raise InvalidResponse( + response, message="No JSON response object could be decoded" + ) from e diff --git a/notifications_python_client/errors.py b/notifications_python_client/errors.py new file mode 100644 index 000000000..430123684 --- /dev/null +++ b/notifications_python_client/errors.py @@ -0,0 +1,90 @@ +from typing import List, Union # noqa: UP035 – Python <3.10 compatibility + +from requests import RequestException, Response + +REQUEST_ERROR_STATUS_CODE = 503 +REQUEST_ERROR_MESSAGE = "Request failed" + +TOKEN_ERROR_GUIDANCE = "See our requirements for JSON Web Tokens \ + at https://docs.notifications.service.gov.uk/rest-api.html#authorisation-header" +TOKEN_ERROR_DEFAULT_ERROR_MESSAGE = "Invalid token: " + TOKEN_ERROR_GUIDANCE + + +class TokenError(Exception): + def __init__(self, message=None, token=None): + self.message = ( + message + ". " + TOKEN_ERROR_GUIDANCE + if message + else TOKEN_ERROR_DEFAULT_ERROR_MESSAGE + ) + self.token = token + + +class TokenExpiredError(TokenError): + pass + + +class TokenAlgorithmError(TokenError): + def __init__(self): + super().__init__("Invalid token: algorithm used is not HS256") + + +class TokenDecodeError(TokenError): + def __init__(self, message=None): + super().__init__(message or "Invalid token: signature") + + +class TokenIssuerError(TokenDecodeError): + def __init__(self): + super().__init__("Invalid token: iss field not provided") + + +class TokenIssuedAtError(TokenDecodeError): + def __init__(self): + super().__init__("Invalid token: iat field not provided") + + +class APIError(Exception): + def __init__(self, response: Response = None, message: str = None): + self.response = response + self._message = message + + def __str__(self): + return f"{self.status_code} - {self.message}" + + @property + def message( + self, + ) -> Union[str, List[dict]]: # noqa: UP006, UP007 – Python <3.10 compatibility + try: + json_resp = self.response.json() # type: ignore + return json_resp.get("message", json_resp.get("errors")) + except (TypeError, ValueError, AttributeError, KeyError): + return self._message or REQUEST_ERROR_MESSAGE + + @property + def status_code(self) -> int: + try: + return self.response.status_code # type: ignore + except AttributeError: + return REQUEST_ERROR_STATUS_CODE + + +class HTTPError(APIError): + @staticmethod + def create(e: RequestException) -> "HTTPError": + error = HTTPError(e.response) + if error.status_code == 503: + error = HTTP503Error(e.response) + return error + + +class HTTP503Error(HTTPError): + """Specific instance of HTTPError for 503 errors + + Used for detecting whether failed requests should be retried. + """ + + +class InvalidResponse(APIError): + pass diff --git a/notifications_python_client/notifications.py b/notifications_python_client/notifications.py new file mode 100644 index 000000000..8b0118d0f --- /dev/null +++ b/notifications_python_client/notifications.py @@ -0,0 +1,154 @@ +import base64 +import logging +import re +from io import BytesIO + +from notifications_python_client.base import BaseAPIClient + +logger = logging.getLogger(__name__) + + +class NotificationsAPIClient(BaseAPIClient): + def send_sms_notification( + self, + phone_number, + template_id, + personalisation=None, + reference=None, + sms_sender_id=None, + ): + notification = {"phone_number": phone_number, "template_id": template_id} + if personalisation: + notification.update({"personalisation": personalisation}) + if reference: + notification.update({"reference": reference}) + if sms_sender_id: + notification.update({"sms_sender_id": sms_sender_id}) + return self.post("/v2/notifications/sms", data=notification) + + def send_email_notification( + self, + email_address, + template_id, + personalisation=None, + reference=None, + email_reply_to_id=None, + one_click_unsubscribe_url=None, + ): + notification = {"email_address": email_address, "template_id": template_id} + if personalisation: + notification.update({"personalisation": personalisation}) + if reference: + notification.update({"reference": reference}) + if email_reply_to_id: + notification.update({"email_reply_to_id": email_reply_to_id}) + if one_click_unsubscribe_url: + notification.update( + {"one_click_unsubscribe_url": one_click_unsubscribe_url} + ) + + return self.post("/v2/notifications/email", data=notification) + + def send_letter_notification(self, template_id, personalisation, reference=None): + notification = {"template_id": template_id, "personalisation": personalisation} + if reference: + notification.update({"reference": reference}) + return self.post("/v2/notifications/letter", data=notification) + + def send_precompiled_letter_notification(self, reference, pdf_file, postage=None): + content = base64.b64encode(pdf_file.read()).decode("utf-8") + notification = {"reference": reference, "content": content} + + if postage: + notification["postage"] = postage + + return self.post("/v2/notifications/letter", data=notification) + + def get_received_texts(self, older_than=None): + if older_than: + query_string = f"?older_than={older_than}" + else: + query_string = "" + + return self.get(f"/v2/received-text-messages{query_string}") + + def get_received_texts_iterator(self, older_than=None): + result = self.get_received_texts(older_than=older_than) + received_texts = result.get("received_text_messages") + while received_texts: + yield from received_texts + next_link = result["links"].get("next") + received_text_id = re.search( + "[0-F]{8}-[0-F]{4}-[0-F]{4}-[0-F]{4}-[0-F]{12}", next_link, re.I + ).group(0) + result = self.get_received_texts(older_than=received_text_id) + received_texts = result.get("received_text_messages") + + def get_notification_by_id(self, id): + return self.get(f"/v2/notifications/{id}") + + def get_pdf_for_letter(self, id): + url = f"/v2/notifications/{id}/pdf" + logger.debug("API request %s %s", "GET", url) + url, kwargs = self._create_request_objects(url, data=None, params=None) + + response = self._perform_request("GET", url, kwargs) + + return BytesIO(response.content) + + def get_all_notifications( + self, + status=None, + template_type=None, + reference=None, + older_than=None, + include_jobs=None, + ): + data = {} + if status: + data.update({"status": status}) + if template_type: + data.update({"template_type": template_type}) + if reference: + data.update({"reference": reference}) + if older_than: + data.update({"older_than": older_than}) + if include_jobs: + data.update({"include_jobs": include_jobs}) + return self.get("/v2/notifications", params=data) + + def get_all_notifications_iterator( + self, status=None, template_type=None, reference=None, older_than=None + ): + result = self.get_all_notifications( + status, template_type, reference, older_than + ) + notifications = result.get("notifications") + while notifications: + yield from notifications + next_link = result["links"].get("next") + notification_id = re.search( + "[0-F]{8}-[0-F]{4}-[0-F]{4}-[0-F]{4}-[0-F]{12}", next_link, re.I + ).group(0) + result = self.get_all_notifications( + status, template_type, reference, notification_id + ) + notifications = result.get("notifications") + + def post_template_preview(self, template_id, personalisation): + template = {"personalisation": personalisation} + return self.post(f"/v2/template/{template_id}/preview", data=template) + + def get_template(self, template_id): + return self.get(f"/v2/template/{template_id}") + + def get_template_version(self, template_id, version): + return self.get(f"/v2/template/{template_id}/version/{version}") + + def get_all_template_versions(self, template_id): + return self.get(f"service/{self.service_id}/template/{template_id}/versions") + + def get_all_templates(self, template_type=None): + _template_type = f"?type={template_type}" if template_type else "" + + return self.get(f"/v2/templates{_template_type}") diff --git a/notifications_python_client/py.typed b/notifications_python_client/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/notifications_python_client/utils.py b/notifications_python_client/utils.py new file mode 100644 index 000000000..a93faa959 --- /dev/null +++ b/notifications_python_client/utils.py @@ -0,0 +1,21 @@ +import base64 + +DOCUMENT_UPLOAD_SIZE_LIMIT = 2 * 1024 * 1024 + + +def prepare_upload( + f, filename=None, confirm_email_before_download=None, retention_period=None +): + contents = f.read() + + if len(contents) > DOCUMENT_UPLOAD_SIZE_LIMIT: + raise ValueError("File is larger than 2MB") + + file_data = { + "file": base64.b64encode(contents).decode("ascii"), + "filename": filename, + "confirm_email_before_download": confirm_email_before_download, + "retention_period": retention_period, + } + + return file_data diff --git a/pyproject.toml b/pyproject.toml index 659ef7bc4..9189d3472 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,6 @@ humanize = "~=4.12" itsdangerous = "~=2.2" jinja2 = "^3.1.6" newrelic = "*" -notifications-python-client = "==10.0.1" pyexcel = "==0.7.3" pyexcel-io = "==0.6.7" pyexcel-ods3 = "==0.6.1" diff --git a/tests/app/main/test_errorhandlers.py b/tests/app/main/test_errorhandlers.py index 57537762a..6b0178382 100644 --- a/tests/app/main/test_errorhandlers.py +++ b/tests/app/main/test_errorhandlers.py @@ -1,6 +1,7 @@ import pytest from flask import Response, url_for from flask_wtf.csrf import CSRFError + from notifications_python_client.errors import HTTPError diff --git a/tests/app/main/views/organizations/test_organizations.py b/tests/app/main/views/organizations/test_organizations.py index d85352522..bb952d661 100644 --- a/tests/app/main/views/organizations/test_organizations.py +++ b/tests/app/main/views/organizations/test_organizations.py @@ -1,8 +1,8 @@ import pytest from flask import url_for from freezegun import freeze_time -from notifications_python_client.errors import HTTPError +from notifications_python_client.errors import HTTPError from tests import organization_json, service_json from tests.conftest import ( ORGANISATION_ID, diff --git a/tests/app/main/views/service_settings/test_service_settings.py b/tests/app/main/views/service_settings/test_service_settings.py index c9234700d..3919a061e 100644 --- a/tests/app/main/views/service_settings/test_service_settings.py +++ b/tests/app/main/views/service_settings/test_service_settings.py @@ -5,9 +5,9 @@ from uuid import uuid4 import pytest from flask import url_for from freezegun import freeze_time -from notifications_python_client.errors import HTTPError import app +from notifications_python_client.errors import HTTPError from tests import ( find_element_by_tag_and_partial_text, organization_json, diff --git a/tests/app/main/views/test_accept_invite.py b/tests/app/main/views/test_accept_invite.py index 297dc7603..77df3a832 100644 --- a/tests/app/main/views/test_accept_invite.py +++ b/tests/app/main/views/test_accept_invite.py @@ -3,9 +3,9 @@ from unittest.mock import ANY, Mock, call import pytest from flask import url_for from freezegun import freeze_time -from notifications_python_client.errors import HTTPError import app +from notifications_python_client.errors import HTTPError from tests import service_json from tests.conftest import ( SERVICE_ONE_ID, diff --git a/tests/app/main/views/test_add_service.py b/tests/app/main/views/test_add_service.py index 4f2ab9964..3a781bda5 100644 --- a/tests/app/main/views/test_add_service.py +++ b/tests/app/main/views/test_add_service.py @@ -1,9 +1,9 @@ import pytest from flask import url_for from freezegun import freeze_time -from notifications_python_client.errors import HTTPError from app.utils.user import is_gov_user +from notifications_python_client.errors import HTTPError from tests import organization_json diff --git a/tests/app/main/views/test_conversation.py b/tests/app/main/views/test_conversation.py index 15a5ec587..389af5930 100644 --- a/tests/app/main/views/test_conversation.py +++ b/tests/app/main/views/test_conversation.py @@ -6,9 +6,9 @@ from unittest.mock import Mock import pytest from flask import url_for from freezegun import freeze_time -from notifications_python_client.errors import HTTPError from app.main.views.conversation import get_user_number +from notifications_python_client.errors import HTTPError from tests.conftest import ( SERVICE_ONE_ID, _template, diff --git a/tests/app/main/views/test_find_users.py b/tests/app/main/views/test_find_users.py index b8056c4e2..6b073d377 100644 --- a/tests/app/main/views/test_find_users.py +++ b/tests/app/main/views/test_find_users.py @@ -2,8 +2,8 @@ import uuid import pytest from flask import url_for -from notifications_python_client.errors import HTTPError +from notifications_python_client.errors import HTTPError from tests import user_json from tests.conftest import normalize_spaces diff --git a/tests/app/main/views/test_forgot_password.py b/tests/app/main/views/test_forgot_password.py index c23650990..538966f47 100644 --- a/tests/app/main/views/test_forgot_password.py +++ b/tests/app/main/views/test_forgot_password.py @@ -1,8 +1,8 @@ import pytest from flask import Response, url_for -from notifications_python_client.errors import HTTPError import app +from notifications_python_client.errors import HTTPError from tests import user_json from tests.conftest import SERVICE_ONE_ID diff --git a/tests/app/main/views/test_send.py b/tests/app/main/views/test_send.py index 11104325a..4a70b01b6 100644 --- a/tests/app/main/views/test_send.py +++ b/tests/app/main/views/test_send.py @@ -12,10 +12,10 @@ from zipfile import BadZipFile import pytest from flask import url_for -from notifications_python_client.errors import HTTPError from xlrd.biffh import XLRDError from xlrd.xldate import XLDateAmbiguous, XLDateError, XLDateNegative, XLDateTooLarge +from notifications_python_client.errors import HTTPError from notifications_utils.recipients import RecipientCSV from notifications_utils.template import SMSPreviewTemplate from tests import ( diff --git a/tests/app/main/views/test_template_folders.py b/tests/app/main/views/test_template_folders.py index f418b2c42..3363b7652 100644 --- a/tests/app/main/views/test_template_folders.py +++ b/tests/app/main/views/test_template_folders.py @@ -2,9 +2,9 @@ import uuid import pytest from flask import abort, url_for -from notifications_python_client.errors import HTTPError from app.models.user import User +from notifications_python_client.errors import HTTPError from tests import sample_uuid from tests.conftest import ( SERVICE_ONE_ID, diff --git a/tests/app/main/views/test_templates.py b/tests/app/main/views/test_templates.py index 250815269..6e4ed0ea9 100644 --- a/tests/app/main/views/test_templates.py +++ b/tests/app/main/views/test_templates.py @@ -5,8 +5,8 @@ import pytest from bs4 import BeautifulSoup from flask import url_for from freezegun import freeze_time -from notifications_python_client.errors import HTTPError +from notifications_python_client.errors import HTTPError from tests import template_json, validate_route_permission from tests.app.main.views.test_template_folders import ( CHILD_FOLDER_ID, diff --git a/tests/app/main/views/test_verify.py b/tests/app/main/views/test_verify.py index 4f2e39feb..b082a5c82 100644 --- a/tests/app/main/views/test_verify.py +++ b/tests/app/main/views/test_verify.py @@ -6,9 +6,9 @@ import pytest from flask import session as flask_session from flask import url_for from itsdangerous import SignatureExpired -from notifications_python_client.errors import HTTPError from app.main.views.verify import activate_user +from notifications_python_client.errors import HTTPError from tests.conftest import create_user diff --git a/tests/conftest.py b/tests/conftest.py index de1ff28a3..c1bfbb7ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,9 +11,9 @@ import pytest from bs4 import BeautifulSoup from dotenv import load_dotenv from flask import Flask, url_for -from notifications_python_client.errors import HTTPError from app import create_app +from notifications_python_client.errors import HTTPError from notifications_utils.url_safe_token import generate_token from . import ( From 7ba13c5d0d92873fb61afcc5a8ab3ef173dfe4fe Mon Sep 17 00:00:00 2001 From: Kenneth Kehl <@kkehl@flexion.us> Date: Tue, 10 Jun 2025 11:43:19 -0700 Subject: [PATCH 2/7] add poetry.lock --- poetry.lock | 49 ++----------------------------------------------- 1 file changed, 2 insertions(+), 47 deletions(-) diff --git a/poetry.lock b/poetry.lock index b26782ec0..aaa571a0c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "ago" @@ -827,17 +827,6 @@ idna = ["idna (>=3.7)"] trio = ["trio (>=0.23)"] wmi = ["wmi (>=1.5.1)"] -[[package]] -name = "docopt" -version = "0.6.2" -description = "Pythonic argument parser, that will make you smile" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, -] - [[package]] name = "docutils" version = "0.16" @@ -2188,22 +2177,6 @@ files = [ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] -[[package]] -name = "notifications-python-client" -version = "10.0.1" -description = "Python API client for GOV.UK Notify." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "notifications_python_client-10.0.1-py3-none-any.whl", hash = "sha256:00d88eacb6fd6eb0467d7396a7e23677194cfebe0ebd88de2efa031fb51eb23f"}, -] - -[package.dependencies] -docopt = ">=0.3.0" -PyJWT = ">=1.5.1" -requests = ">=2.0.0" - [[package]] name = "numpy" version = "2.2.6" @@ -2821,24 +2794,6 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] -[[package]] -name = "pyjwt" -version = "2.10.1" -description = "JSON Web Token implementation in Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, - {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, -] - -[package.extras] -crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] -docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] - [[package]] name = "pyparsing" version = "3.2.3" @@ -4165,4 +4120,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = "^3.12.2" -content-hash = "9cd113e927916e71ccbab592df3aac094e39ec4397caa9c0ab045b2387209f06" +content-hash = "b81d04cabfd5ac8f1ce8ecd8dff3118be3edf415ac95bea21aab9fc5d4ea7a00" From a8bb55570315e7c4f4b54b94c628c5668a755ba6 Mon Sep 17 00:00:00 2001 From: Kenneth Kehl <@kkehl@flexion.us> Date: Tue, 10 Jun 2025 11:57:47 -0700 Subject: [PATCH 3/7] add jwt --- poetry.lock | 16 +++++++++++++++- pyproject.toml | 1 + 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index aaa571a0c..cb2c0b577 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1604,6 +1604,20 @@ files = [ {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, ] +[[package]] +name = "jwt" +version = "1.3.1" +description = "JSON Web Token library for Python 3." +optional = false +python-versions = ">= 3.6" +groups = ["main"] +files = [ + {file = "jwt-1.3.1-py3-none-any.whl", hash = "sha256:61c9170f92e736b530655e75374681d4fcca9cfa8763ab42be57353b2b203494"}, +] + +[package.dependencies] +cryptography = ">=3.1,<3.4.0 || >3.4.0" + [[package]] name = "keyring" version = "25.6.0" @@ -4120,4 +4134,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = "^3.12.2" -content-hash = "b81d04cabfd5ac8f1ce8ecd8dff3118be3edf415ac95bea21aab9fc5d4ea7a00" +content-hash = "5c223f446f124fa56c71a91388080a12479c692741378c9437d1db5fb01f0c1b" diff --git a/pyproject.toml b/pyproject.toml index 9189d3472..ae3bc6fa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ gunicorn = {version = "==23.0.0", extras = ["eventlet"]} humanize = "~=4.12" itsdangerous = "~=2.2" jinja2 = "^3.1.6" +jwt = "^1.3.1" newrelic = "*" pyexcel = "==0.7.3" pyexcel-io = "==0.6.7" From 14a416952578a59d3f4f248e6f695e28bc4bfbca Mon Sep 17 00:00:00 2001 From: Kenneth Kehl <@kkehl@flexion.us> Date: Tue, 10 Jun 2025 12:07:36 -0700 Subject: [PATCH 4/7] try pyjwt instead of jwt --- poetry.lock | 34 +++++++++++++++++++--------------- pyproject.toml | 2 +- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/poetry.lock b/poetry.lock index cb2c0b577..87264eed2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1604,20 +1604,6 @@ files = [ {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, ] -[[package]] -name = "jwt" -version = "1.3.1" -description = "JSON Web Token library for Python 3." -optional = false -python-versions = ">= 3.6" -groups = ["main"] -files = [ - {file = "jwt-1.3.1-py3-none-any.whl", hash = "sha256:61c9170f92e736b530655e75374681d4fcca9cfa8763ab42be57353b2b203494"}, -] - -[package.dependencies] -cryptography = ">=3.1,<3.4.0 || >3.4.0" - [[package]] name = "keyring" version = "25.6.0" @@ -2808,6 +2794,24 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyjwt" +version = "2.10.1" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, + {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + [[package]] name = "pyparsing" version = "3.2.3" @@ -4134,4 +4138,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = "^3.12.2" -content-hash = "5c223f446f124fa56c71a91388080a12479c692741378c9437d1db5fb01f0c1b" +content-hash = "1f065b41b9d3f15f482b6371a4518e081f68cd1b45c7b6f7382ed161dbb57267" diff --git a/pyproject.toml b/pyproject.toml index ae3bc6fa2..9eb887990 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,6 @@ gunicorn = {version = "==23.0.0", extras = ["eventlet"]} humanize = "~=4.12" itsdangerous = "~=2.2" jinja2 = "^3.1.6" -jwt = "^1.3.1" newrelic = "*" pyexcel = "==0.7.3" pyexcel-io = "==0.6.7" @@ -53,6 +52,7 @@ numpy = "^2.2.6" ordered-set = "^4.1.0" phonenumbers = "^9.0.7" pycparser = "^2.22" +pyjwt = "^2.10.1" python-json-logger = "^3.3.0" redis = "^6.2.0" regex = "^2024.11.6" From bc4a4bcd33dc82a24f78ddb7568d43554e027052 Mon Sep 17 00:00:00 2001 From: Kenneth Kehl <@kkehl@flexion.us> Date: Tue, 10 Jun 2025 15:01:18 -0700 Subject: [PATCH 5/7] fix codeql warning --- notifications_python_client/base.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/notifications_python_client/base.py b/notifications_python_client/base.py index 946dea209..1a6bf15bc 100644 --- a/notifications_python_client/base.py +++ b/notifications_python_client/base.py @@ -1,6 +1,5 @@ import json import logging -import time import urllib.parse import requests @@ -94,7 +93,7 @@ class BaseAPIClient: raise TypeError def _perform_request(self, method, url, kwargs): - start_time = time.monotonic() + try: response = self.request_session.request(method, url, **kwargs) response.raise_for_status() @@ -109,11 +108,6 @@ class BaseAPIClient: api_error.message, ) raise api_error from e - finally: - elapsed_time = time.monotonic() - start_time - logger.debug( - "API %s request on %s finished in %s", method, url, elapsed_time - ) def _process_json_response(self, response): try: From 46f665d0c184773a23fe2724a3256258f1036402 Mon Sep 17 00:00:00 2001 From: Kenneth Kehl <@kkehl@flexion.us> Date: Wed, 11 Jun 2025 07:13:24 -0700 Subject: [PATCH 6/7] fix codeql warning --- notifications_python_client/base.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/notifications_python_client/base.py b/notifications_python_client/base.py index 1a6bf15bc..32f76808d 100644 --- a/notifications_python_client/base.py +++ b/notifications_python_client/base.py @@ -101,9 +101,8 @@ class BaseAPIClient: except requests.RequestException as e: api_error = HTTPError.create(e) logger.warning( - "API %s request on %s failed with %s '%s'", + "API %s request failed with %s '%s'", method, - url, api_error.status_code, api_error.message, ) From 64c9e4c088794f034bb072e55d694aad7b5a5e11 Mon Sep 17 00:00:00 2001 From: Kenneth Kehl <@kkehl@flexion.us> Date: Wed, 11 Jun 2025 10:27:31 -0700 Subject: [PATCH 7/7] add poetry.lock ugh --- poetry.lock | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0c785bde0..8e26b0e74 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4138,5 +4138,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = "^3.12.2" -content-hash = "1f065b41b9d3f15f482b6371a4518e081f68cd1b45c7b6f7382ed161dbb57267" - +content-hash = "c08eb761df32c636f80f85dbb68bca64aa1c43b35f41317106a5890234ed48e4"