telnetlib3 4.0.4__tar.gz → 5.0.0__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 (122) hide show
  1. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/PKG-INFO +1 -1
  2. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/history.rst +36 -0
  3. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/rfcs.rst +6 -2
  4. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/pyproject.toml +1 -1
  5. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/_session_context.py +7 -0
  6. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/accessories.py +1 -1
  7. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/client.py +99 -46
  8. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/client_base.py +24 -4
  9. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/client_shell.py +2 -0
  10. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/mud.py +19 -0
  11. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/server_base.py +16 -2
  12. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/server_fingerprinting.py +22 -15
  13. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/stream_writer.py +160 -35
  14. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_client_shell.py +3 -2
  15. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_client_unit.py +175 -0
  16. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_mccp.py +65 -0
  17. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_mud.py +26 -0
  18. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_mud_negotiation.py +20 -8
  19. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_server_fingerprinting.py +45 -8
  20. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_stream_writer_full.py +57 -0
  21. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_writer.py +41 -0
  22. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/.gitignore +0 -0
  23. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/CONTRIBUTING.rst +0 -0
  24. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/LICENSE.txt +0 -0
  25. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/README.rst +0 -0
  26. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/Makefile +0 -0
  27. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api/accessories.rst +0 -0
  28. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api/client.rst +0 -0
  29. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api/client_base.rst +0 -0
  30. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api/client_shell.rst +0 -0
  31. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api/client_shell_win32.rst +0 -0
  32. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api/fingerprinting.rst +0 -0
  33. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api/guard_shells.rst +0 -0
  34. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api/mud.rst +0 -0
  35. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api/server.rst +0 -0
  36. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api/server_base.rst +0 -0
  37. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api/server_pty_shell.rst +0 -0
  38. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api/server_shell.rst +0 -0
  39. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api/session_context.rst +0 -0
  40. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api/slc.rst +0 -0
  41. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api/stream_reader.rst +0 -0
  42. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api/stream_writer.rst +0 -0
  43. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api/sync.rst +0 -0
  44. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api/telnetlib.rst +0 -0
  45. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api/telopt.rst +0 -0
  46. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/api.rst +0 -0
  47. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/conf.py +0 -0
  48. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/contributing.rst +0 -0
  49. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/guidebook.rst +0 -0
  50. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/index.rst +0 -0
  51. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/intro.rst +0 -0
  52. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/make.bat +0 -0
  53. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/docs/sphinxext/github.py +0 -0
  54. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/requirements-analysis.txt +0 -0
  55. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/requirements-docs.txt +0 -0
  56. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/requirements-tests.txt +0 -0
  57. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/requirements.txt +0 -0
  58. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib.py +0 -0
  59. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/.gitignore +0 -0
  60. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/__init__.py +0 -0
  61. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/_base.py +0 -0
  62. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/_paths.py +0 -0
  63. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/_types.py +0 -0
  64. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/client_shell_win32.py +0 -0
  65. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/encodings/__init__.py +0 -0
  66. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/encodings/atarist.py +0 -0
  67. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/encodings/atascii.py +0 -0
  68. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/encodings/big5bbs.py +0 -0
  69. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/encodings/petscii.py +0 -0
  70. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/fingerprinting.py +0 -0
  71. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/fingerprinting_display.py +0 -0
  72. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/guard_shells.py +0 -0
  73. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/py.typed +0 -0
  74. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/relay_server.py +0 -0
  75. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/server.py +0 -0
  76. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/server_pty_shell.py +0 -0
  77. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/server_shell.py +0 -0
  78. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/slc.py +0 -0
  79. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/stream_reader.py +0 -0
  80. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/sync.py +0 -0
  81. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/telnetlib.py +0 -0
  82. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/telopt.py +0 -0
  83. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/accessories.py +0 -0
  84. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/conftest.py +0 -0
  85. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/pty_helper.py +0 -0
  86. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_accessories.py +0 -0
  87. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_accessories_extra.py +0 -0
  88. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_atascii_codec.py +0 -0
  89. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_benchmarks.py +0 -0
  90. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_big5bbs_codec.py +0 -0
  91. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_charset.py +0 -0
  92. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_client_shell_win32.py +0 -0
  93. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_core.py +0 -0
  94. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_encoding.py +0 -0
  95. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_environ.py +0 -0
  96. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_fingerprinting.py +0 -0
  97. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_guard_integration.py +0 -0
  98. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_linemode.py +0 -0
  99. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_naws.py +0 -0
  100. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_petscii_codec.py +0 -0
  101. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_pty_shell.py +0 -0
  102. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_reader.py +0 -0
  103. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_relay_server.py +0 -0
  104. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_server.py +0 -0
  105. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_server_api.py +0 -0
  106. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_server_cli.py +0 -0
  107. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_server_mud.py +0 -0
  108. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_server_shell_unit.py +0 -0
  109. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_shell.py +0 -0
  110. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_slc.py +0 -0
  111. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_status_logger.py +0 -0
  112. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_stream_reader_extra.py +0 -0
  113. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_sync.py +0 -0
  114. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_telnetlib.py +0 -0
  115. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_telnetlib_import.py +0 -0
  116. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_timeout.py +0 -0
  117. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_tls.py +0 -0
  118. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_tspeed.py +0 -0
  119. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_ttype.py +0 -0
  120. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_uvloop_integration.py +0 -0
  121. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/telnetlib3/tests/test_xdisploc.py +0 -0
  122. {telnetlib3-4.0.4 → telnetlib3-5.0.0}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telnetlib3
3
- Version: 4.0.4
3
+ Version: 5.0.0
4
4
  Summary: Python Telnet server and client CLI and Protocol library
5
5
  Project-URL: Homepage, https://github.com/jquast/telnetlib3
6
6
  Project-URL: Documentation, https://telnetlib3.readthedocs.io
@@ -1,5 +1,41 @@
1
1
  History
2
2
  =======
3
+
4
+ 5.0.0
5
+ * changed: :meth:`~telnetlib3.stream_writer.TelnetWriter.handle_zmp` now receives ``command,
6
+ *args`` instead of one ``parts`` list; ``zmp_data`` moved to ``writer.ctx`` and is now a dict
7
+ keyed by command (was a list of messages). New
8
+ :meth:`~telnetlib3.stream_writer.TelnetWriter.send_zmp`.
9
+ * changed: MUD protocol subnegotiation data (``mssp_data``, ``atcp_data``, ``aardwolf_data``,
10
+ ``mxp_data``, ``comport_data``) moved from :class:`~telnetlib3.stream_writer.TelnetWriter` to
11
+ :class:`~telnetlib3._session_context.TelnetSessionContext` (``writer.ctx.mssp_data``, etc.).
12
+ Deprecated writer properties delegate to ``ctx``; ``writer.zmp_data`` is removed without
13
+ deprecation.
14
+ * changed: client-side MUD protocol declines (GMCP, MSDP, MSSP, MSP, MXP, ZMP, AARDWOLF, ATCP)
15
+ remain the default; the decline log messages now name the enable mechanism (``always_will`` /
16
+ ``always_do`` / ``passive_do``) used by downstream clients such as telix to accept them. Note
17
+ that MXP's negotiation direction is server-sends-``DO`` (the LPMud family convention, e.g.
18
+ Discworld), so a client accepting MXP replies ``WILL`` to ``IAC DO MXP``.
19
+ :meth:`~telnetlib3.stream_writer.TelnetWriter.remove_will_callback` for per-option callbacks
20
+ invoked after :meth:`~telnetlib3.stream_writer.TelnetWriter.handle_will` negotiation. Replaces
21
+ the previous
22
+ closure-wrapping pattern in :class:`~telnetlib3.client.TelnetClient` for GMCP, ZMP, and CHARSET
23
+ will-detection.
24
+ * enhancement: ``telnetlib3-fingerprint`` now accepts all MUD protocol offers (ATCP, AARDWOLF,
25
+ MSP, MXP, MSDP, MSSP) to collect subnegotiation data.
26
+ * enhancement: sub-negotiation payloads are bounded to 1,000KB.
27
+ * enhancement: ``--loglevel=trace`` receive dumps show the decompressed telnet stream (MCCP2,
28
+ MCCP3) instead of raw compressed bytes.
29
+ * bugfix: ``IAC SB IAC SE`` (sub-negotiation with no option byte) should not raise ``IndexError``
30
+ * new: :meth:`~telnetlib3.stream_writer.TelnetWriter.add_will_callback` and
31
+
32
+
33
+ 4.0.6
34
+ * bugfix: default GMCP modules requested are now in lowercase instead of titlecase
35
+
36
+ 4.0.5
37
+ * enhancement: ``telnetlib3-client`` client shell now drains stdout.
38
+
3
39
  4.0.4
4
40
  * bugfix: servers using ``robot_check=True`` with ``encoding=False`` raised ``TypeError: buf
5
41
  expected bytes, got <class 'str'>``. ``telnetlib3-server`` now also accepts ``--encoding=False``
@@ -77,6 +77,9 @@ Dungeon) servers and clients.
77
77
  * `GMCP`_ (Generic MUD Communication Protocol, option 201). JSON-based
78
78
  bidirectional messaging for game data such as room info, character vitals,
79
79
  and client metadata.
80
+ * `ZMP`_ (Zenith Mud Protocol, option 93). Bidirectional messaging of
81
+ NUL-delimited string lists (a command plus arguments), carrying data such
82
+ as room info and character vitals.
80
83
  * `MSDP`_ (MUD Server Data Protocol, option 69). Structured key-value protocol
81
84
  for game variables with support for nested tables and arrays.
82
85
  * `MSSP`_ (MUD Server Status Protocol, option 70). Server metadata protocol
@@ -93,13 +96,14 @@ Dungeon) servers and clients.
93
96
  .. _MSSP: https://tintin.mudhalla.net/protocols/mssp/
94
97
  .. _MCCP2: https://tintin.mudhalla.net/protocols/mccp/
95
98
  .. _MCCP3: https://tintin.mudhalla.net/protocols/mccp/
99
+ .. _ZMP: https://discworld.starturtle.net/external/protocols/zmp.html
96
100
 
97
101
  MUDs Not Implemented
98
102
  --------------------
99
103
 
100
104
  Constants are also defined for the following MUD options, though their handlers
101
- are not implemented: MCCP (85, legacy compression), MXP (91, markup), ZMP
102
- (93, messaging), MSP (90, sound), and ATCP (200, Achaea-specific).
105
+ are not implemented: MCCP (85, legacy compression), MXP (91, markup), MSP (90,
106
+ sound), and ATCP (200, Achaea-specific).
103
107
 
104
108
  Additional Resources
105
109
  --------------------
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "telnetlib3"
7
- version = "4.0.4" # Keep in sync with telnetlib3/accessories.py::get_version !
7
+ version = "5.0.0" # Keep in sync with telnetlib3/accessories.py::get_version !
8
8
  description = " Python Telnet server and client CLI and Protocol library"
9
9
  readme = "README.rst"
10
10
  license = "ISC"
@@ -53,3 +53,10 @@ class TelnetSessionContext:
53
53
  self.autoreply_wait_fn = autoreply_wait_fn
54
54
  self.typescript_file = typescript_file
55
55
  self.gmcp_data: dict[str, Any] = gmcp_data if gmcp_data is not None else {}
56
+ self.zmp_data: dict[str, list[str]] = {}
57
+ # MUD protocol data moved from TelnetWriter to ctx for consistency
58
+ self.mssp_data: Optional[dict[str, str | list[str]]] = None
59
+ self.atcp_data: list[tuple[str, str]] = []
60
+ self.aardwolf_data: list[dict[str, Any]] = []
61
+ self.mxp_data: list[bytes] = []
62
+ self.comport_data: Optional[dict[str, Any]] = None
@@ -42,7 +42,7 @@ PATIENCE_MESSAGES = [
42
42
 
43
43
  def get_version() -> str:
44
44
  """Return the current version of telnetlib3."""
45
- return "4.0.4" # keep in sync with pyproject.toml !
45
+ return "5.0.0" # keep in sync with pyproject.toml !
46
46
 
47
47
 
48
48
  def encoding_from_lang(lang: str) -> Optional[str]:
@@ -12,7 +12,7 @@ import struct
12
12
  import asyncio
13
13
  import argparse
14
14
  import functools
15
- from typing import Any, Dict, List, Tuple, Union, Callable, Optional, Sequence
15
+ from typing import Any, Dict, List, Tuple, Union, Callable, Optional, Sequence, Collection
16
16
 
17
17
  # local
18
18
  from telnetlib3 import accessories, client_base
@@ -26,14 +26,14 @@ __all__ = ("TelnetClient", "TelnetTerminalClient", "open_connection")
26
26
  #: Sub-modules are listed explicitly because not all servers treat
27
27
  #: top-level subscriptions as wildcards.
28
28
  _DEFAULT_GMCP_MODULES = [
29
- "Char 1",
30
- "Char.Vitals 1",
31
- "Char.Items 1",
32
- "Room 1",
33
- "Room.Info 1",
34
- "Comm 1",
35
- "Comm.Channel 1",
36
- "Group 1",
29
+ "char 1",
30
+ "char.vitals 1",
31
+ "char.items 1",
32
+ "room 1",
33
+ "room.info 1",
34
+ "comm 1",
35
+ "comm.channel 1",
36
+ "group 1",
37
37
  ]
38
38
 
39
39
 
@@ -73,6 +73,8 @@ class TelnetClient(client_base.BaseClient):
73
73
  waiter_closed: Optional[asyncio.Future[None]] = None,
74
74
  _waiter_connected: Optional[asyncio.Future[None]] = None,
75
75
  gmcp_modules: Optional[List[str]] = None,
76
+ zmp_check_handler: Optional[Callable[[str], bool]] = None,
77
+ zmp_supported_commands: Optional[Collection[str]] = None,
76
78
  ) -> None:
77
79
  """Initialize TelnetClient with terminal parameters."""
78
80
  self._compression = compression
@@ -89,6 +91,11 @@ class TelnetClient(client_base.BaseClient):
89
91
  )
90
92
  self._gmcp_modules = gmcp_modules or list(_DEFAULT_GMCP_MODULES)
91
93
  self._gmcp_hello_sent = False
94
+ self._zmp_check_handler = zmp_check_handler # None means refuse all
95
+ self._zmp_ident_sent = False
96
+ self._zmp_supported_commands = (
97
+ set(zmp_supported_commands) if zmp_supported_commands else set()
98
+ )
92
99
  self._send_environ = set(send_environ or self.DEFAULT_SEND_ENVIRON)
93
100
  self._extra.update(
94
101
  {
@@ -144,61 +151,74 @@ class TelnetClient(client_base.BaseClient):
144
151
  ):
145
152
  self.writer.set_ext_offer_callback(opt, offer_func)
146
153
 
147
- # Override the default handle_will method to detect when both sides support CHARSET
148
- # Store the original only on first connection to prevent chain growth on reconnect.
149
- if not hasattr(self.writer, "_original_handle_will"):
150
- self.writer._original_handle_will = self.writer.handle_will
151
- else:
152
- self.writer.handle_will = ( # type: ignore[method-assign]
153
- self.writer._original_handle_will
154
- )
155
- original_handle_will = self.writer.handle_will
156
- writer = self.writer
154
+ self.writer.add_will_callback(CHARSET, self.on_will_charset)
157
155
 
158
- def enhanced_handle_will(opt: bytes) -> None:
159
- original_handle_will(opt)
156
+ self.setup_gmcp()
157
+ self.setup_zmp()
160
158
 
161
- # If this was a WILL CHARSET from the server, and we also have WILL CHARSET enabled,
162
- # log that both sides support CHARSET. The server should initiate the actual REQUEST.
163
- if (
164
- opt == CHARSET
165
- and writer.remote_option.enabled(CHARSET)
166
- and writer.local_option.enabled(CHARSET)
167
- ):
168
- self.log.debug("Both sides support CHARSET, ready for server to initiate REQUEST")
159
+ def on_will_charset(self, opt: bytes) -> None:
160
+ """Log when both sides support CHARSET after WILL negotiation."""
161
+ from telnetlib3.telopt import CHARSET
169
162
 
170
- self.writer.handle_will = enhanced_handle_will # type: ignore[method-assign]
163
+ if self.writer.remote_option.enabled(CHARSET) and self.writer.local_option.enabled(CHARSET):
164
+ self.log.debug("Both sides support CHARSET, ready for server to initiate REQUEST")
171
165
 
172
- self._setup_gmcp()
173
-
174
- def _setup_gmcp(self) -> None:
166
+ def setup_gmcp(self) -> None:
175
167
  """Wire GMCP callback and WILL-detection for Core.Hello handshake."""
176
168
  from telnetlib3.telopt import GMCP
177
169
 
170
+ self.writer.passive_do.add(GMCP)
178
171
  self.writer.set_ext_callback(GMCP, self.on_gmcp)
172
+ self.writer.add_will_callback(GMCP, self.on_will_gmcp)
179
173
 
180
- # Capture current handle_will (already includes CHARSET wrapper).
181
- # On reconnect, _original_handle_will was already restored in connection_made,
182
- # so this always wraps exactly once.
183
- original_handle_will_gmcp = self.writer.handle_will
174
+ def on_will_gmcp(self, opt: bytes) -> None:
175
+ """Send Core.Hello after GMCP negotiation."""
176
+ from telnetlib3.telopt import GMCP
184
177
 
185
- def _detect_gmcp_will(opt: bytes) -> None:
186
- original_handle_will_gmcp(opt)
187
- if opt == GMCP and self.writer.remote_option.enabled(GMCP):
188
- self._send_gmcp_hello()
178
+ enabled = self.writer.remote_option.enabled(GMCP)
179
+ hello_sent = self._gmcp_hello_sent
180
+ self.log.debug("on_will_gmcp: remote_enabled=%s _gmcp_hello_sent=%s", enabled, hello_sent)
181
+ if enabled:
182
+ self.send_gmcp_hello()
189
183
 
190
- self.writer.handle_will = _detect_gmcp_will # type: ignore[method-assign]
184
+ def setup_zmp(self) -> None:
185
+ """Wire ZMP callback and WILL-detection for zmp.ident handshake."""
186
+ from telnetlib3.telopt import ZMP
191
187
 
192
- def _send_gmcp_hello(self) -> None:
188
+ self.writer.passive_do.add(ZMP)
189
+ self.writer.set_ext_callback(ZMP, self.on_zmp)
190
+ self.writer.add_will_callback(ZMP, self.on_will_zmp)
191
+
192
+ def on_will_zmp(self, opt: bytes) -> None:
193
+ """Send zmp.ident after ZMP negotiation."""
194
+ from telnetlib3.telopt import ZMP
195
+
196
+ if self.writer.remote_option.enabled(ZMP):
197
+ self.send_zmp_ident()
198
+
199
+ def send_gmcp_hello(self) -> None:
193
200
  """Send ``Core.Hello`` and ``Core.Supports.Set`` after GMCP negotiation."""
194
201
  if self._gmcp_hello_sent:
195
202
  return
196
203
  self._gmcp_hello_sent = True
197
204
  from telnetlib3.accessories import get_version
198
205
 
206
+ modules = [m.lower() for m in self._gmcp_modules]
199
207
  self.writer.send_gmcp("Core.Hello", {"client": "telnetlib3", "version": get_version()})
200
- self.writer.send_gmcp("Core.Supports.Set", self._gmcp_modules)
201
- self.log.info("GMCP handshake: Core.Hello + Core.Supports.Set %s", self._gmcp_modules)
208
+ self.writer.send_gmcp("Core.Supports.Set", modules)
209
+ self.log.info("GMCP handshake: Core.Hello + Core.Supports.Set %s", modules)
210
+
211
+ def send_zmp_ident(self) -> None:
212
+ """Send ``zmp.ident`` after ZMP negotiation."""
213
+ if self._zmp_ident_sent:
214
+ return
215
+ self._zmp_ident_sent = True
216
+ from telnetlib3.accessories import get_version
217
+
218
+ self.writer.send_zmp("zmp.ident", "telnetlib3", get_version())
219
+ self.log.info("ZMP handshake: zmp.ident telnetlib3 %s", get_version())
220
+ for cmd in sorted(self._zmp_supported_commands):
221
+ self.writer.send_zmp("zmp.support", cmd)
202
222
 
203
223
  def on_gmcp(self, package: str, data: Any) -> None:
204
224
  """Store incoming GMCP data on ``writer.ctx``, merging dict updates."""
@@ -209,6 +229,39 @@ class TelnetClient(client_base.BaseClient):
209
229
  gmcp[package] = data
210
230
  self.log.debug("GMCP: %s %r", package, data)
211
231
 
232
+ def on_zmp(self, command: str, *args: str) -> None:
233
+ """
234
+ Receive and dispatch a ZMP message.
235
+
236
+ Auto-responds to ``zmp.check`` and ``zmp.send-support``.
237
+ Stores latest value for each command in ``writer.ctx.zmp_data``.
238
+ """
239
+ self.log.debug("ZMP: %s %r", command, args)
240
+ self.writer.ctx.zmp_data[command] = list(args)
241
+ if command == "zmp.check" and args:
242
+ cmd = args[0]
243
+ self._respond_zmp_support(cmd)
244
+ elif command == "zmp.send-support":
245
+ if args:
246
+ for cmd in args:
247
+ self._respond_zmp_support(cmd)
248
+ elif not self._zmp_ident_sent:
249
+ for cmd in sorted(self._zmp_supported_commands):
250
+ self._respond_zmp_support(cmd)
251
+
252
+ def _respond_zmp_support(self, cmd: str) -> None:
253
+ """Send ``zmp.support`` or ``zmp.no-support`` for *cmd*."""
254
+ if self.zmp_check(cmd):
255
+ self.writer.send_zmp("zmp.support", cmd)
256
+ else:
257
+ self.writer.send_zmp("zmp.no-support", cmd)
258
+
259
+ def zmp_check(self, cmd: str) -> bool:
260
+ """Return True if the client supports the given ZMP command."""
261
+ if self._zmp_check_handler is not None:
262
+ return self._zmp_check_handler(cmd)
263
+ return False
264
+
212
265
  def send_ttype(self) -> str:
213
266
  """Callback for responding to TTYPE requests."""
214
267
  result: str = self._extra["term"]
@@ -688,7 +741,7 @@ async def run_client() -> None:
688
741
  client.writer.always_dont = always_dont
689
742
  from .telopt import GMCP as _GMCP
690
743
 
691
- client.writer.passive_do = {_GMCP}
744
+ client.writer.passive_do.add(_GMCP)
692
745
  client.writer.environ_encoding = environ_encoding
693
746
  client.writer._encoding_explicit = encoding_explicit
694
747
 
@@ -226,8 +226,6 @@ class BaseClient(TelnetProtocolBase, asyncio.streams.FlowControlMixin, asyncio.P
226
226
  Buffer incoming data and schedule async processing to keep the event loop responsive. Apply
227
227
  read-side backpressure using transport.pause_reading()/resume_reading().
228
228
  """
229
- if self.log.isEnabledFor(TRACE):
230
- self.log.log(TRACE, "recv %d bytes\n%s", len(data), hexdump(data, prefix="<< "))
231
229
  self._last_received = datetime.datetime.now()
232
230
 
233
231
  # Detect SyncTERM font switching sequences and auto-switch encoding.
@@ -354,6 +352,19 @@ class BaseClient(TelnetProtocolBase, asyncio.streams.FlowControlMixin, asyncio.P
354
352
 
355
353
  # private methods
356
354
 
355
+ def _trace_recv(self, data: bytes, decompressed: bool = False) -> None:
356
+ """
357
+ Log received bytes at TRACE level.
358
+
359
+ :param data: Bytes received from the transport.
360
+ :param decompressed: True when *data* is the MCCP2-decompressed telnet stream; the raw
361
+ compressed bytes are not logged.
362
+ """
363
+ if not data or not self.log.isEnabledFor(TRACE):
364
+ return
365
+ label = " (decompressed)" if decompressed else ""
366
+ self.log.log(TRACE, "recv %d bytes%s\n%s", len(data), label, hexdump(data, prefix="<< "))
367
+
357
368
  def _process_chunk(self, data: bytes) -> bool:
358
369
  """Process a chunk of received bytes; return True if any IAC/SB cmd observed."""
359
370
  self._last_received = datetime.datetime.now()
@@ -380,14 +391,15 @@ class BaseClient(TelnetProtocolBase, asyncio.streams.FlowControlMixin, asyncio.P
380
391
  if self._mccp2_decompressor.eof:
381
392
  unused = self._mccp2_decompressor.unused_data
382
393
  self._mccp2_end()
383
- cmd = self._process_chunk_inner(data)
394
+ cmd = self._process_chunk_inner(data, decompressed=True)
384
395
  if unused:
385
396
  cmd = self._process_chunk(unused) or cmd
386
397
  return cmd
398
+ return self._process_chunk_inner(data, decompressed=True)
387
399
 
388
400
  return self._process_chunk_inner(data)
389
401
 
390
- def _process_chunk_inner(self, data: bytes) -> bool:
402
+ def _process_chunk_inner(self, data: bytes, decompressed: bool = False) -> bool:
391
403
  """Inner chunk processing with IAC interpretation and mid-chunk MCCP2 detection."""
392
404
  try:
393
405
  mode = self.writer.mode
@@ -408,9 +420,17 @@ class BaseClient(TelnetProtocolBase, asyncio.streams.FlowControlMixin, asyncio.P
408
420
  if self.writer._compressed_remainder is not None:
409
421
  remainder = self.writer._compressed_remainder
410
422
  self.writer._compressed_remainder = None
423
+ if not decompressed:
424
+ # The chunk activating MCCP2 mixes plain telnet and compressed
425
+ # bytes; trace only the plain prefix here, the compressed
426
+ # remainder is traced after decompression by the recursive
427
+ # _process_chunk() call.
428
+ self._trace_recv(data[: len(data) - len(remainder)])
411
429
  self._mccp2_start()
412
430
  if remainder:
413
431
  cmd_received = self._process_chunk(remainder) or cmd_received
432
+ else:
433
+ self._trace_recv(data, decompressed=decompressed)
414
434
 
415
435
  # MCCP3: start compressor when writer signals activation
416
436
  if self.writer.mccp3_active and self._mccp3_compressor is None:
@@ -579,6 +579,8 @@ async def _raw_event_loop(
579
579
  if raw_mode is None and want_repl():
580
580
  state.reactivate_repl = True
581
581
  stdout.write(out.encode())
582
+ if hasattr(stdout, "drain"):
583
+ await stdout.drain()
582
584
  _ts_file = telnet_writer.ctx.typescript_file
583
585
  if _ts_file is not None:
584
586
  _ts_file.write(out)
@@ -40,6 +40,7 @@ __all__ = (
40
40
  "mssp_encode",
41
41
  "mssp_decode",
42
42
  "MsdpParser",
43
+ "zmp_encode",
43
44
  "zmp_decode",
44
45
  "atcp_decode",
45
46
  "aardwolf_decode",
@@ -276,6 +277,24 @@ def mssp_decode(buf: bytes, encoding: str = "utf-8") -> dict[str, str | list[str
276
277
  return result
277
278
 
278
279
 
280
+ def zmp_encode(command: str, *args: str) -> bytes:
281
+ r"""
282
+ Encode a ZMP message.
283
+
284
+ :param command: ZMP command name (e.g., ``"zmp.ident"``).
285
+ :param args: Zero or more argument strings.
286
+ :returns: NUL-delimited payload bytes suitable for ZMP subnegotiation.
287
+
288
+ Example::
289
+
290
+ >>> zmp_encode("zmp.ident", "telnetlib3", "1.0")
291
+ b'zmp.ident\\x00telnetlib3\\x001.0\\x00'
292
+ """
293
+ parts = [command.encode("utf-8")]
294
+ parts.extend(a.encode("utf-8") for a in args)
295
+ return b"\x00".join(parts) + b"\x00"
296
+
297
+
279
298
  def zmp_decode(buf: bytes, encoding: str = "utf-8") -> list[str]:
280
299
  """
281
300
  Decode ZMP payload to list of NUL-delimited strings.
@@ -188,14 +188,25 @@ class BaseServer(TelnetProtocolBase, asyncio.streams.FlowControlMixin, asyncio.P
188
188
  loop = asyncio.get_event_loop()
189
189
  loop.create_task(coro)
190
190
 
191
+ def _trace_recv(self, data: bytes, decompressed: bool = False) -> None:
192
+ """
193
+ Log received bytes at TRACE level.
194
+
195
+ :param data: Bytes received from the transport.
196
+ :param decompressed: True when *data* is the MCCP3-decompressed telnet stream; the raw
197
+ compressed bytes are not logged.
198
+ """
199
+ if not data or not logger.isEnabledFor(TRACE):
200
+ return
201
+ label = " (decompressed)" if decompressed else ""
202
+ logger.log(TRACE, "recv %d bytes%s\n%s", len(data), label, hexdump(data, prefix="<< "))
203
+
191
204
  def data_received(self, data: bytes) -> None:
192
205
  """
193
206
  Process bytes received by transport.
194
207
 
195
208
  Feeds raw bytes through the writer's IAC interpreter, forwarding in-band data to the reader.
196
209
  """
197
- if logger.isEnabledFor(TRACE):
198
- logger.log(TRACE, "recv %d bytes\n%s", len(data), hexdump(data, prefix="<< "))
199
210
  self._last_received = datetime.datetime.now()
200
211
  self._rx_bytes += len(data)
201
212
 
@@ -207,12 +218,15 @@ class BaseServer(TelnetProtocolBase, asyncio.streams.FlowControlMixin, asyncio.P
207
218
  logger.warning("MCCP3 decompression error, disabling")
208
219
  self._mccp3_end()
209
220
  return
221
+ self._trace_recv(data, decompressed=True)
210
222
  if self._mccp3_decompressor.eof:
211
223
  unused = self._mccp3_decompressor.unused_data
212
224
  self._mccp3_end()
213
225
  if unused:
214
226
  self.data_received(unused)
215
227
  return
228
+ else:
229
+ self._trace_recv(data)
216
230
 
217
231
  if self.writer.slc_simulated:
218
232
  slc_vals = {defn.val[0] for defn in self.writer.slctab.values() if defn.val != theNULL}
@@ -513,6 +513,13 @@ async def _fingerprint_session(
513
513
  start_time = time.time()
514
514
  cursor = _VirtualCursor(encoding=writer.environ_encoding)
515
515
 
516
+ # Accept all MUD protocol WILL requests so we can collect
517
+ # subnegotiation data (ATCP, AARDWOLF, MXP, MSP, etc.).
518
+ from telnetlib3.telopt import MSP, MXP, ATCP, MSDP, MSSP, AARDWOLF
519
+
520
+ for opt in (ATCP, AARDWOLF, MSP, MXP, MSDP, MSSP):
521
+ writer.passive_do.add(opt)
522
+
516
523
  # 1. Let straggler negotiation settle -- read (and respond to DSR)
517
524
  # instead of sleeping blind so early DSR requests get a CPR reply.
518
525
  settle_data = await _read_banner_until_quiet(
@@ -629,8 +636,8 @@ async def _fingerprint_session(
629
636
  "dsr_replies": cursor.dsr_replies,
630
637
  }
631
638
  )
632
- if writer.mssp_data is not None:
633
- session_data["mssp"] = writer.mssp_data
639
+ if writer.ctx.mssp_data is not None:
640
+ session_data["mssp"] = writer.ctx.mssp_data
634
641
  session_data.update(_collect_mud_data(writer))
635
642
 
636
643
  session_entry: dict[str, Any] = {
@@ -966,18 +973,18 @@ def _create_server_protocol_fingerprint(
966
973
 
967
974
 
968
975
  def _collect_mud_data(writer: TelnetWriter) -> dict[str, Any]:
969
- """Collect MUD protocol data from *writer* into a dict."""
976
+ """Collect MUD protocol data from *writer.ctx* into a dict."""
970
977
  result: dict[str, Any] = {}
971
- if writer.zmp_data:
972
- result["zmp"] = writer.zmp_data
973
- if writer.atcp_data:
974
- result["atcp"] = [{"package": pkg, "value": val} for pkg, val in writer.atcp_data]
975
- if writer.aardwolf_data:
976
- result["aardwolf"] = writer.aardwolf_data
977
- if writer.mxp_data:
978
- result["mxp"] = [d.hex() if d else "activated" for d in writer.mxp_data]
979
- if writer.comport_data:
980
- result["comport"] = writer.comport_data
978
+ if writer.ctx.zmp_data:
979
+ result["zmp"] = writer.ctx.zmp_data
980
+ if writer.ctx.atcp_data:
981
+ result["atcp"] = [{"package": pkg, "value": val} for pkg, val in writer.ctx.atcp_data]
982
+ if writer.ctx.aardwolf_data:
983
+ result["aardwolf"] = writer.ctx.aardwolf_data
984
+ if writer.ctx.mxp_data:
985
+ result["mxp"] = [d.hex() if d else "activated" for d in writer.ctx.mxp_data]
986
+ if writer.ctx.comport_data:
987
+ result["comport"] = writer.ctx.comport_data
981
988
  return result
982
989
 
983
990
 
@@ -1099,10 +1106,10 @@ def _format_banner(data: bytes, encoding: str = "utf-8") -> str:
1099
1106
 
1100
1107
  async def _await_mssp_data(writer: TelnetWriter, deadline: float) -> None:
1101
1108
  """Wait for MSSP data until *deadline* if server acknowledged MSSP."""
1102
- if not writer.remote_option.enabled(MSSP) or writer.mssp_data is not None:
1109
+ if not writer.remote_option.enabled(MSSP) or writer.ctx.mssp_data is not None:
1103
1110
  return
1104
1111
  remaining = deadline - time.time()
1105
- while remaining > 0 and writer.mssp_data is None:
1112
+ while remaining > 0 and writer.ctx.mssp_data is None:
1106
1113
  await asyncio.sleep(min(0.05, remaining))
1107
1114
  remaining = deadline - time.time()
1108
1115