├── .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 | -------------------------------------------------------------------------------- /.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 [![Travis CI status](https://secure.travis-ci.org/cemerick/nrepl-python-client.png)](http://travis-ci.org/#!/cemerick/nrepl-python-client/builds) 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('rw') 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 cStringIO import StringIO 16 | except ImportError: 17 | from io import StringIO 18 | 19 | import sys 20 | 21 | # Some code so we can use different features without worrying about versions. 22 | PY2 = sys.version_info[0] == 2 23 | if not PY2: 24 | text_type = str 25 | string_types = (str, bytes) 26 | unichr = chr 27 | else: 28 | text_type = unicode 29 | string_types = (str, unicode) 30 | unichr = unichr 31 | 32 | 33 | def _read_byte(s): 34 | return s.read(1) 35 | 36 | 37 | def _read_int(s, terminator=None, init_data=None): 38 | int_chrs = init_data or [] 39 | while True: 40 | c = _read_byte(s) 41 | if not c.isdigit() or c == terminator or not c: 42 | break 43 | else: 44 | int_chrs.append(c) 45 | return int(''.join(int_chrs)) 46 | 47 | 48 | def _read_bytes(s, n): 49 | data = StringIO() 50 | cnt = 0 51 | while cnt < n: 52 | m = s.read(n - cnt) 53 | if not m: 54 | raise Exception("Invalid bytestring, unexpected end of input.") 55 | data.write(m) 56 | cnt += len(m) 57 | data.flush() 58 | # Taking into account that Python3 can't decode strings 59 | try: 60 | ret = data.getvalue().decode("UTF-8") 61 | except AttributeError: 62 | ret = data.getvalue() 63 | return ret 64 | 65 | 66 | def _read_delimiter(s): 67 | d = _read_byte(s) 68 | if d.isdigit(): 69 | d = _read_int(s, ":", [d]) 70 | return d 71 | 72 | 73 | def _read_list(s): 74 | data = [] 75 | while True: 76 | datum = _read_datum(s) 77 | if not datum: 78 | break 79 | data.append(datum) 80 | return data 81 | 82 | 83 | def _read_map(s): 84 | i = iter(_read_list(s)) 85 | return dict(zip(i, i)) 86 | 87 | 88 | _read_fns = {"i": _read_int, 89 | "l": _read_list, 90 | "d": _read_map, 91 | "e": lambda _: None, 92 | # EOF 93 | None: lambda _: None} 94 | 95 | 96 | def _read_datum(s): 97 | delim = _read_delimiter(s) 98 | if delim: 99 | return _read_fns.get(delim, lambda s: _read_bytes(s, delim))(s) 100 | 101 | 102 | def _write_datum(x, out): 103 | if isinstance(x, string_types): 104 | # x = x.encode("UTF-8") 105 | # TODO revisit encodings, this is surely not right. Python 106 | # (2.x, anyway) conflates bytes and strings, but 3.x does not... 107 | out.write(str(len(x))) 108 | out.write(":") 109 | out.write(x) 110 | elif isinstance(x, int): 111 | out.write("i") 112 | out.write(str(x)) 113 | out.write("e") 114 | elif isinstance(x, (list, tuple)): 115 | out.write("l") 116 | for v in x: 117 | _write_datum(v, out) 118 | out.write("e") 119 | elif isinstance(x, dict): 120 | out.write("d") 121 | for k, v in x.items(): 122 | _write_datum(k, out) 123 | _write_datum(v, out) 124 | out.write("e") 125 | out.flush() 126 | 127 | 128 | def encode(v): 129 | "bencodes the given value, may be a string, integer, list, or dict." 130 | s = StringIO() 131 | _write_datum(v, s) 132 | return s.getvalue() 133 | 134 | 135 | def decode_file(file): 136 | while True: 137 | x = _read_datum(file) 138 | if not x: 139 | break 140 | yield x 141 | 142 | 143 | def decode(string): 144 | "Generator that yields decoded values from the input string." 145 | return decode_file(StringIO(string)) 146 | 147 | 148 | class BencodeIO(object): 149 | def __init__(self, file, on_close=None): 150 | self._file = file 151 | self._on_close = on_close 152 | 153 | def read(self): 154 | return _read_datum(self._file) 155 | 156 | def __iter__(self): 157 | return self 158 | 159 | def next(self): 160 | v = self.read() 161 | if not v: 162 | raise StopIteration 163 | return v 164 | 165 | def __next__(self): 166 | # In Python3, __next__ it is an own special class. 167 | v = self.read() 168 | if not v: 169 | raise StopIteration 170 | return v 171 | 172 | def write(self, v): 173 | return _write_datum(v, self._file) 174 | 175 | def flush(self): 176 | if self._file.flush: 177 | self._file.flush() 178 | 179 | def close(self): 180 | # Run the on_close handler if one exists, which can do something 181 | # useful like cleanly close a socket. (Note that .close() on a 182 | # socket.makefile('rw') does some kind of unclean close.) 183 | if self._on_close is not None: 184 | self._on_close() 185 | else: 186 | self._file.close() 187 | -------------------------------------------------------------------------------- /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 | license="MIT License", 46 | keywords="clojure repl nrepl", 47 | url="https://github.com/cemerick/nrepl-python-client", 48 | zip_safe=True, 49 | platforms='any', 50 | classifiers=classifiers) 51 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os, unittest, subprocess, re, signal, time 3 | import nrepl 4 | from collections import OrderedDict 5 | from nrepl.bencode import encode, decode 6 | 7 | import sys 8 | PY2 = sys.version_info[0] == 2 9 | if not PY2: 10 | port_type = bytes 11 | else: 12 | port_type = () 13 | 14 | class BencodeTest (unittest.TestCase): 15 | def test_encoding (self): 16 | 17 | # Python3 treats dicts differently. For the sake of testing we use a 18 | # ordered dict so the order does not change. 19 | test_values = OrderedDict((("a", 1), ("b", [2, [3]]), ("c", [{"x": ["y"]}]))) 20 | 21 | self.assertEqual('d1:ai1e1:bli2eli3eee1:cld1:xl1:yeeee', 22 | encode(test_values)) 23 | self.assertEqual([{u'a': 1, u'c': [{u'x': [u'y']}], u'b': [2, [3]]}], 24 | list(decode('d1:ai1e1:cld1:xl1:yeee1:bli2eli3eeee'))) 25 | 26 | class REPLTest (unittest.TestCase): 27 | def setUp (self): 28 | # this here only to accommodate travis, which puts leiningen @ lein2 29 | try: 30 | self.proc = subprocess.Popen(["lein2", "repl", ":headless"], 31 | stdout=subprocess.PIPE) 32 | except OSError: 33 | self.proc = subprocess.Popen(["lein", "repl", ":headless"], 34 | stdout=subprocess.PIPE) 35 | 36 | self.port = re.findall(b"\d+", self.proc.stdout.readline())[0] 37 | 38 | # Because Python3 gives us a bytestring, we need to turn it into a string 39 | if isinstance(self.port, port_type): 40 | self.port = self.port.decode('utf-8') 41 | self.proc.stdout.close() 42 | 43 | def tearDown (self): 44 | # neither os.kill, self.proc.kill, or self.proc.terminate were shutting 45 | # down the leiningen/clojure/nrepl process(es) 46 | c = nrepl.connect("nrepl://localhost:" + self.port) 47 | c.write({"op": "eval", "code": "(System/exit 0)"}) 48 | self.proc.kill() 49 | 50 | def test_simple_connection (self): 51 | c = nrepl.connect("nrepl://localhost:" + self.port) 52 | c.write({"op": "clone"}) 53 | r = c.read() 54 | self.assertEqual(["done"], r["status"]) 55 | session = r["new-session"] 56 | self.assertIsNotNone(session) 57 | c.write({"op": "eval", "code": "(+ 1 2)", "session": session}) 58 | r = c.read() 59 | self.assertEqual(session, r["session"]) 60 | self.assertEqual("3", r["value"]) 61 | self.assertEqual(["done"], c.read()["status"]) 62 | c.write({"op": "eval", "code": "(+ *1 2)", "session": session}) 63 | self.assertEqual("5", c.read()["value"]) 64 | c.close() 65 | 66 | def test_async_watches (self): 67 | c = nrepl.connect("nrepl://localhost:" + self.port) 68 | wc = nrepl.WatchableConnection(c) 69 | outs = {} 70 | def add_resp (session, msg): 71 | out = msg.get("out", None) 72 | if out: outs[session].append(out) 73 | def watch_new_sessions (msg, wc, key): 74 | session = msg.get("new-session") 75 | outs[session] = [] 76 | wc.watch("session" + session, {"session": session}, 77 | lambda msg, wc, key: add_resp(session, msg)) 78 | wc.watch("sessions", {"new-session": None}, watch_new_sessions) 79 | wc.send({"op": "clone"}) 80 | wc.send({"op": "clone"}) 81 | time.sleep(0.5) 82 | for i, session in enumerate(outs.keys()): 83 | wc.send({"op": "eval", 84 | "session": session, 85 | "code": """(do (future (Thread/sleep %s00) 86 | (println %s) 87 | (println (System/currentTimeMillis))))""" % (i, i)}) 88 | time.sleep(2) 89 | for i, (session, _outs) in enumerate(outs.items()): 90 | self.assertEqual(i, int(_outs[0])) 91 | # Python3 got dicts that we cant slice, thus we wrap it in a list. 92 | outs_values = list(outs.values()) 93 | self.assertTrue(int(outs_values[0][1]) < int(outs_values[1][1])) 94 | 95 | if __name__ == '__main__': 96 | unittest.main() 97 | 98 | --------------------------------------------------------------------------------