Repository navigation
ENGL-663: Catch up the Python SDK with the public API - #108
kavanpancholi merged 51 commits into
Conversation
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>
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>
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>
[Medium risk] No finding was reported, but SummaryCatches the Python SDK up with the public subscribers, groups, segments and automations API The change fixes What's good
Review context · 0/5 commands
Reviews (3) · Last reviewed commit: "docs: Readme update" · claude-sonnet-5-5 |
…or-email-endpoints Add the simple editor email endpoints to the SDK
…or-email-endpoints feat: Improvements
- 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.
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.updatecalled the upsert route, group import called an internal route, segment subscribers sentafterinstead ofcursor, and two README examples passedpage=to methods that did not accept it. This catches it up with every subscribers, groups, segments, fields and automations endpoint on the card.Changes
subscribers.updatesendsPUT /subscribers/{subscriber}. It used to sendPOST /subscribers, the upsert. The email or id is URL-quoted in the path. A newnew_emailkwarg changes the address.groups.import_subscribers_to_groupcalls the documentedPOST /groups/{group}/import-subscribers, withresubscribeandtrigger_automations.segments.get_subscriberssendscursor,limitandfilter[status]. The oldafteris still sent unchanged.subscribers.listnow acceptspage, and the group and segment examples page withcursor.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).subscribers.activity_log, and automationcreate,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.mailerlite/sdk/_request_params.pyholds the query and body building that the new and changed methods share. Query booleans go out as1/0, list filter values are comma-joined,Noneis dropped, and unknown kwargs raiseTypeError.with_subscribersis sent only when true, because the API turns it on for any value.sent_requestfixture patchesrequests.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__.pyand 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
POST /subscriberssubscribers.createGET /subscribers/{subscriber}subscribers.getPUT /subscribers/{subscriber}subscribers.updateGET /subscribers/{subscriber}/activity-logsubscribers.activity_logGET /subscribers/import/{import}subscribers.get_importGET /subscriberssubscribers.listPOST /subscribers/{subscriber}/forgetsubscribers.forgetDELETE /subscribers/{subscriber}subscribers.deleteGroups
POST /groupsgroups.createGET /groupsgroups.listPUT /groups/{group}groups.updatePOST /subscribers/{subscriber}/groups/{group}subscribers.assign_subscriber_to_groupDELETE /subscribers/{subscriber}/groups/{group}subscribers.unassign_subscriber_from_groupGET /groups/{group}/subscribersgroups.get_group_subscribersPOST /groups/{group}/import-subscribersgroups.import_subscribers_to_groupDELETE /groups/{group}groups.deleteSegments
GET /segmentssegments.listPUT /segments/{segment}segments.updateGET /segments/{segment}/subscriberssegments.get_subscribersDELETE /segments/{segment}segments.deleteFields
POST /fieldsfields.createGET /fieldsfields.listPUT /fields/{field}fields.updateDELETE /fields/{field}fields.deleteAutomations
POST /automationsautomations.createPUT /automations/{automation}automations.updatePOST /automations/{automation}/testautomations.testPOST /automations/{automation}/enableautomations.enablePOST /automations/{automation}/add-subscribersautomations.add_subscribersGET /automations/{automation}/activityautomations.activityGET /automationsautomations.listPOST /automations/{automation}/activity/add-to-groupautomations.activity_add_to_groupPOST /automations/{automation}/activity/skip-delayautomations.activity_skip_delayPOST /automations/{automation}/activity/cancel-automationautomations.activity_cancel_automationPOST /automations/{automation}/readdautomations.readdPOST /automations/{automation}/copyautomations.copyPOST /automations/{automation}/disableautomations.disableDELETE /automations/{automation}automations.deleteAutomation triggers
POST /automations/{automation}/triggersautomations.create_triggerGET /automations/{automation}/triggersautomations.list_triggersGET /automations/{automation}/triggers/{trigger}automations.get_triggerPUT /automations/{automation}/triggers/{trigger}automations.update_triggerDELETE /automations/{automation}/triggers/{trigger}automations.delete_triggerAutomation steps
POST /automations/{automation}/stepsautomations.create_stepGET /automations/{automation}/steps/indexautomations.list_stepsGET /automations/{automation}/steps/{step}automations.get_stepPUT /automations/{automation}/steps/{step}automations.update_stepPOST /automations/{automation}/steps/{step}/copyautomations.copy_stepPOST /automations/{automation}/steps/reorderautomations.reorder_stepsDELETE /automations/{automation}/steps/{step}automations.delete_stepPUT /automations/{automation}/steps/{step}/select-ab-test-winnerautomations.select_ab_test_winnerAutomation emails
PUT /automations/{automation}/emails/{email}/contentautomations.update_email_contentGET /automations/{automation}/emails/{email}/contentautomations.get_email_contentGET /automations/{automation}/emails/{email}/report-statsautomations.email_report_statsGET /automations/{automation}/emails/{email}/unsubscribe-reasonsautomations.email_unsubscribe_reasonsAutomation templates
GET /automation-templatesautomations.list_templatesPOST /automations/select-template/{automationTemplate}automations.select_templateAutomation reports
GET /automations/{automation}/steps/run-countsautomations.steps_run_countsGET /automations/{automation}/steps/run-counts/{step}automations.step_run_countsGET /automations/{automation}/triggers/run-countsautomations.triggers_run_countsGET /automations/{automation}/triggers/run-counts/{trigger}automations.trigger_run_countsGET /automations/{automation}/activity/steps/{step}/subscribersautomations.step_subscribersGET /automations/{automation}/activity/triggers/{trigger}/subscribersautomations.trigger_subscribersGET /automations/{automation}/monthly-totals/emailautomations.email_monthly_totalsGET /automations/{automation}/monthly-totals/ecommerce(none)GET /automations/{automation}/order-activity(none)GET /automations/{automation}/emails/{email}/ecommerce-overview-stats(none)GET /automations/{automation}/ab-steps-statsautomations.ab_steps_statsGET /automations/{automation}/ab-steps-stats/{step}automations.ab_step_statsRisks
Medium. Two fixes from the card change what existing calls do:
subscribers.updatenow usesPUT. A subscriber that does not exist now gets a 404 instead of being created. Whengroupsis passed, it replaces the subscriber's group list: groups not listed are removed. Before, the upsert only added groups.subscribers.createstill does the additive upsert. The docstring and README say so.groups.import_subscribers_to_groupnow uses the documented route. It returns{"import_progress_url": ...}straight away, not the finished importdatathe internal route waited for. Pollsubscribers.get_importfor 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-updatecassette was edited to the newPUTpath. The livePUTreturned 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
Database migrations
Data modifications
External dependencies
Configuration changes:
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.