Skip to content
Open
58 changes: 41 additions & 17 deletions kombu/utils/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,20 @@ class JSONEncoder(json.JSONEncoder):
"""Kombu custom json encoder."""

def default(self, o):
for t, (marker, encoder) in _encoders.items():
if isinstance(o, t):
return (
encoder(o) if marker is None else _as(marker, encoder(o))
)

Comment on lines 25 to +31
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

This change gives priority to user-registered types within JSONEncoder.default, but stdlib json does not call default() for JSON primitives (e.g. str/int/float/bool/None) or their subclasses. If the intent is to solve #1895's SafeString (a str subclass) example, this implementation likely won’t affect that case; it would require intercepting encoding before the primitive fast-path (e.g. overriding iterencode/preprocessing).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@soceanainn please cross check this and other suggestions

reducer = getattr(o, "__json__", None)
if reducer is not None:
return reducer()
Comment on lines 25 to 34
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

JSONEncoder.default now applies user-registered encoders before checking __json__. This changes existing precedence: objects that implement __json__ and also match a registered type will no longer use their __json__ reducer. Consider keeping the __json__ check first (as before) and only prioritizing user encoders over default encoders.

Copilot uses AI. Check for mistakes.

if isinstance(o, textual_types):
return str(o)

for t, (marker, encoder) in _encoders.items():
for t, (marker, encoder) in _default_encoders.items():
if isinstance(o, t):
return (
encoder(o) if marker is None else _as(marker, encoder(o))
Expand Down Expand Up @@ -66,7 +72,7 @@ def dumps(
def object_hook(o: dict):
"""Hook function to perform custom deserialization."""
if o.keys() == {"__type__", "__value__"}:
decoder = _decoders.get(o["__type__"])
decoder = _decoders.get(o["__type__"]) or _default_decoders.get(o["__type__"])
if decoder:
return decoder(o["__value__"])
else:
Expand Down Expand Up @@ -97,6 +103,16 @@ def loads(s, _loads=json.loads, decode_bytes=True, object_hook=object_hook):
T = TypeVar("T")
EncodedT = TypeVar("EncodedT")

# Separate user registered types from Kombu registered types to allow us to give preference to user types
_encoders: dict[type, tuple[str | None, EncoderT]] = {}
_decoders: dict[str, DecoderT] = {}

_default_encoders: dict[type, tuple[str | None, EncoderT]] = {}
_default_decoders: dict[str, DecoderT] = {
"bytes": lambda o: o.encode("utf-8"),
"base64": lambda o: base64.b64decode(o.encode("utf-8")),
}


def register_type(
t: type[T],
Expand All @@ -110,32 +126,40 @@ def register_type(
is not placed in an envelope, so `decoder` is unnecessary. Decoding must
instead be handled outside this library.
"""
_encoders[t] = (marker, encoder)
if marker is not None:
_decoders[marker] = decoder
_register_type(t, marker, encoder, decoder, is_default_encoder=False)


_encoders: dict[type, tuple[str | None, EncoderT]] = {}
_decoders: dict[str, DecoderT] = {
"bytes": lambda o: o.encode("utf-8"),
"base64": lambda o: base64.b64decode(o.encode("utf-8")),
}
def _register_type(
t: type[T],
marker: str | None,
encoder: Callable[[T], EncodedT],
decoder: Callable[[EncodedT], T] = lambda d: d,
is_default_encoder: bool = True,
):
if is_default_encoder:
_default_encoders[t] = (marker, encoder)
if marker is not None:
_default_decoders[marker] = decoder
else:
_encoders[t] = (marker, encoder)
if marker is not None:
_decoders[marker] = decoder


def _register_default_types():
# NOTE: datetime should be registered before date,
# because datetime is also instance of date.
register_type(datetime, "datetime", datetime.isoformat,
datetime.fromisoformat)
register_type(
_register_type(datetime, "datetime", datetime.isoformat,
datetime.fromisoformat)
_register_type(
Comment on lines 149 to +154
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

_register_default_types() is used as a reset mechanism in tests, but after splitting defaults into _default_encoders/_default_decoders it no longer clears user-registered _encoders/_decoders. This means a test that calls register_type (e.g. overriding uuid.UUID) can leak that override into subsequent tests. Consider clearing _encoders/_decoders (and reinitializing _default_*) at the start of _register_default_types, or adding a dedicated reset helper for tests.

Copilot uses AI. Check for mistakes.
date,
"date",
lambda o: o.isoformat(),
lambda o: datetime.fromisoformat(o).date(),
lambda o: datetime.fromisoformat(o).date()
)
register_type(time, "time", lambda o: o.isoformat(), time.fromisoformat)
register_type(Decimal, "decimal", str, Decimal)
register_type(
_register_type(time, "time", lambda o: o.isoformat(), time.fromisoformat)
_register_type(Decimal, "decimal", str, Decimal)
_register_type(
Comment on lines 149 to +162
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

_register_default_types() no longer resets user overrides registered via register_type(). In the test suite, the autouse fixture calls _register_default_types() to reset state between tests, but user-registered encoders/decoders will now persist and can make tests order-dependent (e.g. after overriding the UUID handler). Consider clearing _encoders/_decoders as part of the test reset, or adding an explicit reset helper that restores a clean default state.

Copilot uses AI. Check for mistakes.
uuid.UUID,
"uuid",
lambda o: {"hex": o.hex},
Expand Down
14 changes: 14 additions & 0 deletions t/unit/utils/test_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ def test_register_type_overrides_defaults(self):
loaded_value = loads(dumps({'u': value}))
assert loaded_value == {'u': "custom"}

def test_register_type_takes_priority(self):
class MyDecimal(Decimal):
pass

register_type(MyDecimal, "mydecimal", str, MyDecimal)
original = {'md': MyDecimal('3314132.13363235235324234123213213214134')}
serialized_str = dumps(original)
# Ensure our custom marker is used instead of the default Decimal handler
assert '"mydecimal"' in serialized_str
loaded_value = loads(serialized_str)
Comment on lines +104 to +107
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

assert '"mydecimal"' in serialized_str is a brittle check (it can match unrelated string content). It’s more robust to parse the JSON and assert on the __type__ field for md, which also makes the intent clearer.

Copilot uses AI. Check for mistakes.
# Ensure the decoded value is of the registered subclass, not just equal
assert isinstance(loaded_value['md'], MyDecimal)
assert original == loaded_value
Comment on lines +98 to +110
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

This test registers a new custom type via register_type(), which mutates module-level global registries. With the new split between user/default registries, the existing _register_default_types() fixture no longer clears user registrations, so this (and other register_type tests) can leak state into subsequent tests and make the suite order-dependent. Consider resetting the user registries in the autouse fixture (or adding teardown in this test) to keep tests isolated.

Copilot uses AI. Check for mistakes.

def test_register_type_with_new_type(self):
# Guaranteed never before seen type
@dataclass()
Expand Down
Loading