diff --git a/CHANGELOG.md b/CHANGELOG.md index 729f7fe..da8d322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.0] - 2026-05-28 + +### Added +- `CampaignDetail` type — a `Campaign` subclass that adds the rendered + `html_content` field. Returned by `client.campaigns.get(id)`. + +### Changed +- `client.campaigns.get(id)` is now typed as returning `CampaignDetail` + (previously `Campaign`). `CampaignDetail` inherits from `Campaign`, so + existing callers that read summary fields keep working unchanged. + +### Removed +- `Campaign.html_content` — moved to `CampaignDetail`. The field was only + ever populated by `get()`; on `list()` / `send()` / `schedule()` / + `unschedule()` it was always `None`, so reading it on those return + values was a dead branch the type system silently encouraged. Move + any `html_content` access to a `get()` result. + ## [1.3.0] - 2026-05-27 ### Added @@ -172,7 +190,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `ValidationError`, `NotFoundError`, `ConflictError`, `BadRequestError`, `ServerError`) -[Unreleased]: https://github.com/lettr/lettr-python/compare/v1.3.0...HEAD +[Unreleased]: https://github.com/lettr/lettr-python/compare/v1.4.0...HEAD +[1.4.0]: https://github.com/lettr/lettr-python/compare/v1.3.0...v1.4.0 [1.3.0]: https://github.com/lettr/lettr-python/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/lettr/lettr-python/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/lettr/lettr-python/compare/v1.0.0...v1.1.0 diff --git a/README.md b/README.md index 5579dc8..712170d 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,9 @@ page = client.campaigns.list(status="sent", per_page=50) for campaign in page.campaigns: print(f"{campaign.name}: {campaign.status} — {campaign.stats.unique_opens} opens") -# Get a single campaign, including rendered HTML +# Get a single campaign — returns a CampaignDetail with the rendered body. +# (`list`, `send`, `schedule`, `unschedule` return the base `Campaign`, +# which does not carry `html_content`.) campaign = client.campaigns.get(page.campaigns[0].id) print(campaign.html_content) diff --git a/pyproject.toml b/pyproject.toml index ad10ccc..f9c2cab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "lettr" -version = "1.3.0" +version = "1.4.0" description = "Official Python SDK for the Lettr Email API" readme = "README.md" license = "MIT" diff --git a/src/lettr/__init__.py b/src/lettr/__init__.py index da782f6..74e7e3d 100644 --- a/src/lettr/__init__.py +++ b/src/lettr/__init__.py @@ -49,6 +49,7 @@ BulkListsAttachResult, BulkListsDetachResult, Campaign, + CampaignDetail, CampaignEvent, CampaignEventPage, CampaignPage, @@ -121,6 +122,7 @@ "BulkListsAttachResult", "BulkListsDetachResult", "Campaign", + "CampaignDetail", "CampaignEvent", "CampaignEventPage", "CampaignPage", diff --git a/src/lettr/_types.py b/src/lettr/_types.py index 72a5e42..f8ab011 100644 --- a/src/lettr/_types.py +++ b/src/lettr/_types.py @@ -690,9 +690,11 @@ class CampaignStats: class Campaign: """A campaign with embedded engagement stats. - ``html_content`` is populated by ``client.campaigns.get(id)`` (which - returns the full rendered email body) and left as ``None`` on - ``client.campaigns.list()`` responses, which omit the heavy field. + Returned by :meth:`Campaigns.list`, :meth:`Campaigns.send`, + :meth:`Campaigns.schedule`, and :meth:`Campaigns.unschedule`. The + rendered email body lives on :class:`CampaignDetail` (returned by + :meth:`Campaigns.get`) so callers of the list/action endpoints aren't + handed an attribute that the API never populates there. """ id: str @@ -708,6 +710,16 @@ class Campaign: scheduled_at: str | None = None total_recipients: int | None = None sent_at: str | None = None + + +@dataclass +class CampaignDetail(Campaign): + """A campaign plus its rendered HTML body. + + Returned only by :meth:`Campaigns.get`. Inherits every :class:`Campaign` + field so existing summary-shaped consumers keep working unchanged. + """ + html_content: str | None = None diff --git a/src/lettr/resources/campaigns.py b/src/lettr/resources/campaigns.py index 3ff5f21..8b3c445 100644 --- a/src/lettr/resources/campaigns.py +++ b/src/lettr/resources/campaigns.py @@ -3,11 +3,12 @@ from __future__ import annotations from datetime import datetime -from typing import Any +from typing import Any, TypeVar from .._client import ApiClient from .._types import ( Campaign, + CampaignDetail, CampaignEvent, CampaignEventPage, CampaignPage, @@ -20,19 +21,19 @@ # Parsers # --------------------------------------------------------------------------- +_C = TypeVar("_C", bound=Campaign) -def _parse_campaign(d: dict[str, Any]) -> Campaign: - """Parse a campaign payload (CampaignSummary or CampaignDetail). - The ``html_content`` field is only present on the detail (``get``) - response; for list responses it stays ``None``. Unknown keys are - silently dropped via :func:`_from_dict` so the parser is - forward-compatible. +def _parse_campaign_as(cls: type[_C], d: dict[str, Any]) -> _C: + """Parse a campaign payload into ``cls`` (Campaign or CampaignDetail). + + Pass :class:`Campaign` for list/action endpoints and + :class:`CampaignDetail` for the detail endpoint. Unknown keys are + dropped by :func:`_from_dict`, so the list/action paths can't + accidentally surface a stray ``html_content`` if the API ever + includes one. """ - return _from_dict( - Campaign, - {**d, "stats": _from_dict(CampaignStats, d["stats"])}, - ) + return _from_dict(cls, {**d, "stats": _from_dict(CampaignStats, d["stats"])}) def _parse_event(d: dict[str, Any]) -> CampaignEvent: @@ -46,11 +47,11 @@ def _parse_action_response(body: dict[str, Any]) -> Campaign | None: server omits it in the rare case the campaign can't be re-read after the action (e.g. it was concurrently deleted). ``None`` here means "absent key", not "empty object": a spec-violating ``data: {}`` is - intentionally left to raise inside :func:`_parse_campaign` rather than - being silently swallowed. + intentionally left to raise inside :func:`_parse_campaign_as` rather + than being silently swallowed. """ data = body.get("data") - return _parse_campaign(data) if data is not None else None + return _parse_campaign_as(Campaign, data) if data is not None else None # --------------------------------------------------------------------------- @@ -105,18 +106,20 @@ def list( body = self._client.get("/campaigns", params=params) data = body["data"] return CampaignPage( - campaigns=[_parse_campaign(item) for item in data["campaigns"]], + campaigns=[_parse_campaign_as(Campaign, item) for item in data["campaigns"]], **_pagination_kwargs(data["pagination"]), ) - def get(self, campaign_id: str) -> Campaign: - """Get a single campaign by ID, including its rendered HTML content. + def get(self, campaign_id: str) -> CampaignDetail: + """Get a single campaign by ID, including its rendered HTML body. - The returned :class:`Campaign` has ``html_content`` populated; list - responses leave it as ``None``. + Returns a :class:`CampaignDetail` (a :class:`Campaign` subclass) + whose ``html_content`` field carries the rendered email; the + list/action endpoints return the base :class:`Campaign` and do + not expose ``html_content`` at all. """ body = self._client.get(f"/campaigns/{campaign_id}") - return _parse_campaign(body["data"]) + return _parse_campaign_as(CampaignDetail, body["data"]) def list_events( self, diff --git a/tests/test_campaigns.py b/tests/test_campaigns.py index 0af1502..61bc1ef 100644 --- a/tests/test_campaigns.py +++ b/tests/test_campaigns.py @@ -9,6 +9,7 @@ from lettr._types import ( Campaign, + CampaignDetail, CampaignEventPage, CampaignPage, CampaignStats, @@ -98,8 +99,8 @@ def test_list(self, campaigns: Campaigns, mock_client: MagicMock) -> None: assert page.campaigns[0].name == "Spring Sale" assert isinstance(page.campaigns[0].stats, CampaignStats) assert page.campaigns[0].stats.unique_opens == 45 - # list responses do not include the rendered body - assert page.campaigns[0].html_content is None + # list responses return the base type, not the detail variant + assert type(page.campaigns[0]) is Campaign assert page.total == 1 assert page.per_page == 20 mock_client.get.assert_called_once_with("/campaigns", params={}) @@ -128,7 +129,17 @@ def test_list_handles_minimal_payload( assert c.scheduled_at is None assert c.total_recipients is None assert c.sent_at is None - assert c.html_content is None + + def test_list_drops_unexpected_html_content( + self, campaigns: Campaigns, mock_client: MagicMock + ) -> None: + # Regression guard: even if the API ever leaks html_content into + # a list item, _from_dict's field-set filter must strip it before + # construction so the base Campaign instance has no such attribute. + polluted = {**CAMPAIGN_DATA, "html_content": "

leak

"} + mock_client.get.return_value = {"data": {"campaigns": [polluted], "pagination": PAGINATION}} + page = campaigns.list() + assert not hasattr(page.campaigns[0], "html_content") # --------------------------------------------------------------------------- @@ -140,7 +151,9 @@ class TestGet: def test_get(self, campaigns: Campaigns, mock_client: MagicMock) -> None: mock_client.get.return_value = {"data": CAMPAIGN_DETAIL_DATA} result = campaigns.get("camp_1") - assert isinstance(result, Campaign) + # CampaignDetail IS-A Campaign — substitutability preserved for + # existing summary-shaped callers. + assert isinstance(result, CampaignDetail) assert result.id == "camp_1" assert result.html_content == "

Hi

" assert result.stats.clicks == 20 @@ -201,7 +214,8 @@ class TestSend: def test_send_returns_campaign(self, campaigns: Campaigns, mock_client: MagicMock) -> None: mock_client.post.return_value = {"message": "Sending.", "data": CAMPAIGN_DATA} result = campaigns.send("camp_1") - assert isinstance(result, Campaign) + # Action endpoints return the base Campaign, never the detail variant. + assert type(result) is Campaign assert result.id == "camp_1" mock_client.post.assert_called_once_with("/campaigns/camp_1/send") @@ -217,7 +231,7 @@ def test_schedule_returns_campaign(self, campaigns: Campaigns, mock_client: Magi scheduled = {**CAMPAIGN_DATA, "status": "scheduled"} mock_client.post.return_value = {"message": "Scheduled.", "data": scheduled} result = campaigns.schedule("camp_1", scheduled_at="2026-06-01T09:00:00+00:00") - assert isinstance(result, Campaign) + assert type(result) is Campaign assert result.status == "scheduled" mock_client.post.assert_called_once_with( "/campaigns/camp_1/schedule", @@ -247,7 +261,7 @@ def test_unschedule_returns_campaign( drafted = {**CAMPAIGN_DATA, "status": "draft"} mock_client.post.return_value = {"message": "Unscheduled.", "data": drafted} result = campaigns.unschedule("camp_1") - assert isinstance(result, Campaign) + assert type(result) is Campaign assert result.status == "draft" mock_client.post.assert_called_once_with("/campaigns/camp_1/unschedule")