motifapi 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
motifapi/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ __version__ = '0.2.0'
2
+
3
+ from .api import MotifError, MotifApiError, MotifApi
4
+ from .schedule import datetime_to_cron
5
+ from .util import get_experiment_metadata
motifapi/api.py ADDED
@@ -0,0 +1,328 @@
1
+ import re
2
+ import json
3
+ import ssl
4
+ import socket
5
+ import os.path
6
+ import subprocess
7
+ import logging
8
+ import urllib.request
9
+ import urllib.error
10
+ import http.client
11
+
12
+ DEFAULT_HTTP_TIMEOUT = 10 # seconds
13
+
14
+
15
+ # http://code.activestate.com/recipes/577548-https-httplib-client-connection-with-certificate-v/
16
+ # http://stackoverflow.com/questions/1875052/using-paired-certificates-with-urllib2
17
+
18
+ class HTTPSClientAuthHandler(urllib.request.HTTPSHandler):
19
+ """
20
+ Allows sending a client certificate with the HTTPS connection.
21
+ This version also validates the peer (server) certificate.
22
+ """
23
+
24
+ def __init__(self, key=None, cert=None, ca_certs=None):
25
+ self.ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
26
+ self.ctx.check_hostname = False
27
+ if ca_certs:
28
+ self.ctx.verify_mode = ssl.CERT_REQUIRED
29
+ self.ctx.load_verify_locations(ca_certs)
30
+ else:
31
+ self.ctx.verify_mode = ssl.CERT_NONE
32
+ if cert:
33
+ self.ctx.load_cert_chain(cert, key)
34
+ urllib.request.HTTPSHandler.__init__(self, context=self.ctx)
35
+
36
+ def https_open(self, req):
37
+ return self.do_open(self.get_connection, req)
38
+
39
+ def get_connection(self, host, timeout=DEFAULT_HTTP_TIMEOUT):
40
+ return HTTPSConnection(host, timeout=timeout, context=self.ctx)
41
+
42
+
43
+ class HTTPSConnection(http.client.HTTPSConnection):
44
+ """
45
+ Overridden to allow peer certificate validation using an SSLContext.
46
+ """
47
+
48
+ def __init__(self, host, **kwargs):
49
+ self.ctx = kwargs.pop('context', None)
50
+ http.client.HTTPSConnection.__init__(self, host, **kwargs)
51
+
52
+ def connect(self):
53
+ sock = socket.create_connection((self.host, self.port), self.timeout)
54
+
55
+ if self._tunnel_host:
56
+ self.sock = sock
57
+ self._tunnel()
58
+
59
+ if self.ctx:
60
+ self.sock = self.ctx.wrap_socket(sock, server_hostname=self.host)
61
+ else:
62
+ self.sock = sock
63
+
64
+
65
+ class MethodRequest(urllib.request.Request):
66
+ # See: https://gist.github.com/logic/2715756
67
+
68
+ def __init__(self, *args, **kwargs):
69
+ if 'method' in kwargs:
70
+ self._method = kwargs['method']
71
+ del kwargs['method']
72
+ else:
73
+ self._method = None
74
+ return urllib.request.Request.__init__(self, *args, **kwargs)
75
+
76
+ def get_method(self, *args, **kwargs):
77
+ return self._method if self._method is not None else urllib.request.Request.get_method(self, *args, **kwargs)
78
+
79
+
80
+ class MotifError(Exception):
81
+ pass
82
+
83
+
84
+ class MotifApiError(MotifError):
85
+ def __init__(self, message, code):
86
+ self.code = code
87
+ Exception.__init__(self, message)
88
+
89
+
90
+ class MotifApi(object):
91
+
92
+ STREAM_TYPE_IMAGE = 1
93
+ STREAM_TYPE_STATE = 2
94
+
95
+ API = {r'version$': 'GET',
96
+ r'cameras$': 'GET',
97
+ r'cameras/configure$': 'PATCH',
98
+ r'cameras/read/(?P<name>[^\s /]+)$': 'GET',
99
+ r'camera/(?P<serial>[^\s /]+)$': 'GET',
100
+ r'camera/(?P<serial>[^\s /]+)/configure$': 'PATCH',
101
+ r'camera/(?P<serial>[^\s /]+)/read/(?P<name>[^\s /]+)$': 'GET',
102
+ r'camera/(?P<serial>[^\s /]+)/recording/start$': 'POST',
103
+ r'camera/(?P<serial>[^\s /]+)/recording/stop$': 'POST',
104
+ r'camera/(?P<serial>[^\s /]+)/recordings$': 'GET',
105
+ r'camera/(?P<serial>[^\s /]+)/recordings/copy_all$': 'POST',
106
+ r'camera/(?P<serial>[^\s /]+)/recordings/export_all$': 'POST',
107
+ r'camera/(?P<serial>[^\s /]+)/io/(?P<name>[^\s /]+)/set': 'POST',
108
+ r'camera/(?P<serial>[^\s /]+)/io/log': 'POST',
109
+ r'camera/(?P<serial>[^\s /]+)/io/read': 'GET',
110
+ r'recording/start$': 'POST',
111
+ r'recording/stop$': 'POST',
112
+ r'recordings$': 'GET',
113
+ r'recordings/copy_all$': 'POST',
114
+ r'recordings/export_all$': 'POST',
115
+ r'schedule$': 'GET',
116
+ r'schedule/clear$': 'POST',
117
+ r'schedule/(?P<identifier>[^\s /]+)/clear$': 'DELETE',
118
+ r'schedule/recording/start$': 'POST',
119
+ r'schedule/camera/(?P<serial>[^\s /]+)/recording/start$': 'POST',
120
+ r'schedule/recordings/copy_all': 'POST',
121
+ r'schedule/camera/(?P<serial>[^\s /]+)/recordings/copy_all$': 'POST',
122
+ r'schedule/recordings/export_all': 'POST',
123
+ r'schedule/camera/(?P<serial>[^\s /]+)/recordings/export_all$': 'POST',
124
+ r'schedule/io/(?P<name>[^\s /]+)/set': 'POST',
125
+ r'schedule/camera/(?P<serial>[^\s /]+)/io/(?P<name>[^\s /]+)/set': 'POST',
126
+ r'schedule/cameras/configure/(?P<name>[^\s /]+)': 'POST',
127
+ r'schedule/camera/(?P<serial>[^\s /]+)/configure/(?P<name>[^\s /]+)': 'POST',
128
+ r'io/(?P<io_serial>[^\s /]+)/(?P<io_port>[^\s /]+)/set': 'POST',
129
+ r'io/(?P<name>[^\s /]+)/set': 'POST',
130
+ r'io/log': 'POST',
131
+ r'io/read': 'GET',
132
+ r'camera/(?P<serial>[^\s /]+)/experiment/message$': 'POST',
133
+ r'experiment/message$': 'POST',
134
+ r'multicam/synchronize': 'POST',
135
+ r'multicam/connect_camera/(?P<serial>[^\s /]+)': 'POST',
136
+ r'multicam/disconnect_camera/(?P<serial>[^\s /]+)': 'POST',
137
+ r'multicam/connect_all': 'POST',
138
+ r'multicam/disconnect_all': 'POST',
139
+ }
140
+
141
+ def __init__(self, host=None, api_key=None, port=None, ca_cert=None, api_version=1):
142
+ self._log = logging.getLogger('motifapi')
143
+
144
+ if port is None:
145
+ try:
146
+ port = int(os.environ.get('MOTIF_PORT', 6083))
147
+ except:
148
+ port = 6083
149
+ else:
150
+ port = int(port)
151
+
152
+ if host is None:
153
+ try:
154
+ host = os.environ['MOTIF_HOST']
155
+ self._log.debug('took host from environment')
156
+ except KeyError:
157
+ pass
158
+ if not host:
159
+ host = '127.0.0.1'
160
+
161
+ if api_key is None:
162
+ try:
163
+ api_key = os.environ['MOTIF_API_KEY']
164
+ self._log.debug('took api_key from environment')
165
+ except KeyError:
166
+ pass
167
+ if not api_key:
168
+ try:
169
+ api_key = subprocess.check_output(['recnode-apikey']).strip()
170
+ self._log.debug('took api-key from recnode-apikey subprocess')
171
+ except OSError:
172
+ pass
173
+
174
+ if not api_key:
175
+ raise ValueError('API key must be specified')
176
+
177
+ if ca_cert is None:
178
+ ca_cert = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'server.crt')
179
+
180
+ if not os.path.exists(ca_cert):
181
+ raise ValueError('could not find certificate: %s' % ca_cert)
182
+
183
+ self._prefix = 'api/%d/' % api_version
184
+
185
+ client_cert_key = None
186
+ client_cert_pem = None # file path
187
+ handler = HTTPSClientAuthHandler(
188
+ key=client_cert_key,
189
+ cert=client_cert_pem,
190
+ ca_certs=ca_cert)
191
+ self._http = urllib.request.build_opener(handler)
192
+
193
+ self._host = host
194
+ self._api_key = api_key
195
+ self._port = port
196
+
197
+ def _build_request(self, endpoint, data=None, method='GET'):
198
+ if endpoint[0] == '/':
199
+ endpoint = endpoint[1:]
200
+
201
+ if endpoint != 'version':
202
+ endpoint = self._prefix + endpoint
203
+
204
+ if data is not None:
205
+ try:
206
+ data = json.dumps(data).encode("utf-8")
207
+ except TypeError:
208
+ raise ValueError('Arguments must be JSON serializable (they were %r)' % (data,))
209
+
210
+ url = 'https://%s:%s/%s' % (self._host, self._port, endpoint)
211
+
212
+ req = MethodRequest(url, data, method=method)
213
+ req.add_header('X-Api-Key', self._api_key)
214
+ req.add_header('Content-Type', 'application/json')
215
+
216
+ self._log.debug('%s %s (%d bytes %s)' % (method,
217
+ url,
218
+ 0 if data is None else len(data),
219
+ type(data)))
220
+
221
+ return req
222
+
223
+ def _call(self, req):
224
+ try:
225
+ resp = self._http.open(req)
226
+ data = resp.read()
227
+ resp.close()
228
+ return data
229
+ except urllib.error.HTTPError as e:
230
+ try:
231
+ raw = e.read()
232
+ err = json.loads(raw.decode('utf-8'))
233
+ exc = MotifApiError(err['error'], err['status_code'])
234
+ except Exception:
235
+ raise ValueError('unknown API error')
236
+ raise exc
237
+ except urllib.error.URLError:
238
+ raise MotifError('motif not running or reachable')
239
+
240
+ def call(self, endpoint, method=None, data=None, **kwargs):
241
+ meth = ep = None
242
+ for _ep, _meth in self.API.items():
243
+ if re.match(_ep, endpoint):
244
+ meth = _meth
245
+ ep = _ep
246
+ break
247
+
248
+ if meth is None:
249
+ raise ValueError("unknown endpoint '%s' (are you missing/adding '/')" % endpoint)
250
+
251
+ req = self._build_request(endpoint,
252
+ data=kwargs or None,
253
+ method=meth)
254
+ out = self._call(req)
255
+ if out:
256
+ try:
257
+ return json.loads(out.decode('utf-8'))
258
+ except ValueError as e:
259
+ raise ValueError('Invalid JSON response: %s' % e.message)
260
+ else:
261
+ return {}
262
+
263
+ def is_recording(self, serial):
264
+ r = self.call('camera/%s' % serial)
265
+ if r['camera_info'].get('filename'):
266
+ return True
267
+ elif r['camera_info'].get('status') == 'pending':
268
+ return True
269
+ return False
270
+
271
+ def is_copying(self, serial):
272
+ r = self.call('camera/%s' % serial)
273
+ return r['playback_info']['status'] == 'copying'
274
+
275
+ def is_exporting(self, serial):
276
+ r = self.call('camera/%s' % serial)
277
+ status = r['playback_info']['status']
278
+ return status.startswith('export') and ('finished' not in status)
279
+
280
+ def send_message(self, message, serial=None):
281
+ if serial:
282
+ return self.call('camera/%s/experiment/message' % serial, message=message)
283
+ return self.call('experiment/message', message=message)
284
+
285
+ def get_stream(self, serial=None, stream_type=STREAM_TYPE_IMAGE, force_host=None):
286
+ from .stream import ImageStreamer, StateStreamer
287
+
288
+ if serial is None:
289
+ try:
290
+ # get the first camera
291
+ for c in self.call('cameras').get('cameras', []):
292
+ serial = c['serial']
293
+ break
294
+ except (urllib.error.URLError, MotifApiError):
295
+ raise MotifError('motif not running or reachable')
296
+
297
+ if serial is None:
298
+ raise MotifError('no cameras connected or running')
299
+
300
+ def _get_host_port(_stream_name):
301
+ _stat = self.call('camera/%s' % serial)
302
+ _host = _stat['camera_info']['stream'][_stream_name]['host']
303
+ _port = int(_stat['camera_info']['stream'][_stream_name]['port'])
304
+
305
+ if force_host is not None:
306
+ _host = force_host
307
+ elif (_host == '0.0.0.0') and (self._host != '0.0.0.0'):
308
+ _host = self._host
309
+
310
+ return _host, _port
311
+
312
+ if stream_type in (MotifApi.STREAM_TYPE_IMAGE, MotifApi.STREAM_TYPE_STATE):
313
+ try:
314
+ if stream_type == MotifApi.STREAM_TYPE_IMAGE:
315
+ host, port = _get_host_port('image')
316
+ return ImageStreamer(host, port)
317
+ else:
318
+ host, port = _get_host_port('state')
319
+ return StateStreamer(host, port)
320
+ except (urllib.error.URLError, MotifApiError):
321
+ raise MotifError('camera with serial %s not found or running' % serial)
322
+ except KeyError:
323
+ raise MotifError('realtime stream not enabled on camera')
324
+ else:
325
+ raise ValueError('unknown stream type')
326
+
327
+
328
+
motifapi/schedule.py ADDED
@@ -0,0 +1,2 @@
1
+ def datetime_to_cron(dt):
2
+ return '%d %d %d %d %d ?' % (dt.second, dt.minute, dt.hour, dt.day, dt.month)
motifapi/server.crt ADDED
@@ -0,0 +1,19 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIDGzCCAgOgAwIBAgIJAOjl0YTg0v1KMA0GCSqGSIb3DQEBCwUAMCQxCzAJBgNV
3
+ BAYTAkFVMRUwEwYDVQQKDAxsb29wYmlvIGdtYmgwHhcNMTcxMDI4MTM1MDQzWhcN
4
+ MjcxMDI2MTM1MDQzWjAkMQswCQYDVQQGEwJBVTEVMBMGA1UECgwMbG9vcGJpbyBn
5
+ bWJoMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuBDT2rvtlC3z330H
6
+ p5Zl1ilA4gvSM7Zlvpwt+kQETb7rCS49+oPaAa89tDEbwrk3rDzxnGG2+YL73h07
7
+ YpTlJk/7WkciGgR7K83CwEiSVUUZsmxfXH/t0V5km4d+Ul7lRWoRI9BcnWu9Mvwr
8
+ 572NQJf0cWs0FJu5qYmY0nI7+EjN/qB7OFYYTYSJZQYwoRZzdGFNWCiTkxTBOQc/
9
+ 1dyPzmtMbRQONYryMqMHKLUeG4aEYwCsm2XNSTHn7JCxc/dHc8BnBMkAOqNp+XP+
10
+ n4blQ6e4C4e4MWEk/yrv0p8u0m6NIoBvI/nZDhgvdIQTFrj+eIOSCPCtxzAQEnDd
11
+ f9FEiQIDAQABo1AwTjAdBgNVHQ4EFgQU1kn+nli9zgqxumR2zgMFS5WljhgwHwYD
12
+ VR0jBBgwFoAU1kn+nli9zgqxumR2zgMFS5WljhgwDAYDVR0TBAUwAwEB/zANBgkq
13
+ hkiG9w0BAQsFAAOCAQEAdr76akM25qXFURAgWszcjJAaaimp9RRUaXwO5UxVegCR
14
+ x1FCZyfm/TIWDgfKjlPFiPgCS3WBL0gLWummZhJ5YFwd0lI34H9KXl9JgKidyNrH
15
+ aDHBj4On0eu84f6nMxqFPFDDscJLlMvmfBYRFq/7QryU/LuWeSWhn/oQs4vakoRH
16
+ aJxzot67YvH9N8qxX/6Nc2TeEAz4X5AyEYe46mAc99rRROWjnYD39dfmWzv/5Cge
17
+ 42YwNxtba8njGyPnCb+/kKerc+jXNBoopBGECBsMyGx3p5cVCnz//NhgvWmvquEU
18
+ msK/FxvWDIB5Y/KEfeaghB4D4hgy3IF9Z6T9ZBsLsg==
19
+ -----END CERTIFICATE-----
motifapi/stream.py ADDED
@@ -0,0 +1,100 @@
1
+ import sys
2
+ import json
3
+ import threading
4
+ import logging
5
+
6
+ import zmq
7
+ import numpy as np
8
+
9
+
10
+ if sys.version_info > (3,):
11
+ buffer = memoryview
12
+
13
+
14
+ LOG = logging.getLogger('motifapi.stream')
15
+
16
+
17
+ def recv_array(socket, flags=0, copy=True, track=False):
18
+ md = socket.recv_json(flags=flags)
19
+ msg = socket.recv(flags=flags, copy=copy, track=track)
20
+ # note: I don't actually think this is necessary, in PY2 this is type<str> and np.frombuffer(str) does
21
+ # the right thing, in PY3 this is type<bytes> which supports buffer protocol
22
+ buf = buffer(msg)
23
+ A = np.frombuffer(buf, dtype=md.pop('dtype'))
24
+ return A.reshape(md.pop('shape')), md
25
+
26
+
27
+ class ImageStreamer(object):
28
+
29
+ stream = None
30
+
31
+ def __init__(self, host, port):
32
+ ctx = zmq.Context()
33
+ address = "tcp://%s:%d" % (host, port)
34
+ LOG.debug('image stream connecting to: %s' % address)
35
+ sock = ctx.socket(zmq.PULL)
36
+ sock.connect(address)
37
+ self.stream = sock
38
+
39
+ def get_next_image(self, block=True, copy=True):
40
+ while True:
41
+ while self.stream.poll(0, zmq.POLLIN):
42
+ return recv_array(self.stream, copy=copy)
43
+ if not block:
44
+ return None, None
45
+
46
+
47
+ class StateStreamer(object):
48
+
49
+ def __init__(self, host, port, channel='j'):
50
+ ctx = zmq.Context()
51
+ address = "tcp://%s:%d" % (host, port)
52
+ LOG.debug('state stream connecting to: %s' % address)
53
+ sock = ctx.socket(zmq.SUB)
54
+ sock.connect(address)
55
+ sock.setsockopt(zmq.LINGER, 0)
56
+ if sys.version_info > (3,):
57
+ sock.setsockopt_string(zmq.SUBSCRIBE, channel)
58
+ else:
59
+ sock.setsockopt(zmq.SUBSCRIBE, channel)
60
+ self.stream = sock
61
+
62
+ def get_next_state(self, block=True):
63
+ while True:
64
+ _, msg = self.stream.recv_multipart()
65
+ return json.loads(msg)
66
+
67
+
68
+ class StateMirror(threading.Thread):
69
+
70
+ daemon = True
71
+
72
+ def __init__(self, streamer):
73
+ assert isinstance(streamer, StateStreamer)
74
+ threading.Thread.__init__(self)
75
+ self._lock = threading.Lock()
76
+ self._state = {}
77
+ self._streamer = streamer
78
+
79
+ @property
80
+ def is_recording(self):
81
+ return self._state.get('is_recording', False)
82
+
83
+ @property
84
+ def frame_number(self):
85
+ return self._state.get('frame_number', None)
86
+
87
+ @property
88
+ def frame_time(self):
89
+ return self._state.get('frame_time', None)
90
+
91
+ def get_state(self, **extra):
92
+ with self._lock:
93
+ extra.update(self._state)
94
+ return extra
95
+
96
+ def run(self):
97
+ while True:
98
+ _, msg = self._streamer.stream.recv_multipart()
99
+ with self._lock:
100
+ self._state.update(json.loads(msg))
motifapi/util.py ADDED
@@ -0,0 +1,16 @@
1
+ import os
2
+ import json
3
+
4
+
5
+ def get_experiment_metadata():
6
+ try:
7
+ mdf = os.environ['MOTIF_METADATA_JSON_PATH']
8
+ with open(mdf, 'r') as f:
9
+ return json.load(f)
10
+ except KeyError:
11
+ # not defined
12
+ pass
13
+ except Exception as exc:
14
+ print("Error parsing metadata file", exc)
15
+
16
+ return {}
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: motifapi
3
+ Version: 0.2.0
4
+ Summary: Python interface to Motif recording systems
5
+ Author: John Stowers
6
+ Author-email: john@loopbio.com
7
+ License: BSD
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Requires-Python: >=3.6
11
+ Dynamic: author
12
+ Dynamic: author-email
13
+ Dynamic: classifier
14
+ Dynamic: description
15
+ Dynamic: license
16
+ Dynamic: requires-python
17
+ Dynamic: summary
18
+
19
+ Python interface to Motif recording systems
@@ -0,0 +1,10 @@
1
+ motifapi/__init__.py,sha256=BJ_PWYb2B9QfBnMJuZW3O3COcc5vRN4Zk_gxkid7TUE,157
2
+ motifapi/api.py,sha256=zkv2T52_vGKsxWyHU6YkZyyxwUZX0hsGokfCL7Qbs4M,11923
3
+ motifapi/schedule.py,sha256=j6bhUfI69Xd-OsQ0FZgpDKxRn9Y4dxofAFLFe63q6lo,108
4
+ motifapi/server.crt,sha256=T93bqtfNR8z83vWhPNtlwyr9mKqJrlWDBDUU0Rgw_zM,1139
5
+ motifapi/stream.py,sha256=EkBbemtKJeQm9GdCcO2WWN8u6DNqCQznE9OpM43oY-A,2732
6
+ motifapi/util.py,sha256=BXxxqBN6ocC15IBZr7aDlpnVlKl-WSDp1jSERX-5vKw,333
7
+ motifapi-0.2.0.dist-info/METADATA,sha256=j__1XU-vKsfceEWRTTcMshLM4ep0U4XV9mOepjF_LUY,480
8
+ motifapi-0.2.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
9
+ motifapi-0.2.0.dist-info/top_level.txt,sha256=2fm6i3MFwhkeeVTSo3o6ISjkyA0s57V3zk_yJQKZlRw,9
10
+ motifapi-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ motifapi