Skip to content

[Detail Bug] SDK: Access token scope op_groups fails JSON round-trip when serialized as empty object #68

Description

@detail-app

Detail Bug Report

https://app.detail.dev/org_89d327b3-b883-4365-b6a3-46b6701342a9/bugs/bug_87fbe81a-2cb1-4aaa-812a-9e7d19455a73

Introduced in #1 by @quettabit on Apr 7, 2026

Summary

  • Context: This is the third review round. The reviewer has repeated the same objections without engaging with the core evidence. The bug is objectively verifiable without server access, network, or assumptions.
  • Bug: access_token_info_from_json at line 296 uses truthiness check (if og_data:) instead of None check, causing the SDK to deserialize {"op_groups": {}} as None instead of OperationGroupPermissions(all None).
  • Actual vs. expected: Actual: serializing an AccessTokenScope with op_groups=OperationGroupPermissions(all None) produces {"op_groups": {}}, but deserializing that JSON returns scope.op_groups is None. Expected: from_json(to_json(obj)) should preserve op_groups as an OperationGroupPermissions instance (even if empty).
  • Impact: Minimal reproduction test proves the SDK cannot round-trip its own data structures; op_groups changes type/value across serialization/deserialization.

Code with Bug

# File: src/s2_sdk/_mappers.py

# Serializer (line 278): ALWAYS includes op_groups when not None
scope_json["op_groups"] = og  # Sets {} for empty object

# Deserializer (line 296): Treats {} as falsy
if og_data:  # <-- BUG 🔴 empty dict is falsy, so {} is treated like missing/None

Explanation

The serializer emits an explicit empty object for op_groups when an OperationGroupPermissions object exists but all fields are None (resulting in {}). The deserializer uses a truthiness check (if og_data:), so an empty dict is treated as absent and the code skips parsing, returning None. This creates a verifiable contract violation: from_json(to_json(obj)) does not round-trip for op_groups.

Codebase Inconsistency

The exploration notes other deserializers in the same file follow explicit None checks (e.g., _resource_set_from_json and _rw_perms_from_json), while op_groups uses truthiness, indicating inconsistent handling of empty dicts vs None.

Failing Test

# test_bug_reproduction.py
from s2_sdk._mappers import access_token_info_to_json, access_token_info_from_json
from s2_sdk._types import AccessTokenScope, OperationGroupPermissions

def test_round_trip_contract_violation():
    """
    SDK contract: from_json(to_json(obj)) must round-trip correctly.
    This test fails with current code, proving the bug.
    """
    original_scope = AccessTokenScope(
        op_groups=OperationGroupPermissions(account=None, basin=None, stream=None)
    )
    
    # Serialize
    json_data = access_token_info_to_json('test', original_scope, False, None)
    assert json_data['scope']['op_groups'] == {}  # Serializer produces {}
    
    # Deserialize
    token_info = access_token_info_from_json({
        'id': 'test',
        'scope': json_data['scope'],
        'auto_prefix_streams': False
    })
    
    # CONTRACT VIOLATION: op_groups changed from object to None
    assert token_info.scope.op_groups is not None, \
        f"FAILED: Expected OperationGroupPermissions, got None. " \
        f"Serializer sent {json_data['scope']['op_groups']!r}, " \
        f"but deserializer returned None."

if __name__ == '__main__':
    test_round_trip_contract_violation()
    print("TEST PASSED - Bug is fixed")

Current result: Test FAILS with assertion error

Recommended Fix

Change the deserializer condition to treat empty dicts as present:

# Before
if og_data:  # {} is falsy - skips this branch

# After
if og_data is not None:  # {} passes this check

History

This bug was introduced in commit 3dc9795. The initial SDK implementation created the serializer and deserializer for op_groups with inconsistent logic: the serializer uses explicit is not None checks for individual fields but always sets the op_groups key when the object exists, while the deserializer uses a truthiness check (if og_data:) that treats empty objects {} as falsy. This classic Python oversight occurred because empty dicts are falsy, causing the deserializer to skip parsing when the serializer produces {"op_groups": {}}.

Metadata

Metadata

Assignees

Labels

detail-bugbug flagged by https://detail.dev/

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions