Skip to content

Commit c351848

Browse files
authored
bump for 3.0.2 (jquast#129)
* bugfix: method ``TelnetWriter.request_charset`` raised ``TypeError``, jquast#128. Offer callbacks (no-arg, returning a list of items to propose) are now separated from send callbacks (which respond to received requests) via new ``TelnetWriter.set_ext_offer_callback`` method. Closes jquast#128
1 parent 09b58f6 commit c351848

11 files changed

Lines changed: 174 additions & 27 deletions

docs/history.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
History
22
=======
3+
3.0.2
4+
* bugfix: :meth:`~telnetlib3.stream_writer.TelnetWriter.request_charset` raised :exc:`TypeError`,
5+
:ghissue:`128`. Offer callbacks (no-arg, returning a list of items to propose) are now
6+
separated from send callbacks (which respond to received requests) via new
7+
:meth:`~telnetlib3.stream_writer.TelnetWriter.set_ext_offer_callback` method.
8+
39
3.0.1
410
* change: Unused client argument ``gmcp_log`` removed.
511
* new: MCCP2 and MCCP3. Both client and server ends passively support if requested, and request
612
support by --compression or deny support by --no-compression.
13+
* new: :meth:`~telnetlib3.client.TelnetClient.on_request_charset` and
14+
:meth:`~telnetlib3.client.TelnetClient.on_request_environ` offer callbacks
15+
on the client, symmetric with the existing server-side callbacks.
716

817
3.0.0
918
* change: :attr:`~telnetlib3.client_base.BaseClient.connect_minwait` default

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "telnetlib3"
7-
version = "3.0.1" ## Keep in sync with telnetlib3.accessories.get_version() !
7+
version = "3.0.2" # Keep in sync with telnetlib3/accessories.py::get_version !
88
description = " Python Telnet server and client CLI and Protocol library"
99
readme = "README.rst"
1010
license = "ISC"

telnetlib3/accessories.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242

4343
def get_version() -> str:
4444
"""Return the current version of telnetlib3."""
45-
return "3.0.1" # keep in sync with pyproject.toml and docs/conf.py !!
45+
return "3.0.2" # keep in sync with pyproject.toml !
4646

4747

4848
def encoding_from_lang(lang: str) -> Optional[str]:

telnetlib3/client.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,14 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None:
136136
):
137137
self.writer.set_ext_send_callback(opt, func)
138138

139+
# Offer callbacks define what to include in outgoing requests
140+
# (e.g. what charsets to offer in SB CHARSET REQUEST).
141+
for opt, offer_func in (
142+
(CHARSET, self.on_request_charset),
143+
(NEW_ENVIRON, self.on_request_environ),
144+
):
145+
self.writer.set_ext_offer_callback(opt, offer_func)
146+
139147
# Override the default handle_will method to detect when both sides support CHARSET
140148
# Store the original only on first connection to prevent chain growth on reconnect.
141149
if not hasattr(self.writer, "_original_handle_will"):
@@ -372,6 +380,28 @@ def send_charset(self, offered: List[str]) -> str:
372380
self.log.warning("No suitable encoding offered by server: %s", offered)
373381
return ""
374382

383+
def on_request_charset(self) -> List[str]:
384+
"""
385+
Offer callback for client-initiated CHARSET REQUEST, :rfc:`2066`.
386+
387+
Called by :meth:`~.TelnetWriter.request_charset` to determine which
388+
character sets the client offers to the server.
389+
390+
:returns: List of charset name strings to offer.
391+
"""
392+
return ["UTF-8", "LATIN1", "US-ASCII"]
393+
394+
def on_request_environ(self) -> List[str]:
395+
"""
396+
Offer callback for client-initiated NEW_ENVIRON SEND, :rfc:`1572`.
397+
398+
Called by :meth:`~.TelnetWriter.request_environ` to determine which
399+
environment variable names the client requests from the server.
400+
401+
:returns: List of environment variable names to request.
402+
"""
403+
return []
404+
375405
def send_naws(self) -> Tuple[int, int]:
376406
"""
377407
Callback for responding to NAWS requests.

telnetlib3/client_base.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ def __init__(
6868
self._limit = limit
6969

7070
# MCCP2: server→client decompression
71-
self._mccp2_decompressor: Optional[zlib.Decompress] = None
71+
self._mccp2_decompressor: Optional[zlib._Decompress] = None
7272
# MCCP3: client→server compression
73-
self._mccp3_compressor: Optional[zlib.Compress] = None
73+
self._mccp3_compressor: Optional[zlib._Compress] = None
7474
self._mccp3_orig_write: Any = None
7575

7676
# High-throughput receive pipeline
@@ -470,7 +470,7 @@ def compressed_write(data: bytes) -> None:
470470
else:
471471
orig_write(data)
472472

473-
transport.write = compressed_write # type: ignore[assignment]
473+
transport.write = compressed_write # type: ignore[method-assign]
474474
self._mccp3_orig_write = orig_write
475475
self.log.debug("MCCP3 compression started (client→server)")
476476

telnetlib3/server.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,12 +167,12 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None:
167167
for tel_opt, callback_fn in _ext_callbacks:
168168
self.writer.set_ext_callback(tel_opt, callback_fn)
169169

170-
# Wire up a callbacks that return definitions for requests.
170+
# Wire up offer callbacks that return definitions for outgoing requests.
171171
for tel_opt, callback_fn in [
172172
(NEW_ENVIRON, self.on_request_environ),
173173
(CHARSET, self.on_request_charset),
174174
]:
175-
self.writer.set_ext_send_callback(tel_opt, callback_fn)
175+
self.writer.set_ext_offer_callback(tel_opt, callback_fn)
176176

177177
def data_received(self, data: bytes) -> None:
178178
"""Process received data and reset timeout timer."""
@@ -208,7 +208,7 @@ def compressed_write(data: bytes) -> None:
208208
else:
209209
orig_write(data)
210210

211-
transport.write = compressed_write # type: ignore[assignment]
211+
transport.write = compressed_write # type: ignore[method-assign]
212212
self._mccp2_orig_write = orig_write
213213
self.writer.mccp2_active = True
214214
logger.debug("MCCP2 compression started (server→client)")
@@ -217,6 +217,7 @@ def _mccp2_end(self) -> None:
217217
"""Stop MCCP2 compression, flush Z_FINISH."""
218218
if self._mccp2_compressor is not None:
219219
try:
220+
assert self._mccp2_orig_write is not None
220221
self._mccp2_orig_write(self._mccp2_compressor.flush(zlib.Z_FINISH))
221222
except zlib.error as exc:
222223
logger.debug("MCCP2 Z_FINISH flush error: %s", exc)

telnetlib3/server_base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class BaseServer(TelnetProtocolBase, asyncio.streams.FlowControlMixin, asyncio.P
3030
_check_later = None
3131
_rx_bytes = 0
3232
_tx_bytes = 0
33-
_mccp3_decompressor: Optional[zlib.Decompress] = None
33+
_mccp3_decompressor: Optional[zlib._Decompress] = None
3434

3535
def __init__(
3636
self,

telnetlib3/stream_writer.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -395,9 +395,19 @@ def __init__(
395395
):
396396
self.set_ext_send_callback(cmd=ext_cmd, func=getattr(self, f"handle_send_{key}"))
397397

398+
# Offer callbacks: used by request_charset() and request_environ()
399+
# to get the list of items to offer/request. Separate from
400+
# _ext_send_callback which is used to *respond* to received requests.
401+
self._ext_offer_callback: dict[bytes, Callable[..., Any]] = {}
402+
398403
for ext_cmd, key in ((CHARSET, "charset"), (NEW_ENVIRON, "environ")):
399-
_cbname = "handle_send_server_" if self.server else "handle_send_client_"
400-
self.set_ext_send_callback(cmd=ext_cmd, func=getattr(self, _cbname + key))
404+
# The "server" default handlers take no args and return lists
405+
# (what to offer/request). The "client" handlers take args
406+
# (respond to received offers).
407+
self.set_ext_offer_callback(
408+
cmd=ext_cmd, func=getattr(self, f"handle_send_server_{key}")
409+
)
410+
self.set_ext_send_callback(cmd=ext_cmd, func=getattr(self, f"handle_send_client_{key}"))
401411

402412
@property
403413
def connection_closed(self) -> bool:
@@ -430,6 +440,7 @@ def close(self) -> None:
430440
# break circular refs
431441
self._ext_callback.clear()
432442
self._ext_send_callback.clear()
443+
self._ext_offer_callback.clear()
433444
self._slc_callback.clear()
434445
self._iac_callback.clear()
435446
self._protocol = None
@@ -1148,7 +1159,7 @@ def request_charset(self) -> bool:
11481159
self.log.debug("cannot send SB CHARSET REQUEST, request pending.")
11491160
return False
11501161

1151-
codepages = self._ext_send_callback[CHARSET]()
1162+
codepages = self._ext_offer_callback[CHARSET]()
11521163

11531164
sep = " "
11541165
response: collections.deque[bytes] = collections.deque()
@@ -1171,7 +1182,7 @@ def request_environ(self) -> bool:
11711182
self.log.debug("cannot send SB NEW_ENVIRON SEND IS without receipt of WILL NEW_ENVIRON")
11721183
return False
11731184

1174-
request_list = self._ext_send_callback[NEW_ENVIRON]()
1185+
request_list = self._ext_offer_callback[NEW_ENVIRON]()
11751186

11761187
if not request_list:
11771188
self.log.debug(
@@ -1512,6 +1523,26 @@ def set_ext_send_callback(self, cmd: bytes, func: Callable[..., Any]) -> None:
15121523
"""
15131524
self._ext_send_callback[cmd] = func
15141525

1526+
def set_ext_offer_callback(self, cmd: bytes, func: Callable[..., Any]) -> None:
1527+
"""
1528+
Register callback for building outgoing sub-negotiation requests.
1529+
1530+
Unlike :meth:`set_ext_send_callback` (which responds to *received*
1531+
requests), this callback is invoked with **no arguments** and must
1532+
return a list describing what to offer or request.
1533+
1534+
:param cmd: Telnet option byte.
1535+
:param func: Callable returning a list:
1536+
1537+
* ``CHARSET``: return a list of charset name strings to offer
1538+
in an outgoing ``SB CHARSET REQUEST``, :rfc:`2066`.
1539+
1540+
* ``NEW_ENVIRON``: return a list of environment variable name
1541+
strings (or the special ``VAR``/``USERVAR`` bytes) to request
1542+
in an outgoing ``SB NEW_ENVIRON SEND``, :rfc:`1572`.
1543+
"""
1544+
self._ext_offer_callback[cmd] = func
1545+
15151546
def set_ext_callback(self, cmd: bytes, func: Callable[..., Any]) -> None:
15161547
"""
15171548
Register ``func`` as callback for receipt of ``cmd`` negotiation.

telnetlib3/tests/test_charset.py

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ def test_server_sends_do_and_will_charset():
182182
def test_client_do_will_then_server_will_allows_client_request():
183183
"""Test scenario from logfile: DO->WILL then server WILL allows client to send SB REQUEST."""
184184
wc, tc, _ = new_writer(server=False, client=True)
185-
wc.set_ext_send_callback(CHARSET, lambda: ["UTF-8"])
185+
wc.set_ext_offer_callback(CHARSET, lambda: ["UTF-8"])
186186

187187
# Simulate server DO CHARSET
188188
# Note: handle_do() returns True but local_option[...] is set by the caller
@@ -208,11 +208,11 @@ def test_bidirectional_charset_both_sides_can_request():
208208
"""Test that both server and client can initiate CHARSET REQUEST when both have WILL/DO."""
209209
# Server side
210210
ws, ts, _ = new_writer(server=True)
211-
ws.set_ext_send_callback(CHARSET, lambda: ["UTF-8", "ASCII"])
211+
ws.set_ext_offer_callback(CHARSET, lambda: ["UTF-8", "ASCII"])
212212

213213
# Client side
214214
wc, tc, _ = new_writer(server=False, client=True)
215-
wc.set_ext_send_callback(CHARSET, lambda: ["UTF-8"])
215+
wc.set_ext_offer_callback(CHARSET, lambda: ["UTF-8"])
216216

217217
# Simulate full negotiation: server DO, client WILL, server WILL, client DO
218218
ws.remote_option[CHARSET] = True # client sent WILL
@@ -234,7 +234,7 @@ def test_charset_request_response_cycle():
234234
# Server initiates REQUEST
235235
ws, ts, _ = new_writer(server=True)
236236
ws.remote_option[CHARSET] = True
237-
ws.set_ext_send_callback(CHARSET, lambda: ["UTF-8", "ASCII"])
237+
ws.set_ext_offer_callback(CHARSET, lambda: ["UTF-8", "ASCII"])
238238

239239
assert ws.request_charset() is True
240240
request_frame = ts.writes[-1]
@@ -265,7 +265,7 @@ def test_server_sends_will_charset_after_client_will():
265265
# Verify server also called request_charset as usual
266266
# (this is tested by checking if it would send a request,
267267
# but we need to set up the callback first)
268-
ws.set_ext_send_callback(CHARSET, lambda: ["UTF-8"])
268+
ws.set_ext_offer_callback(CHARSET, lambda: ["UTF-8"])
269269
# Clear previous writes to test just the request
270270
ts.writes.clear()
271271

@@ -401,3 +401,67 @@ def test_charset_accepted_sets_force_binary_on_accepting_side():
401401

402402
assert w.environ_encoding == "UTF-8"
403403
assert p.force_binary is True
404+
405+
406+
def test_client_request_charset_uses_offer_callback():
407+
"""Client request_charset() must use offer callback, not send callback."""
408+
wc, tc, _ = new_writer(server=False, client=True)
409+
wc.local_option[CHARSET] = True
410+
wc.remote_option[CHARSET] = True
411+
412+
wc.set_ext_offer_callback(CHARSET, lambda: ["UTF-8", "CP437"])
413+
wc.set_ext_send_callback(CHARSET, lambda offered: offered[0])
414+
415+
assert wc.request_charset() is True
416+
frame = tc.writes[-1]
417+
assert frame.startswith(IAC + SB + CHARSET + REQUEST)
418+
assert b"UTF-8" in frame
419+
assert b"CP437" in frame
420+
421+
422+
def test_server_request_charset_uses_offer_callback():
423+
"""Server request_charset() must use offer callback, not send callback."""
424+
ws, ts, _ = new_writer(server=True)
425+
ws.remote_option[CHARSET] = True
426+
427+
ws.set_ext_offer_callback(CHARSET, lambda: ["UTF-8", "ASCII"])
428+
ws.set_ext_send_callback(CHARSET, lambda offered: offered[0])
429+
430+
assert ws.request_charset() is True
431+
frame = ts.writes[-1]
432+
assert frame.startswith(IAC + SB + CHARSET + REQUEST)
433+
assert b"UTF-8" in frame
434+
435+
436+
def test_handle_sb_charset_request_uses_send_callback():
437+
"""Receiving SB CHARSET REQUEST must use send callback (not offer)."""
438+
wc, tc, _ = new_writer(server=False, client=True)
439+
wc.local_option[CHARSET] = True
440+
wc.remote_option[CHARSET] = True
441+
442+
offer_called = []
443+
wc.set_ext_offer_callback(CHARSET, lambda: offer_called.append(True) or ["NOPE"])
444+
wc.set_ext_send_callback(CHARSET, lambda offered: "UTF-8")
445+
446+
sep = b" "
447+
buf = collections.deque([CHARSET, REQUEST, sep, b"UTF-8"])
448+
wc._handle_sb_charset(buf)
449+
450+
assert not offer_called
451+
assert wc.environ_encoding == "UTF-8"
452+
453+
454+
def test_server_handle_sb_charset_request_uses_send_callback():
455+
"""Server receiving SB CHARSET REQUEST from client uses send callback."""
456+
ws, ts, _ = new_writer(server=True)
457+
ws.remote_option[CHARSET] = True
458+
ws.local_option[CHARSET] = True
459+
460+
ws.set_ext_offer_callback(CHARSET, lambda: ["SHOULD-NOT-USE"])
461+
ws.set_ext_send_callback(CHARSET, lambda offered: "CP437" if "CP437" in offered else "")
462+
463+
sep = b" "
464+
buf = collections.deque([CHARSET, REQUEST, sep, b"CP437"])
465+
ws._handle_sb_charset(buf)
466+
467+
assert ws.environ_encoding == "CP437"

telnetlib3/tests/test_mccp.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def test_sb_mccp2_sets_activated_flag(self):
141141
def test_sb_mccp2_calls_ext_callback(self):
142142
w, _t, _p = new_writer(server=False, client=True)
143143
received = []
144-
w.set_ext_callback(MCCP2_COMPRESS, lambda val: received.append(val))
144+
w.set_ext_callback(MCCP2_COMPRESS, received.append)
145145
w.pending_option[SB + MCCP2_COMPRESS] = True
146146
buf = collections.deque([MCCP2_COMPRESS])
147147
w.handle_subnegotiation(buf)
@@ -358,7 +358,7 @@ async def test_client_corrupt_mccp2_drops_data(self):
358358

359359
# Decompressor should be disabled, corrupt data not fed to reader
360360
assert client._mccp2_decompressor is None
361-
assert received == []
361+
assert not received
362362

363363
async def test_server_corrupt_mccp3_drops_data(self):
364364
"""Corrupt MCCP3 data is discarded, not fed to IAC parser."""
@@ -379,7 +379,7 @@ async def test_server_corrupt_mccp3_drops_data(self):
379379
server.data_received(b"\x00\x01\x02\x03\xff\xfe\xfd")
380380

381381
assert server._mccp3_decompressor is None
382-
assert received == []
382+
assert not received
383383

384384

385385
@pytest.mark.asyncio
@@ -500,7 +500,7 @@ async def test_mccp3_end_skips_write_when_closing(self):
500500

501501
assert client._mccp3_compressor is None
502502
# No final flush written because transport is closing
503-
assert transport.writes == []
503+
assert not transport.writes
504504

505505
async def test_mccp3_end_noop_when_inactive(self):
506506
"""_mccp3_end is safe to call when compression is not active."""

0 commit comments

Comments
 (0)