diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..e25b38b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,48 @@ +name: Bug report +description: Report a problem with the SenderKit Python SDK +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug. Please do **not** include API + keys, signing secrets, or other credentials in this report. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear description of the bug and what you expected instead. + placeholder: When I call `client.send(...)`, I get ... + validations: + required: true + - type: textarea + id: repro + attributes: + label: Minimal reproduction + description: The smallest code snippet that reproduces the issue. + render: python + validations: + required: true + - type: input + id: sdk-version + attributes: + label: SDK version + description: "Output of `python -c 'import senderkit; print(senderkit.__version__)'`" + placeholder: 0.1.0 + validations: + required: true + - type: input + id: python-version + attributes: + label: Python version + placeholder: "3.12" + validations: + required: true + - type: textarea + id: traceback + attributes: + label: Traceback / logs + description: Full traceback, with any secrets redacted. + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..f43d171 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Documentation + url: https://docs.senderkit.com + about: Guides and API reference for SenderKit. + - name: Security vulnerability + url: https://github.com/senderkit/senderkit-sdk-python/security/advisories/new + about: Report security issues privately — please do not open a public issue. + - name: Questions & support + url: https://senderkit.com + about: For account or product questions, contact SenderKit support. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..24d22b8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,25 @@ +name: Feature request +description: Suggest an improvement or new capability for the SDK +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem / use case + description: What are you trying to do that the SDK makes hard today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: What would the ideal API or behavior look like? + render: python + validations: + required: false + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..447f3e4 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ + + +## Summary + + + +## Related issues + + + +## Checklist + +- [ ] `ruff check .` passes +- [ ] `ruff format .` applied +- [ ] `mypy src` passes +- [ ] `pytest` passes (new behavior is covered by tests) +- [ ] `CHANGELOG.md` updated for user-facing changes +- [ ] Docs / docstrings updated if the public API changed diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f73ad9d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + groups: + python-dependencies: + patterns: ["*"] + open-pull-requests-limit: 5 + labels: ["dependencies"] + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + groups: + github-actions: + patterns: ["*"] + labels: ["dependencies", "ci"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e80d56e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +# Cancel superseded runs on the same ref to save CI minutes. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint & type-check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: pyproject.toml + - name: Install + run: python -m pip install -e ".[dev]" + - name: ruff check + run: ruff check --output-format=github . + - name: ruff format + run: ruff format --check . + - name: mypy + run: mypy src + + test: + name: Test (py${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: pyproject.toml + - name: Install + run: python -m pip install -e ".[dev]" + - name: pytest + run: pytest --cov=senderkit --cov-report=term-missing --cov-report=xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: py${{ matrix.python-version }} + fail_ci_if_error: false + + # Single required status check that gates merges, so branch protection only + # needs to reference one job regardless of how the matrix grows. + ci-ok: + name: CI + if: always() + needs: [lint, test] + runs-on: ubuntu-latest + steps: + - name: Verify all jobs succeeded + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: exit 1 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..f235faa --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,30 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "27 4 * * 1" # weekly, Mondays + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (Python) + runs-on: ubuntu-latest + permissions: + actions: read # read workflow run metadata + contents: read # required for actions/checkout + security-events: write # upload CodeQL results + steps: + - uses: actions/checkout@v4 + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: python + queries: security-and-quality + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8376b9c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: Release + +# Publishes to PyPI when a GitHub Release is published. Uses PyPI Trusted +# Publishing (OIDC) — no API tokens or secrets are stored in the repo. +# One-time setup: add a trusted publisher on PyPI for this repo + the +# `release.yml` workflow + the `pypi` environment. +# https://docs.pypi.org/trusted-publishers/ + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + build: + name: Build distributions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install build tooling + run: python -m pip install build twine + - name: Build sdist and wheel + run: python -m build + - name: Check metadata + run: twine check dist/* + - name: Verify version matches release tag + env: + TAG: ${{ github.event.release.tag_name }} + run: | + VERSION="$(python -c 'import senderkit; print(senderkit.__version__)')" + echo "Package version: $VERSION" + echo "Release tag: $TAG" + if [ "${TAG#v}" != "$VERSION" ]; then + echo "::error::Release tag ($TAG) does not match package version ($VERSION)." + exit 1 + fi + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/senderkit + permissions: + id-token: write # required for Trusted Publishing + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 2868171..5280c86 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ htmlcov/ # Local editor / tooling (not part of the SDK) .vscode/ .claude/ +.idea +.gitattributes diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1a002b0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +# Run `pre-commit install` to enable. Mirrors the CI lint/format gates. +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.17 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..9dc8954 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,15 @@ +# Code of Conduct + +This project adopts the [Contributor Covenant](https://www.contributor-covenant.org), +version 2.1, as its code of conduct. The full text is available at: + +https://www.contributor-covenant.org/version/2/1/code_of_conduct/ + +By participating in this project — contributing code, opening issues, or taking +part in discussions — you agree to uphold this code of conduct. + +## Reporting + +To report unacceptable behavior, use GitHub's private reporting on this +repository's **Security** tab, or contact the maintainers privately. All reports +will be reviewed and handled confidentially. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..06d8009 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,64 @@ +# Contributing to the SenderKit Python SDK + +Thanks for your interest in improving the SDK! This document covers how to set +up a development environment and the checks your change needs to pass. + +## Development setup + +Requires Python 3.10+. + +```bash +git clone https://github.com/senderkit/senderkit-sdk-python.git +cd senderkit-sdk-python +python -m venv .venv && source .venv/bin/activate +pip install -e ".[dev]" +pre-commit install # optional: runs lint/format on every commit +``` + +## Running the checks + +All of these run in CI; run them locally before opening a PR. The `Makefile` +wraps them: + +```bash +make lint # ruff check +make format # ruff format (apply) +make typecheck # mypy src +make test # pytest with coverage +make check # everything CI runs (format --check, lint, typecheck, test) +``` + +Or invoke the tools directly: + +```bash +ruff check . +ruff format . +mypy src +pytest --cov=senderkit +``` + +## Guidelines + +- **Tests.** New behavior needs tests. We use `pytest` with + [`respx`](https://lundberg.github.io/respx/) to mock HTTP — no live network + calls in the suite. +- **Typing.** The package ships `py.typed`; keep the public API fully typed and + `mypy`-clean. +- **Style.** `ruff` handles linting and formatting (100-char lines). Match the + surrounding code; the CI format gate is enforced. +- **Changelog.** Add an entry to `CHANGELOG.md` for any user-facing change. +- **Commits & PRs.** Keep PRs focused on one logical change. Write clear commit + messages explaining the *why*. + +## Releasing (maintainers) + +1. Bump `VERSION` in `src/senderkit/_version.py` (single source of truth) and + update `CHANGELOG.md`. +2. Merge to `main`. +3. Create a GitHub Release with tag `vX.Y.Z` matching the new version. +4. The `Release` workflow builds and publishes to PyPI via Trusted Publishing. + +## Code of Conduct + +By participating in this project you agree to abide by the +[Code of Conduct](CODE_OF_CONDUCT.md). diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..150c49f --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +.DEFAULT_GOAL := help +.PHONY: help install lint format format-check typecheck test check build clean + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-13s\033[0m %s\n", $$1, $$2}' + +install: ## Install the package with dev dependencies + pip install -e ".[dev]" + +lint: ## Lint with ruff + ruff check . + +format: ## Format with ruff (apply changes) + ruff format . + +format-check: ## Check formatting without changing files + ruff format --check . + +typecheck: ## Type-check with mypy + mypy src + +test: ## Run the test suite with coverage + pytest --cov=senderkit --cov-report=term-missing + +check: format-check lint typecheck test ## Run everything CI runs + +build: ## Build sdist and wheel + python -m build + +clean: ## Remove build and cache artifacts + rm -rf dist build *.egg-info src/*.egg-info .pytest_cache .mypy_cache .ruff_cache .coverage htmlcov + find . -type d -name __pycache__ -exec rm -rf {} + diff --git a/README.md b/README.md index d080836..9db882f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ # SenderKit Python SDK +[![PyPI version](https://img.shields.io/pypi/v/senderkit.svg)](https://pypi.org/project/senderkit/) +[![Python versions](https://img.shields.io/pypi/pyversions/senderkit.svg)](https://pypi.org/project/senderkit/) +[![CI](https://github.com/senderkit/senderkit-sdk-python/actions/workflows/ci.yml/badge.svg)](https://github.com/senderkit/senderkit-sdk-python/actions/workflows/ci.yml) +[![CodeQL](https://github.com/senderkit/senderkit-sdk-python/actions/workflows/codeql.yml/badge.svg)](https://github.com/senderkit/senderkit-sdk-python/actions/workflows/codeql.yml) +[![codecov](https://codecov.io/gh/senderkit/senderkit-sdk-python/branch/main/graph/badge.svg)](https://codecov.io/gh/senderkit/senderkit-sdk-python) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) +[![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/) + The official Python client for [SenderKit](https://senderkit.com) — send transactional **email, SMS, push, and web-push** through a single API, from one client. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4c53c2b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security Policy + +## Reporting a vulnerability + +Please **do not** report security vulnerabilities through public GitHub issues. + +Instead, report them privately via GitHub's +[security advisory form](https://github.com/senderkit/senderkit-sdk-python/security/advisories/new), +or email **security@senderkit.com**. + +Please include: + +- A description of the vulnerability and its impact. +- Steps to reproduce or a proof of concept. +- The SDK version and Python version affected. + +We aim to acknowledge reports within 3 business days and to provide a remediation +timeline after triage. We will credit reporters in the release notes unless you +prefer to remain anonymous. + +## Supported versions + +Security fixes are released for the latest minor version. We recommend always +running the most recent release. + +## Handling credentials + +The SDK never logs API keys or webhook signing secrets. When filing bug reports +or sharing tracebacks, redact any `sk_...` API keys and signing secrets. diff --git a/pyproject.toml b/pyproject.toml index a6dc109..a58952a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "senderkit" -version = "0.1.0" +dynamic = ["version"] description = "Python SDK for SenderKit — send transactional email, SMS, push, and web-push." readme = "README.md" requires-python = ">=3.10" @@ -37,15 +37,23 @@ celery = ["celery>=5.3"] dev = [ "pytest>=8", "pytest-asyncio>=0.23", + "pytest-cov>=5", "respx>=0.21", "mypy>=1.10", "ruff>=0.5", + "pre-commit>=3.7", + "build>=1.2", + "twine>=5", "django>=4.2", "fastapi>=0.100", "flask>=2.3", "celery>=5.3", ] +[tool.hatch.version] +path = "src/senderkit/_version.py" +pattern = 'VERSION = "(?P[^"]+)"' + [tool.hatch.build.targets.wheel] packages = ["src/senderkit"] @@ -53,7 +61,21 @@ packages = ["src/senderkit"] asyncio_mode = "auto" pythonpath = ["src", "."] testpaths = ["tests"] -addopts = "--import-mode=importlib" +addopts = "--import-mode=importlib --strict-markers --strict-config" + +[tool.coverage.run] +source = ["senderkit"] +branch = true + +[tool.coverage.report] +show_missing = true +fail_under = 85 +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", + "\\.\\.\\.", +] [tool.mypy] python_version = "3.10" diff --git a/src/senderkit/_http.py b/src/senderkit/_http.py index 938bbe5..8c5479a 100644 --- a/src/senderkit/_http.py +++ b/src/senderkit/_http.py @@ -121,9 +121,7 @@ def _raise_for_status(response: httpx.Response) -> None: request_id=request_id, retry_after=_parse_retry_after(response), ) - raise errors.APIError( - message, status=status, code=code, issues=issues, request_id=request_id - ) + raise errors.APIError(message, status=status, code=code, issues=issues, request_id=request_id) class _BaseTransport: @@ -262,9 +260,7 @@ async def request( raise errors.NetworkError(f"Network error: {exc}") from exc if _is_retryable(response.status_code) and attempt < attempts - 1: - await asyncio.sleep( - _backoff_seconds(attempt, _parse_retry_after(response)) - ) + await asyncio.sleep(_backoff_seconds(attempt, _parse_retry_after(response))) continue _raise_for_status(response) @@ -272,9 +268,7 @@ async def request( raise errors.NetworkError("Request failed after retries") # pragma: no cover - async def request_json( - self, method: str, path: str, **kwargs: Any - ) -> Dict[str, Any]: + async def request_json(self, method: str, path: str, **kwargs: Any) -> Dict[str, Any]: response = await self.request(method, path, **kwargs) if not response.content: return {} diff --git a/src/senderkit/_serialize.py b/src/senderkit/_serialize.py index 66df81c..04198b2 100644 --- a/src/senderkit/_serialize.py +++ b/src/senderkit/_serialize.py @@ -126,9 +126,7 @@ def template_send_to_body(req: TemplateSend) -> Dict[str, Any]: "bcc": req.bcc, "replyTo": req.reply_to, "attachments": ( - [_attachment_to_wire(a) for a in req.attachments] - if req.attachments - else None + [_attachment_to_wire(a) for a in req.attachments] if req.attachments else None ), } ) diff --git a/src/senderkit/client.py b/src/senderkit/client.py index 71ccc00..45501db 100644 --- a/src/senderkit/client.py +++ b/src/senderkit/client.py @@ -67,9 +67,7 @@ def __init__( if not api_key: raise ValueError("api_key is required") self.mode = _mode_for_key(api_key) - self._transport = Transport( - api_key, base_url, timeout, max_retries, http_client - ) + self._transport = Transport(api_key, base_url, timeout, max_retries, http_client) self.messages = Messages(self._transport) self.templates = Templates(self._transport) @@ -138,9 +136,7 @@ def send_raw( def _send(self, request: SendItem, idempotency_key: Optional[str] = None) -> SendResult: body, own_key = build_send(request) key = idempotency_key or own_key or str(uuid.uuid4()) - data = self._transport.request_json( - "POST", "/v1/send", body=body, idempotency_key=key - ) + data = self._transport.request_json("POST", "/v1/send", body=body, idempotency_key=key) return SendResult.from_dict(data) def send_batch( @@ -167,9 +163,7 @@ def run(index: int, request: SendItem) -> BatchResult: return [] with ThreadPoolExecutor(max_workers=max(1, concurrency)) as pool: - for outcome in pool.map( - lambda pair: run(*pair), list(enumerate(requests)) - ): + for outcome in pool.map(lambda pair: run(*pair), list(enumerate(requests))): results[outcome.index] = outcome return [r for r in results if r is not None] @@ -203,9 +197,7 @@ def __init__( if not api_key: raise ValueError("api_key is required") self.mode = _mode_for_key(api_key) - self._transport = AsyncTransport( - api_key, base_url, timeout, max_retries, http_client - ) + self._transport = AsyncTransport(api_key, base_url, timeout, max_retries, http_client) self.messages = AsyncMessages(self._transport) self.templates = AsyncTemplates(self._transport) @@ -269,9 +261,7 @@ async def send_raw( ) ) - async def _send( - self, request: SendItem, idempotency_key: Optional[str] = None - ) -> SendResult: + async def _send(self, request: SendItem, idempotency_key: Optional[str] = None) -> SendResult: body, own_key = build_send(request) key = idempotency_key or own_key or str(uuid.uuid4()) data = await self._transport.request_json( @@ -299,15 +289,11 @@ async def run(index: int, request: SendItem) -> BatchResult: except errors.SenderKitError as exc: return BatchResult(ok=False, index=index, error=exc) - outcomes = await asyncio.gather( - *(run(i, r) for i, r in enumerate(requests)) - ) + outcomes = await asyncio.gather(*(run(i, r) for i, r in enumerate(requests))) return sorted(outcomes, key=lambda r: r.index) async def context(self) -> Context: - return Context.from_dict( - await self._transport.request_json("GET", "/v1/context") - ) + return Context.from_dict(await self._transport.request_json("GET", "/v1/context")) async def aclose(self) -> None: await self._transport.aclose() diff --git a/src/senderkit/errors.py b/src/senderkit/errors.py index 587e19a..9cc18fe 100644 --- a/src/senderkit/errors.py +++ b/src/senderkit/errors.py @@ -73,9 +73,7 @@ def __init__( request_id: Optional[str] = None, retry_after: Optional[float] = None, ) -> None: - super().__init__( - message, status=status, code=code, issues=issues, request_id=request_id - ) + super().__init__(message, status=status, code=code, issues=issues, request_id=request_id) self.retry_after = retry_after diff --git a/src/senderkit/models.py b/src/senderkit/models.py index 35f5e48..b7d2420 100644 --- a/src/senderkit/models.py +++ b/src/senderkit/models.py @@ -242,9 +242,7 @@ class TemplateVersion: @classmethod def from_dict(cls, d: Dict[str, Any]) -> TemplateVersion: raw_vars = d.get("variables") or [] - variables = [ - TemplateVariable.from_dict(v) for v in raw_vars if isinstance(v, dict) - ] + variables = [TemplateVariable.from_dict(v) for v in raw_vars if isinstance(v, dict)] return cls( version_number=int(d.get("versionNumber", 0)), variables=variables, diff --git a/src/senderkit/resources/messages.py b/src/senderkit/resources/messages.py index 5910422..e545631 100644 --- a/src/senderkit/resources/messages.py +++ b/src/senderkit/resources/messages.py @@ -36,9 +36,7 @@ def list( metadata=metadata, tail=tail, ) - return MessageList.from_dict( - self._t.request_json("GET", "/v1/messages", query=query) - ) + return MessageList.from_dict(self._t.request_json("GET", "/v1/messages", query=query)) def iter( self, @@ -71,9 +69,7 @@ def get(self, id: str) -> Message: def cancel(self, id: str) -> CancelResult: """Cancel a still-pending (scheduled or queued) message.""" - return CancelResult.from_dict( - self._t.request_json("DELETE", f"/v1/messages/{id}") - ) + return CancelResult.from_dict(self._t.request_json("DELETE", f"/v1/messages/{id}")) class AsyncMessages: @@ -102,9 +98,7 @@ async def list( metadata=metadata, tail=tail, ) - return MessageList.from_dict( - await self._t.request_json("GET", "/v1/messages", query=query) - ) + return MessageList.from_dict(await self._t.request_json("GET", "/v1/messages", query=query)) async def aiter( self, @@ -132,11 +126,7 @@ async def aiter( cursor = page.next_cursor async def get(self, id: str) -> Message: - return Message.from_dict( - await self._t.request_json("GET", f"/v1/messages/{id}") - ) + return Message.from_dict(await self._t.request_json("GET", f"/v1/messages/{id}")) async def cancel(self, id: str) -> CancelResult: - return CancelResult.from_dict( - await self._t.request_json("DELETE", f"/v1/messages/{id}") - ) + return CancelResult.from_dict(await self._t.request_json("DELETE", f"/v1/messages/{id}")) diff --git a/src/senderkit/resources/templates.py b/src/senderkit/resources/templates.py index 51e5aa9..1a2e65e 100644 --- a/src/senderkit/resources/templates.py +++ b/src/senderkit/resources/templates.py @@ -22,18 +22,12 @@ def list(self) -> List[TemplateSummary]: def get(self, slug: str) -> TemplateDetail: """Return a template's detail, including its current published version.""" - return TemplateDetail.from_dict( - self._t.request_json("GET", f"/v1/templates/{slug}") - ) + return TemplateDetail.from_dict(self._t.request_json("GET", f"/v1/templates/{slug}")) - def render( - self, slug: str, vars: Optional[Dict[str, Any]] = None - ) -> RenderResult: + def render(self, slug: str, vars: Optional[Dict[str, Any]] = None) -> RenderResult: """Render the published version with ``vars`` without sending.""" return RenderResult.from_dict( - self._t.request_json( - "POST", f"/v1/templates/{slug}/render", body={"vars": vars or {}} - ) + self._t.request_json("POST", f"/v1/templates/{slug}/render", body={"vars": vars or {}}) ) @@ -49,13 +43,9 @@ async def list(self) -> List[TemplateSummary]: return [TemplateSummary.from_dict(t) for t in items] async def get(self, slug: str) -> TemplateDetail: - return TemplateDetail.from_dict( - await self._t.request_json("GET", f"/v1/templates/{slug}") - ) + return TemplateDetail.from_dict(await self._t.request_json("GET", f"/v1/templates/{slug}")) - async def render( - self, slug: str, vars: Optional[Dict[str, Any]] = None - ) -> RenderResult: + async def render(self, slug: str, vars: Optional[Dict[str, Any]] = None) -> RenderResult: return RenderResult.from_dict( await self._t.request_json( "POST", f"/v1/templates/{slug}/render", body={"vars": vars or {}} diff --git a/tests/integrations/test_django_backend.py b/tests/integrations/test_django_backend.py index e9ab475..109c504 100644 --- a/tests/integrations/test_django_backend.py +++ b/tests/integrations/test_django_backend.py @@ -47,9 +47,7 @@ def _reset_client(): def test_send_mail_routes_through_senderkit(): from django.core.mail import send_mail - route = respx.post(f"{BASE_URL}/v1/send").mock( - return_value=httpx.Response(202, json=QUEUED) - ) + route = respx.post(f"{BASE_URL}/v1/send").mock(return_value=httpx.Response(202, json=QUEUED)) sent = send_mail( subject="Hi", message="Plain body", @@ -70,9 +68,7 @@ def test_send_mail_routes_through_senderkit(): def test_send_html_alternative(): from django.core.mail import EmailMultiAlternatives - route = respx.post(f"{BASE_URL}/v1/send").mock( - return_value=httpx.Response(202, json=QUEUED) - ) + route = respx.post(f"{BASE_URL}/v1/send").mock(return_value=httpx.Response(202, json=QUEUED)) msg = EmailMultiAlternatives( subject="Hi", body="text", from_email="f@example.com", to=["a@example.com"] ) diff --git a/tests/test_send.py b/tests/test_send.py index 04d5912..95e0c6f 100644 --- a/tests/test_send.py +++ b/tests/test_send.py @@ -9,9 +9,7 @@ @respx.mock def test_send_basic(client): - route = respx.post(f"{BASE_URL}/v1/send").mock( - return_value=json_response(202, QUEUED) - ) + route = respx.post(f"{BASE_URL}/v1/send").mock(return_value=json_response(202, QUEUED)) result = client.send("welcome", "user@example.com", vars={"name": "Ada"}) assert (result.id, result.status, result.livemode) == ("msg_1", "queued", False) @@ -28,9 +26,7 @@ def test_send_basic(client): @respx.mock def test_send_custom_idempotency_key(client): - route = respx.post(f"{BASE_URL}/v1/send").mock( - return_value=json_response(202, QUEUED) - ) + route = respx.post(f"{BASE_URL}/v1/send").mock(return_value=json_response(202, QUEUED)) client.send("welcome", "user@example.com", idempotency_key="my-key") assert route.calls.last.request.headers["idempotency-key"] == "my-key" @@ -39,9 +35,7 @@ def test_send_custom_idempotency_key(client): def test_send_serializes_envelope_and_datetime(client): from datetime import datetime - route = respx.post(f"{BASE_URL}/v1/send").mock( - return_value=json_response(202, QUEUED) - ) + route = respx.post(f"{BASE_URL}/v1/send").mock(return_value=json_response(202, QUEUED)) client.send( "welcome", "user@example.com", @@ -67,9 +61,7 @@ def test_mode_detection(): @respx.mock async def test_send_async(aclient): - route = respx.post(f"{BASE_URL}/v1/send").mock( - return_value=json_response(202, QUEUED) - ) + route = respx.post(f"{BASE_URL}/v1/send").mock(return_value=json_response(202, QUEUED)) result = await aclient.send("welcome", "user@example.com") assert result.id == "msg_1" assert route.calls.last.request.headers["idempotency-key"] diff --git a/tests/test_send_raw.py b/tests/test_send_raw.py index 15ad333..da9887f 100644 --- a/tests/test_send_raw.py +++ b/tests/test_send_raw.py @@ -8,9 +8,7 @@ @respx.mock def test_send_raw_email_infers_channel(client): - route = respx.post(f"{BASE_URL}/v1/send").mock( - return_value=json_response(202, QUEUED) - ) + route = respx.post(f"{BASE_URL}/v1/send").mock(return_value=json_response(202, QUEUED)) client.send_raw( "user@example.com", EmailContent(subject="Hi", html="

Hi {{name}}

", text="Hi"), @@ -27,9 +25,7 @@ def test_send_raw_email_infers_channel(client): @respx.mock def test_send_raw_sms(client): - route = respx.post(f"{BASE_URL}/v1/send").mock( - return_value=json_response(202, QUEUED) - ) + route = respx.post(f"{BASE_URL}/v1/send").mock(return_value=json_response(202, QUEUED)) client.send_raw("+15555550123", SmsContent(body="Your code is 123456")) body = request_body(route.calls.last.request) assert body["channel"] == "sms" @@ -38,9 +34,7 @@ def test_send_raw_sms(client): @respx.mock def test_send_raw_push(client): - route = respx.post(f"{BASE_URL}/v1/send").mock( - return_value=json_response(202, QUEUED) - ) + route = respx.post(f"{BASE_URL}/v1/send").mock(return_value=json_response(202, QUEUED)) client.send_raw("device-token", PushContent(title="T", body="B", badge=2)) body = request_body(route.calls.last.request) assert body["channel"] == "push" @@ -49,9 +43,7 @@ def test_send_raw_push(client): @respx.mock def test_send_raw_web_push(client): - route = respx.post(f"{BASE_URL}/v1/send").mock( - return_value=json_response(202, QUEUED) - ) + route = respx.post(f"{BASE_URL}/v1/send").mock(return_value=json_response(202, QUEUED)) client.send_raw( "subscription", WebPushContent(title="T", body="B", click_url="https://x.test/y"), diff --git a/tests/test_templates.py b/tests/test_templates.py index b3c8c26..6723167 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -41,9 +41,7 @@ def test_get_template_with_version(client): "updatedAt": "2026-01-01T00:00:00Z", "currentVersion": { "versionNumber": 3, - "variables": [ - {"name": "name", "type": "string", "required": True} - ], + "variables": [{"name": "name", "type": "string", "required": True}], "publishedAt": "2026-01-01T00:00:00Z", }, }, diff --git a/tests/test_timeout.py b/tests/test_timeout.py index 720e31d..3240cf3 100644 --- a/tests/test_timeout.py +++ b/tests/test_timeout.py @@ -8,9 +8,7 @@ @respx.mock def test_timeout_raises_and_is_not_retried(client): - route = respx.post(f"{BASE_URL}/v1/send").mock( - side_effect=httpx.ReadTimeout("slow") - ) + route = respx.post(f"{BASE_URL}/v1/send").mock(side_effect=httpx.ReadTimeout("slow")) with pytest.raises(errors.TimeoutError): client.send("welcome", "user@example.com") assert route.call_count == 1 # timeouts are terminal