diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 86a1da6..9391846 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -117,7 +117,7 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit # Ruff version. - rev: v0.7.1 + rev: v0.7.2 hooks: # Run the linter. - id: ruff @@ -141,7 +141,7 @@ repos: exclude: ^samples/ - repo: https://github.com/RobertCraigie/pyright-python - rev: v1.1.387 + rev: v1.1.388 hooks: - id: pyright @@ -155,7 +155,7 @@ repos: additional_dependencies: [".[toml]"] - repo: https://github.com/crate-ci/typos - rev: v1.26.8 + rev: v1.27.0 hooks: - id: typos diff --git a/mailjet_rest/__init__.py b/mailjet_rest/__init__.py index cc550a8..df91474 100644 --- a/mailjet_rest/__init__.py +++ b/mailjet_rest/__init__.py @@ -1,3 +1,19 @@ +"""The `mailjet_rest` package provides a Python client for interacting with the Mailjet API. + +This package includes the main `Client` class for handling API requests, along with +utility functions for version management. The package exposes a consistent interface +for Mailjet API operations. + +Attributes: + __version__ (str): The current version of the `mailjet_rest` package. + __all__ (list): Specifies the public API of the package, including `Client` + for API interactions and `get_version` for retrieving version information. + +Modules: + - client: Defines the main API client. + - utils.version: Provides version management functionality. +""" + from mailjet_rest.client import Client from mailjet_rest.utils.version import get_version diff --git a/mailjet_rest/client.py b/mailjet_rest/client.py index 26186bb..d369350 100644 --- a/mailjet_rest/client.py +++ b/mailjet_rest/client.py @@ -1,3 +1,31 @@ +"""This module provides the main client and helper classes for interacting with the Mailjet API. + +The `mailjet_rest.client` module includes the core `Client` class for managing +API requests, configuration, and error handling, as well as utility functions +and classes for building request headers, URLs, and parsing responses. + +Classes: + - Config: Manages configuration settings for the Mailjet API. + - Endpoint: Represents specific API endpoints and provides methods for + common HTTP operations like GET, POST, PUT, and DELETE. + - Client: The main API client for authenticating and making requests. + - ApiError: Base class for handling API-specific errors, with subclasses + for more specific error types (e.g., `AuthorizationError`, `TimeoutError`). + +Functions: + - prepare_url: Prepares URLs for API requests. + - api_call: A helper function that sends HTTP requests to the API and handles + responses. + - build_headers: Builds HTTP headers for the requests. + - build_url: Constructs the full API URL based on endpoint and parameters. + - parse_response: Parses API responses and handles error conditions. + +Exceptions: + - ApiError: Base exception for API errors, with subclasses to represent + specific error types, such as `AuthorizationError`, `TimeoutError`, + `ActionDeniedError`, and `ValidationError`. +""" + from __future__ import annotations import json @@ -23,7 +51,14 @@ def prepare_url(key: Match[str]) -> str: - """Replaces capital letters to lower one with dash prefix.""" + """Replace capital letters in the input string with a dash prefix and converts them to lowercase. + + Parameters: + key (Match[str]): A match object representing a substring from the input string. The substring should contain a single capital letter. + + Returns: + str: A string containing a dash followed by the lowercase version of the input capital letter. + """ char_elem = key.group(0) if char_elem.isupper(): return "-" + char_elem.lower() @@ -31,17 +66,58 @@ def prepare_url(key: Match[str]) -> str: class Config: + """Configuration settings for interacting with the Mailjet API. + + This class stores and manages API configuration details, including the API URL, + version, and user agent string. It provides methods for initializing these settings + and generating endpoint-specific URLs and headers as required for API interactions. + + Attributes: + DEFAULT_API_URL (str): The default base URL for Mailjet API requests. + API_REF (str): Reference URL for Mailjet's API documentation. + version (str): API version to use, defaulting to 'v3'. + user_agent (str): User agent string including the package version for tracking. + """ + DEFAULT_API_URL: str = "https://api.mailjet.com/" - API_REF: str = "http://dev.mailjet.com/email-api/v3/" + API_REF: str = "https://dev.mailjet.com/email-api/v3/" version: str = "v3" user_agent: str = "mailjet-apiv3-python/v" + get_version() def __init__(self, version: str | None = None, api_url: str | None = None) -> None: + """Initialize a new Config instance with specified or default API settings. + + This initializer sets the API version and base URL. If no version or URL + is provided, it defaults to the predefined class values. + + Parameters: + - version (str | None): The API version to use. If None, the default version ('v3') is used. + - api_url (str | None): The base URL for API requests. If None, the default URL (DEFAULT_API_URL) is used. + """ if version is not None: self.version = version self.api_url = api_url or self.DEFAULT_API_URL def __getitem__(self, key: str) -> tuple[str, dict[str, str]]: + """Retrieve the API endpoint URL and headers for a given key. + + This method builds the URL and headers required for specific API interactions. + The URL is adjusted based on the API version, and additional headers are + appended depending on the endpoint type. Specific keys modify content-type + for endpoints expecting CSV or plain text. + + Parameters: + - key (str): The name of the API endpoint, which influences URL structure and header configuration. + + Returns: + - tuple[str, dict[str, str]]: A tuple containing the constructed URL and headers required for the specified endpoint. + + Examples: + For the "contactslist_csvdata" key, a URL pointing to 'DATA/' and a + 'Content-type' of 'text/plain' is returned. + For the "batchjob_csverror" key, a URL with 'DATA/' and a 'Content-type' + of 'text/csv' is returned. + """ # Append version to URL. # Forward slash is ignored if present in self.version. url = urljoin(self.api_url, self.version + "/") @@ -62,6 +138,27 @@ def __getitem__(self, key: str) -> tuple[str, dict[str, str]]: class Endpoint: + """A class representing a specific Mailjet API endpoint. + + This class provides methods to perform HTTP requests to a given API endpoint, + including GET, POST, PUT, and DELETE requests. It manages URL construction, + headers, and authentication for interacting with the endpoint. + + Attributes: + - _url (str): The base URL of the endpoint. + - headers (dict[str, str]): The headers to be included in API requests. + - _auth (tuple[str, str] | None): The authentication credentials. + - action (str | None): The specific action to be performed on the endpoint. + + Methods: + - _get: Internal method to perform a GET request. + - get_many: Performs a GET request to retrieve multiple resources. + - get: Performs a GET request to retrieve a specific resource. + - create: Performs a POST request to create a new resource. + - update: Performs a PUT request to update an existing resource. + - delete: Performs a DELETE request to delete a resource. + """ + def __init__( self, url: str, @@ -69,6 +166,14 @@ def __init__( auth: tuple[str, str] | None, action: str | None = None, ) -> None: + """Initialize a new Endpoint instance. + + Args: + url (str): The base URL for the endpoint. + headers (dict[str, str]): Headers for API requests. + auth (tuple[str, str] | None): Authentication credentials. + action (str | None): Action to perform on the endpoint, if any. + """ self._url, self.headers, self._auth, self.action = url, headers, auth, action def _get( @@ -78,6 +183,20 @@ def _get( id: str | None = None, **kwargs: Any, ) -> Response: + """Perform an internal GET request to the endpoint. + + Constructs the URL with the provided filters and action_id to retrieve + specific data from the API. + + Parameters: + - filters (Mapping[str, str | Any] | None): Filters to be applied in the request. + - action_id (str | None): The specific action ID for the endpoint to be performed. + - id (str | None): The ID of the specific resource to be retrieved. + - **kwargs (Any): Additional keyword arguments to be passed to the API call. + + Returns: + - Response: The response object from the API call. + """ return api_call( self._auth, "get", @@ -96,6 +215,16 @@ def get_many( action_id: str | None = None, **kwargs: Any, ) -> Response: + """Perform a GET request to retrieve multiple resources. + + Parameters: + - filters (Mapping[str, str | Any] | None): Filters to be applied in the request. + - action_id (str | None): The specific action ID to be performed. + - **kwargs (Any): Additional keyword arguments to be passed to the API call. + + Returns: + - Response: The response object from the API call containing multiple resources. + """ return self._get(filters=filters, action_id=action_id, **kwargs) def get( @@ -105,6 +234,17 @@ def get( action_id: str | None = None, **kwargs: Any, ) -> Response: + """Perform a GET request to retrieve a specific resource. + + Parameters: + - id (str | None): The ID of the specific resource to be retrieved. + - filters (Mapping[str, str | Any] | None): Filters to be applied in the request. + - action_id (str | None): The specific action ID to be performed. + - **kwargs (Any): Additional keyword arguments to be passed to the API call. + + Returns: + - Response: The response object from the API call containing the specific resource. + """ return self._get(id=id, filters=filters, action_id=action_id, **kwargs) def create( @@ -117,6 +257,20 @@ def create( data_encoding: str = "utf-8", **kwargs: Any, ) -> Response: + """Perform a POST request to create a new resource. + + Parameters: + - data (dict | None): The data to include in the request body. + - filters (Mapping[str, str | Any] | None): Filters to be applied in the request. + - id (str | None): The ID of the specific resource to be created. + - action_id (str | None): The specific action ID to be performed. + - ensure_ascii (bool): Whether to ensure ASCII characters in the data. + - data_encoding (str): The encoding to be used for the data. + - **kwargs (Any): Additional keyword arguments to be passed to the API call. + + Returns: + - Response: The response object from the API call. + """ json_data: str | bytes | None = None if self.headers.get("Content-type") == "application/json" and data is not None: json_data = json.dumps(data, ensure_ascii=ensure_ascii) @@ -145,6 +299,20 @@ def update( data_encoding: str = "utf-8", **kwargs: Any, ) -> Response: + """Perform a PUT request to update an existing resource. + + Parameters: + - id (str | None): The ID of the specific resource to be updated. + - data (dict | None): The data to be sent in the request body. + - filters (Mapping[str, str | Any] | None): Filters to be applied in the request. + - action_id (str | None): The specific action ID to be performed. + - ensure_ascii (bool): Whether to ensure ASCII characters in the data. + - data_encoding (str): The encoding to be used for the data. + - **kwargs (Any): Additional keyword arguments to be passed to the API call. + + Returns: + - Response: The response object from the API call. + """ json_data: str | bytes | None = None if self.headers.get("Content-type") == "application/json" and data is not None: json_data = json.dumps(data, ensure_ascii=ensure_ascii) @@ -164,6 +332,15 @@ def update( ) def delete(self, id: str | None, **kwargs: Any) -> Response: + """Perform a DELETE request to delete a resource. + + Parameters: + - id (str | None): The ID of the specific resource to be deleted. + - **kwargs (Any): Additional keyword arguments to be passed to the API call. + + Returns: + - Response: The response object from the API call. + """ return api_call( self._auth, "delete", @@ -176,13 +353,54 @@ def delete(self, id: str | None, **kwargs: Any) -> Response: class Client: + """A client for interacting with the Mailjet API. + + This class manages authentication, configuration, and API endpoint access. + It initializes with API authentication details and uses dynamic attribute access + to allow flexible interaction with various Mailjet API endpoints. + + Attributes: + - auth (tuple[str, str] | None): A tuple containing the API key and secret for authentication. + - config (Config): An instance of the Config class, which holds API configuration settings. + + Methods: + - __init__: Initializes a new Client instance with authentication and configuration settings. + - __getattr__: Handles dynamic attribute access, allowing for accessing API endpoints as attributes. + """ + def __init__(self, auth: tuple[str, str] | None = None, **kwargs: Any) -> None: + """Initialize a new Client instance for API interaction. + + This method sets up API authentication and configuration. The `auth` parameter + provides a tuple with the API key and secret. Additional keyword arguments can + specify configuration options like API version and URL. + + Parameters: + - auth (tuple[str, str] | None): A tuple containing the API key and secret for authentication. If None, authentication is not required. + - **kwargs (Any): Additional keyword arguments, such as `version` and `api_url`, for configuring the client. + + Example: + client = Client(auth=("api_key", "api_secret"), version="v3") + """ self.auth = auth version: str | None = kwargs.get("version") api_url: str | None = kwargs.get("api_url") self.config = Config(version=version, api_url=api_url) def __getattr__(self, name: str) -> Any: + """Dynamically access API endpoints as attributes. + + This method allows for flexible, attribute-style access to API endpoints. + It constructs the appropriate endpoint URL and headers based on the attribute + name, which it parses to identify the resource and optional sub-resources. + + Parameters: + - name (str): The name of the attribute being accessed, corresponding to the Mailjet API endpoint. + + + Returns: + - Endpoint: An instance of the `Endpoint` class, initialized with the constructed URL, headers, action, and authentication details. + """ name_regex: str = re.sub(r"[A-Z]", prepare_url, name) split: list[str] = name_regex.split("_") # noqa: RUF100, FURB184 # identify the resource @@ -218,6 +436,25 @@ def api_call( action_id: str | None = None, **kwargs: Any, ) -> Response | Any: + """Make an API call to a specified URL using the provided method, headers, and other parameters. + + Parameters: + - auth (tuple[str, str] | None): A tuple containing the API key and secret for authentication. + - method (str): The HTTP method to be used for the API call (e.g., 'get', 'post', 'put', 'delete'). + - url (str): The URL to which the API call will be made. + - headers (dict[str, str]): A dictionary containing the headers to be included in the API call. + - data (str | bytes | None): The data to be sent in the request body. + - filters (Mapping[str, str | Any] | None): A dictionary containing filters to be applied in the request. + - resource_id (str | None): The ID of the specific resource to be accessed. + - timeout (int): The timeout for the API call in seconds. + - debug (bool): A flag indicating whether debug mode is enabled. + - action (str | None): The specific action to be performed on the resource. + - action_id (str | None): The ID of the specific action to be performed. + - **kwargs (Any): Additional keyword arguments to be passed to the API call. + + Returns: + - Response | Any: The response object from the API call if the request is successful, or an exception if an error occurs. + """ url = build_url( url, method=method, @@ -257,7 +494,16 @@ def build_headers( action: str, extra_headers: dict[str, str] | None = None, ) -> dict[str, str]: - """Build headers based on resource and action.""" + """Build headers based on resource and action. + + Parameters: + - resource (str): The name of the resource for which headers are being built. + - action (str): The specific action being performed on the resource. + - extra_headers (dict[str, str] | None): Additional headers to be included in the request. Defaults to None. + + Returns: + - dict[str, str]: A dictionary containing the headers to be included in the API request. + """ headers: dict[str, str] = {"Content-type": "application/json"} if resource.lower() == "contactslist" and action.lower() == "csvdata": @@ -278,6 +524,21 @@ def build_url( resource_id: str | None = None, action_id: str | None = None, ) -> str: + """Construct a URL for making an API request. + + This function takes the base URL, method, action, resource ID, and action ID as parameters + and constructs a URL by appending the resource ID, action, and action ID to the base URL. + + Parameters: + url (str): The base URL for the API request. + method (str | None): The HTTP method for the API request (e.g., 'get', 'post', 'put', 'delete'). + action (str | None): The specific action to be performed on the resource. Defaults to None. + resource_id (str | None): The ID of the specific resource to be accessed. Defaults to None. + action_id (str | None): The ID of the specific action to be performed. Defaults to None. + + Returns: + str: The constructed URL for the API request. + """ if resource_id: url += f"/{resource_id}" if action: @@ -288,6 +549,17 @@ def build_url( def parse_response(response: Response, debug: bool = False) -> Any: + """Parse the response from an API request. + + This function extracts the JSON data from the response and logs debug information if the `debug` flag is set to True. + + Parameters: + response (requests.models.Response): The response object from the API request. + debug (bool, optional): A flag indicating whether debug information should be logged. Defaults to False. + + Returns: + Any: The JSON data extracted from the response. + """ data = response.json() if debug: @@ -303,32 +575,67 @@ def parse_response(response: Response, debug: bool = False) -> Any: class ApiError(Exception): - pass + """Base class for all API-related errors. + + This exception serves as the root for all custom API error types, + allowing for more specific error handling based on the type of API + failure encountered. + """ class AuthorizationError(ApiError): - pass + """Error raised for authorization failures. + + This error is raised when the API request fails due to invalid + or missing authentication credentials. + """ class ActionDeniedError(ApiError): - pass + """Error raised when an action is denied by the API. + + This exception is triggered when an action is requested but is not + permitted, likely due to insufficient permissions. + """ class CriticalApiError(ApiError): - pass + """Error raised for critical API failures. + + This error represents severe issues with the API or infrastructure + that prevent requests from completing. + """ class ApiRateLimitError(ApiError): - pass + """Error raised when the API rate limit is exceeded. + + This exception is raised when the user has made too many requests + within a given time frame, as enforced by the API's rate limit policy. + """ class TimeoutError(ApiError): - pass + """Error raised when an API request times out. + + This error is raised if an API request does not complete within + the allowed timeframe, possibly due to network issues or server load. + """ class DoesNotExistError(ApiError): - pass + """Error raised when a requested resource does not exist. + + This exception is triggered when a specific resource is requested + but cannot be found in the API, indicating a potential data mismatch + or invalid identifier. + """ class ValidationError(ApiError): - pass + """Error raised for invalid input data. + + This exception is raised when the input data for an API request + does not meet validation requirements, such as incorrect data types + or missing fields. + """ diff --git a/mailjet_rest/utils/__init__.py b/mailjet_rest/utils/__init__.py index e69de29..21b7c8b 100644 --- a/mailjet_rest/utils/__init__.py +++ b/mailjet_rest/utils/__init__.py @@ -0,0 +1,7 @@ +"""The `mailjet_rest.utils` package provides utility functions for interacting with the package versionI. + +This package includes a module for managing the package version. + +Modules: + - version: Manages the package versioning. +""" diff --git a/mailjet_rest/utils/version.py b/mailjet_rest/utils/version.py index 2955c71..48abae5 100644 --- a/mailjet_rest/utils/version.py +++ b/mailjet_rest/utils/version.py @@ -1,3 +1,16 @@ +"""Versioning utilities for the Mailjet REST API client. + +This module defines the current version of the Mailjet client and provides +a helper function, `get_version`, to retrieve the version as a formatted string. + +Attributes: + VERSION (tuple[int, int, int]): A tuple representing the major, minor, and patch + version of the package. + +Functions: + get_version: Returns the version as a string in the format "major.minor.patch". +""" + from __future__ import annotations @@ -5,9 +18,20 @@ def get_version(version: tuple[int, ...] | None = None) -> str: - """ - Calculate package version based on a 3 item tuple. - In addition verify that the tuple contains 3 items + """Calculate package version based on a 3 item tuple. + + In addition, verify that the tuple contains 3 items. + + Parameters: + version (tuple[int, ...], optional): A tuple representing the version of the package. + If not provided, the function will use the VERSION constant. + Default is None. + + Returns: + str: The version as a string in the format "major.minor.patch". + + Raises: + ValueError: If the provided tuple does not contain exactly 3 items. """ if version is None: version = VERSION diff --git a/pyproject.toml b/pyproject.toml index 83e5a6b..1c0593e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -197,33 +197,20 @@ ignore = [ "B904", # Within an `except` clause, raise exceptions with `raise ... from err` or `raise ... from None` # pycodestyle (E, W) "CPY001", # Missing copyright notice at top of file - # TODO: Enable docstring lints when they will be available. - "D", - "DOC", + "DOC501", # DOC501 Raised exception `TimeoutError` and `ApiError` missing from docstring "E501", "FBT001", # Boolean-typed positional argument in function definition "FBT002", # Boolean default positional argument in function definition - "INP001", # INP001 File `samples/campaign_sample.py` is part of an implicit namespace package. Add an `__init__.py`. - "PD901", - "PD015", - # pep8-naming (N) - "N802", - "N806", - # TODO: PLE0604 Invalid object in `__all__`, must contain only strings - "PLE0604", + # TODO: Replace with http.HTTPStatus, see https://docs.python.org/3/library/http.html#http-status-codes "PLR2004", # PLR2004 Magic value used in comparison, consider replacing `XXX` with a constant variable "PLR0913", # PLR0913 Too many arguments in function definition (6 > 5) - #"PLR0917", # PLR0917 Too many positional arguments - # TODO: "Remove Q000 it before the next release - "Q000", + "PLR0917", # PLR0917 Too many positional arguments "Q003", # Checks for avoidable escaped quotes ("\"" -> '"') - "RET504", # RET504 Unnecessary assignment to `response` before `return` statement - "RUF012", # TODO:" PT009 Use a regular `assert` instead of unittest-style `assertTrue` "PT009", "S311", # S311 Standard pseudo-random generators are not suitable for cryptographic purposes + # TODO: T201 Replace `print` with logging functions "T201", # T201 `print` found - "UP031", # pyupgrade (UP): Skip for logging: UP031 Use format specifiers instead of percent format ] @@ -289,7 +276,6 @@ ignore-overlong-task-comments = true [tool.ruff.lint.pydocstyle] convention = "google" - [tool.mypy] strict = true # Adapted from this StackOverflow post: diff --git a/test.py b/test.py index b327115..7b03ebc 100644 --- a/test.py +++ b/test.py @@ -1,3 +1,5 @@ +"""A suite of tests for Mailjet API client functionality.""" + import os import random import string @@ -8,7 +10,26 @@ class TestSuite(unittest.TestCase): + """A suite of tests for Mailjet API client functionality. + + This class provides setup and teardown functionality for tests involving the + Mailjet API client, with authentication and client initialization handled + in `setUp`. Each test in this suite operates with the configured Mailjet client + instance to simulate API interactions. + """ + def setUp(self) -> None: + """Set up the test environment by initializing authentication credentials and the Mailjet client. + + This method is called before each test to ensure a consistent testing + environment. It retrieves the API keys from environment variables and + uses them to create an instance of the Mailjet `Client` for authenticated + API interactions. + + Attributes: + - self.auth (tuple[str, str]): A tuple containing the public and private API keys obtained from the environment variables 'MJ_APIKEY_PUBLIC' and 'MJ_APIKEY_PRIVATE' respectively. + - self.client (Client): An instance of the Mailjet Client class, initialized with the provided authentication credentials. + """ self.auth: tuple[str, str] = ( os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"], @@ -16,24 +37,71 @@ def setUp(self) -> None: self.client: Client = Client(auth=self.auth) def test_get_no_param(self) -> None: + """This test function sends a GET request to the Mailjet API endpoint for contacts without any parameters. + + It verifies that the response contains 'Data' and 'Count' fields. + + Parameters: + None + """ result: Any = self.client.contact.get().json() self.assertTrue("Data" in result and "Count" in result) def test_get_valid_params(self) -> None: + """This test function sends a GET request to the Mailjet API endpoint for contacts with a valid parameter 'limit'. + + It verifies that the response contains a count of contacts that is within the range of 0 to 2. + + Parameters: + None + """ result: Any = self.client.contact.get(filters={"limit": 2}).json() self.assertTrue(result["Count"] >= 0 or result["Count"] <= 2) def test_get_invalid_parameters(self) -> None: + """This test function sends a GET request to the Mailjet API endpoint for contacts with an invalid parameter. + + It verifies that the response contains 'Count' field, demonstrating that invalid parameters are ignored. + + Parameters: + None + """ # invalid parameters are ignored result: Any = self.client.contact.get(filters={"invalid": "false"}).json() self.assertTrue("Count" in result) def test_get_with_data(self) -> None: + """This test function sends a GET request to the Mailjet API endpoint for contacts with 'data' parameter. + + It verifies that the request is successful (status code 200) and does not use the 'data' parameter. + + Parameters: + None + """ # it shouldn't use data result = self.client.contact.get(data={"Email": "api@mailjet.com"}) self.assertTrue(result.status_code == 200) def test_get_with_action(self) -> None: + """This function tests the functionality of adding a contact to a contact list using the Mailjet API client. + + It first retrieves a contact and a contact list from the API, then adds the contact to the list. + Finally, it verifies that the contact has been successfully added to the list. + + Parameters: + None + + Attributes: + - get_contact (Any): The result of the initial contact retrieval, containing a single contact. + - contact_id (str): The ID of the retrieved contact. + - post_contact (Response): The response from creating a new contact if no contact was found. + - get_contact_list (Any): The result of the contact list retrieval, containing a single contact list. + - list_id (str): The ID of the retrieved contact list. + - post_contact_list (Response): The response from creating a new contact list if no contact list was found. + - data (dict[str, list[dict[str, str]]]): The data for managing contact lists, containing the list ID and action to add the contact. + - result_add_list (Response): The response from adding the contact to the contact list. + - result (Any): The result of retrieving the contact's contact lists, containing the count of contact lists. + """ get_contact: Any = self.client.contact.get(filters={"limit": 1}).json() if get_contact["Count"] != 0: contact_id: str = get_contact["Data"][0]["ID"] @@ -83,6 +151,17 @@ def test_get_with_action(self) -> None: self.assertTrue("Count" in result) def test_get_with_id_filter(self) -> None: + """This test function sends a GET request to the Mailjet API endpoint for contacts with a specific email address obtained from a previous contact retrieval. + + It verifies that the response contains a contact with the same email address as the one used in the filter. + + Parameters: + None + + Attributes: + - result_contact (Any): The result of the initial contact retrieval, containing a single contact. + - result_contact_with_id (Any): The result of the contact retrieval using the email address from the initial contact as a filter. + """ result_contact: Any = self.client.contact.get(filters={"limit": 1}).json() result_contact_with_id: Any = self.client.contact.get( filter={"Email": result_contact["Data"][0]["Email"]}, @@ -93,10 +172,29 @@ def test_get_with_id_filter(self) -> None: ) def test_post_with_no_param(self) -> None: + """This function tests the behavior of the Mailjet API client when attempting to create a sender with no parameters. + + The function sends a POST request to the Mailjet API endpoint for creating a sender with an empty + data dictionary. It then verifies that the response contains a 'StatusCode' field with a value of 400, + indicating a bad request. This test ensures that the client handles missing required parameters + appropriately. + + Parameters: + None + """ result: Any = self.client.sender.create(data={}).json() self.assertTrue("StatusCode" in result and result["StatusCode"] == 400) def test_client_custom_version(self) -> None: + """This test function verifies the functionality of setting a custom version for the Mailjet API client. + + The function initializes a new instance of the Mailjet Client with custom version "v3.1". + It then asserts that the client's configuration version is correctly set to "v3.1". + Additionally, it verifies that the send endpoint URL in the client's configuration is updated to the correct version. + + Parameters: + None + """ self.client = Client(auth=self.auth, version="v3.1") self.assertEqual(self.client.config.version, "v3.1") self.assertEqual( @@ -105,6 +203,15 @@ def test_client_custom_version(self) -> None: ) def test_user_agent(self) -> None: + """This function tests the user agent configuration of the Mailjet API client. + + The function initializes a new instance of the Mailjet Client with a custom version "v3.1". + It then asserts that the client's user agent is correctly set to "mailjet-apiv3-python/v1.3.5". + This test ensures that the client's user agent is properly configured and includes the correct version information. + + Parameters: + None + """ self.client = Client(auth=self.auth, version="v3.1") self.assertEqual(self.client.config.user_agent, "mailjet-apiv3-python/v1.3.5") diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..3e9246c 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,6 @@ +"""The `tests` package contains unit and integration tests for the Mailjet REST API client. + +This package ensures that all core functionalities of the Mailjet client, including +authentication, API requests, error handling, and response parsing, work as expected. +Each module within this package tests a specific aspect or component of the client. +""" diff --git a/tests/test_client.py b/tests/test_client.py index 2efd2df..35a7943 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,6 +3,7 @@ import json import os import re +from typing import Any import pytest @@ -13,6 +14,16 @@ @pytest.fixture def simple_data() -> tuple[dict[str, list[dict[str, str]]], str]: + """This function provides a simple data structure and its encoding for testing purposes. + + Parameters: + None + + Returns: + tuple: A tuple containing two elements: + - A dictionary representing structured data with a list of dictionaries. + - A string representing the encoding of the data. + """ data: dict[str, list[dict[str, str]]] = { "Data": [{"Name": "first_name", "Value": "John"}] } @@ -22,6 +33,14 @@ def simple_data() -> tuple[dict[str, list[dict[str, str]]], str]: @pytest.fixture def client_mj30() -> Client: + """This function creates and returns a Mailjet API client instance for version 3.0. + + Parameters: + None + + Returns: + Client: An instance of the Mailjet API client configured for version 3.0. The client is authenticated using the public and private API keys provided as environment variables. + """ auth: tuple[str, str] = ( os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"], @@ -29,8 +48,40 @@ def client_mj30() -> Client: return Client(auth=auth) +@pytest.fixture +def client_mj30_invalid_auth() -> Client: + """This function creates and returns a Mailjet API client instance for version 3.0, but with invalid authentication credentials. + + Parameters: + None + + Returns: + Client: An instance of the Mailjet API client configured for version 3.0. + The client is authenticated using invalid public and private API keys. + If the client is used to make requests, it will raise a ValueError. + """ + auth: tuple[str, str] = ( + "invalid_public_key", + "invalid_private_key", + ) + return Client(auth=auth) + + @pytest.fixture def client_mj31() -> Client: + """This function creates and returns a Mailjet API client instance for version 3.1. + + Parameters: + None + + Returns: + Client: An instance of the Mailjet API client configured for version 3.1. + The client is authenticated using the public and private API keys provided as environment variables. + + Note: + - The function retrieves the public and private API keys from the environment variables 'MJ_APIKEY_PUBLIC' and 'MJ_APIKEY_PRIVATE' respectively. + - The client is initialized with the provided authentication credentials and the version set to 'v3.1'. + """ auth: tuple[str, str] = ( os.environ["MJ_APIKEY_PUBLIC"], os.environ["MJ_APIKEY_PRIVATE"], @@ -44,6 +95,17 @@ def client_mj31() -> Client: def test_json_data_str_or_bytes_with_ensure_ascii( simple_data: tuple[dict[str, list[dict[str, str]]], str] ) -> None: + """ + This function tests the conversion of structured data into JSON format with the specified encoding settings. + + Parameters: + simple_data (tuple[dict[str, list[dict[str, str]]], str]): A tuple containing two elements: + - A dictionary representing structured data with a list of dictionaries. + - A string representing the encoding of the data. + + Returns: + None: The function does not return any value. It performs assertions to validate the JSON conversion. + """ data, data_encoding = simple_data ensure_ascii: bool = True @@ -60,6 +122,18 @@ def test_json_data_str_or_bytes_with_ensure_ascii( def test_json_data_str_or_bytes_with_ensure_ascii_false( simple_data: tuple[dict[str, list[dict[str, str]]], str] ) -> None: + """This function tests the conversion of structured data into JSON format with the specified encoding settings. + + It specifically tests the case where the 'ensure_ascii' parameter is set to False. + + Parameters: + simple_data (tuple[dict[str, list[dict[str, str]]], str]): A tuple containing two elements: + - A dictionary representing structured data with a list of dictionaries. + - A string representing the encoding of the data. + + Returns: + None: The function does not return any value. It performs assertions to validate the JSON conversion. + """ data, data_encoding = simple_data ensure_ascii: bool = False @@ -76,6 +150,17 @@ def test_json_data_str_or_bytes_with_ensure_ascii_false( def test_json_data_is_none( simple_data: tuple[dict[str, list[dict[str, str]]], str] ) -> None: + """ + This function tests the conversion of structured data into JSON format when the data is None. + + Parameters: + simple_data (tuple[dict[str, list[dict[str, str]]], str]): A tuple containing two elements: + - A dictionary representing structured data with a list of dictionaries. + - A string representing the encoding of the data. + + Returns: + None: The function does not return any value. It performs assertions to validate the JSON conversion. + """ data, data_encoding = simple_data ensure_ascii: bool = True data: dict[str, list[dict[str, str]]] | None = None # type: ignore @@ -91,21 +176,53 @@ def test_json_data_is_none( def test_prepare_url_list_splitting() -> None: - """Test prepare_url: list splitting""" + """This function tests the prepare_url function by splitting a string containing underscores and converting the first letter of each word to uppercase. + + The function then compares the resulting list with an expected list. + + Parameters: + None + + Note: + - The function uses the re.sub method to replace uppercase letters with the prepare_url function. + - It splits the resulting string into a list using the underscore as the delimiter. + - It asserts that the resulting list is equal to the expected list ["contact", "managecontactslists"]. + """ name: str = re.sub(r"[A-Z]", prepare_url, "contact_managecontactslists") split: list[str] = name.split("_") # noqa: FURB184 assert split == ["contact", "managecontactslists"] def test_prepare_url_first_list_element() -> None: - """Test prepare_url: list splitting, the first element, url, and headers""" + """This function tests the prepare_url function by splitting a string containing underscores, and retrieving the first element of the resulting list. + + Parameters: + None + + Note: + - The function uses the re.sub method to replace uppercase letters with the prepare_url function. + - It splits the resulting string into a list using the underscore as the delimiter. + - It asserts that the first element of the split list is equal to "contact". + """ name: str = re.sub(r"[A-Z]", prepare_url, "contact_managecontactslists") fname: str = name.split("_")[0] assert fname == "contact" def test_prepare_url_headers_and_url() -> None: - """Test prepare_url: list splitting, the first element, url, and headers""" + """Test the prepare_url function by splitting a string containing underscores, and retrieving the first element of the resulting list. + + Additionally, this test verifies the URL and headers generated by the prepare_url function. + + Parameters: + None + + Note: + - The function uses the re.sub method to replace uppercase letters with the prepare_url function. + - It creates a Config object with the specified version and API URL. + - It retrieves the URL and headers from the Config object using the modified string as the key. + - It asserts that the URL is equal to "https://api.mailjet.com/v3/REST/contact" and that the headers match the expected headers. + """ name: str = re.sub(r"[A-Z]", prepare_url, "contact_managecontactslists") config: Config = Config(version="v3", api_url="https://api.mailjet.com/") url, headers = config[name] @@ -120,5 +237,179 @@ def test_prepare_url_headers_and_url() -> None: def test_post_with_no_param(client_mj30: Client) -> None: + """Tests a POST request with an empty data payload. + + This test sends a POST request to the 'create' endpoint using an empty dictionary + as the data payload. It checks that the API responds with a 400 status code, + indicating a bad request due to missing required parameters. + + Parameters: + client_mj30 (Client): An instance of the Mailjet API client. + + Raises: + AssertionError: If "StatusCode" is not in the result or if its value + is not 400. + """ result = client_mj30.sender.create(data={}).json() assert "StatusCode" in result and result["StatusCode"] == 400 + + +def test_get_no_param(client_mj30: Client) -> None: + """Tests a GET request to retrieve contact data without any parameters. + + This test sends a GET request to the 'contact' endpoint without filters or + additional parameters. It verifies that the response includes both "Data" + and "Count" fields, confirming the endpoint returns a valid structure. + + Parameters: + client_mj30 (Client): An instance of the Mailjet API client. + + Raises: + AssertionError: If "Data" or "Count" are not present in the response. + """ + result: Any = client_mj30.contact.get().json() + assert "Data" in result and "Count" in result + + +def test_client_initialization_with_invalid_api_key( + client_mj30_invalid_auth: Client, +) -> None: + """This function tests the initialization of a Mailjet API client with invalid authentication credentials. + + Parameters: + client_mj30_invalid_auth (Client): An instance of the Mailjet API client configured for version 3.0. + The client is authenticated using invalid public and private API keys. + + Returns: + None: The function does not return any value. It is expected to raise a ValueError when the client is used to make requests. + + Note: + - The function uses the pytest.raises context manager to assert that a ValueError is raised when the client's contact.get() method is called. + """ + with pytest.raises(ValueError): + client_mj30_invalid_auth.contact.get().json() + + +def test_prepare_url_mixed_case_input() -> None: + """Test prepare_url function with mixed case input. + + This function tests the prepare_url function by providing a string with mixed case characters. + It then compares the resulting URL with the expected URL. + + Parameters: + None + + Note: + - The function uses the re.sub method to replace uppercase letters with the prepare_url function. + - It creates a Config object with the specified version and API URL. + - It retrieves the URL and headers from the Config object using the modified string as the key. + - It asserts that the URL is equal to the expected URL and that the headers match the expected headers. + """ + name: str = re.sub(r"[A-Z]", prepare_url, "contact") + config: Config = Config(version="v3", api_url="https://api.mailjet.com/") + url, headers = config[name] + assert url == "https://api.mailjet.com/v3/REST/contact" + assert headers == { + "Content-type": "application/json", + "User-agent": f"mailjet-apiv3-python/v{get_version()}", + } + + +def test_prepare_url_empty_input() -> None: + """Test prepare_url function with empty input. + + This function tests the prepare_url function by providing an empty string as input. + It then compares the resulting URL with the expected URL. + + Parameters: + None + + Note: + - The function uses the re.sub method to replace uppercase letters with the prepare_url function. + - It creates a Config object with the specified version and API URL. + - It retrieves the URL and headers from the Config object using the modified string as the key. + - It asserts that the URL is equal to the expected URL and that the headers match the expected headers. + """ + name = re.sub(r"[A-Z]", prepare_url, "") + config = Config(version="v3", api_url="https://api.mailjet.com/") + url, headers = config[name] + assert url == "https://api.mailjet.com/v3/REST/" + assert headers == { + "Content-type": "application/json", + "User-agent": f"mailjet-apiv3-python/v{get_version()}", + } + + +def test_prepare_url_with_numbers_input_bad() -> None: + """Test the prepare_url function with input containing numbers. + + This function tests the prepare_url function by providing a string with numbers. + It then compares the resulting URL with the expected URL. + + Parameters: + None + + Note: + - The function uses the re.sub method to replace uppercase letters with the prepare_url function. + - It creates a Config object with the specified version and API URL. + - It retrieves the URL and headers from the Config object using the modified string as the key. + - It asserts that the URL is not equal to the expected URL and that the headers match the expected headers. + """ + name = re.sub(r"[A-Z]", prepare_url, "contact1_managecontactslists1") + config = Config(version="v3", api_url="https://api.mailjet.com/") + url, headers = config[name] + assert url != "https://api.mailjet.com/v3/REST/contact" + assert headers == { + "Content-type": "application/json", + "User-agent": f"mailjet-apiv3-python/v{get_version()}", + } + + +def test_prepare_url_leading_trailing_underscores_input_bad() -> None: + """Test prepare_url function with input containing leading and trailing underscores. + + This function tests the prepare_url function by providing a string with leading and trailing underscores. + It then compares the resulting URL with the expected URL. + + Parameters: + None + + Note: + - The function uses the re.sub method to replace uppercase letters with the prepare_url function. + - It creates a Config object with the specified version and API URL. + - It retrieves the URL and headers from the Config object using the modified string as the key. + - It asserts that the URL is not equal to the expected URL and that the headers match the expected headers. + """ + name: str = re.sub(r"[A-Z]", prepare_url, "_contact_managecontactslists_") + config: Config = Config(version="v3", api_url="https://api.mailjet.com/") + url, headers = config[name] + assert url != "https://api.mailjet.com/v3/REST/contact" + assert headers == { + "Content-type": "application/json", + "User-agent": f"mailjet-apiv3-python/v{get_version()}", + } + + +def test_prepare_url_mixed_case_input_bad() -> None: + """Test prepare_url function with mixed case input. + + This function tests the prepare_url function by providing a string with mixed case characters. + It then compares the resulting URL with the expected URL. + + Parameters: + None + + Note: + - The function uses the re.sub method to replace uppercase letters with the prepare_url function. + - It creates a Config object with the specified version and API URL. + - It retrieves the URL and headers from the Config object using the modified string as the key. + - It asserts that the URL is not equal to the expected URL and that the headers match the expected headers. + """ + name: str = re.sub(r"[A-Z]", prepare_url, "cOntact") + config: Config = Config(version="v3", api_url="https://api.mailjet.com/") + url, headers = config[name] + assert url != "https://api.mailjet.com/v3/REST/contact" + assert headers == { + "Content-type": "application/json", + "User-agent": f"mailjet-apiv3-python/v{get_version()}", + } diff --git a/tests/test_version.py b/tests/test_version.py index 7eb79dd..e74e9f0 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -23,6 +23,7 @@ def test_get_version_is_none() -> None: def test_get_version() -> None: """Test that package version is string. + Verify that if it's equal to tuple after splitting and mapped to tuple. """ result: str | tuple[int, ...]