phoenix-channels-python-client 0.5.1__tar.gz → 0.5.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. {phoenix_channels_python_client-0.5.1/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.5.2}/PKG-INFO +10 -6
  2. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/README.md +9 -5
  3. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/__init__.py +1 -1
  4. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/codec.py +3 -1
  5. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/diagnostics.py +29 -9
  6. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/messages.py +1 -1
  7. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/replies.py +7 -9
  8. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/supervisor.py +2 -0
  9. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/topic_runtime.py +3 -2
  10. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2/phoenix_channels_python_client.egg-info}/PKG-INFO +10 -6
  11. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/pyproject.toml +1 -1
  12. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_channel_recovery.py +2 -2
  13. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_security.py +43 -0
  14. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/LICENSE +0 -0
  15. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/MANIFEST.in +0 -0
  16. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/authentication.py +0 -0
  17. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/channel.py +0 -0
  18. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/client.py +0 -0
  19. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/client_state_machine.py +0 -0
  20. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/client_types.py +0 -0
  21. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/default_decider.py +0 -0
  22. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/exceptions.py +0 -0
  23. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/protocol.py +0 -0
  24. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/protocol_handler.py +0 -0
  25. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/py.typed +0 -0
  26. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/reconnect_controller.py +0 -0
  27. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/send_session.py +0 -0
  28. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/shutdown_signals.py +0 -0
  29. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/topic_subscription.py +0 -0
  30. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client/utils.py +0 -0
  31. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client.egg-info/SOURCES.txt +0 -0
  32. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
  33. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client.egg-info/requires.txt +0 -0
  34. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
  35. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/setup.cfg +0 -0
  36. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_binary.py +0 -0
  37. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_cancel_and_wait.py +0 -0
  38. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_channel_api.py +0 -0
  39. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_cleanup_ownership.py +0 -0
  40. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_client_state.py +0 -0
  41. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_codec.py +0 -0
  42. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_connection.py +0 -0
  43. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_heartbeat.py +0 -0
  44. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_heartbeat_timeout.py +0 -0
  45. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_join_installation.py +0 -0
  46. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_join_lifecycle.py +0 -0
  47. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_join_params.py +0 -0
  48. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_join_params_serialization.py +0 -0
  49. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_join_responses.py +0 -0
  50. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_optional_lifecycle.py +0 -0
  51. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_optional_reconnect.py +0 -0
  52. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_package_typing.py +0 -0
  53. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_protocol_handler.py +0 -0
  54. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_public_api.py +0 -0
  55. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_push.py +0 -0
  56. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_reconnect.py +0 -0
  57. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_reconnect_decider.py +0 -0
  58. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_reconnect_policy_invariants.py +0 -0
  59. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_reconnect_stress.py +0 -0
  60. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_run_forever_signals.py +0 -0
  61. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_send_session.py +0 -0
  62. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_shutdown_races.py +0 -0
  63. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_subscriptions.py +0 -0
  64. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_supervisor_internals.py +0 -0
  65. {phoenix_channels_python_client-0.5.1 → phoenix_channels_python_client-0.5.2}/tests/test_topic_runtime_internals.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phoenix-channels-python-client
3
- Version: 0.5.1
3
+ Version: 0.5.2
4
4
  Summary: A Python client library for connecting to Phoenix Channels
5
5
  Author: Phoenix Channels Python Client
6
6
  License-Expression: MIT
@@ -681,7 +681,9 @@ Expiry or heartbeat send failure force-closes that socket; the supervisor handle
681
681
  ## Logging
682
682
 
683
683
  The library uses loggers under `phoenix_channels_python_client`. Configure
684
- logging in your application; importing the package does not configure it:
684
+ logging in your application; importing the package does not configure it.
685
+ Connection log lines show `<omitted>` in place of values that may carry personal
686
+ data (see [Authentication and diagnostic safety](#authentication-and-diagnostic-safety)):
685
687
 
686
688
  ```python
687
689
  import logging
@@ -736,10 +738,12 @@ exception objects, or socket metadata. `client.redact_diagnostic(text)` applies
736
738
  the credential policy to transport errors a caller needs to retain. Caller-visible
737
739
  errors may still contain personal data and should not be logged outside this policy.
738
740
 
739
- Connection diagnostics omit dynamic strings and transport payloads, including URLs,
740
- headers, frame bodies, user/topic identities, and untrusted server error details.
741
- Logs retain static lifecycle descriptions, native connection states,
742
- and exception types. Application data and caller-visible errors are not changed;
741
+ Connection diagnostics omit dynamic values and transport payloads, including URLs,
742
+ headers, frame bodies, user/topic identities, numbers, and untrusted server error
743
+ details. Each omitted value appears as `<omitted>`, for example
744
+ `No handler found for event <omitted> on topic <omitted>`. Logs retain static
745
+ lifecycle descriptions, enum values such as connection states, booleans, and
746
+ exception types. Application data and caller-visible errors are not changed;
743
747
  applications remain responsible for logs outside this connection-owned boundary.
744
748
 
745
749
  ---
@@ -648,7 +648,9 @@ Expiry or heartbeat send failure force-closes that socket; the supervisor handle
648
648
  ## Logging
649
649
 
650
650
  The library uses loggers under `phoenix_channels_python_client`. Configure
651
- logging in your application; importing the package does not configure it:
651
+ logging in your application; importing the package does not configure it.
652
+ Connection log lines show `<omitted>` in place of values that may carry personal
653
+ data (see [Authentication and diagnostic safety](#authentication-and-diagnostic-safety)):
652
654
 
653
655
  ```python
654
656
  import logging
@@ -703,10 +705,12 @@ exception objects, or socket metadata. `client.redact_diagnostic(text)` applies
703
705
  the credential policy to transport errors a caller needs to retain. Caller-visible
704
706
  errors may still contain personal data and should not be logged outside this policy.
705
707
 
706
- Connection diagnostics omit dynamic strings and transport payloads, including URLs,
707
- headers, frame bodies, user/topic identities, and untrusted server error details.
708
- Logs retain static lifecycle descriptions, native connection states,
709
- and exception types. Application data and caller-visible errors are not changed;
708
+ Connection diagnostics omit dynamic values and transport payloads, including URLs,
709
+ headers, frame bodies, user/topic identities, numbers, and untrusted server error
710
+ details. Each omitted value appears as `<omitted>`, for example
711
+ `No handler found for event <omitted> on topic <omitted>`. Logs retain static
712
+ lifecycle descriptions, enum values such as connection states, booleans, and
713
+ exception types. Application data and caller-visible errors are not changed;
710
714
  applications remain responsible for logs outside this connection-owned boundary.
711
715
 
712
716
  ---
@@ -27,7 +27,7 @@ from phoenix_channels_python_client.messages import Message, Payload, SystemEven
27
27
  from phoenix_channels_python_client.protocol import ProtocolVersion
28
28
  from phoenix_channels_python_client.replies import Reply
29
29
 
30
- __version__ = "0.5.1"
30
+ __version__ = "0.5.2"
31
31
 
32
32
  __all__ = [
33
33
  "Channel",
@@ -120,7 +120,9 @@ def _decode_binary(raw: bytes) -> DecodedMessage:
120
120
  raise ValueError("Empty binary frame")
121
121
  kind = BinaryKind(raw[0])
122
122
  fields, payload = _binary_fields(raw, SERVER_HEADER_FIELDS[kind])
123
- status = None
123
+ join_ref: str | None
124
+ ref: str | None
125
+ status: str | None = None
124
126
  match kind:
125
127
  case BinaryKind.PUSH:
126
128
  join_ref, topic, event = fields
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import json
4
4
  import logging
5
5
  import re
6
+ from enum import Enum
6
7
  from urllib.parse import parse_qsl, quote, quote_plus, urlsplit
7
8
 
8
9
  from websockets.protocol import State
@@ -14,7 +15,34 @@ from phoenix_channels_python_client.authentication import (
14
15
  )
15
16
 
16
17
  REDACTED = "[REDACTED]"
18
+ OMITTED = "<omitted>"
17
19
  PERCENT_ESCAPE = re.compile(r"%[0-9a-fA-F]{2}")
20
+ # A printf-style conversion, as logging applies to a record's msg and args.
21
+ PLACEHOLDER = re.compile(
22
+ r"%(?:\([^)]*\))?[#0 +-]*(?:\*|\d+)?(?:\.(?:\*|\d+))?[hlL]?[diouxXeEfFgGcrsa%]"
23
+ )
24
+ WEBSOCKET_STATE_MESSAGE = "= connection is %s"
25
+
26
+
27
+ def render_value(value: object) -> str:
28
+ # Enum members and booleans are code-defined; any other value, numbers
29
+ # included, may carry personal data.
30
+ return str(value) if isinstance(value, Enum | bool) else OMITTED
31
+
32
+
33
+ def render_message(record: logging.LogRecord) -> str:
34
+ """Fill a record's placeholders without exposing its arguments."""
35
+ message = str(record.msg)
36
+ if not record.args:
37
+ return message
38
+ args = record.args if isinstance(record.args, tuple) else ()
39
+ if message == WEBSOCKET_STATE_MESSAGE and args and args[0] in State.__members__:
40
+ return message % args[0]
41
+ values = iter(args)
42
+ return PLACEHOLDER.sub(
43
+ lambda match: "%" if match[0] == "%%" else render_value(next(values, None)),
44
+ message,
45
+ )
18
46
 
19
47
 
20
48
  AUTHENTICATION_NAMES = frozenset(
@@ -86,15 +114,7 @@ class ConnectionLogger(logging.Logger):
86
114
  def handle(self, record: logging.LogRecord) -> None:
87
115
  if self.target.disabled:
88
116
  return
89
- message = str(record.msg)
90
- if (
91
- record.msg == "= connection is %s"
92
- and isinstance(record.args, tuple)
93
- and len(record.args) == 1
94
- and isinstance(record.args[0], str)
95
- and record.args[0] in State.__members__
96
- ):
97
- message = record.msg % record.args
117
+ message = render_message(record)
98
118
  if record.exc_info and record.exc_info[0]:
99
119
  message += " [" + record.exc_info[0].__name__ + "]"
100
120
  safe_record = logging.LogRecord(
@@ -14,7 +14,7 @@ class SystemEvent(StrEnum):
14
14
  close = "phx_close"
15
15
  error = "phx_error"
16
16
  # The protocol event intentionally shadows str.join.
17
- join = "phx_join" # type: ignore[bad-override]
17
+ join = "phx_join" # type: ignore[bad-override, assignment]
18
18
  reply = "phx_reply"
19
19
  leave = "phx_leave"
20
20
 
@@ -20,13 +20,11 @@ def reply_from_message(message: Message, *, binary_status: str | None = None) ->
20
20
  if binary_status is not None:
21
21
  if type(message.payload) is not bytes:
22
22
  raise TypeError("Binary reply requires a bytes payload")
23
- status, response = binary_status, message.payload
24
- else:
25
- payload = message.payload
26
- if not isinstance(payload, dict):
27
- raise TypeError("Text reply requires a response envelope")
28
- status = payload.get("status")
29
- response = payload.get("response")
30
- if not isinstance(status, str) or not isinstance(response, dict):
31
- raise TypeError("Reply must contain a string status and a response map")
23
+ return Reply(status=binary_status, response=message.payload)
24
+ payload = message.payload
25
+ if not isinstance(payload, dict):
26
+ raise TypeError("Text reply requires a response envelope")
27
+ status, response = payload.get("status"), payload.get("response")
28
+ if not isinstance(status, str) or not isinstance(response, dict):
29
+ raise TypeError("Reply must contain a string status and a response map")
32
30
  return Reply(status=status, response=response)
@@ -27,6 +27,7 @@ from phoenix_channels_python_client.messages import (
27
27
  Message,
28
28
  )
29
29
  from phoenix_channels_python_client.protocol_handler import PHXProtocolHandler
30
+ from phoenix_channels_python_client.send_session import SessionSends
30
31
  from phoenix_channels_python_client.shutdown_signals import handle_shutdown_signals
31
32
  from phoenix_channels_python_client.topic_subscription import (
32
33
  ChannelState,
@@ -102,6 +103,7 @@ class SupervisorMixin:
102
103
  heartbeat_interval_s: float | None
103
104
  heartbeat_task: asyncio.Task[None] | None
104
105
  pending_heartbeat_ref: str | None
106
+ send_session: SessionSends | None
105
107
  on_reconnect: Callable[[], Awaitable[None]] | None
106
108
  on_disconnect: Callable[[Exception | None], Awaitable[None]] | None
107
109
  on_heartbeat_ack: Callable[[], None] | None
@@ -838,8 +838,9 @@ class TopicRuntimeMixin:
838
838
 
839
839
  def _validate_join_hook(self, topic: TopicSubscription) -> None:
840
840
  hook = topic.on_join_response
841
- if inspect.iscoroutinefunction(hook) or inspect.iscoroutinefunction(
842
- hook.__call__ if callable(hook) else None
841
+ if hook is not None and (
842
+ inspect.iscoroutinefunction(hook)
843
+ or inspect.iscoroutinefunction(type(hook).__call__)
843
844
  ):
844
845
  raise ChannelError("on_join_response must be synchronous")
845
846
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phoenix-channels-python-client
3
- Version: 0.5.1
3
+ Version: 0.5.2
4
4
  Summary: A Python client library for connecting to Phoenix Channels
5
5
  Author: Phoenix Channels Python Client
6
6
  License-Expression: MIT
@@ -681,7 +681,9 @@ Expiry or heartbeat send failure force-closes that socket; the supervisor handle
681
681
  ## Logging
682
682
 
683
683
  The library uses loggers under `phoenix_channels_python_client`. Configure
684
- logging in your application; importing the package does not configure it:
684
+ logging in your application; importing the package does not configure it.
685
+ Connection log lines show `<omitted>` in place of values that may carry personal
686
+ data (see [Authentication and diagnostic safety](#authentication-and-diagnostic-safety)):
685
687
 
686
688
  ```python
687
689
  import logging
@@ -736,10 +738,12 @@ exception objects, or socket metadata. `client.redact_diagnostic(text)` applies
736
738
  the credential policy to transport errors a caller needs to retain. Caller-visible
737
739
  errors may still contain personal data and should not be logged outside this policy.
738
740
 
739
- Connection diagnostics omit dynamic strings and transport payloads, including URLs,
740
- headers, frame bodies, user/topic identities, and untrusted server error details.
741
- Logs retain static lifecycle descriptions, native connection states,
742
- and exception types. Application data and caller-visible errors are not changed;
741
+ Connection diagnostics omit dynamic values and transport payloads, including URLs,
742
+ headers, frame bodies, user/topic identities, numbers, and untrusted server error
743
+ details. Each omitted value appears as `<omitted>`, for example
744
+ `No handler found for event <omitted> on topic <omitted>`. Logs retain static
745
+ lifecycle descriptions, enum values such as connection states, booleans, and
746
+ exception types. Application data and caller-visible errors are not changed;
743
747
  applications remain responsible for logs outside this connection-owned boundary.
744
748
 
745
749
  ---
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "phoenix-channels-python-client"
7
- version = "0.5.1"
7
+ version = "0.5.2"
8
8
  description = "A Python client library for connecting to Phoenix Channels"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -91,7 +91,7 @@ async def test_a_crashed_channel_rejoins_on_the_live_socket(
91
91
  assert (await expect_message(received)).event == EVENT
92
92
  assert reconnects.count == 0
93
93
 
94
- assert "Recovered channel for topic %s" in caplog.text
94
+ assert "Recovered channel for topic <omitted>" in caplog.text
95
95
 
96
96
 
97
97
  @each_protocol
@@ -178,7 +178,7 @@ async def test_a_rejected_channel_rejoin_loses_the_topic(
178
178
  lost.only_error()
179
179
  assert TOPIC not in client.topic_subscriptions.copy()
180
180
  assert joins(phoenix_server) == REJOINED_ONCE
181
- assert "Lost topic %s" in caplog.text
181
+ assert "Lost topic <omitted>" in caplog.text
182
182
 
183
183
 
184
184
  @each_protocol
@@ -15,6 +15,7 @@ from phoenix_channels_python_client import Client
15
15
  from phoenix_channels_python_client.client_types import ReconnectPolicy
16
16
  from phoenix_channels_python_client.diagnostics import ConnectionLogger
17
17
  from phoenix_channels_python_client.exceptions import SocketError
18
+ from phoenix_channels_python_client.messages import SystemEvent
18
19
  from tests.fake_server import FakePhoenixServer
19
20
 
20
21
  URL_CREDENTIAL = "url/private+ credential?x=1&z=#[]"
@@ -199,6 +200,45 @@ class TestConnectionSecurity:
199
200
  target.disabled = previous_disabled
200
201
  target.removeFilter(reject)
201
202
 
203
+ @pytest.mark.parametrize(
204
+ ("message", "args", "rendered"),
205
+ [
206
+ (
207
+ "No handler found for event %s on topic %s",
208
+ ("user_join", "room:42"),
209
+ "No handler found for event <omitted> on topic <omitted>",
210
+ ),
211
+ (
212
+ "Channel %s is %s after %d attempts",
213
+ (SystemEvent.join, True, 3),
214
+ "Channel phx_join is True after <omitted> attempts",
215
+ ),
216
+ ("100%% of %s", ("room:42",), "100% of <omitted>"),
217
+ ("%s and %s", ("only-one",), "<omitted> and <omitted>"),
218
+ ("%d with text", ("not-a-number",), "<omitted> with text"),
219
+ ("%(topic)s by name", ({"topic": "room:42"},), "<omitted> by name"),
220
+ ],
221
+ ids=[
222
+ "strings",
223
+ "enum-bool-number",
224
+ "escape",
225
+ "too-few",
226
+ "type-mismatch",
227
+ "mapping",
228
+ ],
229
+ )
230
+ def test_placeholders_render_without_exposing_arguments(
231
+ self, message: str, args: tuple[object, ...], rendered: str
232
+ ) -> None:
233
+ client = Client("ws://127.0.0.1:1/socket", api_key="synthetic-key")
234
+ collector = BufferingHandler(10)
235
+ client.logger.addHandler(collector)
236
+ client.logger.setLevel(logging.WARNING)
237
+
238
+ client.logger.warning(message, *args)
239
+
240
+ assert [record.getMessage() for record in collector.buffer] == [rendered]
241
+
202
242
  @pytest.mark.parametrize(
203
243
  "header_names",
204
244
  [
@@ -395,6 +435,9 @@ class TestConnectionSecurity:
395
435
  "Parsing raw message:" in record.getMessage() for record in collector.buffer
396
436
  )
397
437
  assert any("OPEN" in record.getMessage() for record in collector.buffer)
438
+ messages = [record.getMessage() for record in collector.buffer]
439
+ assert "Connection callback failed: <omitted> [RuntimeError]" in messages
440
+ assert "Untrusted numeric identity: <omitted> <omitted> <omitted>" in messages
398
441
  for record in collector.buffer:
399
442
  assert identity not in repr(record.__dict__)
400
443
  assert session not in repr(record.__dict__)