telnetlib3 5.0.0__tar.gz → 5.0.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 (122) hide show
  1. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/PKG-INFO +2 -2
  2. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/history.rst +17 -4
  3. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/pyproject.toml +1 -1
  4. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/accessories.py +33 -1
  5. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/client.py +5 -3
  6. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/guard_shells.py +5 -1
  7. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/server_shell.py +5 -1
  8. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/stream_reader.py +85 -20
  9. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_client_unit.py +74 -3
  10. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_reader.py +123 -0
  11. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_server_shell_unit.py +22 -0
  12. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/.gitignore +0 -0
  13. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/CONTRIBUTING.rst +0 -0
  14. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/LICENSE.txt +0 -0
  15. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/README.rst +0 -0
  16. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/Makefile +0 -0
  17. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api/accessories.rst +0 -0
  18. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api/client.rst +0 -0
  19. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api/client_base.rst +0 -0
  20. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api/client_shell.rst +0 -0
  21. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api/client_shell_win32.rst +0 -0
  22. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api/fingerprinting.rst +0 -0
  23. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api/guard_shells.rst +0 -0
  24. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api/mud.rst +0 -0
  25. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api/server.rst +0 -0
  26. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api/server_base.rst +0 -0
  27. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api/server_pty_shell.rst +0 -0
  28. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api/server_shell.rst +0 -0
  29. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api/session_context.rst +0 -0
  30. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api/slc.rst +0 -0
  31. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api/stream_reader.rst +0 -0
  32. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api/stream_writer.rst +0 -0
  33. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api/sync.rst +0 -0
  34. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api/telnetlib.rst +0 -0
  35. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api/telopt.rst +0 -0
  36. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/api.rst +0 -0
  37. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/conf.py +0 -0
  38. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/contributing.rst +0 -0
  39. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/guidebook.rst +0 -0
  40. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/index.rst +0 -0
  41. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/intro.rst +0 -0
  42. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/make.bat +0 -0
  43. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/rfcs.rst +0 -0
  44. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/docs/sphinxext/github.py +0 -0
  45. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/requirements-analysis.txt +0 -0
  46. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/requirements-docs.txt +0 -0
  47. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/requirements-tests.txt +0 -0
  48. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/requirements.txt +0 -0
  49. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib.py +0 -0
  50. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/.gitignore +0 -0
  51. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/__init__.py +0 -0
  52. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/_base.py +0 -0
  53. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/_paths.py +0 -0
  54. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/_session_context.py +0 -0
  55. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/_types.py +0 -0
  56. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/client_base.py +0 -0
  57. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/client_shell.py +0 -0
  58. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/client_shell_win32.py +0 -0
  59. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/encodings/__init__.py +0 -0
  60. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/encodings/atarist.py +0 -0
  61. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/encodings/atascii.py +0 -0
  62. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/encodings/big5bbs.py +0 -0
  63. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/encodings/petscii.py +0 -0
  64. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/fingerprinting.py +0 -0
  65. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/fingerprinting_display.py +0 -0
  66. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/mud.py +0 -0
  67. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/py.typed +0 -0
  68. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/relay_server.py +0 -0
  69. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/server.py +0 -0
  70. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/server_base.py +0 -0
  71. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/server_fingerprinting.py +0 -0
  72. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/server_pty_shell.py +0 -0
  73. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/slc.py +0 -0
  74. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/stream_writer.py +0 -0
  75. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/sync.py +0 -0
  76. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/telnetlib.py +0 -0
  77. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/telopt.py +0 -0
  78. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/accessories.py +0 -0
  79. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/conftest.py +0 -0
  80. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/pty_helper.py +0 -0
  81. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_accessories.py +0 -0
  82. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_accessories_extra.py +0 -0
  83. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_atascii_codec.py +0 -0
  84. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_benchmarks.py +0 -0
  85. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_big5bbs_codec.py +0 -0
  86. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_charset.py +0 -0
  87. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_client_shell.py +0 -0
  88. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_client_shell_win32.py +0 -0
  89. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_core.py +0 -0
  90. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_encoding.py +0 -0
  91. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_environ.py +0 -0
  92. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_fingerprinting.py +0 -0
  93. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_guard_integration.py +0 -0
  94. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_linemode.py +0 -0
  95. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_mccp.py +0 -0
  96. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_mud.py +0 -0
  97. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_mud_negotiation.py +0 -0
  98. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_naws.py +0 -0
  99. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_petscii_codec.py +0 -0
  100. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_pty_shell.py +0 -0
  101. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_relay_server.py +0 -0
  102. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_server.py +0 -0
  103. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_server_api.py +0 -0
  104. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_server_cli.py +0 -0
  105. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_server_fingerprinting.py +0 -0
  106. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_server_mud.py +0 -0
  107. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_shell.py +0 -0
  108. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_slc.py +0 -0
  109. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_status_logger.py +0 -0
  110. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_stream_reader_extra.py +0 -0
  111. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_stream_writer_full.py +0 -0
  112. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_sync.py +0 -0
  113. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_telnetlib.py +0 -0
  114. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_telnetlib_import.py +0 -0
  115. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_timeout.py +0 -0
  116. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_tls.py +0 -0
  117. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_tspeed.py +0 -0
  118. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_ttype.py +0 -0
  119. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_uvloop_integration.py +0 -0
  120. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_writer.py +0 -0
  121. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/telnetlib3/tests/test_xdisploc.py +0 -0
  122. {telnetlib3-5.0.0 → telnetlib3-5.0.2}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: telnetlib3
3
- Version: 5.0.0
3
+ Version: 5.0.2
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,6 +1,20 @@
1
1
  History
2
2
  =======
3
3
 
4
+ 5.0.2
5
+ * bugfix: :meth:`~telnetlib3.stream_reader.TelnetReaderUnicode.read`,
6
+ :meth:`~telnetlib3.stream_reader.TelnetReaderUnicode.readline`, and
7
+ :meth:`~telnetlib3.stream_reader.TelnetReaderUnicode.readexactly` returned incorrect text when
8
+ a multibyte sequence ended on a packet boundary, and dropped text retained by the incremental
9
+ decoder at end of stream. ``readline()`` now finalizes a truncated character with the line it
10
+ belongs to, ``read(n)`` no longer returns more than *n* characters, and
11
+ ``encoding_errors="strict"`` raises :exc:`UnicodeDecodeError` at EOF.
12
+
13
+ 5.0.1
14
+ * bugfix: :func:`~telnetlib3.client.open_connection` raised ``AttributeError: 'NoneType' object
15
+ has no attribute 'isatty'`` in processes without a console, like ``pythonw.exe`` on Windows.
16
+
17
+
4
18
  5.0.0
5
19
  * changed: :meth:`~telnetlib3.stream_writer.TelnetWriter.handle_zmp` now receives ``command,
6
20
  *args`` instead of one ``parts`` list; ``zmp_data`` moved to ``writer.ctx`` and is now a dict
@@ -16,18 +30,17 @@ History
16
30
  ``always_do`` / ``passive_do``) used by downstream clients such as telix to accept them. Note
17
31
  that MXP's negotiation direction is server-sends-``DO`` (the LPMud family convention, e.g.
18
32
  Discworld), so a client accepting MXP replies ``WILL`` to ``IAC DO MXP``.
33
+ * new: :meth:`~telnetlib3.stream_writer.TelnetWriter.add_will_callback` and
19
34
  :meth:`~telnetlib3.stream_writer.TelnetWriter.remove_will_callback` for per-option callbacks
20
35
  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.
36
+ the previous closure-wrapping pattern in :class:`~telnetlib3.client.TelnetClient` for GMCP, ZMP,
37
+ and CHARSET will-detection.
24
38
  * enhancement: ``telnetlib3-fingerprint`` now accepts all MUD protocol offers (ATCP, AARDWOLF,
25
39
  MSP, MXP, MSDP, MSSP) to collect subnegotiation data.
26
40
  * enhancement: sub-negotiation payloads are bounded to 1,000KB.
27
41
  * enhancement: ``--loglevel=trace`` receive dumps show the decompressed telnet stream (MCCP2,
28
42
  MCCP3) instead of raw compressed bytes.
29
43
  * 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
44
 
32
45
 
33
46
  4.0.6
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "telnetlib3"
7
- version = "5.0.0" # Keep in sync with telnetlib3/accessories.py::get_version !
7
+ version = "5.0.2" # 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"
@@ -3,6 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  # std imports
6
+ import os
7
+ import sys
6
8
  import shlex
7
9
  import asyncio
8
10
  import logging
@@ -26,6 +28,7 @@ __all__ = (
26
28
  "repr_mapping",
27
29
  "function_lookup",
28
30
  "make_reader_task",
31
+ "is_a_tty",
29
32
  )
30
33
 
31
34
  PATIENCE_MESSAGES = [
@@ -42,7 +45,7 @@ PATIENCE_MESSAGES = [
42
45
 
43
46
  def get_version() -> str:
44
47
  """Return the current version of telnetlib3."""
45
- return "5.0.0" # keep in sync with pyproject.toml !
48
+ return "5.0.2" # keep in sync with pyproject.toml !
46
49
 
47
50
 
48
51
  def encoding_from_lang(lang: str) -> Optional[str]:
@@ -172,3 +175,32 @@ def make_reader_task(
172
175
  ) -> "asyncio.Task[Any]":
173
176
  """Return asyncio task wrapping coroutine of reader.read(size)."""
174
177
  return asyncio.ensure_future(reader.read(size))
178
+
179
+
180
+ def is_a_tty() -> bool:
181
+ """
182
+ Whether an interactive terminal is available on standard input.
183
+
184
+ This is a more conservative test than ``sys.stdin.isatty()``: the standard
185
+ streams may be ``None`` (running under ``pythonw.exe`` on Windows, or any
186
+ process launched without a console), replaced by an object without a
187
+ working :meth:`~io.IOBase.isatty` or :meth:`~io.IOBase.fileno` (test
188
+ harnesses, GUI frameworks), or detached. A real file descriptor is
189
+ required, because terminal features such as window size and raw mode are
190
+ performed against ``sys.stdin.fileno()``.
191
+
192
+ :returns: ``True`` only when stdin is a terminal backed by a file
193
+ descriptor.
194
+ """
195
+ stdin = sys.stdin
196
+ if stdin is None:
197
+ return False
198
+ try:
199
+ if not stdin.isatty():
200
+ return False
201
+ return os.isatty(stdin.fileno())
202
+ except (AttributeError, ValueError, OSError):
203
+ # AttributeError: stream without isatty/fileno,
204
+ # ValueError: closed or detached stream,
205
+ # OSError: fileno() unsupported by the underlying object.
206
+ return False
@@ -577,7 +577,9 @@ async def open_connection(
577
577
  :param port: Remote Internet host TCP port.
578
578
  :param client_factory: Client connection class factory. When ``None``,
579
579
  :class:`TelnetTerminalClient` is used when *stdin* is attached to a
580
- terminal, :class:`TelnetClient` otherwise.
580
+ terminal, :class:`TelnetClient` otherwise. Processes without a
581
+ console, such as those launched by ``pythonw.exe`` on Windows, where
582
+ ``sys.stdin`` is ``None``, always receive :class:`TelnetClient`.
581
583
  :param family: Same meaning as
582
584
  :meth:`asyncio.loop.create_connection`.
583
585
  :param flags: Same meaning as
@@ -643,7 +645,7 @@ async def open_connection(
643
645
  """
644
646
  if client_factory is None:
645
647
  client_factory = TelnetClient
646
- if sys.stdin.isatty():
648
+ if accessories.is_a_tty():
647
649
  client_factory = TelnetTerminalClient
648
650
 
649
651
  def connection_factory() -> client_base.BaseClient:
@@ -724,7 +726,7 @@ async def run_client() -> None:
724
726
  def _client_factory(**kwargs: Any) -> client_base.BaseClient:
725
727
  client: TelnetClient
726
728
  kwargs["gmcp_modules"] = gmcp_modules
727
- if sys.stdin.isatty():
729
+ if accessories.is_a_tty():
728
730
  client = TelnetTerminalClient(**kwargs)
729
731
  else:
730
732
  client = TelnetClient(**kwargs)
@@ -110,7 +110,11 @@ async def _read_line_inner(reader: Union[TelnetReader, TelnetReaderUnicode], max
110
110
  """Inner loop for _read_line, separated for wait_for compatibility."""
111
111
  buf = ""
112
112
  while len(buf) < max_len:
113
- char = await reader.read(1)
113
+ try:
114
+ char = await reader.read(1)
115
+ except UnicodeDecodeError:
116
+ # An incomplete character at end of stream is end of input, as with EOF.
117
+ break
114
118
  if not char:
115
119
  break
116
120
  if isinstance(char, bytes):
@@ -67,7 +67,11 @@ async def filter_ansi(
67
67
  return reader.read(1)
68
68
 
69
69
  async def _next() -> str:
70
- ch = await _rch()
70
+ try:
71
+ ch = await _rch()
72
+ except UnicodeDecodeError:
73
+ # An incomplete character at end of stream is end of input, as with EOF.
74
+ return ""
71
75
  if not ch:
72
76
  return ""
73
77
  return ch.decode("latin-1") if binary else ch # type: ignore[return-value]
@@ -564,6 +564,11 @@ class TelnetReaderUnicode(TelnetReader):
564
564
  #: practice, however.
565
565
  _decoder = None
566
566
 
567
+ #: Characters finalized from bytes retained by :attr:`_decoder` at end of
568
+ #: stream, held back when :meth:`read` requested fewer. Only ever set once
569
+ #: :attr:`_buffer` is empty, so these are always the last of the stream.
570
+ _pending = ""
571
+
567
572
  def __init__(
568
573
  self,
569
574
  fn_encoding: Callable[..., str],
@@ -584,8 +589,13 @@ class TelnetReaderUnicode(TelnetReader):
584
589
  self.encoding_errors = encoding_errors
585
590
 
586
591
  def decode(self, buf: bytes, final: bool = False) -> str:
587
- """Decode bytes ``buf`` using preferred encoding."""
588
- if buf == b"":
592
+ """
593
+ Decode bytes ``buf`` using preferred encoding.
594
+
595
+ :param final: When True, bytes retained by the incremental decoder are decoded as the end
596
+ of the stream, applying ``encoding_errors`` to an incomplete character.
597
+ """
598
+ if buf == b"" and (not final or self._decoder is None):
589
599
  return "" # EOF
590
600
 
591
601
  encoding = self.fn_encoding(incoming=True)
@@ -597,20 +607,53 @@ class TelnetReaderUnicode(TelnetReader):
597
607
 
598
608
  return self._decoder.decode(buf, final)
599
609
 
610
+ def _finalize_decoder(self, n: int = -1) -> str:
611
+ """
612
+ Decode and return bytes retained by the incremental decoder at end of stream.
613
+
614
+ Characters decoded beyond *n* are held in :attr:`_pending` for the next read, so that
615
+ :meth:`read` never returns more characters than requested.
616
+ """
617
+ if self._decoder is None:
618
+ return ""
619
+
620
+ u_data = self.decode(b"", final=True)
621
+ if n < 0 or len(u_data) <= n:
622
+ return u_data
623
+
624
+ self._pending = u_data[n:]
625
+ return u_data[:n]
626
+
627
+ def at_eof(self) -> bool:
628
+ """Return True if the buffer is empty, nothing is pending, and 'feed_eof' was called."""
629
+ return self._eof and not self._buffer and not self._pending
630
+
600
631
  async def readline(self) -> str: # type: ignore[override]
601
632
  """
602
633
  Read one line.
603
634
 
604
- See ancestor method, :func:`~TelnetReader.readline` for details.
635
+ See ancestor method, :func:`~TelnetReader.readline` for details. When the returned bytes
636
+ are the last of the stream, an incomplete character retained by the incremental decoder is
637
+ finalized, applying ``encoding_errors``.
638
+
639
+ :raises UnicodeDecodeError: if ``encoding_errors`` is ``"strict"`` and the stream
640
+ ends with an incomplete character.
605
641
  """
642
+ if self._pending:
643
+ line, self._pending = self._pending, ""
644
+ return line
645
+
606
646
  buf = await super().readline()
607
- return self.decode(buf)
647
+ return self.decode(buf, final=self._eof and not self._buffer)
608
648
 
609
649
  async def read(self, n: int = -1) -> str: # type: ignore[override]
610
650
  """
611
- Read up to *n* bytes.
651
+ Read up to *n* characters.
612
652
 
613
- If the EOF was received and the internal buffer is empty, return an empty string.
653
+ If the EOF was received and no characters remain, return an empty string. Bytes retained
654
+ by the incremental decoder are finalized at end of stream, applying
655
+ ``encoding_errors`` to an incomplete character: ``"strict"`` raises
656
+ :exc:`UnicodeDecodeError`.
614
657
 
615
658
  :param n: If *n* is not provided, or set to -1, read until EOF and return all characters as
616
659
  one large string.
@@ -621,6 +664,11 @@ class TelnetReaderUnicode(TelnetReader):
621
664
  if not n:
622
665
  return ""
623
666
 
667
+ if self._pending and n > 0:
668
+ u_data = self._pending[:n]
669
+ self._pending = self._pending[n:]
670
+ return u_data
671
+
624
672
  if n < 0:
625
673
  # This used to just loop creating a new waiter hoping to
626
674
  # collect everything in self._buffer, but that would
@@ -635,20 +683,35 @@ class TelnetReaderUnicode(TelnetReader):
635
683
  blocks.append(block)
636
684
  return "".join(blocks)
637
685
 
638
- if not self._buffer and not self._eof:
639
- await self._wait_for_data("read")
640
-
641
- buf = self.decode(bytes(self._buffer))
642
- if n < 0 or len(buf) <= n:
643
- u_data = buf
644
- self._buffer.clear()
645
- else:
646
- u_data = ""
647
- while n > len(u_data):
648
- u_data += self.decode(bytes([self._buffer.pop(0)]))
649
-
650
- self._maybe_resume_transport()
651
- return u_data
686
+ while True:
687
+ if self._exception is not None:
688
+ raise self._exception
689
+ if not self._buffer and not self._eof:
690
+ await self._wait_for_data("read")
691
+
692
+ decoder = self._decoder
693
+ decoder_state = decoder.getstate() if decoder is not None else None
694
+ buf = self.decode(bytes(self._buffer))
695
+ if len(buf) <= n:
696
+ u_data = buf
697
+ self._buffer.clear()
698
+ else:
699
+ # The probe decoded bytes that remain buffered. Restore the
700
+ # decoder before consuming only the requested characters.
701
+ if self._decoder is decoder and decoder_state is not None:
702
+ self._decoder.setstate(decoder_state)
703
+ else:
704
+ self._decoder.reset()
705
+ u_data = ""
706
+ while n > len(u_data):
707
+ u_data += self.decode(bytes([self._buffer.pop(0)]))
708
+
709
+ self._maybe_resume_transport()
710
+ if u_data:
711
+ return u_data
712
+ if self._eof:
713
+ return self._finalize_decoder(n)
714
+ # A partial character is not EOF: wait for the rest of the character.
652
715
 
653
716
  async def readexactly(self, n: int) -> str: # type: ignore[override]
654
717
  """
@@ -658,6 +721,8 @@ class TelnetReaderUnicode(TelnetReader):
658
721
  reached before *n* can be read. The
659
722
  :attr:`asyncio.IncompleteReadError.partial` attribute of the
660
723
  exception contains the partial read characters.
724
+ :raises UnicodeDecodeError: if ``encoding_errors`` is ``"strict"`` and the stream
725
+ ends with an incomplete character.
661
726
  """
662
727
  if self._exception is not None:
663
728
  raise self._exception
@@ -1,4 +1,6 @@
1
1
  # std imports
2
+ import io
3
+ import os
2
4
  import sys
3
5
  import types
4
6
  import asyncio
@@ -224,7 +226,7 @@ def test_transform_args_typescript():
224
226
 
225
227
  @pytest.mark.asyncio
226
228
  async def test_open_connection_default_factory(bind_host, unused_tcp_port, monkeypatch):
227
- monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
229
+ monkeypatch.setattr(cl.accessories, "is_a_tty", lambda: False)
228
230
 
229
231
  async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5):
230
232
  reader, writer = await cl.open_connection(
@@ -235,10 +237,9 @@ async def test_open_connection_default_factory(bind_host, unused_tcp_port, monke
235
237
  writer.close()
236
238
 
237
239
 
238
- @pytest.mark.skipif(sys.platform == "win32", reason="TTY factory not used on win32")
239
240
  @pytest.mark.asyncio
240
241
  async def test_open_connection_tty_factory(bind_host, unused_tcp_port, monkeypatch):
241
- monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
242
+ monkeypatch.setattr(cl.accessories, "is_a_tty", lambda: True)
242
243
 
243
244
  async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5):
244
245
  reader, writer = await cl.open_connection(
@@ -248,6 +249,76 @@ async def test_open_connection_tty_factory(bind_host, unused_tcp_port, monkeypat
248
249
  writer.close()
249
250
 
250
251
 
252
+ @pytest.mark.asyncio
253
+ async def test_open_connection_without_stdin(bind_host, unused_tcp_port, monkeypatch):
254
+ """Processes without a console, sys.stdin is None, such as pythonw.exe."""
255
+ monkeypatch.setattr(sys, "stdin", None)
256
+
257
+ async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5):
258
+ reader, writer = await cl.open_connection(
259
+ host=bind_host, port=unused_tcp_port, connect_maxwait=0.1, encoding=False
260
+ )
261
+ assert isinstance(writer.protocol, cl.TelnetClient)
262
+ assert not isinstance(writer.protocol, cl.TelnetTerminalClient)
263
+ writer.close()
264
+
265
+
266
+ def test_is_a_tty_without_stdin(monkeypatch):
267
+ monkeypatch.setattr(sys, "stdin", None)
268
+ assert accessories.is_a_tty() is False
269
+
270
+
271
+ def test_is_a_tty_stdin_without_isatty(monkeypatch):
272
+ monkeypatch.setattr(sys, "stdin", object())
273
+ assert accessories.is_a_tty() is False
274
+
275
+
276
+ def test_is_a_tty_stdin_not_a_tty(monkeypatch):
277
+ monkeypatch.setattr(sys, "stdin", io.StringIO())
278
+ assert accessories.is_a_tty() is False
279
+
280
+
281
+ def test_is_a_tty_stdin_detached(monkeypatch):
282
+ class _Detached:
283
+ def isatty(self):
284
+ return True
285
+
286
+ def fileno(self):
287
+ raise ValueError("underlying buffer has been detached")
288
+
289
+ monkeypatch.setattr(sys, "stdin", _Detached())
290
+ assert accessories.is_a_tty() is False
291
+
292
+
293
+ def test_is_a_tty_stdin_unsupported_fileno(monkeypatch):
294
+ class _NoFileno:
295
+ def isatty(self):
296
+ return True
297
+
298
+ monkeypatch.setattr(sys, "stdin", _NoFileno())
299
+ assert accessories.is_a_tty() is False
300
+
301
+
302
+ def test_is_a_tty_stdin_is_a_tty(monkeypatch):
303
+ class _Tty:
304
+ def isatty(self):
305
+ return True
306
+
307
+ def fileno(self):
308
+ # a file descriptor that os.isatty() agrees is a terminal
309
+ return _Tty.fd
310
+
311
+ pty = pytest.importorskip("pty")
312
+ master_fd, slave_fd = pty.openpty()
313
+ _Tty.fd = slave_fd
314
+ try:
315
+ monkeypatch.setattr(sys, "stdin", _Tty())
316
+ assert accessories.is_a_tty() is True
317
+ finally:
318
+ os.close(slave_fd)
319
+ os.close(master_fd)
320
+
321
+
251
322
  def test_detect_syncterm_font_sets_force_binary():
252
323
  client = BaseClient.__new__(BaseClient)
253
324
  client.log = types.SimpleNamespace(debug=lambda *a, **kw: None, isEnabledFor=lambda _: False)
@@ -425,3 +425,126 @@ async def test_telnet_reader_readuntil_pattern_cancelled_error(bind_host, unused
425
425
  client_reader.set_exception(asyncio.CancelledError())
426
426
  with pytest.raises(asyncio.CancelledError):
427
427
  await client_reader.readuntil_pattern(pattern)
428
+
429
+
430
+ @pytest.mark.parametrize("encoding", ["utf-8", "utf-16", "iso2022_jp"])
431
+ async def test_unicode_read_preserves_decoder_state(encoding):
432
+ """A size probe must not decode still-buffered bytes twice."""
433
+ reader = telnetlib3.TelnetReaderUnicode(fn_encoding=lambda **_: encoding)
434
+ given = "abc€" if encoding != "iso2022_jp" else "abc日本語"
435
+ encoded = given.encode(encoding)
436
+ reader.feed_data(encoded[:-1])
437
+ assert await reader.read(1) == "a"
438
+ assert await reader.read(1) == "b"
439
+ reader.feed_data(encoded[-1:])
440
+ reader.feed_eof()
441
+ assert await reader.read() == given[2:]
442
+ assert await reader.read() == ""
443
+
444
+
445
+ @pytest.mark.parametrize("method", ["read", "readexactly"])
446
+ async def test_unicode_read_waits_for_split_character(method):
447
+ """A partial multibyte character must not look like end of stream."""
448
+ reader = telnetlib3.TelnetReaderUnicode(fn_encoding=lambda **_: "utf-8")
449
+ reader.feed_data(b"\xe2")
450
+ task = asyncio.create_task(getattr(reader, method)(1))
451
+ asyncio.get_running_loop().call_soon(reader.feed_data, b"\x82\xac")
452
+ assert await asyncio.wait_for(task, 1) == "€"
453
+
454
+
455
+ @pytest.mark.parametrize(
456
+ "method, args, data, errors, expected",
457
+ [
458
+ ("read", (), b"ab\xe2", "replace", "ab�"),
459
+ ("read", (), b"ab\xe2", "ignore", "ab"),
460
+ ("read", (), b"ab\xe2", "strict", UnicodeDecodeError),
461
+ ("readline", (), b"ab\xe2", "replace", "ab�"),
462
+ ("readline", (), b"ab\xe2", "ignore", "ab"),
463
+ ("readline", (), b"ab\xe2", "strict", UnicodeDecodeError),
464
+ ("readexactly", (3,), b"ab\xe2", "replace", "ab�"),
465
+ ("readexactly", (3,), b"ab\xe2", "ignore", asyncio.IncompleteReadError),
466
+ ("readexactly", (1,), b"\xe2", "strict", UnicodeDecodeError),
467
+ ("read", (), b"", "replace", ""),
468
+ ],
469
+ )
470
+ async def test_unicode_finalizes_trailing_character(method, args, data, errors, expected):
471
+ """The character that ends the stream is decoded once by the configured error policy."""
472
+ reader = telnetlib3.TelnetReaderUnicode(fn_encoding=lambda **_: "utf-8", encoding_errors=errors)
473
+ reader.feed_data(data)
474
+ reader.feed_eof()
475
+ if not isinstance(expected, str):
476
+ with pytest.raises(expected):
477
+ await getattr(reader, method)(*args)
478
+ return
479
+ assert await getattr(reader, method)(*args) == expected
480
+ assert await reader.read() == ""
481
+
482
+
483
+ async def test_unicode_readline_finalizes_only_last_line():
484
+ """Only the last of the buffered lines is decoded as end of stream."""
485
+ reader = telnetlib3.TelnetReaderUnicode(fn_encoding=lambda **_: "utf-8")
486
+ reader.feed_data(b"one\ntwo\xe2")
487
+ reader.feed_eof()
488
+ assert [await reader.readline() for _ in range(3)] == ["one\n", "two�", ""]
489
+
490
+
491
+ async def test_unicode_read_split_character_over_connection(bind_host, unused_tcp_port):
492
+ """Read-size probes preserve UTF-8 across separate Telnet network writes."""
493
+
494
+ async def shell(reader, writer):
495
+ writer.write(b"abc\xe2")
496
+ await writer.drain()
497
+ assert await reader.readexactly(1) == b"!"
498
+ writer.write(b"\x82\xac")
499
+ writer.close()
500
+
501
+ async with create_server(host=bind_host, port=unused_tcp_port, shell=shell, encoding=False):
502
+ async with open_connection(host=bind_host, port=unused_tcp_port, encoding="utf-8") as (
503
+ client_reader,
504
+ client_writer,
505
+ ):
506
+ assert await asyncio.wait_for(client_reader.read(1), 1) == "a"
507
+ client_writer.write("!")
508
+ await client_writer.drain()
509
+ assert await asyncio.wait_for(client_reader.read(), 1) == "bc€"
510
+
511
+
512
+ async def test_unicode_read_partial_character_then_connection_error():
513
+ """A reset immediately after a partial character must wake the read caller."""
514
+ reader = telnetlib3.TelnetReaderUnicode(fn_encoding=lambda **_: "utf-8")
515
+ task = asyncio.create_task(reader.read(1))
516
+ await asyncio.sleep(0)
517
+ assert reader._waiter is not None
518
+ reader.feed_data(b"\xe2")
519
+ reader.set_exception(ConnectionResetError("connection reset"))
520
+ with pytest.raises(ConnectionResetError, match="connection reset"):
521
+ await asyncio.wait_for(task, 1)
522
+
523
+
524
+ async def test_unicode_read_cancelled_partial_character():
525
+ """Cancellation leaves the partial decoder state usable by the next reader."""
526
+ reader = telnetlib3.TelnetReaderUnicode(fn_encoding=lambda **_: "utf-8")
527
+ reader.feed_data(b"\xe2")
528
+ task = asyncio.create_task(reader.read(1))
529
+ await asyncio.sleep(0)
530
+ assert not task.done()
531
+ task.cancel()
532
+ with pytest.raises(asyncio.CancelledError):
533
+ await task
534
+ asyncio.get_running_loop().call_soon(reader.feed_data, b"\x82\xac")
535
+ assert await asyncio.wait_for(reader.read(1), 1) == "€"
536
+
537
+
538
+ @pytest.mark.parametrize("method, args", [("read", (1,)), ("readline", ())])
539
+ async def test_unicode_read_holds_back_finalized_characters(method, args):
540
+ """A sized read holds back characters decoded past the request."""
541
+ reader = telnetlib3.TelnetReaderUnicode(
542
+ fn_encoding=lambda **_: "utf-8", encoding_errors="surrogateescape"
543
+ )
544
+ reader.feed_data(b"a\xe2\x82")
545
+ reader.feed_eof()
546
+ assert await reader.read(1) == "a"
547
+ assert await reader.read(1) == "\udce2"
548
+ assert not reader.at_eof()
549
+ assert await getattr(reader, method)(*args) == "\udc82"
550
+ assert reader.at_eof()
@@ -43,6 +43,8 @@ def _run_readline(sequence, max_visible_width=0):
43
43
 
44
44
 
45
45
  class MockReader:
46
+ """Reader of one character per call; a ``None`` entry raises UnicodeDecodeError."""
47
+
46
48
  is_binary_reader = False
47
49
 
48
50
  def __init__(self, data):
@@ -54,6 +56,8 @@ class MockReader:
54
56
  return ""
55
57
  result = self._data[self._idx]
56
58
  self._idx += 1
59
+ if result is None:
60
+ raise UnicodeDecodeError("utf-8", b"\xe2", 0, 1, "unexpected end of data")
57
61
  return result
58
62
 
59
63
 
@@ -225,6 +229,11 @@ async def test_read_line_inner(input_data, max_len, expected):
225
229
  assert await gs._read_line_inner(MockReader(list(input_data)), max_len) == expected
226
230
 
227
231
 
232
+ @pytest.mark.asyncio
233
+ async def test_read_line_inner_truncated_character_at_eof():
234
+ assert await gs._read_line_inner(MockReader(["h", "i", None]), 100) == "hi"
235
+
236
+
228
237
  @pytest.mark.asyncio
229
238
  async def test_read_line_with_timeout():
230
239
  assert await gs._read_line(MockReader(list("hello\r")), timeout=5.0) == "hello"
@@ -710,6 +719,19 @@ async def test_filter_ansi_esc_then_eof():
710
719
  assert not result
711
720
 
712
721
 
722
+ @pytest.mark.parametrize(
723
+ "input_chars,expected",
724
+ [
725
+ pytest.param(["a", None], "a", id="after_character"),
726
+ pytest.param([None], "", id="immediately"),
727
+ pytest.param(["\x1b", None], "", id="esc_then_eof"),
728
+ ],
729
+ )
730
+ @pytest.mark.asyncio
731
+ async def test_filter_ansi_truncated_character_at_eof(input_chars, expected):
732
+ assert await ss.filter_ansi(MockReader(input_chars), MockWriter()) == expected
733
+
734
+
713
735
  class MockBinaryReader:
714
736
  is_binary_reader = True
715
737
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes