Skip to content

Fix SDK bugs found by the live docs snippet tests - #242

Open
techwritermat wants to merge 3 commits into
masterfrom
fix/sdk-bugs-from-docs-snippets
Open

techwritermat wants to merge 3 commits into
masterfrom
fix/sdk-bugs-from-docs-snippets

Conversation

@techwritermat

@techwritermat techwritermat commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

What

Fixes 15 bugs found while writing live tests for the Python and asyncio docs samples (PR #241). Each bug was reproduced against the real service or by calling the SDK, except the ones marked "code only". Two commits: subscriptions and listeners, then the rest. No public signature changes.

Fixes

Bug File Evidence
get_message_actions drops end when start is set, limit() accepts only str pubnub/endpoints/message_actions/get_message_actions.py live
PubNubSubscriptionSet.unsubscribe() sends an empty unsubscribe, channels stay subscribed pubnub/models/subscription.py live
Registry callback calls listener methods that do not exist (uuid, channel, membership, file_message), the sync worker raises on the first App Context or file event pubnub/models/subscription.py, pubnub/workers.py live (App Context), code only (files)
unsubscribe_all() sets a list where a dict is used, next subscribe() raises TypeError pubnub/models/subscription.py probe
subscription.add_listener(SubscribeCallback) raises TypeError pubnub/models/subscription.py called
PubNubUserMetadata sets _types instead of _type pubnub/models/subscription.py code only
pubnub.reconnect() always raises AttributeError, no disconnect() pubnub/pubnub_core.py called
asyncio get_subscribed_channels() and unsubscribe_all() raise AttributeError before the first subscribe pubnub/event_engine/models/states.py live
if_matches_etag() leaks the If-Match header into every later request (class-level dict), a plain set returned 412 pubnub/endpoints/objects_v2/objects_endpoint.py live
MembershipIncludes(total_count=True) and MemberIncludes(total_count=True) do not request a count pubnub/endpoints/objects_v2/objects_endpoint.py live
Message action events do not say added or removed pubnub/models/consumer/pubsub.py, pubnub/workers.py live
parse_token() raises binascii.Error or TypeError on malformed tokens pubnub/managers.py called
grant() ignores delete in the "at least one flag" check pubnub/endpoints/access/grant.py code only
fetch_messages items never parse custom_message_type pubnub/models/consumer/history.py code only
setup.py aiohttp>3.10.11 does not overlap the dev pin >=3.10.11,<3.11, pip cannot resolve both setup.py pip fails

Tests

  • 57 new offline unit tests in tests/unit/ (test_subscription_registry.py, test_message_worker_events.py, test_get_message_actions_params.py, test_sdk_fixes.py)
  • The cassettes channel_memberships_with_include_object.json and channel_members_with_include_object.json now record count=True, because the total count is now requested
  • Locally on Python 3.11 against a clean checkout of master, with fake PN_KEY_* values so the recorded tests replay: unit, functional and integration give 1032 passed and 46 failed before, 1089 passed and 46 failed after. The 46 failures are identical before and after and need the real service (file upload, asyncio and threaded subscribe, message actions integration, two event engine tests)
  • The live docs snippet suite from Live tests for the Python and asyncio docs code samples #241 against these changes: open shard 164 passed, pam shard 29 passed. Before: the 4 steps for get_message_actions and the set unsubscribe (sync and asyncio) failed
  • flake8 passes, the changed files parse with the Python 3.9 grammar (no 3.9 interpreter was available locally, CI covers 3.9 to 3.13)

Example fixed because of the get_message_actions change

examples/native_sync/message_reactions.py (run by tests/examples/native_sync/test_examples.py::test_message_reactions) passed start below end. The SDK dropped end, so it worked by accident and was flaky. With both bounds sent the service answers HTTP 400 "Start must be greater than or equal to end". The example now uses the newest reaction plus one as start (newer, exclusive) and the message timetoken as end (older, inclusive). Passes 5 of 5 against the real service with the demo keys.

Needs a maintainer decision (not changed here)

  1. asyncio event engine loop ignores filter_expression (pubnub/event_engine/effects.py)
  2. asyncio sends a leave on unsubscribe only with enable_presence_heartbeat on (pubnub/pubnub_asyncio.py:624-630), sync always does
  3. should_store defaults to 0 for files (pubnub/endpoints/file_operations/publish_file_message.py:96), the docs say true
  4. files are sent unencrypted when only crypto_module is set (send_file.py:44-53), this one is a security question
  5. request threads are non-daemon by default, a subscribe long poll can hold the process up to 310 seconds after stop()
  6. SDK_VERSION is 10.4.0, .pubnub.yml says 10.8.1 (release tooling)
  7. the custom= keyword does not imply include_custom, the builder .custom() does
  8. reconnect() and disconnect() are no-ops or raise on the asyncio default event engine manager
  9. listeners added with subscription.add_listener() receive events of every subscription, sets never receive presence

No version bump or changelog entry, left to release.

🤖 Generated with Claude Code

techwritermat and others added 2 commits November 12, 2026 13:26
* PubNubSubscriptionSet.unsubscribe() now unsubscribes the channels of the set
  and removes the set from the registry. It sent an empty unsubscribe before.
* unsubscribe_all() resets the registry to dicts. It set a list, so the next
  subscribe() raised TypeError and a second unsubscribe_all() AttributeError.
* subscriptions and sets get channel_metadata, user_metadata, membership and
  file_message methods. The registry callback called methods that did not
  exist, so the sync message worker raised on the first App Context or file
  event.
* subscription.add_listener() calls SubscribeCallback listeners with
  (pubnub, event) like the client does. It raised TypeError.
* PubNubUserMetadata sets _type, it set the unused _types.
* message action events expose event ("added" or "removed").

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ging

* get_message_actions sends end when start is set, limit accepts int or str.
* pubnub.reconnect() calls the subscription manager and no longer raises
  AttributeError. Added pubnub.disconnect().
* asyncio get_subscribed_channels(), get_subscribed_channel_groups() and
  unsubscribe_all() work before the first subscribe (PNContext starts empty).
* if_matches_etag() no longer leaks the If-Match header into later requests
  (the headers dict was shared by the class).
* total_count=True in MembershipIncludes and MemberIncludes requests a count.
  The two include_object cassettes now record count=True.
* parse_token() raises PubNubException for every malformed token.
* grant() counts delete as a flag.
* fetch_messages items parse custom_message_type.
* setup.py: aiohttp>=3.10.11, the old range did not overlap the dev pin.

Adds offline regression tests in tests/unit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@pubnub-snyk-universal-prd

pubnub-snyk-universal-prd Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues
✅ Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

The example passed start below end. The SDK dropped end, so the call worked by
accident. Now that get_message_actions sends both bounds the service answers
HTTP 400 "Start must be greater than or equal to end". start is the newer bound
(exclusive) and end the older bound (inclusive).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

@parfeon parfeon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly looks good but still had some small questions.

def limit(self, limit: str) -> 'GetMessageActions':
assert isinstance(limit, str)
self._limit = limit
assert isinstance(limit, (str, int)) and not isinstance(limit, bool)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't our current contract according to the docs is str?

Comment thread pubnub/pubnub_core.py
itself, so there this call does nothing.
"""
self._validate_subscribe_manager_enabled()
return self._subscription_manager.reconnect()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will work only for NativeSubscriptionManager and not implemented for EventEngineSubscriptionManager (pubnub_asyncio)

Comment thread pubnub/pubnub_core.py
Comment on lines +424 to +427
if not hasattr(self._subscription_manager, 'disconnect'):
raise PubNubException(
errormsg="disconnect() is not supported by %s" % type(self._subscription_manager).__name__)
return self._subscription_manager.disconnect()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Situation similar to the reconnect. Should we add same exception for reconnect?

Comment thread pubnub/workers.py
Comment on lines +43 to +47
@staticmethod
def _set_origin(result, channel, subscription):
# subscription and subscription set listeners match App Context events by these attributes
result.channel = channel
result.subscription = subscription

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious, why we can't follow the same pattern as we have with PNSignalMessageResult, PNMessageActionResult, and PNFileMessageResult for PNChannelMetadataResult, PNUUIDMetadataResult, and PNMembershipResult constructors where channel and subscription_match passed as one of the constructor arguments?

"request": {
"method": "PATCH",
"uri": "https://ps.pndsn.com/v2/objects/{PN_KEY_SUBSCRIBE}/uuids/someuuid/channels?include=custom%2Cstatus%2Ctype%2CtotalCount%2Cchannel%2Cchannel.custom%2Cchannel.type%2Cchannel.status",
"uri": "https://ps.pndsn.com/v2/objects/{PN_KEY_SUBSCRIBE}/uuids/someuuid/channels?include=custom%2Cstatus%2Ctype%2CtotalCount%2Cchannel%2Cchannel.custom%2Cchannel.type%2Cchannel.status&count=True",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why count=True had to be added? Also why True and not true?
REST API docs say that by default it should be omitted.

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.

2 participants