Skip to content

ENGL-663: Catch up the Python SDK with the public API - #108

Merged
kavanpancholi merged 51 commits into
mainfrom
feature/engl-663-catch-up-the-ml-sdks-with-the-public-api-subscribers-groups
Nov 4, 2026
Merged

kavanpancholi merged 51 commits into
mainfrom
feature/engl-663-catch-up-the-ml-sdks-with-the-public-api-subscribers-groups

Conversation

@kavanpancholi

Copy link
Copy Markdown
Contributor

Why?

The Python SDK was behind the public API. It could read automations but not build, change or control them. It also had the known bugs from ENGL-663: subscribers.update called the upsert route, group import called an internal route, segment subscribers sent after instead of cursor, and two README examples passed page= to methods that did not accept it. This catches it up with every subscribers, groups, segments, fields and automations endpoint on the card.

Changes

  1. Known bugs fixed:
    • subscribers.update sends PUT /subscribers/{subscriber}. It used to send POST /subscribers, the upsert. The email or id is URL-quoted in the path. A new new_email kwarg changes the address.
    • groups.import_subscribers_to_group calls the documented POST /groups/{group}/import-subscribers, with resubscribe and trigger_automations.
    • segments.get_subscribers sends cursor, limit and filter[status]. The old after is still sent unchanged.
    • The README examples are fixed. subscribers.list now accepts page, and the group and segment examples page with cursor.
  2. New documented params on existing methods: subscriber list (page, sort, include, dates, with_fields, with_groups), subscriber create (resubscribe, double_optin, notes), group subscribers (include), segment list (sort), segment update (filter), automation list (sort, with_steps), automation activity (sort, with_subscribers).
  3. New methods: subscribers.activity_log, and automation create, update, delete, copy, enable, disable, test, add_subscribers, readd, the three activity actions, triggers, steps, email content and stats, templates, and the run count, A/B and subscriber reports.
  4. mailerlite/sdk/_request_params.py holds the query and body building that the new and changed methods share. Query booleans go out as 1/0, list filter values are comma-joined, None is dropped, and unknown kwargs raise TypeError. with_subscribers is sent only when true, because the API turns it on for any value.
  5. Tests: a sent_request fixture patches requests.request, so the new request tests make no network calls. Each new or changed method has a test asserting the HTTP method, path, query and body against the OpenAPI document.

No existing method signature changed and nothing was removed. mailerlite/__init__.py and the client are untouched.

The e-commerce automation reports (monthly-totals/ecommerce, order-activity, ecommerce-overview-stats) are not in this PR, because the open e-commerce PR #106 adds them.

Endpoint checklist

"Live call" means the method was called once against a local API on a test account, and the effect was read back with a separate request.

Subscribers

Endpoint SDK method How checked
POST /subscribers subscribers.create test + live call
GET /subscribers/{subscriber} subscribers.get test + live call
PUT /subscribers/{subscriber} subscribers.update test + live call
GET /subscribers/{subscriber}/activity-log subscribers.activity_log test + live call
GET /subscribers/import/{import} subscribers.get_import test + live call
GET /subscribers subscribers.list test + live call
POST /subscribers/{subscriber}/forget subscribers.forget test + live call
DELETE /subscribers/{subscriber} subscribers.delete test + live call

Groups

Endpoint SDK method How checked
POST /groups groups.create test + live call
GET /groups groups.list test + live call
PUT /groups/{group} groups.update test + live call
POST /subscribers/{subscriber}/groups/{group} subscribers.assign_subscriber_to_group test + live call
DELETE /subscribers/{subscriber}/groups/{group} subscribers.unassign_subscriber_from_group test + live call
GET /groups/{group}/subscribers groups.get_group_subscribers test + live call
POST /groups/{group}/import-subscribers groups.import_subscribers_to_group test + live call
DELETE /groups/{group} groups.delete test + live call

Segments

Endpoint SDK method How checked
GET /segments segments.list test + live call
PUT /segments/{segment} segments.update test + live call
GET /segments/{segment}/subscribers segments.get_subscribers test + live call
DELETE /segments/{segment} segments.delete test + live call

Fields

Endpoint SDK method How checked
POST /fields fields.create test + live call
GET /fields fields.list test + live call
PUT /fields/{field} fields.update test + live call
DELETE /fields/{field} fields.delete test + live call

Automations

Endpoint SDK method How checked
POST /automations automations.create test + live call
PUT /automations/{automation} automations.update test + live call
POST /automations/{automation}/test automations.test test + live call
POST /automations/{automation}/enable automations.enable test + live call
POST /automations/{automation}/add-subscribers automations.add_subscribers test + live call
GET /automations/{automation}/activity automations.activity test + live call
GET /automations automations.list test + live call
POST /automations/{automation}/activity/add-to-group automations.activity_add_to_group test + live call
POST /automations/{automation}/activity/skip-delay automations.activity_skip_delay test + live call
POST /automations/{automation}/activity/cancel-automation automations.activity_cancel_automation test + live call
POST /automations/{automation}/readd automations.readd test + live call
POST /automations/{automation}/copy automations.copy test + live call
POST /automations/{automation}/disable automations.disable test + live call
DELETE /automations/{automation} automations.delete test + live call

Automation triggers

Endpoint SDK method How checked
POST /automations/{automation}/triggers automations.create_trigger test + live call
GET /automations/{automation}/triggers automations.list_triggers test + live call
GET /automations/{automation}/triggers/{trigger} automations.get_trigger test + live call
PUT /automations/{automation}/triggers/{trigger} automations.update_trigger test + live call
DELETE /automations/{automation}/triggers/{trigger} automations.delete_trigger test + live call

Automation steps

Endpoint SDK method How checked
POST /automations/{automation}/steps automations.create_step test + live call
GET /automations/{automation}/steps/index automations.list_steps test + live call
GET /automations/{automation}/steps/{step} automations.get_step test + live call
PUT /automations/{automation}/steps/{step} automations.update_step test + live call
POST /automations/{automation}/steps/{step}/copy automations.copy_step test + live call
POST /automations/{automation}/steps/reorder automations.reorder_steps test + live call
DELETE /automations/{automation}/steps/{step} automations.delete_step test + live call
PUT /automations/{automation}/steps/{step}/select-ab-test-winner automations.select_ab_test_winner test + live call

Automation emails

Endpoint SDK method How checked
PUT /automations/{automation}/emails/{email}/content automations.update_email_content test + live call
GET /automations/{automation}/emails/{email}/content automations.get_email_content test + live call
GET /automations/{automation}/emails/{email}/report-stats automations.email_report_stats test + live call
GET /automations/{automation}/emails/{email}/unsubscribe-reasons automations.email_unsubscribe_reasons test + live call

Automation templates

Endpoint SDK method How checked
GET /automation-templates automations.list_templates test + live call
POST /automations/select-template/{automationTemplate} automations.select_template test + live call

Automation reports

Endpoint SDK method How checked
GET /automations/{automation}/steps/run-counts automations.steps_run_counts test + live call
GET /automations/{automation}/steps/run-counts/{step} automations.step_run_counts test + live call
GET /automations/{automation}/triggers/run-counts automations.triggers_run_counts test + live call
GET /automations/{automation}/triggers/run-counts/{trigger} automations.trigger_run_counts test + live call
GET /automations/{automation}/activity/steps/{step}/subscribers automations.step_subscribers test + live call
GET /automations/{automation}/activity/triggers/{trigger}/subscribers automations.trigger_subscribers test + live call
GET /automations/{automation}/monthly-totals/email automations.email_monthly_totals test + live call
GET /automations/{automation}/monthly-totals/ecommerce (none) not on this branch: added by the open e-commerce PR #106
GET /automations/{automation}/order-activity (none) not on this branch: added by the open e-commerce PR #106
GET /automations/{automation}/emails/{email}/ecommerce-overview-stats (none) not on this branch: added by the open e-commerce PR #106
GET /automations/{automation}/ab-steps-stats automations.ab_steps_stats test + live call
GET /automations/{automation}/ab-steps-stats/{step} automations.ab_step_stats test + live call

Risks

Medium. Two fixes from the card change what existing calls do:

  • subscribers.update now uses PUT. A subscriber that does not exist now gets a 404 instead of being created. When groups is passed, it replaces the subscriber's group list: groups not listed are removed. Before, the upsert only added groups. subscribers.create still does the additive upsert. The docstring and README say so.
  • groups.import_subscribers_to_group now uses the documented route. It returns {"import_progress_url": ...} straight away, not the finished import data the internal route waited for. Poll subscribers.get_import for progress.

These need a minor version bump and a release note.

The open simple-editor PR #107 adds its own mailerlite/sdk/_params.py, with a filter helper that overlaps _request_params.py. The files differ, so there is no conflict, but the two helpers could be merged after both land.

Performance impact

None. Each method makes one request.

Security impact

No security impact. The subscriber path is now URL-quoted, so an email with # or ? can no longer reach the wrong path.

How to QA

No QA needed on the app. This is a library change, and nothing in the MailerLite app changes. Reviewers can run pytest tests/.

Verified before review: 81 of 81 live calls passed, plus 3 skipped (the e-commerce reports that PR #106 adds). The subscribers-update cassette was edited to the new PUT path. The live PUT returned 200 with every key the cassette has. What a local run cannot show: run counts and report stats stay 0, because they read analytics data the local stack does not produce.

pytest tests/ -q: 173 passed, 1 skipped. ruff format --check .: 29 files already formatted.

How to release

Minor release (see Risks). Versioning is handled by semantic-release, so there is no hand-written CHANGELOG.

Rollback strategy

Primary rollback method: revert

Code changes

  • Safe to revert. SDK code only.

Database migrations

  • None.

Data modifications

  • None.

External dependencies

  • None.

Configuration changes:

  • None.

Screenshots, recordings

N/A, library change.

I used AI to generate parts of this PR

Yes

Docs

The PR adheres to Pull Request guidelines.

See mandatory MailerLite PR rules and flow for more information.

tomasmartty and others added 30 commits October 28, 2026 17:49
The mock_api fixture replaces requests.Session.send. Tests can assert the
method, the full URL, the headers and the body of each request, and give
the response that the API returns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ApiClient.request() rejected PATCH. Allow it, and send the body as JSON
like POST and PUT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some endpoints need a body on DELETE, for example the etag to delete an
email block. Send the body when the caller gives one. A DELETE without a
body continues to send no body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
API errors now raise mailerlite.ApiError, a subclass of
requests.HTTPError. It has the status code, the message, the code, the
etag and the field errors of the response body. A version conflict sends
the current etag of the email, so the caller can get the email again.

The error text keeps the format of raise_for_status() and adds the code
and the message of the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ApiResponse is a dict with the decoded JSON body, so callers use it like
the dicts that the other methods return. It also has the status code,
the headers and is_idempotent_replay, which is true when the API sends
"Idempotent-Replayed: true".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the Emails service for emails built with the simple editor, with
get(). IDs in the URL path must contain only digits. An empty ID raises
EmptyIdError and any other ID raises InvalidIdError, and the SDK sends
no request. URL encoding is not sufficient, because the server decodes
"%2F" in the path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Writes send the etag from the last response for the email. An empty
etag raises EmptyEtagError, and the SDK sends no request. Add the
BlockType and BlockPosition constants, and VERSION_CONFLICT for the
error code of a write with an old etag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The etag goes in the body of the DELETE request. The method returns the
response, because it has the new etag of the email.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Validation sends no etag and needs no email. An invalid block returns
200 with "valid" set to false, so it does not raise an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SDK sends only the keys that the caller gives, so each value can be
not set, set, or unset with an empty string. Add the BrandMode constants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Validation sends no etag. Invalid settings return 200 with "valid" set
to false, so they do not raise an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API returns 201 for a new version, and 200 with the last version
when nothing changed. Both are a success, and status_code on the
response tells them apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Returns the palettes of the account. "brand" is None when the account
has no brand styles. A preset identifier applies the preset to an email
through emails.update_settings().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lists the files of the media library, with the name and type filters,
the sort and the pagination. Add the FileType and FileSort constants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
campaigns.create() sends the campaign dict as it is, so "editor" and
"template_id" already reach the API. Add the EDITOR_SIMPLE constant, a
test that the body has "editor" and no "content", and the documentation.
The API refuses "content" together with the simple editor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The eight email writes accept a keyword-only idempotency_key. The SDK
sends it in the Idempotency-Key header, so a retry after a network error
does not do the write two times. ApiResponse.is_idempotent_replay tells
if the API returned the stored response.

The SDK checks the key before the request: 1 to 255 printable ASCII
characters, as the API requires, and no space at the start or the end,
because HTTP removes these spaces. An invalid key raises
InvalidIdempotencyKeyError, and the SDK sends no request.

ApiClient.request() accepts extra headers for one request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Set the same version in pyproject.toml, VERSION and setup.py. The three
files had different versions before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ApiClient sent the JSON body `null` for a POST, PUT or PATCH request
without a body. This affected emails.create_version and campaigns.cancel.
The client now sends no body, as it already did for DELETE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pickle rebuilds an exception with cls(*self.args), so it gave the message
string to ApiError.__init__, which needs the response. An ApiError from
a multiprocessing or Celery worker then raised AttributeError.
ApiError now rebuilds itself from the response.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
create_block, move_block and validate_block sent placement["block_id"]
unchanged. An int ID went to the API as a JSON number, and an empty or
invalid ID reached the API. The block_id now gets the same check as the
IDs in the URL path, and goes to the API as a string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
create_block and validate_block sent any block_type to the API, for
example None or an int. They now raise TypeError when block_type is not
a str, as their docstrings say.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
update_settings merged the settings and the etag at the same level of the
body, so it silently replaced an `etag` key in settings. update_settings
and validate_settings now raise ValueError when settings contains `etag`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
is_idempotent_replay compared the header value to "true" with a case
check. A proxy that sent "True" or "TRUE" made a replayed response look
like a new write. The check now ignores case and spaces at the ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tomasmartty and others added 12 commits October 29, 2026 11:39
The checks for IDs, etags, dicts and idempotency keys were private
functions in emails.py, so other resources could not use them. They are
now in mailerlite/sdk/_params.py. The behavior does not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
files.list had its own copies of the dict check, the filter expansion and
the None filter. It now uses the helpers in mailerlite/sdk/_params.py,
with a new filter_params helper. The behavior does not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API uses placement.block_id only for the before and after
positions. For start and end, it ignores the value and does not
check it. The SDK checked the block_id for all positions, so it
refused valid requests, and it sent the block_id for start and end.

Now the SDK does not check or send the block_id for start and end.
For before and after, a missing block_id is an EmptyIdError.
For an unknown position, the SDK checks and sends a given block_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An empty string unsets only a brand colour. An empty editor document
unsets a footer text. For other colours, sizes, button.border_radius
and brand.style, an empty string gives a 422 error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Remove the docstrings of private helpers and tests that only repeat the
function name. The public docstrings do not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kavanpancholi kavanpancholi self-assigned this Oct 30, 2026
The SDK now checks only the IDs in the URL path and the settings of
update_settings before the request. It sends the placement, the etag,
the idempotency key and the other body values as the caller gives them,
and the API checks them. EmptyEtagError and InvalidIdempotencyKeyError
are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kavanpancholi
kavanpancholi marked this pull request as ready for review October 30, 2026 11:41
@mailerlite-kritika

mailerlite-kritika Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Re-runViewConfidence Score: 4/5

[Medium risk] No finding was reported, but segments.get drops after from its accepted kwargs, so existing callers now get a TypeError, and the description's "nothing was removed" and risk sections do not mention it; the account calls this only a stale doc line. The risk rests on the PUT semantics change of subscribers.update (group replacement, 404 on missing subscribers) and the group import route change, which the description does disclose and the cassette and tests cover; the removeprefix in …

Summary

Catches the Python SDK up with the public subscribers, groups, segments and automations API

The change fixes subscribers.update (now PUT /subscribers/{subscriber}), groups.import_subscribers_to_group (now /import-subscribers) and the segment cursor param. It adds the automation management, report and activity methods. Since the last review, the shared helpers moved into _params.py. excel is rejected on the report methods, after on segment subscribers is now sent as cursor with a DeprecationWarning, and bool subscriber IDs are rejected. Merging changes what existing calls do: subscribers.update no longer creates missing subscribers and replaces groups, and group import returns import_progress_url instead of the finished import. The PR description still says after is "sent unchanged" and that nothing was removed. The code now maps after to cursor, and segments.get no longer accepts after, so those two lines are stale.

What's good

  • Each new or changed method has a test asserting HTTP verb, path, query and body through a patched requests.request, with no network calls.
  • Subscriber emails are URL-quoted in the path, with tests for # and +.
  • Unknown kwargs raise TypeError consistently, and the helpers are merged into the existing _params.py instead of a second module.
Review context · 0/5 commands
  • Commands run: none

Reviews (3) · Last reviewed commit: "docs: Readme update" · claude-sonnet-5-5

igorhrcek and others added 8 commits November 3, 2026 14:00
…or-email-endpoints

Add the simple editor email endpoints to the SDK
- email_report_stats and email_monthly_totals drop the `excel`
  argument: the SDK always decodes JSON, so the XLSX download could
  never work
- subscribers.get quotes the email in the path like update does
- segments.get_subscribers sends `after` as `cursor` and warns: the
  API ignores `after`, so old paging loops never advanced
- a bool is not a subscriber ID
_params.py and _request_params.py built params two ways. One module,
one rule: unknown arguments raise TypeError, `filter` must be a dict,
`None` values are not sent, booleans and lists are encoded for the
query string. groups.list and segments.get use it too, which removes
the stray `list` argument of groups.list and `after` of segments.get.
@kavanpancholi
kavanpancholi merged commit c244ea1 into main Nov 4, 2026
5 checks passed
@kavanpancholi
kavanpancholi deleted the feature/engl-663-catch-up-the-ml-sdks-with-the-public-api-subscribers-groups branch November 4, 2026 07:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants