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