Skip to content

Commit 706306d

Browse files
committed
Move app-level _send, _recv into base class in unpythonic.net.common
1 parent 4f0aa50 commit 706306d

File tree

3 files changed

+103
-67
lines changed

3 files changed

+103
-67
lines changed

unpythonic/net/client.py

Lines changed: 12 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33

44
import readline # noqa: F401, input() uses the readline module if it has been loaded.
55
import socket
6-
import json
76
import select
87
import sys
98
import signal
109
import threading
1110

12-
from .msg import encodemsg, socketsource, MessageDecoder
11+
from .msg import socketsource, MessageDecoder
12+
from .common import ApplevelProtocol
1313

1414
__all__ = ["connect"]
1515

@@ -50,7 +50,7 @@ def _handle_alarm(signum, frame):
5050
# to the beginning of the second message has already arrived and been read
5151
# from the socket, when trying to read the last batch of data belonging to
5252
# the end of the first message.
53-
class ControlClient:
53+
class ControlClient(ApplevelProtocol):
5454
# TODO: manage the socket internally. We need to make this into a context manager,
5555
# so that __enter__ can set up the socket and __exit__ can tear it down.
5656
def __init__(self, sock):
@@ -61,34 +61,10 @@ def __init__(self, sock):
6161
"""
6262
self.sock = sock
6363
# The source is just an abstraction over the details of how to actually
64-
# read data from a specific type of data source; buffering occurs in
64+
# read data from a specific type of message source; buffering occurs in
6565
# ReceiveBuffer inside MessageDecoder.
6666
self.decoder = MessageDecoder(socketsource(sock))
6767

68-
# TODO: Refactor low-level _send, _recv functions into a base class common for server and client.
69-
def _send(self, data):
70-
"""Send a message on the control channel.
71-
72-
data: dict-like.
73-
"""
74-
json_data = json.dumps(data)
75-
bytes_out = json_data.encode("utf-8")
76-
self.sock.sendall(encodemsg(bytes_out))
77-
def _recv(self):
78-
"""Receive a message on the control channel.
79-
80-
Returns a dict-like.
81-
82-
Blocks if no data is currently available on the channel,
83-
but EOF has not been signaled.
84-
"""
85-
bytes_in = self.decoder.decode()
86-
if not bytes_in:
87-
print("Socket closed by other end.")
88-
return None
89-
json_data = bytes_in.decode("utf-8")
90-
return json.loads(json_data)
91-
9268
def complete(self, text, state):
9369
"""Tab-complete in a remote REPL session.
9470
@@ -99,7 +75,10 @@ def complete(self, text, state):
9975
self._send(request)
10076
reply = self._recv()
10177
# print("text '{}' state '{}' reply '{}'".format(text, state, reply))
102-
if reply and reply["status"] == "ok":
78+
if not reply:
79+
print("Socket closed by other end.")
80+
return None
81+
if reply["status"] == "ok":
10382
return reply["result"]
10483
except BaseException as err:
10584
print(type(err), err)
@@ -117,7 +96,10 @@ def send_kbinterrupt(self):
11796
request = {"command": "KeyboardInterrupt"}
11897
self._send(request)
11998
reply = self._recv()
120-
return reply and reply["status"] == "ok"
99+
if not reply:
100+
print("Socket closed by other end.")
101+
return None
102+
return reply["status"] == "ok"
121103
except BaseException as err:
122104
print(type(err), err)
123105
return False

unpythonic/net/common.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# -*- coding: utf-8; -*-
2+
3+
import json
4+
5+
from .msg import encodemsg
6+
7+
__all__ = ["ApplevelProtocol"]
8+
9+
class ApplevelProtocol:
10+
"""Application-level communication protocol.
11+
12+
We encode the payload as JSON encoded dictionaries, then encoded as utf-8
13+
text. The bytes are stuffed into a message using the `unpythonic.net.msg`
14+
low-level message protocol.
15+
16+
This format was chosen instead of pickle to ensure the client and server
17+
can talk to each other regardless of the Python versions on each end of the
18+
connection.
19+
20+
Transmission is synchronous; when one end is sending, the other one must be
21+
receiving. Both sending and receiving will block until success, or until
22+
the socket is closed.
23+
24+
This can be used as a common base class for server/client object pairs.
25+
26+
**NOTE**: The derived class must define two attributes:
27+
28+
- `sock`: an open TCP socket connected to the peer to communicate with.
29+
30+
- `decoder`: `unpythonic.net.msg.MessageDecoder` instance for receiving
31+
messages. Typically this is connected to `sock` using an
32+
`unpythonic.net.msg.socketsource`, like
33+
`MessageDecoder(socketsource(sock))`.
34+
35+
These are left to the user code to define, because typically the client and
36+
server sides must handle this differently. The client can create `sock` and
37+
`decoder` in its constructor, whereas a TCP server typically inherits from
38+
`socketserver.BaseRequestHandler`, and receives an incoming connection in
39+
its `handle` method (which is then the official place to create any
40+
session-specific attributes).
41+
"""
42+
def _send(self, data):
43+
"""Send a message using the application-level protocol.
44+
45+
data: dict-like.
46+
"""
47+
json_data = json.dumps(data)
48+
bytes_out = json_data.encode("utf-8")
49+
self.sock.sendall(encodemsg(bytes_out))
50+
51+
def _recv(self):
52+
"""Receive a message using the application-level protocol.
53+
54+
Returns a dict-like, or `None` if the decoder's message source
55+
signaled EOF.
56+
57+
Blocks if no data is currently available at the message source,
58+
but EOF has not been signaled.
59+
"""
60+
bytes_in = self.decoder.decode()
61+
if not bytes_in:
62+
return None
63+
json_data = bytes_in.decode("utf-8")
64+
return json.loads(json_data)

unpythonic/net/server.py

Lines changed: 27 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
66
In a REPL session, you can inspect and mutate the global state of your running
77
program. You can e.g. replace top-level function definitions with new versions
8-
in your running process.
8+
in your running process, or reload modules from disk (with `importlib.reload`).
99
1010
To enable it in your app::
1111
@@ -14,7 +14,7 @@
1414
1515
To connect to a running REPL server::
1616
17-
python3 -m unpythonic.replclient localhost 1337
17+
python3 -m unpythonic.net.client localhost 1337
1818
1919
If you're already running in a local Python REPL, this should also work::
2020
@@ -33,12 +33,24 @@
3333
process will terminate the server, also forcibly terminating all open REPL
3434
sessions in that process.
3535
36+
**CAUTION**: `help(foo)` currently does not work in this REPL server. Its
37+
stdin/stdout are not redirected to the socket; instead, it will run locally on
38+
the server, causing the client to hang. The top-level `help()`, which uses a
39+
command-based interface, appears to work, until you ask for a help page, at
40+
which point it runs into the same problem.
41+
3642
**CAUTION**: as Python was not designed for arbitrary hot-patching, if you
37-
change a **class** definition, only new instances will use the new definition,
38-
unless you specifically monkey-patch existing instances to change their type.
43+
change a **class** definition (whether by re-assigning the reference or by
44+
reloading the module containing the definition), only new instances will use
45+
the new definition, unless you specifically monkey-patch existing instances to
46+
change their type.
47+
3948
The language docs hint it is somehow possible to retroactively change an
4049
object's type, if you're careful with it:
4150
https://docs.python.org/3/reference/requestmodel.html#id8
51+
In fact, ActiveState recipe 160164 explicitly tells how to do it,
52+
and even automate that with a custom metaclass:
53+
https://github.com/ActiveState/code/tree/master/recipes/Python/160164_automatically_upgrade_class_instances
4254
4355
Based on socketserverREPL by Ivor Wanders, 2017. Used under the MIT license.
4456
https://github.com/iwanders/socketserverREPL
@@ -56,6 +68,7 @@
5668
The `socketserverREPL` package uses the same default, and actually its
5769
`repl_tool.py` can talk to this server (but doesn't currently feature
5870
remote tab completion).
71+
5972
"""
6073

6174
# TODO: use logging module instead of server-side print
@@ -81,14 +94,14 @@
8194
import os
8295
import time
8396
import socketserver
84-
import json
8597
import atexit
8698

8799
from ..collections import ThreadLocalBox, Shim
88100
#from ..misc import async_raise
89101

90102
from .util import ReuseAddrThreadingTCPServer
91-
from .msg import encodemsg, MessageDecoder, socketsource
103+
from .msg import MessageDecoder, socketsource
104+
from .common import ApplevelProtocol
92105
from .ptyproxy import PTYSocketProxy
93106

94107
_server_instance = None
@@ -104,6 +117,8 @@
104117
_banner = None
105118

106119
# TODO: inject this to globals of the target module
120+
# - Maybe better to inject just a single "repl" container which has this and
121+
# the other stuff, and print out at connection time where to find this stuff.
107122
def halt(doit=True):
108123
"""Tell the REPL server to shut down after the last client has disconnected.
109124
@@ -130,44 +145,15 @@ def server_print(*values, **kwargs):
130145
print(*values, **kwargs, file=_original_stdout)
131146

132147

133-
class ControlSession(socketserver.BaseRequestHandler):
148+
class ControlSession(socketserver.BaseRequestHandler, ApplevelProtocol):
134149
"""Entry point for connections to the control server.
135150
136151
We use a separate connection for control to avoid head-of-line blocking.
137152
138153
In a session, the client sends us requests for remote tab completion. We
139154
invoke `rlcompleter` on the server side, and return its response to the
140155
client.
141-
142-
We encode the payload as JSON encoded dictionaries. This format was chosen
143-
instead of pickle to ensure the client and server can talk to each other
144-
regardless of the Python versions on each end of the connection.
145156
"""
146-
147-
# TODO: Refactor low-level _send, _recv functions into a base class common for server and client.
148-
def _send(self, data):
149-
"""Send a message on the control channel.
150-
151-
data: dict-like.
152-
"""
153-
json_data = json.dumps(data)
154-
bytes_out = json_data.encode("utf-8")
155-
self.sock.sendall(encodemsg(bytes_out))
156-
def _recv(self):
157-
"""Receive a message on the control channel.
158-
159-
Returns a dict-like.
160-
161-
Blocks if no data is currently available on the channel,
162-
but EOF has not been signaled.
163-
"""
164-
bytes_in = self.decoder.decode()
165-
if not bytes_in:
166-
print("Socket closed by other end.")
167-
return None
168-
json_data = bytes_in.decode("utf-8")
169-
return json.loads(json_data)
170-
171157
def handle(self):
172158
# TODO: ipv6 support
173159
caddr, cport = self.client_address
@@ -211,6 +197,7 @@ class ClientExit(Exception):
211197
# about the failure may be included in arbitrary other fields.
212198
request = self._recv()
213199
if not request:
200+
print("Socket closed by other end.")
214201
raise ClientExit
215202

216203
if request["command"] == "TabComplete":
@@ -242,7 +229,10 @@ class ClientExit(Exception):
242229

243230

244231
class ConsoleSession(socketserver.BaseRequestHandler):
245-
"""Entry point for connections from the TCP server."""
232+
"""Entry point for connections from the TCP server.
233+
234+
Primary channel. This serves the actual REPL session.
235+
"""
246236
def handle(self):
247237
# TODO: ipv6 support
248238
caddr, cport = self.client_address

0 commit comments

Comments
 (0)