experiment_server 0.3.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.
@@ -0,0 +1,403 @@
1
+ #!/usr/bin/env python
2
+ """Main script."""
3
+
4
+ from loguru import logger
5
+ from pathlib import Path
6
+ from multiprocessing import Process
7
+
8
+ from tornado.web import RequestHandler, Application, StaticFileHandler
9
+ import tornado.ioloop
10
+ import asyncio
11
+ import json
12
+
13
+ from experiment_server._api import Experiment
14
+ from experiment_server.utils import ExperimentServerConfigurationExcetion, ExperimentServerExcetion
15
+
16
+
17
+ def _create_app(default_participant_index, config_file):
18
+ resource_parameters = {"experiment": Experiment(config_file, default_participant_index)}
19
+
20
+ static_location = (Path(__file__).parent / "static" ).absolute()
21
+
22
+ application = Application([
23
+ (r"/()",StaticFileHandler, {'path': str(static_location / "index.html")}),
24
+ (r"/index()",StaticFileHandler, {'path': str(static_location / "index.html")}),
25
+ (r"/web/([^/]+)", WebHandler, resource_parameters),
26
+ (r"/web/([^/]+)/([0-9]+)", WebHandler, resource_parameters),
27
+ (r"/api/([^/]+)", ExperimentHandler, resource_parameters),
28
+ (r"/api/([^/]+)/([0-9]+)", ExperimentHandler, resource_parameters),
29
+ (r"/api/([^/]+)/([0-9]+)/([0-9]+)", ExperimentHandler, resource_parameters),
30
+ (r"/(.*)",StaticFileHandler, {'path': static_location, 'default_filename': "index.html"})
31
+ ])
32
+ return application
33
+
34
+
35
+ async def _init_api(config_file, default_participant_index, host="127.0.0.1", port=5000):
36
+ application = _create_app(default_participant_index, config_file)
37
+ application.listen(port=port, address=host)
38
+ await asyncio.Event().wait()
39
+
40
+
41
+ def _server(config_file, default_participant_index, host="127.0.0.1", port=5000):
42
+ asyncio.run(_init_api(config_file, default_participant_index, host, port))
43
+
44
+
45
+ def server_process(config_file, default_participant_index=None, host="127.0.0.1", port="5000"):
46
+ p = Process(target=_server,
47
+ kwargs={
48
+ "default_participant_index":default_participant_index,
49
+ "host":host, "port":port, "config_file":config_file
50
+ })
51
+ return p
52
+
53
+
54
+ class WebHandler(RequestHandler):
55
+ def initialize(self, experiment:Experiment):
56
+ self.experiment = experiment
57
+ self.output_written: bool = False
58
+
59
+ def write_to_output(self, message):
60
+ self.write(f"<div hx-swap-oob=\"innerHTML:#output\">{message}</div>")
61
+
62
+ def write_alert(self, alert_type, message):
63
+ self.write_to_output(f"<div class=\"alert alert-{alert_type}\">{message}</div>")
64
+
65
+ def write_info(self, message):
66
+ self.write_alert("info", message)
67
+
68
+ def write_warn(self, message):
69
+ self.write_alert("warning", message)
70
+
71
+ def write_danger(self, message):
72
+ self.write_alert("danger", message)
73
+
74
+ def write_empty_status(self):
75
+ self.write_status_string(None, True)
76
+
77
+ def write_status_string(self, participant_id=None, no_message=False):
78
+ if not no_message:
79
+ message = self.experiment.get_participant_state(participant_id).status_string().replace("\n", "&nbsp;&nbsp;&nbsp;")
80
+ else:
81
+ message = ""
82
+ self.write(f"<div hx-swap-oob=\"innerHTML:#status\">{message}</div>")
83
+
84
+ def _process_participant_id(self):
85
+ participant_id = self.get_argument("txtPPID", self.experiment.default_participant_index, True)
86
+ use_default = self.get_argument("checkUseDefult", "off", True)
87
+
88
+ if use_default == "on":
89
+ participant_id = None
90
+ else:
91
+ try:
92
+ participant_id = int(participant_id)
93
+ except ValueError:
94
+ participant_id = self.experiment.default_participant_index
95
+
96
+ if participant_id is not None and participant_id not in self.experiment.global_state:
97
+ self.write_danger(f"Participant with ID {participant_id} not known. Consider initializing new participant.")
98
+ self.write_empty_status()
99
+ return
100
+
101
+ return participant_id
102
+
103
+ def _get_config_table(self, config) -> str:
104
+ table_output = "<table class=\"table\"><tr><th>key</th><th>value</th></tr>"
105
+ for k,v in config.items():
106
+ table_output += f"<tr><td>{k}</td><td>{v}</td></tr>"
107
+ btn = """<button type="button" class="btn btn-primary"
108
+ hx-get="/web/config-editable"
109
+ hx-trigger="click"
110
+ hx-include="#checkUseDefault,#txtPPID"
111
+ hx-swap="none"/>Edit</button>"""
112
+
113
+ table_output += f"<tr><td></td><td>{btn}</td></tr>"
114
+ table_output += "</table>"
115
+ return table_output
116
+
117
+ def _get_editable_config_table(self, config) -> str:
118
+ table_output = """<form hx-post="/web/update-config" hx-include="#checkUseDefault,#txtPPID"><table class="table"><tr><th>key</th><th>value</th></tr>"""
119
+ for k,v in config.items():
120
+ if k in ["participant_index", "block_id", "name"]:
121
+ table_output += f"<tr><td>{k}</td><td>{v}</td></tr>"
122
+ else:
123
+ _name = f"_c_{k}"
124
+ table_output += f"<tr><td><label for={_name}>{k}</label></td><td><input type=\"text\" id={_name} name={_name} placeholder=\"{v}\"></td></tr>"
125
+
126
+ btn = """<button type="submit" class="btn btn-primary" />Submit</button>
127
+ <button type="button" class="btn btn-secondary"
128
+ hx-get="/web/config"
129
+ hx-trigger="click"
130
+ hx-include="#checkUseDefault,#txtPPID"
131
+ hx-swap="none"/>Cancel</button>"""
132
+
133
+ table_output += f"<tr><td></td><td>{btn}</td></tr>"
134
+ table_output += "</table></form>"
135
+ return table_output
136
+
137
+ # NOTE: I am abusing the GET here!
138
+ def get(self, action=None):
139
+ if action in ["status-string", "acive-participant-change", "config", "config-editable", "reset-participant", "move-to-block", "move-to-next"]:
140
+ participant_id = self._process_participant_id()
141
+
142
+ if action == "status-string":
143
+ self.write_status_string(participant_id)
144
+
145
+ elif action == "acive-participant-change":
146
+ self.write_status_string(participant_id)
147
+ self.write_to_output("")
148
+
149
+ elif action == "config":
150
+ config = self.experiment.get_config(participant_id)
151
+ if config is not None:
152
+ self.write_info(self._get_config_table(config))
153
+ else:
154
+ self.write_warn(f"participant {participant_id} not active. A call to `/move-to-next` must be made before calling `/config`")
155
+
156
+ elif action == "config-editable":
157
+ config = self.experiment.get_config(participant_id)
158
+ if config is not None:
159
+ self.write_info(self._get_editable_config_table(config))
160
+ else:
161
+ self.write_warn(f"participant {participant_id} not active. A call to `/move-to-next` must be made before calling `/config`")
162
+
163
+ elif action == "reset-participant":
164
+ self.experiment.reset_participant(participant_id)
165
+ _str = f"index {participant_id}" if participant_id is not None else "default index"
166
+ self.write_info(f"Reset config for all blocks for participant with {_str}")
167
+
168
+ elif action == "move-to-block":
169
+ new_block_id = self.get_argument("txtBlockID", "-", True)
170
+ try:
171
+ new_block_id = int(new_block_id)
172
+ except ValueError:
173
+ self.write_danger("Invaid input")
174
+ return
175
+
176
+ try:
177
+ new_block_name = self.experiment.move_to_block(new_block_id, participant_id)
178
+ self.write_info(f"Moved to block: {new_block_name}")
179
+ except Exception as e:
180
+ self.write_danger(e)
181
+ self.write_status_string(participant_id)
182
+
183
+ elif action == "move-to-next":
184
+ try:
185
+ new_block_name = self.experiment.move_to_next(participant_id)
186
+ self.write_info(f"Moved to block: {new_block_name}")
187
+ except Exception as e:
188
+ self.write_danger(e)
189
+ self.write_status_string(participant_id)
190
+
191
+ elif action == "move-all-to-block":
192
+ new_block_id = self.get_argument("txtAllBlockID", "-", True)
193
+ try:
194
+ new_block_id = int(new_block_id)
195
+ except ValueError:
196
+ self.write_danger("Invaid input")
197
+ return
198
+
199
+ try:
200
+ new_block_name = self.experiment.move_all_to_block(new_block_id)
201
+ self.write_info(f"Moved all to block: {new_block_name}")
202
+ except Exception as e:
203
+ self.write_danger(e)
204
+
205
+ elif action == "new-participant":
206
+ self.write_info("New participant id added: " + str(self.experiment.get_next_participant()))
207
+
208
+ elif action == "add-participant":
209
+ new_participant_id = self.get_argument("newPPID", "-", True)
210
+ try:
211
+ new_participant_id = int(new_participant_id)
212
+ except ValueError:
213
+ self.write_danger("Invaid input")
214
+ return
215
+
216
+ added_participant = self.experiment.add_participant_index(new_participant_id)
217
+ if not added_participant:
218
+ self.write_warn(f"Participant id {new_participant_id} already exists.")
219
+ else:
220
+ self.write_info(f"Added new participant with id: {new_participant_id}")
221
+
222
+ elif action == "list-participants":
223
+ table_output = "<table class=\"table\"><tr><th>participant ID</th><th>Block ID</th><th>Block Name</th></tr>"
224
+ for idx, state in self.experiment.global_state.items():
225
+ table_output += f"<tr><td>{idx}</td><td>{state.block_id}</td><td>{state.block_name}</td></tr>"
226
+ table_output += "</table>"
227
+ self.write_info(table_output)
228
+
229
+ def post(self, action=None):
230
+ participant_id = self._process_participant_id()
231
+
232
+ if action == "update-config":
233
+ valid_submission = True
234
+ config = self.experiment.get_config(participant_id)
235
+ if config is not None:
236
+ for key in config.keys():
237
+ if key in ["participant_index", "block_id", "name"]:
238
+ continue
239
+ try:
240
+ param_key = f"_c_{key}"
241
+ new_value = self.get_argument(param_key)
242
+ if len(new_value) == 0:
243
+ continue # value was not set!
244
+ new_value = json.loads(new_value)
245
+ config[key] = new_value # its a dict! it's a reference value!
246
+ except Exception as e:
247
+ logger.exception(f"Failed to process key {key} with {e}")
248
+ logger.error(f"Failed to process key {key} with {e}")
249
+ valid_submission = False
250
+
251
+ if valid_submission:
252
+ output = "<b>Update Successful</b></br>"
253
+ output += self._get_config_table(config)
254
+ self.write_info(output)
255
+ else:
256
+ output = "<b>Update Failed</b></br>"
257
+ output += self._get_editable_config_table(config)
258
+ self.write_warn(output)
259
+
260
+
261
+ class ExperimentHandler(RequestHandler):
262
+ def initialize(self, experiment:Experiment):
263
+ self.experiment = experiment
264
+
265
+ def get(self, action=None, param=None):
266
+ # The experiment methods treat None as default pp
267
+ if param is not None:
268
+ participant_id = self._get_int_from_param(param)
269
+ else:
270
+ participant_id = None
271
+
272
+ if participant_id is not None and participant_id not in self.experiment.global_state:
273
+ self.write(f"Participant with ID {participant_id} not known. Consider initializing new participant.")
274
+ self.set_status(406)
275
+ return
276
+
277
+ if action == "blocks-count":
278
+ self.write(json.dumps(self.experiment.get_blocks_count()))
279
+ elif action == "block-id":
280
+ self.write(json.dumps(self.experiment.get_participant_state(participant_id).block_id))
281
+ elif action == "active":
282
+ self.write(json.dumps(self.experiment.get_state(participant_id)))
283
+ elif action == "config":
284
+ config = self.experiment.get_config(participant_id)
285
+ if config is not None:
286
+ logger.info(f"Config returned: {config}")
287
+ self.write(json.dumps(config, indent=4))
288
+ else:
289
+ self.set_status(406)
290
+ self.write(f"participant {participant_id} not active. A call to `/move-to-next` must be made before calling `/config`")
291
+ elif action == "summary-data":
292
+ self.write({
293
+ "participant_index": participant_id if participant_id is not None else self.experiment.default_participant_index,
294
+ "configs_length": self.experiment.get_blocks_count(participant_id)
295
+ })
296
+ elif action == "all-configs":
297
+ self.write(json.dumps(self.experiment.get_all_configs(participant_id), indent=4))
298
+ elif action == "status-string":
299
+ self.write(self.experiment.get_participant_state(participant_id).status_string().replace("\n", "&nbsp;&nbsp;&nbsp;"))
300
+ else:
301
+ self.set_status(404)
302
+ self.write("N/A")
303
+
304
+ def post(self, action=None, param1=None, param2=None):
305
+ if action == "move-to-next":
306
+ if param2 is not None:
307
+ self.set_status(404)
308
+ self.write(f"unknown second parameter {param2}")
309
+ if param1 is not None:
310
+ participant_id = self._get_int_from_param(param1)
311
+ else:
312
+ participant_id = None
313
+
314
+ try:
315
+ block_name = self.experiment.move_to_next(participant_id)
316
+ logger.info(f"Loading block: {self.experiment.get_participant_state(participant_id).block}\n")
317
+ self.write({"name": block_name})
318
+ except KeyError:
319
+ self.write(f"Participant with ID {participant_id} not known. Consider initializing new participant.")
320
+ self.set_status(406)
321
+
322
+ elif action == "move-to-block":
323
+ if param1 is None and param2 is None:
324
+ self.set_status(404)
325
+ self.write("Need atleast one paramter.")
326
+ return
327
+ elif param2 is None:
328
+ participant_id = None
329
+ new_block_id = self._get_int_from_param(param1)
330
+ else:
331
+ participant_id = self._get_int_from_param(param1)
332
+ new_block_id = self._get_int_from_param(param2)
333
+ if new_block_id is not None:
334
+ try:
335
+ if new_block_id >= self.experiment.get_blocks_count(participant_id) or new_block_id < 0:
336
+ self.set_status(404)
337
+ self.write("param should be >= 0 and < " + str(self.experiment.get_blocks_count(participant_id)))
338
+ else:
339
+ self.experiment.move_to_block(new_block_id, participant_id)
340
+ self.write(str(new_block_id))
341
+ except KeyError:
342
+ self.write(f"Participant with ID {participant_id} not known. Consider initializing new participant.")
343
+ self.set_status(406)
344
+ elif action == "move-all-to-block":
345
+ if param2 is not None:
346
+ self.set_status(404)
347
+ self.write(f"unknown second parameter {param2}")
348
+ return
349
+ new_block_id = self._get_int_from_param(param1)
350
+ if new_block_id is not None:
351
+ if new_block_id >= self.experiment.get_blocks_count() or new_block_id < 0:
352
+ self.set_status(404)
353
+ self.write("param should be >= 0 and < " + str(self.experiment.get_blocks_count()))
354
+ else:
355
+ self.experiment.move_all_to_block(new_block_id)
356
+ self.write(str(new_block_id))
357
+ elif action == "shutdown":
358
+ self.experiment.watchdog.end_watch()
359
+ shutdown_server()
360
+ else:
361
+ self.set_status(404)
362
+ self.write("n/a")
363
+
364
+ def put(self, action=None, param=None):
365
+ if action == "new-participant":
366
+ if param != None:
367
+ self.set_status(406)
368
+ self.write("`new-participant` doesn't take params")
369
+ else:
370
+ self.write(str(self.experiment.get_next_participant()))
371
+ elif action == "add-participant":
372
+ participant_id = self._get_int_from_param(param)
373
+ if participant_id is not None:
374
+ try:
375
+ added_participant = self.experiment.add_participant_index(participant_id)
376
+ if not added_participant:
377
+ self.set_status(406)
378
+ self.write(json.dumps(added_participant))
379
+ except ExperimentServerConfigurationExcetion as e:
380
+ self.set_status(406)
381
+ self.write(e.args[0][0])
382
+
383
+ def _get_int_from_param(self, param):
384
+ try:
385
+ param = int(param)
386
+ return param
387
+ except ValueError:
388
+ self.set_status(404)
389
+ self.write(f"param should be an integer, got {param}, processing as None")
390
+ return None
391
+
392
+
393
+ # # From: https://stackoverflow.com/questions/15562446/how-to-stop-flask-application-without-using-ctrl-c
394
+ # def shutdown_server():
395
+ # func = request.environ.get('werkzeug.server.shutdown')
396
+ # if func is None:
397
+ # raise RuntimeError('Not running with the Werkzeug Server')
398
+ # func()
399
+
400
+
401
+ # from https://stackoverflow.com/questions/5375220/how-do-i-stop-tornado-web-server
402
+ def shutdown_server():
403
+ tornado.ioloop.IOLoop.instance().stop()
@@ -0,0 +1,72 @@
1
+ """CLI."""
2
+
3
+ import click
4
+ from loguru import logger
5
+ from pathlib import Path
6
+ from click_aliases import ClickAliasedGroup
7
+
8
+ from experiment_server._server import _server
9
+ from experiment_server._process_config import verify_config
10
+ from experiment_server._api import _generate_config_json
11
+ from experiment_server.utils import ExperimentServerExcetion
12
+
13
+
14
+ @click.group(cls=ClickAliasedGroup)
15
+ def cli():
16
+ pass
17
+
18
+
19
+ @cli.command(aliases=["r"])
20
+ @click.argument("config-file")
21
+ @click.option("-i", "--default-participant-index", default=1, type=click.IntRange(min=1, max_open=True))
22
+ @click.option("-h", "--host", default='127.0.0.1')
23
+ @click.option("-p", "--port", default='5000')
24
+ def run(default_participant_index, config_file, host, port):
25
+ """Launch server with the `config-file` used to setup the configurations"""
26
+ _server(default_participant_index=default_participant_index if default_participant_index > 0 else None, host=host, port=port, config_file=config_file)
27
+
28
+
29
+ @cli.command(aliases=["v", "verify"])
30
+ @click.argument("config-file", type=click.Path())
31
+ def verify_config_file(config_file):
32
+ """Verify if the config-file provided is valid"""
33
+ verify_config(f=config_file)
34
+
35
+
36
+ @cli.command(aliases=["g", "generate"])
37
+ @click.argument("config-file", type=click.Path())
38
+ @click.option("-i", "--participant-index", default=None, type=int)
39
+ @click.option("-r", "--participant-range", default=None, type=int)
40
+ @click.option("-d", "--out-dir", default=None, type=click.Path(file_okay=False))
41
+ def generate_config_json(config_file, participant_index, participant_range, out_dir):
42
+ """Generate json config files after processing config-file for participant_index or
43
+ till participant_range. If `out_location` is passed, it is expected to be a directory.
44
+ If not passed will write out the config's to stdout, one line per participant.
45
+ """
46
+ if participant_index is None and participant_range is None:
47
+ logger.error("Both `participant-index` and `participant-range` cannot be empty.")
48
+ return
49
+ elif participant_index is not None and participant_range is not None:
50
+ logger.error("Both `participant-index` and `participant-range` provided. Ignoring `participant-index`.")
51
+
52
+ with logger.catch(ExperimentServerExcetion, reraise=False):
53
+ _generate_config_json(config_file=config_file, participant_indices=range(1, participant_range + 1) if participant_range is not None else [participant_index, ], out_dir=out_dir)
54
+
55
+
56
+ @cli.command(aliases=["n", "new"])
57
+ @click.argument("new-file-location")
58
+ def new_config_file(new_file_location):
59
+ """Create a new config file."""
60
+ out_location = Path(new_file_location)
61
+ if out_location.is_dir():
62
+ out_location = out_location / "new_config.toml"
63
+
64
+ if out_location.exists():
65
+ logger.error(f"{out_location} already exists!")
66
+ return
67
+
68
+ with open(Path(__file__).parent.parent / "sample_config.toml", "r") as in_f:
69
+ with open(out_location, "w") as out_f:
70
+ out_f.writelines(in_f.readlines())
71
+
72
+ logger.info(f"New config at: {out_location}")