├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── nrepl ├── __init__.py └── bencode.py ├── setup.py └── test.py /.gitignore: -------------------------------------------------------------------------------- 1 | # emacs + vi backup files 2 | *~ 3 | .*.sw* 4 | 5 | # various IDE junk 6 | *.ipr 7 | *.iml 8 | *.iws 9 | .project 10 | .classpath 11 | .settings 12 | 13 | target 14 | classes 15 | build 16 | *.pyc 17 | dist 18 | 19 | *.egg* 20 | 21 | .hypothesis 22 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | lein: lein2 2 | language: python 3 | python: 4 | - "2.7" 5 | - "3.3" 6 | script: python test.py 7 | 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Chas Emerick 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nrepl-python-client 2 | 3 | Surprisingly, an [nREPL](http://github.com/clojure/tools.nrepl) client 4 | written in Python. 5 | 6 | It pretty much works. 7 | 8 | Requires Python 2.7 or 3.3. Will work with any nREPL >= 0.2.0 endpoint that uses the 9 | default bencode socket transport. Support for [other 10 | transports](https://github.com/clojure/tools.nrepl/wiki/Extensions#transports) 11 | should be straightforward, thanks to an unpythonic multimethod thing that 12 | `nrepl.connect()` uses. 13 | 14 | ## Installation 15 | 16 | Clone from here and use the source, or you can install from 17 | [PyPI](https://pypi.python.org/pypi/nrepl-python-client), e.g.: 18 | 19 | ```sh 20 | $ easy_install nrepl-python-client 21 | ``` 22 | 23 | Or alternatively: 24 | 25 | ```sh 26 | $ pip install nrepl-python-client 27 | ``` 28 | 29 | ## Usage 30 | 31 | Two options, currently. First, explicit, synchronous send/receive of messages 32 | to an nREPL endpoint: 33 | 34 | ```python 35 | >>> import nrepl 36 | >>> c = nrepl.connect("nrepl://localhost:58226") 37 | >>> c.write({"op": "eval", "code": "(reduce + (range 20))"}) 38 | >>> c.read() 39 | {u'session': u'7fb4b7a0-f9e5-4f5f-b506-eb2d0a6e21b1', u'ns': u'user', u'value': u'190'} 40 | >>> c.read() 41 | {u'status': [u'done'], u'session': u'7fb4b7a0-f9e5-4f5f-b506-eb2d0a6e21b1'} 42 | ``` 43 | 44 | `WatchableConnection` provides a facility vaguely similar to Clojure watches, 45 | where a function is called asynchronously when an nREPL response is received if 46 | a predicate or a set of pattern-matching criteria provided with that function 47 | matches the response. For example (from the tests), this code will 48 | asynchronously capture `out` (i.e. `stdout`) content from multiple 49 | sessions' responses: 50 | 51 | ```python 52 | c = nrepl.connect("nrepl://localhost:58226") 53 | wc = nrepl.WatchableConnection(c) 54 | outs = {} 55 | def add_resp (session, msg): 56 | out = msg.get("out", None) 57 | if out: outs[session].append(out) 58 | 59 | def watch_new_sessions (msg, wc, key): 60 | session = msg.get("new-session") 61 | outs[session] = [] 62 | wc.watch("session" + session, {"session": session}, 63 | lambda msg, wc, key: add_resp(session, msg)) 64 | 65 | wc.watch("sessions", {"new-session": None}, watch_new_sessions) 66 | wc.send({"op": "clone"}) 67 | wc.send({"op": "clone"}) 68 | wc.send({"op": "eval", "session": outs.keys()[0], 69 | "code": '(println "hello" "%s")' % outs.keys()[0]}) 70 | wc.send({"op": "eval", "session": outs.keys()[1], 71 | "code": '(println "hello" "%s")' % outs.keys()[1]}) 72 | outs 73 | #>> {u'fee02643-c5c6-479d-9fb4-d1934cfdd29f': [u'hello fee02643-c5c6-479d-9fb4-d1934cfdd29f\n'], 74 | u'696130c8-0310-4bb2-a880-b810d2a198d0': [u'hello 696130c8-0310-4bb2-a880-b810d2a198d0\n']} 75 | ``` 76 | 77 | The watch criteria dicts (e.g. `{"new-session": None}`) are used to constrain 78 | which responses received by the `WatchableConnection` will be passed to the 79 | corresponding callbacks: 80 | 81 | * `{"new-session": None}` will match any response that has any value in the 82 | `"new-session"` slot 83 | * `{"session": session}` will match any response that has the value `session` in 84 | the `"session"` slot. 85 | 86 | Sets may also be used as values in criteria dicts to match responses that 87 | contain any of the set's values in the slot that the set is fond in the criteria 88 | dict. 89 | 90 | Finally, regular predicates may be passed to `watch()` to handle more complex 91 | filtering. 92 | 93 | The callbacks provided to `watch()` must accept three arguments: the matched 94 | incoming message, the instance of `WatchableConnection`, and the key under which 95 | the watch was registered. 96 | 97 | ## Send help 98 | 99 | * Make this a _more_ Proper Python Library. I've been away from Python for a 100 | loooooong time, and I don't know what the current best practices are around 101 | eggs, distribution, and so on. The library is on PyPI, but may not be 102 | following the latest best practices for all I know. Open an issue if you see 103 | a problem or some corner that could be made better. 104 | * Fix my busted Python. Like I said, the last time I did any serious Pythoning 105 | was in the 2.3 days or something (to use new-style classes, or not, that was 106 | the question, etc). If I goofed, open an issue with a fix. 107 | 108 | ## Need Help? 109 | 110 | Ping `cemerick` on freenode irc or 111 | [twitter](http://twitter.com/cemerick) if you have questions or would 112 | like to contribute patches. 113 | 114 | ## License 115 | 116 | Copyright ©2013 [Chas Emerick](http://cemerick.com) and other contributors 117 | 118 | Distributed under the MIT License. Please see the `LICENSE` file at the top 119 | level of this repo. 120 | -------------------------------------------------------------------------------- /nrepl/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | ''' 4 | nrepl 5 | ----- 6 | 7 | A Python client for the nREPL Clojure networked-REPL server. 8 | 9 | :copyright: (c) 2013 by Chas Emerick. 10 | :license: MIT, see LICENSE for more details. 11 | ''' 12 | 13 | 14 | import socket 15 | import nrepl.bencode as bencode 16 | import threading 17 | try: 18 | from urlparse import urlparse, ParseResult 19 | except ImportError: 20 | from urllib.parse import urlparse, ParseResult 21 | 22 | __version_info__ = ('0', '0', '3') 23 | __version__ = '.'.join(__version_info__) 24 | __author__ = 'Chas Emerick' 25 | __license__ = 'MIT' 26 | __copyright__ = '(c) 2013 by Chas Emerick' 27 | __all__ = ['connect', 'WatchableConnection'] 28 | 29 | 30 | def _bencode_connect(uri): 31 | s = socket.create_connection(uri.netloc.split(":")) 32 | f = s.makefile('rwb') 33 | return bencode.BencodeIO(f, on_close=s.close) 34 | 35 | 36 | def _match_criteria(criteria, msg): 37 | for k, v in criteria.items(): 38 | mv = msg.get(k, None) 39 | if isinstance(v, set): 40 | if mv not in v: 41 | return False 42 | elif not v and mv: 43 | pass 44 | elif not mv or v != mv: 45 | return False 46 | return True 47 | 48 | 49 | class WatchableConnection(object): 50 | def __init__(self, IO): 51 | """ 52 | Create a new WatchableConnection with an nREPL message transport 53 | supporting `read()` and `write()` methods that return and accept nREPL 54 | messages, e.g. bencode.BencodeIO. 55 | """ 56 | self._IO = IO 57 | self._watches = {} 58 | self._watches_lock = threading.RLock() 59 | 60 | class Monitor(threading.Thread): 61 | def run(_): 62 | watches = None 63 | for incoming in self._IO: 64 | with self._watches_lock: 65 | watches = dict(self._watches) 66 | for key, (pred, callback) in watches.items(): 67 | if pred(incoming): 68 | callback(incoming, self, key) 69 | self._thread = Monitor() 70 | self._thread.daemon = True 71 | self._thread.start() 72 | 73 | def close(self): 74 | self._IO.close() 75 | 76 | def send(self, message): 77 | "Send an nREPL message." 78 | self._IO.write(message) 79 | 80 | def unwatch(self, key): 81 | "Removes the watch previously registered with [key]." 82 | with self._watches_lock: 83 | self._watches.pop(key, None) 84 | 85 | def watch(self, key, criteria, callback): 86 | """ 87 | Registers a new watch under [key] (which can be used with `unwatch()` 88 | to remove the watch) that filters messages using [criteria] (may be a 89 | predicate or a 'criteria dict' [see the README for more info there]). 90 | Matching messages are passed to [callback], which must accept three 91 | arguments: the matched incoming message, this instance of 92 | `WatchableConnection`, and the key under which the watch was 93 | registered. 94 | """ 95 | if hasattr(criteria, '__call__'): 96 | pred = criteria 97 | else: 98 | pred = lambda incoming: _match_criteria(criteria, incoming) 99 | with self._watches_lock: 100 | self._watches[key] = (pred, callback) 101 | 102 | # others can add in implementations here 103 | _connect_fns = {"nrepl": _bencode_connect} 104 | 105 | 106 | def connect(uri): 107 | """ 108 | Connects to an nREPL endpoint identified by the given URL/URI. Valid 109 | examples include: 110 | 111 | nrepl://192.168.0.12:7889 112 | telnet://localhost:5000 113 | http://your-app-name.heroku.com/repl 114 | 115 | This fn delegates to another looked up in that dispatches on the scheme of 116 | the URI provided (which can be a string or java.net.URI). By default, only 117 | `nrepl` (corresponding to using the default bencode transport) is 118 | supported. Alternative implementations may add support for other schemes, 119 | such as http/https, JMX, various message queues, etc. 120 | """ 121 | # 122 | uri = uri if isinstance(uri, ParseResult) else urlparse(uri) 123 | if not uri.scheme: 124 | raise ValueError("uri has no scheme: " + uri) 125 | f = _connect_fns.get(uri.scheme.lower(), None) 126 | if not f: 127 | err = "No connect function registered for scheme `%s`" % uri.scheme 128 | raise Exception(err) 129 | return f(uri) 130 | -------------------------------------------------------------------------------- /nrepl/bencode.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | ''' 4 | nrepl.bencode 5 | ------------- 6 | 7 | This module provides BEncode-protocol support. 8 | 9 | :copyright: (c) 2013 by Chas Emerick. 10 | :license: MIT, see LICENSE for more details. 11 | ''' 12 | 13 | 14 | try: 15 | from StringIO import StringIO 16 | except ImportError: 17 | from io import StringIO 18 | 19 | import sys 20 | from io import BytesIO 21 | import array 22 | import numbers 23 | 24 | # Some code so we can use different features without worrying about versions. 25 | PY2 = sys.version_info[0] == 2 26 | if not PY2: 27 | text_type = str 28 | string_types = (str, bytes) 29 | unichr = chr 30 | else: 31 | text_type = unicode 32 | string_types = (str, unicode) 33 | unichr = unichr 34 | 35 | 36 | def _read_byte(s): 37 | return s.read(1) 38 | 39 | 40 | def _read_int(s, terminator=None, init_data=None): 41 | int_chrs = init_data or [] 42 | while True: 43 | c = _read_byte(s) 44 | if not (c.isdigit() or c == b'-') or c == terminator or not c: 45 | break 46 | else: 47 | int_chrs.append(c) 48 | return int(b''.join(int_chrs)) 49 | 50 | 51 | def _read_bytes(s, n): 52 | data = BytesIO() 53 | cnt = 0 54 | while cnt < n: 55 | m = s.read(n - cnt) 56 | if not m: 57 | raise Exception("Invalid bytestring, unexpected end of input.") 58 | data.write(m) 59 | cnt += len(m) 60 | data.flush() 61 | # Taking into account that Python3 can't decode strings 62 | try: 63 | ret = data.getvalue().decode("UTF-8") 64 | except AttributeError: 65 | ret = data.getvalue() 66 | return ret 67 | 68 | 69 | def _read_delimiter(s): 70 | d = _read_byte(s) 71 | if d.isdigit(): 72 | d = _read_int(s, ":", [d]) 73 | return d 74 | 75 | 76 | def _read_list(s): 77 | data = [] 78 | while True: 79 | datum = _read_datum(s) 80 | if datum is None: 81 | break 82 | data.append(datum) 83 | return data 84 | 85 | 86 | def _read_map(s): 87 | i = iter(_read_list(s)) 88 | return dict(zip(i, i)) 89 | 90 | 91 | _read_fns = {b"i": _read_int, 92 | b"l": _read_list, 93 | b"d": _read_map, 94 | b"e": lambda _: None, 95 | # EOF 96 | None: lambda _: None} 97 | 98 | 99 | def _read_datum(s): 100 | delim = _read_delimiter(s) 101 | if delim != b'': 102 | return _read_fns.get(delim, lambda s: _read_bytes(s, delim))(s) 103 | 104 | 105 | def _write_datum(x, out): 106 | if isinstance(x, string_types): 107 | # x = x.encode("UTF-8") 108 | # TODO revisit encodings, this is surely not right. Python 109 | # (2.x, anyway) conflates bytes and strings, but 3.x does not... 110 | out.write(str(len(x.encode('utf-8'))).encode('utf-8')) 111 | out.write(b":") 112 | out.write(x.encode('utf-8')) 113 | elif isinstance(x, numbers.Integral): 114 | out.write(b"i") 115 | out.write(str(x).encode('utf-8')) 116 | out.write(b"e") 117 | elif isinstance(x, (list, tuple)): 118 | out.write(b"l") 119 | for v in x: 120 | _write_datum(v, out) 121 | out.write(b"e") 122 | elif isinstance(x, dict): 123 | out.write(b"d") 124 | for k, v in x.items(): 125 | _write_datum(k, out) 126 | _write_datum(v, out) 127 | out.write(b"e") 128 | out.flush() 129 | 130 | 131 | def encode(v): 132 | "bencodes the given value, may be a string, integer, list, or dict." 133 | s = BytesIO() 134 | _write_datum(v, s) 135 | return s.getvalue().decode('utf-8') 136 | 137 | 138 | def decode_file(file): 139 | while True: 140 | x = _read_datum(file) 141 | if x is None: 142 | break 143 | yield x 144 | 145 | 146 | def decode(string): 147 | "Generator that yields decoded values from the input string." 148 | return decode_file(BytesIO(string.encode('utf-8'))) 149 | 150 | 151 | class BencodeIO(object): 152 | def __init__(self, file, on_close=None): 153 | self._file = file 154 | self._on_close = on_close 155 | 156 | def read(self): 157 | return _read_datum(self._file) 158 | 159 | def __iter__(self): 160 | return self 161 | 162 | def next(self): 163 | v = self.read() 164 | if not v: 165 | raise StopIteration 166 | return v 167 | 168 | def __next__(self): 169 | # In Python3, __next__ it is an own special class. 170 | v = self.read() 171 | if not v: 172 | raise StopIteration 173 | return v 174 | 175 | def write(self, v): 176 | return _write_datum(v, self._file) 177 | 178 | def flush(self): 179 | if self._file.flush: 180 | self._file.flush() 181 | 182 | def close(self): 183 | # Run the on_close handler if one exists, which can do something 184 | # useful like cleanly close a socket. (Note that .close() on a 185 | # socket.makefile('rw') does some kind of unclean close.) 186 | if self._on_close is not None: 187 | self._on_close() 188 | else: 189 | self._file.close() 190 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | ''' 2 | nrepl-python-client 3 | ------------------- 4 | 5 | A Python client for the nREPL Clojure networked-REPL server. 6 | 7 | Links 8 | ````` 9 | * `development version `_ 10 | ''' 11 | 12 | import os 13 | 14 | from setuptools import setup, find_packages 15 | 16 | # HACK: Pull the version number without requiring the package to be installed 17 | # beforehand, i.e. without using import. 18 | module_path = os.path.join(os.path.dirname(__file__), 'nrepl/__init__.py') 19 | version_line = [line for line in open(module_path) 20 | if line.startswith('__version_info__')][0] 21 | 22 | __version__ = '.'.join(eval(version_line.split('__version_info__ = ')[-1])) 23 | 24 | description = "A Python client for the nREPL Clojure networked-REPL server." 25 | classifiers = ['Development Status :: 4 - Beta', 26 | 'Environment :: Console', 27 | 'Intended Audience :: Developers', 28 | 'License :: OSI Approved :: MIT License', 29 | 'Natural Language :: English', 30 | 'Operating System :: OS Independent', 31 | 'Programming Language :: Python', 32 | 'Topic :: Software Development :: Interpreters', 33 | 'Topic :: Software Development :: Libraries :: Python Modules', 34 | 'Topic :: Utilities'] 35 | 36 | setup(name="nrepl-python-client", 37 | version=__version__, 38 | packages=find_packages(), 39 | # metadata for upload to PyPI 40 | author="Chas Emerick", 41 | author_email="chas@cemerick.com", 42 | description=description, 43 | long_description=__doc__, 44 | test_suite='test', 45 | tests_require=['hypothesis'], 46 | license="MIT License", 47 | keywords="clojure repl nrepl", 48 | url="https://github.com/cemerick/nrepl-python-client", 49 | zip_safe=True, 50 | platforms='any', 51 | classifiers=classifiers) 52 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding=utf-8 3 | 4 | import os, unittest, subprocess, re, signal, time 5 | import nrepl 6 | from collections import OrderedDict 7 | from nrepl.bencode import encode, decode 8 | 9 | from hypothesis import given, reproduce_failure 10 | import hypothesis.strategies as st 11 | 12 | import sys 13 | PY2 = sys.version_info[0] == 2 14 | if not PY2: 15 | port_type = bytes 16 | else: 17 | port_type = () 18 | 19 | class BencodeTest (unittest.TestCase): 20 | def test_encoding (self): 21 | 22 | # Python3 treats dicts differently. For the sake of testing we use a 23 | # ordered dict so the order does not change. 24 | test_values = OrderedDict((("a", 1), ("b", [2, [3]]), ("c", [{"x": ["y"]}]))) 25 | 26 | self.assertEqual('d1:ai1e1:bli2eli3eee1:cld1:xl1:yeeee', 27 | encode(test_values)) 28 | self.assertEqual([{u'a': 1, u'c': [{u'x': [u'y']}], u'b': [2, [3]]}], 29 | list(decode('d1:ai1e1:cld1:xl1:yeee1:bli2eli3eeee'))) 30 | 31 | def test_empty_string (self): 32 | self.assertEqual(['a'], list(decode('1:a'))) 33 | self.assertEqual([''], list(decode('0:'))) 34 | self.assertEqual([['spam', '', 'a', 'ab']], list(decode('l4:spam0:1:a2:abe'))) 35 | self.assertEqual([{'spam': ''}], list(decode('d4:spam0:e'))) 36 | 37 | def test_unicode_string(self): 38 | self.assertEqual([u'á'], list(decode(u'2:á'))) 39 | self.assertEqual(u'2:á', encode(u'á')) 40 | 41 | def bencode_primitives(): 42 | return st.one_of(st.integers(), st.text()) 43 | 44 | bencode = st.recursive(st.integers() | st.text(),lambda children: st.lists(children) | st.dictionaries(st.text(), children)) 45 | 46 | class GenerativeBencodeTest(unittest.TestCase): 47 | @given(st.integers()) 48 | def test_integers(self, i): 49 | self.assertEqual(i, next(decode(encode(i)))) 50 | 51 | @given(st.text()) 52 | def test_integers(self, s): 53 | self.assertEqual(s, next(decode(encode(s)))) 54 | 55 | @given(st.recursive(bencode_primitives(), st.lists)) 56 | def test_lists(self, l): 57 | self.assertEqual(l, next(decode(encode(l)))) 58 | 59 | @given(st.dictionaries(bencode_primitives(), bencode_primitives())) 60 | def test_dicts(self, d): 61 | self.assertEqual(d, next(decode(encode(d)))) 62 | 63 | @given(bencode) 64 | def test_x(self, x): 65 | self.assertEqual(x, next(decode(encode(x)))) 66 | 67 | 68 | class REPLTest (unittest.TestCase): 69 | def setUp (self): 70 | # this here only to accommodate travis, which puts leiningen @ lein2 71 | try: 72 | self.proc = subprocess.Popen(["lein2", "repl", ":headless"], 73 | stdout=subprocess.PIPE) 74 | except OSError: 75 | self.proc = subprocess.Popen(["lein", "repl", ":headless"], 76 | stdout=subprocess.PIPE) 77 | 78 | self.port = re.findall(b"\d+", self.proc.stdout.readline())[0] 79 | 80 | # Because Python3 gives us a bytestring, we need to turn it into a string 81 | if isinstance(self.port, port_type): 82 | self.port = self.port.decode('utf-8') 83 | self.proc.stdout.close() 84 | 85 | def tearDown (self): 86 | # neither os.kill, self.proc.kill, or self.proc.terminate were shutting 87 | # down the leiningen/clojure/nrepl process(es) 88 | c = nrepl.connect("nrepl://localhost:" + self.port) 89 | c.write({"op": "eval", "code": "(System/exit 0)"}) 90 | self.proc.kill() 91 | 92 | def test_simple_connection (self): 93 | c = nrepl.connect("nrepl://localhost:" + self.port) 94 | c.write({"op": "clone"}) 95 | r = c.read() 96 | self.assertEqual(["done"], r["status"]) 97 | session = r["new-session"] 98 | self.assertIsNotNone(session) 99 | c.write({"op": "eval", "code": "(+ 1 2)", "session": session}) 100 | r = c.read() 101 | self.assertEqual(session, r["session"]) 102 | self.assertEqual("3", r["value"]) 103 | self.assertEqual(["done"], c.read()["status"]) 104 | c.write({"op": "eval", "code": "(+ *1 2)", "session": session}) 105 | self.assertEqual("5", c.read()["value"]) 106 | c.close() 107 | 108 | def test_async_watches (self): 109 | c = nrepl.connect("nrepl://localhost:" + self.port) 110 | wc = nrepl.WatchableConnection(c) 111 | outs = {} 112 | def add_resp (session, msg): 113 | out = msg.get("out", None) 114 | if out: outs[session].append(out) 115 | def watch_new_sessions (msg, wc, key): 116 | session = msg.get("new-session") 117 | outs[session] = [] 118 | wc.watch("session" + session, {"session": session}, 119 | lambda msg, wc, key: add_resp(session, msg)) 120 | wc.watch("sessions", {"new-session": None}, watch_new_sessions) 121 | wc.send({"op": "clone"}) 122 | wc.send({"op": "clone"}) 123 | time.sleep(0.5) 124 | for i, session in enumerate(outs.keys()): 125 | wc.send({"op": "eval", 126 | "session": session, 127 | "code": """(do (future (Thread/sleep %s00) 128 | (println %s) 129 | (println (System/currentTimeMillis))))""" % (i, i)}) 130 | time.sleep(2) 131 | for i, (session, _outs) in enumerate(outs.items()): 132 | self.assertEqual(i, int(_outs[0])) 133 | # Python3 got dicts that we cant slice, thus we wrap it in a list. 134 | outs_values = list(outs.values()) 135 | self.assertTrue(int(outs_values[0][1]) < int(outs_values[1][1])) 136 | 137 | if __name__ == '__main__': 138 | unittest.main() 139 | 140 | --------------------------------------------------------------------------------