diff --git a/.editorconfig b/.editorconfig index 07dc7ba..9ca629b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,10 +13,6 @@ end_of_line = lf [*.md] trim_trailing_whitespace = false -[*.bat] -indent_style = tab -end_of_line = crlf - [LICENSE] insert_final_newline = false diff --git a/CHANGELOG.md b/CHANGELOG.md index 163ffa3..a363f4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ We [keep a changelog.](http://keepachangelog.com/) ## [Unreleased] +## [1.6.0] - 2026-04-27 + ### Security - **CWE-22 (Prevented Path Traversal):** Prevented vulnerabilities by enforcing strict URL encoding (`urllib.parse.quote`) on all dynamically injected path parameters (`id` and `action_id`). @@ -68,6 +70,9 @@ We [keep a changelog.](http://keepachangelog.com/) ### Pull Requests Merged - [PR_125](https://github.com/mailjet/mailjet-apiv3-python/pull/125) - Refactor client. +- [PR_126](https://github.com/mailjet/mailjet-apiv3-python/pull/126) - build(deps): bump conda-incubator/setup-miniconda from 3.3.0 to 4.0.1 +- [PR_128](https://github.com/mailjet/mailjet-apiv3-python/pull/128) - Release 1.6.0. +- [PR_129](https://github.com/mailjet/mailjet-apiv3-python/pull/129) - Use hyphen in the package name in readme. ## [1.5.1] - 2025-07-14 @@ -255,4 +260,5 @@ We [keep a changelog.](http://keepachangelog.com/) [1.4.0]: https://github.com/mailjet/mailjet-apiv3-python/releases/tag/v1.4.0 [1.5.0]: https://github.com/mailjet/mailjet-apiv3-python/releases/tag/v1.5.0 [1.5.1]: https://github.com/mailjet/mailjet-apiv3-python/releases/tag/v1.5.1 -[unreleased]: https://github.com/mailjet/mailjet-apiv3-python/releases/tag/v1.5.1...HEAD +[1.6.0]: https://github.com/mailjet/mailjet-apiv3-python/releases/tag/v1.6.0 +[unreleased]: https://github.com/mailjet/mailjet-apiv3-python/releases/tag/v1.6.0...HEAD diff --git a/README.md b/README.md index 7633ee9..a580dad 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ with Client(auth=(api_key, api_secret), version="v3.1") as mailjet: (Note: > **Note** -> If you choose not to use the context manager, you should manually call mailjet.close() when your application shuts down). +> If you choose not to use the context manager, you should manually call mailjet.close() when your application shuts down. ### Advanced Configuration @@ -464,7 +464,7 @@ Some requests (for example [GET /contact](https://dev.mailjet.com/email/referenc `limit` `int` Limit the response to a select number of returned objects. Default value: `10`. Maximum value: `1000` `offset` `int` Retrieve a list of objects starting from a certain offset. Combine this query parameter with `limit` to retrieve a specific section of the list of objects. Default value: `0` `sort` `str` Sort the results by a property and select ascending (ASC) or descending (DESC) order. The default order is ascending. Keep in mind that this is not available for all properties. Default value: `ID asc` -Next example returns 40 contacts starting from 51th record sorted by `Email` field descendally: +Next example returns 40 contacts starting from 51st record sorted by `Email` field descendally: ```python filters = { @@ -645,7 +645,7 @@ Feel free to ask anything, and contribute: - Create a new branch. - Implement your feature or bug fix. - Add documentation to it. -- Commit, push, open a pull request and voila. +- Commit, push, open a pull request and voilĂ . If you have suggestions on how to improve the guides, please submit an issue in our [Official API Documentation repo](https://github.com/mailjet/api-documentation). diff --git a/SECURITY.md b/SECURITY.md index ff4cd01..35dc6ec 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,7 +26,7 @@ Please include the following details: If English is not your first language, please try to describe the problem and its impact to the best of your ability. For greater detail, -please use your native language and we will try our best to translate it +please use your native language, and we will try our best to translate it using online services. Please also include the code you used to find the problem and the diff --git a/conda.recipe/meta.yaml b/conda.recipe/meta.yaml index 8567d1a..48aa540 100644 --- a/conda.recipe/meta.yaml +++ b/conda.recipe/meta.yaml @@ -30,9 +30,8 @@ requirements: {% endfor %} run: - python - {% for dep in pyproject['project']['dependencies'] %} - - {{ dep.lower() }} - {% endfor %} + - requests >=2.33.0 + - typing-extensions >=4.7.1 # [py<311] test: imports: diff --git a/mailjet_rest/_version.py b/mailjet_rest/_version.py index 2d81c94..df44d33 100644 --- a/mailjet_rest/_version.py +++ b/mailjet_rest/_version.py @@ -1 +1 @@ -__version__ = "1.5.1.post1.dev40" \ No newline at end of file +__version__ = "1.6.0" \ No newline at end of file diff --git a/mailjet_rest/client.py b/mailjet_rest/client.py index b99da24..9a2cacb 100644 --- a/mailjet_rest/client.py +++ b/mailjet_rest/client.py @@ -120,19 +120,6 @@ class ApiRateLimitError(ApiError): # Utilities # ========================================== - -def prepare_url(match: Any) -> str: - """Replace capital letters in the input string with a dash prefix and convert to lowercase. - - Args: - match (Any): A regex match object containing a capital letter. - - Returns: - str: A formatted URL string fragment (e.g., '_m'). - """ - return f"_{match.group(0).lower()}" - - # --- Deprecated Utilities --- @@ -141,7 +128,7 @@ def logging_handler(to_file: bool = False, **_kwargs: Any) -> logging.Logger: # Args: to_file (bool): Deprecated flag. Output is no longer written to files natively. - **kwargs (Any): Absorbs any other legacy keyword arguments. + **_kwargs (Any): Absorbs any other legacy keyword arguments. Returns: logging.Logger: A legacy logger instance to prevent AttributeError in old integrations. @@ -152,15 +139,15 @@ def logging_handler(to_file: bool = False, **_kwargs: Any) -> logging.Logger: # ) warnings.warn(msg, DeprecationWarning, stacklevel=2) - logger = logging.getLogger("mailjet_legacy") - logger.setLevel(logging.DEBUG) + legacy_logger = logging.getLogger("mailjet_legacy") + legacy_logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(levelname)s | %(message)s") stdout_handler = logging.StreamHandler(sys.stdout) stdout_handler.setFormatter(formatter) - logger.addHandler(stdout_handler) + legacy_logger.addHandler(stdout_handler) # Return a safe, isolated logger so downstream code like `logger.debug()` doesn't crash - return logger + return legacy_logger def parse_response( @@ -175,7 +162,7 @@ def parse_response( response (requests.Response): The HTTP response. log (Any, optional): Deprecated logging callable. debug (bool): Deprecated debug flag. - **kwargs (Any): Absorbs any other legacy keyword arguments. + **_kwargs (Any): Absorbs any other legacy keyword arguments. Returns: Any: The parsed JSON dictionary or raw text string. @@ -194,7 +181,8 @@ def parse_response( # Soft legacy support: run the logger if explicitly passed without crashing if debug and callable(log): with suppress(Exception): - lgr = log() + lgr = cast("logging.Logger", cast("object", log())) + lgr.debug("REQUEST: %s", response.request.url) lgr.debug("RESPONSE_CODE: %s", response.status_code) logging.getLogger().handlers.clear() @@ -303,8 +291,18 @@ class Endpoint: def __post_init__(self) -> None: """Pre-compute routing strings ONCE instead of on every network call.""" self._name_lower = self.name.lower() - self._action_parts = self.name.split("_") - self._resource_lower = self._action_parts[0].lower() + parts = self.name.split("_") + + # Base resource ignores CamelCase-to-dash conversion (matches legacy behavior) + self._resource_lower = parts[0].lower() + self._action_parts = [self._resource_lower] + + # Re-implement camelCase-to-dash conversion natively for sub-actions + if len(parts) > 1: + for part in parts[1:]: + # Convert 'linkClick' to 'link-click' natively + dashed = "".join("-" + c.lower() if c.isupper() else c for c in part) + self._action_parts.append(dashed.lstrip("-")) @staticmethod def _build_csv_url(base_url: str, version: str, resource: str, name_lower: str, id_val: int | str | None) -> str: @@ -621,6 +619,10 @@ class Client: "myprofile", ) + config: Config + session: requests.Session + _endpoint_cache: dict[str, Endpoint] + # --- Initialization & Magic Methods --- def __init__( diff --git a/mailjet_rest/utils/version.py b/mailjet_rest/utils/version.py index b74fb9e..9c5a4fd 100644 --- a/mailjet_rest/utils/version.py +++ b/mailjet_rest/utils/version.py @@ -34,7 +34,7 @@ def clean_version(version_str: str) -> tuple[int, ...]: except (IndexError, ValueError): return 0, 0, 0 else: - return (major, minor, patch) + return major, minor, patch # VERSION is a tuple of integers (1, 3, 2). diff --git a/pyproject.toml b/pyproject.toml index 2dd5a14..a171634 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -252,7 +252,7 @@ namespace_packages = true pretty = true # 3rd party import ignore_missing_imports = true -# flag to suppress Name already defined on line +# flag to suppress Name already defined on a line allow_redefinition = false # Disallow dynamic typing disallow_any_unimported = false diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 2585d33..e50fde7 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -13,17 +13,15 @@ from requests.exceptions import RequestException from requests.exceptions import Timeout as RequestsTimeout -from mailjet_rest._version import __version__ from mailjet_rest.client import ( ApiError, Client, Config, CriticalApiError, TimeoutError, - prepare_url, ) from mailjet_rest.utils.guardrails import SecurityGuard -from mailjet_rest.client import _JSON_HEADERS, _TEXT_HEADERS +from mailjet_rest.client import _JSON_HEADERS, _TEXT_HEADERS # type: ignore[attr-defined] if TYPE_CHECKING: # Explicitly import fixture type for MyPy in a type-checking block @@ -253,6 +251,12 @@ def test_statcounters_endpoint_routing(client_offline: Client) -> None: assert url == "https://api.mailjet.com/v3/REST/statcounters" +def test_camel_case_to_dash_routing(client_offline: Client) -> None: + """Verify that CamelCase endpoints correctly translate to dashed paths (e.g., linkClick -> link-click).""" + url = client_offline.statistics_linkClick._build_url() + assert "link-click" in url, f"Expected 'link-click' in URL, got {url}" + + # ========================================== # 4. HTTP Execution & Network Handling Tests # ========================================== @@ -413,34 +417,6 @@ def mock_request(method: str, url: str, **kwargs: Any) -> requests.Response: # Calling with action_id but no id client_offline.contact.get(action_id=123) - -def test_prepare_url_headers_and_url() -> None: - assert prepare_url(re.search(r"[A-Z]", "MyURL")) == "_m" - - -def test_prepare_url_mixed_case_input() -> None: - match = re.search(r"[A-Z]", "mixedCaseInput") - assert match is not None - assert prepare_url(match) == "_c" - - -def test_prepare_url_empty_input() -> None: - match = re.search(r"[A-Z]", "") - assert match is None - - -def test_prepare_url_with_numbers_input_bad() -> None: - match = re.search(r"[A-Z]", "url1With2Numbers") - assert match is not None - assert prepare_url(match) == "_w" - - -def test_prepare_url_leading_trailing_underscores_input_bad() -> None: - match = re.search(r"[A-Z]", "_urlWithUnderscores_") - assert match is not None - assert prepare_url(match) == "_w" - - # ========================================== # 5. Resource Management (Context Managers) # ========================================== @@ -534,7 +510,7 @@ def test_endpoint_precomputes_routing_strings(client_offline: Client) -> None: endpoint = getattr(client_offline, "Contact_Data") assert getattr(endpoint, "_name_lower") == "contact_data" - assert getattr(endpoint, "_action_parts") == ["Contact", "Data"] + assert getattr(endpoint, "_action_parts") == ["contact", "data"] assert getattr(endpoint, "_resource_lower") == "contact"