From 8ff93f667a8570bc9b52c24047cee7416b44144b Mon Sep 17 00:00:00 2001 From: isaacCodes1 Date: Mon, 22 Jun 2026 12:41:00 +0100 Subject: [PATCH 1/2] Add request/response debug logging for ShadeClient Introduce ShadeClient with debug mode and global config so developers can inspect masked HTTP traffic during integration troubleshooting. --- poetry.lock | 85 ++++++++++++++++++++++- pyproject.toml | 1 + src/shade/__init__.py | 4 +- src/shade/_debug.py | 65 ++++++++++++++++++ src/shade/client.py | 67 ++++++++++++++++++ src/shade/config.py | 7 ++ tests/test_debug_logging.py | 133 ++++++++++++++++++++++++++++++++++++ tests/test_gateway.py | 2 +- 8 files changed, 359 insertions(+), 5 deletions(-) create mode 100644 src/shade/_debug.py create mode 100644 src/shade/client.py create mode 100644 src/shade/config.py create mode 100644 tests/test_debug_logging.py diff --git a/poetry.lock b/poetry.lock index d2e4e03..91df6a2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -12,6 +12,26 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "anyio" +version = "4.14.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9"}, + {file = "anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.32.0)"] + [[package]] name = "black" version = "23.12.1" @@ -326,7 +346,7 @@ version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, @@ -356,6 +376,65 @@ mccabe = ">=0.7.0,<0.8.0" pycodestyle = ">=2.11.0,<2.12.0" pyflakes = ">=3.1.0,<3.2.0" +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "idna" version = "3.11" @@ -945,4 +1024,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "dbb3841dd6f95030e4943e027df7a9d9cfea543097dedf859932797ac80756cc" +content-hash = "571a55d16975df489d41619e0c239774df24bca9d678fedcf1b05068a58296bf" diff --git a/pyproject.toml b/pyproject.toml index 0aa7778..b7878ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ packages = [{include = "shade", from = "src"}] [tool.poetry.dependencies] python = "^3.10" +httpx = "^0.28.1" stellar-sdk = "^13.2.1" [tool.poetry.group.dev.dependencies] diff --git a/src/shade/__init__.py b/src/shade/__init__.py index fe0645a..fa6f77c 100644 --- a/src/shade/__init__.py +++ b/src/shade/__init__.py @@ -1,5 +1,7 @@ +from .client import ShadeClient +from .config import config from .gateway import Gateway __version__ = "0.1.0" -__all__ = ["Gateway"] +__all__ = ["Gateway", "ShadeClient", "config"] diff --git a/src/shade/_debug.py b/src/shade/_debug.py new file mode 100644 index 0000000..994a9ff --- /dev/null +++ b/src/shade/_debug.py @@ -0,0 +1,65 @@ +import logging +from typing import Any, Mapping + +logger = logging.getLogger("shade") + +BODY_TRUNCATE_LENGTH = 2000 + + +def mask_headers(headers: Mapping[str, str]) -> dict[str, str]: + """Return a copy of headers with sensitive values masked.""" + masked: dict[str, str] = {} + for key, value in headers.items(): + if key.lower() == "authorization": + masked[key] = _mask_authorization(value) + else: + masked[key] = value + return masked + + +def _mask_authorization(value: str) -> str: + if len(value) <= 4: + return "****" + return "*" * (len(value) - 4) + value[-4:] + + +def truncate_body(body: str, max_length: int = BODY_TRUNCATE_LENGTH) -> str: + if len(body) <= max_length: + return body + return body[:max_length] + "[truncated]" + + +def _body_to_str(body: Any) -> str: + if body is None: + return "" + if isinstance(body, bytes): + return body.decode("utf-8", errors="replace") + return str(body) + + +def log_request( + method: str, + url: str, + headers: Mapping[str, str], + body: Any = None, +) -> None: + logger.debug( + "Request: %s %s | headers=%s | body=%s", + method, + url, + mask_headers(headers), + truncate_body(_body_to_str(body)), + ) + + +def log_response( + status_code: int, + headers: Mapping[str, str], + body: Any = None, +) -> None: + logger.debug( + "Response: status=%s | headers=%s | body=%s", + status_code, + mask_headers(headers), + truncate_body(_body_to_str(body)), + ) diff --git a/src/shade/client.py b/src/shade/client.py new file mode 100644 index 0000000..3c582e8 --- /dev/null +++ b/src/shade/client.py @@ -0,0 +1,67 @@ +from typing import Any, Mapping, Optional + +import httpx + +from shade._debug import log_request, log_response +from shade.config import config + + +class ShadeClient: + """HTTP client for the Shade Payment Gateway API.""" + + def __init__( + self, + api_key: str, + base_url: str = "https://api.shadeprotocol.io", + debug: bool = False, + http_client: Optional[httpx.Client] = None, + ): + self.api_key = api_key + self.base_url = base_url.rstrip("/") + self.debug = debug + self._http = http_client or httpx.Client() + self._owns_http_client = http_client is None + + def close(self) -> None: + if self._owns_http_client: + self._http.close() + + def __enter__(self) -> "ShadeClient": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + def _should_debug(self) -> bool: + return self.debug or config.debug + + def _default_headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self.api_key}"} + + def request( + self, + method: str, + path: str, + *, + headers: Optional[Mapping[str, str]] = None, + json: Any = None, + content: Optional[bytes] = None, + ) -> httpx.Response: + url = f"{self.base_url}{path}" + request_headers = {**self._default_headers(), **(headers or {})} + + if self._should_debug(): + log_request(method, url, request_headers, content if content is not None else json) + + response = self._http.request( + method, + url, + headers=request_headers, + json=json, + content=content, + ) + + if self._should_debug(): + log_response(response.status_code, response.headers, response.text) + + return response diff --git a/src/shade/config.py b/src/shade/config.py new file mode 100644 index 0000000..4f6ccd0 --- /dev/null +++ b/src/shade/config.py @@ -0,0 +1,7 @@ +class Config: + """Global SDK configuration.""" + + debug: bool = False + + +config = Config() diff --git a/tests/test_debug_logging.py b/tests/test_debug_logging.py new file mode 100644 index 0000000..1f16ec6 --- /dev/null +++ b/tests/test_debug_logging.py @@ -0,0 +1,133 @@ +import logging + +import httpx +import pytest + +from shade import ShadeClient, config +from shade._debug import mask_headers, truncate_body + + +@pytest.fixture(autouse=True) +def reset_config(): + original = config.debug + config.debug = False + yield + config.debug = original + + +def _mock_transport(handler): + return httpx.MockTransport(handler) + + +def test_mask_authorization_header(): + headers = {"Authorization": "Bearer sk_test_abcdefghij", "Content-Type": "application/json"} + masked = mask_headers(headers) + + assert masked["Content-Type"] == "application/json" + assert masked["Authorization"].endswith("ghij") + assert masked["Authorization"][:-4] == "*" * (len("Bearer sk_test_abcdefghij") - 4) + + +def test_mask_short_authorization_header(): + masked = mask_headers({"Authorization": "abc"}) + assert masked["Authorization"] == "****" + + +def test_truncate_body(): + body = "x" * 2500 + truncated = truncate_body(body) + + assert truncated.endswith("[truncated]") + assert len(truncated) == 2000 + len("[truncated]") + + +def test_truncate_body_under_limit(): + body = "short body" + assert truncate_body(body) == body + + +def test_debug_logs_request_and_response(caplog): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"status": "ok"}, headers={"X-Request-Id": "req-1"}) + + transport = _mock_transport(handler) + http_client = httpx.Client(transport=transport) + + with caplog.at_level(logging.DEBUG, logger="shade"): + with ShadeClient( + api_key="sk_test_secret_key_1234", + base_url="https://api.example.com", + debug=True, + http_client=http_client, + ) as client: + client.request("POST", "/payments", json={"amount": 100}) + + assert any("Request: POST https://api.example.com/payments" in record.message for record in caplog.records) + assert any("Response: status=200" in record.message for record in caplog.records) + assert not any("sk_test_secret_key_1234" in record.message for record in caplog.records) + assert any("1234" in record.message for record in caplog.records) + + +def test_debug_false_does_not_log(caplog): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text="ok") + + transport = _mock_transport(handler) + http_client = httpx.Client(transport=transport) + + with caplog.at_level(logging.DEBUG, logger="shade"): + with ShadeClient( + api_key="sk_test_secret_key_1234", + base_url="https://api.example.com", + debug=False, + http_client=http_client, + ) as client: + client.request("GET", "/health") + + shade_records = [record for record in caplog.records if record.name == "shade"] + assert shade_records == [] + + +def test_global_config_debug_enables_logging(caplog): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(201, text='{"created": true}') + + transport = _mock_transport(handler) + http_client = httpx.Client(transport=transport) + config.debug = True + + with caplog.at_level(logging.DEBUG, logger="shade"): + with ShadeClient( + api_key="sk_test_secret_key_5678", + base_url="https://api.example.com", + debug=False, + http_client=http_client, + ) as client: + client.request("POST", "/items") + + assert any("Request: POST https://api.example.com/items" in record.message for record in caplog.records) + assert any("Response: status=201" in record.message for record in caplog.records) + + +def test_response_body_truncated_in_logs(caplog): + long_body = "y" * 3000 + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text=long_body) + + transport = _mock_transport(handler) + http_client = httpx.Client(transport=transport) + + with caplog.at_level(logging.DEBUG, logger="shade"): + with ShadeClient( + api_key="sk_test_key_abcd", + base_url="https://api.example.com", + debug=True, + http_client=http_client, + ) as client: + client.request("GET", "/large") + + response_logs = [record.message for record in caplog.records if "Response: status=200" in record.message] + assert len(response_logs) == 1 + assert "[truncated]" in response_logs[0] + assert "y" * 3000 not in response_logs[0] diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 95dc234..097c45a 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -1,6 +1,6 @@ -import pytest from shade import Gateway + def test_gateway_initialization(): gateway = Gateway() assert gateway is not None From 7e048a466e0be52222a874fd1a729a3b51b3fc76 Mon Sep 17 00:00:00 2001 From: CodeBestia <158113179+codebestia@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:57:33 +0100 Subject: [PATCH 2/2] Update src/shade/client.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/shade/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/shade/client.py b/src/shade/client.py index 3c582e8..9557a5c 100644 --- a/src/shade/client.py +++ b/src/shade/client.py @@ -47,7 +47,8 @@ def request( json: Any = None, content: Optional[bytes] = None, ) -> httpx.Response: - url = f"{self.base_url}{path}" + normalized_path = path if path.startswith("/") else f"/{path}" + url = f"{self.base_url}{normalized_path}" request_headers = {**self._default_headers(), **(headers or {})} if self._should_debug():