@@ -30,17 +30,17 @@ def _handle_alarm(signum, frame):
3030# to configure the client's prompt detector before opening the primary channel.
3131# - To keep us netcat compatible, handshake is optional. It is legal
3232# to just immediately connect on the primary channel, in which case
33- # there will be no control channel associated with the REPL session.
34- # - 2: Open the primary channel, parse the session number from the first line of text.
35- # - To keep us netcat compatible, we must transmit the session number as
33+ # there will be no control channel paired with the REPL session.
34+ # - 2: Open the primary channel, parse the session id from the first line of text.
35+ # - To keep us netcat compatible, we must transmit the session id as
3636# part of the primary data stream; it cannot be packaged into a message
3737# since only `unpythonic` knows about the message protocol.
3838# - So, print "Session XX connected\n" as the first line on the server side
3939# when a client connects. In the client, parse the first line (beside
4040# printing it as usual, to have the same appearance for both unpythonic
4141# and netcat connections).
42- # - 3: Send command on the control channel to associate that control channel
43- # to session number XX. Maybe print a message on the client side saying
42+ # - 3: Send command on the control channel to pair that control channel
43+ # to session id XX. Maybe print a message on the client side saying
4444# that tab completion and Ctrl+C are available.
4545
4646# Messages must be processed by just one central decoder, to prevent data
@@ -65,44 +65,56 @@ def __init__(self, sock):
6565 # ReceiveBuffer inside MessageDecoder.
6666 self .decoder = MessageDecoder (socketsource (sock ))
6767
68- def complete (self , text , state ):
69- """Tab-complete in a remote REPL session .
68+ def _send_command (self , request ):
69+ """Send a command to the server, get the reply .
7070
71- This is a completer for `readline.set_completer`.
71+ request: a dict-like, containing the "command" field and any required
72+ parameters (command-dependent).
73+
74+ On success, return the `reply` dict. On failure, return `None`.
7275 """
7376 try :
74- request = {"command" : "TabComplete" , "text" : text , "state" : state }
7577 self ._send (request )
7678 reply = self ._recv ()
77- # print("text '{}' state '{}' reply '{}'".format(text, state, reply))
7879 if not reply :
7980 print ("Socket closed by other end." )
8081 return None
8182 if reply ["status" ] == "ok" :
82- return reply ["result" ]
83+ return reply
84+ elif reply ["status" ] == "failed" :
85+ if "reason" in reply :
86+ print ("Server command failed, reason: {}" .format (reply ["reason" ]))
8387 except BaseException as err :
8488 print (type (err ), err )
8589 return None
8690
91+ def describe_server (self ):
92+ """Return server metadata such as prompt settings and version."""
93+ return self ._send_command ({"command" : "DescribeServer" })
94+
95+ def pair_with_session (self , session_id ):
96+ """Pair this control channel with a REPL session."""
97+ return self ._send_command ({"command" : "PairWithSession" , "id" : session_id })
98+
99+ def complete (self , text , state ):
100+ """Tab-complete in a remote REPL session.
101+
102+ This is a completer for `readline.set_completer`.
103+ """
104+ reply = self ._send_command ({"command" : "TabComplete" , "text" : text , "state" : state })
105+ if reply :
106+ return reply ["result" ]
107+ return None
108+
87109 def send_kbinterrupt (self ):
88110 """Request the server to perform a `KeyboardInterrupt` (Ctrl+C).
89111
90- The `KeyboardInterrupt` occurs in the REPL session associated with
112+ The `KeyboardInterrupt` occurs in the REPL session paired with
91113 this control channel.
92114
93115 Returns truthy on success, falsey on failure.
94116 """
95- try :
96- request = {"command" : "KeyboardInterrupt" }
97- self ._send (request )
98- reply = self ._recv ()
99- if not reply :
100- print ("Socket closed by other end." )
101- return None
102- return reply ["status" ] == "ok"
103- except BaseException as err :
104- print (type (err ), err )
105- return False
117+ return self ._send_command ({"command" : "KeyboardInterrupt" })
106118
107119
108120def connect (addrspec ):
@@ -122,19 +134,47 @@ def connect(addrspec):
122134 class SessionExit (Exception ):
123135 pass
124136 try :
125- with socket .socket (socket .AF_INET , socket .SOCK_STREAM ) as sock : # remote REPL session
126- sock .connect (addrspec )
127-
128- with socket .socket (socket .AF_INET , socket .SOCK_STREAM ) as csock : # control channel (remote tab completion, remote Ctrl+C)
129- # TODO: configurable control port
130- csock .connect ((addrspec [0 ], 8128 )) # TODO: IPv6 support
131-
132- # Set a custom tab completer for readline.
133- # https://stackoverflow.com/questions/35115208/is-there-any-way-to-combine-readline-rlcompleter-and-interactiveconsole-in-pytho
134- controller = ControlClient (csock )
135- readline .set_completer (controller .complete )
136- readline .parse_and_bind ("tab: complete" )
137-
137+ # First handshake on control channel to get prompt information.
138+ with socket .socket (socket .AF_INET , socket .SOCK_STREAM ) as csock : # control channel (remote tab completion, remote Ctrl+C)
139+ # TODO: configurable control port
140+ csock .connect ((addrspec [0 ], 8128 )) # TODO: IPv6 support
141+ controller = ControlClient (csock )
142+
143+ # TODO: use the prompts information in calls to input()
144+ metadata = controller .describe_server ()
145+ # print(metadata["prompts"])
146+
147+ # Set up remote tab completion, using a custom completer for readline.
148+ # https://stackoverflow.com/questions/35115208/is-there-any-way-to-combine-readline-rlcompleter-and-interactiveconsole-in-pytho
149+ readline .set_completer (controller .complete )
150+ readline .parse_and_bind ("tab: complete" )
151+
152+ with socket .socket (socket .AF_INET , socket .SOCK_STREAM ) as sock : # remote REPL session
153+ sock .connect (addrspec )
154+
155+ # TODO: refactor
156+ # The first line of text contains the session id.
157+ # We can't use the message protocol; this information must arrive on the primary channel.
158+ rs , ws , es = select .select ([sock ], [], [])
159+ for r in rs :
160+ data = sock .recv (4096 )
161+ if len (data ) == 0 :
162+ print ("replclient: disconnected by server." )
163+ raise SessionExit
164+ text = data .decode ("utf-8" )
165+ sys .stdout .write (text )
166+ if "\n " in text :
167+ first_line , * rest = text .split ("\n " )
168+ import re
169+ matches = re .findall (r"session (\d+) connected" , first_line )
170+ assert len (matches ) == 1 , "Expected server to print session id on the first line"
171+ repl_session_id = int (matches [0 ])
172+ else :
173+ # TODO: make sure we always receive a whole line
174+ assert False
175+ controller .pair_with_session (repl_session_id )
176+
177+ # TODO: This can't be a separate thread, we must listen for input only when a prompt has appeared.
138178 def sock_to_stdout ():
139179 try :
140180 while True :
@@ -144,7 +184,8 @@ def sock_to_stdout():
144184 if len (data ) == 0 :
145185 print ("replclient: disconnected by server." )
146186 raise SessionExit
147- sys .stdout .write (data .decode ("utf-8" ))
187+ text = data .decode ("utf-8" )
188+ sys .stdout .write (text )
148189 except SessionExit :
149190 # Exit also in main thread, which is waiting on input() when this happens.
150191 signal .alarm (1 )
0 commit comments