motifapi 0.2.0__tar.gz

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.
@@ -0,0 +1 @@
1
+ include motifapi/server.crt
@@ -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,610 @@
1
+ API for Motif Recording Systems (Python Wrapper)
2
+ ================================================
3
+
4
+ [This library](https://github.com/loopbio/python-motifapi) allows you to control cameras
5
+ and recording on loopbio [motif](http://loopbio.com/recording) recording systems, including
6
+ the ability to control outputs and schedule operations. This allows you to implement
7
+ experimental or operational protocols like;
8
+
9
+ * "start recording every hour for 30 minutes. while recording give this stimulus (switch this output)
10
+ every 5 minutes"
11
+ * "record at 9am and 3pm. at 5pm copy all recorded videos to network storage"
12
+ * "while recording, at minute 1 switch this output on, then every 30 seconds thereafter, switch on and
13
+ off this other output"
14
+
15
+ **Table of Contents**
16
+
17
+ * [Examples](#examples)
18
+ * [Examples (IO and Scheduling)](#scheduling-examples)
19
+ * [API Documentation](#api-documentation)
20
+ * [Experiment Script Messages](#experiment-script-messages)
21
+ * [Scheduling](#scheduling-function)
22
+ * [API Documentation (Scheduling)](#scheduling-api-documentation)
23
+ * [MATLAB](#matlab) and [Other Language](#other-languages) support
24
+ * [Realtime Streaming](#realtime-streaming)
25
+
26
+ ## Getting Started
27
+
28
+ * this library works out of the box on Python2 and Python3 and has no dependencies outside
29
+ of the standard library
30
+ * this library wraps the REST+JSON API and handles sending and parsing the responses
31
+ * this library is compatible with all motif versions, although real-time image streaming and
32
+ some IO operations are only supported in motif 5 and above
33
+ * to control a motif recording system you need to know it's `IP_ADDRESS` and an `API_KEY`.
34
+ * the IP address is that which you browse to in order to control the recording software
35
+ using your web browser
36
+ * the API key is specific to the installation of the recording software and secures the
37
+ API from unauthorized access. To find the API key for the recording system you wish to
38
+ control, SSH into the machine (or request your sysadmin to) and execute the following
39
+ command `recnode-apikey`
40
+
41
+
42
+ ### Examples
43
+
44
+ Set up the connection to the machine
45
+
46
+ ```python
47
+ from motifapi import MotifApi
48
+
49
+ IP_ADDRESS = '10.11.12.23'
50
+ API_KEY = 'abcdef123456abcdef123456abcdef12'
51
+
52
+ api = MotifApi(IP_ADDRESS, API_KEY)
53
+ # check the client is connected
54
+ api.call('version')
55
+ # list the connected cameras
56
+ api.call('cameras')
57
+ ```
58
+
59
+ To start recording on all cameras with the predefined compression settings named 'high'
60
+ (see the web UI for the names of your configured compression formats). The recording will run
61
+ for 5 seconds and will have have the following metadata attached.
62
+
63
+ ```python
64
+ api.call('recording/start',
65
+ codec='high',
66
+ duration=5,
67
+ metadata={'foo': 1, 'bar': 'bob'})
68
+ ```
69
+
70
+ To stop all recordings on all cameras
71
+
72
+ ```python
73
+ api.call('recording/stop')
74
+ ```
75
+
76
+ To configure one camera and start recording on it alone
77
+
78
+ ```python
79
+ api.call('camera/FAKE0/configure', Gain=0))
80
+ api.call('camera/FAKE0/recording/start', codec='low')
81
+ ```
82
+
83
+ Cameras are identified by their serial number ('FAKE0' in the example above). To find the serial
84
+ numbers of connected cameras do
85
+
86
+ ```python
87
+ api.call('cameras')
88
+ ```
89
+
90
+ To find the status of a camera (is it recording, uploading, etc) do
91
+
92
+ ```python
93
+ api.call('camera/FAKE0')
94
+ ```
95
+
96
+ **See `examples/*.py` for further examples of API usage**
97
+
98
+ ## API Documentation
99
+
100
+ The complete list of API endpoints/paths, as passed to `call(path, **arguments)`,
101
+ can be found below. Text between `<` and `>` characters should be replaced
102
+ with the appropriate values. Arguments are passed after the path, e.g.
103
+ `api.call('recording/start', duration=5.0)`.
104
+
105
+ * `version`
106
+ * return the current software version
107
+ * `cameras`
108
+ * return a list of connected cameras, their names and serial numbers
109
+ * `camera/<serial>`
110
+ * returns the selected camera status
111
+ * `serial`: the serial number of the camera
112
+ * `camera/<serial>/configure`
113
+ * change camera configuration
114
+ * `serial`: the serial number of the camera
115
+ * arguments (for example, optional)
116
+ * `AcquisitionFrameRate`: change the framerate of the camera
117
+ * `ExposureTime`: exposure time in us
118
+ * `...` or any other camera supported parameter name and value
119
+ * `cameras/configure`
120
+ * as previous, but apply the configuration changes to every attached camera
121
+ * `camera/<serial>/read/<parameter_name>`
122
+ * change camera configuration
123
+ * `serial`: the serial number of the camera
124
+ * `parameter_name`: the name of a camera parameter, like that passed to `/configure`, e.g. `AcquisitionFrameRate`
125
+ * `cameras/read/<parameter_name>`
126
+ * as previous, but return values for all cameras
127
+ * `camera/<serial>/recording/start`
128
+ * start recording on the selected camera
129
+ * `serial`: the serial number of the camera
130
+ * arguments
131
+ * `filename` (optional): recording filename (excluding timestamp)
132
+ * `record_to_store` (optional)
133
+ * `True`
134
+ * `False`
135
+ * no argument provided: use configured defaults
136
+ * `codec` (optional): codec, or use configured default if omitted
137
+ * `duration` (optional): number of seconds to record for, or indefinately if omitted
138
+ * `metadata` (optional): a dictionary of metadata to save in the resulting video
139
+ * `codec_identifier` (optional): advanced use only
140
+ * `recording/start`
141
+ * as previous, but start recording on all cameras
142
+ * `camera/<serial>/recording/stop`
143
+ * stop recording on the selected camera
144
+ * `serial`: the serial number of the camera
145
+ * `recording/stop`
146
+ * as previous, but stop recording on all cameras
147
+ * `camera/<serial>/recordings`
148
+ * return a list of recordings
149
+ * `serial`: the serial number of the camera
150
+ * `recordings`
151
+ * as previous, but return recordings for all cameras
152
+ * `camera/<serial>/recordings/copy_all`
153
+ * copy (or move) all currently completed recordings to another location
154
+ * `serial`: the serial number of the camera
155
+ * arguments
156
+ * `location` (optional): local user path or if omitted, default configued location
157
+ * `delete_after` (optional, default=False): delete original recordings after successful copy
158
+ * `loopy_username` (optional)
159
+ * `loopy_url` (optional)
160
+ * `loopy_api_key` (optional)
161
+ * `loopy_import_base` (optional):
162
+ connection details of accesibly self hosted loopy instance for automatic
163
+ importing of videos into loopy after copy finishes
164
+ * `recordings/copy_all`
165
+ * as previous, but copy recordings from all cameras
166
+ * `camera/<serial>/recordings/export_all`
167
+ * export image stores to normal mp4 videos (or image stores)
168
+ * `serial`: the serial number of the camera
169
+ * arguments
170
+ * `to_store` (optional)
171
+ * `True`
172
+ * `False`
173
+ * no argument provided: use configured defaults
174
+ * `codec` (optional)
175
+ * `delete_after` (optional)
176
+ * `path` (optional)
177
+ * `recordings/export_all`
178
+ * as previous, but export image stores from all cameras
179
+ * `camera/<serial>/io/<name>/set`
180
+ * set named output associated with camera to provided value
181
+ * `serial`: the serial number of the camera
182
+ * `name`: the name of the configured output channel
183
+ * arguments
184
+ * `value`: the value to set on the output (backend dependent)
185
+ * `io/<name>/set`
186
+ * set named output to the provided value. in index or master mode in
187
+ a multiple camera setup, the named output channel must be on a
188
+ output device attached to the master or index node.
189
+ setup (or to the only attached camera in a single camera setup)
190
+ * `name`: the name of the configured output channel
191
+ * arguments
192
+ * `value`: the value to set on the output (backend dependent)
193
+ * `io/<io_serial>/<io_port>/set` (DEPRECATED)
194
+ * configure and set output to provided value
195
+ * `io_serial`: serial number of IO device
196
+ * `io_port`: port on device to set
197
+ * arguments
198
+ * `value`: continuous value to set OR
199
+ * `state`: 0 or 1 to turn on or off
200
+ * `io/log`
201
+ * send multiple arguements to be recorded in the imgstore extra_data - in addition to the current frame_number and time of the log message
202
+ * `camera/<serial>/experiment/message`
203
+ * send a user-facing status message from an experiment script to the web UI
204
+ for the specified camera
205
+ * `serial`: the serial number of the camera
206
+ * arguments
207
+ * `message`: the text to display. pass an empty string to clear the message
208
+ * `experiment/message`
209
+ * as previous, but in a single camera setup or to target the multicam
210
+ master/index UI
211
+ * `multicam/synchronize`
212
+ * synchronize the cameras (in a multiple camera setup)
213
+ * `multicam/connect_camera/<serial>`
214
+ * start the camera process for the camera with the provided serial
215
+ * `serial`: the serial number of the camera.
216
+ * passing the special value `__all` here connects all cameras
217
+ * `multicam/disconnect_camera/<serial>`
218
+ * stops the camera process for the camera with the provided serial
219
+ * `serial`: the serial number of the camera.
220
+ * passing the special value `__all` here disconnects all cameras
221
+
222
+
223
+ **Outputs and Toggling Values**
224
+
225
+ There exists a special value for (digital) outputs that results in the output state being
226
+ toggled (alternating) between minimum and maximum values. If you pass `value=+inf` then this means
227
+ start with a switch to maximum, then toggle between minimum and maximum subsequently. `value=-inf`
228
+ start with a switch to minimum, then toggle between maximum and minimum subsequently.
229
+
230
+ ## Experiment Script Messages
231
+
232
+ Experiment scripts can send a short text message back to the Motif web UI using the
233
+ `send_message` convenience method (or the `experiment/message` endpoint). The message
234
+ appears in the Experiment Scripts section of the UI with a timestamp, and can be updated
235
+ at any time to show progress or status.
236
+
237
+ ```python
238
+ from motifapi import MotifApi
239
+
240
+ api = MotifApi(IP_ADDRESS, API_KEY)
241
+
242
+ # send a message (appears in the multicam or single-cam UI)
243
+ api.send_message('Starting trial 1')
244
+
245
+ # send a message targeting a specific camera
246
+ api.send_message('Acquiring baseline', serial='FAKE0')
247
+
248
+ # clear the message
249
+ api.send_message('')
250
+ ```
251
+
252
+ Using `api.call` directly:
253
+
254
+ ```python
255
+ # to the multicam / single-cam UI
256
+ api.call('experiment/message', message='Hello from script')
257
+
258
+ # to a specific camera's UI
259
+ api.call('camera/FAKE0/experiment/message', message='Running phase 2')
260
+ ```
261
+
262
+ ## Scheduling Function
263
+
264
+ Motif allows scheduling of almost all previously documented API operations.
265
+ Scheduling allows executing a certain operation as a defined time.
266
+
267
+ This allows for example, to schedule recordings and their subsequent copy
268
+ to storage to occur at specific times. Task scheduling re-uses [Cron syntax]()
269
+ with some extensions.
270
+
271
+ ### Cron Syntax
272
+
273
+ Please refer to [here](README.cronex.md) for a complete specification and more examples.
274
+
275
+ Each scheduled operation is specified with a combination of six white-space
276
+ separated fields that dictate when the event should occur. In order, the fields
277
+ specify trigger times for the second, minute, hour, day of the month,
278
+ month and day of the week.
279
+
280
+ ```text
281
+ .------------------ Second (0 - 59)
282
+ | .--------------- Minute (0 - 59)
283
+ | | .------------ Hour (0 - 23)
284
+ | | | .--------- Day of the month (1 - 31)
285
+ | | | | .------ Month (1 - 12) or Jan, Feb ... Dec
286
+ | | | | | .---- Day of the week (0 (Sun.; also 7) - 6 (Sat.))
287
+ | | | | | |
288
+ V V V V V V
289
+ * * * * * *
290
+ ```
291
+
292
+ ### Scheduling API Documentation
293
+
294
+ The API for scheduling tasks is very similar to that of other tasks - API endpoints are
295
+ prefixed with `schedule/VERB` may/should be passed additional optional or
296
+ compulsory arguments.
297
+
298
+ **Scheduling specific arguments**
299
+
300
+ * `task_name` (required): A unique short identifier for this task
301
+ * `cron_expression` (required): a cron specifier conforming to the cron syntax above
302
+ * `camera_relative` (optional, Motif 5 and above only)
303
+ * `True`: if true all absolute and relative dates are relative to the start of the recording
304
+ * `False` (default): all dates are absolute, and monotonic expressions are relative to 1-Jan-1970
305
+
306
+ **Camera Relative Schedules**
307
+
308
+ Please ensure you have read the [full documentation](README.cron.md) on cron triggers first. Often one needs to schedule events not in the conventional cron sense - relative to the date and time of the day, but instead wants to schedule operations relative to when a recording was started. If a task is scheduled with `camera_relative=True` then dates are interpreted differently. For example the expression
309
+
310
+ * `0 5 * ? * * *`
311
+ * with `camera_relative=False` means execute the task *at second :00 of minute :05 of every hour*
312
+ * e.g. Fri May 17 16:05:00 UTC 2019, Fri May 17 17:05:00 UTC 2019, Fri May 17 18:05:00 UTC 2019
313
+ * with `camera_relative=False` means execute the task *at second :00 of minute :05 of every hour* relative to when recording was started.
314
+ * e.g., for a recording started at Fri May 17 13:14:00 UTC 2019, the task would trigger at Fri May 17 13:19:00 UTC 2019, Fri May 17 14:19:00 UTC 2019, etc.
315
+
316
+ With monotonic triggers `7%7` or `%10` this can be confusing. Monotonic triggers execute *every unit of time*. For example the expression
317
+
318
+ * `0 * %2 ? * * *`
319
+ * with `camera_relative=False` means *execute every 2 hours starting at 00:00:00*
320
+ * e.g. Fri May 17 00:00:00 UTC 2019, Fri May 17 02:00:00 UTC 2019
321
+ * with `camera_relative=True` means *execute every 2 hours starting at the time of recording start*
322
+ * e.g., for a recording started at Fri May 17 13:14:00 UTC 2019, the task would trigger at Fri May 17 15:14:00 UTC 2019, Fri May 17 17:14:00 UTC 2019, etc.
323
+
324
+ **API (continued)**
325
+
326
+ * `schedule`
327
+ * list all scheduled tasks, return also including the current time on
328
+ the system
329
+ * `schedule/clear`
330
+ * delete all scheduled tasks
331
+ * `schedule/<task_name>/clear`
332
+ * delete the provided task
333
+ * `task_name`: the identifier of the task to clear
334
+ * `schedule/camera/<serial>/recording/start`
335
+ * schedule the start of recording on the selected camera
336
+ * `serial`: the serial number of the camera
337
+ * arguments
338
+ * scheduling specific (above), excluding `camera_relative`
339
+ * see `recording/start` (above)
340
+ * `schedule/recording/start`
341
+ * as previous, but schedule the start of recording on all cameras
342
+ * `schedule/camera/<serial>/recordings/copy_all`
343
+ * schedule copy (or move) of recordings
344
+ * `serial`: the serial number of the camera
345
+ * arguments
346
+ * scheduling specific (above), excluding `camera_relative`
347
+ * see `recordings/copy_all` (above)
348
+ * `schedule/recordings/copy_all`
349
+ * as previous, but schedule copy (or move) of recordings on all cameras
350
+ * `schedule/camera/<serial>/recordings/export_all`
351
+ * schedule export of recordings on the selected camera
352
+ * `serial`: the serial number of the camera
353
+ * arguments
354
+ * scheduling specific (above), excluding `camera_relative`
355
+ * see `recordings/export_all`
356
+ * `schedule/recordings/export_all`
357
+ * as previous, but schedule export of recordings on all cameras
358
+ * `schedule/camera/<serial>/configure/<name>`
359
+ * schedule the change of a camera configuration
360
+ * `serial`: the serial number of the camera
361
+ * `name`: the the name of the parameter to change (e.g. `ExposureTime`)
362
+ * arguments
363
+ * scheduling specific (above)
364
+ * `value` (required): the new value of the parameter
365
+ * `schedule/cameras/configure/<name>`
366
+ * as previous, but schedule the change of camera configuration on all cameras
367
+ * `schedule/camera/<serial>/io/<name>/set`
368
+ * schedule the setting of a named output, on an IO device connected to the
369
+ supplied camera, to a provided value
370
+ * `serial`: the serial number of the camera
371
+ * `name`: the name of the configured output channel
372
+ * arguments
373
+ * scheduling specific (above)
374
+ * `value` (required): the new value of the parameter
375
+ * `schedule/io/<name>/set`
376
+ * schedule the setting of the named output to the provided value.
377
+ in index or master mode in a multiple camera setup, the named output
378
+ channel must be on a output device attached to the master or index node.
379
+ * arguments
380
+ * scheduling specific (above)
381
+ * `value` (required): the new value of the parameter
382
+
383
+ ## Scheduling Examples
384
+
385
+ To schedule a 30 minute recording as configured above to be executed every-hour-on-the-hour between
386
+ 9am and 4pm, make the following API call
387
+
388
+ ```python
389
+ api.call('schedule/recording/start',
390
+ task_name='record_video',
391
+ cron_expression='0 6-16 * * * *',
392
+ duration=30*60)
393
+ ```
394
+
395
+ To schedule events relative to the start of recording on a specific camera, consider the following examples.
396
+
397
+ ```python
398
+ import datetime
399
+ from motifapi import datetime_to_cron
400
+
401
+ camera_serial = '123456'
402
+ io_name = 'led'
403
+
404
+ # note the use of datetime_to_cron() which converts an absolute date/time to a non-repeating
405
+ # non-monotonic cron expression. Furthermore, note that because this should occur 17s relative
406
+ # to the start of recording, we pass a datetime initialized from 0 seconds.
407
+
408
+ api.call('schedule/camera/%s/io/%s/set' % (camera_serial, io_name),
409
+ task_name='led_on_after_17s',
410
+ cron_expression=datetime_to_cron(datetime.datetime.fromtimestamp(0) + datetime.timedelta(seconds=17)),
411
+ camera_relative=True,
412
+ value=1.0)
413
+ ```
414
+
415
+ monotonic expressions do not require the use of `datetime_to_cron` as the repeat regardless. For example the following expression flashes / toggles an LED every second
416
+
417
+ ```python
418
+ api.call('schedule/camera/%s/io/%s/set' % (camera_serial, io_name),
419
+ task_name='toggle_led_2s',
420
+ cron_expression='%2 * * ? * * *',
421
+ camera_relative=True,
422
+ value=float('+inf'))
423
+ ```
424
+
425
+ neither to regular repeating cron expressions. Consider the following two scheduled operations that turn on an LED at `00:15:00` and `00:45:00` and turn it off at `00:00:00` and `00:30:00`.
426
+
427
+ ```python
428
+ api.call('schedule/camera/%s/io/%s/set' % (camera_serial, io_name),
429
+ task_name='turn_on_led',
430
+ cron_expression='0 15,45 * ? * * *',
431
+ camera_relative=True,
432
+ value=1.0)
433
+ api.call('schedule/camera/%s/io/%s/set' % (camera_serial, io_name),
434
+ task_name='turn_off_led',
435
+ cron_expression='0 0,30 * ? * * *',
436
+ camera_relative=True,
437
+ value=0.0)
438
+ ```
439
+
440
+ note: this same sequence of operations could have likewise been described using a monotonic expression `0 15%15 * ? * * *` and 'toggling IO (see above)' `value=float(+inf)`.
441
+
442
+
443
+ ## FAQ
444
+
445
+ * Q) What if Motif is running on another computer? how to I find the time on the system
446
+
447
+ ```python
448
+ now = api.call('schedule')['now']
449
+ dt = datetime.datetime.fromtimestamp(now)
450
+ ```
451
+
452
+ * Q) What is the easiest way to schedule something to happen 'some time from now'
453
+
454
+ ```python
455
+ # following from the example above
456
+ import datetime
457
+ from motifapi import datetime_to_cron
458
+ future = dt + datetime.timedelta(seconds=30)
459
+ api.call('schedule/recording/start',
460
+ task_name='record_video_in_30s_time',
461
+ cron_expression=datetime_to_cron(future),
462
+ duration=30*60)
463
+ ```
464
+
465
+ * Q) Cron expressions are confusing. Can you recommend ways to help understanding
466
+ * you can test and design cron expressions online using the following site (note: neither support monotonic expressions)
467
+ * [https://www.freeformatter.com/cron-expression-generator-quartz.html](https://www.freeformatter.com/cron-expression-generator-quartz.html)
468
+ * [https://cronexpressiondescriptor.azurewebsites.net/](https://cronexpressiondescriptor.azurewebsites.net/)
469
+
470
+ **See examples/scheduler.py for more information**
471
+
472
+
473
+ ## Other Languages
474
+
475
+ The API is a pure REST api, which means any language / framework / environment with support for calling REST+JSON endpoints and handling their responses is supported.
476
+
477
+ Note: You must provide the HTTPS server certificate and authentication API key in a manner appropriate for your language / framework / environment.
478
+
479
+ ### MATLAB
480
+
481
+ Because the Motif API is pure HTTP+JSON you can also use MATLAB to control the recording systems. Note however the following caveats
482
+
483
+ * Examples below are for the hypothetical IP address `10.11.12.13` and API_KEY `abcdef123456abcdef123456abcdef12`. Please replace with values appropriate to your system
484
+ * On windows, you can find the API key in the config file (link in Start menu), on Linux by executing `recnode-apikey` on the PC
485
+ * `server.crt` must be the full path to the `server.crt` file which you should [download](https://raw.githubusercontent.com/loopbio/python-motifapi/master/motifapi/server.crt) from this repository.
486
+ * Not all endpoints are `POST`, although most are. `GET` endpoints are read using `webread`, `POST`, `PATCH`, `DELETE` using `webwrite`. The endpoint type is documented in [api.py](http://github.com/loopbio/python-motifapi/blob/master/motifapi/api.py).
487
+
488
+ **Checking Status**
489
+
490
+ ```matlab
491
+ % example showing how to list connected cameras from MATLAB
492
+ o = weboptions('CertificateFilename', 'server.crt', ...
493
+ 'HeaderFields', {'X-Api-Key', 'abcdef123456abcdef123456abcdef12'})
494
+ # list the connected cameras
495
+ webread('https://10.11.12.13:6083/api/1/cameras', o)
496
+ ```
497
+
498
+ **Turning on an Output**
499
+
500
+ ```matlab
501
+ % example showing how to set an output using a POST request
502
+ o = weboptions('CertificateFilename', 'server.crt', ...
503
+ 'HeaderFields', {'X-Api-Key', 'abcdef123456abcdef123456abcdef12'}, ...
504
+ 'MediaType', 'application/json')
505
+ arguments = struct('value',0.3);
506
+ webwrite('https://10.11.12.13:6083/api/1/io/led/set',arguments,o)
507
+ ```
508
+
509
+ **Starting Recording**
510
+
511
+ ```matlab
512
+ o = weboptions('CertificateFilename', 'server.crt', ...
513
+ 'HeaderFields', {'X-Api-Key', 'abcdef123456abcdef123456abcdef12'}, ...
514
+ 'MediaType', 'application/json')
515
+ arguments = struct('codec','h264');
516
+ webwrite('https://10.11.12.13:6083/api/1/recording/start',arguments,o)
517
+ ```
518
+
519
+ **Stopping Recording**
520
+
521
+ ```matlab
522
+ o = weboptions('CertificateFilename', 'server.crt', ...
523
+ 'HeaderFields', {'X-Api-Key', 'abcdef123456abcdef123456abcdef12'}, ...
524
+ 'MediaType', 'application/json')
525
+ arguments = struct('codec','h264');
526
+ webwrite('https://10.11.12.13:6083/api/1/recording/stop',arguments,o)
527
+ ```
528
+
529
+ **Checking Status**
530
+
531
+ ```matlab
532
+ % example showing how to list connected cameras from MATLAB
533
+ o = weboptions('CertificateFilename', 'server.crt', ...
534
+ 'HeaderFields', {'X-Api-Key', 'abcdef123456abcdef123456abcdef12'})
535
+ # list the connected cameras
536
+ webread('https://10.11.12.13:6083/api/1/cameras', o)
537
+ ```
538
+
539
+ **Turning on an Output**
540
+
541
+ ```matlab
542
+ % example showing how to set an output using a POST request
543
+ o = weboptions('CertificateFilename', 'server.crt', ...
544
+ 'HeaderFields', {'X-Api-Key', 'abcdef123456abcdef123456abcdef12'}, ...
545
+ 'MediaType', 'application/json')
546
+ arguments = struct('value',0.3);
547
+ webwrite('https://10.11.12.13:6083/api/1/io/led/set',arguments,o)
548
+ ```
549
+
550
+ **Starting Recording**
551
+
552
+ ```matlab
553
+ o = weboptions('CertificateFilename', 'server.crt', ...
554
+ 'HeaderFields', {'X-Api-Key', 'abcdef123456abcdef123456abcdef12'}, ...
555
+ 'MediaType', 'application/json')
556
+ arguments = struct('codec','h264');
557
+ webwrite('https://10.11.12.13:6083/api/1/recording/start',arguments,o)
558
+ ```
559
+
560
+ **Stopping Recording**
561
+
562
+ ```matlab
563
+ o = weboptions('CertificateFilename', 'server.crt', ...
564
+ 'HeaderFields', {'X-Api-Key', 'abcdef123456abcdef123456abcdef12'}, ...
565
+ 'MediaType', 'application/json')
566
+ arguments = struct('codec','h264');
567
+ webwrite('https://10.11.12.13:6083/api/1/recording/stop',arguments,o)
568
+ ```
569
+
570
+ <!---motifcutstart--->
571
+
572
+ ## Realtime Streaming
573
+
574
+ Motif can, with very low latency (&lt;1ms), stream realtime images from the camera, without interfering with
575
+ the recording, compression, or any other motif functions. This allows developing *out of process* realtime
576
+ image processing algorithms for closed loop experiments. Such experiments could for example then provide
577
+ stimulus to the animal using either Motif connected and configured outputs, or other user provided methods.
578
+
579
+ Per default streaming is limited to localhost (so such scripts must run on the same PC as Motif), however
580
+ Motif can be configured to stream also to other network locations if you are aware of the latency
581
+ implications and have sufficient network infrastructure.
582
+
583
+ ### Streaming Images
584
+
585
+ A realtime image stream can be established as follows (after constructing the `api` object with the correct IP address and API_KEY as indicated above
586
+
587
+ ```python
588
+ # small example using opencv to show the image in realtime, outside of motif
589
+
590
+ stream = api.get_stream(stream_type=MotifApi.STREAM_TYPE_IMAGE)
591
+ if stream is not None:
592
+ while True:
593
+ I, md = stream.get_next_image()
594
+ cv2.imshow('live', I)
595
+ cv2.waitKey(1)
596
+ ```
597
+
598
+ ### Streaming State
599
+
600
+ If you have other more custom data acquisition needs not supported by Motif IO / DAQ support, and want to subsequently
601
+ integrate or synchronize this custom data with the motif imagestore `frame_number`s and `frame_time`s then you can use the realtime state streaming. Motif publishes to this stream, at low latency (&lt;&lt;1ms), the current `frame_number`, `frame_time`, and other information. A realtime state stream is established as follows
602
+
603
+ ```python
604
+ stream = api.get_stream(stream_type=MotifApi.STREAM_TYPE_STATE)
605
+ if stream is not None:
606
+ while True:
607
+ print stream.get_next_state()
608
+ ```
609
+
610
+ <!---motifcutend--->
@@ -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
@@ -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
+
@@ -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)
@@ -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-----
@@ -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))
@@ -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,13 @@
1
+ MANIFEST.in
2
+ README.md
3
+ setup.py
4
+ motifapi/__init__.py
5
+ motifapi/api.py
6
+ motifapi/schedule.py
7
+ motifapi/server.crt
8
+ motifapi/stream.py
9
+ motifapi/util.py
10
+ motifapi.egg-info/PKG-INFO
11
+ motifapi.egg-info/SOURCES.txt
12
+ motifapi.egg-info/dependency_links.txt
13
+ motifapi.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ motifapi
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env python
2
+
3
+ from setuptools import setup
4
+
5
+ setup(
6
+ name='motifapi',
7
+ license='BSD',
8
+ description='Python interface to Motif recording systems',
9
+ long_description='Python interface to Motif recording systems',
10
+ version='0.2.0',
11
+ author='John Stowers',
12
+ author_email='john@loopbio.com',
13
+ packages=['motifapi'],
14
+ python_requires='>=3.6',
15
+ classifiers=[
16
+ 'Programming Language :: Python :: 3',
17
+ 'Programming Language :: Python :: 3 :: Only',
18
+ ],
19
+ include_package_data=True,
20
+ )
21
+