Skip to content

Commit fc8ee4a

Browse files
authored
bugfix/improve linemode/raw switching live (jquast#123)
- bugfix that we accidentally "enabled" MUD protocols in telnetlib3-client in last release which causes SGML and probably other stuff we don't want or can't use -- these are now only enabled for fingerprinting client. - mainly, for supporting telnetlib3-client to connect to muds, and temporarily enabling and disabling echo for password prompts, this was not previously supported, we just "always did raw mode", but now we honor the server's wishes. guidebook entry about it. - pexpect is re-enabled an used again to great effect of coverage, - bugfix just one small item was missing to correctly track coverage w/pty - all tests changed to exclude common pylint disables globally and remove them individually
1 parent 07104ea commit fc8ee4a

55 files changed

Lines changed: 2420 additions & 1825 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bin/moderate_fingerprints.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
from pathlib import Path
1616

1717
try:
18-
# 3rd party
1918
from wcwidth import iter_sequences, strip_sequences
2019

2120
_HAS_WCWIDTH = True

docs/guidebook.rst

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -277,25 +277,24 @@ telnet implementations, always use ``\r\n`` with ``write()``.
277277
Raw Mode and Line Mode
278278
~~~~~~~~~~~~~~~~~~~~~~
279279

280-
``telnetlib3-client`` defaults to **raw terminal mode** -- the local
281-
terminal is set to raw (no line buffering, no local echo, no signal
282-
processing), and each keystroke is sent to the server immediately. This
283-
is the correct mode for most BBS and MUD servers that handle their own
284-
echo and line editing.
280+
By default ``telnetlib3-client`` matches the terminal's mode by the
281+
server's stated telnet negotiation. It starts in line mode (local echo,
282+
line buffering) and switches dynamically depending on server:
285283

286-
Use ``--line-mode`` to switch to line-buffered input with local echo,
287-
which is appropriate for simple command-line services that expect the
288-
client to perform local line editing::
284+
- Nothing: line mode with local echo
285+
- ``WILL ECHO`` + ``WILL SGA``: kludge mode (raw, no local echo)
286+
- ``WILL ECHO``: raw mode, server echoes
287+
- ``WILL SGA``: character-at-a-time with local echo
289288

290-
# Default: raw mode (correct for most servers)
291-
telnetlib3-client bbs.example.com
289+
Use ``--raw-mode`` to force raw mode (no line buffering, no local echo),
290+
which is needed for some legacy BBS systems that don't negotiate ``WILL
291+
ECHO``. This is set true when ``--encoding=petscii`` or ``atascii``.
292292

293-
# Line mode: local echo and line buffering
294-
telnetlib3-client --line-mode simple-service.example.com
293+
Conversely, Use ``--line-mode`` to force line-buffered input with local echo.
295294

296295
Similarly, ``telnetlib3-server --pty-exec`` defaults to raw PTY mode
297296
(disabling PTY echo), which is correct for programs that handle their own
298-
terminal I/O (curses, blessed, etc.). Use ``--line-mode`` for programs
297+
terminal I/O (bash, curses, etc.). Use ``--line-mode`` for programs
299298
that expect cooked/canonical PTY mode::
300299

301300
# Default: raw PTY (correct for curses programs)

docs/history.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
History
22
=======
3+
2.6.0
4+
* change: ``telnetlib3-client`` now sets terminal mode to the server's
5+
preference via ``WILL ECHO`` and ``WILL SGA`` negotiation. Use
6+
``--raw-mode`` to restore legacy raw mode for servers that don't negotiate.
7+
The Python API (``open_connection``, ``create_server``) is unchanged.
8+
* change: ``telnetlib3-client`` declines MUD protocol options (GMCP, MSDP,
9+
MSSP, MSP, MXP, ZMP, AARDWOLF, ATCP) by default. Use ``--always-do`` or
10+
``--always-will`` to opt in.
11+
* bugfix: log output "staircase text" in raw terminal mode.
12+
313
2.5.0
414
* change: ``telnetlib3-client`` now defaults to raw terminal mode (no line buffering, no local
515
echo), which is correct for most servers. Use ``--line-mode`` to restore line-buffered

telnetlib3/accessories.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
logging.addLevelName(TRACE, "TRACE")
1515

1616
if TYPE_CHECKING: # pragma: no cover
17-
# local
1817
from .stream_reader import TelnetReader, TelnetReaderUnicode
1918

2019
__all__ = (
@@ -142,6 +141,11 @@ def make_logger(
142141
if logfile:
143142
_cfg["filename"] = logfile
144143
logging.basicConfig(**_cfg)
144+
for handler in logging.getLogger().handlers:
145+
if isinstance(handler, logging.StreamHandler) and not isinstance(
146+
handler, logging.FileHandler
147+
):
148+
handler.terminator = "\r\n"
145149
logging.getLogger().setLevel(lvl)
146150
logging.getLogger(name).setLevel(lvl)
147151
return logging.getLogger(name)

telnetlib3/client.py

Lines changed: 64 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None:
9898
and character set negotiation.
9999
"""
100100
# pylint: disable=import-outside-toplevel
101-
# local
102101
from telnetlib3.telopt import NAWS, TTYPE, TSPEED, CHARSET, XDISPLOC, NEW_ENVIRON
103102

104103
super().connection_made(transport)
@@ -198,17 +197,17 @@ def _normalize_charset_name(name: str) -> str:
198197
:param name: Raw charset name from the server.
199198
:returns: Normalized name suitable for :func:`codecs.lookup`.
200199
"""
201-
# std imports
202200
import re # pylint: disable=import-outside-toplevel
203-
base = name.strip().replace(' ', '-')
201+
202+
base = name.strip().replace(" ", "-")
204203
# Strip leading zeros from numeric segments: iso-8859-02 → iso-8859-2
205-
no_leading_zeros = re.sub(r'-0+(\d)', r'-\1', base)
204+
no_leading_zeros = re.sub(r"-0+(\d)", r"-\1", base)
206205
# All hyphens removed: cp-1250 → cp1250
207-
no_hyphens = base.replace('-', '')
206+
no_hyphens = base.replace("-", "")
208207
# Keep first hyphen-segment, collapse the rest: iso-8859-2 stays
209-
parts = no_leading_zeros.split('-')
208+
parts = no_leading_zeros.split("-")
210209
if len(parts) > 2:
211-
partial = parts[0] + '-' + ''.join(parts[1:])
210+
partial = parts[0] + "-" + "".join(parts[1:])
212211
else:
213212
partial = no_leading_zeros
214213
for candidate in (base, no_leading_zeros, no_hyphens, partial):
@@ -256,9 +255,7 @@ def send_charset(self, offered: List[str]) -> str:
256255

257256
for offer in offered:
258257
try:
259-
canon = codecs.lookup(
260-
self._normalize_charset_name(offer)
261-
).name
258+
canon = codecs.lookup(self._normalize_charset_name(offer)).name
262259

263260
# Record first viable encoding
264261
if first_viable is None:
@@ -382,7 +379,6 @@ def send_env(self, keys: Sequence[str]) -> Dict[str, Any]:
382379
@staticmethod
383380
def _winsize() -> Tuple[int, int]:
384381
try:
385-
# std imports
386382
import fcntl # pylint: disable=import-outside-toplevel
387383
import termios # pylint: disable=import-outside-toplevel
388384

@@ -575,7 +571,6 @@ def _patched_connection_made(transport: asyncio.BaseTransport) -> None:
575571
colormatch: str = args["colormatch"]
576572
shell_callback = args["shell"]
577573
if colormatch.lower() != "none":
578-
# local
579574
from .color_filter import ( # pylint: disable=import-outside-toplevel
580575
PALETTES,
581576
ColorConfig,
@@ -625,29 +620,36 @@ async def _color_shell(
625620
shell_callback = _color_shell
626621

627622
# Wrap shell to inject raw_mode flag and input translation for retro encodings
628-
raw_mode: bool = args.get("raw_mode", False)
629-
if raw_mode:
630-
# local
623+
raw_mode_val: Optional[bool] = args.get("raw_mode", False)
624+
if raw_mode_val is not False:
631625
from .client_shell import ( # pylint: disable=import-outside-toplevel
632626
_INPUT_XLAT,
633627
_INPUT_SEQ_XLAT,
634628
InputFilter,
635629
)
636630

637631
enc_key = (args.get("encoding", "") or "").lower()
638-
byte_xlat = _INPUT_XLAT.get(enc_key, {})
639-
seq_xlat = _INPUT_SEQ_XLAT.get(enc_key, {})
632+
byte_xlat = dict(_INPUT_XLAT.get(enc_key, {}))
633+
if args.get("ascii_eol"):
634+
# --ascii-eol: don't translate CR/LF to encoding-native EOL
635+
byte_xlat.pop(0x0D, None)
636+
byte_xlat.pop(0x0A, None)
637+
seq_xlat = {} if args.get("ansi_keys") else _INPUT_SEQ_XLAT.get(enc_key, {})
640638
input_filter: Optional[InputFilter] = (
641639
InputFilter(seq_xlat, byte_xlat) if (seq_xlat or byte_xlat) else None
642640
)
641+
ascii_eol: bool = args.get("ascii_eol", False)
643642
_inner_shell = shell_callback
644643

645644
async def _raw_shell(
646645
reader: Union[TelnetReader, TelnetReaderUnicode],
647646
writer_arg: Union[TelnetWriter, TelnetWriterUnicode],
648647
) -> None:
649648
# pylint: disable-next=protected-access
650-
writer_arg._raw_mode = True # type: ignore[union-attr]
649+
writer_arg._raw_mode = raw_mode_val # type: ignore[union-attr]
650+
if ascii_eol:
651+
# pylint: disable-next=protected-access
652+
writer_arg._ascii_eol = True # type: ignore[union-attr]
651653
if input_filter is not None:
652654
# pylint: disable-next=protected-access
653655
writer_arg._input_filter = input_filter # type: ignore[union-attr]
@@ -703,13 +705,21 @@ def _get_argument_parser() -> argparse.ArgumentParser:
703705
)
704706

705707
parser.add_argument("--force-binary", action="store_true", help="force encoding", default=True)
706-
parser.add_argument(
708+
mode_group = parser.add_mutually_exclusive_group()
709+
mode_group.add_argument(
710+
"--raw-mode",
711+
action="store_true",
712+
default=False,
713+
help="force raw terminal mode (no line buffering, no local echo). "
714+
"Correct for BBS and retro systems. Default: auto-detect from "
715+
"server negotiation.",
716+
)
717+
mode_group.add_argument(
707718
"--line-mode",
708719
action="store_true",
709720
default=False,
710-
help="use line-buffered input with local echo instead of raw terminal "
711-
"mode. By default the client uses raw mode (no line buffering, no "
712-
"local echo) which is correct for most BBS and MUD servers.",
721+
help="force line-buffered input with local echo. Appropriate for "
722+
"simple command-line services.",
713723
)
714724
parser.add_argument(
715725
"--connect-minwait", default=0, type=float, help="shell delay for negotiation"
@@ -779,6 +789,22 @@ def _get_argument_parser() -> argparse.ArgumentParser:
779789
default=False,
780790
help="swap foreground/background for light-background terminals",
781791
)
792+
parser.add_argument(
793+
"--ascii-eol",
794+
action="store_true",
795+
default=False,
796+
help="use ASCII CR/LF for line endings instead of encoding-native "
797+
"EOL (e.g. ATASCII 0x9B). Use for BBSes that display retro "
798+
"graphics but use standard CR/LF for line breaks.",
799+
)
800+
parser.add_argument(
801+
"--ansi-keys",
802+
action="store_true",
803+
default=False,
804+
help="transmit raw ANSI escape sequences for arrow and function "
805+
"keys instead of encoding-specific control codes. Use for "
806+
"BBSes that expect ANSI cursor sequences.",
807+
)
782808
return parser
783809

784810

@@ -790,7 +816,6 @@ def _parse_option_arg(value: str) -> bytes:
790816
:returns: Single-byte option value.
791817
:raises ValueError: When *value* is not a known name or valid integer.
792818
"""
793-
# local
794819
from .telopt import option_from_name # pylint: disable=import-outside-toplevel
795820

796821
try:
@@ -815,12 +840,17 @@ def _parse_background_color(value: str) -> Tuple[int, int, int]:
815840

816841
def _transform_args(args: argparse.Namespace) -> Dict[str, Any]:
817842
# Auto-enable force_binary for retro BBS encodings that use high-bit bytes.
818-
# local
819843
from .encodings import FORCE_BINARY_ENCODINGS # pylint: disable=import-outside-toplevel
820844

821845
force_binary = args.force_binary
822-
raw_mode = not args.line_mode
823-
if args.encoding.lower().replace('-', '_') in FORCE_BINARY_ENCODINGS:
846+
# Three-state: True (forced raw), False (forced line), None (auto-detect)
847+
if args.raw_mode:
848+
raw_mode: Optional[bool] = True
849+
elif args.line_mode:
850+
raw_mode = False
851+
else:
852+
raw_mode = None
853+
if args.encoding.lower().replace("-", "_") in FORCE_BINARY_ENCODINGS:
824854
force_binary = True
825855
raw_mode = True
826856

@@ -847,6 +877,8 @@ def _transform_args(args: argparse.Namespace) -> Dict[str, Any]:
847877
"background_color": _parse_background_color(args.background_color),
848878
"reverse_video": args.reverse_video,
849879
"raw_mode": raw_mode,
880+
"ascii_eol": args.ascii_eol,
881+
"ansi_keys": args.ansi_keys,
850882
}
851883

852884

@@ -957,7 +989,6 @@ async def run_fingerprint_client() -> None:
957989
:func:`~telnetlib3.server_fingerprinting.fingerprinting_client_shell`
958990
via :func:`functools.partial`, and runs the connection.
959991
"""
960-
# local
961992
from . import fingerprinting # pylint: disable=import-outside-toplevel
962993
from . import server_fingerprinting # pylint: disable=import-outside-toplevel
963994

@@ -1021,8 +1052,12 @@ def patched_connection_made(transport: asyncio.BaseTransport) -> None:
10211052
client.writer.environ_encoding = environ_encoding
10221053
# pylint: disable-next=protected-access
10231054
client.writer._encoding_explicit = environ_encoding != "ascii"
1024-
client.writer.always_will = fp_always_will
1025-
client.writer.always_do = fp_always_do
1055+
# pylint: disable-next=import-outside-toplevel
1056+
from .fingerprinting import EXTENDED_OPTIONS
1057+
1058+
mud_opts = {opt for opt, _, _ in EXTENDED_OPTIONS}
1059+
client.writer.always_will = fp_always_will | mud_opts
1060+
client.writer.always_do = fp_always_do | mud_opts
10261061

10271062
def patched_send_env(keys: Sequence[str]) -> Dict[str, Any]:
10281063
result = orig_send_env(keys)

telnetlib3/client_base.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,7 @@ def begin_shell(self, future: asyncio.Future[None]) -> None:
200200
fut.add_done_callback(
201201
lambda fut_obj: (
202202
self.waiter_closed.set_result(weakref.proxy(self))
203-
if self.waiter_closed is not None
204-
and not self.waiter_closed.done()
203+
if self.waiter_closed is not None and not self.waiter_closed.done()
205204
else None
206205
)
207206
)
@@ -249,18 +248,18 @@ def _detect_syncterm_font(self, data: bytes) -> None:
249248
"""
250249
if self.writer is None:
251250
return
252-
# local
253251
from .server_fingerprinting import ( # pylint: disable=import-outside-toplevel
254252
_SYNCTERM_BINARY_ENCODINGS,
255253
detect_syncterm_font,
256254
)
255+
257256
encoding = detect_syncterm_font(data)
258257
if encoding is not None:
259258
self.log.debug("SyncTERM font switch: %s", encoding)
260-
if getattr(self.writer, '_encoding_explicit', False):
259+
if getattr(self.writer, "_encoding_explicit", False):
261260
self.log.debug(
262-
"ignoring font switch, explicit encoding: %s",
263-
self.writer.environ_encoding)
261+
"ignoring font switch, explicit encoding: %s", self.writer.environ_encoding
262+
)
264263
else:
265264
self.writer.environ_encoding = encoding
266265
if encoding in _SYNCTERM_BINARY_ENCODINGS:
@@ -345,7 +344,6 @@ def check_negotiation(self, final: bool = False) -> bool:
345344
combined when derived.
346345
"""
347346
# pylint: disable=import-outside-toplevel
348-
# local
349347
from .telopt import TTYPE, CHARSET, NEW_ENVIRON
350348

351349
# First check if there are any pending options

0 commit comments

Comments
 (0)