Skip to content

Commit f8fefb0

Browse files
erikjohnstonclaude
andauthored
Re-expose the standalone format_event_* transforms for backwards compat (#19922)
The port of event serialization to Rust (#19837) removed `format_event_raw`, `format_event_for_client_v1`, `format_event_for_client_v2` and `format_event_for_client_v2_without_room_id` from `synapse.events.utils`, but there are modules in the wild that import them from there. Reimplement them as standalone pyfunctions in Rust, operating directly on the Python dict so the original semantics are preserved exactly (in-place mutation, returning the same dict, arbitrary non-JSON values passing through, KeyError on a missing `unsigned` in the v1 format), and re-export them from `synapse.events.utils`. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 27c3b53 commit f8fefb0

6 files changed

Lines changed: 225 additions & 2 deletions

File tree

changelog.d/19922.misc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Port the synchronous core of client event serialization to Rust.

rust/src/events/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()>
118118
child_module.add_function(wrap_pyfunction!(redact_event_py, m)?)?;
119119
child_module.add_function(wrap_pyfunction!(redact_event_dict, m)?)?;
120120
child_module.add_function(wrap_pyfunction!(serialize::serialize_events, m)?)?;
121+
child_module.add_function(wrap_pyfunction!(serialize::format_event_raw, m)?)?;
122+
child_module.add_function(wrap_pyfunction!(serialize::format_event_for_client_v1, m)?)?;
123+
child_module.add_function(wrap_pyfunction!(serialize::format_event_for_client_v2, m)?)?;
124+
child_module.add_function(wrap_pyfunction!(
125+
serialize::format_event_for_client_v2_without_room_id,
126+
m
127+
)?)?;
121128

122129
m.add_submodule(&child_module)?;
123130

rust/src/events/serialize.rs

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ use std::collections::HashMap;
3232

3333
use pyo3::{
3434
exceptions::{PyTypeError, PyValueError},
35-
pyclass, pyfunction, pymethods, Bound, PyAny, PyResult, Python,
35+
pyclass, pyfunction, pymethods,
36+
types::{PyAnyMethods, PyDict, PyDictMethods},
37+
Bound, PyAny, PyResult, Python,
3638
};
3739
use pythonize::pythonize;
3840
use serde_json::{Map, Number, Value};
@@ -582,6 +584,69 @@ fn format_for_client_v2(d: &mut Map<String, Value>) {
582584
}
583585
}
584586

587+
// Standalone versions of the client format transforms, re-exported from
588+
// `synapse.events.utils` purely as a backwards compatibility hack: they have
589+
// never been part of the module API and modules shouldn't be pulling them in,
590+
// but some in the wild import them from there anyway. They may be removed in
591+
// the future; nothing in Synapse itself should use them.
592+
//
593+
// Unlike [`apply_event_format`], these mutate a Python dict in place and
594+
// return it, matching the original Python implementations that used to live
595+
// in `synapse/events/utils.py`.
596+
597+
/// Return the event dict unchanged (federation format).
598+
#[pyfunction]
599+
pub fn format_event_raw(d: Bound<'_, PyDict>) -> Bound<'_, PyDict> {
600+
d
601+
}
602+
603+
/// Apply the legacy `/events`-style v1 client format to `d` in place.
604+
#[pyfunction]
605+
pub fn format_event_for_client_v1(d: Bound<'_, PyDict>) -> PyResult<Bound<'_, PyDict>> {
606+
let d = format_event_for_client_v2(d)?;
607+
608+
if let Some(sender) = d.get_item(SENDER)? {
609+
if !sender.is_none() {
610+
d.set_item(USER_ID, sender)?;
611+
}
612+
}
613+
614+
// As in the original Python (`d["unsigned"]`), a missing `unsigned` key
615+
// raises `KeyError`.
616+
let unsigned = d.as_any().get_item(UNSIGNED)?;
617+
for key in V1_COPY_KEYS {
618+
if unsigned.contains(key)? {
619+
d.set_item(key, unsigned.get_item(key)?)?;
620+
}
621+
}
622+
623+
Ok(d)
624+
}
625+
626+
/// Apply the `/sync`-style v2 client format to `d` in place.
627+
#[pyfunction]
628+
pub fn format_event_for_client_v2(d: Bound<'_, PyDict>) -> PyResult<Bound<'_, PyDict>> {
629+
for key in V2_DROP_KEYS {
630+
// Equivalent to `d.pop(key, None)`.
631+
if d.contains(key)? {
632+
d.del_item(key)?;
633+
}
634+
}
635+
Ok(d)
636+
}
637+
638+
/// Apply the v2 client format to `d` in place, additionally stripping `room_id`.
639+
#[pyfunction]
640+
pub fn format_event_for_client_v2_without_room_id(
641+
d: Bound<'_, PyDict>,
642+
) -> PyResult<Bound<'_, PyDict>> {
643+
let d = format_event_for_client_v2(d)?;
644+
if d.contains(ROOM_ID)? {
645+
d.del_item(ROOM_ID)?;
646+
}
647+
Ok(d)
648+
}
649+
585650
/// Return a mutable reference to `map["unsigned"]`, creating it as an empty
586651
/// object if it is missing or not an object.
587652
fn unsigned_mut(map: &mut Map<String, Value>) -> PyResult<&mut Map<String, Value>> {

synapse/events/utils.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@
4545
EventFormat,
4646
SerializeEventConfig,
4747
Unsigned,
48+
format_event_for_client_v1,
49+
format_event_for_client_v2,
50+
format_event_for_client_v2_without_room_id,
51+
format_event_raw,
4852
redact_event,
4953
serialize_events,
5054
)
@@ -56,7 +60,19 @@
5660
# These are imported only to re-export them (callers import them from this
5761
# module); listing them in __all__ stops the unused-import lint flagging them
5862
# and re-exports them for `import *`.
59-
__all__ = ["EventFormat", "SerializeEventConfig"]
63+
#
64+
# The `format_event_*` functions are a backwards compatibility hack: they have
65+
# never been part of the module API and modules shouldn't be pulling them in,
66+
# but some in the wild import them from here anyway. They may be removed in
67+
# the future; nothing in Synapse itself should use them.
68+
__all__ = [
69+
"EventFormat",
70+
"SerializeEventConfig",
71+
"format_event_for_client_v1",
72+
"format_event_for_client_v2",
73+
"format_event_for_client_v2_without_room_id",
74+
"format_event_raw",
75+
]
6076

6177
if TYPE_CHECKING:
6278
from synapse.handlers.relations import BundledAggregations

synapse/synapse_rust/events.pyi

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,40 @@ def serialize_events(
442442
The serialized events, in the same order as `events`.
443443
"""
444444

445+
# The standalone `format_event_*` transforms below are a backwards
446+
# compatibility hack: they have never been part of the module API and modules
447+
# shouldn't be pulling them in, but some in the wild import them (via
448+
# `synapse.events.utils`) anyway. They may be removed in the future; nothing
449+
# in Synapse itself should use them.
450+
451+
def format_event_raw(d: JsonDict) -> JsonDict:
452+
"""Return the event dict unchanged (federation format).
453+
454+
Deprecated backwards compatibility hack for modules importing it from
455+
`synapse.events.utils`; don't use this in new code.
456+
"""
457+
458+
def format_event_for_client_v1(d: JsonDict) -> JsonDict:
459+
"""Apply the legacy `/events`-style v1 client format to `d` in place.
460+
461+
Deprecated backwards compatibility hack for modules importing it from
462+
`synapse.events.utils`; don't use this in new code.
463+
"""
464+
465+
def format_event_for_client_v2(d: JsonDict) -> JsonDict:
466+
"""Apply the `/sync`-style v2 client format to `d` in place.
467+
468+
Deprecated backwards compatibility hack for modules importing it from
469+
`synapse.events.utils`; don't use this in new code.
470+
"""
471+
472+
def format_event_for_client_v2_without_room_id(d: JsonDict) -> JsonDict:
473+
"""Apply the v2 client format to `d` in place, additionally stripping `room_id`.
474+
475+
Deprecated backwards compatibility hack for modules importing it from
476+
`synapse.events.utils`; don't use this in new code.
477+
"""
478+
445479
def redact_event(event: Event) -> Event:
446480
"""Returns a pruned version of the given event, which removes all keys we
447481
don't know about or think could potentially be dodgy.

tests/events/test_utils.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
PowerLevelsContent,
3131
clone_event,
3232
copy_and_fixup_power_levels_contents,
33+
format_event_for_client_v1,
34+
format_event_for_client_v2,
35+
format_event_for_client_v2_without_room_id,
36+
format_event_raw,
3337
maybe_upsert_event_field,
3438
prune_event,
3539
)
@@ -997,3 +1001,99 @@ def test_invalid_types_raise_type_error(self) -> None:
9971001
def test_invalid_nesting_raises_type_error(self) -> None:
9981002
with self.assertRaises(TypeError):
9991003
copy_and_fixup_power_levels_contents({"a": {"b": {"c": 1}}}) # type: ignore[dict-item]
1004+
1005+
1006+
class FormatEventForClientTestCase(stdlib_unittest.TestCase):
1007+
"""Tests for the standalone `format_event_*` transforms.
1008+
1009+
These are Rust reimplementations kept purely as a backwards compatibility
1010+
hack for modules in the wild that import them from `synapse.events.utils`
1011+
(they were never part of the module API, and nothing in Synapse itself uses
1012+
them); like the original Python implementations they must mutate the dict
1013+
in place and return it.
1014+
"""
1015+
1016+
def make_event(self) -> JsonDict:
1017+
return {
1018+
"event_id": "$event_id",
1019+
"room_id": "!room:test",
1020+
"sender": "@sender:test",
1021+
"type": "m.room.message",
1022+
"content": {"body": "hello"},
1023+
"auth_events": [],
1024+
"prev_events": [],
1025+
"hashes": {},
1026+
"signatures": {},
1027+
"depth": 5,
1028+
"origin": "test",
1029+
"prev_state": [],
1030+
"unsigned": {"age": 100, "replaces_state": "$old", "other": 1},
1031+
}
1032+
1033+
def test_raw(self) -> None:
1034+
event_dict = self.make_event()
1035+
result = format_event_raw(event_dict)
1036+
self.assertIs(result, event_dict)
1037+
self.assertEqual(result, self.make_event())
1038+
1039+
def test_v2_drops_federation_keys(self) -> None:
1040+
event_dict = self.make_event()
1041+
result = format_event_for_client_v2(event_dict)
1042+
self.assertIs(result, event_dict)
1043+
self.assertEqual(
1044+
result,
1045+
{
1046+
"event_id": "$event_id",
1047+
"room_id": "!room:test",
1048+
"sender": "@sender:test",
1049+
"type": "m.room.message",
1050+
"content": {"body": "hello"},
1051+
"unsigned": {"age": 100, "replaces_state": "$old", "other": 1},
1052+
},
1053+
)
1054+
1055+
def test_v2_without_room_id(self) -> None:
1056+
event_dict = self.make_event()
1057+
result = format_event_for_client_v2_without_room_id(event_dict)
1058+
self.assertIs(result, event_dict)
1059+
self.assertNotIn("room_id", result)
1060+
1061+
def test_v1_copies_unsigned_keys(self) -> None:
1062+
event_dict = self.make_event()
1063+
result = format_event_for_client_v1(event_dict)
1064+
self.assertIs(result, event_dict)
1065+
self.assertEqual(
1066+
result,
1067+
{
1068+
"event_id": "$event_id",
1069+
"room_id": "!room:test",
1070+
"sender": "@sender:test",
1071+
"user_id": "@sender:test",
1072+
"type": "m.room.message",
1073+
"content": {"body": "hello"},
1074+
"age": 100,
1075+
"replaces_state": "$old",
1076+
"unsigned": {"age": 100, "replaces_state": "$old", "other": 1},
1077+
},
1078+
)
1079+
1080+
def test_v1_no_sender(self) -> None:
1081+
event_dict = self.make_event()
1082+
del event_dict["sender"]
1083+
result = format_event_for_client_v1(event_dict)
1084+
self.assertNotIn("user_id", result)
1085+
1086+
def test_non_json_values_pass_through(self) -> None:
1087+
# The transforms only move keys around; values that aren't
1088+
# JSON-serializable must survive untouched.
1089+
marker = object()
1090+
event_dict = {
1091+
"sender": "@sender:test",
1092+
"auth_events": marker,
1093+
"content": marker,
1094+
"unsigned": {"age": marker},
1095+
}
1096+
result = format_event_for_client_v1(event_dict)
1097+
self.assertIs(result["content"], marker)
1098+
self.assertIs(result["age"], marker)
1099+
self.assertNotIn("auth_events", result)

0 commit comments

Comments
 (0)