Skip to content

Commit

Permalink
Prepare 0.4.0 release
Browse files Browse the repository at this point in the history
* bugfix: cannot connect to IPv6 address as client.
* change: TelnetClient.CONNECT_DEFERED class attribute renamed DEFERRED.
  Default value changed to 50ms from 100ms.
* change: TelnetClient.waiter renamed to TelnetClient.waiter_closed.
* enhancement: TelnetClient.waiter_connected future added.
* add: functional tests using curl
* add: functional tests piping client and server together
  • Loading branch information
jquast committed Oct 17, 2015
1 parent d551dec commit 9cb02a9
Show file tree
Hide file tree
Showing 8 changed files with 201 additions and 28 deletions.
1 change: 1 addition & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ addons:
packages:
# on ubuntu 12.04 this is linux debian 'netkit-telnet' package renamed,
- telnet
- curl
22 changes: 22 additions & 0 deletions DESIGN.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
TelnetStream
------------

feed_byte called by telnet server should be a coroutine
receiving data by send. It should yield 'is_oob', or 'slc_received',
etc.? We're still considering ... the state still requires tracking,
but this would turn multiple function calls into a single call as a
generator, probably preferred for bandwidth.

handle_xon resumes writing in a way that is not obvious -- we should
be using the true 'pause_writing' and 'resume_writing' methods of our
base protocol. The given code was written before these methods became
available in asyncio (then, tulip). We need to accommodate the new
availabilities.

On Encoding
-----------

currently we determine 'CHARSET': 'utf8' because we cannot correctly
determine the first part of LANG (en_us.UTF-8, for example). It should
be possible, in a derived (demo) application, to determine the region
code by geoip (maxmind database, etc).
7 changes: 7 additions & 0 deletions docs/history.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
0.4.0
* bugfix: cannot connect to IPv6 address as client.
* change: TelnetClient.CONNECT_DEFERED class attribute renamed DEFERRED.
Default value changed to 50ms from 100ms.
* change: TelnetClient.waiter renamed to TelnetClient.waiter_closed.
* enhancement: TelnetClient.waiter_connected future added.

0.3.0
* bugfix: cannot bind to IPv6 address :ghissue:`5`.
* enhancement: Futures waiter_connected, and waiter_closed added to server.
Expand Down
66 changes: 39 additions & 27 deletions telnetlib3/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ class TelnetClient(asyncio.protocols.Protocol):
#: maximum on-connect time to wait for server-initiated negotiation options
# before negotiation is considered 'final'.
CONNECT_MAXWAIT = 6.00
#: timer length for check_negotiation re-scheduling
CONNECT_DEFERED = 0.1
#: timer length for check_negotiation re-scheduling deferred.
CONNECT_DEFERRED = 0.05

#: default client environment variables,
default_env = {
Expand All @@ -36,7 +36,8 @@ class TelnetClient(asyncio.protocols.Protocol):
}

def __init__(self, shell=TerminalShell, stream=TelnetStream,
encoding='utf-8', log=logging, force_binary=False):
encoding='utf-8', log=logging, force_binary=False,
waiter_connected=None, waiter_closed=False):
""" Constructor method for TelnetClient.
:param shell: Terminal Client shell factory class.
Expand Down Expand Up @@ -77,16 +78,18 @@ def __init__(self, shell=TerminalShell, stream=TelnetStream,
self._server_ip = None
self._server_port = None

self._telopt_negotiation = asyncio.Future()
self._telopt_negotiation.add_done_callback(
self.after_telopt_negotiation)

self._encoding_negotiation = asyncio.Future()
self._encoding_negotiation.add_done_callback(
self.after_encoding_negotiation)

#: waiter is a Future that completes when connection is closed.
self.waiter = asyncio.Future()
if waiter_closed is None:
waiter_closed = asyncio.Future()
self.waiter_closed = waiter_closed

if waiter_connected is None:
waiter_connected = asyncio.Future()
self.waiter_connected = waiter_connected

def __str__(self):
""" Return string reporting status of client session. """
Expand All @@ -104,7 +107,7 @@ def connection_made(self, transport):
"""
self.transport = transport
self._server_ip, self._server_port = (
transport.get_extra_info('peername'))
transport.get_extra_info('peername')[:2])
self.stream = self._stream_factory(
transport=transport, client=True, log=self.log)
self.shell = self._shell_factory(client=self, log=self.log)
Expand Down Expand Up @@ -384,7 +387,7 @@ def begin_negotiation(self):
elapsed.
"""
if self._closing:
self._telopt_negotiation.cancel()
self.waiter_connected.cancel()
return

self._loop.call_soon(self.check_negotiation)
Expand All @@ -397,17 +400,25 @@ def check_negotiation(self):
when complete.
"""
if self._closing:
self._telopt_negotiation.cancel()
self.waiter_connected.cancel()
return
pots = self.stream.pending_option
if not any(pots.values()):
if self.duration > self.CONNECT_MINWAIT:
self._telopt_negotiation.set_result(self.stream.__str__())
# the number of seconds since connection has reached
# CONNECT_MINWAIT and no pending telnet options are
# awaiting negotiation.
self.waiter_connected.set_result(self)
return

elif self.duration > self.CONNECT_MAXWAIT:
self._telopt_negotiation.set_result(self.stream.__str__())
# with telnet options pending, we set waiter_connected anyway -- it
# is unlikely after such time elapsed that the server will complete
# negotiation after this time.
self.water_connected.set_result(self)
return
self._loop.call_later(self.CONNECT_DEFERED, self.check_negotiation)

self._loop.call_later(self.CONNECT_DEFERRED, self.check_negotiation)

def after_telopt_negotiation(self, status):
""" Callback when on-connect option negotiation is complete.
Expand All @@ -419,8 +430,10 @@ def after_telopt_negotiation(self, status):
"""
if status.cancelled():
self.log.debug('telopt negotiation cancelled')
self.waiter_connected.cancel()
else:
self.log.debug('stream status: {}.'.format(status.result()))
self.log.debug('stream status: {}.'.format(self.stream))
self.waiter_connected.set_result(self)

def check_encoding_negotiation(self):
""" Callback to check on-connect option negotiation for encoding.
Expand Down Expand Up @@ -448,7 +461,7 @@ def check_encoding_negotiation(self):
not (DO, BINARY,) in self.stream.pending_option):
self.log.debug('outbinary=True, requesting inbinary.')
self.stream.iac(DO, BINARY)
self._loop.call_later(self.CONNECT_DEFERED,
self._loop.call_later(self.CONNECT_DEFERRED,
self.check_encoding_negotiation)

elif self.duration > self.CONNECT_MAXWAIT:
Expand All @@ -457,7 +470,7 @@ def check_encoding_negotiation(self):
self._encoding_negotiation.set_result(False)

else:
self._loop.call_later(self.CONNECT_DEFERED,
self._loop.call_later(self.CONNECT_DEFERRED,
self.check_encoding_negotiation)

def after_encoding_negotiation(self, status):
Expand All @@ -471,6 +484,7 @@ def after_encoding_negotiation(self, status):
if status.cancelled():
self.log.debug('encoding negotiation cancelled')
return

self.log.debug('client encoding is {}.'.format(
self.encoding(outgoing=True, incoming=True)))

Expand All @@ -487,9 +501,9 @@ def duration(self):
def data_received(self, data):
""" Process each byte as received by transport.
All bytes are sent to :py:meth:`self.feed_byte` to check
for Telnet Is-A-Command (IAC) or continuation bytes. When
bytes are in-band, they are then sent to
All bytes are sent to :py:meth:`TelnetStream.feed_byte` to
check for Telnet Is-A-Command (IAC) or continuation bytes.
When bytes are in-band, they are then sent to
:py:meth:`self.shell.feed_byte`
"""
self.log.debug('data_received: {!r}'.format(data))
Expand Down Expand Up @@ -518,20 +532,18 @@ def connection_lost(self, exc):
:param exc: exception
"""
if not self._closing:
self._closing = True
self.log.info('{about}{reason}'.format(
about=self.__str__(),
reason=': {}'.format(exc) if exc is not None else ''))
self.waiter.set_result(None)
self._closing = True
self.waiter_closed.set_result(self)


def describe_connection(client):
if client._closing:
direction = 'from'
state = 'Disconnected'
state, direction = 'Disconnected', 'from'
else:
direction = 'to'
state = 'Connected'
state, direction = 'Connected', 'to'
if (client.server_hostname.done() and
client.server_hostname.result() != client.server_ip):
hostname = ' ({})'.format(client.server_hostname.result())
Expand All @@ -542,7 +554,7 @@ def describe_connection(client):
else:
port = ''

duration = '{:0.1f}s'.format(client.duration)
duration = '{:0.2f}s'.format(client.duration)
return ('{state} {direction} {serverip}{port}{hostname} after {duration}'
.format(
state=state,
Expand Down
16 changes: 16 additions & 0 deletions telnetlib3/tests/accessories.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,23 @@ def bind_host(request):
class TestTelnetServer(telnetlib3.TelnetServer):
CONNECT_MINWAIT = 0.10
CONNECT_MAXWAIT = 0.50
CONNECT_DEFERRED = 0.01
TTYPE_LOOPMAX = 2
default_env = {
'PS1': 'test-telsh %# ',
}

class TestTelnetClient(telnetlib3.TelnetClient):
#: mininum on-connect time to wait for server-initiated negotiation options
CONNECT_MINWAIT = 0.20
CONNECT_MAXWAIT = 0.75
CONNECT_DEFERRED = 0.01

#: default client environment variables,
default_env = {
'COLUMNS': '80',
'LINES': '24',
'USER': 'test-client',
'TERM': 'test-terminal',
'CHARSET': 'ascii',
}
63 changes: 63 additions & 0 deletions telnetlib3/tests/test_clientserver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Functionally tests telnetlib3 Client against its own Server."""

# std imports
import asyncio

# local imports
from .accessories import (
TestTelnetServer,
TestTelnetClient,
unused_tcp_port,
event_loop,
bind_host,
log
)

# 3rd party
import pytest

@pytest.mark.asyncio
def test_telnet_coupled(event_loop, bind_host, unused_tcp_port, log):

waiter_server_connected = asyncio.Future()
waiter_client_connected = asyncio.Future()
waiter_server_closed = asyncio.Future()
waiter_client_closed = asyncio.Future()

server = yield from event_loop.create_server(
protocol_factory=lambda: TestTelnetServer(
waiter_connected=waiter_server_connected,
waiter_closed=waiter_server_closed,
log=log),
host=bind_host, port=unused_tcp_port)

log.info('Listening on {0}'.format(server.sockets[0].getsockname()))

_transport, client_protocol = yield from event_loop.create_connection(
protocol_factory=lambda: TestTelnetClient(
waiter_connected=waiter_client_connected,
waiter_closed=waiter_client_closed,
encoding='utf8', log=log),
host=bind_host, port=unused_tcp_port)

done, pending = yield from asyncio.wait(
[waiter_client_connected, waiter_server_connected],
loop=event_loop, timeout=1)

assert not pending, (done,
pending,
waiter_client_connected,
waiter_server_connected)

client_protocol.stream.write(u'quit\r'.encode('ascii'))

done, pending = yield from asyncio.wait(
[waiter_client_closed, waiter_server_closed],
loop=event_loop, timeout=1)

assert not pending, (done,
pending,
waiter_client_connected,
waiter_server_connected)


52 changes: 52 additions & 0 deletions telnetlib3/tests/test_curltelnet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Functionally tests telnetlib3 as a server using curl(1)."""
# std imports
import subprocess
import asyncio
import locale
import codecs

# local imports
from .accessories import (
TestTelnetServer,
unused_tcp_port,
event_loop,
bind_host,
log
)

# local
import telnetlib3

# 3rd party imports
import pytest
import pexpect


@pytest.mark.skipif(pexpect.which('curl') is None,
reason="Requires curl(1)")
@pytest.mark.asyncio
def test_curltelnet(event_loop, bind_host, unused_tcp_port, log):

waiter_closed = asyncio.Future()
waiter_connected = asyncio.Future()

server = yield from event_loop.create_server(
protocol_factory=lambda: TestTelnetServer(
waiter_closed=waiter_closed,
waiter_connected=waiter_connected,
log=log),
host=bind_host, port=unused_tcp_port)

log.info('Listening on {0}'.format(server.sockets[0].getsockname()))

curl = yield from asyncio.create_subprocess_exec(
'curl', '--verbose', '--progress-bar',
'telnet://{0}:{1}'.format(bind_host, unused_tcp_port),
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)

stdout, stderr = yield from curl.communicate(input=b'quit\r\n')

server = yield from waiter_closed
2 changes: 1 addition & 1 deletion version.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"version": "0.3.0"}
{"version": "0.4.0"}

0 comments on commit 9cb02a9

Please sign in to comment.