Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ jobs:
run: python -Im tox -e ${{ matrix.toxenv }}

tests:
name: Python ${{ matrix.python-version }} (${{ matrix.os }})${{ matrix.asyncio-debug && ' [asyncio-debug]' || '' }}
name: Python ${{ matrix.python-version }} (${{ matrix.os }})${{ matrix.asyncio-debug && ' [asyncio-debug]' || '' }}${{ matrix.optional && ' [OPTIONAL]' || '' }}
continue-on-error: ${{ matrix.optional || false }}
strategy:
fail-fast: false
matrix:
Expand All @@ -65,15 +66,22 @@ jobs:
- "3.13"

include:
# Python 3.14 pre-release
# Python 3.14 pre-release (Linux + Windows)
- os: ubuntu-latest
python-version: "3.14"
- os: windows-latest
python-version: "3.14"

# Python 3.14 with asyncio debug mode
- os: ubuntu-latest
python-version: "3.14"
asyncio-debug: true

# Python 3.15 pre-release (optional)
- os: ubuntu-latest
python-version: "3.15"
optional: true

runs-on: ${{ matrix.os }}

steps:
Expand Down Expand Up @@ -145,9 +153,8 @@ jobs:
python -Im coverage report --format=markdown >> $GITHUB_STEP_SUMMARY

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
verbose: true

Expand Down
167 changes: 88 additions & 79 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,111 +38,109 @@ The python telnetlib.py_ module removed by Python 3.13 is also re-distributed as

See the `Guidebook`_ for examples and the `API documentation`_.

Asyncio Protocol
----------------

The core protocol and CLI utilities are written using an `Asyncio Interface`_.

Blocking API
------------

A Synchronous interface, modeled after telnetlib.py_ (client) and miniboa_ (server), with various
enhancements in protocol negotiation is also provided. See `sync API documentation`_ for more.

Command-line Utilities
----------------------

Two CLI tools are included: ``telnetlib3-client`` for connecting to servers
and ``telnetlib3-server`` for hosting a server.
The CLI utility ``telnetlib3-client`` is provided for connecting to servers and
``telnetlib3-server`` for hosting a server.

Both tools argument ``--shell=my_module.fn_shell`` describing a python
module path to a function of signature ``async def shell(reader, writer)``.
The server also provides ``--pty-exec`` argument to host a stand-alone
program.
Both tools accept the argument ``--shell=my_module.fn_shell`` describing a python module path to a
function of signature ``async def shell(reader, writer)``. The server also provides ``--pty-exec``
argument to host stand-alone programs.

::

# utf8 roguelike server
# telnet to utf8 roguelike server
telnetlib3-client nethack.alt.org
# utf8 bbs

# or bbs,
telnetlib3-client xibalba.l33t.codes 44510
# automatic communication with telnet server

# automatic script communicates with a server
telnetlib3-client --shell bin.client_wargame.shell 1984.ws 666
# run a server with default shell

# run a server bound with the default shell bound to 127.0.0.1 6023
telnetlib3-server
# or custom port and ip and shell

# or custom ip, port and shell
telnetlib3-server 0.0.0.0 1984 --shell=bin.server_wargame.shell
# run an external program with a pseudo-terminal (raw mode is default)
telnetlib3-server --pty-exec /bin/bash -- --login
# or a linemode program, bc (calculator)
telnetlib3-server --pty-exec /bin/bc --line-mode

# host an external program with a pseudo-terminal (raw mode is default)
telnetlib3-server --pty-exec /bin/bash -- --login

There are also fingerprinting CLIs, ``telnetlib3-fingerprint`` and
``telnetlib3-fingerprint-server``
# or host a program in linemode,
telnetlib3-server --pty-exec /bin/bc --line-mode

::
There are also two fingerprinting CLIs, ``telnetlib3-fingerprint`` and
``telnetlib3-fingerprint-server``::

# host a server, wait for clients to connect and fingerprint them,
telnetlib3-fingerprint-server

# report fingerprint of telnet server on 1984.ws
# report fingerprint of the telnet server on 1984.ws
telnetlib3-fingerprint 1984.ws

Encoding
~~~~~~~~

Legacy telnetlib
----------------
The default encoding is the system locale, usually UTF-8, and, without negotiation of BINARY
transmission, all Telnet protocol text *should* be limited to ASCII text, by strict compliance of
Telnet. Further, the encoding used *should* be negotiated by CHARSET.

This library contains an *unadulterated copy* of Python 3.12's telnetlib.py_,
from the standard library before it was removed in Python 3.13.
When these conditions are true, telnetlib3-server and telnetlib3-client allow connections of any
encoding supporting by the python language, and additionally specially ``ATASCII`` and ``PETSCII``
encodings. Any server capable of negotiating CHARSET or LANG through NEW_ENVIRON is also presumed
to support BINARY.

To migrate code, change import statements:
From a February 2026 `census of MUDs <https://muds.modem.xyz>`_ and `BBSs servers
<https://bbs.modem.xyz>`_:

.. code-block:: python
- 2.8% of MUDs support bi-directional CHARSET
- 0.5% of BBSs support bi-directional CHARSET.
- 18.4% of BBSs support BINARY.
- 3.2% of MUDs support BINARY.

# OLD imports:
import telnetlib
For this reason, it is often required to specify the encoding, eg.!

# NEW imports:
import telnetlib3
telnetlib3-client --encoding=cp437 20forbeers.com 1337

``telnetlib3`` did not provide server support, while this library also provides
both client and server support through a similar Blocking API interface.
Raw Mode
~~~~~~~~

See `sync API documentation`_ for details.
Some telnet servers, especially BBS systems or those designed for serial transmission but are
connected to a TCP socket without any telnet negotiation may require "raw" mode argument::

Encoding
--------
telnetlib3-client --raw-mode area52.tk 5200 --encoding=atascii

Often required, ``--encoding`` and ``--force-binary``::
Asyncio Protocol
----------------

telnetlib3-client --encoding=cp437 --force-binary 20forbeers.com 1337
The core protocol and CLI utilities are written using an `Asyncio Interface`_.

The default encoding is the system locale, usually UTF-8, but all Telnet
protocol text *should* be limited to ASCII until BINARY mode is agreed by
compliance of their respective RFCs.
Blocking API
------------

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 negotiate about it.
A Synchronous interface, modeled after telnetlib.py_ (client) and miniboa_ (server), with various
enhancements in protocol negotiation is also provided. See `sync API documentation`_ for more.

In this case, use ``--force-binary`` and ``--encoding`` when the encoding of
the remote end is known.
Legacy telnetlib
----------------

Go-Ahead (GA)
--------------
This library contains an *unadulterated copy* of Python 3.12's telnetlib.py_,
from the standard library before it was removed in Python 3.13.

When a client does not negotiate Suppress Go-Ahead (SGA), the server sends
``IAC GA`` after output to signal that the client may transmit. This is
correct behavior for MUD clients like Mudlet that expect prompt detection
via GA.
To migrate code, change import statements:

If GA causes unwanted output for your use case, disable it::
.. code-block:: python

telnetlib3-server --never-send-ga
# OLD imports:
import telnetlib

For PTY shells, GA is sent after 500ms of output idle time to avoid
injecting GA in the middle of streaming output.
# NEW imports:
import telnetlib3

This library *also* provides an additional client (and server) API through a similar interface but
offering more advanced negotiation features and options. See `sync API documentation`_ for more.

Quick Example
=============
Expand All @@ -156,7 +154,7 @@ A simple telnet server:

async def shell(reader, writer):
writer.write('\r\nWould you like to play a game? ')
inp = await reader.read(1)
inp = await reader.readline()
if inp:
writer.echo(inp)
writer.write('\r\nThey say the only way to win '
Expand All @@ -170,6 +168,29 @@ A simple telnet server:

asyncio.run(main())

A client that connects and plays the game:

.. code-block:: python

import asyncio
import telnetlib3

async def shell(reader, writer):
while True:
output = await reader.read(1024)
if not output:
break
if '?' in output:
writer.write('y\r\n')
print(output, end='', flush=True)
print()

async def main():
reader, writer = await telnetlib3.open_connection('localhost', 6023)
await shell(reader, writer)

asyncio.run(main())

More examples are available in the `Guidebook`_ and the `bin/`_ directory of the repository.

Features
Expand All @@ -185,7 +206,7 @@ The following RFC specifications are implemented:
* `rfc-857`_, "Telnet Echo Option", May 1983.
* `rfc-858`_, "Telnet Suppress Go Ahead Option", May 1983.
* `rfc-859`_, "Telnet Status Option", May 1983.
* `rfc-860`_, "Telnet Timing mark Option", May 1983.
* `rfc-860`_, "Telnet Timing Mark Option", May 1983.
* `rfc-885`_, "Telnet End of Record Option", Dec 1983.
* `rfc-930`_, "Telnet Terminal Type Option", Jan 1984.
* `rfc-1073`_, "Telnet Window Size Option", Oct 1988.
Expand Down Expand Up @@ -231,18 +252,6 @@ The following RFC specifications are implemented:
.. _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
---------------
Expand Down
2 changes: 1 addition & 1 deletion bin/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
# bin/ package shell callbacks for telnetlib3 examples.
# bin/ package -- shell callbacks for telnetlib3 examples.
50 changes: 9 additions & 41 deletions bin/server_mud.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@

Usage::

$ python bin/server_mud.py
$ telnet localhost 6023
$ telnetlib3-server --line-mode --shell bin.server_mud.shell
$ telnetlib3-client localhost 6023

The default Telnet server negotiates WILL SGA + WILL ECHO, which puts telnetlib3-client into kludge
(raw) mode. MUD clients would prefer that you start the server with ``--line-mode``, to keep
clients in their (default) NVT line mode.
"""

from __future__ import annotations
Expand All @@ -15,12 +19,10 @@
import random
import asyncio
import logging
import argparse
import unicodedata
from typing import Any

# local
import telnetlib3
from telnetlib3.telopt import GMCP, MSDP, MSSP, WILL
from telnetlib3.server_shell import readline2

Expand Down Expand Up @@ -101,7 +103,7 @@
}


class Weapon: # pylint: disable=too-few-public-methods
class Weapon:
"""A weapon that can be held or placed in a room."""

def __init__(self, name: str, damage: tuple[int, int], start_room: str) -> None:
Expand All @@ -117,7 +119,7 @@ def damage_display(self) -> str:
return f"{self.damage[0]}-{self.damage[1]}"


class Player: # pylint: disable=too-few-public-methods
class Player:
"""A connected player."""

def __init__(self, name: str = "Adventurer") -> None:
Expand Down Expand Up @@ -272,9 +274,7 @@ def on_gmcp(writer: Any, package: str, data: Any) -> None:
writer.write(f"[DEBUG GMCP] {package}: {json.dumps(data)}\r\n")


def get_msdp_var( # pylint: disable=too-many-return-statements
player: Player, var: str
) -> dict[str, Any] | None:
def get_msdp_var(player: Player, var: str) -> dict[str, Any] | None:
"""Return MSDP value dict for *var*, or ``None`` if unknown."""
if var == "CHARACTER_NAME":
return {"CHARACTER_NAME": player.name}
Expand Down Expand Up @@ -834,35 +834,3 @@ async def shell(reader: Any, writer: Any) -> None:
broadcast_room(None, player.room, f"{player.name} has left.")
update_room_all(player.room)
writer.close()


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""Parse command-line arguments."""
ap = argparse.ArgumentParser(description="Mini-MUD demo server with telnetlib3")
ap.add_argument("--host", default="127.0.0.1", help="bind address (default: 127.0.0.1)")
ap.add_argument("--port", type=int, default=6023, help="bind port (default: 6023)")
ap.add_argument("--log-level", default="INFO", help="log level (default: INFO)")
return ap.parse_args(argv)


async def main(argv: list[str] | None = None) -> None:
"""Start the MUD server."""
args = parse_args(argv)
logging.basicConfig(
level=getattr(logging, args.log_level.upper()),
format="%(asctime)s %(message)s",
datefmt="%H:%M:%S",
)
server = await telnetlib3.create_server(host=args.host, port=args.port, shell=shell)
log.info("%s running on %s:%d", SERVER_NAME, args.host, args.port)
print(
f"{SERVER_NAME} running on"
f" {args.host}:{args.port}\n"
f"Connect with: telnet {args.host} {args.port}\n"
"Press Ctrl+C to stop"
)
await server.wait_closed()


if __name__ == "__main__":
asyncio.run(main())
5 changes: 5 additions & 0 deletions docs/api/session_context.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
session_context
---------------

.. automodule:: telnetlib3._session_context
:members:
Loading
Loading