From c76ff4eff04ce3966c9e0234c598459dd491cb41 Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Sat, 24 Jan 2026 13:42:43 -0500 Subject: [PATCH 1/3] Add synchronous API and wait_for negotiation support (#107) Adds a blocking/synchronous interface for telnetlib3 and wait_for methods for telnet option negotiation. - **Deletes** the "_waiter_closed" future, this thing was a mistake and finally not necessary by new Server() - unfortunately _waiter_connected still exists and is used .. maybe that goes next - **New** Server.wait_for_clients() and other methods replaces need for _waiter_connected, closed, etc. - **New** writer.wait_for() to wait for specific option negotiation states (remote/local/pending), wait_for_condition() for custom predicates - **New** telnetlib3.sync module with TelnetConnection, BlockingTelnetServer, and ServerConnection classes for non-asyncio usage - Miniboa-compatible properties on ServerConnection for easier migration for "more advanced server of the same interface" - New docs/guidebook.rst with usage patterns and examples - Example scripts in bin/ (blocking client/server, wait_for demos) - Removed pylint.extensions.docparams (redundant with type annotations) --- .github/workflows/ci.yml | 9 +- DESIGN.rst | 33 - README.rst | 273 +-------- bin/blocking_client.py | 69 +++ bin/blocking_echo_server.py | 69 +++ bin/client_wargame.py | 49 ++ bin/server_broadcast.py | 76 +++ bin/server_wait_for_client.py | 54 ++ bin/server_wait_for_negotiation.py | 60 ++ bin/server_wargame.py | 46 ++ docs/api/sync.rst | 7 + docs/conf.py | 5 + docs/example_linemode.py | 47 -- docs/example_readline.py | 260 -------- docs/guidebook.rst | 403 +++++++++++++ docs/history.rst | 11 +- docs/index.rst | 1 + pyproject.toml | 7 - telnetlib3/__init__.py | 9 +- telnetlib3/client.py | 2 +- telnetlib3/client_base.py | 27 +- telnetlib3/client_shell.py | 3 - telnetlib3/py.typed | 0 telnetlib3/server.py | 109 +++- telnetlib3/server_base.py | 32 +- telnetlib3/stream_reader.py | 17 +- telnetlib3/stream_writer.py | 121 +++- telnetlib3/sync.py | 897 ++++++++++++++++++++++++++++ telnetlib3/telopt.py | 15 + telnetlib3/tests/accessories.py | 6 - telnetlib3/tests/test_core.py | 164 ++--- telnetlib3/tests/test_encoding.py | 69 +-- telnetlib3/tests/test_linemode.py | 30 +- telnetlib3/tests/test_naws.py | 7 +- telnetlib3/tests/test_reader.py | 20 +- telnetlib3/tests/test_server_api.py | 129 ++++ telnetlib3/tests/test_server_cli.py | 89 +++ telnetlib3/tests/test_shell.py | 32 +- telnetlib3/tests/test_sync.py | 403 +++++++++++++ telnetlib3/tests/test_timeout.py | 26 +- telnetlib3/tests/test_writer.py | 179 +++++- tox.ini | 24 +- 42 files changed, 3023 insertions(+), 866 deletions(-) create mode 100755 bin/blocking_client.py create mode 100755 bin/blocking_echo_server.py create mode 100755 bin/client_wargame.py create mode 100755 bin/server_broadcast.py create mode 100755 bin/server_wait_for_client.py create mode 100755 bin/server_wait_for_negotiation.py create mode 100755 bin/server_wargame.py create mode 100644 docs/api/sync.rst delete mode 100755 docs/example_linemode.py delete mode 100644 docs/example_readline.py create mode 100644 docs/guidebook.rst create mode 100644 telnetlib3/py.typed create mode 100644 telnetlib3/sync.py create mode 100644 telnetlib3/tests/test_server_api.py create mode 100644 telnetlib3/tests/test_server_cli.py create mode 100644 telnetlib3/tests/test_sync.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 716517a..5b1888d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: run: python -Im tox -e ${{ matrix.toxenv }} tests: - name: Python ${{ matrix.python-version }} (${{ matrix.os }}) + name: Python ${{ matrix.python-version }} (${{ matrix.os }})${{ matrix.asyncio-debug && ' [asyncio-debug]' || '' }} strategy: fail-fast: false matrix: @@ -70,6 +70,11 @@ jobs: - os: ubuntu-latest python-version: "3.14" + # Python 3.14 with asyncio debug mode + - os: ubuntu-latest + python-version: "3.14" + asyncio-debug: true + runs-on: ${{ matrix.os }} steps: @@ -93,6 +98,8 @@ jobs: - name: Run tests run: python -Im tox -e ${{ env.TOX_ENV }} + env: + PYTHONASYNCIODEBUG: ${{ matrix.asyncio-debug && '1' || '' }} - name: Upload coverage data uses: actions/upload-artifact@v4 diff --git a/DESIGN.rst b/DESIGN.rst index 9dbfba2..9d22d20 100644 --- a/DESIGN.rst +++ b/DESIGN.rst @@ -6,17 +6,6 @@ Retired by author, looking for new owner :) or some help! Design ====== -performance ------------ - -The client appears to be very poor-performing under load. I wrote a basic server -that is capable of sending a very large stream of data, "max headroom" demo at -telnet 1984.ws, and it totally kills the client. Maybe the client is so busy -processing incoming data that it is not allowing for tasks to read it from the -other end? - -Maybe this is async "backpressure" - reduce ------ @@ -27,28 +16,6 @@ rather shoe-horned, main() should declare keywords. **this is completed for server, copy to client** -wait_for_negotiation --------------------- - -We need a way to wish to wait for a state. For example, our client shell might await -until local_echo is False, or remote_option[ECHO] is True to change terminal state -to reflect it. A function wait_for, receiving a function that returns True when state -is met, will be called back continuously after each block of data received containing -an IAC command byte, but the boiler code simply returns the waiter. - -This should allow us to spray the client with feature requests, and await the -completion of their negotiation, especially for things like LINEMODE that might -have many state changes, this allows asyncio to solve the complex "awaiting -many future states in parallel" event loop easily - --- just accept a future, and on each state change, call an internal function -that checks for equality for the parameters given, and when true, set .done() - -after some thought, we should hardcode common ones, such as await -negotiate_kludge() -> bool, wait_for_naws() -> bool, and, -maybe a common wait_for_slc() that just returns some description -of the state change that has occurred with maybe values - BaseTelnetProtocol ------------------ diff --git a/README.rst b/README.rst index 350f6fb..4c6a18c 100644 --- a/README.rst +++ b/README.rst @@ -18,39 +18,15 @@ requires python 3.7 and later, using the asyncio_ module. .. _asyncio: http://docs.python.org/3.11/library/asyncio.html -Legacy 'telnetlib' ------------------- - -This library *also* contains a copy of telnetlib.py_ from the standard library of -Python 3.12 before it was removed in Python 3.13. asyncio_ is not required. - -To migrate code from Python 3.11 and earlier, install this library and change -instances of `telnetlib` to `telnetlib3`: - -.. code-block:: python - - # OLD imports: - import telnetlib - # - or - - from telnetlib import Telnet, ECHO, BINARY - - # NEW imports: - import telnetlib3.telnetlib as telnetlib - # - or - - from telnetlib3 import Telnet, ECHO, BINARY - from telnetlib3.telnetlib import Telnet, ECHO, BINARY - -.. _telnetlib.py: https://docs.python.org/3.12/library/telnetlib.html - - Quick Example ------------- -Writing a Telnet Server that offers a basic "war game": +A simple telnet server: .. code-block:: python - import asyncio, telnetlib3 + import asyncio + import telnetlib3 async def shell(reader, writer): writer.write('\r\nWould you like to play a game? ') @@ -63,247 +39,60 @@ Writing a Telnet Server that offers a basic "war game": writer.close() async def main(): - server = await telnetlib3.create_server('127.0.0.1', 6023, shell=shell) + server = await telnetlib3.create_server(port=6023, shell=shell) await server.wait_closed() asyncio.run(main()) -Writing a Telnet Client that plays the "war game" against this server: +More examples are available in the `Guidebook`_ and the ``bin/`` directory. -.. code-block:: python +.. _Guidebook: https://telnetlib3.readthedocs.io/en/latest/guidebook.html - import asyncio, telnetlib3 - - async def shell(reader, writer): - while True: - # read stream until '?' mark is found - outp = await reader.read(1024) - if not outp: - # End of File - break - elif '?' in outp: - # reply all questions with 'y'. - writer.write('y') - - # display all server output - print(outp, flush=True) - - # EOF - print() - - async def main(): - reader, writer = await telnetlib3.open_connection('localhost', 6023, shell=shell) - await writer.protocol.waiter_closed - - asyncio.run(main()) +Legacy telnetlib +---------------- -Command-line ------------- - -Two command-line scripts are distributed with this package, -`telnetlib3-client` and `telnetlib3-server`. - -Both command-line scripts accept argument ``--shell=my_module.fn_shell`` -describing a python module path to an function of signature -``async def shell(reader, writer)``, as in the above examples. - -These scripts also serve as more advanced server and client examples that -perform advanced telnet option negotiation and may serve as a basis for -creating your own custom negotiation behaviors. - -Find their filepaths using command:: +This library *also* contains a copy of telnetlib.py_ from the standard library of +Python 3.12 before it was removed in Python 3.13. asyncio_ is not required. - python -c 'import telnetlib3.server;print(telnetlib3.server.__file__, telnetlib3.client.__file__)' +To migrate code from Python 3.11 and earlier: -telnetlib3-client -~~~~~~~~~~~~~~~~~ +.. code-block:: python -This is an entry point for command ``python -m telnetlib3.client`` + # OLD imports: + import telnetlib + # - or - + from telnetlib import Telnet, ECHO, BINARY -Small terminal telnet client. Some example destinations and options:: + # NEW imports: + import telnetlib3.telnetlib as telnetlib + # - or - + from telnetlib3.telnetlib import Telnet, ECHO, BINARY - telnetlib3-client --loglevel warn 1984.ws - telnetlib3-client --loglevel debug --logfile logfile.txt nethack.alt.org - telnetlib3-client --encoding=cp437 --force-binary blackflag.acid.org +.. _telnetlib.py: https://docs.python.org/3.12/library/telnetlib.html -See section Encoding_ about arguments, ``--encoding=cp437`` and ``--force-binary``. +Command-line +------------ -:: +Two command-line scripts are distributed with this package, +``telnetlib3-client`` and ``telnetlib3-server``. - usage: telnetlib3-client [-h] [--term TERM] [--loglevel LOGLEVEL] - [--logfmt LOGFMT] [--logfile LOGFILE] [--shell SHELL] - [--encoding ENCODING] [--speed SPEED] - [--encoding-errors {replace,ignore,strict}] - [--force-binary] [--connect-minwait CONNECT_MINWAIT] - [--connect-maxwait CONNECT_MAXWAIT] - host [port] - - Telnet protocol client - - positional arguments: - host hostname - port port number (default: 23) - - optional arguments: - -h, --help show this help message and exit - --term TERM terminal type (default: xterm-256color) - --loglevel LOGLEVEL log level (default: warn) - --logfmt LOGFMT log format (default: %(asctime)s %(levelname)s - %(filename)s:%(lineno)d %(message)s) - --logfile LOGFILE filepath (default: None) - --shell SHELL module.function_name (default: - telnetlib3.telnet_client_shell) - --encoding ENCODING encoding name (default: utf8) - --speed SPEED connection speed (default: 38400) - --encoding-errors {replace,ignore,strict} - handler for encoding errors (default: replace) - --force-binary force encoding (default: True) - --connect-minwait CONNECT_MINWAIT - shell delay for negotiation (default: 1.0) - --connect-maxwait CONNECT_MAXWAIT - timeout for pending negotiation (default: 4.0) - -telnetlib3-server -~~~~~~~~~~~~~~~~~ - -This is an entry point for command ``python -m telnetlib3.server`` - -Telnet server providing the default debugging shell. This provides a simple -shell server that allows introspection of the session's values. - -Example session:: - - tel:sh> help - quit, writer, slc, toggle [option|all], reader, proto - - tel:sh> writer - - - tel:sh> reader - - - tel:sh> toggle all - wont echo. - wont suppress go-ahead. - wont outbinary. - dont inbinary. - xon-any enabled. - lineflow disabled. - - tel:sh> reader - - - tel:sh> writer - +Both accept argument ``--shell=my_module.fn_shell`` describing a python +module path to a function of signature ``async def shell(reader, writer)``. :: - usage: telnetlib3-server [-h] [--loglevel LOGLEVEL] [--logfile LOGFILE] - [--logfmt LOGFMT] [--shell SHELL] - [--encoding ENCODING] [--force-binary] - [--timeout TIMEOUT] - [--connect-maxwait CONNECT_MAXWAIT] - [--pty-exec PROGRAM] - [--robot-check] [--pty-fork-limit N] - [host] [port] [-- ARG ...] - - Telnet protocol server - - positional arguments: - host bind address (default: localhost) - port bind port (default: 6023) - - optional arguments: - -h, --help show this help message and exit - --loglevel LOGLEVEL level name (default: info) - --logfile LOGFILE filepath (default: None) - --logfmt LOGFMT log format (default: %(asctime)s %(levelname)s - %(filename)s:%(lineno)d %(message)s) - --shell SHELL module.function_name (default: telnet_server_shell) - --encoding ENCODING encoding name (default: utf8) - --force-binary force binary transmission (default: False) - --timeout TIMEOUT idle disconnect (0 disables) (default: 300) - --connect-maxwait CONNECT_MAXWAIT - timeout for pending negotiation (default: 4.0) - --pty-exec PROGRAM execute PROGRAM in a PTY for each connection - (use -- to pass args to PROGRAM) - --robot-check check if client can render wide unicode (default: False) - --pty-fork-limit N limit concurrent PTY connections (default: 0, unlimited) - -PTY Execution -~~~~~~~~~~~~~ - -The server can spawn a PTY-connected program for each connection:: - + telnetlib3-client nethack.alt.org telnetlib3-server --pty-exec /bin/bash -- --login -This spawns an interactive bash login shell. The ``--login`` flag (or ``-l``) -is recommended for proper shell initialization (readline, history, profile -sourcing). - -For a minimal shell without these features:: - - telnetlib3-server --pty-exec /bin/sh - -Arguments after ``--`` are passed to the program, for example, to execute python -with argument of a script as a subprocess:: - - telnetlib3-server --pty-exec $(which python) -- ../blessed/bin/cellestial.py - -Connection Guards -~~~~~~~~~~~~~~~~~ - -The server supports two guard mechanisms to protect resources: - -**Robot Check** (``--robot-check``): Tests whether the connecting client can -properly render wide Unicode characters. This uses cursor position reporting -(CPR) to measure if a wide character (U+231A ⌚) renders as width 2. Clients -that fail (bots, basic scripts, broken terminals) are redirected to a -philosophical shell that asks questions and logs responses before disconnecting. - -**Connection Limit** (``--pty-fork-limit N``): Limits the number of concurrent -PTY connections. When the limit is reached, new connections are redirected to -a "busy" shell that displays a message, logs any input, and disconnects. This -is useful for resource-constrained servers running memory-intensive programs. - -Example with both guards enabled, limiting to 4 concurrent connections:: - - telnetlib3-server --robot-check --pty-fork-limit 4 --pty-exec /bin/bash -- --login - -Guard shells log all input at INFO level, useful for monitoring connection -attempts. Input is limited to 2KB per read with timeouts (10s for robot check, -30s for busy shell). - Encoding -------- -In this client connection example:: +Use ``--encoding`` and ``--force-binary`` for non-ASCII terminals:: telnetlib3-client --encoding=cp437 --force-binary blackflag.acid.org -Note the use of `--encoding=cp437` to translate input and output characters of -the remote end. This example legacy telnet BBS is unable to negotiate about -or present characters in any other encoding but CP437. Without these arguments, -Telnet protocol would dictate our session to be US-ASCII. - -Argument `--force-binary` is *also* required in many cases, with both -``telnetlib3-client`` and ``telnetlib3-server``. In the original Telnet protocol -specifications, the Network Virtual Terminal (NVT) is defined as 7-bit US-ASCII, -and this is the default state for both ends until negotiated otherwise by RFC-856_ -by negotiation of BINARY TRANSMISSION. - -However, **many common telnet clients and servers fail to negotiate for BINARY** -correctly or at all. Using ``--force-binary`` allows non-ASCII encodings to be -used with those kinds of clients. - -A Telnet Server that prefers "utf8" encoding, and, transmits it even in the case -of failed BINARY negotiation, to support a "dumb" telnet client like netcat:: - - telnetlib3-server --encoding=utf8 --force-binary - -Connecting with "dumb" client:: - - nc -t localhost 6023 +The default encoding is UTF-8. Use ``--force-binary`` when the server +doesn't properly negotiate BINARY mode. Features -------- diff --git a/bin/blocking_client.py b/bin/blocking_client.py new file mode 100755 index 0000000..d61c565 --- /dev/null +++ b/bin/blocking_client.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python +""" +Blocking (synchronous) telnet client. + +This example demonstrates using TelnetConnection for a traditional +blocking client that doesn't require asyncio knowledge. + +Example usage:: + + $ python blocking_client.py localhost 6023 + Connected to localhost:6023 + >>> hello + Echo: hello + >>> quit + Goodbye! + Connection closed. +""" + +# std imports +import sys + +# local +from telnetlib3.sync import TelnetConnection + + +def main(): + """Connect to a telnet server and interact.""" + host = sys.argv[1] if len(sys.argv) > 1 else "localhost" + port = int(sys.argv[2]) if len(sys.argv) > 2 else 6023 + + print(f"Connecting to {host}:{port}...") + + with TelnetConnection(host, port, timeout=10) as conn: + print(f"Connected to {host}:{port}") + + # Read initial server greeting + try: + greeting = conn.read(timeout=2) + if greeting: + print(greeting, end="") + except TimeoutError: + pass + + # Interactive loop + while True: + try: + user_input = input(">>> ") + conn.write(user_input + "\r\n") + conn.flush() + + # Read response + response = conn.read(timeout=5) + if response: + print(response, end="") + + if not response or "goodbye" in response.lower(): + break + + except (EOFError, KeyboardInterrupt): + print("\nDisconnecting...") + break + except TimeoutError: + print("(no response)") + + print("Connection closed.") + + +if __name__ == "__main__": + main() diff --git a/bin/blocking_echo_server.py b/bin/blocking_echo_server.py new file mode 100755 index 0000000..0fc17ac --- /dev/null +++ b/bin/blocking_echo_server.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python +""" +Blocking (synchronous) telnet echo server. + +This example demonstrates using BlockingTelnetServer for a traditional +threaded server that doesn't require asyncio knowledge. + +Each client connection is handled in a separate thread. + +Example session:: + + $ telnet localhost 6023 + Welcome! Type messages and I'll echo them back. + Type 'quit' to disconnect. + + hello + Echo: hello + quit + Goodbye! +""" + +# local +from telnetlib3.sync import BlockingTelnetServer + + +def handle_client(conn): + """Handle a single client connection (runs in its own thread).""" + conn.write("Welcome! Type messages and I'll echo them back.\r\n") + conn.write("Type 'quit' to disconnect.\r\n\r\n") + conn.flush() + + while True: + try: + line = conn.readline(timeout=300) # 5 minute timeout + if not line: + break + + line = line.strip() + if line.lower() == "quit": + conn.write("Goodbye!\r\n") + conn.flush() + break + + conn.write(f"Echo: {line}\r\n") + conn.flush() + except TimeoutError: + conn.write("\r\nTimeout - disconnecting.\r\n") + conn.flush() + break + + conn.close() + + +def main(): + """Start the blocking echo server.""" + server = BlockingTelnetServer("127.0.0.1", 6023, handler=handle_client) + print("Blocking echo server running on localhost:6023") + print("Connect with: telnet localhost 6023") + print("Press Ctrl+C to stop") + + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nShutting down...") + server.shutdown() + + +if __name__ == "__main__": + main() diff --git a/bin/client_wargame.py b/bin/client_wargame.py new file mode 100755 index 0000000..21608f6 --- /dev/null +++ b/bin/client_wargame.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python +""" +Telnet client that plays the "war game" against a server. + +This example connects to a telnet server and automatically answers +any question with 'y'. Run server_wargame.py first, then this client. + +Example output:: + + $ python client_wargame.py + + Would you like to play a game? y + They say the only way to win is to not play at all. +""" + +# std imports +import asyncio + +# local +import telnetlib3 + + +async def shell(reader, writer): + """Handle client session, auto-answering questions.""" + while True: + # Read stream until '?' mark is found + outp = await reader.read(1024) + if not outp: + # End of File + break + if "?" in outp: + # Reply to all questions with 'y' + writer.write("y") + + # Display all server output + print(outp, flush=True, end="") + + # EOF + print() + + +async def main(): + """Connect to the telnet server.""" + _reader, writer = await telnetlib3.open_connection(host="localhost", port=6023, shell=shell) + await writer.protocol.waiter_closed + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bin/server_broadcast.py b/bin/server_broadcast.py new file mode 100755 index 0000000..05cf8e8 --- /dev/null +++ b/bin/server_broadcast.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python +""" +Telnet server that broadcasts messages to all connected clients. + +This example demonstrates using server.clients to access all connected +protocols and broadcast messages. It also shows wait_for() to await +specific negotiation states. + +Run this server, then connect multiple telnet clients. Messages typed +in one client will be broadcast to all others. +""" + +# std imports +import asyncio + +# local +import telnetlib3 + + +async def handle_client(server, client, client_id): + """Handle a single client, broadcasting their input to all others.""" + client.writer.write(f"\r\nYou are client #{client_id}\r\n") + client.writer.write("Type messages to broadcast (Ctrl+] to disconnect)\r\n\r\n") + + # Wait for BINARY mode if available + try: + await asyncio.wait_for(client.writer.wait_for(remote={"BINARY": True}), timeout=2.0) + except asyncio.TimeoutError: + pass # Continue without BINARY mode + + while True: + data = await client.reader.read(1024) + if not data: + break + + # Broadcast to all other clients + message = f"[Client #{client_id}]: {data}" + for other in server.clients: + if other is not client: + other.writer.write(message) + + # Notify others of disconnect + for other in server.clients: + if other is not client: + other.writer.write(f"\r\n[Client #{client_id} disconnected]\r\n") + + +async def main(): + """Start server and handle client connections.""" + server = await telnetlib3.create_server(host="127.0.0.1", port=6023) + print("Broadcast server running on localhost:6023") + print("Connect multiple clients with: telnet localhost 6023") + + client_counter = 0 + tasks = [] + + try: + while True: + client = await server.wait_for_client() + client_counter += 1 + print(f"Client #{client_counter} connected (total: {len(server.clients)})") + + # Handle each client in a separate task + task = asyncio.create_task(handle_client(server, client, client_counter)) + tasks.append(task) + + except KeyboardInterrupt: + print("\nShutting down...") + server.close() + await server.wait_closed() + for task in tasks: + task.cancel() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bin/server_wait_for_client.py b/bin/server_wait_for_client.py new file mode 100755 index 0000000..7567565 --- /dev/null +++ b/bin/server_wait_for_client.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +""" +Telnet server demonstrating wait_for_client() API. + +This example shows how to use the Server.wait_for_client() method +to get access to connected client protocols without using a shell callback. + +Example session:: + + $ python server_wait_for_client.py + Server running on localhost:6023 + Waiting for client... + Client connected! + Terminal: xterm-256color + Window size: 80x24 +""" + +# std imports +import asyncio + +# local +import telnetlib3 + + +async def main(): + """Start server and wait for clients.""" + server = await telnetlib3.create_server(host="127.0.0.1", port=6023) + print("Server running on localhost:6023") + print("Connect with: telnet localhost 6023") + + while True: + print("Waiting for client...") + client = await server.wait_for_client() + print("Client connected!") + + # Access negotiated terminal information + term = client.get_extra_info("TERM") or "unknown" + cols = client.get_extra_info("cols") or 80 + rows = client.get_extra_info("rows") or 24 + print(f"Terminal: {term}") + print(f"Window size: {cols}x{rows}") + + # Send welcome message + client.writer.write(f"\r\nWelcome! Your terminal is {term} ({cols}x{rows})\r\n") + client.writer.write("Goodbye!\r\n") + await client.writer.drain() + client.writer.close() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nServer stopped") diff --git a/bin/server_wait_for_negotiation.py b/bin/server_wait_for_negotiation.py new file mode 100755 index 0000000..6b0c31f --- /dev/null +++ b/bin/server_wait_for_negotiation.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python +""" +Telnet server demonstrating wait_for() negotiation states. + +This example shows how to use writer.wait_for() to await specific +telnet option negotiation states before proceeding. + +The server waits for: +- NAWS (window size) to be negotiated +- TTYPE (terminal type) negotiation to complete +- BINARY mode (bidirectional) +""" + +# std imports +import asyncio + +# local +import telnetlib3 + + +async def shell(_reader, writer): + """Handle client with explicit negotiation waits.""" + writer.write("\r\nWaiting for terminal negotiation...\r\n") + + # Wait for NAWS, TTYPE, and BINARY negotiation to complete + try: + await asyncio.wait_for( + writer.wait_for( + local={"NAWS": True, "BINARY": True}, + remote={"BINARY": True}, + pending={"TTYPE": False}, + ), + timeout=1.5, + ) + cols = writer.get_extra_info("cols") + rows = writer.get_extra_info("rows") + term = writer.get_extra_info("TERM") + writer.write(f"Window size: {cols}x{rows}\r\n") + writer.write(f"Terminal type: {term}\r\n") + writer.write("Binary mode enabled (bidirectional)\r\n") + except asyncio.TimeoutError: + writer.write("Negotiation timed out\r\n") + + writer.write("\r\nNegotiation complete. Goodbye!\r\n") + await writer.drain() + writer.close() + + +async def main(): + """Start the telnet server.""" + server = await telnetlib3.create_server(host="127.0.0.1", port=6023, shell=shell) + print("Negotiation demo server running on localhost:6023") + await server.wait_closed() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nServer stopped") diff --git a/bin/server_wargame.py b/bin/server_wargame.py new file mode 100755 index 0000000..2a9a795 --- /dev/null +++ b/bin/server_wargame.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +""" +Telnet server that offers a basic "war game" question. + +This example demonstrates a simple telnet server using asyncio. +Run this server, then connect with: telnet localhost 6023 + +Example session:: + + $ telnet localhost 6023 + Escape character is '^]'. + + Would you like to play a game? y + They say the only way to win is to not play at all. + Connection closed by foreign host. +""" + +# std imports +import asyncio + +# local +import telnetlib3 # pylint: disable=cyclic-import + + +async def shell(reader, writer): + """Handle a single client connection.""" + writer.write("\r\nWould you like to play a game? ") + inp = await reader.read(1) + if inp: + writer.echo(inp) + writer.write("\r\nThey say the only way to win is to not play at all.\r\n") + await writer.drain() + writer.close() + + +async def main(): + """Start the telnet server.""" + server = await telnetlib3.create_server(host="127.0.0.1", port=6023, shell=shell) + print("Telnet server running on localhost:6023") + print("Connect with: telnet localhost 6023") + print("Press Ctrl+C to stop") + await server.wait_closed() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/api/sync.rst b/docs/api/sync.rst new file mode 100644 index 0000000..3f0260a --- /dev/null +++ b/docs/api/sync.rst @@ -0,0 +1,7 @@ +sync +---- + +.. automodule:: telnetlib3.sync + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/conf.py b/docs/conf.py index 8dd0f9c..6ae464d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -240,6 +240,11 @@ intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} +# Ignore these references that can't be resolved (internal asyncio paths, etc.) +nitpick_ignore = [ + ("py:class", "asyncio.events.AbstractEventLoop"), +] + # Both the class’ and the __init__ method’s docstring are concatenated and # inserted. autoclass_content = "both" diff --git a/docs/example_linemode.py b/docs/example_linemode.py deleted file mode 100755 index 7805598..0000000 --- a/docs/example_linemode.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 -""" -A very simple linemode server shell. -""" -# std -import asyncio -import sys -import pkg_resources - -# local -import telnetlib3 - - -async def shell(reader, writer): - from telnetlib3 import WONT, ECHO - - writer.iac(WONT, ECHO) - - while True: - writer.write("> ") - - recv = await reader.readline() - - # eof - if not recv: - return - - writer.write("\r\n") - - if recv.rstrip() == "bye": - writer.write("goodbye.\r\n") - await writer.drain() - writer.close() - - writer.write("".join(reversed(recv)) + "\r\n") - - -if __name__ == "__main__": - kwargs = telnetlib3.parse_server_args() - kwargs["shell"] = shell - telnetlib3.run_server(**kwargs) - # sys.argv.append('--shell={ - sys.exit( - pkg_resources.load_entry_point( - "telnetlib3", "console_scripts", "telnetlib3-server" - )() - ) diff --git a/docs/example_readline.py b/docs/example_readline.py deleted file mode 100644 index 7af99ff..0000000 --- a/docs/example_readline.py +++ /dev/null @@ -1,260 +0,0 @@ -""" -КОСМОС/300 Lunar server: a demonstration Telnet Server shell. - -With this shell, multiple clients may instruct the lander to: - - - collect rock samples - - launch sample return capsule - - relay a message (talker). - -All input and output is forced uppercase, but unicode is otherwise supported. - -A simple character-at-a-time repl is provided, supporting backspace. -""" - -# std imports -import collections -import contextlib -import logging -import asyncio - - -class Client(collections.namedtuple("Client", ["reader", "writer", "notify_queue"])): - def __str__(self): - return "#{1}".format(*self.writer.get_extra_info("peername")) - - -class Lander(object): - """ - КОСМОС/300 Lunar module. - """ - - collecting = False - capsule_amount = 0 - capsule_launched = False - capsule_launching = False - - def __init__(self): - self.log = logging.getLogger("lunar.lander") - self.clients = [] - self._loop = asyncio.get_event_loop() - - def __str__(self): - collector = "RUNNING" if self.collecting else "READY" - capsule = ( - "LAUNCH IN-PROGRESS" - if self.capsule_launching - else ( - "LAUNCHED" - if self.capsule_launched - else "{}/4".format(self.capsule_amount) - ) - ) - clients = ", ".join(map(str, self.clients)) - return "COLLECTOR {}\r\nCAPSULE {}\r\nUPLINKS: {}".format( - collector, capsule, clients - ) - - @contextlib.contextmanager - def register_link(self, reader, writer): - client = Client(reader, writer, notify_queue=asyncio.Queue()) - self.clients.append(client) - try: - self.notify_event("LINK ESTABLISHED TO {}".format(client)) - yield client - - finally: - self.clients.remove(client) - self.notify_event("LOST CONNECTION TO {}".format(client)) - - def notify_event(self, event_msg): - self.log.info(event_msg) - for client in self.clients: - client.notify_queue.put_nowait(event_msg) - - def repl_readline(self, client): - """ - Lander REPL, provides no process, local echo. - """ - from telnetlib3 import WONT, ECHO, SGA - - client.writer.iac(WONT, ECHO) - client.writer.iac(WONT, SGA) - readline = asyncio.ensure_future(client.reader.readline()) - recv_msg = asyncio.ensure_future(client.notify_queue.get()) - client.writer.write("КОСМОС/300: READY\r\n") - wait_for = set([readline, recv_msg]) - try: - while True: - client.writer.write("? ") - - # await (1) client input or (2) system notification - done, pending = await asyncio.wait( - wait_for, return_when=asyncio.FIRST_COMPLETED - ) - - task = done.pop() - wait_for.remove(task) - if task == readline: - # (1) client input - cmd = task.result().rstrip().upper() - - client.writer.echo(cmd) - self.process_command(client, cmd) - - # await next, - readline = asyncio.ensure_future(client.reader.readline()) - wait_for.add(readline) - - else: - # (2) system notification - msg = task.result() - - # await next, - recv_msg = asyncio.ensure_future(client.notify_queue.get()) - wait_for.add(recv_msg) - - # show and display prompt, - client.writer.write("\r\x1b[K{}\r\n".format(msg)) - - finally: - for task in wait_for: - task.cancel() - - async def repl_catime(self, client): - """ - Lander REPL providing character-at-a-time processing. - """ - read_one = asyncio.ensure_future(client.reader.read(1)) - recv_msg = asyncio.ensure_future(client.notify_queue.get()) - wait_for = set([read_one, recv_msg]) - - client.writer.write("КОСМОС/300: READY\r\n") - - while True: - cmd = "" - - # prompt - client.writer.write("? ") - while True: - # await (1) client input (2) system notification - done, pending = await asyncio.wait( - wait_for, return_when=asyncio.FIRST_COMPLETED - ) - - task = done.pop() - wait_for.remove(task) - if task == read_one: - # (1) client input - char = task.result().upper() - - # await next, - read_one = asyncio.ensure_future(client.reader.read(1)) - wait_for.add(read_one) - - if char == "": - # disconnect, exit - return - - elif char in "\r\n": - # carriage return, process command. - break - - elif char in "\b\x7f": - # backspace - cmd = cmd[:-1] - client.writer.echo("\b") - - else: - # echo input - cmd += char - client.writer.echo(char) - - else: - # (2) system notification - msg = task.result() - - # await next, - recv_msg = asyncio.ensure_future(client.notify_queue.get()) - wait_for.add(recv_msg) - - # show and display prompt, - client.writer.write("\r\x1b[K{}\r\n".format(msg)) - client.writer.write("? {}".format(cmd)) - - # reached when user pressed return by inner 'break' statement. - self.process_command(client, cmd) - - def process_command(self, client, cmd): - result = "\r\n" - if cmd == "HELP": - result += ( - "COLLECT COLLECT ROCK SAMPLE\r\n" - " STATUS DEVICE STATUS\r\n" - " LAUNCH LAUNCH RETURN CAPSULE\r\n" - " RELAY MESSAGE TRANSMISSION RELAY" - ) - elif cmd == "STATUS": - result += str(self) - elif cmd == "COLLECT": - result += self.collect_sample(client) - elif cmd == "LAUNCH": - result += self.launch_capsule(client) - elif cmd == "RELAY" or cmd.startswith("RELAY ") or cmd.startswith("R "): - cmd, *args = cmd.split(None, 1) - if args: - self.notify_event("RELAY FROM {}: {}".format(client, args[0])) - result = "" - elif cmd: - result += "NOT A COMMAND, {!r}".format(cmd) - client.writer.write(result + "\r\n") - - def launch_capsule(self, client): - if self.capsule_launched: - return "ERROR: NO CAPSULE" - elif self.capsule_launching: - return "ERROR: LAUNCH SEQUENCE IN-PROGRESS" - elif self.collecting: - return "ERROR: COLLECTOR ACTIVE" - self.capsule_launching = True - self.notify_event("CAPSULE LAUNCH SEQUENCE INITIATED!") - asyncio.get_event_loop().call_later(10, self.after_launch) - for count in range(1, 10): - asyncio.get_event_loop().call_later( - count, self.notify_event, "{} ...".format(10 - count) - ) - return "OK" - - def collect_sample(self, client): - if self.collecting: - return "ERROR: COLLECTION ALREADY IN PROGRESS" - elif self.capsule_launched: - return "ERROR: COLLECTOR CAPSULE NOT CONNECTED" - elif self.capsule_launching: - return "ERROR: LAUNCH SEQUENCE IN-PROGRESS." - elif self.capsule_amount >= 4: - return "ERROR: COLLECTOR CAPSULE FULL" - self.collecting = True - self.notify_event("SAMPLE COLLECTION HAS BEGUN") - self._loop.call_later(7, self.collected_sample) - return "OK" - - def collected_sample(self): - self.notify_event("SAMPLE COLLECTED") - self.capsule_amount += 1 - self.collecting = False - - def after_launch(self): - self.capsule_launching = False - self.capsule_launched = True - self.notify_event("CAPSULE LAUNCHED SUCCESSFULLY") - - -# each client shares, even communicates through lunar 'lander' instance. -lander = Lander() - - -def shell(reader, writer): - global lander - with lander.register_link(reader, writer) as client: - await lander.repl_readline(client) diff --git a/docs/guidebook.rst b/docs/guidebook.rst new file mode 100644 index 0000000..653450f --- /dev/null +++ b/docs/guidebook.rst @@ -0,0 +1,403 @@ +========= +Guidebook +========= + +This guide provides examples for using telnetlib3 to build telnet servers +and clients. All examples are available as standalone scripts in the +``bin/`` directory of the repository. + +These examples are not distributed with the package -- they are only available +in the github repository. You can retrieve them by cloning the repository, or +downloading the "raw" file link. + +.. contents:: Contents + :local: + :depth: 2 + +Asyncio Interface +================= + +The primary interface for telnetlib3 uses Python's asyncio library for +asynchronous I/O. This allows handling many concurrent connections +efficiently in a single thread. + +Server Examples +--------------- + +server_wargame.py +~~~~~~~~~~~~~~~~~ + +https://github.com/jquast/telnetlib3/blob/master/bin/server_wargame.py + +A minimal telnet server that demonstrates the basic shell callback pattern. +The server asks a simple question and responds based on user input. + +.. literalinclude:: ../bin/server_wargame.py + :language: python + :lines: 17-35 + +Run the server:: + + python bin/server_wargame.py + +Then connect with:: + + telnet localhost 6023 + + +server_wait_for_client.py +~~~~~~~~~~~~~~~~~~~~~~~~~ + +https://github.com/jquast/telnetlib3/blob/master/bin/server_wait_for_client.py + +Demonstrates the ``Server.wait_for_client()`` API for accessing +client protocols without using a shell callback. This pattern is useful when +you need direct control over client handling. + +.. literalinclude:: ../bin/server_wait_for_client.py + :language: python + :lines: 21-44 + + +server_broadcast.py +~~~~~~~~~~~~~~~~~~~ + +https://github.com/jquast/telnetlib3/blob/master/bin/server_broadcast.py + +A chat-style server that broadcasts messages from one client to all others. +Demonstrates: + +- Using ``server.clients`` to access all connected protocols +- Handling multiple clients with asyncio tasks +- Using ``wait_for()`` to check negotiation states + +.. literalinclude:: ../bin/server_broadcast.py + :language: python + :lines: 18-43 + + +server_wait_for_negotiation.py +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +https://github.com/jquast/telnetlib3/blob/master/bin/server_wait_for_negotiation.py + +Demonstrates using ``writer.wait_for()`` to await specific +telnet option negotiation states before proceeding. This is useful when your +application depends on certain terminal capabilities being negotiated. + +The server waits for: + +- NAWS (Negotiate About Window Size) - window dimensions +- TTYPE (Terminal Type) - terminal identification +- BINARY mode - 8-bit clean transmission + +.. literalinclude:: ../bin/server_wait_for_negotiation.py + :language: python + :lines: 19-54 + + +Client Examples +--------------- + +client_wargame.py +~~~~~~~~~~~~~~~~~ + +https://github.com/jquast/telnetlib3/blob/master/bin/client_wargame.py + +A telnet client that connects to a server and automatically answers +questions. Demonstrates the client shell callback pattern. + +.. literalinclude:: ../bin/client_wargame.py + :language: python + :lines: 18-41 + + +Server API Reference +-------------------- + +The ``create_server()`` function returns a ``Server`` instance with these +key methods and properties: + +wait_for_client() +~~~~~~~~~~~~~~~~~ + +``Server.wait_for_client()`` waits for a client to connect and complete +initial negotiation:: + + server = await telnetlib3.create_server(port=6023) + client = await server.wait_for_client() + client.writer.write("Welcome!\r\n") + +clients +~~~~~~~ + +The ``Server.clients`` property provides access to all currently connected +client protocols:: + + # Broadcast to all clients + for client in server.clients: + client.writer.write("Server announcement\r\n") + +wait_for() +~~~~~~~~~~ + +``TelnetWriter.wait_for()`` waits for specific telnet option negotiation +states:: + + # Wait for BINARY mode + await asyncio.wait_for( + client.writer.wait_for(remote={"BINARY": True}), + timeout=5.0 + ) + + # Wait for terminal type negotiation to complete + await asyncio.wait_for( + client.writer.wait_for(pending={"TTYPE": False}), + timeout=5.0 + ) + +The method accepts these keyword arguments: + +- ``remote``: Dict of options to wait for in ``remote_option`` (client WILL) +- ``local``: Dict of options to wait for in ``local_option`` (client DO) +- ``pending``: Dict of options to wait for in ``pending_option`` + +Option names are strings: ``"BINARY"``, ``"ECHO"``, ``"NAWS"``, ``"TTYPE"``, etc. + +wait_for_condition() +~~~~~~~~~~~~~~~~~~~~ + +The ``wait_for_condition()`` method waits for a custom condition:: + + from telnetlib3.telopt import ECHO + + await client.writer.wait_for_condition( + lambda w: w.mode == "kludge" and w.remote_option.enabled(ECHO) + ) + + +Blocking Interface +================== + +Asyncio can be complex or unnecessary for many applications. For these cases, +telnetlib3 provides a blocking (synchronous) interface via :mod:`telnetlib3.sync`. +The asyncio event loop runs in a background thread, exposing familiar blocking +methods. + +Client Usage +------------ + +The :class:`~telnetlib3.sync.TelnetConnection` class provides a blocking client +interface:: + + from telnetlib3.sync import TelnetConnection + + # Using context manager (recommended) + with TelnetConnection('localhost', 6023) as conn: + conn.write('hello\r\n') + response = conn.readline() + print(response) + + # Manual lifecycle + conn = TelnetConnection('localhost', 6023, encoding='utf8') + conn.connect() + try: + conn.write('command\r\n') + data = conn.read_until(b'>>> ') + print(data) + finally: + conn.close() + +Server Usage +------------ + +The :class:`~telnetlib3.sync.BlockingTelnetServer` class provides a blocking +server interface with thread-per-connection handling:: + + from telnetlib3.sync import BlockingTelnetServer + + def handle_client(conn): + """Called in a new thread for each client.""" + conn.write('Welcome!\r\n') + while True: + line = conn.readline(timeout=60) + if not line or line.strip() in ('quit', b'quit'): + break + conn.write(f'Echo: {line}') + conn.close() + + # Simple: auto-spawns thread per client + server = BlockingTelnetServer('localhost', 6023, handler=handle_client) + server.serve_forever() + +Or with a manual accept loop for custom threading strategies:: + + import threading + + server = BlockingTelnetServer('localhost', 6023) + server.start() + while True: + conn = server.accept() + threading.Thread(target=handle_client, args=(conn,)).start() + + +Blocking Server Example +----------------------- + +blocking_echo_server.py +~~~~~~~~~~~~~~~~~~~~~~~ + +https://github.com/jquast/telnetlib3/blob/master/bin/blocking_echo_server.py + +A traditional threaded echo server using :class:`~telnetlib3.sync.BlockingTelnetServer`. +Each client connection runs in its own thread. + +.. literalinclude:: ../bin/blocking_echo_server.py + :language: python + :lines: 21-52 + +Run the server:: + + python bin/blocking_echo_server.py + + +Blocking Client Example +----------------------- + +blocking_client.py +~~~~~~~~~~~~~~~~~~ + +https://github.com/jquast/telnetlib3/blob/master/bin/blocking_client.py + +A traditional blocking telnet client using :class:`~telnetlib3.sync.TelnetConnection`. + +.. literalinclude:: ../bin/blocking_client.py + :language: python + :lines: 18-54 + +Usage:: + + python bin/blocking_client.py localhost 6023 + + +Miniboa Compatibility +--------------------- + +The :class:`~telnetlib3.sync.ServerConnection` class (received in handler +callbacks) provides miniboa-compatible properties and methods for easier +migration:: + + from telnetlib3.sync import BlockingTelnetServer + + def handler(client): + # Miniboa-compatible properties + print(f"Connected: {client.addrport()}") + print(f"Terminal: {client.terminal_type}") + print(f"Size: {client.columns}x{client.rows}") + + # Miniboa-compatible send (converts \n to \r\n) + client.send("Welcome!\n") + + while client.active: + if client.idle() > 300: + client.send("Timeout.\n") + client.deactivate() + break + + try: + line = client.readline(timeout=1) + except TimeoutError: + continue + if line: + client.send(f"Echo: {line}") + + server = BlockingTelnetServer('0.0.0.0', 6023, handler=handler) + server.serve_forever() + +Property and method mapping: + +========================= ==================================== +miniboa :mod:`telnetlib3.sync` +========================= ==================================== +``client.active`` ``conn.active`` +``client.address`` ``conn.address`` +``client.port`` ``conn.port`` +``client.terminal_type`` ``conn.terminal_type`` +``client.columns`` ``conn.columns`` +``client.rows`` ``conn.rows`` +``client.send()`` ``conn.send()`` +``client.addrport()`` ``conn.addrport()`` +``client.idle()`` ``conn.idle()`` +``client.duration()`` ``conn.duration()`` +``client.deactivate()`` ``conn.deactivate()`` +========================= ==================================== + +Key differences from miniboa: + +- telnetlib3 uses a thread-per-connection model (blocking I/O) +- miniboa uses a poll-based model (non-blocking with ``server.poll()``) +- telnetlib3 has ``readline()``/``read()`` blocking methods +- miniboa uses ``get_command()`` (non-blocking, check ``cmd_ready``) + + +Advanced Negotiation +-------------------- + +Use :meth:`~telnetlib3.sync.TelnetConnection.wait_for` to block until telnet +options are negotiated:: + + conn.wait_for(remote={'NAWS': True, 'TTYPE': True}, timeout=5.0) + term = conn.get_extra_info('TERM') + cols = conn.get_extra_info('cols') + rows = conn.get_extra_info('rows') + +The :meth:`~telnetlib3.sync.TelnetConnection.wait_for` method accepts ``remote``, +``local``, and ``pending`` dicts. Option names are strings: ``"BINARY"``, +``"ECHO"``, ``"NAWS"``, ``"TTYPE"``, etc. + +For protocol state inspection, use the :attr:`~telnetlib3.sync.TelnetConnection.writer` +property:: + + writer = conn.writer + print(f"Mode: {writer.mode}") # 'local', 'remote', or 'kludge' + print(f"ECHO enabled: {writer.remote_option.enabled(ECHO)}") + + +Legacy telnetlib Compatibility +============================== + +Python's ``telnetlib`` was removed in Python 3.13 (`PEP 594 +`_). telnetlib3 includes a verbatim copy +from Python 3.12 with its original test suite:: + + # OLD: + from telnetlib import Telnet + + # NEW: + from telnetlib3.telnetlib import Telnet + +The legacy module has limited negotiation support and is maintained for +compatibility only. + +Modern Alternative +------------------ + +:mod:`telnetlib3.sync` provides a modern blocking interface: + +====================== ============================== +Old telnetlib :mod:`telnetlib3.sync` +====================== ============================== +``Telnet(host)`` ``TelnetConnection(host)`` +``tn.read_until()`` ``conn.read_until()`` +``tn.read_some()`` ``conn.read_some()`` +``tn.write()`` ``conn.write()`` +``tn.close()`` ``conn.close()`` +====================== ============================== + +Enhancements over legacy telnetlib: + +- Full RFC 854 protocol negotiation (NAWS, TTYPE, BINARY, ECHO, SGA) +- ``wait_for()`` to await negotiation states +- ``get_extra_info()`` for terminal type, size, and other metadata +- ``writer`` property for protocol state inspection +- Server support via ``BlockingTelnetServer`` diff --git a/docs/history.rst b/docs/history.rst index 7843648..1b41afb 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -1,7 +1,14 @@ History ======= -2.1.0 - * new: pty_shell_ and demonstrating ``telnetlib3-server --pty-exec`` CLI argument +2.2.0 + * new: ``Server`` class returned by ``create_server()`` with + ``wait_for_client()`` method and ``clients`` property for tracking + connected clients. + * new: ``TelnetWriter.wait_for()`` and ``wait_for_condition()`` + methods for waiting on telnet option negotiation state. + * new: ``telnetlib3.sync`` module with blocking (non-asyncio) APIs: + ``TelnetConnection`` for clients, ``BlockingTelnetServer`` for servers. + * new: ``pty_shell`` module and demonstrating ``telnetlib3-server --pty-exec`` CLI argument 2.0.8 * bugfix: object has no attribute '_extra' :ghissue:`100` diff --git a/docs/index.rst b/docs/index.rst index cacd817..9313645 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,6 +11,7 @@ Contents: :glob: intro + guidebook api rfcs contributing diff --git a/pyproject.toml b/pyproject.toml index 00ab538..496bb8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,6 @@ packages = ["telnetlib3"] [tool.pylint.main] load-plugins = [ "pylint.extensions.check_elif", - "pylint.extensions.docparams", "pylint.extensions.mccabe", "pylint.extensions.overlapping_exceptions", "pylint.extensions.redefined_variable_type", @@ -108,12 +107,6 @@ disable = [ max-line-length = 100 good-names = ["fd", "_", "x", "y", "tn", "ip"] -[tool.pylint.parameter_documentation] -default-docstring-type = "sphinx" -accept-no-raise-doc = false -accept-no-param-doc = true -accept-no-return-doc = true - [tool.pylint.design] max-args = 12 max-attributes = 15 diff --git a/telnetlib3/__init__.py b/telnetlib3/__init__.py index a6a1969..3b22cac 100644 --- a/telnetlib3/__init__.py +++ b/telnetlib3/__init__.py @@ -30,12 +30,14 @@ try: from . import pty_shell as _pty_shell_module from .pty_shell import * # noqa - PTY_SUPPORT = True # invalid-name + PTY_SUPPORT = True # pylint: disable=invalid-name except ImportError: - _pty_shell_module = None - PTY_SUPPORT = False # invalid-name + _pty_shell_module = None # type: ignore[assignment] + PTY_SUPPORT = False # pylint: disable=invalid-name from . import guard_shells as _guard_shells_module from .guard_shells import * # noqa +from . import sync as _sync_module +from .sync import * # noqa from .accessories import get_version as __get_version # isort: on # fmt: on @@ -54,6 +56,7 @@ + telnetlib.__all__ + (_pty_shell_module.__all__ if PTY_SUPPORT else ()) + _guard_shells_module.__all__ + + _sync_module.__all__ ) # noqa __author__ = "Jeff Quast" diff --git a/telnetlib3/client.py b/telnetlib3/client.py index 61f2656..84508b7 100755 --- a/telnetlib3/client.py +++ b/telnetlib3/client.py @@ -397,7 +397,7 @@ async def open_connection( # pylint: disable=too-many-locals of BINARY mode negotiation. :param asyncio.Future waiter_closed: Future that completes when the connection is closed. - :param callable shell: An async function that is called after negotiation completes, + :param shell: An async function that is called after negotiation completes, receiving arguments ``(reader, writer)``. :param int limit: The buffer limit for reader stream. :return (reader, writer): The reader is a :class:`~.TelnetReader` diff --git a/telnetlib3/client_base.py b/telnetlib3/client_base.py index 461556f..d90f6ce 100644 --- a/telnetlib3/client_base.py +++ b/telnetlib3/client_base.py @@ -114,7 +114,7 @@ def connection_lost(self, exc): # we are disconnected before negotiation may be considered # complete. We set waiter_closed, and any function consuming # the StreamReader will receive eof. - self._waiter_connected.set_result(weakref.proxy(self)) + self._waiter_connected.set_result(None) if self.shell is None: # when a shell is defined, we allow the completion of the coroutine @@ -321,7 +321,7 @@ def check_negotiation(self, final=False): # private methods - def _process_chunk(self, data): + def _process_chunk(self, data): # pylint: disable=too-many-branches,too-complex """Process a chunk of received bytes; return True if any IAC/SB cmd observed.""" # This mirrors the previous optimized logic, but is called from an async task. self._last_received = datetime.datetime.now() @@ -348,14 +348,25 @@ def _process_chunk(self, data): out_start = 0 feeding_oob = False - def is_special(b): - return b == 255 or (slc_needed and slc_vals and b in slc_vals) + # Build set of special bytes for fast lookup + special_bytes = frozenset({255} | (slc_vals or set())) while i < n: if not feeding_oob: # Scan forward until next special byte (IAC or SLC trigger) - while i < n and not is_special(data[i]): - i += 1 + if not slc_vals: + # Fast path: only IAC (255) is special - use C-level find + next_iac = data.find(255, i) + if next_iac == -1: + # No IAC found, consume rest of chunk + if n > out_start: + reader.feed_data(data[out_start:]) + return cmd_received + i = next_iac + else: + # Slow path: SLC bytes also special - scan byte by byte + while i < n and data[i] not in special_bytes: + i += 1 # Flush non-special run if i > out_start: reader.feed_data(data[out_start:i]) @@ -425,7 +436,7 @@ def _check_negotiation_timer(self): if self.check_negotiation(final=final): self.log.debug("negotiation complete after %1.2fs.", self.duration) - self._waiter_connected.set_result(weakref.proxy(self)) + self._waiter_connected.set_result(None) elif final: self.log.debug("negotiation failed after %1.2fs.", self.duration) _failed = [ @@ -434,7 +445,7 @@ def _check_negotiation_timer(self): if pending ] self.log.debug("failed-reply: %r", ", ".join(_failed)) - self._waiter_connected.set_result(weakref.proxy(self)) + self._waiter_connected.set_result(None) else: # keep re-queuing until complete. Aggressively re-queue until # connect_minwait, or connect_maxwait, whichever occurs next diff --git a/telnetlib3/client_shell.py b/telnetlib3/client_shell.py index 068e590..736af73 100644 --- a/telnetlib3/client_shell.py +++ b/telnetlib3/client_shell.py @@ -13,9 +13,6 @@ __all__ = ("telnet_client_shell",) -# TODO: needs 'wait_for' implementation (see DESIGN.rst) -# task = telnet_writer.wait_for(lambda: telnet_writer.local_mode[ECHO] == True) - if sys.platform == "win32": async def telnet_client_shell(telnet_reader, telnet_writer): diff --git a/telnetlib3/py.typed b/telnetlib3/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/telnetlib3/server.py b/telnetlib3/server.py index 047ec3e..f97b4a1 100755 --- a/telnetlib3/server.py +++ b/telnetlib3/server.py @@ -28,11 +28,12 @@ import pty # noqa: F401 pylint:disable=unused-import import fcntl # noqa: F401 pylint:disable=unused-import import termios # noqa: F401 pylint:disable=unused-import + PTY_SUPPORT = True except ImportError: PTY_SUPPORT = False -__all__ = ("TelnetServer", "create_server", "run_server", "parse_server_args") +__all__ = ("TelnetServer", "Server", "create_server", "run_server", "parse_server_args") class CONFIG(NamedTuple): @@ -503,9 +504,85 @@ def _check_encoding(self): return (self.writer.outbinary and self.writer.inbinary) or self.force_binary -async def create_server( - host=None, port=23, protocol_factory=TelnetServer, **kwds -): # pylint: disable=differing-param-doc,differing-type-doc +class Server: + """ + Telnet server that tracks connected clients. + + Wraps asyncio.Server with protocol tracking and connection waiting. + Returned by :func:`create_server`. + """ + + def __init__(self, server): + """Initialize wrapper around asyncio.Server.""" + self._server = server + self._protocols = [] + self._new_client = asyncio.Queue() + + def close(self): + """Close the server, stop accepting new connections, and close all clients.""" + self._server.close() + # Close all connected client transports + for protocol in list(self._protocols): + # pylint: disable=protected-access + if hasattr(protocol, "_transport") and protocol._transport is not None: + protocol._transport.close() + + async def wait_closed(self): + """Wait until the server and all client connections are closed.""" + await self._server.wait_closed() + # Yield to event loop for pending close callbacks + await asyncio.sleep(0) + # Clear protocol list now that server is closed + self._protocols.clear() + + @property + def sockets(self): + """Return list of socket objects the server is listening on.""" + return self._server.sockets + + def is_serving(self): + """Return True if the server is accepting new connections.""" + return self._server.is_serving() + + @property + def clients(self): + """ + List of connected client protocol instances. + + :returns: List of protocol instances for all connected clients. + """ + # Filter out closed protocols (lazy cleanup) + # pylint: disable=protected-access + self._protocols = [p for p in self._protocols if not getattr(p, "_closing", False)] + return list(self._protocols) + + async def wait_for_client(self): + r""" + Wait for a client to connect and complete negotiation. + + :returns: The protocol instance for the connected client. + + Example:: + + server = await telnetlib3.create_server(port=6023) + client = await server.wait_for_client() + client.writer.write("Welcome!\r\n") + """ + return await self._new_client.get() + + def _register_protocol(self, protocol): + """Register a new protocol instance (called by factory).""" + # pylint: disable=protected-access + self._protocols.append(protocol) + # Only register callbacks if protocol has the required waiters + # (custom protocols like plain asyncio.Protocol won't have these) + if hasattr(protocol, "_waiter_connected"): + protocol._waiter_connected.add_done_callback( + lambda f, p=protocol: self._new_client.put_nowait(p) if not f.cancelled() else None + ) + + +async def create_server(host=None, port=23, protocol_factory=TelnetServer, **kwds): """ Create a TCP Telnet server. @@ -517,8 +594,8 @@ async def create_server( :param server_base.BaseServer protocol_factory: An alternate protocol factory for the server, when unspecified, :class:`TelnetServer` is used. - :param Callable shell: An async function that is called after - negotiation completes, receiving arguments ``(reader, writer)``. + :param shell: An async function that is called after negotiation + completes, receiving arguments ``(reader, writer)``. Default is :func:`~.telnet_server_shell`. The reader is a :class:`~.TelnetReader` instance, the writer is a :class:`~.TelnetWriter` instance. @@ -565,13 +642,24 @@ async def create_server( :param int limit: The buffer limit for the reader stream. :param kwds: Additional keyword arguments passed to the protocol factory. - :return asyncio.Server: The return value is the same as - :meth:`asyncio.loop.create_server`, An object which can be used - to stop the service. + :return Server: A :class:`Server` instance that wraps the asyncio.Server + and provides access to connected client protocols via + :meth:`Server.wait_for_client` and :attr:`Server.clients`. """ protocol_factory = protocol_factory or TelnetServer loop = asyncio.get_event_loop() - return await loop.create_server(lambda: protocol_factory(**kwds), host, port) + + telnet_server = Server(None) + + def factory(): + protocol = protocol_factory(**kwds) + telnet_server._register_protocol(protocol) # pylint: disable=protected-access + return protocol + + server = await loop.create_server(factory, host, port) + telnet_server._server = server # pylint: disable=protected-access + + return telnet_server async def _sigterm_handler(server, _log): @@ -668,7 +756,6 @@ async def run_server( # pylint: disable=too-many-positional-arguments,too-many- robot_check=_config.robot_check, pty_fork_limit=_config.pty_fork_limit, ): - # pylint: disable=missing-raises-doc """ Program entry point for server daemon. diff --git a/telnetlib3/server_base.py b/telnetlib3/server_base.py index 4b74eb5..69ee984 100644 --- a/telnetlib3/server_base.py +++ b/telnetlib3/server_base.py @@ -4,7 +4,6 @@ import sys import asyncio import logging -import weakref import datetime import traceback @@ -32,7 +31,6 @@ def __init__( # pylint: disable=too-many-positional-arguments self, shell=None, _waiter_connected=None, - _waiter_closed=None, encoding="utf8", encoding_errors="strict", force_binary=False, @@ -57,8 +55,6 @@ def __init__( # pylint: disable=too-many-positional-arguments #: a future used for testing self._waiter_connected = _waiter_connected or asyncio.Future() - #: a future used for testing - self._waiter_closed = _waiter_closed or asyncio.Future() self._tasks = [self._waiter_connected] self.shell = shell self.reader = None @@ -114,8 +110,7 @@ def connection_lost(self, exc): except Exception: # pylint: disable=broad-exception-caught pass - # close transport (may already be closed), set _waiter_closed and - # cancel Future _waiter_connected. + # close transport (may already be closed), cancel Future _waiter_connected. if self._transport is not None: # Detach protocol from transport to drop strong reference immediately. try: @@ -126,9 +121,6 @@ def connection_lost(self, exc): self._transport.close() if not self._waiter_connected.cancelled() and not self._waiter_connected.done(): self._waiter_connected.cancel() - if self.shell is None and self._waiter_closed is not None: - # raise deprecation warning, _waiter_closed should not be used! - self._waiter_closed.set_result(weakref.proxy(self)) # break circular references for transport; keep reader/writer available # for inspection by tests after close. @@ -184,23 +176,7 @@ def begin_shell(self, _result): coro = self.shell(self.reader, self.writer) if asyncio.iscoroutine(coro): loop = asyncio.get_event_loop() - fut = loop.create_task(coro) - # Avoid capturing self strongly in the callback to prevent - # keeping the protocol instance alive after close. Although I - # hope folks aren't using the 'waiter_closed' argument, we use - # it in automatic tests, and, because it returns "self", we have - # to ensure it is a "weak" reference -- in the future we should - # migrate to more dynamic "await connection and/or negotiation - # state" - ref_self = weakref.ref(self) - - def _on_shell_done(_fut): - self_ = ref_self() - # pylint: disable=protected-access - if self_ is not None and self_._waiter_closed is not None: - self_._waiter_closed.set_result(weakref.proxy(self_)) - - fut.add_done_callback(_on_shell_done) + loop.create_task(coro) def data_received(self, data): """Process bytes received by transport.""" @@ -341,10 +317,10 @@ def _check_negotiation_timer(self): if self.check_negotiation(final=final): logger.debug("negotiation complete after %1.2fs.", self.duration) - self._waiter_connected.set_result(weakref.proxy(self)) + self._waiter_connected.set_result(None) elif final: logger.debug("negotiation failed after %1.2fs.", self.duration) - self._waiter_connected.set_result(weakref.proxy(self)) + self._waiter_connected.set_result(None) else: # keep re-queuing until complete self._check_later = asyncio.get_event_loop().call_later( diff --git a/telnetlib3/stream_reader.py b/telnetlib3/stream_reader.py index 9e35d6b..5edc31b 100644 --- a/telnetlib3/stream_reader.py +++ b/telnetlib3/stream_reader.py @@ -192,8 +192,8 @@ async def readuntil(self, separator=b"\n"): raised, and the data will be left in the internal buffer, so it can be read again. :raises ValueError: If separator is empty. - :raises LimitOverrunError: If separator is not found and buffer exceeds limit. - :raises IncompleteReadError: If EOF is reached before separator is found. + :raises asyncio.LimitOverrunError: If separator is not found and buffer exceeds limit. + :raises asyncio.IncompleteReadError: If EOF is reached before separator is found. """ seplen = len(separator) if seplen == 0: @@ -285,8 +285,8 @@ async def readuntil_pattern(self, pattern: re.Pattern) -> bytes: raised, and the data will be left in the internal buffer, so it can be read again. :raises ValueError: If pattern is None, not a re.Pattern, or not a bytes pattern. - :raises LimitOverrunError: If pattern is not found and buffer exceeds limit. - :raises IncompleteReadError: If EOF is reached before pattern is found. + :raises asyncio.LimitOverrunError: If pattern is not found and buffer exceeds limit. + :raises asyncio.IncompleteReadError: If EOF is reached before pattern is found. """ if pattern is None or not isinstance(pattern, re.Pattern): raise ValueError("pattern should be a re.Pattern object") @@ -404,7 +404,7 @@ async def readexactly(self, n): needed. :raises ValueError: If n is negative. - :raises IncompleteReadError: If EOF is reached before n bytes are read. + :raises asyncio.IncompleteReadError: If EOF is reached before n bytes are read. """ if n < 0: raise ValueError("readexactly size can not be less than zero") @@ -571,10 +571,9 @@ def __init__(self, fn_encoding, *, limit=_DEFAULT_LIMIT, encoding_errors="replac """ A Unicode StreamReader interface for Telnet protocol. - :param Callable fn_encoding: function callback, receiving boolean - keyword argument, ``incoming=True``, which is used by the callback - to determine what encoding should be used to decode the value in - the direction specified. + :param fn_encoding: Function callback, receiving boolean keyword argument + ``incoming=True``, which is used by the callback to determine what + encoding should be used to decode the value in the direction specified. """ super().__init__(limit=limit) diff --git a/telnetlib3/stream_writer.py b/telnetlib3/stream_writer.py index 22e6d9d..13d16b0 100644 --- a/telnetlib3/stream_writer.py +++ b/telnetlib3/stream_writer.py @@ -72,6 +72,7 @@ theNULL, name_command, name_commands, + option_from_name, ) __all__ = ( @@ -172,20 +173,23 @@ def __init__( self._server = server self.log = logging.getLogger(__name__) + #: List of (predicate, future) tuples for wait_for functionality + self._waiters = [] + #: Dictionary of telnet option byte(s) that follow an #: IAC-DO or IAC-DONT command, and contains a value of ``True`` #: until IAC-WILL or IAC-WONT has been received by remote end. - self.pending_option = Option("pending_option", self.log) + self.pending_option = Option("pending_option", self.log, on_change=self._check_waiters) #: Dictionary of telnet option byte(s) that follow an #: IAC-WILL or IAC-WONT command, sent by our end, #: indicating state of local capabilities. - self.local_option = Option("local_option", self.log) + self.local_option = Option("local_option", self.log, on_change=self._check_waiters) #: Dictionary of telnet option byte(s) that follow an #: IAC-WILL or IAC-WONT command received by remote end, #: indicating state of remote capabilities. - self.remote_option = Option("remote_option", self.log) + self.remote_option = Option("remote_option", self.log, on_change=self._check_waiters) #: Sub-negotiation buffer self._sb_buffer = collections.deque() @@ -288,6 +292,8 @@ def close(self): """Close the connection and release resources.""" if self.connection_closed: return + # Cancel any pending waiters + self._cancel_waiters() # Proactively notify the protocol so it can release references immediately. # Transport will also call connection_lost(), but doing it here ensures # cleanup happens deterministically and is idempotent due to _closing guard. @@ -327,11 +333,102 @@ async def wait_closed(self): for the connection to be fully closed after calling close(). """ if self._connection_closed: + # Yield to event loop for pending close callbacks + await asyncio.sleep(0) return if self._closed_fut is None: self._closed_fut = asyncio.get_running_loop().create_future() await self._closed_fut + def _check_waiters(self): + """Check all registered waiters and resolve those whose conditions are met.""" + for check, fut in self._waiters[:]: + if not fut.done() and check(): + fut.set_result(True) + + def _cancel_waiters(self): + """Cancel all pending waiters, typically called on connection close.""" + for _check, fut in self._waiters[:]: + if not fut.done(): + fut.cancel() + self._waiters.clear() + + async def wait_for(self, *, remote=None, local=None, pending=None): + """ + Wait for negotiation state conditions to be met. + + :param dict remote: Dict of option_name -> bool for remote_option checks. + :param dict local: Dict of option_name -> bool for local_option checks. + :param dict pending: Dict of option_name -> bool for pending_option checks. + :returns: True when all conditions are met. + :raises KeyError: If an option name is not recognized. + :raises asyncio.CancelledError: If connection closes while waiting. + + Example:: + + # Wait for TTYPE and NAWS negotiation to complete + await writer.wait_for(remote={"TTYPE": True, "NAWS": True}) + + # Wait for pending options to clear + await writer.wait_for(pending={"TTYPE": False}) + """ + conditions = [] + for spec, option_dict in [ + (remote, self.remote_option), + (local, self.local_option), + (pending, self.pending_option), + ]: + if spec: + for name, expected in spec.items(): + opt = option_from_name(name) + conditions.append((option_dict, opt, expected)) + + def check(): + for option_dict, opt, expected in conditions: + if expected: + if not option_dict.enabled(opt): + return False + elif option_dict.get(opt) not in (False, None): + return False + return True + + if check(): + return True + + fut = asyncio.get_running_loop().create_future() + self._waiters.append((check, fut)) + + try: + return await fut + finally: + self._waiters = [(c, f) for c, f in self._waiters if f is not fut] + + async def wait_for_condition(self, predicate): + """ + Wait for a custom condition to be met. + + :param predicate: Callable taking TelnetWriter, returning bool. + :returns: True when predicate returns True. + :raises asyncio.CancelledError: If connection closes while waiting. + + Example:: + + await writer.wait_for_condition(lambda w: w.mode == "kludge") + """ + if predicate(self): + return True + + def check(): + return predicate(self) + + fut = asyncio.get_running_loop().create_future() + self._waiters.append((check, fut)) + + try: + return await fut + finally: + self._waiters = [(c, f) for c, f in self._waiters if f is not fut] + def __repr__(self): """Description of stream encoding state.""" info = ["TelnetWriter"] @@ -1154,10 +1251,10 @@ def set_slc_callback(self, slc_byte, func): :param bytes slc_byte: any of SLC_SYNCH, SLC_BRK, SLC_IP, SLC_AO, SLC_AYT, SLC_EOR, SLC_ABORT, SLC_EOF, SLC_SUSP, SLC_EC, SLC_EL, SLC_EW, SLC_RP, SLC_XON, SLC_XOFF ... - :param Callable func: These callbacks receive a single argument: the - SLC function byte that fired it. Some SLC and IAC functions are - intermixed; which signaling mechanism used by client can be tested - by evaluating this argument. + :param func: Callback receiving a single argument: the SLC function byte + that fired it. Some SLC and IAC functions are intermixed; which + signaling mechanism used by client can be tested by evaluating this + argument. """ assert callable(func), "Argument func must be callable" assert ( @@ -1196,7 +1293,7 @@ def set_ext_send_callback(self, cmd, func): """ Register callback for inquires of sub-negotiation of ``cmd``. - :param Callable func: A callable function for the given ``cmd`` byte. + :param func: A callable function for the given ``cmd`` byte. Note that the return type must match those documented. :param bytes cmd: These callbacks must return any number of arguments, for each registered ``cmd`` byte, respectively: @@ -1264,7 +1361,7 @@ def set_ext_callback(self, cmd, func): * ``CHARSET``: for servers, receiving one string, the character set negotiated by client. :rfc:`2066`. - :param callable func: The callback function to register. + :param func: The callback function to register. """ assert cmd in ( LOGOUT, @@ -2541,14 +2638,16 @@ class Option(dict): telnet option negotiation. """ - def __init__(self, name, log): + def __init__(self, name, log, on_change=None): """ Class initializer. :param str name: decorated name representing option class, such as 'local', 'remote', or 'pending'. + :param on_change: optional callback invoked when option state changes. """ self.name, self.log = name, log + self._on_change = on_change dict.__init__(self) def enabled(self, key): @@ -2568,6 +2667,8 @@ def __setitem__(self, key, value): ) self.log.debug("%s[%s] = %s", self.name, descr, value) dict.__setitem__(self, key, value) + if self._on_change is not None: + self._on_change() def _escape_environ(buf): diff --git a/telnetlib3/sync.py b/telnetlib3/sync.py new file mode 100644 index 0000000..599877e --- /dev/null +++ b/telnetlib3/sync.py @@ -0,0 +1,897 @@ +r""" +Synchronous (blocking) interface for telnetlib3. + +This module provides a non-asyncio interface that wraps the async +telnetlib3 implementation. The asyncio event loop runs in a background +thread, and blocking methods wait on thread-safe futures. + +Example client usage:: + + from telnetlib3.sync import TelnetConnection + + with TelnetConnection('localhost', 6023) as conn: + conn.write('hello\r\n') + print(conn.readline()) + +Example server usage:: + + from telnetlib3.sync import BlockingTelnetServer + import threading + + def handler(conn): + conn.write('Hello!\r\n') + while line := conn.readline(): + conn.write(f'Echo: {line}') + + server = BlockingTelnetServer('localhost', 6023, handler=handler) + server.serve_forever() +""" + +# std imports +import time +import queue +import asyncio +import threading +from typing import Any, Union, Callable, Optional + +# local +# Import from submodules to avoid cyclic import +from .client import open_connection as _open_connection +from .server import Server +from .server import create_server as _create_server +from .stream_reader import TelnetReader +from .stream_writer import TelnetWriter + +__all__ = ("TelnetConnection", "BlockingTelnetServer", "ServerConnection") + + +class TelnetConnection: + r""" + Blocking telnet client connection. + + Wraps async ``telnetlib3.open_connection()`` with blocking methods. + The asyncio event loop runs in a daemon thread. + + :param str host: Remote server hostname or IP address. + :param int port: Remote server port (default 23). + :param float timeout: Default timeout for operations in seconds. + :param str encoding: Character encoding (default 'utf8'). + :param kwargs: Additional arguments passed to ``telnetlib3.open_connection()``. + + Example:: + + with TelnetConnection('localhost', 6023) as conn: + conn.write('hello\r\n') + response = conn.readline() + """ + + def __init__( + self, + host: str, + port: int = 23, + timeout: Optional[float] = None, + encoding: str = "utf8", + **kwargs: Any, + ): + """Initialize connection parameters without connecting.""" + self._host = host + self._port = port + self._timeout = timeout + self._encoding = encoding + self._kwargs = kwargs + + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._thread: Optional[threading.Thread] = None + self._reader: Optional[TelnetReader] = None + self._writer: Optional[TelnetWriter] = None + self._connected = threading.Event() + self._closed = False + + def connect(self) -> None: + """ + Establish connection to the server. + + Blocks until connected or timeout expires. + + :raises RuntimeError: If already connected. + :raises TimeoutError: If connection times out. + :raises ConnectionError: If connection fails. + :raises Exception: If connection fails for other reasons. + """ + if self._thread is not None: + raise RuntimeError("Already connected") + + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread(target=self._run_loop, daemon=True) + self._thread.start() + + future = asyncio.run_coroutine_threadsafe(self._async_connect(), self._loop) + try: + future.result(timeout=self._timeout) + except asyncio.TimeoutError as exc: + self._cleanup() + raise TimeoutError("Connection timed out") from exc + except Exception: + self._cleanup() + raise + + def _run_loop(self) -> None: + """Run event loop in background thread.""" + assert self._loop is not None + asyncio.set_event_loop(self._loop) + self._loop.run_forever() + + async def _async_connect(self) -> None: + """Async connection coroutine.""" + self._reader, self._writer = await _open_connection( + self._host, + self._port, + encoding=self._encoding, + **self._kwargs, + ) + self._connected.set() + + def _ensure_connected(self) -> None: + """Raise if not connected.""" + if not self._connected.is_set(): + raise RuntimeError("Not connected") + if self._closed: + raise RuntimeError("Connection closed") + + def read(self, n: int = -1, timeout: Optional[float] = None) -> Union[str, bytes]: + """ + Read up to n bytes/characters from the connection. + + Blocks until data is available or timeout expires. + + :param int n: Maximum bytes to read (-1 for any available data). + :param float timeout: Timeout in seconds (uses default if None). + :returns: Data read from connection. + :raises TimeoutError: If timeout expires before data available. + :raises EOFError: If connection closed. + """ + self._ensure_connected() + assert self._reader is not None and self._loop is not None + timeout = timeout if timeout is not None else self._timeout + future = asyncio.run_coroutine_threadsafe(self._reader.read(n), self._loop) + try: + result = future.result(timeout=timeout) + if not result: + raise EOFError("Connection closed") + return result + except asyncio.TimeoutError as exc: + future.cancel() + raise TimeoutError("Read timed out") from exc + + def read_some(self, timeout: Optional[float] = None) -> Union[str, bytes]: + """ + Read some data from the connection. + + Alias for :meth:`read` for compatibility with old telnetlib. + + :param float timeout: Timeout in seconds. + :returns: Data read from connection. + """ + return self.read(-1, timeout=timeout) + + def readline(self, timeout: Optional[float] = None) -> Union[str, bytes]: + """ + Read one line from the connection. + + Blocks until a complete line is received or timeout expires. + + :param float timeout: Timeout in seconds (uses default if None). + :returns: Line including terminator. + :raises TimeoutError: If timeout expires. + :raises EOFError: If connection closed before line complete. + """ + self._ensure_connected() + assert self._reader is not None and self._loop is not None + timeout = timeout if timeout is not None else self._timeout + future = asyncio.run_coroutine_threadsafe(self._reader.readline(), self._loop) + try: + result = future.result(timeout=timeout) + if not result: + raise EOFError("Connection closed") + return result + except asyncio.TimeoutError as exc: + future.cancel() + raise TimeoutError("Readline timed out") from exc + + def read_until( + self, match: Union[str, bytes], timeout: Optional[float] = None + ) -> Union[str, bytes]: + """ + Read until match is found. + + Like old telnetlib's read_until method. + + :param match: String or bytes to match. + :param float timeout: Timeout in seconds (uses default if None). + :returns: Data up to and including match. + :raises TimeoutError: If timeout expires before match found. + :raises EOFError: If connection closed before match found. + """ + self._ensure_connected() + assert self._reader is not None and self._loop is not None + timeout = timeout if timeout is not None else self._timeout + # readuntil expects bytes, encode if string + if isinstance(match, str): + match = match.encode(self._encoding or "utf-8") + future = asyncio.run_coroutine_threadsafe(self._reader.readuntil(match), self._loop) + try: + return future.result(timeout=timeout) + except asyncio.TimeoutError as exc: + future.cancel() + raise TimeoutError("Read until timed out") from exc + except asyncio.IncompleteReadError as exc: + raise EOFError("Connection closed before match found") from exc + + def write(self, data: Union[str, bytes]) -> None: + """ + Write data to the connection. + + This method buffers data and returns immediately. Use :meth:`flush` + to ensure data is sent. + + :param data: String or bytes to write. + """ + self._ensure_connected() + assert self._loop is not None and self._writer is not None + self._loop.call_soon_threadsafe(self._writer.write, data) + + def flush(self, timeout: Optional[float] = None) -> None: + """ + Flush buffered data to the connection. + + Blocks until all buffered data has been sent. + + :param float timeout: Timeout in seconds (uses default if None). + :raises TimeoutError: If timeout expires. + """ + self._ensure_connected() + assert self._loop is not None and self._writer is not None + timeout = timeout if timeout is not None else self._timeout + future = asyncio.run_coroutine_threadsafe(self._writer.drain(), self._loop) + try: + future.result(timeout=timeout) + except asyncio.TimeoutError as exc: + future.cancel() + raise TimeoutError("Flush timed out") from exc + + def close(self) -> None: + """Close the connection and stop the event loop.""" + if self._closed: + return + self._closed = True + self._cleanup() + + def _cleanup(self) -> None: + """Clean up resources.""" + if self._writer and self._loop and self._loop.is_running(): + # Schedule proper async cleanup + future = asyncio.run_coroutine_threadsafe(self._async_cleanup(), self._loop) + try: + future.result(timeout=2.0) + except Exception: # pylint: disable=broad-exception-caught + pass # Cleanup should not raise + if self._loop and self._loop.is_running(): + self._loop.call_soon_threadsafe(self._loop.stop) + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=2.0) + # Always close the loop if it exists and isn't closed + if self._loop and not self._loop.is_closed(): + self._loop.close() + + async def _async_cleanup(self) -> None: + """Async cleanup for writer.""" + if self._writer: + self._writer.close() + try: + await self._writer.wait_closed() + except Exception: # pylint: disable=broad-exception-caught + pass # Cleanup should not raise + + def get_extra_info(self, name: str, default: Any = None) -> Any: + """ + Get extra information about the connection. + + After negotiation completes, provides access to negotiated values: + + - ``'TERM'``: Terminal type (e.g., 'xterm-256color') + - ``'cols'``: Terminal width in columns + - ``'rows'``: Terminal height in rows + - ``'peername'``: Remote address tuple (host, port) + - ``'LANG'``: Language/locale setting + + :param str name: Information key. + :param default: Default value if key not found. + :returns: Information value or default. + """ + self._ensure_connected() + assert self._writer is not None + return self._writer.get_extra_info(name, default) + + def wait_for( + self, + remote: Optional[dict] = None, + local: Optional[dict] = None, + pending: Optional[dict] = None, + timeout: Optional[float] = None, + ) -> None: + """ + Wait for telnet option negotiation states. + + This method blocks until the specified options reach their desired + states, or timeout expires. This is not possible with the legacy + telnetlib module. + + :param remote: Dict of options for remote (client WILL) state. + Example: ``{'NAWS': True, 'TTYPE': True}`` + :param local: Dict of options for local (client DO) state. + Example: ``{'BINARY': True, 'ECHO': True}`` + :param pending: Dict of options for pending negotiation state. + Example: ``{'TTYPE': False}`` (wait for negotiation to complete) + :param timeout: Timeout in seconds (uses default if None). + :raises TimeoutError: If timeout expires before conditions met. + + Example - wait for terminal info before proceeding:: + + conn = TelnetConnection('localhost', 6023) + conn.connect() + + # Wait for NAWS and TTYPE negotiation to complete + conn.wait_for(remote={'NAWS': True, 'TTYPE': True}, timeout=5.0) + + # Now terminal info is available + term = conn.get_extra_info('TERM') + cols = conn.get_extra_info('cols') + rows = conn.get_extra_info('rows') + print(f"Terminal: {term} ({cols}x{rows})") + """ + self._ensure_connected() + assert self._loop is not None and self._writer is not None + timeout = timeout if timeout is not None else self._timeout + future = asyncio.run_coroutine_threadsafe( + self._writer.wait_for(remote=remote, local=local, pending=pending), + self._loop, + ) + try: + future.result(timeout=timeout) + except asyncio.TimeoutError as exc: + future.cancel() + raise TimeoutError("Wait for negotiation timed out") from exc + + @property + def writer(self) -> TelnetWriter: + """ + Access the underlying TelnetWriter for advanced operations. + + This provides access to telnet protocol features not available + in the legacy telnetlib: + + - Option state inspection (``writer.remote_option``, ``writer.local_option``) + - Mode detection (``writer.mode`` - 'local', 'remote', 'kludge') + - Protocol constants and negotiation methods + + :returns: The underlying TelnetWriter instance. + """ + self._ensure_connected() + assert self._writer is not None + return self._writer + + def __enter__(self) -> "TelnetConnection": + self.connect() + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + +class BlockingTelnetServer: + r""" + Blocking telnet server. + + Wraps async ``telnetlib3.create_server()`` with a blocking interface. + Each client connection can be handled in a separate thread. + + :param str host: Address to bind to. + :param int port: Port to bind to (default 6023). + :param handler: Function called for each client connection. + Receives a :class:`TelnetConnection`-like object as argument. + :param kwargs: Additional arguments passed to ``telnetlib3.create_server()``. + + Example with handler:: + + def handle_client(conn): + conn.write('Welcome!\r\n') + while line := conn.readline(): + conn.write(f'Echo: {line}') + + server = BlockingTelnetServer('localhost', 6023, handler=handle_client) + server.serve_forever() + + Example with manual accept loop:: + + server = BlockingTelnetServer('localhost', 6023) + server.start() + while True: + conn = server.accept() + threading.Thread(target=handle_client, args=(conn,)).start() + """ + + def __init__( + self, + host: str = "localhost", + port: int = 6023, + handler: Optional[Callable[["ServerConnection"], None]] = None, + **kwargs: Any, + ): + """Initialize server parameters without starting.""" + self._host = host + self._port = port + self._handler = handler + self._kwargs = kwargs + + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._thread: Optional[threading.Thread] = None + self._server: Optional[Server] = None + self._client_queue: queue.Queue = queue.Queue() + self._started = threading.Event() + self._shutdown = threading.Event() + + def start(self) -> None: + """ + Start the server. + + Non-blocking. Use :meth:`accept` or :meth:`serve_forever` to handle clients. + + :raises RuntimeError: If already started. + """ + if self._thread is not None: + raise RuntimeError("Server already started") + + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread(target=self._run_loop, daemon=True) + self._thread.start() + + # Wait for server to be ready + self._started.wait() + + def _run_loop(self) -> None: + """Run event loop in background thread.""" + assert self._loop is not None + asyncio.set_event_loop(self._loop) + self._loop.run_until_complete(self._start_server()) + self._started.set() + self._loop.run_forever() + + async def _start_server(self) -> None: + """Start the async server.""" + assert self._loop is not None + loop = self._loop # Capture for closure + + async def shell(reader: TelnetReader, writer: TelnetWriter) -> None: + """Shell that queues connections for sync handling.""" + conn = ServerConnection(reader, writer, loop) + self._client_queue.put(conn) + # Wait until the sync handler closes the connection + # pylint: disable=protected-access + await conn._wait_closed() + + self._server = await _create_server( + self._host, + self._port, + shell=shell, + **self._kwargs, + ) + + def accept(self, timeout: Optional[float] = None) -> "ServerConnection": + """ + Accept a client connection. + + Blocks until a client connects. + + :param float timeout: Timeout in seconds (None for no timeout). + :returns: Connection object for the client. + :raises TimeoutError: If timeout expires. + :raises RuntimeError: If server not started. + """ + if not self._started.is_set(): + raise RuntimeError("Server not started") + + try: + return self._client_queue.get(timeout=timeout) + except queue.Empty: + raise TimeoutError("Accept timed out") from None + + def serve_forever(self) -> None: + """ + Serve clients forever. + + Blocks and handles each client in a new thread using the handler function provided at + construction. + + :raises RuntimeError: If no handler was provided. + """ + if self._handler is None: + raise RuntimeError("No handler provided") + + self.start() + + while not self._shutdown.is_set(): + try: + conn = self.accept(timeout=1.0) + except TimeoutError: + continue + + thread = threading.Thread(target=self._handle_client, args=(conn,), daemon=True) + thread.start() + + def _handle_client(self, conn: "ServerConnection") -> None: + """Handle a client in the handler function.""" + assert self._handler is not None + try: + self._handler(conn) + finally: + if not conn._closed: # pylint: disable=protected-access + conn.close() + + def shutdown(self) -> None: + """ + Shutdown the server. + + Stops accepting new connections and closes the server. + """ + self._shutdown.set() + if self._server and self._loop and self._loop.is_running(): + # Schedule proper async cleanup + future = asyncio.run_coroutine_threadsafe(self._async_shutdown(), self._loop) + try: + future.result(timeout=2.0) + except Exception: # pylint: disable=broad-exception-caught + pass # Cleanup should not raise + if self._loop and self._loop.is_running(): + self._loop.call_soon_threadsafe(self._loop.stop) + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=2.0) + # Always close the loop if it exists and isn't closed + if self._loop and not self._loop.is_closed(): + self._loop.close() + + async def _async_shutdown(self) -> None: + """Async cleanup for server.""" + if self._server: + self._server.close() + try: + await self._server.wait_closed() + except Exception: # pylint: disable=broad-exception-caught + pass # Cleanup should not raise + # Cancel all pending tasks to avoid "Task was destroyed but pending" warnings + for task in asyncio.all_tasks(self._loop): + if task is not asyncio.current_task(): + task.cancel() + # Give cancelled tasks a chance to clean up + await asyncio.sleep(0) + + +class ServerConnection: + """ + Blocking interface for a server-side client connection. + + This is similar to :class:`TelnetConnection` but for server-side use. + Created automatically when a client connects to :class:`BlockingTelnetServer`. + + Provides miniboa-compatible properties for easier migration: + + - :attr:`active` - Connection state (set to False to disconnect) + - :attr:`address`, :attr:`port` - Client address info + - :attr:`terminal_type`, :attr:`columns`, :attr:`rows` - Terminal info + - :meth:`send` - Alias for :meth:`write` + - :meth:`addrport` - Returns "IP:PORT" string + - :meth:`idle`, :meth:`duration` - Timing information + - :meth:`deactivate` - Set active=False to queue disconnection + """ + + def __init__( + self, + reader: TelnetReader, + writer: TelnetWriter, + loop: asyncio.AbstractEventLoop, + ): + """Initialize connection from reader/writer pair.""" + self._reader = reader + self._writer = writer + self._loop = loop + self._closed = False + self._close_event = asyncio.Event() + self._connect_time = time.time() + self._last_input_time = time.time() + + async def _wait_closed(self) -> None: + """Wait for the connection to be closed (called from async shell).""" + await self._close_event.wait() + + def read(self, n: int = -1, timeout: Optional[float] = None) -> Union[str, bytes]: + """ + Read up to n bytes/characters from the connection. + + :param int n: Maximum bytes to read (-1 for any available data). + :param float timeout: Timeout in seconds. + :returns: Data read from connection. + :raises RuntimeError: If connection already closed. + :raises TimeoutError: If timeout expires. + :raises EOFError: If connection closed. + """ + if self._closed: + raise RuntimeError("Connection closed") + future = asyncio.run_coroutine_threadsafe(self._reader.read(n), self._loop) + try: + result = future.result(timeout=timeout) + if not result: + raise EOFError("Connection closed") + self._last_input_time = time.time() + return result + except asyncio.TimeoutError as exc: + future.cancel() + raise TimeoutError("Read timed out") from exc + + def read_some(self, timeout: Optional[float] = None) -> Union[str, bytes]: + """ + Read some data from the connection. + + Alias for :meth:`read` for compatibility with old telnetlib. + + :param float timeout: Timeout in seconds. + :returns: Data read from connection. + """ + return self.read(-1, timeout=timeout) + + def readline(self, timeout: Optional[float] = None) -> Union[str, bytes]: + """ + Read one line from the connection. + + :param float timeout: Timeout in seconds. + :returns: Line including terminator. + :raises RuntimeError: If connection already closed. + :raises TimeoutError: If timeout expires. + :raises EOFError: If connection closed. + """ + if self._closed: + raise RuntimeError("Connection closed") + future = asyncio.run_coroutine_threadsafe(self._reader.readline(), self._loop) + try: + result = future.result(timeout=timeout) + if not result: + raise EOFError("Connection closed") + self._last_input_time = time.time() + return result + except asyncio.TimeoutError as exc: + future.cancel() + raise TimeoutError("Readline timed out") from exc + + def read_until( + self, match: Union[str, bytes], timeout: Optional[float] = None + ) -> Union[str, bytes]: + """ + Read until match is found. + + :param match: String or bytes to match. + :param float timeout: Timeout in seconds. + :returns: Data up to and including match. + :raises RuntimeError: If connection already closed. + :raises TimeoutError: If timeout expires. + :raises EOFError: If connection closed. + """ + if self._closed: + raise RuntimeError("Connection closed") + # readuntil expects bytes, encode if string + if isinstance(match, str): + match = match.encode("utf-8") + future = asyncio.run_coroutine_threadsafe(self._reader.readuntil(match), self._loop) + try: + result = future.result(timeout=timeout) + self._last_input_time = time.time() + return result + except asyncio.TimeoutError as exc: + future.cancel() + raise TimeoutError("Read until timed out") from exc + except asyncio.IncompleteReadError as exc: + raise EOFError("Connection closed before match found") from exc + + def write(self, data: Union[str, bytes]) -> None: + """ + Write data to the connection. + + :param data: String or bytes to write. + :raises RuntimeError: If connection already closed. + """ + if self._closed: + raise RuntimeError("Connection closed") + self._loop.call_soon_threadsafe(self._writer.write, data) + + def flush(self, timeout: Optional[float] = None) -> None: + """ + Flush buffered data to the connection. + + :param float timeout: Timeout in seconds. + :raises RuntimeError: If connection already closed. + :raises TimeoutError: If timeout expires. + """ + if self._closed: + raise RuntimeError("Connection closed") + future = asyncio.run_coroutine_threadsafe(self._writer.drain(), self._loop) + try: + future.result(timeout=timeout) + except asyncio.TimeoutError as exc: + future.cancel() + raise TimeoutError("Flush timed out") from exc + + def close(self) -> None: + """Close the connection.""" + if self._closed: + return + self._closed = True + self._loop.call_soon_threadsafe(self._writer.close) + self._loop.call_soon_threadsafe(self._close_event.set) + + def get_extra_info(self, name: str, default: Any = None) -> Any: + """ + Get extra information about the connection. + + After negotiation completes, provides access to negotiated values: + + - ``'TERM'``: Terminal type (e.g., 'xterm-256color') + - ``'cols'``: Terminal width in columns + - ``'rows'``: Terminal height in rows + - ``'peername'``: Remote address tuple (host, port) + + :param str name: Information key. + :param default: Default value if key not found. + :returns: Information value or default. + """ + return self._writer.get_extra_info(name, default) + + def wait_for( + self, + remote: Optional[dict] = None, + local: Optional[dict] = None, + pending: Optional[dict] = None, + timeout: Optional[float] = None, + ) -> None: + """ + Wait for telnet option negotiation states. + + Blocks until the specified options reach their desired states. + + :param remote: Dict of options for remote state. + Example: ``{'NAWS': True, 'TTYPE': True}`` + :param local: Dict of options for local state. + :param pending: Dict of options for pending state. + :param timeout: Timeout in seconds. + :raises RuntimeError: If connection already closed. + :raises TimeoutError: If timeout expires. + + Example:: + + conn = server.accept() + conn.wait_for(remote={'NAWS': True}, timeout=5.0) + print(f"Window: {conn.columns}x{conn.rows}") + """ + if self._closed: + raise RuntimeError("Connection closed") + future = asyncio.run_coroutine_threadsafe( + self._writer.wait_for(remote=remote, local=local, pending=pending), + self._loop, + ) + try: + future.result(timeout=timeout) + except asyncio.TimeoutError as exc: + future.cancel() + raise TimeoutError("Wait for negotiation timed out") from exc + + @property + def writer(self) -> TelnetWriter: + """ + Access the underlying TelnetWriter for advanced operations. + + :returns: The underlying TelnetWriter instance. + """ + return self._writer + + # Miniboa-compatible properties and methods + + @property + def active(self) -> bool: + """ + Connection health status (miniboa-compatible). + + Set to False to disconnect on next opportunity. + """ + return not self._closed + + @active.setter + def active(self, value: bool) -> None: + if not value: + self.close() + + @property + def address(self) -> str: + """Remote IP address of the connected client (miniboa-compatible).""" + peername = self.get_extra_info("peername", ("", 0)) + return peername[0] if peername else "" + + @property + def port(self) -> int: + """Remote port number of the connected client (miniboa-compatible).""" + peername = self.get_extra_info("peername", ("", 0)) + return peername[1] if peername else 0 + + @property + def terminal_type(self) -> str: + """Client terminal type (miniboa-compatible).""" + return self.get_extra_info("TERM", "unknown") + + @property + def columns(self) -> int: + """Terminal width (miniboa-compatible).""" + return self.get_extra_info("cols", 80) + + @property + def rows(self) -> int: + """Terminal height (miniboa-compatible).""" + return self.get_extra_info("rows", 24) + + @property + def connect_time(self) -> float: + """Timestamp when connection was established (miniboa-compatible).""" + return self._connect_time + + @property + def last_input_time(self) -> float: + """Timestamp of last input received (miniboa-compatible).""" + return self._last_input_time + + def send(self, text: Union[str, bytes]) -> None: + r""" + Send text to the client (miniboa-compatible). + + Alias for :meth:`write`. Converts \n to \r\n like miniboa. + + :param text: Text to send. + """ + if isinstance(text, str): + text = text.replace("\n", "\r\n") + self.write(text) + + def addrport(self) -> str: + """ + Return client's IP:PORT as string (miniboa-compatible). + + :returns: String in format "IP:PORT". + """ + return f"{self.address}:{self.port}" + + def idle(self) -> float: + """ + Seconds since last input received (miniboa-compatible). + + :returns: Idle time in seconds. + """ + return time.time() - self._last_input_time + + def duration(self) -> float: + """ + Seconds since connection was established (miniboa-compatible). + + :returns: Connection duration in seconds. + """ + return time.time() - self._connect_time + + def deactivate(self) -> None: + """ + Set connection to disconnect on next opportunity (miniboa-compatible). + + Same as setting ``active = False``. + """ + self.active = False diff --git a/telnetlib3/telopt.py b/telnetlib3/telopt.py index b151a00..65513a1 100644 --- a/telnetlib3/telopt.py +++ b/telnetlib3/telopt.py @@ -167,6 +167,7 @@ "theNULL", "name_command", "name_commands", + "option_from_name", ) EOF, SUSP, ABORT, CMD_EOR = (bytes([const]) for const in range(236, 240)) @@ -266,6 +267,20 @@ ) } +#: Reverse mapping of option names to option bytes +_NAME_TO_OPT = {name: opt for opt, name in _DEBUG_OPTS.items()} + + +def option_from_name(name): + """ + Return option bytes for a given option name. + + :param str name: Option name (e.g., "NAWS", "TTYPE") + :returns: Option bytes + :raises KeyError: If name is not a known telnet option + """ + return _NAME_TO_OPT[name.upper()] + def name_command(byte): """Return string description for (maybe) telnet command byte.""" diff --git a/telnetlib3/tests/accessories.py b/telnetlib3/tests/accessories.py index 33fd752..a11fed7 100644 --- a/telnetlib3/tests/accessories.py +++ b/telnetlib3/tests/accessories.py @@ -23,7 +23,6 @@ async def server_context(server): finally: server.close() await server.wait_closed() - await asyncio.sleep(0) @contextlib.asynccontextmanager @@ -34,7 +33,6 @@ async def connection_context(reader, writer): finally: writer.close() await writer.wait_closed() - await asyncio.sleep(0) @contextlib.asynccontextmanager @@ -50,7 +48,6 @@ async def create_server(*args, **kwargs): finally: server.close() await server.wait_closed() - await asyncio.sleep(0) @contextlib.asynccontextmanager @@ -66,7 +63,6 @@ async def open_connection(*args, **kwargs): finally: writer.close() await writer.wait_closed() - await asyncio.sleep(0) @contextlib.asynccontextmanager @@ -81,7 +77,6 @@ async def asyncio_connection(host, port): await writer.wait_closed() except (BrokenPipeError, ConnectionResetError): pass - await asyncio.sleep(0) @contextlib.asynccontextmanager @@ -93,7 +88,6 @@ async def asyncio_server(protocol_factory, host, port): finally: server.close() await server.wait_closed() - await asyncio.sleep(0) __all__ = ( diff --git a/telnetlib3/tests/test_core.py b/telnetlib3/tests/test_core.py index 04a6760..d7735b3 100644 --- a/telnetlib3/tests/test_core.py +++ b/telnetlib3/tests/test_core.py @@ -30,28 +30,6 @@ async def test_create_server(bind_host, unused_tcp_port): pass -# disabled by jquast Sun Feb 17 13:44:15 PST 2019, -# we need to await completion of full negotiation, travis-ci -# is failing with additional, 'failed-reply:DO BINARY' -# async def test_open_connection(bind_host, unused_tcp_port): -# """Exercise telnetlib3.open_connection with default options.""" -# _waiter = asyncio.Future() -# await telnetlib3.create_server(bind_host, unused_tcp_port, -# _waiter_connected=_waiter, -# connect_maxwait=0.05) -# client_reader, client_writer = await telnetlib3.open_connection( -# bind_host, unused_tcp_port, connect_minwait=0.05) -# server = await asyncio.wait_for(_waiter, 0.5) -# assert repr(server.writer) == ( -# '') -# assert repr(client_writer) == ( -# '') - - async def test_create_server_conditionals(bind_host, unused_tcp_port): """Test telnetlib3.create_server conditionals.""" # local @@ -70,18 +48,25 @@ async def test_create_server_on_connect(bind_host, unused_tcp_port): # local from telnetlib3.tests.accessories import create_server, asyncio_connection - call_tracker = {"called": False} + call_tracker = {"called": False, "transport": None} class TrackingProtocol(asyncio.Protocol): def __init__(self): call_tracker["called"] = True + def connection_made(self, transport): + call_tracker["transport"] = transport + async with create_server( protocol_factory=TrackingProtocol, host=bind_host, port=unused_tcp_port ): async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): await asyncio.sleep(0.01) assert call_tracker["called"] + # Close server-side transport before server closes + if call_tracker["transport"]: + call_tracker["transport"].close() + await asyncio.sleep(0) async def test_telnet_server_open_close(bind_host, unused_tcp_port): @@ -90,18 +75,17 @@ async def test_telnet_server_open_close(bind_host, unused_tcp_port): from telnetlib3.telopt import IAC, WONT, TTYPE from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter = asyncio.Future() - async with create_server(_waiter_connected=_waiter, host=bind_host, port=unused_tcp_port): + async with create_server(host=bind_host, port=unused_tcp_port) as server: async with asyncio_connection(bind_host, unused_tcp_port) as ( stream_reader, stream_writer, ): stream_writer.write(IAC + WONT + TTYPE + b"bye\r") - server = await asyncio.wait_for(_waiter, 0.5) - server.writer.write("Goodbye!") - server.writer.close() - await server.writer.wait_closed() - assert server.writer.is_closing() + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) + srv_instance.writer.write("Goodbye!") + srv_instance.writer.close() + await srv_instance.writer.wait_closed() + assert srv_instance.writer.is_closing() result = await stream_reader.read() assert result == b"\xff\xfd\x18Goodbye!" @@ -172,10 +156,10 @@ def begin_advanced_negotiation(self): ): async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + WILL + TTYPE) - server = await asyncio.wait_for(_waiter, 0.5) + srv_instance = await asyncio.wait_for(_waiter, 0.5) - assert server.writer.remote_option[TTYPE] is True - assert server.writer.pending_option == { + assert srv_instance.writer.remote_option[TTYPE] is True + assert srv_instance.writer.pending_option == { # server's request to negotiation TTYPE affirmed DO + TTYPE: False, # server's request for TTYPE value unreplied @@ -193,33 +177,55 @@ def begin_advanced_negotiation(self): async def test_telnet_server_closed_by_client(bind_host, unused_tcp_port): """Exercise TelnetServer.connection_lost.""" # local + from telnetlib3.telopt import DO, IAC, WONT, TTYPE from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter = asyncio.Future() - - async with create_server(_waiter_closed=_waiter, host=bind_host, port=unused_tcp_port): + async with create_server(host=bind_host, port=unused_tcp_port) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): + # Read server's negotiation request and send minimal reply + expect_hello = IAC + DO + TTYPE + hello = await reader.readexactly(len(expect_hello)) + assert hello == expect_hello + writer.write(IAC + WONT + TTYPE) + + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) + + # Verify negotiation state: client refused TTYPE + assert srv_instance.writer.remote_option[TTYPE] is False + assert srv_instance.writer.pending_option.get(TTYPE) is not True + writer.close() await writer.wait_closed() - srv_instance = await asyncio.wait_for(_waiter, 0.5) + # Wait for server to notice client disconnect + await asyncio.sleep(0.05) assert srv_instance._closing - srv_instance.connection_lost(exc=None) - async def test_telnet_server_eof_by_client(bind_host, unused_tcp_port): """Exercise TelnetServer.eof_received().""" # local + from telnetlib3.telopt import DO, IAC, WONT, TTYPE from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter = asyncio.Future() - - async with create_server(_waiter_closed=_waiter, host=bind_host, port=unused_tcp_port): + async with create_server(host=bind_host, port=unused_tcp_port) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): + # Read server's negotiation request and send minimal reply + expect_hello = IAC + DO + TTYPE + hello = await reader.readexactly(len(expect_hello)) + assert hello == expect_hello + writer.write(IAC + WONT + TTYPE) + + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) + + # Verify negotiation state: client refused TTYPE + assert srv_instance.writer.remote_option[TTYPE] is False + assert srv_instance.writer.pending_option.get(TTYPE) is not True + writer.write_eof() - srv_instance = await asyncio.wait_for(_waiter, 0.5) + # Wait for server to notice EOF + await asyncio.sleep(0.05) assert srv_instance._closing @@ -229,15 +235,10 @@ async def test_telnet_server_closed_by_server(bind_host, unused_tcp_port): from telnetlib3.telopt import DO, IAC, WONT, TTYPE from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter_connected = asyncio.Future() - _waiter_closed = asyncio.Future() - async with create_server( - _waiter_connected=_waiter_connected, - _waiter_closed=_waiter_closed, host=bind_host, port=unused_tcp_port, - ): + ) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): expect_hello = IAC + DO + TTYPE hello_reply = IAC + WONT + TTYPE + b"quit" + b"\r\n" @@ -246,12 +247,22 @@ async def test_telnet_server_closed_by_server(bind_host, unused_tcp_port): assert hello == expect_hello writer.write(hello_reply) - server = await asyncio.wait_for(_waiter_connected, 0.5) + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) - server.writer.close() - await server.writer.wait_closed() + # Verify negotiation state: client refused TTYPE + assert srv_instance.writer.remote_option[TTYPE] is False + assert srv_instance.writer.pending_option.get(TTYPE) is not True - await asyncio.wait_for(_waiter_closed, 0.5) + # Verify in-band data was received + data = await asyncio.wait_for(srv_instance.reader.readline(), 0.5) + assert data == "quit\r\n" + + srv_instance.writer.close() + await srv_instance.writer.wait_closed() + + # Wait for server to process connection close + await asyncio.sleep(0.05) + assert srv_instance._closing async def test_telnet_server_idle_duration(bind_host, unused_tcp_port): @@ -260,21 +271,16 @@ async def test_telnet_server_idle_duration(bind_host, unused_tcp_port): from telnetlib3.telopt import IAC, WONT, TTYPE from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter_connected = asyncio.Future() - _waiter_closed = asyncio.Future() - async with create_server( - _waiter_connected=_waiter_connected, - _waiter_closed=_waiter_closed, host=bind_host, port=unused_tcp_port, - ): + ) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + WONT + TTYPE) - server = await asyncio.wait_for(_waiter_connected, 0.5) + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) - assert 0 <= server.idle <= 0.5 - assert 0 <= server.duration <= 0.5 + assert 0 <= srv_instance.idle <= 0.5 + assert 0 <= srv_instance.duration <= 0.5 async def test_telnet_client_idle_duration_minwait(bind_host, unused_tcp_port): @@ -306,27 +312,22 @@ async def test_telnet_server_closed_by_error(bind_host, unused_tcp_port): from telnetlib3.telopt import IAC, WONT, TTYPE from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter_connected = asyncio.Future() - _waiter_closed = asyncio.Future() - async with create_server( - _waiter_connected=_waiter_connected, - _waiter_closed=_waiter_closed, host=bind_host, port=unused_tcp_port, - ): + ) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + WONT + TTYPE) - server = await asyncio.wait_for(_waiter_connected, 0.5) + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) class CustomException(Exception): pass - server.writer.write("Bye!") - server.connection_lost(CustomException("blah!")) + srv_instance.writer.write("Bye!") + srv_instance.connection_lost(CustomException("blah!")) with pytest.raises(CustomException): - await server.reader.read() + await srv_instance.reader.read() async def test_telnet_client_open_close_by_error(bind_host, unused_tcp_port): @@ -353,23 +354,20 @@ async def test_telnet_server_negotiation_fail(bind_host, unused_tcp_port): from telnetlib3.telopt import DO, TTYPE from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter_connected = asyncio.Future() - async with create_server( - _waiter_connected=_waiter_connected, host=bind_host, port=unused_tcp_port, connect_maxwait=0.05, - ): + ) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): await reader.readexactly(3) # IAC DO TTYPE, we ignore it! - server = await asyncio.wait_for(_waiter_connected, 1.0) + srv_instance = await asyncio.wait_for(server.wait_for_client(), 1.0) - assert server.negotiation_should_advance() is False - assert server.writer.pending_option[DO + TTYPE] + assert srv_instance.negotiation_should_advance() is False + assert srv_instance.writer.pending_option[DO + TTYPE] - assert repr(server.writer) == ( + assert repr(srv_instance.writer) == ( "" @@ -513,7 +511,11 @@ def connection_made(self, transport): @pytest.mark.skipif( - tuple(map(int, platform.python_version_tuple())) > (3, 10), + sys.platform == "win32", + reason="pexpect.spawn requires Unix PTY", +) +@pytest.mark.skipif( + tuple(map(int, platform.python_version_tuple()[:2])) > (3, 10), reason="those shabby pexpect maintainers still use @asyncio.coroutine", ) async def test_telnet_client_tty_cmdline(bind_host, unused_tcp_port): diff --git a/telnetlib3/tests/test_encoding.py b/telnetlib3/tests/test_encoding.py index adf8618..804179e 100644 --- a/telnetlib3/tests/test_encoding.py +++ b/telnetlib3/tests/test_encoding.py @@ -22,18 +22,15 @@ async def test_telnet_server_encoding_default(bind_host, unused_tcp_port): from telnetlib3.telopt import IAC, WONT, TTYPE from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter = asyncio.Future() - async with create_server( host=bind_host, port=unused_tcp_port, - _waiter_connected=_waiter, connect_maxwait=0.05, - ): + ) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + WONT + TTYPE) - srv_instance = await asyncio.wait_for(_waiter, 0.5) + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) assert srv_instance.encoding(incoming=True) == "US-ASCII" assert srv_instance.encoding(outgoing=True) == "US-ASCII" assert srv_instance.encoding(incoming=True, outgoing=True) == "US-ASCII" @@ -64,14 +61,12 @@ async def test_telnet_server_encoding_client_will(bind_host, unused_tcp_port): from telnetlib3.telopt import IAC, WILL, WONT, TTYPE, BINARY from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter = asyncio.Future() - - async with create_server(host=bind_host, port=unused_tcp_port, _waiter_connected=_waiter): + async with create_server(host=bind_host, port=unused_tcp_port) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + WILL + BINARY) writer.write(IAC + WONT + TTYPE) - srv_instance = await asyncio.wait_for(_waiter, 0.5) + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) assert srv_instance.encoding(incoming=True) == "utf8" assert srv_instance.encoding(outgoing=True) == "US-ASCII" assert srv_instance.encoding(incoming=True, outgoing=True) == "US-ASCII" @@ -83,14 +78,13 @@ async def test_telnet_server_encoding_server_do(bind_host, unused_tcp_port): from telnetlib3.telopt import DO, IAC, WONT, TTYPE, BINARY from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter = asyncio.Future() - - async with create_server(host=bind_host, port=unused_tcp_port, _waiter_connected=_waiter): + async with create_server(host=bind_host, port=unused_tcp_port, connect_maxwait=0.5) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + DO + BINARY) writer.write(IAC + WONT + TTYPE) + await writer.drain() - srv_instance = await asyncio.wait_for(_waiter, 0.5) + srv_instance = await asyncio.wait_for(server.wait_for_client(), 2.0) assert srv_instance.encoding(incoming=True) == "US-ASCII" assert srv_instance.encoding(outgoing=True) == "utf8" assert srv_instance.encoding(incoming=True, outgoing=True) == "US-ASCII" @@ -102,20 +96,17 @@ async def test_telnet_server_encoding_bidirectional(bind_host, unused_tcp_port): from telnetlib3.telopt import DO, IAC, WILL, WONT, TTYPE, BINARY from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter = asyncio.Future() - async with create_server( host=bind_host, port=unused_tcp_port, - _waiter_connected=_waiter, connect_maxwait=0.05, - ): + ) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + DO + BINARY) writer.write(IAC + WILL + BINARY) writer.write(IAC + WONT + TTYPE) - srv_instance = await asyncio.wait_for(_waiter, 0.5) + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) assert srv_instance.encoding(incoming=True) == "utf8" assert srv_instance.encoding(outgoing=True) == "utf8" assert srv_instance.encoding(incoming=True, outgoing=True) == "utf8" @@ -126,19 +117,16 @@ async def test_telnet_client_and_server_encoding_bidirectional(bind_host, unused # local from telnetlib3.tests.accessories import create_server, open_connection - _waiter = asyncio.Future() - async with create_server( host=bind_host, port=unused_tcp_port, - _waiter_connected=_waiter, encoding="latin1", connect_maxwait=1.0, - ): + ) as server: async with open_connection( host=bind_host, port=unused_tcp_port, encoding="cp437", connect_minwait=1.0 ) as (reader, writer): - srv_instance = await asyncio.wait_for(_waiter, 1.5) + srv_instance = await asyncio.wait_for(server.wait_for_client(), 1.5) assert srv_instance.encoding(incoming=True) == "cp437" assert srv_instance.encoding(outgoing=True) == "cp437" @@ -154,9 +142,7 @@ async def test_telnet_server_encoding_by_LANG(bind_host, unused_tcp_port): from telnetlib3.telopt import DO, IS, SB, SE, IAC, WILL, WONT, TTYPE, BINARY, NEW_ENVIRON from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter = asyncio.Future() - - async with create_server(host=bind_host, port=unused_tcp_port, _waiter_connected=_waiter): + async with create_server(host=bind_host, port=unused_tcp_port) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + DO + BINARY) writer.write(IAC + WILL + BINARY) @@ -176,7 +162,7 @@ async def test_telnet_server_encoding_by_LANG(bind_host, unused_tcp_port): ) writer.write(IAC + WONT + TTYPE) - srv_instance = await asyncio.wait_for(_waiter, 0.5) + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) assert srv_instance.encoding(incoming=True) == "KOI8-U" assert srv_instance.encoding(outgoing=True) == "KOI8-U" assert srv_instance.encoding(incoming=True, outgoing=True) == "KOI8-U" @@ -189,8 +175,6 @@ async def test_telnet_server_binary_mode(bind_host, unused_tcp_port): from telnetlib3.telopt import DO, IAC, WONT, TTYPE from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter = asyncio.Future() - async def binary_shell(reader, writer): writer.write(b"server_output") @@ -207,9 +191,8 @@ async def binary_shell(reader, writer): host=bind_host, port=unused_tcp_port, shell=binary_shell, - _waiter_connected=_waiter, encoding=False, - ): + ) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): val = await reader.readexactly(len(IAC + DO + TTYPE)) assert val == IAC + DO + TTYPE @@ -229,26 +212,24 @@ async def test_telnet_client_and_server_escape_iac_encoding(bind_host, unused_tc # local from telnetlib3.tests.accessories import create_server, open_connection - _waiter = asyncio.Future() given_string = "".join(chr(val) for val in list(range(256))) * 2 async with create_server( host=bind_host, port=unused_tcp_port, - _waiter_connected=_waiter, encoding="iso8859-1", connect_maxwait=0.05, - ): + ) as server: async with open_connection( host=bind_host, port=unused_tcp_port, encoding="iso8859-1", connect_minwait=0.05 ) as (client_reader, client_writer): - server = await asyncio.wait_for(_waiter, 0.5) + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) - server.writer.write(given_string) + srv_instance.writer.write(given_string) result = await client_reader.readexactly(len(given_string)) assert result == given_string - server.writer.close() - await server.writer.wait_closed() + srv_instance.writer.close() + await srv_instance.writer.wait_closed() eof = await asyncio.wait_for(client_reader.read(), 0.5) assert not eof @@ -258,25 +239,23 @@ async def test_telnet_client_and_server_escape_iac_binary(bind_host, unused_tcp_ # local from telnetlib3.tests.accessories import create_server, open_connection - _waiter = asyncio.Future() given_string = bytes(range(256)) * 2 async with create_server( host=bind_host, port=unused_tcp_port, - _waiter_connected=_waiter, encoding=False, connect_maxwait=0.05, - ): + ) as server: async with open_connection( host=bind_host, port=unused_tcp_port, encoding=False, connect_minwait=0.05 ) as (client_reader, client_writer): - server = await asyncio.wait_for(_waiter, 0.5) + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) - server.writer.write(given_string) + srv_instance.writer.write(given_string) result = await client_reader.readexactly(len(given_string)) assert result == given_string - server.writer.close() - await server.writer.wait_closed() + srv_instance.writer.close() + await srv_instance.writer.wait_closed() eof = await asyncio.wait_for(client_reader.read(), 0.5) assert eof == b"" diff --git a/telnetlib3/tests/test_linemode.py b/telnetlib3/tests/test_linemode.py index d53aa7e..433e5d9 100644 --- a/telnetlib3/tests/test_linemode.py +++ b/telnetlib3/tests/test_linemode.py @@ -21,8 +21,6 @@ async def test_server_demands_remote_linemode_client_agrees( # pylint: disable= from telnetlib3.telopt import DO, SB, SE, IAC, WILL, LINEMODE from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter = asyncio.Future() - class ServerTestLinemode(telnetlib3.BaseServer): def begin_negotiation(self): super().begin_negotiation() @@ -33,8 +31,7 @@ def begin_negotiation(self): protocol_factory=ServerTestLinemode, host=bind_host, port=unused_tcp_port, - _waiter_connected=_waiter, - ): + ) as server: async with asyncio_connection(bind_host, unused_tcp_port) as ( client_reader, client_writer, @@ -55,8 +52,13 @@ def begin_negotiation(self): assert result == expect_stage2 client_writer.write(reply_stage2) - srv_instance = await asyncio.wait_for(_waiter, 0.1) - assert not any(srv_instance.writer.pending_option.values()) + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.1) + await asyncio.wait_for( + srv_instance.writer.wait_for( + remote={"LINEMODE": True}, pending={"LINEMODE": False} + ), + 0.1, + ) result = await client_reader.read() assert result == b"" @@ -68,7 +70,6 @@ def begin_negotiation(self): assert srv_instance.writer.linemode.ack is True assert srv_instance.writer.linemode.soft_tab is False assert srv_instance.writer.linemode.lit_echo is True - assert srv_instance.writer.remote_option.enabled(LINEMODE) async def test_server_demands_remote_linemode_client_demands_local( # pylint: disable=too-many-locals @@ -79,8 +80,6 @@ async def test_server_demands_remote_linemode_client_demands_local( # pylint: d from telnetlib3.telopt import DO, SB, SE, IAC, WILL, LINEMODE from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter = asyncio.Future() - class ServerTestLinemode(telnetlib3.BaseServer): def begin_negotiation(self): super().begin_negotiation() @@ -91,8 +90,7 @@ def begin_negotiation(self): protocol_factory=ServerTestLinemode, host=bind_host, port=unused_tcp_port, - _waiter_connected=_waiter, - ): + ) as server: async with asyncio_connection(bind_host, unused_tcp_port) as ( client_reader, client_writer, @@ -114,8 +112,13 @@ def begin_negotiation(self): assert result == expect_stage2 client_writer.write(reply_stage2) - srv_instance = await asyncio.wait_for(_waiter, 0.1) - assert not any(srv_instance.writer.pending_option.values()) + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.1) + await asyncio.wait_for( + srv_instance.writer.wait_for( + remote={"LINEMODE": True}, pending={"LINEMODE": False} + ), + 0.1, + ) result = await client_reader.read() assert result == b"" @@ -127,4 +130,3 @@ def begin_negotiation(self): assert srv_instance.writer.linemode.ack is True assert srv_instance.writer.linemode.soft_tab is False assert srv_instance.writer.linemode.lit_echo is False - assert srv_instance.writer.remote_option.enabled(LINEMODE) diff --git a/telnetlib3/tests/test_naws.py b/telnetlib3/tests/test_naws.py index 8010c69..8c0b130 100644 --- a/telnetlib3/tests/test_naws.py +++ b/telnetlib3/tests/test_naws.py @@ -5,6 +5,7 @@ """ # std imports +import sys import struct import asyncio import platform @@ -83,7 +84,11 @@ def on_naws(self, rows, cols): @pytest.mark.skipif( - tuple(map(int, platform.python_version_tuple())) > (3, 10), + sys.platform == "win32", + reason="pexpect.spawn requires Unix PTY", +) +@pytest.mark.skipif( + tuple(map(int, platform.python_version_tuple()[:2])) > (3, 10), reason="those shabby pexpect maintainers still use @asyncio.coroutine", ) async def test_telnet_client_send_tty_naws(bind_host, unused_tcp_port): diff --git a/telnetlib3/tests/test_reader.py b/telnetlib3/tests/test_reader.py index bc364b6..9d59cc3 100644 --- a/telnetlib3/tests/test_reader.py +++ b/telnetlib3/tests/test_reader.py @@ -310,9 +310,11 @@ async def test_telnet_reader_readuntil_pattern_success(bind_host, unused_tcp_por pattern = re.compile(rb"\S+[>#]") limit = 50 - def shell(_, writer): + async def shell(_, writer): writer.write(given_shell_banner) + await writer.drain() writer.close() + await writer.wait_closed() async with create_server( host=bind_host, @@ -361,9 +363,11 @@ async def test_telnet_reader_readuntil_pattern_limit_overrun_chunk_too_large( pattern = re.compile(rb"\S+[>#]") limit = 30 - def shell(_, writer): + async def shell(_, writer): writer.write(given_shell_banner) + await writer.drain() writer.close() + await writer.wait_closed() async with create_server( host=bind_host, @@ -414,9 +418,11 @@ async def test_telnet_reader_readuntil_pattern_limit_overrun_buffer_full( pattern = re.compile(rb"\S+[>#]") limit = 30 - def shell(_, writer): + async def shell(_, writer): writer.write(given_shell_banner) + await writer.drain() writer.close() + await writer.wait_closed() async with create_server( host=bind_host, @@ -455,9 +461,11 @@ async def test_telnet_reader_readuntil_pattern_incomplete_read_eof(bind_host, un pattern = re.compile(rb"\S+[>#]") limit = 50 - def shell(_, writer): + async def shell(_, writer): writer.write(given_shell_banner) + await writer.drain() writer.close() + await writer.wait_closed() async with create_server( host=bind_host, @@ -514,9 +522,11 @@ async def test_telnet_reader_readuntil_pattern_cancelled_error(bind_host, unused pattern = re.compile(rb"\S+[>#]") limit = 50 - def shell(_, writer): + async def shell(_, writer): writer.write(given_shell_banner) + await writer.drain() writer.close() + await writer.wait_closed() async with create_server( host=bind_host, diff --git a/telnetlib3/tests/test_server_api.py b/telnetlib3/tests/test_server_api.py new file mode 100644 index 0000000..d819b7b --- /dev/null +++ b/telnetlib3/tests/test_server_api.py @@ -0,0 +1,129 @@ +# pylint: disable=unused-import +# std imports +import asyncio + +# local +from telnetlib3.telopt import IAC, WONT, TTYPE +from telnetlib3.tests.accessories import bind_host # pytest fixture +from telnetlib3.tests.accessories import unused_tcp_port # pytest fixture +from telnetlib3.tests.accessories import ( + create_server, + asyncio_connection, +) + + +async def test_server_wait_for_client(bind_host, unused_tcp_port): + """Test Server.wait_for_client() returns protocol after negotiation.""" + async with create_server( + host=bind_host, + port=unused_tcp_port, + connect_maxwait=0.05, + ) as server: + async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): + writer.write(IAC + WONT + TTYPE) + client = await asyncio.wait_for(server.wait_for_client(), 0.5) + assert client is not None + assert hasattr(client, "writer") + assert hasattr(client, "reader") + + +async def test_server_clients_list(bind_host, unused_tcp_port): + """Test Server.clients property returns list of connected protocols.""" + async with create_server( + host=bind_host, + port=unused_tcp_port, + connect_maxwait=0.05, + ) as server: + assert server.clients == [] + + async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): + writer.write(IAC + WONT + TTYPE) + client = await asyncio.wait_for(server.wait_for_client(), 0.5) + assert len(server.clients) == 1 + # client is a weakref proxy, clients[0] is actual protocol + assert server.clients[0] == client + + +async def test_server_client_disconnect_cleanup(bind_host, unused_tcp_port): + """Test that clients are removed from list on disconnect.""" + async with create_server( + host=bind_host, + port=unused_tcp_port, + connect_maxwait=0.05, + ) as server: + async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): + writer.write(IAC + WONT + TTYPE) + client = await asyncio.wait_for(server.wait_for_client(), 0.5) + assert len(server.clients) == 1 + + # Connection closed, wait a moment for cleanup + await asyncio.sleep(0.05) + assert len(server.clients) == 0 + + +async def test_server_is_serving(bind_host, unused_tcp_port): + """Test Server.is_serving() delegates to underlying server.""" + async with create_server( + host=bind_host, + port=unused_tcp_port, + ) as server: + assert server.is_serving() is True + + # After context exit, server is closed + assert server.is_serving() is False + + +async def test_server_sockets(bind_host, unused_tcp_port): + """Test Server.sockets property returns socket list.""" + async with create_server( + host=bind_host, + port=unused_tcp_port, + ) as server: + assert server.sockets is not None + assert len(server.sockets) > 0 + + +async def test_server_with_wait_for(bind_host, unused_tcp_port): + """Test integration of Server.wait_for_client() with writer.wait_for().""" + # local + from telnetlib3.telopt import WILL, BINARY + + async with create_server( + host=bind_host, + port=unused_tcp_port, + connect_maxwait=0.05, + ) as server: + async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): + # Send WILL BINARY and WONT TTYPE + writer.write(IAC + WILL + BINARY) + writer.write(IAC + WONT + TTYPE) + + client = await asyncio.wait_for(server.wait_for_client(), 0.5) + + # Use wait_for to check specific negotiation state + await asyncio.wait_for(client.writer.wait_for(remote={"BINARY": True}), 0.5) + assert client.writer.remote_option[BINARY] is True + + +async def test_server_multiple_sequential_clients(bind_host, unused_tcp_port): + """Test wait_for_client() works for multiple sequential connections.""" + async with create_server( + host=bind_host, + port=unused_tcp_port, + connect_maxwait=0.05, + ) as server: + # First client + async with asyncio_connection(bind_host, unused_tcp_port) as (reader1, writer1): + writer1.write(IAC + WONT + TTYPE) + client1 = await asyncio.wait_for(server.wait_for_client(), 0.5) + assert client1 is not None + + # Wait for first client to disconnect + await asyncio.sleep(0.05) + + # Second client + async with asyncio_connection(bind_host, unused_tcp_port) as (reader2, writer2): + writer2.write(IAC + WONT + TTYPE) + client2 = await asyncio.wait_for(server.wait_for_client(), 0.5) + assert client2 is not None + assert client2 is not client1 diff --git a/telnetlib3/tests/test_server_cli.py b/telnetlib3/tests/test_server_cli.py new file mode 100644 index 0000000..1dd7230 --- /dev/null +++ b/telnetlib3/tests/test_server_cli.py @@ -0,0 +1,89 @@ +"""Tests for server CLI argument parsing and PTY support detection.""" + +# std imports +import sys +from unittest import mock + +# 3rd party +import pytest + + +def test_pty_support_detection_with_modules(): + """PTY_SUPPORT is True when all required modules are available.""" + # local + from telnetlib3 import server + + if sys.platform == "win32": + assert server.PTY_SUPPORT is False + else: + assert server.PTY_SUPPORT is True + + +def test_parse_server_args_includes_pty_options_when_supported(): + """CLI parser includes --pty-exec when PTY is supported.""" + # local + from telnetlib3 import server + + if not server.PTY_SUPPORT: + pytest.skip("PTY not supported on this platform") + + with mock.patch.object(sys, "argv", ["server"]): + result = server.parse_server_args() + assert "pty_exec" in result + assert "pty_fork_limit" in result + + +def test_parse_server_args_excludes_pty_options_when_not_supported(): + """CLI parser sets PTY options to defaults when PTY is not supported.""" + # local + from telnetlib3 import server + + original_support = server.PTY_SUPPORT + try: + server.PTY_SUPPORT = False + with mock.patch.object(sys, "argv", ["server"]): + result = server.parse_server_args() + assert result["pty_exec"] is None + assert result["pty_fork_limit"] == 0 + assert result["pty_args"] is None + finally: + server.PTY_SUPPORT = original_support + + +def test_run_server_raises_on_pty_exec_without_support(): + """run_server raises NotImplementedError when pty_exec is used without PTY support.""" + # local + from telnetlib3 import server + + original_support = server.PTY_SUPPORT + try: + server.PTY_SUPPORT = False + with pytest.raises(NotImplementedError, match="PTY support is not available"): + # std imports + import asyncio + + asyncio.run(server.run_server(pty_exec="/bin/bash")) + finally: + server.PTY_SUPPORT = original_support + + +def test_telnetlib3_import_exposes_pty_support(): + """Telnetlib3 package exposes PTY_SUPPORT flag.""" + # local + import telnetlib3 + + assert hasattr(telnetlib3, "PTY_SUPPORT") + assert isinstance(telnetlib3.PTY_SUPPORT, bool) + + +def test_telnetlib3_pty_shell_exports_conditional(): + """pty_shell exports are only in __all__ when PTY is supported.""" + # local + import telnetlib3 + + if telnetlib3.PTY_SUPPORT: + assert "make_pty_shell" in telnetlib3.__all__ + assert "pty_shell" in telnetlib3.__all__ + else: + assert "make_pty_shell" not in telnetlib3.__all__ + assert "pty_shell" not in telnetlib3.__all__ diff --git a/telnetlib3/tests/test_shell.py b/telnetlib3/tests/test_shell.py index 4b065f5..266c2eb 100644 --- a/telnetlib3/tests/test_shell.py +++ b/telnetlib3/tests/test_shell.py @@ -103,20 +103,19 @@ async def test_telnet_server_no_shell(bind_host, unused_tcp_port): from telnetlib3.telopt import DO, IAC, WONT, TTYPE from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter = asyncio.Future() client_expected = IAC + DO + TTYPE + b"beta" - async with create_server(_waiter_connected=_waiter, host=bind_host, port=unused_tcp_port): + async with create_server(host=bind_host, port=unused_tcp_port) as server: async with asyncio_connection(bind_host, unused_tcp_port) as ( client_reader, client_writer, ): client_writer.write(IAC + WONT + TTYPE + b"alpha") - server = await asyncio.wait_for(_waiter, 0.5) - server.writer.write("beta") - server.writer.close() - await server.writer.wait_closed() + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) + srv_instance.writer.write("beta") + srv_instance.writer.close() + await srv_instance.writer.wait_closed() client_recv = await client_reader.read() assert client_recv == client_expected @@ -130,16 +129,14 @@ async def test_telnet_server_given_shell( from telnetlib3.telopt import DO, IAC, SGA, ECHO, WILL, WONT, TTYPE, BINARY from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter = asyncio.Future() async with create_server( host=bind_host, port=unused_tcp_port, shell=telnet_server_shell, - _waiter_connected=_waiter, connect_maxwait=0.05, timeout=1.25, limit=13377, - ): + ) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): expected = IAC + DO + TTYPE result = await asyncio.wait_for(reader.readexactly(len(expected)), 0.5) @@ -151,8 +148,8 @@ async def test_telnet_server_given_shell( result = await asyncio.wait_for(reader.readexactly(len(expected)), 0.5) assert result == expected - server = await asyncio.wait_for(_waiter, 0.5) - server_port = str(server._transport.get_extra_info("peername")[1]) + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) + server_port = str(srv_instance._transport.get_extra_info("peername")[1]) # Command & Response table cmd_output_table = ( @@ -305,18 +302,15 @@ async def test_telnet_server_shell_eof(bind_host, unused_tcp_port): from telnetlib3.telopt import IAC, WONT, TTYPE from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter_connected = asyncio.Future() - _waiter_closed = asyncio.Future() - async with create_server( host=bind_host, port=unused_tcp_port, - _waiter_connected=_waiter_connected, - _waiter_closed=_waiter_closed, shell=telnet_server_shell, timeout=0.25, - ): + ) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + WONT + TTYPE) - await asyncio.wait_for(_waiter_connected, 0.5) - await asyncio.wait_for(_waiter_closed, 0.5) + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) + # Wait for server to process client disconnect + await asyncio.sleep(0.05) + assert srv_instance._closing diff --git a/telnetlib3/tests/test_sync.py b/telnetlib3/tests/test_sync.py new file mode 100644 index 0000000..dbc691f --- /dev/null +++ b/telnetlib3/tests/test_sync.py @@ -0,0 +1,403 @@ +"""Tests for the synchronous (blocking) interface.""" + +# pylint: disable=unused-import + +# std imports +import time +import threading + +# 3rd party +import pytest + +# local +from telnetlib3.sync import ServerConnection, TelnetConnection, BlockingTelnetServer +from telnetlib3.tests.accessories import bind_host, unused_tcp_port # pytest fixtures + + +def test_client_connect_and_close(bind_host, unused_tcp_port): + """TelnetConnection connects and closes properly.""" + server = BlockingTelnetServer(bind_host, unused_tcp_port) + server.start() + + conn = TelnetConnection(bind_host, unused_tcp_port, timeout=5) + conn.connect() + assert conn._connected.is_set() + conn.close() + + server.shutdown() + + +def test_client_context_manager(bind_host, unused_tcp_port): + """TelnetConnection works as context manager.""" + server = BlockingTelnetServer(bind_host, unused_tcp_port) + server.start() + + with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + assert conn._connected.is_set() + + server.shutdown() + + +def test_client_read_write(bind_host, unused_tcp_port): + """TelnetConnection and ServerConnection read/write work correctly.""" + + def handler(server_conn): + data = server_conn.read(5, timeout=5) + server_conn.write(data.upper()) + server_conn.flush(timeout=5) + + server = BlockingTelnetServer(bind_host, unused_tcp_port, handler=handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + time.sleep(0.1) + + with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + conn.write("hello") + conn.flush() + assert conn.read(5, timeout=5) == "HELLO" + + server.shutdown() + + +def test_client_readline(bind_host, unused_tcp_port): + """TelnetConnection readline works correctly.""" + + def handler(server_conn): + server_conn.write("Hello, World!\r\n") + server_conn.flush(timeout=5) + + server = BlockingTelnetServer(bind_host, unused_tcp_port, handler=handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + time.sleep(0.1) + + with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + line = conn.readline(timeout=5) + assert "Hello, World!" in line + + server.shutdown() + + +def test_client_read_until(bind_host, unused_tcp_port): + """TelnetConnection read_until works correctly.""" + + def handler(server_conn): + server_conn.write(">>> ") + server_conn.flush(timeout=5) + + server = BlockingTelnetServer(bind_host, unused_tcp_port, handler=handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + time.sleep(0.1) + + with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + data = conn.read_until(">>> ", timeout=5) + assert data.endswith(b">>> ") + + server.shutdown() + + +def test_client_read_some_alias(bind_host, unused_tcp_port): + """TelnetConnection read_some is alias for read.""" + + def handler(server_conn): + server_conn.write("test") + server_conn.flush(timeout=5) + + server = BlockingTelnetServer(bind_host, unused_tcp_port, handler=handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + time.sleep(0.1) + + with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + assert "test" in conn.read_some(timeout=5) + + server.shutdown() + + +def test_client_not_connected_error(): + """Operations fail when not connected.""" + conn = TelnetConnection("localhost", 12345) + with pytest.raises(RuntimeError, match="Not connected"): + conn.read() + with pytest.raises(RuntimeError, match="Not connected"): + conn.write("test") + + +def test_client_already_connected_error(bind_host, unused_tcp_port): + """Connect fails if already connected.""" + server = BlockingTelnetServer(bind_host, unused_tcp_port) + server.start() + + with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + with pytest.raises(RuntimeError, match="Already connected"): + conn.connect() + + server.shutdown() + + +def test_server_start_and_shutdown(bind_host, unused_tcp_port): + """BlockingTelnetServer starts and shuts down properly.""" + server = BlockingTelnetServer(bind_host, unused_tcp_port) + server.start() + assert server._started.is_set() + server.shutdown() + + +def test_server_accept(bind_host, unused_tcp_port): + """BlockingTelnetServer accepts connections.""" + server = BlockingTelnetServer(bind_host, unused_tcp_port) + server.start() + + def client_thread(): + time.sleep(0.1) + with TelnetConnection(bind_host, unused_tcp_port, timeout=5): + time.sleep(0.5) + + thread = threading.Thread(target=client_thread, daemon=True) + thread.start() + + conn = server.accept(timeout=5) + assert isinstance(conn, ServerConnection) + conn.close() + server.shutdown() + + +def test_server_serve_forever(bind_host, unused_tcp_port): + """BlockingTelnetServer serve_forever with handler.""" + received = [] + + def handler(conn): + received.append(conn.read(4, timeout=5)) + conn.write(received[-1].upper()) + conn.flush(timeout=5) + + server = BlockingTelnetServer(bind_host, unused_tcp_port, handler=handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + time.sleep(0.2) + + with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + conn.write("test") + conn.flush() + assert conn.read(4, timeout=5) == "TEST" + + server.shutdown() + assert received == ["test"] + + +def test_server_serve_forever_no_handler_error(bind_host, unused_tcp_port): + """serve_forever raises without handler.""" + server = BlockingTelnetServer(bind_host, unused_tcp_port) + with pytest.raises(RuntimeError, match="No handler provided"): + server.serve_forever() + + +def test_server_accept_not_started_error(bind_host, unused_tcp_port): + """Accept raises if server not started.""" + server = BlockingTelnetServer(bind_host, unused_tcp_port) + with pytest.raises(RuntimeError, match="Server not started"): + server.accept() + + +def test_server_accept_timeout(bind_host, unused_tcp_port): + """Accept times out when no client connects.""" + server = BlockingTelnetServer(bind_host, unused_tcp_port) + server.start() + with pytest.raises(TimeoutError, match="Accept timed out"): + server.accept(timeout=0.1) + server.shutdown() + + +def test_server_connection_read_write(bind_host, unused_tcp_port): + """ServerConnection read and write work correctly.""" + server = BlockingTelnetServer(bind_host, unused_tcp_port) + server.start() + + def client_thread(): + time.sleep(0.1) + with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + conn.write("hello") + conn.flush() + assert conn.read(5, timeout=5) == "HELLO" + + thread = threading.Thread(target=client_thread) + thread.start() + + conn = server.accept(timeout=5) + data = conn.read(5, timeout=5) + conn.write(data.upper()) + conn.flush(timeout=5) + conn.close() + + thread.join(timeout=5) + server.shutdown() + + +def test_server_connection_closed_error(bind_host, unused_tcp_port): + """Operations fail on closed ServerConnection.""" + server = BlockingTelnetServer(bind_host, unused_tcp_port) + server.start() + + def client_thread(): + time.sleep(0.1) + with TelnetConnection(bind_host, unused_tcp_port, timeout=5): + time.sleep(0.1) + + thread = threading.Thread(target=client_thread, daemon=True) + thread.start() + + conn = server.accept(timeout=5) + conn.close() + + with pytest.raises(RuntimeError, match="Connection closed"): + conn.read() + with pytest.raises(RuntimeError, match="Connection closed"): + conn.write("test") + + server.shutdown() + + +def test_server_connection_miniboa_properties(bind_host, unused_tcp_port): + """ServerConnection has miniboa-compatible properties.""" + server = BlockingTelnetServer(bind_host, unused_tcp_port) + server.start() + + def client_thread(): + time.sleep(0.1) + with TelnetConnection(bind_host, unused_tcp_port, timeout=5): + time.sleep(0.5) + + thread = threading.Thread(target=client_thread, daemon=True) + thread.start() + + conn = server.accept(timeout=5) + + assert conn.active is True + assert conn.address == bind_host + assert isinstance(conn.port, int) and conn.port > 0 + assert conn.terminal_type == "unknown" + assert conn.columns == 80 + assert conn.rows == 25 + assert isinstance(conn.connect_time, float) + assert isinstance(conn.last_input_time, float) + + conn.close() + assert conn.active is False + server.shutdown() + + +def test_server_connection_miniboa_methods(bind_host, unused_tcp_port): + """ServerConnection has miniboa-compatible methods.""" + server = BlockingTelnetServer(bind_host, unused_tcp_port) + server.start() + + def client_thread(): + time.sleep(0.1) + with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + conn.write("test\r\n") + conn.flush() + time.sleep(0.5) + + thread = threading.Thread(target=client_thread, daemon=True) + thread.start() + + conn = server.accept(timeout=5) + + assert f"{bind_host}:" in conn.addrport() + assert conn.idle() >= 0 + assert conn.duration() >= 0 + + time.sleep(0.05) + assert conn.idle() >= 0.05 + + conn.readline(timeout=5) + assert conn.idle() < 0.1 + + conn.deactivate() + assert conn.active is False + server.shutdown() + + +def test_server_connection_send_converts_newlines(bind_host, unused_tcp_port): + """ServerConnection send() converts \\n to \\r\\n like miniboa.""" + server = BlockingTelnetServer(bind_host, unused_tcp_port) + server.start() + + received = [] + + def client_thread(): + time.sleep(0.1) + with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + received.append(conn.read(20, timeout=5)) + + thread = threading.Thread(target=client_thread) + thread.start() + + conn = server.accept(timeout=5) + conn.send("Hello\nWorld\n") + conn.flush(timeout=5) + conn.close() + + thread.join(timeout=5) + server.shutdown() + + assert len(received) == 1 + assert "\r\n" in received[0] + + +def test_client_writer_property(bind_host, unused_tcp_port): + """TelnetConnection.writer exposes underlying TelnetWriter.""" + server = BlockingTelnetServer(bind_host, unused_tcp_port) + server.start() + + with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + writer = conn.writer + assert writer is not None + assert hasattr(writer, "mode") + assert hasattr(writer, "remote_option") + assert hasattr(writer, "local_option") + + server.shutdown() + + +def test_server_connection_writer_property(bind_host, unused_tcp_port): + """ServerConnection.writer exposes underlying TelnetWriter.""" + server = BlockingTelnetServer(bind_host, unused_tcp_port) + server.start() + + def client_thread(): + time.sleep(0.1) + with TelnetConnection(bind_host, unused_tcp_port, timeout=5): + time.sleep(0.5) + + thread = threading.Thread(target=client_thread, daemon=True) + thread.start() + + conn = server.accept(timeout=5) + writer = conn.writer + assert writer is not None + assert hasattr(writer, "mode") + assert hasattr(writer, "remote_option") + assert hasattr(writer, "local_option") + + conn.close() + server.shutdown() + + +def test_client_get_extra_info(bind_host, unused_tcp_port): + """TelnetConnection.get_extra_info returns connection metadata.""" + server = BlockingTelnetServer(bind_host, unused_tcp_port) + server.start() + + with TelnetConnection(bind_host, unused_tcp_port, timeout=5) as conn: + peername = conn.get_extra_info("peername") + assert peername is not None + assert len(peername) == 2 + assert isinstance(peername[1], int) + + # Non-existent key returns default + assert conn.get_extra_info("nonexistent") is None + assert conn.get_extra_info("nonexistent", "default") == "default" + + server.shutdown() diff --git a/telnetlib3/tests/test_timeout.py b/telnetlib3/tests/test_timeout.py index 159e5d9..38d289c 100644 --- a/telnetlib3/tests/test_timeout.py +++ b/telnetlib3/tests/test_timeout.py @@ -18,23 +18,21 @@ async def test_telnet_server_default_timeout(bind_host, unused_tcp_port): from telnetlib3.telopt import IAC, WONT, TTYPE from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter = asyncio.Future() given_timeout = 19.29 async with create_server( - _waiter_connected=_waiter, host=bind_host, port=unused_tcp_port, timeout=given_timeout, - ): + ) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + WONT + TTYPE) - server = await asyncio.wait_for(_waiter, 0.5) - assert server.get_extra_info("timeout") == given_timeout + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) + assert srv_instance.get_extra_info("timeout") == given_timeout - server.set_timeout() - assert server.get_extra_info("timeout") == given_timeout + srv_instance.set_timeout() + assert srv_instance.get_extra_info("timeout") == given_timeout async def test_telnet_server_set_timeout(bind_host, unused_tcp_port): @@ -43,19 +41,17 @@ async def test_telnet_server_set_timeout(bind_host, unused_tcp_port): from telnetlib3.telopt import IAC, WONT, TTYPE from telnetlib3.tests.accessories import create_server, asyncio_connection - _waiter = asyncio.Future() - - async with create_server(_waiter_connected=_waiter, host=bind_host, port=unused_tcp_port): + async with create_server(host=bind_host, port=unused_tcp_port) as server: async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): writer.write(IAC + WONT + TTYPE) - server = await asyncio.wait_for(_waiter, 0.5) + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) for value in (19.29, 0): - server.set_timeout(value) - assert server.get_extra_info("timeout") == value + srv_instance.set_timeout(value) + assert srv_instance.get_extra_info("timeout") == value - server.set_timeout() - assert server.get_extra_info("timeout") == 0 + srv_instance.set_timeout() + assert srv_instance.get_extra_info("timeout") == 0 async def test_telnet_server_waitfor_timeout(bind_host, unused_tcp_port): diff --git a/telnetlib3/tests/test_writer.py b/telnetlib3/tests/test_writer.py index 1596edd..5fe1d04 100644 --- a/telnetlib3/tests/test_writer.py +++ b/telnetlib3/tests/test_writer.py @@ -213,17 +213,12 @@ async def test_send_iac_dont_dont(bind_host, unused_tcp_port): from telnetlib3.telopt import DONT, ECHO from telnetlib3.tests.accessories import create_server, open_connection - _waiter_connected = asyncio.Future() - _waiter_closed = asyncio.Future() - async with create_server( protocol_factory=telnetlib3.BaseServer, host=bind_host, port=unused_tcp_port, connect_maxwait=0.05, - _waiter_connected=_waiter_connected, - _waiter_closed=_waiter_closed, - ): + ) as server: async with open_connection( host=bind_host, port=unused_tcp_port, connect_minwait=0.05, connect_maxwait=0.05 ) as (_, client_writer): @@ -235,9 +230,11 @@ async def test_send_iac_dont_dont(bind_host, unused_tcp_port): result = client_writer.iac(DONT, ECHO) assert result is False - server_writer = (await asyncio.wait_for(_waiter_connected, 0.5)).writer + srv_instance = await asyncio.wait_for(server.wait_for_client(), 0.5) + server_writer = srv_instance.writer - await asyncio.wait_for(_waiter_closed, 0.5) + # Wait for server to process client disconnect + await asyncio.sleep(0.05) assert client_writer.remote_option[ECHO] is False, client_writer.remote_option assert server_writer.local_option[ECHO] is False, server_writer.local_option @@ -573,3 +570,169 @@ async def _drain_helper(self): # Test calling wait_closed() after close() - should complete immediately await writer.wait_closed() # Should complete immediately + + +def test_option_from_name(): + """Test option_from_name returns correct option bytes.""" + # local + from telnetlib3.telopt import ECHO, NAWS, TTYPE, option_from_name + + assert option_from_name("NAWS") == NAWS + assert option_from_name("naws") == NAWS + assert option_from_name("TTYPE") == TTYPE + assert option_from_name("ECHO") == ECHO + + with pytest.raises(KeyError): + option_from_name("INVALID_OPTION") + + +async def test_wait_for_immediate_return(): + """Test wait_for returns immediately when conditions already met.""" + # local + from telnetlib3.telopt import ECHO + + writer = telnetlib3.TelnetWriter(transport=None, protocol=None, server=True) + writer.remote_option[ECHO] = True + + result = await writer.wait_for(remote={"ECHO": True}) + assert result is True + + +async def test_wait_for_remote_option(): + """Test wait_for waits for remote option to become true.""" + # local + from telnetlib3.telopt import ECHO + + writer = telnetlib3.TelnetWriter(transport=None, protocol=None, server=True) + + async def set_option_later(): + await asyncio.sleep(0.01) + writer.remote_option[ECHO] = True + + task = asyncio.create_task(set_option_later()) + result = await asyncio.wait_for(writer.wait_for(remote={"ECHO": True}), 0.5) + assert result is True + await task + + +async def test_wait_for_local_option(): + """Test wait_for waits for local option to become true.""" + # local + from telnetlib3.telopt import ECHO + + writer = telnetlib3.TelnetWriter(transport=None, protocol=None, server=True) + + async def set_option_later(): + await asyncio.sleep(0.01) + writer.local_option[ECHO] = True + + task = asyncio.create_task(set_option_later()) + result = await asyncio.wait_for(writer.wait_for(local={"ECHO": True}), 0.5) + assert result is True + await task + + +async def test_wait_for_pending_false(): + """Test wait_for waits for pending option to become false.""" + # local + from telnetlib3.telopt import DO, TTYPE + + writer = telnetlib3.TelnetWriter(transport=None, protocol=None, server=True) + writer.pending_option[DO + TTYPE] = True + + async def clear_pending_later(): + await asyncio.sleep(0.01) + writer.pending_option[DO + TTYPE] = False + + task = asyncio.create_task(clear_pending_later()) + result = await asyncio.wait_for(writer.wait_for(pending={"TTYPE": False}), 0.5) + assert result is True + await task + + +async def test_wait_for_combined_conditions(): + """Test wait_for with multiple conditions.""" + # local + from telnetlib3.telopt import ECHO, NAWS + + writer = telnetlib3.TelnetWriter(transport=None, protocol=None, server=True) + + async def set_options_later(): + await asyncio.sleep(0.01) + writer.remote_option[ECHO] = True + await asyncio.sleep(0.01) + writer.local_option[NAWS] = True + + task = asyncio.create_task(set_options_later()) + result = await asyncio.wait_for( + writer.wait_for(remote={"ECHO": True}, local={"NAWS": True}), 0.5 + ) + assert result is True + await task + + +async def test_wait_for_invalid_option(): + """Test wait_for raises KeyError for invalid option names.""" + writer = telnetlib3.TelnetWriter(transport=None, protocol=None, server=True) + + with pytest.raises(KeyError): + await writer.wait_for(remote={"INVALID": True}) + + +async def test_wait_for_cancelled_on_close(): + """Test wait_for is cancelled when connection closes.""" + writer = telnetlib3.TelnetWriter(transport=None, protocol=None, server=True) + + wait_task = asyncio.create_task(writer.wait_for(remote={"ECHO": True})) + await asyncio.sleep(0.01) + + assert not wait_task.done() + writer.close() + + with pytest.raises(asyncio.CancelledError): + await wait_task + + +async def test_wait_for_condition_immediate(): + """Test wait_for_condition returns immediately when condition met.""" + writer = telnetlib3.TelnetWriter(transport=None, protocol=None, server=True) + + result = await writer.wait_for_condition(lambda w: w.server is True) + assert result is True + + +async def test_wait_for_condition_waits(): + """Test wait_for_condition waits for condition to become true.""" + # local + from telnetlib3.telopt import ECHO + + writer = telnetlib3.TelnetWriter(transport=None, protocol=None, server=True) + + async def set_option_later(): + await asyncio.sleep(0.01) + writer.remote_option[ECHO] = True + + task = asyncio.create_task(set_option_later()) + result = await asyncio.wait_for( + writer.wait_for_condition(lambda w: w.remote_option.enabled(ECHO)), 0.5 + ) + assert result is True + await task + + +async def test_wait_for_cleanup_on_success(): + """Test that waiters are cleaned up after successful completion.""" + # local + from telnetlib3.telopt import ECHO + + writer = telnetlib3.TelnetWriter(transport=None, protocol=None, server=True) + + async def set_option_later(): + await asyncio.sleep(0.01) + writer.remote_option[ECHO] = True + + task = asyncio.create_task(set_option_later()) + await asyncio.wait_for(writer.wait_for(remote={"ECHO": True}), 0.5) + await task + + assert len(writer._waiters) == 0 diff --git a/tox.ini b/tox.ini index a90c22a..a89abc2 100644 --- a/tox.ini +++ b/tox.ini @@ -40,7 +40,7 @@ commands = deps = black commands = - black telnetlib3/ + black telnetlib3/ bin/ [testenv:docformatter] basepython = python3.13 @@ -77,7 +77,7 @@ commands = deps = flake8 commands = - flake8 --exclude=tests telnetlib3/ + flake8 --exclude=tests telnetlib3/ bin/ [testenv:flake8_tests] deps = @@ -92,13 +92,13 @@ commands = deps = isort commands = - isort telnetlib3 + isort telnetlib3 bin [testenv:isort_check] deps = isort commands = - isort --diff --check-only telnetlib3 + isort --diff --check-only telnetlib3 bin [testenv:mypy] deps = @@ -121,7 +121,7 @@ commands = deps = pylint commands = - pylint {posargs} telnetlib3 --ignore=tests + pylint {posargs} telnetlib3 bin --ignore=tests [testenv:pylint_tests] deps = @@ -183,7 +183,7 @@ commands = basepython = python3.12 extras = docs commands = - sphinx-build -E -a -v -n \ + sphinx-build -E -a -v -n -W \ -d {toxinidir}/docs/_build/doctrees \ {posargs:-b html} docs \ {toxinidir}/docs/_build/html @@ -263,5 +263,15 @@ addopts = --timeout=15 --junit-xml=.tox/results.{envname}.xml faulthandler_timeout = 30 -filterwarnings = error +filterwarnings = + error + # Ignore ResourceWarnings from asyncio internal sockets during coverage cleanup + # Pattern matches both "family=1" (py312+) and "family=AddressFamily.AF_UNIX" (py38-py311) + ignore:Exception ignored in.*socket\.socket.*AF_UNIX:pytest.PytestUnraisableExceptionWarning + ignore:Exception ignored in.*socket\.socket.*family=1:pytest.PytestUnraisableExceptionWarning + ignore:Exception ignored in.*BaseEventLoop\.__del__:pytest.PytestUnraisableExceptionWarning + # Ignore AF_INET socket warnings on Windows - IOCP socket cleanup is asynchronous + # and may not complete before pytest cleanup runs + ignore:Exception ignored in.*socket\.socket.*AF_INET:pytest.PytestUnraisableExceptionWarning + ignore:Exception ignored in.*socket\.socket.*family=2:pytest.PytestUnraisableExceptionWarning junit_family = xunit1 From b20a1ad6bc3e817364221133d751106969eebf84 Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Sat, 24 Jan 2026 15:16:02 -0500 Subject: [PATCH 2/3] CI Test fixes and Benchmarking (codspeed.io) (#109) This also includes a performance-enhanced data_received on server end matching similar technique in client end, reducing loops/if statements and improving performance 50x or more --- .github/workflows/ci.yml | 9 + .github/workflows/codspeed.yml | 36 ++++ docs/history.rst | 2 + telnetlib3/client_base.py | 5 +- telnetlib3/server.py | 1 - telnetlib3/server_base.py | 85 +++++++-- telnetlib3/tests/accessories.py | 27 ++- telnetlib3/tests/conftest.py | 18 ++ telnetlib3/tests/test_benchmarks.py | 264 ++++++++++++++++++++++++++++ telnetlib3/tests/test_charset.py | 21 +++ telnetlib3/tests/test_core.py | 4 +- telnetlib3/tests/test_telnetlib.py | 2 +- telnetlib3/tests/test_timeout.py | 2 +- tox.ini | 5 +- 14 files changed, 455 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/codspeed.yml create mode 100644 telnetlib3/tests/conftest.py create mode 100644 telnetlib3/tests/test_benchmarks.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b1888d..b7dcdbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,7 +101,16 @@ jobs: env: PYTHONASYNCIODEBUG: ${{ matrix.asyncio-debug && '1' || '' }} + - name: Rename coverage data + if: ${{ !matrix.asyncio-debug }} + shell: bash + run: | + if test -f .coverage; then + mv .coverage{,.${{ matrix.os }}.${{ env.TOX_ENV }}} + fi + - name: Upload coverage data + if: ${{ !matrix.asyncio-debug }} uses: actions/upload-artifact@v4 with: name: coverage-data-${{ matrix.os }}-${{ matrix.python-version }} diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml new file mode 100644 index 0000000..1551d35 --- /dev/null +++ b/.github/workflows/codspeed.yml @@ -0,0 +1,36 @@ +name: CodSpeed + +on: + push: + branches: + - master + pull_request: + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + codspeed: + name: Run benchmarks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: pip + + - name: Install dependencies + run: | + python -Im pip install --upgrade pip + python -Im pip install -e . + python -Im pip install pytest pytest-asyncio pytest-codspeed + + - name: Run benchmarks + uses: CodSpeedHQ/action@v4 + with: + mode: simulation + run: pytest telnetlib3/tests/test_benchmarks.py --codspeed -o "addopts=" diff --git a/docs/history.rst b/docs/history.rst index 1b41afb..2d8eb33 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -9,6 +9,8 @@ History * new: ``telnetlib3.sync`` module with blocking (non-asyncio) APIs: ``TelnetConnection`` for clients, ``BlockingTelnetServer`` for servers. * new: ``pty_shell`` module and demonstrating ``telnetlib3-server --pty-exec`` CLI argument + * performance: both client and server protocol data_received methods were + optimized for ~50x throughput improvement in bulk data transfers. 2.0.8 * bugfix: object has no attribute '_extra' :ghissue:`100` diff --git a/telnetlib3/client_base.py b/telnetlib3/client_base.py index d90f6ce..dca0754 100644 --- a/telnetlib3/client_base.py +++ b/telnetlib3/client_base.py @@ -172,8 +172,11 @@ def connection_made(self, transport): self._waiter_connected.add_done_callback(self.begin_shell) asyncio.get_event_loop().call_soon(self.begin_negotiation) - def begin_shell(self, _result): + def begin_shell(self, future): """Start the shell coroutine after negotiation completes.""" + # Don't start shell if the connection was cancelled or errored + if future.cancelled() or future.exception() is not None: + return if self.shell is not None: coro = self.shell(self.reader, self.writer) if asyncio.iscoroutine(coro): diff --git a/telnetlib3/server.py b/telnetlib3/server.py index f97b4a1..3cdde6e 100755 --- a/telnetlib3/server.py +++ b/telnetlib3/server.py @@ -552,7 +552,6 @@ def clients(self): :returns: List of protocol instances for all connected clients. """ # Filter out closed protocols (lazy cleanup) - # pylint: disable=protected-access self._protocols = [p for p in self._protocols if not getattr(p, "_closing", False)] return list(self._protocols) diff --git a/telnetlib3/server_base.py b/telnetlib3/server_base.py index 69ee984..4c2d073 100644 --- a/telnetlib3/server_base.py +++ b/telnetlib3/server_base.py @@ -8,11 +8,15 @@ import traceback # local +from .telopt import theNULL from .stream_reader import TelnetReader, TelnetReaderUnicode from .stream_writer import TelnetWriter, TelnetWriterUnicode __all__ = ("BaseServer",) +# Pre-allocated single-byte cache to avoid per-byte bytes() allocations +_ONE_BYTE = [bytes([i]) for i in range(256)] + logger = logging.getLogger("telnetlib3.server_base") @@ -170,8 +174,11 @@ def connection_made(self, transport): self._waiter_connected.add_done_callback(self.begin_shell) asyncio.get_event_loop().call_soon(self.begin_negotiation) - def begin_shell(self, _result): + def begin_shell(self, future): """Start the shell coroutine after negotiation completes.""" + # Don't start shell if the connection was cancelled or errored + if future.cancelled() or future.exception() is not None: + return if self.shell is not None: coro = self.shell(self.reader, self.writer) if asyncio.iscoroutine(coro): @@ -179,30 +186,76 @@ def begin_shell(self, _result): loop.create_task(coro) def data_received(self, data): - """Process bytes received by transport.""" - # This may seem strange; feeding all bytes received to the **writer**, - # and, only if they test positive, duplicating to the **reader**. + """ + Process bytes received by transport. + + This may seem strange; feeding all bytes received to the **writer**, and, only if they test + positive, duplicating to the **reader**. + + The writer receives a copy of all raw bytes because, as an IAC interpreter, it may likely + **write** a responding reply. + """ + # pylint: disable=too-many-branches + # This is a "hot path" method, and so it is not broken into "helper functions" to help with + # performance. Uses batched processing: scans for IAC (255) and SLC bytes, batching regular + # data into single feed_data() calls for performance. This can be done, and previously was, + # more simply by processing a "byte at a time", but, this "batch and seek" solution can be + # hundreds of times faster though much more complicated. # - # The writer receives a copy of all raw bytes because, as an IAC - # interpreter, it may likely **write** a responding reply. self._last_received = datetime.datetime.now() + writer = self.writer + reader = self.reader + + # Build set of special bytes: IAC + SLC values when simulation enabled + if writer.slc_simulated: + slc_vals = {defn.val[0] for defn in writer.slctab.values() if defn.val != theNULL} + special = frozenset({255} | slc_vals) + else: + special = None # Only IAC is special cmd_received = False - for byte in data: + n = len(data) + i = 0 + out_start = 0 + feeding_oob = False + + while i < n: + if not feeding_oob: + # Scan forward to next special byte + if special is None: + # Fast path: only IAC (255) is special + next_special = data.find(255, i) + if next_special == -1: + # No IAC found - batch entire remainder + if n > out_start: + reader.feed_data(data[out_start:]) + break + i = next_special + else: + # SLC bytes also special + while i < n and data[i] not in special: + i += 1 + # Flush non-special bytes + if i > out_start: + reader.feed_data(data[out_start:i]) + if i >= n: + break + + # Process special byte try: - recv_inband = self.writer.feed_byte(bytes([byte])) + recv_inband = writer.feed_byte(_ONE_BYTE[data[i]]) except BaseException: # pylint: disable=broad-exception-caught self._log_exception(logger.warning, *sys.exc_info()) else: if recv_inband: - # forward to reader (shell). - self.reader.feed_data(bytes([byte])) - - # becomes True if any out of band data is received. - cmd_received = cmd_received or not recv_inband - - # until negotiation is complete, re-check negotiation aggressively - # upon receipt of any command byte. + reader.feed_data(data[i : i + 1]) + else: + cmd_received = True + i += 1 + out_start = i + feeding_oob = bool(writer.is_oob) + + # Re-check negotiation on command receipt if not self._waiter_connected.done() and cmd_received: self._check_negotiation_timer() diff --git a/telnetlib3/tests/accessories.py b/telnetlib3/tests/accessories.py index a11fed7..e88571a 100644 --- a/telnetlib3/tests/accessories.py +++ b/telnetlib3/tests/accessories.py @@ -79,13 +79,38 @@ async def asyncio_connection(host, port): pass +class _TrackingProtocol(asyncio.Protocol): + """Protocol wrapper that tracks transport for cleanup.""" + + _transports = None # Class-level list, set per-server instance + + def connection_made(self, transport): + if self._transports is not None: + self._transports.append(transport) + super().connection_made(transport) + + @contextlib.asynccontextmanager async def asyncio_server(protocol_factory, host, port): """Create an asyncio server with automatic cleanup.""" - server = await asyncio.get_event_loop().create_server(protocol_factory, host, port) + # Track transports for accepted connections so we can close them + transports = [] + + # Create a subclass that tracks transports for this server instance + class TrackingProtocol(_TrackingProtocol, protocol_factory): + _transports = transports + + server = await asyncio.get_event_loop().create_server(TrackingProtocol, host, port) try: yield server finally: + # Close all accepted connection transports + for transport in transports: + if not transport.is_closing(): + transport.close() + # Give transports time to close + if transports: + await asyncio.sleep(0) server.close() await server.wait_closed() diff --git a/telnetlib3/tests/conftest.py b/telnetlib3/tests/conftest.py new file mode 100644 index 0000000..ad6238b --- /dev/null +++ b/telnetlib3/tests/conftest.py @@ -0,0 +1,18 @@ +"""Pytest configuration and fixtures.""" + +# 3rd party +import pytest + +try: + # 3rd party + from pytest_codspeed import BenchmarkFixture # noqa: F401 pylint:disable=unused-import +except ImportError: + # Provide a no-op benchmark fixture when pytest-codspeed is not installed + @pytest.fixture + def benchmark(): + """No-op benchmark fixture for environments without pytest-codspeed.""" + + def _passthrough(func, *args, **kwargs): + return func(*args, **kwargs) + + return _passthrough diff --git a/telnetlib3/tests/test_benchmarks.py b/telnetlib3/tests/test_benchmarks.py new file mode 100644 index 0000000..aae7147 --- /dev/null +++ b/telnetlib3/tests/test_benchmarks.py @@ -0,0 +1,264 @@ +"""Benchmarks for telnetlib3 hot paths.""" + +# std imports +import asyncio + +# 3rd party +import pytest + +# local +import telnetlib3 +from telnetlib3.slc import snoop, generate_slctab +from telnetlib3.telopt import IAC, NAWS, WILL, TTYPE, theNULL +from telnetlib3.stream_reader import TelnetReader +from telnetlib3.stream_writer import TelnetWriter + + +class MockTransport: + """Minimal transport mock for benchmarking.""" + + def write(self, data): + pass + + def get_write_buffer_size(self): + return 0 + + +class MockProtocol: + """Minimal protocol mock for benchmarking.""" + + +@pytest.fixture +def writer(): + """Create a TelnetWriter for benchmarking.""" + return TelnetWriter( + transport=MockTransport(), + protocol=MockProtocol(), + server=True, + ) + + +@pytest.fixture +def reader(): + """Create a TelnetReader for benchmarking.""" + return TelnetReader() + + +# -- feed_byte: the main IAC parser, called for every byte on server -- + + +@pytest.mark.parametrize( + "byte", + [ + pytest.param(b"A", id="normal"), + pytest.param(b"\x00", id="null"), + pytest.param(b"\xff", id="iac"), + ], +) +def test_feed_byte(benchmark, writer, byte): + """Benchmark feed_byte() with different byte types.""" + benchmark(writer.feed_byte, byte) + + +def test_feed_byte_iac_nop(benchmark, writer): + """Benchmark feed_byte() for complete IAC NOP sequence.""" + + def feed_iac_nop(): + writer.feed_byte(IAC) + writer.feed_byte(b"\xf1") # NOP + + benchmark(feed_iac_nop) + + +def test_feed_byte_iac_will(benchmark, writer): + """Benchmark feed_byte() for IAC WILL TTYPE negotiation.""" + + def feed_iac_will(): + writer.feed_byte(IAC) + writer.feed_byte(WILL) + writer.feed_byte(TTYPE) + + benchmark(feed_iac_will) + + +# -- is_oob: checked after every feed_byte() call -- + + +def test_is_oob_property(benchmark, writer): + """Benchmark is_oob property access.""" + benchmark(lambda: writer.is_oob) + + +# -- Option dict lookups: checked during negotiation -- + + +@pytest.mark.parametrize( + "option_attr", + [ + pytest.param("local_option", id="local"), + pytest.param("remote_option", id="remote"), + pytest.param("pending_option", id="pending"), + ], +) +def test_option_lookup(benchmark, writer, option_attr): + """Benchmark option dictionary lookups.""" + option = getattr(writer, option_attr) + benchmark(option.enabled, TTYPE) + + +def test_option_setitem(benchmark, writer): + """Benchmark option dictionary assignment.""" + benchmark(writer.local_option.__setitem__, NAWS, True) + + +# -- TelnetReader.feed_data: buffers incoming data -- + + +@pytest.mark.parametrize( + "size", + [ + pytest.param(1, id="1byte"), + pytest.param(64, id="64bytes"), + pytest.param(1024, id="1kb"), + ], +) +def test_reader_feed_data(benchmark, reader, size): + """Benchmark TelnetReader.feed_data() with different chunk sizes.""" + data = b"x" * size + benchmark(reader.feed_data, data) + + +# -- SLC snoop: used in client fast path for SLC character detection -- + + +@pytest.fixture +def slctab(): + """Generate SLC table for benchmarking.""" + return generate_slctab() + + +@pytest.mark.parametrize( + "byte", + [ + pytest.param(b"\x03", id="match_ip"), + pytest.param(b"A", id="no_match"), + ], +) +def test_snoop(benchmark, slctab, byte): + """Benchmark snoop() for SLC character matching.""" + benchmark(snoop, byte, slctab, {}) + + +def test_slc_value_set_membership(benchmark, slctab): + """Benchmark SLC value set membership check (client fast path).""" + slc_vals = frozenset(defn.val[0] for defn in slctab.values() if defn.val != theNULL) + benchmark(lambda: 3 in slc_vals) + + +# -- End-to-end: full connection with bulk data transfer -- + + +DATA_1MB = b"x" * (1024 * 1024) + + +async def _setup_server_client_pair(): + """Create connected server and client pair.""" + received_data = bytearray() + server_ready = asyncio.Event() + srv_writer = None + + async def shell(reader, writer): + nonlocal srv_writer + srv_writer = writer + server_ready.set() + while True: + data = await reader.read(65536) + if not data: + break + received_data.extend(data.encode() if isinstance(data, str) else data) + + server = await telnetlib3.create_server( + host="127.0.0.1", + port=0, + shell=shell, + encoding=False, + connect_maxwait=0.1, + ) + port = server.sockets[0].getsockname()[1] + + client_reader, client_writer = await telnetlib3.open_connection( + host="127.0.0.1", + port=port, + encoding=False, + connect_minwait=0.05, + connect_maxwait=0.1, + ) + + await server_ready.wait() + + return { + "server": server, + "srv_writer": srv_writer, + "client_reader": client_reader, + "client_writer": client_writer, + "received_data": received_data, + } + + +async def _teardown_server_client_pair(pair): + """Clean up server and client pair.""" + pair["client_writer"].close() + await pair["client_writer"].wait_closed() + pair["server"].close() + await pair["server"].wait_closed() + + +def test_bulk_transfer_client_to_server(benchmark): + """Benchmark 1MB bulk transfer from client to server.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + pair = loop.run_until_complete(_setup_server_client_pair()) + client_writer = pair["client_writer"] + received = pair["received_data"] + + async def send_1mb(): + received.clear() + client_writer.write(DATA_1MB) + await client_writer.drain() + while len(received) < len(DATA_1MB): + await asyncio.sleep(0.001) + + benchmark(lambda: loop.run_until_complete(send_1mb())) + + loop.run_until_complete(_teardown_server_client_pair(pair)) + finally: + loop.close() + + +def test_bulk_transfer_server_to_client(benchmark): + """Benchmark 1MB bulk transfer from server to client.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + pair = loop.run_until_complete(_setup_server_client_pair()) + srv_writer = pair["srv_writer"] + client_reader = pair["client_reader"] + + async def send_1mb(): + srv_writer.write(DATA_1MB) + await srv_writer.drain() + received = 0 + while received < len(DATA_1MB): + chunk = await client_reader.read(65536) + if not chunk: + break + received += len(chunk) + + benchmark(lambda: loop.run_until_complete(send_1mb())) + + loop.run_until_complete(_teardown_server_client_pair(pair)) + finally: + loop.close() diff --git a/telnetlib3/tests/test_charset.py b/telnetlib3/tests/test_charset.py index d83cc0b..daa54bb 100644 --- a/telnetlib3/tests/test_charset.py +++ b/telnetlib3/tests/test_charset.py @@ -118,6 +118,9 @@ def on_charset(self, charset): srv_instance = await asyncio.wait_for(_waiter, 2.0) assert srv_instance.get_extra_info("charset") == given_charset + srv_instance.writer.close() + await srv_instance.writer.wait_closed() + async def test_telnet_client_send_charset(bind_host, unused_tcp_port): """Test Client's callback method send_charset() selection for illegals.""" @@ -125,8 +128,13 @@ async def test_telnet_client_send_charset(bind_host, unused_tcp_port): from telnetlib3.tests.accessories import create_server, open_connection _waiter = asyncio.Future() + server_instance = {"protocol": None} class ServerTestCharset(telnetlib3.TelnetServer): + def begin_negotiation(self): + server_instance["protocol"] = self + return super().begin_negotiation() + def on_request_charset(self): return ["illegal", "cp437"] @@ -150,6 +158,10 @@ def send_charset(self, offered): assert val == "cp437" assert writer.get_extra_info("charset") == "cp437" + if server_instance["protocol"]: + server_instance["protocol"].writer.close() + await server_instance["protocol"].writer.wait_closed() + async def test_telnet_client_no_charset(bind_host, unused_tcp_port): """Test Client's callback method send_charset() does not select.""" @@ -157,8 +169,13 @@ async def test_telnet_client_no_charset(bind_host, unused_tcp_port): from telnetlib3.tests.accessories import create_server, open_connection _waiter = asyncio.Future() + server_instance = {"protocol": None} class ServerTestCharset(telnetlib3.TelnetServer): + def begin_negotiation(self): + server_instance["protocol"] = self + return super().begin_negotiation() + def on_request_charset(self): return ["illegal", "this-is-no-good-either"] @@ -184,6 +201,10 @@ def send_charset(self, offered): assert not val assert writer.get_extra_info("charset") == "latin1" + if server_instance["protocol"]: + server_instance["protocol"].writer.close() + await server_instance["protocol"].writer.wait_closed() + # --- Negotiation Protocol Tests --- diff --git a/telnetlib3/tests/test_core.py b/telnetlib3/tests/test_core.py index d7735b3..4014e26 100644 --- a/telnetlib3/tests/test_core.py +++ b/telnetlib3/tests/test_core.py @@ -598,7 +598,7 @@ async def shell(reader, writer): b"\x1b[m\nConnection closed by foreign host.\n" ) - assert len(logfile_output) == 2, logfile + assert len(logfile_output) in (2, 3), logfile assert "Connected to Date: Sat, 24 Jan 2026 17:30:31 -0500 Subject: [PATCH 3/3] Improvements for next release (#110) - Add new StatusLogger to server, count and report rx/tx bytes - update README.rst, main page hyperlinks to RTD often and overviews everything - rename pty module for better module namespace - reorganization of ``__all__`` for orderly ``help(telnetlib3)`` reading (like 4k lines! enjoy!!) --- README.rst | 102 ++++++++-- docs/api/pty_shell.rst | 6 - docs/api/server_pty_shell.rst | 6 + telnetlib3/__init__.py | 60 +++--- telnetlib3/server.py | 90 ++++++++- telnetlib3/server_base.py | 15 ++ .../{pty_shell.py => server_pty_shell.py} | 30 ++- telnetlib3/slc.py | 46 ++--- telnetlib3/stream_writer.py | 4 + telnetlib3/telnetlib.py | 2 +- telnetlib3/tests/test_pty_shell.py | 136 ++++++++++++- telnetlib3/tests/test_status_logger.py | 189 ++++++++++++++++++ 12 files changed, 605 insertions(+), 81 deletions(-) delete mode 100644 docs/api/pty_shell.rst create mode 100644 docs/api/server_pty_shell.rst rename telnetlib3/{pty_shell.py => server_pty_shell.py} (92%) create mode 100644 telnetlib3/tests/test_status_logger.py diff --git a/README.rst b/README.rst index 4c6a18c..16147c2 100644 --- a/README.rst +++ b/README.rst @@ -10,13 +10,62 @@ :alt: codecov.io Code Coverage :target: https://codecov.io/gh/jquast/telnetlib3/ +.. image:: https://img.shields.io/badge/Linux-yes-success?logo=linux + :alt: Linux supported + +.. image:: https://img.shields.io/badge/Windows-yes-success?logo=windows + :alt: Windows supported + +.. image:: https://img.shields.io/badge/MacOS-yes-success?logo=apple + :alt: MacOS supported + +.. image:: https://img.shields.io/badge/BSD-yes-success?logo=freebsd + :alt: BSD supported + Introduction ============ -telnetlib3 is a Telnet Client and Server library for python. This project -requires python 3.7 and later, using the asyncio_ module. +``telnetlib3`` is a full-featured Telnet Client and Server library for python3.8 and newer. + +Modern asyncio_ and legacy blocking API's are provided. + +The python telnetlib.py_ module removed by Python 3.13 is also re-distributed as a backport. + +Overview +-------- + +telnetlib3 provides multiple interfaces for working with the Telnet protocol: + +**Legacy telnetlib** + An unadulterated copy of Python 3.12's telnetlib.py_ See `Legacy telnetlib`_ below. + +**Asyncio Protocol** + Modern async/await interface for both client and server, supporting concurrent + connections. See the `Guidebook`_ for examples and the `API documentation`_. + +**Command-line Utilities** + Two CLI tools are included: ``telnetlib3-client`` for connecting to servers + and ``telnetlib3-server`` for hosting. See `Command-line`_ below. + +**Blocking API** + A synchronous interface modeled after telnetlib (client) and miniboa_ (server), + with enhancements. See the `sync API documentation`_. -.. _asyncio: http://docs.python.org/3.11/library/asyncio.html + Enhancements over standard telnetlib: + + - Full RFC 854 protocol negotiation (NAWS, TTYPE, BINARY, ECHO, SGA) + - `wait_for()`_ method to block until specific option states are negotiated + - `get_extra_info()`_ for terminal type, size, and other metadata + - Context manager support (``with TelnetConnection(...) as conn:``) + - Thread-safe operation with asyncio_ running in background + + Enhancements over miniboa for server-side: + + - Thread-per-connection model with blocking I/O (vs poll-based) + - `readline()`_ and `read_until()`_ blocking methods + - Full telnet option negotiation and inspection + - miniboa-compatible properties: `active`_, `address`_, `terminal_type`_, + `columns`_, `rows`_, `idle()`_, `duration()`_, `deactivate()`_ Quick Example ------------- @@ -46,29 +95,22 @@ A simple telnet server: More examples are available in the `Guidebook`_ and the ``bin/`` directory. -.. _Guidebook: https://telnetlib3.readthedocs.io/en/latest/guidebook.html - Legacy telnetlib ---------------- This library *also* contains a copy of telnetlib.py_ from the standard library of -Python 3.12 before it was removed in Python 3.13. asyncio_ is not required. +Python 3.12 before it was removed in Python 3.13. asyncio_ is not required to use +it. -To migrate code from Python 3.11 and earlier: +To migrate code, change import statements: .. code-block:: python # OLD imports: import telnetlib - # - or - - from telnetlib import Telnet, ECHO, BINARY # NEW imports: - import telnetlib3.telnetlib as telnetlib - # - or - - from telnetlib3.telnetlib import Telnet, ECHO, BINARY - -.. _telnetlib.py: https://docs.python.org/3.12/library/telnetlib.html + import telnetlib3 Command-line ------------ @@ -82,7 +124,10 @@ module path to a function of signature ``async def shell(reader, writer)``. :: telnetlib3-client nethack.alt.org + telnetlib3-client xibalba.l33t.codes 44510 + telnetlib3-client --shell bin.client_wargame.shell 1984.ws 666 telnetlib3-server --pty-exec /bin/bash -- --login + telnetlib3-server 0.0.0.0 6023 --shell='bin.server_wargame.shell Encoding -------- @@ -91,8 +136,15 @@ Use ``--encoding`` and ``--force-binary`` for non-ASCII terminals:: telnetlib3-client --encoding=cp437 --force-binary blackflag.acid.org -The default encoding is UTF-8. Use ``--force-binary`` when the server -doesn't properly negotiate BINARY mode. +The default encoding is UTF-8, but all text is limited to ASCII until BINARY +mode is agreed by compliance of their respective RFCs. + +However, many clients and servers that are capable of non-ascii encodings like +utf-8 or cp437 may not be capable of negotiating about BINARY, NEW_ENVIRON, +or CHARSET to demand about it. + +In this case, use ``--force-binary`` argument for clients and servers to +enforce that the specified ``--encoding`` is always used, no matter what. Features -------- @@ -142,6 +194,24 @@ The following RFC specifications are implemented: .. _rfc-1571: https://www.rfc-editor.org/rfc/rfc1571.txt .. _rfc-1572: https://www.rfc-editor.org/rfc/rfc1572.txt .. _rfc-2066: https://www.rfc-editor.org/rfc/rfc2066.txt +.. _telnetlib.py: https://docs.python.org/3.12/library/telnetlib.html +.. _Guidebook: https://telnetlib3.readthedocs.io/en/latest/guidebook.html +.. _API documentation: https://telnetlib3.readthedocs.io/en/latest/api.html +.. _sync API documentation: https://telnetlib3.readthedocs.io/en/latest/api/sync.html +.. _miniboa: https://github.com/shmup/miniboa +.. _asyncio: https://docs.python.org/3/library/asyncio.html +.. _wait_for(): https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.TelnetConnection.wait_for +.. _get_extra_info(): https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.TelnetConnection.get_extra_info +.. _readline(): https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.TelnetConnection.readline +.. _read_until(): https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.TelnetConnection.read_until +.. _active: https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.ServerConnection.active +.. _address: https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.ServerConnection.address +.. _terminal_type: https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.ServerConnection.terminal_type +.. _columns: https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.ServerConnection.columns +.. _rows: https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.ServerConnection.rows +.. _idle(): https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.ServerConnection.idle +.. _duration(): https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.ServerConnection.duration +.. _deactivate(): https://telnetlib3.readthedocs.io/en/latest/api/sync.html#telnetlib3.sync.ServerConnection.deactivate Further Reading --------------- diff --git a/docs/api/pty_shell.rst b/docs/api/pty_shell.rst deleted file mode 100644 index 5b59ef2..0000000 --- a/docs/api/pty_shell.rst +++ /dev/null @@ -1,6 +0,0 @@ -pty_shell ------------- - -.. automodule:: telnetlib3.pty_shell - :members: - diff --git a/docs/api/server_pty_shell.rst b/docs/api/server_pty_shell.rst new file mode 100644 index 0000000..9b463d7 --- /dev/null +++ b/docs/api/server_pty_shell.rst @@ -0,0 +1,6 @@ +server_pty_shell +---------------- + +.. automodule:: telnetlib3.server_pty_shell + :members: + diff --git a/telnetlib3/__init__.py b/telnetlib3/__init__.py index 3b22cac..ed8b65e 100644 --- a/telnetlib3/__init__.py +++ b/telnetlib3/__init__.py @@ -17,6 +17,8 @@ from . import telopt from . import slc from . import telnetlib +from . import guard_shells +from . import sync from .server_base import * # noqa from .server import * # noqa from .stream_writer import * # noqa @@ -25,43 +27,49 @@ from .client_shell import * # noqa from .client import * # noqa from .telopt import * # noqa -from .telnetlib import * # noqa from .slc import * # noqa +from .telnetlib import * # noqa +from .guard_shells import * # noqa +from .sync import * # noqa try: - from . import pty_shell as _pty_shell_module - from .pty_shell import * # noqa + from . import server_pty_shell + from .server_pty_shell import * # noqa PTY_SUPPORT = True # pylint: disable=invalid-name except ImportError: - _pty_shell_module = None # type: ignore[assignment] + server_pty_shell = None # type: ignore[assignment] PTY_SUPPORT = False # pylint: disable=invalid-name -from . import guard_shells as _guard_shells_module -from .guard_shells import * # noqa -from . import sync as _sync_module -from .sync import * # noqa -from .accessories import get_version as __get_version +from .accessories import get_version as _get_version # isort: on # fmt: on -__all__ = ( - server_base.__all__ - + server_shell.__all__ - + server.__all__ - + client_base.__all__ - + client_shell.__all__ - + client.__all__ - + stream_writer.__all__ - + stream_reader.__all__ - + telopt.__all__ - + slc.__all__ - + telnetlib.__all__ - + (_pty_shell_module.__all__ if PTY_SUPPORT else ()) - + _guard_shells_module.__all__ - + _sync_module.__all__ -) # noqa +__all__ = tuple( + dict.fromkeys( + # server, + server_base.__all__ + + server.__all__ + + server_shell.__all__ + + guard_shells.__all__ + + (server_pty_shell.__all__ if PTY_SUPPORT else ()) + # client, + + client_base.__all__ + + client.__all__ + + client_shell.__all__ + # telnet protocol stream / decoders, + + stream_writer.__all__ + + stream_reader.__all__ + # blocking i/o wrapper + + sync.__all__ + # protocol bits, bytes, and names + + telopt.__all__ + + slc.__all__ + # python's legacy stdlib api + + telnetlib.__all__ + ) +) # noqa - deduplicate, preserving order __author__ = "Jeff Quast" __url__ = "https://github.com/jquast/telnetlib3/" __copyright__ = "Copyright 2013" __credits__ = ["Jim Storch", "Wijnand Modderman-Lenstra"] __license__ = "ISC" -__version__ = __get_version() +__version__ = _get_version() diff --git a/telnetlib3/server.py b/telnetlib3/server.py index 3cdde6e..c121fcc 100755 --- a/telnetlib3/server.py +++ b/telnetlib3/server.py @@ -53,6 +53,7 @@ class CONFIG(NamedTuple): pty_args: Optional[str] = None robot_check: bool = False pty_fork_limit: int = 0 + status_interval: int = 20 # Default config instance - use this to access default values @@ -581,6 +582,73 @@ def _register_protocol(self, protocol): ) +class StatusLogger: + """Periodic status logger for connected clients.""" + + def __init__(self, server, interval): + """ + Initialize status logger. + + :param server: Server instance to monitor. + :param interval: Logging interval in seconds. + """ + self._server = server + self._interval = interval + self._task = None + self._last_status = None + + def _get_status(self): + """Get current status snapshot using IP:port pairs for change detection.""" + clients = self._server.clients + client_data = [] + for client in clients: + peername = client.get_extra_info("peername", ("-", 0)) + client_data.append( + { + "ip": peername[0], + "port": peername[1], + "rx": getattr(client, "rx_bytes", 0), + "tx": getattr(client, "tx_bytes", 0), + } + ) + client_data.sort(key=lambda x: (x["ip"], x["port"])) + return {"count": len(clients), "clients": client_data} + + def _status_changed(self, current): + """Check if status differs from last logged.""" + if self._last_status is None: + return current["count"] > 0 + return current != self._last_status + + def _format_status(self, status): + """Format status for logging.""" + if status["count"] == 0: + return "0 clients connected" + client_info = ", ".join( + f"{c['ip']}:{c['port']} (rx={c['rx']}, tx={c['tx']})" for c in status["clients"] + ) + return f"{status['count']} client(s): {client_info}" + + async def _run(self): + """Run periodic status logging.""" + while True: + await asyncio.sleep(self._interval) + status = self._get_status() + if self._status_changed(status): + logger.info("Status: %s", self._format_status(status)) + self._last_status = status + + def start(self): + """Start the status logging task.""" + if self._interval > 0: + self._task = asyncio.create_task(self._run()) + + def stop(self): + """Stop the status logging task.""" + if self._task: + self._task.cancel() + + async def create_server(host=None, port=23, protocol_factory=TelnetServer, **kwds): """ Create a TCP Telnet server. @@ -731,6 +799,16 @@ def parse_server_args(): default=_config.robot_check, help="check if client can render wide unicode (rejects bots)", ) + parser.add_argument( + "--status-interval", + type=int, + metavar="SECONDS", + default=_config.status_interval, + help=( + "periodic status log interval in seconds (0 to disable). " + "status only logged when connected clients has changed." + ), + ) result = vars(parser.parse_args(argv)) result["pty_args"] = pty_args if PTY_SUPPORT else None if not PTY_SUPPORT: @@ -754,6 +832,7 @@ async def run_server( # pylint: disable=too-many-positional-arguments,too-many- pty_args=_config.pty_args, robot_check=_config.robot_check, pty_fork_limit=_config.pty_fork_limit, + status_interval=_config.status_interval, ): """ Program entry point for server daemon. @@ -769,7 +848,7 @@ async def run_server( # pylint: disable=too-many-positional-arguments,too-many- if not PTY_SUPPORT: raise NotImplementedError("PTY support is not available on this platform (Windows?)") # local - from .pty_shell import make_pty_shell # pylint: disable=import-outside-toplevel + from .server_pty_shell import make_pty_shell # pylint: disable=import-outside-toplevel shell = make_pty_shell(pty_exec, pty_args) @@ -833,12 +912,21 @@ async def guarded_shell(reader, writer): # SIGTERM cases server to gracefully stop loop.add_signal_handler(signal.SIGTERM, asyncio.ensure_future, _sigterm_handler(server, log)) + # Start periodic status logger if enabled + status_logger = None + if status_interval > 0: + status_logger = StatusLogger(server, status_interval) + status_logger.start() + logger.info("Server ready on %s:%s", host, port) # await completion of server stop try: await server.wait_closed() finally: + # stop status logger + if status_logger: + status_logger.stop() # remove signal handler on stop loop.remove_signal_handler(signal.SIGTERM) diff --git a/telnetlib3/server_base.py b/telnetlib3/server_base.py index 4c2d073..46c81d1 100644 --- a/telnetlib3/server_base.py +++ b/telnetlib3/server_base.py @@ -30,6 +30,8 @@ class BaseServer(asyncio.streams.FlowControlMixin, asyncio.Protocol): _advanced = False _closing = False _check_later = None + _rx_bytes = 0 + _tx_bytes = 0 def __init__( # pylint: disable=too-many-positional-arguments self, @@ -203,6 +205,7 @@ def data_received(self, data): # hundreds of times faster though much more complicated. # self._last_received = datetime.datetime.now() + self._rx_bytes += len(data) writer = self.writer reader = self.reader @@ -244,6 +247,8 @@ def data_received(self, data): # Process special byte try: recv_inband = writer.feed_byte(_ONE_BYTE[data[i]]) + except ValueError as exc: + logger.debug("Invalid telnet byte from %s: %s", self, exc) except BaseException: # pylint: disable=broad-exception-caught self._log_exception(logger.warning, *sys.exc_info()) else: @@ -271,6 +276,16 @@ def idle(self): """Time elapsed since data last received, in seconds as float.""" return (datetime.datetime.now() - self._last_received).total_seconds() + @property + def rx_bytes(self): + """Total bytes received from client.""" + return self._rx_bytes + + @property + def tx_bytes(self): + """Total bytes sent to client.""" + return self._tx_bytes + # public protocol methods def __repr__(self): diff --git a/telnetlib3/pty_shell.py b/telnetlib3/server_pty_shell.py similarity index 92% rename from telnetlib3/pty_shell.py rename to telnetlib3/server_pty_shell.py index a6d5424..44ee9b6 100644 --- a/telnetlib3/pty_shell.py +++ b/telnetlib3/server_pty_shell.py @@ -23,7 +23,7 @@ __all__ = ("make_pty_shell", "pty_shell") -logger = logging.getLogger("telnetlib3.pty_shell") +logger = logging.getLogger("telnetlib3.server_pty_shell") # Synchronized Output sequences (DEC private mode 2026) # https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036 @@ -60,6 +60,8 @@ def __init__(self, reader, writer, program, args): self._in_sync_update = False self._decoder = None self._decoder_charset = None + self._naws_pending = None + self._naws_timer = None def start(self): """Fork PTY, configure environment, and exec program.""" @@ -149,9 +151,25 @@ def _setup_parent(self): self.writer.set_ext_callback(NAWS, self._on_naws) def _on_naws(self, rows, cols): - """Handle NAWS updates by resizing PTY.""" + """Handle NAWS updates by resizing PTY with debouncing.""" self.writer.protocol.on_naws(rows, cols) - self._set_window_size(rows, cols) + self._schedule_naws_update(rows, cols) + + def _schedule_naws_update(self, rows, cols): + """Schedule debounced NAWS update to avoid signal storms during rapid resize.""" + self._naws_pending = (rows, cols) + if self._naws_timer is not None: + self._naws_timer.cancel() + loop = asyncio.get_event_loop() + self._naws_timer = loop.call_later(0.2, self._fire_naws_update) + + def _fire_naws_update(self): + """Fire the pending NAWS update after debounce delay.""" + if self._naws_pending is not None: + rows, cols = self._naws_pending + self._naws_pending = None + self._naws_timer = None + self._set_window_size(rows, cols) def _set_window_size(self, rows, cols): """Set PTY window size and send SIGWINCH to child.""" @@ -331,6 +349,12 @@ def _flush_remaining(self): def cleanup(self): """Kill child process and close PTY fd.""" + # Cancel any pending NAWS timer + if self._naws_timer is not None: + self._naws_timer.cancel() + self._naws_timer = None + self._naws_pending = None + # Flush any remaining output buffer with final=True to emit buffered bytes if self._output_buffer: self._flush_output(self._output_buffer, final=True) diff --git a/telnetlib3/slc.py b/telnetlib3/slc.py index 9a1ce82..3432c8c 100644 --- a/telnetlib3/slc.py +++ b/telnetlib3/slc.py @@ -5,42 +5,38 @@ from .accessories import eightbits, name_unicode __all__ = ( - "SLC", - "SLC_AYT", - "NSLC", "BSD_SLC_TAB", + "Forwardmask", + "generate_forwardmask", "generate_slctab", "Linemode", - "LMODE_MODE_REMOTE", - "SLC_SYNCH", - "SLC_IP", - "SLC_AYT", - "SLC_ABORT", - "SLC_SUSP", - "SLC_EL", - "SLC_RP", - "SLC_XON", - "snoop", - "generate_forwardmask", - "Forwardmask", - "name_slc_command", "LMODE_FORWARDMASK", "LMODE_MODE", - "NSLC", - "LMODE_MODE", + "LMODE_MODE_REMOTE", "LMODE_SLC", + "name_slc_command", + "NSLC", "SLC", - "SLC_nosupport", - "SLC_DEFAULT", - "SLC_VARIABLE", - "SLC_NOSUPPORT", + "SLC_ABORT", "SLC_ACK", + "SLC_AO", + "SLC_AYT", "SLC_CANTCHANGE", - "SLC_LNEXT", + "SLC_DEFAULT", "SLC_EC", - "SLC_EW", + "SLC_EL", "SLC_EOF", - "SLC_AO", + "SLC_EW", + "SLC_IP", + "SLC_LNEXT", + "SLC_nosupport", + "SLC_NOSUPPORT", + "SLC_RP", + "SLC_SUSP", + "SLC_SYNCH", + "SLC_VARIABLE", + "SLC_XON", + "snoop", ) SLC_NOSUPPORT, SLC_CANTCHANGE, SLC_VARIABLE, SLC_DEFAULT = ( diff --git a/telnetlib3/stream_writer.py b/telnetlib3/stream_writer.py index 13d16b0..a2e3a86 100644 --- a/telnetlib3/stream_writer.py +++ b/telnetlib3/stream_writer.py @@ -781,6 +781,8 @@ def send_iac(self, buf): assert buf and buf.startswith(IAC), buf if self._transport is not None: self._transport.write(buf) + if hasattr(self._protocol, "_tx_bytes"): + self._protocol._tx_bytes += len(buf) def iac(self, cmd, opt=b""): """ @@ -1830,6 +1832,8 @@ def _write(self, buf, escape_iac=True): buf = self._escape_iac(buf) self._transport.write(buf) + if hasattr(self._protocol, "_tx_bytes"): + self._protocol._tx_bytes += len(buf) # Private sub-negotiation (SB) routines diff --git a/telnetlib3/telnetlib.py b/telnetlib3/telnetlib.py index 8d8a723..12eb639 100644 --- a/telnetlib3/telnetlib.py +++ b/telnetlib3/telnetlib.py @@ -11,7 +11,7 @@ Example:: - >>> from telnetlib import Telnet + >>> from telnetlib3 import Telnet >>> tn = Telnet('www.python.org', 79) # connect to finger port >>> tn.write(b'guido\r\n') >>> print(tn.read_all()) diff --git a/telnetlib3/tests/test_pty_shell.py b/telnetlib3/tests/test_pty_shell.py index e3b9c9b..85b95f1 100644 --- a/telnetlib3/tests/test_pty_shell.py +++ b/telnetlib3/tests/test_pty_shell.py @@ -226,7 +226,7 @@ def begin_shell(self, result): def test_platform_check_not_windows(): """Test that platform check raises on Windows.""" # local - from telnetlib3.pty_shell import _platform_check + from telnetlib3.server_pty_shell import _platform_check original_platform = sys.platform try: @@ -255,7 +255,7 @@ async def test_pty_session_build_environment(): from unittest.mock import MagicMock # local - from telnetlib3.pty_shell import PTYSession + from telnetlib3.server_pty_shell import PTYSession reader = MagicMock() writer = MagicMock() @@ -286,7 +286,7 @@ async def test_pty_session_build_environment_charset_fallback(): from unittest.mock import MagicMock # local - from telnetlib3.pty_shell import PTYSession + from telnetlib3.server_pty_shell import PTYSession reader = MagicMock() writer = MagicMock() @@ -304,3 +304,133 @@ async def test_pty_session_build_environment_charset_fallback(): assert env["TERM"] == "vt100" assert env["LANG"] == "en_US.ISO-8859-1" + + +async def test_pty_session_naws_debouncing(): + """Test that rapid NAWS updates are debounced.""" + # std imports + from unittest.mock import MagicMock, patch + + # local + from telnetlib3.server_pty_shell import PTYSession + + reader = MagicMock() + writer = MagicMock() + protocol = MagicMock() + writer.protocol = protocol + writer.get_extra_info = MagicMock(return_value=None) + + session = PTYSession(reader, writer, "/bin/sh", []) + session.master_fd = 1 + session.child_pid = 12345 + + signal_calls = [] + + def mock_killpg(pgid, sig): + signal_calls.append((pgid, sig)) + + with patch("os.getpgid", return_value=12345), \ + patch("os.killpg", side_effect=mock_killpg), \ + patch("fcntl.ioctl"): + session._on_naws(25, 80) + session._on_naws(30, 90) + session._on_naws(35, 100) + + assert len(signal_calls) == 0 + + await asyncio.sleep(0.25) + + assert len(signal_calls) == 1 + + signal_calls.clear() + session._on_naws(40, 120) + session._on_naws(45, 130) + + assert len(signal_calls) == 0 + + await asyncio.sleep(0.25) + + assert len(signal_calls) == 1 + + +async def test_pty_session_naws_debounce_uses_latest_values(): + """Test that debounced NAWS uses the latest values.""" + # std imports + from unittest.mock import MagicMock, call, patch + + # local + from telnetlib3.server_pty_shell import PTYSession + + reader = MagicMock() + writer = MagicMock() + protocol = MagicMock() + writer.protocol = protocol + writer.get_extra_info = MagicMock(return_value=None) + + session = PTYSession(reader, writer, "/bin/sh", []) + session.master_fd = 1 + session.child_pid = 12345 + + ioctl_calls = [] + + def mock_ioctl(fd, cmd, data): + ioctl_calls.append((fd, cmd, data)) + + with patch("os.getpgid", return_value=12345), \ + patch("os.killpg"), \ + patch("fcntl.ioctl", side_effect=mock_ioctl): + session._on_naws(25, 80) + session._on_naws(30, 90) + session._on_naws(50, 150) + + await asyncio.sleep(0.25) + + assert len(ioctl_calls) == 1 + # std imports + import struct + import termios + + expected_winsize = struct.pack("HHHH", 50, 150, 0, 0) + assert ioctl_calls[0][2] == expected_winsize + + +async def test_pty_session_naws_cleanup_cancels_pending(): + """Test that cleanup cancels pending NAWS timer.""" + # std imports + from unittest.mock import MagicMock, patch + + # local + from telnetlib3.server_pty_shell import PTYSession + + reader = MagicMock() + writer = MagicMock() + protocol = MagicMock() + writer.protocol = protocol + writer.get_extra_info = MagicMock(return_value=None) + + session = PTYSession(reader, writer, "/bin/sh", []) + session.master_fd = 1 + session.child_pid = 12345 + + signal_calls = [] + + def mock_killpg(pgid, sig): + # std imports + import signal as signal_mod + + if sig == signal_mod.SIGWINCH: + signal_calls.append((pgid, sig)) + + with patch("os.getpgid", return_value=12345), \ + patch("os.killpg", side_effect=mock_killpg), \ + patch("os.kill"), \ + patch("os.waitpid", return_value=(0, 0)), \ + patch("os.close"), \ + patch("fcntl.ioctl"): + session._on_naws(25, 80) + + session.cleanup() + + await asyncio.sleep(0.25) + + assert len(signal_calls) == 0 diff --git a/telnetlib3/tests/test_status_logger.py b/telnetlib3/tests/test_status_logger.py new file mode 100644 index 0000000..f2f50eb --- /dev/null +++ b/telnetlib3/tests/test_status_logger.py @@ -0,0 +1,189 @@ +# pylint: disable=unused-import +# std imports +import sys +import asyncio + +# local +from telnetlib3.server import StatusLogger, parse_server_args +from telnetlib3.telopt import IAC, WONT, TTYPE +from telnetlib3.tests.accessories import bind_host # pytest fixture +from telnetlib3.tests.accessories import unused_tcp_port # pytest fixture +from telnetlib3.tests.accessories import ( + create_server, + asyncio_connection, +) + + +async def test_rx_bytes_tracking(bind_host, unused_tcp_port): + """rx_bytes increments when data is received from client.""" + async with create_server( + host=bind_host, + port=unused_tcp_port, + connect_maxwait=0.05, + ) as server: + async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): + writer.write(IAC + WONT + TTYPE) + client = await asyncio.wait_for(server.wait_for_client(), 0.5) + + initial_rx = client.rx_bytes + assert initial_rx > 0 + + writer.write(b"hello") + await asyncio.sleep(0.05) + assert client.rx_bytes == initial_rx + 5 + + +async def test_tx_bytes_tracking(bind_host, unused_tcp_port): + """tx_bytes increments when data is sent to client.""" + async with create_server( + host=bind_host, + port=unused_tcp_port, + connect_maxwait=0.05, + ) as server: + async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): + writer.write(IAC + WONT + TTYPE) + client = await asyncio.wait_for(server.wait_for_client(), 0.5) + + initial_tx = client.tx_bytes + assert initial_tx > 0 + + client.writer.write("hello") + await client.writer.drain() + assert client.tx_bytes > initial_tx + + +async def test_status_logger_get_status(bind_host, unused_tcp_port): + """StatusLogger._get_status() returns correct client data.""" + async with create_server( + host=bind_host, + port=unused_tcp_port, + connect_maxwait=0.05, + ) as server: + status_logger = StatusLogger(server, 60) + status = status_logger._get_status() + assert status["count"] == 0 + assert status["clients"] == [] + + async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): + writer.write(IAC + WONT + TTYPE) + await asyncio.wait_for(server.wait_for_client(), 0.5) + + status = status_logger._get_status() + assert status["count"] == 1 + assert len(status["clients"]) == 1 + assert "ip" in status["clients"][0] + assert "port" in status["clients"][0] + assert "rx" in status["clients"][0] + assert "tx" in status["clients"][0] + + +async def test_status_logger_status_changed(bind_host, unused_tcp_port): + """StatusLogger._status_changed() detects changes correctly.""" + async with create_server( + host=bind_host, + port=unused_tcp_port, + connect_maxwait=0.05, + ) as server: + status_logger = StatusLogger(server, 60) + + status_empty = status_logger._get_status() + assert not status_logger._status_changed(status_empty) + + async with asyncio_connection(bind_host, unused_tcp_port) as (reader, writer): + writer.write(IAC + WONT + TTYPE) + await asyncio.wait_for(server.wait_for_client(), 0.5) + + status_with_client = status_logger._get_status() + assert status_logger._status_changed(status_with_client) + + status_logger._last_status = status_with_client + status_same = status_logger._get_status() + assert not status_logger._status_changed(status_same) + + +async def test_status_logger_format_status(): + """StatusLogger._format_status() formats correctly.""" + + class MockServer: + @property + def clients(self): + return [] + + status_logger = StatusLogger(MockServer(), 60) + + status_empty = {"count": 0, "clients": []} + assert status_logger._format_status(status_empty) == "0 clients connected" + + status_one = { + "count": 1, + "clients": [{"ip": "127.0.0.1", "port": 12345, "rx": 100, "tx": 200}], + } + formatted = status_logger._format_status(status_one) + assert "1 client(s)" in formatted + assert "127.0.0.1:12345" in formatted + assert "rx=100" in formatted + assert "tx=200" in formatted + + +async def test_status_logger_start_stop(bind_host, unused_tcp_port): + """StatusLogger.start() and stop() manage task lifecycle.""" + async with create_server( + host=bind_host, + port=unused_tcp_port, + connect_maxwait=0.05, + ) as server: + status_logger = StatusLogger(server, 60) + assert status_logger._task is None + + status_logger.start() + assert status_logger._task is not None + assert not status_logger._task.done() + + status_logger.stop() + await asyncio.sleep(0.01) + assert status_logger._task.cancelled() + + +async def test_status_logger_disabled_with_zero_interval(bind_host, unused_tcp_port): + """StatusLogger with interval=0 does not create task.""" + async with create_server( + host=bind_host, + port=unused_tcp_port, + connect_maxwait=0.05, + ) as server: + status_logger = StatusLogger(server, 0) + status_logger.start() + assert status_logger._task is None + + +def test_status_interval_cli_arg_default(): + """--status-interval CLI argument has correct default.""" + old_argv = sys.argv + try: + sys.argv = ["test"] + args = parse_server_args() + assert args["status_interval"] == 20 + finally: + sys.argv = old_argv + + +def test_status_interval_cli_arg_custom(): + """--status-interval CLI argument accepts custom values.""" + old_argv = sys.argv + try: + sys.argv = ["test", "--status-interval", "30"] + args = parse_server_args() + assert args["status_interval"] == 30 + finally: + sys.argv = old_argv + + +def test_status_interval_cli_arg_disabled(): + """--status-interval 0 disables status logging.""" + old_argv = sys.argv + try: + sys.argv = ["test", "--status-interval", "0"] + args = parse_server_args() + assert args["status_interval"] == 0 + finally: + sys.argv = old_argv