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,31 @@
1
+ from importlib_metadata import version
2
+ import logging
3
+ from loguru import logger
4
+
5
+ __version__ = version(__package__)
6
+
7
+
8
+ class __InterceptHandler(logging.Handler):
9
+ def emit(self, record):
10
+ # Get corresponding Loguru level if it exists
11
+ try:
12
+ level = logger.level(record.levelname).name
13
+ except ValueError:
14
+ level = record.levelno
15
+
16
+ # Find caller from where originated the logged message
17
+ frame, depth = logging.currentframe(), 2
18
+ while frame.f_code.co_filename == logging.__file__:
19
+ frame = frame.f_back
20
+ depth += 1
21
+
22
+ logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
23
+
24
+
25
+ logging.basicConfig(handlers=[__InterceptHandler()], level=0)
26
+
27
+ from experiment_server._server import server_process
28
+ from experiment_server._client import Client
29
+ from experiment_server._api import Experiment
30
+
31
+ __all__ = [server_process, Client, Experiment]
@@ -0,0 +1,200 @@
1
+ from sys import stdout
2
+ from typing import Any, Dict, Iterable, List, Union
3
+
4
+ from loguru import logger
5
+ from experiment_server._process_config import process_config_file
6
+ from pathlib import Path
7
+ import json
8
+
9
+ from experiment_server.utils import ExperimentServerExcetion, FileModifiedWatcher
10
+
11
+
12
+ class ParticipantState:
13
+ def __init__(self, config, participant_index, active):
14
+ self.participant_index = participant_index
15
+ self._block_id: int = -1
16
+ self.config = config
17
+ self.active = active # if not started or has ended, then this will be False
18
+
19
+ @property
20
+ def block_id(self) -> int:
21
+ return self._block_id
22
+
23
+ @block_id.setter
24
+ def block_id(self, block_id: int):
25
+ if block_id < 0:
26
+ self._block_id = -1
27
+ self.active = False
28
+ elif block_id >= len(self.config):
29
+ self._block_id = len(self.config)
30
+ self.active = False
31
+ else:
32
+ self.active = True
33
+ self._block_id = block_id
34
+ if self.active:
35
+ assert self._block_id == self.config[self._block_id]["config"]["block_id"]
36
+
37
+ @property
38
+ def block(self) -> Dict[str, Any]|None:
39
+ if self.block_id >= len(self.config) or self.block_id < 0:
40
+ return None
41
+ else:
42
+ return self.config[self.block_id]
43
+
44
+ @property
45
+ def block_name(self) -> str:
46
+ if self.block_id >= len(self.config):
47
+ return "END"
48
+ elif self.block_id < 0:
49
+ return "START"
50
+ return self.config[self.block_id]["name"]
51
+
52
+ def move_to_next_block(self) -> str:
53
+ self.block_id += 1
54
+ return self.block_name
55
+
56
+ def status_string(self):
57
+ return f'Participant index: {self.participant_index} \nBlock: {self._block_id} / {len(self.config)} \n Name: {self.block["name"] if self.block is not None else "N/A"}'
58
+
59
+
60
+ class Experiment:
61
+ """Load and manage experiemnt from a local file."""
62
+ def __init__(self, config_file:str, default_participant_index:int=1) -> None:
63
+ assert default_participant_index > 0, "Default participant index should be >0"
64
+ self.global_state: Dict[int, ParticipantState] = {}
65
+ self.default_participant_index = default_participant_index
66
+ self.watchdog = FileModifiedWatcher(config_file, self._config_file_modified_callback)
67
+ self.config_file = config_file
68
+
69
+ if default_participant_index not in self.global_state:
70
+ self.global_state[default_participant_index] = ParticipantState(process_config_file(config_file, default_participant_index),
71
+ default_participant_index,
72
+ False)
73
+
74
+ def _config_file_modified_callback(self):
75
+ logger.info("Reloading config")
76
+ for participantState in self.global_state.values():
77
+ config = process_config_file(self.config_file, participantState.participant_index)
78
+ participantState.config = config
79
+
80
+ def get_next_participant(self) -> int:
81
+ """Return the index of the next participant."""
82
+ new_participant_index = max(self.global_state.keys()) + 1
83
+ self.add_participant_index(new_participant_index)
84
+ return new_participant_index
85
+
86
+ def add_participant_index(self, participant_index) -> bool:
87
+ """Add a participant with `participant_index`.
88
+ If `participant_index` already exists, returns False, else return True"""
89
+ if participant_index in self.global_state:
90
+ return False
91
+ self.global_state[participant_index] = ParticipantState(process_config_file(self.config_file, participant_index),
92
+ participant_index,
93
+ False)
94
+ return True
95
+
96
+ def get_participant_state(self, participant_index) -> ParticipantState:
97
+ if participant_index is None:
98
+ participant_index = self.default_participant_index
99
+ if participant_index not in self.global_state:
100
+ raise ExperimentServerExcetion(f"participant with index {participant_index} is not set. Consider using `add_participant_index`")
101
+ return self.global_state[participant_index]
102
+
103
+ def get_state(self, participant_index:int|None=None):
104
+ if participant_index is None:
105
+ participant_index = self.default_participant_index
106
+ return self.global_state[participant_index].active
107
+
108
+ def move_to_next(self, participant_index:int|None=None) -> str:
109
+ """Moves the pointer to the current block to the next block for `participant_index`.
110
+ if `participant_index` is None, seld.default_participant_index is used.
111
+ """
112
+ if participant_index is None:
113
+ participant_index = self.default_participant_index
114
+ return self.global_state[participant_index].move_to_next_block()
115
+
116
+ def get_config(self, participant_index:int|None=None) -> Union[Dict[str, Any], None]:
117
+ """Return the config of the current block for `participant_index`.
118
+ if `participant_index` is None, seld.default_participant_index is used.
119
+ If the experiment has not started (`move_to_next` has not
120
+ been called atleast once), this will return `None`."""
121
+ if participant_index is None:
122
+ participant_index = self.default_participant_index
123
+ block = self.global_state[participant_index].block
124
+ if block is None:
125
+ return None
126
+ else:
127
+ return block["config"]
128
+
129
+ def reset_participant(self, participant_index:int|None=None) -> bool:
130
+ """Reset the participant's config to that was loaded from the file."""
131
+ if participant_index is None:
132
+ participant_index = self.default_participant_index
133
+ self.global_state[participant_index].config = process_config_file(self.config_file, participant_index)
134
+ return True
135
+
136
+ def get_blocks_count(self, participant_index:int|None=None) -> int:
137
+ """Return the total number of blocks."""
138
+ if participant_index is None:
139
+ participant_index = self.default_participant_index
140
+ return len(self.global_state[participant_index].config)
141
+
142
+ def get_all_configs(self, participant_index:int|None=None) -> List[dict]:
143
+ """Return all configs in order for `participant_index`.
144
+ if `participant_index` is None, seld.default_participant_index is used.
145
+ """
146
+ if participant_index is None:
147
+ participant_index = self.default_participant_index
148
+ return [c["config"] for c in self.global_state[participant_index].config]
149
+
150
+ def move_to_block(self, block_id: int, participant_index:int|None=None) -> str:
151
+ """For `participant_index` move the pointer of the current
152
+ block to the block in index a `block_id` in the list of
153
+ blocks. If `participant_index` is None, seld.default_participant_index is used.
154
+ """
155
+ assert isinstance(block_id, int), "`block` should be an int"
156
+ if participant_index is None:
157
+ participant_index = self.default_participant_index
158
+ self.global_state[participant_index].block_id = block_id
159
+ return self.global_state[participant_index].block_name
160
+
161
+ def move_all_to_block(self, block_id: int) -> str:
162
+ """For active participants move the pointer of the current
163
+ block to the block in index a `block_id` in the list of
164
+ blocks.
165
+ """
166
+ assert isinstance(block_id, int), "`block` should be an int"
167
+ for participantState in self.global_state.values():
168
+ participantState.block_id = block_id
169
+ return list(self.global_state.values())[0].block_name
170
+
171
+
172
+ def _generate_config_json(config_file: Union[str, Path], participant_indices:Iterable[int], out_dir: Union[str, Path, None] = None) -> None:
173
+ """
174
+ Write out the json config from the `config_file` for participants in `participant_indices`
175
+ in the `out_dir`. The `out_dir` should be a directory or should be None.
176
+ If it is None, it will write the config's, one line per participant. If the passed directory
177
+ does not exist, it will be created.
178
+ """
179
+ if out_dir is not None:
180
+ out_dir = Path(out_dir)
181
+ if not out_dir.exists():
182
+ logger.info(f"Creating direcotry {out_dir}")
183
+ out_dir.mkdir(parents=True)
184
+ elif not out_dir.is_dir():
185
+ raise ExperimentServerExcetion(f"`out_file_location` should be a directory. Got {out_dir}")
186
+
187
+ out_files = []
188
+ for participant_index in participant_indices:
189
+ config = process_config_file(config_file, participant_index, supress_message=True)
190
+ if out_dir is not None:
191
+ out_file = Path(out_dir) / f"{Path(config_file).stem}-participant_{participant_index}.json"
192
+ out_files.append(out_file)
193
+
194
+ with open(out_file, "w") as f:
195
+ json.dump([c["config"] for c in config], f, indent=2)
196
+ else:
197
+ stdout.write(json.dumps([c["config"] for c in config]))
198
+
199
+ if len(out_files) != 0:
200
+ logger.info("Generated files: \n" + "\n".join([str(f) for f in out_files]))
@@ -0,0 +1,76 @@
1
+ import json
2
+ from typing import Tuple, Union
3
+ import requests
4
+
5
+ from experiment_server.utils import ExperimentServerExcetion
6
+
7
+
8
+ class Client:
9
+ def __init__(self, server_host:str ="127.0.0.1", server_port:Union[str, int]="5000") -> None:
10
+ self._server_url = f"http://{server_host}:{server_port}"
11
+
12
+ def _request(self, end_point:str, verb:str) -> Tuple[bool, dict]:
13
+ url = self._server_url + f"/api/{end_point}"
14
+ if verb == "GET":
15
+ r = requests.get(url)
16
+ elif verb == "POST":
17
+ r = requests.post(url)
18
+ elif verb == "PUT":
19
+ r = requests.put(url)
20
+ else:
21
+ raise ExperimentServerExcetion("huh?")
22
+
23
+ if r.status_code != 200:
24
+ return False, {"message": f"status {r.status_code} with text: {r.text}"}
25
+ return True, json.loads(r.text) if len(r.text) > 0 else ""
26
+
27
+ def _get(self, end_point:str) -> Tuple[bool, dict]:
28
+ return self._request(end_point, "GET")
29
+
30
+ def _post(self, end_point:str) -> Tuple[bool, dict]:
31
+ return self._request(end_point, "POST")
32
+
33
+ def _put(self, end_point:str) -> Tuple[bool, dict]:
34
+ return self._request(end_point, "PUT")
35
+
36
+ def move_to_next(self, participant_index:int|None=None) -> Tuple[bool, dict]:
37
+ url = _process_participant_index("move-to-next", participant_index)
38
+ return self._post(url)
39
+
40
+ def get_config(self, participant_index:int|None=None) -> Tuple[bool, dict]:
41
+ url = _process_participant_index("config", participant_index)
42
+ return self._get(url)
43
+
44
+ def server_is_active(self) -> Tuple[bool, dict]:
45
+ return self._get("active")
46
+
47
+ def get_blocks_count(self, participant_index:int|None=None) -> Tuple[bool, dict]:
48
+ url = _process_participant_index("blocks-count", participant_index)
49
+ return self._get(url)
50
+
51
+ def get_all_configs(self, participant_index:int|None=None) -> Tuple[bool, dict]:
52
+ url = _process_participant_index("all-configs", participant_index)
53
+ return self._get(url)
54
+
55
+ def move_to_block(self, block_id:int, participant_index:int|None=None) -> Tuple[bool, dict]:
56
+ assert isinstance(block_id, int), "`block` should be a int"
57
+ url = _process_participant_index("move-to-block", participant_index)
58
+ return self._post(f"{url}/{block_id}")
59
+
60
+ def new_participant(self) -> Tuple[bool, dict]:
61
+ return self._put("new-participant");
62
+
63
+ def add_participant(self, participant_index:int) -> Tuple[bool, dict]:
64
+ assert participant_index is not None
65
+ url = _process_participant_index("add-participant", participant_index)
66
+ return self._put(url);
67
+
68
+ def shutdown(self) -> Tuple[bool, dict]:
69
+ return self._post("shutdown")
70
+
71
+
72
+ def _process_participant_index(url:str, participant_index:int|None) -> str:
73
+ if participant_index is not None:
74
+ assert isinstance(participant_index, int), "`participant_index` should be a int"
75
+ url += f"/{participant_index}"
76
+ return url
@@ -0,0 +1,127 @@
1
+ import random
2
+ import itertools
3
+ from typing import Dict, List, Union
4
+ from easydict import EasyDict as edict
5
+
6
+ from experiment_server.utils import ExperimentServerConfigurationExcetion, balanced_latin_square
7
+
8
+
9
+ ORDERING_BEHAVIOUR = edict({v:v for v in ["randomize", "latin_square", "as_is"]})
10
+
11
+
12
+ def construct_participant_condition(config: List[Dict], participant_index: int, order: Union[dict, list], within_groups:str =None, groups:str =None) -> List:
13
+ if within_groups is None:
14
+ within_groups = ORDERING_BEHAVIOUR.as_is
15
+ if groups is None:
16
+ groups = ORDERING_BEHAVIOUR.as_is
17
+
18
+ names = {}
19
+
20
+ # To make sure the string based indexing works
21
+ for idx, c in enumerate(config):
22
+ c["name"] = str(c["name"])
23
+ names[c["name"]] = idx
24
+
25
+ if len(set(names.values()).symmetric_difference(list(range(len(config))))) != 0:
26
+ raise ExperimentServerConfigurationExcetion("Duplicate block names: {}".format(set([c["name"] for idx, c in enumerate(config) if idx not in names.values()])))
27
+
28
+ if within_groups not in list(ORDERING_BEHAVIOUR.values()):
29
+ raise ExperimentServerConfigurationExcetion(f"Allowed values for `within_groups` are {ORDERING_BEHAVIOUR.values()}, for {within_groups}")
30
+ if groups not in list(ORDERING_BEHAVIOUR.values()):
31
+ raise ExperimentServerConfigurationExcetion(f"Allowed values for `groups` are {ORDERING_BEHAVIOUR.values()}, for {groups}")
32
+
33
+ if isinstance(order, list):
34
+ if not all([isinstance(group, list) for group in order]):
35
+ order = [order,]
36
+ # Making sure the stratergy set for groups is used for within groups
37
+ within_groups = groups
38
+ if not all([isinstance(g, int) for group in order for g in group]) and not all([isinstance(g, str) for group in order for g in group]):
39
+ raise ExperimentServerConfigurationExcetion(f"Each group in the order needs to be a list of `int` or list of `str`, got {order}")
40
+
41
+ _filtered_order = order
42
+
43
+ elif isinstance(order, dict):
44
+ order = {int(k):v for k, v in order.items()}
45
+ if not all([isinstance(_order, list) for _order in order.values()]) or not all([isinstance(group, list) for _order in order.values() for group in _order]):
46
+ raise ExperimentServerConfigurationExcetion(f"Each group in orders for all participants needs to be list, got {order}")
47
+ if not all([isinstance(g, int) for _order in order.values() for group in _order for g in group]) and not all([isinstance(g, str) for _order in order.values() for group in _order for g in group]):
48
+ raise ExperimentServerConfigurationExcetion(f"Each group in orders for all participants needs to be a list of `int` or list of `str`, got {order}")
49
+
50
+ if not all([idx+1 in order.keys() for idx in range(len(order))]):
51
+ raise ExperimentServerConfigurationExcetion(f"Keys order oredr should match the consecutive indices starting from 1. Got keys {list(order.keys())}, expected keys {list(range(len(order)))}")
52
+
53
+ if groups != ORDERING_BEHAVIOUR.as_is:
54
+ raise ExperimentServerConfigurationExcetion(f"Ordering behaviour for groups should be {ORDERING_BEHAVIOUR.as_is} when order is a dictionary. Got {groups}")
55
+ _key = ((participant_index - 1) % len(order)) + 1
56
+ _filtered_order = order[_key]
57
+
58
+
59
+ if groups == ORDERING_BEHAVIOUR.randomize:
60
+ random.shuffle(_filtered_order)
61
+ elif groups == ORDERING_BEHAVIOUR.latin_square:
62
+ _latin_square = balanced_latin_square(len(_filtered_order))
63
+ _participant_order = _latin_square[(participant_index - 1) % len(_filtered_order)]
64
+
65
+ _filtered_order = [_filtered_order[idx] for idx in _participant_order]
66
+
67
+ if within_groups == ORDERING_BEHAVIOUR.randomize:
68
+ for group in _filtered_order:
69
+ random.shuffle(group)
70
+ elif within_groups == ORDERING_BEHAVIOUR.latin_square:
71
+ elements_in_group = set([len(_g) for _g in _filtered_order])
72
+ if len(elements_in_group) != 1:
73
+ raise ExperimentServerConfigurationExcetion(f"Currently {ORDERING_BEHAVIOUR.latin_square} not supported for `within_groups` when the number of elements in all groups are not the same")
74
+ else:
75
+ _elements_count = elements_in_group.pop()
76
+ _latin_square = balanced_latin_square(_elements_count)
77
+ _group_order = _latin_square[(participant_index - 1) % _elements_count]
78
+
79
+ _filtered_order = [[_g[idx] for idx in _group_order] for _g in _filtered_order]
80
+
81
+ chained_order = list(itertools.chain(*_filtered_order))
82
+ if isinstance(chained_order[0], int):
83
+ return [config[i] for i in chained_order]
84
+ else:
85
+ return [config[names[i]] for i in chained_order]
86
+
87
+
88
+ # def _construct_participant_condition_old(config, participant_index, use_latin_square=False, latin_square=None, config_categorization=None, default_configuration=None, randomize=True):
89
+ # if participant_index < 1:
90
+ # participant_index = 1
91
+ # if use_latin_square:
92
+ # _config = [config[i - 1] for i in latin_square[(participant_index - 1) % len(config)]]
93
+ # else:
94
+ # assert len(config_categorization) == 2
95
+ # if not randomize or participant_index % len(config_categorization) == 0:
96
+ # init_condition = config_categorization[0][:]
97
+ # other_condition = config_categorization[1][:]
98
+ # else:
99
+ # init_condition = config_categorization[1][:]
100
+ # other_condition = config_categorization[0][:]
101
+ # init_condition = [config[i] for i in init_condition]
102
+ # other_condition = [config[i] for i in other_condition]
103
+ # random.shuffle(init_condition)
104
+ # random.shuffle(other_condition)
105
+
106
+ # if default_configuration is not None:
107
+ # default_configuration_config = default_configuration[0]["config"]
108
+ # non_default_keys = [k for k in default_configuration_config.keys() if k not in ["conditionId"]]
109
+
110
+ # init_condition_train = init_condition[0].copy()
111
+ # init_condition_train["config"] = init_condition_train["config"].copy()
112
+ # init_condition_train["config"]["conditionId"] = "training1"
113
+ # for k in non_default_keys:
114
+ # init_condition_train["config"][k] = default_configuration_config[k]
115
+
116
+ # other_condition_train = other_condition[0].copy()
117
+ # other_condition_train["config"] = other_condition_train["config"].copy()
118
+ # other_condition_train["config"]["conditionId"] = "training2"
119
+ # for k in non_default_keys:
120
+ # other_condition_train["config"][k] = default_configuration_config[k]
121
+
122
+ # _config = [init_condition_train] + init_condition + [other_condition_train] + other_condition
123
+
124
+ # else:
125
+ # _config = init_condition + other_condition
126
+ # # _config = [config[i] for i in _config_list]
127
+ # return _config