sortition-algorithms 0.9.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.
- sortition_algorithms/__init__.py +22 -0
- sortition_algorithms/__main__.py +229 -0
- sortition_algorithms/adapters.py +308 -0
- sortition_algorithms/committee_generation.py +1377 -0
- sortition_algorithms/core.py +591 -0
- sortition_algorithms/errors.py +26 -0
- sortition_algorithms/features.py +351 -0
- sortition_algorithms/find_sample.py +93 -0
- sortition_algorithms/people.py +175 -0
- sortition_algorithms/people_features.py +288 -0
- sortition_algorithms/settings.py +110 -0
- sortition_algorithms/utils.py +107 -0
- sortition_algorithms-0.9.0.dist-info/METADATA +191 -0
- sortition_algorithms-0.9.0.dist-info/RECORD +17 -0
- sortition_algorithms-0.9.0.dist-info/WHEEL +4 -0
- sortition_algorithms-0.9.0.dist-info/entry_points.txt +2 -0
- sortition_algorithms-0.9.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import typing
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
|
|
7
|
+
from attrs import define
|
|
8
|
+
|
|
9
|
+
from sortition_algorithms import errors
|
|
10
|
+
from sortition_algorithms.features import FeatureCollection
|
|
11
|
+
from sortition_algorithms.people import People
|
|
12
|
+
from sortition_algorithms.settings import Settings
|
|
13
|
+
from sortition_algorithms.utils import random_provider
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@define(kw_only=True, slots=True)
|
|
17
|
+
class MaxRatioResult:
|
|
18
|
+
"""Result from finding the category with maximum selection ratio."""
|
|
19
|
+
|
|
20
|
+
feature_name: str
|
|
21
|
+
feature_value: str
|
|
22
|
+
random_person_index: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PeopleFeatures:
|
|
26
|
+
"""
|
|
27
|
+
This class manipulates people and features together, making a deepcopy on init.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
# TODO: consider naming: maybe SelectionState
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
people: People,
|
|
35
|
+
features: FeatureCollection,
|
|
36
|
+
check_same_address: bool = False,
|
|
37
|
+
check_same_address_columns: list[str] | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.people = deepcopy(people)
|
|
40
|
+
self.features = deepcopy(features)
|
|
41
|
+
self.check_same_address = check_same_address
|
|
42
|
+
self.check_same_address_columns = check_same_address_columns or []
|
|
43
|
+
|
|
44
|
+
def update_features_remaining(self, person_key: str) -> None:
|
|
45
|
+
# this will blow up if the person does not exist
|
|
46
|
+
person = self.people.get_person_dict(person_key)
|
|
47
|
+
for feature_name in self.features.feature_names:
|
|
48
|
+
self.features.add_remaining(feature_name, person[feature_name])
|
|
49
|
+
|
|
50
|
+
def update_all_features_remaining(self) -> None:
|
|
51
|
+
for person_key in self.people:
|
|
52
|
+
self.update_features_remaining(person_key)
|
|
53
|
+
|
|
54
|
+
def delete_all_with_feature_value(self, feature_name: str, feature_value: str) -> tuple[int, int]:
|
|
55
|
+
"""
|
|
56
|
+
When a feature/value is "full" we delete everyone else in it.
|
|
57
|
+
"Full" means that the number selected equals the "max" amount - that
|
|
58
|
+
is detected elsewhere and then this method is called.
|
|
59
|
+
Returns count of those deleted, and count of those left
|
|
60
|
+
"""
|
|
61
|
+
# when a category is full we want to delete everyone in it
|
|
62
|
+
people_to_delete: list[str] = []
|
|
63
|
+
for pkey, person in self.people.items():
|
|
64
|
+
if person[feature_name] == feature_value:
|
|
65
|
+
people_to_delete.append(pkey)
|
|
66
|
+
for feature in self.features.feature_names:
|
|
67
|
+
try:
|
|
68
|
+
self.features.remove_remaining(feature, person[feature])
|
|
69
|
+
except errors.SelectionError as e:
|
|
70
|
+
msg = (
|
|
71
|
+
f"SELECTION IMPOSSIBLE: FAIL in delete_all_in_feature_value() "
|
|
72
|
+
f"as after previous deletion no one/not enough left in {feature} "
|
|
73
|
+
f"{person[feature]}. Tried to delete: {len(people_to_delete)}"
|
|
74
|
+
)
|
|
75
|
+
raise errors.SelectionError(msg) from e
|
|
76
|
+
|
|
77
|
+
self.people.remove_many(people_to_delete)
|
|
78
|
+
# return the number of people deleted and the number of people left
|
|
79
|
+
return len(people_to_delete), self.people.count
|
|
80
|
+
|
|
81
|
+
def prune_for_feature_max_0(self) -> list[str]:
|
|
82
|
+
"""
|
|
83
|
+
Check if any feature_value.max is set to zero. if so delete everyone with that feature value
|
|
84
|
+
NOT DONE: could then check if anyone is left.
|
|
85
|
+
"""
|
|
86
|
+
# TODO: when do we want to do this?
|
|
87
|
+
msg: list[str] = []
|
|
88
|
+
msg.append(f"Number of people: {self.people.count}.")
|
|
89
|
+
total_num_deleted = 0
|
|
90
|
+
for (
|
|
91
|
+
feature_name,
|
|
92
|
+
feature_value,
|
|
93
|
+
fv_counts,
|
|
94
|
+
) in self.features.feature_values_counts():
|
|
95
|
+
if fv_counts.max == 0: # we don't want any of these people
|
|
96
|
+
# pass the message in as deleting them might throw an exception
|
|
97
|
+
msg.append(f"Feature/value {feature_name}/{feature_value} full - deleting people...")
|
|
98
|
+
num_deleted, num_left = self.delete_all_with_feature_value(feature_name, feature_value)
|
|
99
|
+
# if no exception was thrown above add this bit to the end of the previous message
|
|
100
|
+
msg[-1] += f" Deleted {num_deleted}, {num_left} left."
|
|
101
|
+
total_num_deleted += num_deleted
|
|
102
|
+
# if the total number of people deleted is lots then we're probably doing a replacement selection, which means
|
|
103
|
+
# the 'remaining' file will be useless - remind the user of this!
|
|
104
|
+
if total_num_deleted >= self.people.count / 2:
|
|
105
|
+
msg.append(
|
|
106
|
+
">>> WARNING <<< That deleted MANY PEOPLE - are you doing a "
|
|
107
|
+
"replacement? If so your REMAINING FILE WILL BE USELESS!!!"
|
|
108
|
+
)
|
|
109
|
+
return msg
|
|
110
|
+
|
|
111
|
+
def select_person(self, person_key: str) -> list[str]:
|
|
112
|
+
"""
|
|
113
|
+
Selecting a person means:
|
|
114
|
+
- remove the person from our copy of People
|
|
115
|
+
- update the `selected` and `remaining` counts of the FeatureCollection
|
|
116
|
+
- if check_same_address is True, also remove household members (without adding to selected)
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
List of additional people removed due to same address (empty if check_same_address is False)
|
|
120
|
+
"""
|
|
121
|
+
# First, find household members if address checking is enabled (before removing the person)
|
|
122
|
+
household_members_removed = []
|
|
123
|
+
if self.check_same_address and self.check_same_address_columns:
|
|
124
|
+
household_members_removed = list(self.people.matching_address(person_key, self.check_same_address_columns))
|
|
125
|
+
|
|
126
|
+
# Handle the main person selection
|
|
127
|
+
person = self.people.get_person_dict(person_key)
|
|
128
|
+
for feature in self.features.feature_names:
|
|
129
|
+
self.features.remove_remaining(feature, person[feature])
|
|
130
|
+
self.features.add_selected(feature, person[feature])
|
|
131
|
+
self.people.remove(person_key)
|
|
132
|
+
|
|
133
|
+
# Then remove household members if any were found
|
|
134
|
+
for household_member_key in household_members_removed:
|
|
135
|
+
household_member = self.people.get_person_dict(household_member_key)
|
|
136
|
+
for feature in self.features.feature_names:
|
|
137
|
+
self.features.remove_remaining(feature, household_member[feature])
|
|
138
|
+
# Note: we don't call add_selected() for household members
|
|
139
|
+
self.people.remove(household_member_key)
|
|
140
|
+
|
|
141
|
+
return household_members_removed
|
|
142
|
+
|
|
143
|
+
def find_max_ratio_category(self) -> MaxRatioResult:
|
|
144
|
+
"""
|
|
145
|
+
Find the feature/value combination with the highest selection ratio.
|
|
146
|
+
|
|
147
|
+
The ratio is calculated as: (min - selected) / remaining
|
|
148
|
+
This represents how urgently we need people from this category.
|
|
149
|
+
Higher ratio = more urgent need (fewer people available relative to what we still need).
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
MaxRatioResult containing the feature name, value, and a random person index
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
SelectionError: If insufficient people remain to meet minimum requirements
|
|
156
|
+
"""
|
|
157
|
+
max_ratio = -100.0
|
|
158
|
+
result_feature_name = ""
|
|
159
|
+
result_feature_value = ""
|
|
160
|
+
random_person_index = -1
|
|
161
|
+
|
|
162
|
+
for (
|
|
163
|
+
feature_name,
|
|
164
|
+
feature_value,
|
|
165
|
+
fv_counts,
|
|
166
|
+
) in self.features.feature_values_counts():
|
|
167
|
+
# Check if we have insufficient people to meet minimum requirements
|
|
168
|
+
people_still_needed = fv_counts.min - fv_counts.selected
|
|
169
|
+
if fv_counts.selected < fv_counts.min and fv_counts.remaining < people_still_needed:
|
|
170
|
+
msg = (
|
|
171
|
+
f"SELECTION IMPOSSIBLE: Not enough people remaining in {feature_name}/{feature_value}. "
|
|
172
|
+
f"Need {people_still_needed} more, but only {fv_counts.remaining} remaining."
|
|
173
|
+
)
|
|
174
|
+
raise errors.SelectionError(msg)
|
|
175
|
+
|
|
176
|
+
# Skip categories with no remaining people or max = 0
|
|
177
|
+
if fv_counts.remaining == 0 or fv_counts.max == 0:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
# Calculate the priority ratio
|
|
181
|
+
ratio = people_still_needed / float(fv_counts.remaining)
|
|
182
|
+
|
|
183
|
+
# Track the highest ratio category
|
|
184
|
+
if ratio > max_ratio:
|
|
185
|
+
max_ratio = ratio
|
|
186
|
+
result_feature_name = feature_name
|
|
187
|
+
result_feature_value = feature_value
|
|
188
|
+
# from 1 to remaining
|
|
189
|
+
random_person_index = random_provider().randbelow(fv_counts.remaining) + 1
|
|
190
|
+
|
|
191
|
+
# If no valid category found, all categories must be at their max or have max=0
|
|
192
|
+
if not result_feature_name:
|
|
193
|
+
msg = "No valid categories found - all may be at maximum or have max=0"
|
|
194
|
+
raise errors.SelectionError(msg)
|
|
195
|
+
|
|
196
|
+
return MaxRatioResult(
|
|
197
|
+
feature_name=result_feature_name,
|
|
198
|
+
feature_value=result_feature_value,
|
|
199
|
+
random_person_index=random_person_index,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def handle_category_full_deletions(self, selected_person_data: dict[str, str]) -> list[str]:
|
|
203
|
+
"""
|
|
204
|
+
Check if any categories are now full after a selection and delete remaining people.
|
|
205
|
+
|
|
206
|
+
When a person is selected, some categories may reach their maximum quota.
|
|
207
|
+
This method identifies such categories and removes all remaining people from them.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
selected_person_data: Dictionary of the selected person's feature values
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
List of output messages about categories that became full and people deleted
|
|
214
|
+
|
|
215
|
+
Raises:
|
|
216
|
+
SelectionError: If deletions would violate minimum constraints
|
|
217
|
+
"""
|
|
218
|
+
output_messages = []
|
|
219
|
+
|
|
220
|
+
for (
|
|
221
|
+
feature_name,
|
|
222
|
+
feature_value,
|
|
223
|
+
fv_counts,
|
|
224
|
+
) in self.features.feature_values_counts():
|
|
225
|
+
if feature_value == selected_person_data[feature_name] and fv_counts.selected == fv_counts.max:
|
|
226
|
+
num_deleted, num_left = self.delete_all_with_feature_value(feature_name, feature_value)
|
|
227
|
+
if num_deleted > 0:
|
|
228
|
+
output_messages.append(
|
|
229
|
+
f"Category {feature_name}/{feature_value} full - deleted {num_deleted} people, {num_left} left."
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return output_messages
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def simple_add_selected(person_keys: Iterable[str], people: People, features: FeatureCollection) -> None:
|
|
236
|
+
"""
|
|
237
|
+
Just add the person to the selected counts for the feature values for that person.
|
|
238
|
+
Don't do the more complex handling of the full PeopleFeatures.add_selected()
|
|
239
|
+
"""
|
|
240
|
+
for person_key in person_keys:
|
|
241
|
+
person = people.get_person_dict(person_key)
|
|
242
|
+
for feature_name in features.feature_names:
|
|
243
|
+
features.add_selected(feature_name, person[feature_name])
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class WeightedSample:
|
|
247
|
+
def __init__(self, features: FeatureCollection) -> None:
|
|
248
|
+
"""
|
|
249
|
+
This produces a set of lists of feature values for each feature. Each value
|
|
250
|
+
is in the list `fv_counts.max` times - so a random choice with represent the max.
|
|
251
|
+
|
|
252
|
+
So if we had feature "ethnicity", value "white" w max 4, "asian" w max 3 and
|
|
253
|
+
"black" with max 2 we'd get:
|
|
254
|
+
|
|
255
|
+
["white", "white", "white", "white", "asian", "asian", "asian", "black", "black"]
|
|
256
|
+
|
|
257
|
+
Then making random choices from that list produces a weighted sample.
|
|
258
|
+
"""
|
|
259
|
+
self.weighted: dict[str, list[str]] = defaultdict(list)
|
|
260
|
+
for feature_name, value, fv_counts in features.feature_values_counts():
|
|
261
|
+
self.weighted[feature_name] += [value] * fv_counts.max
|
|
262
|
+
|
|
263
|
+
def value_for(self, feature_name: str) -> str:
|
|
264
|
+
# S311 is random numbers for crypto - but this is just for a sample file
|
|
265
|
+
return random_provider().choice(self.weighted[feature_name])
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def create_readable_sample_file(
|
|
269
|
+
features: FeatureCollection,
|
|
270
|
+
people_file: typing.TextIO,
|
|
271
|
+
number_people_example_file: int,
|
|
272
|
+
settings: Settings,
|
|
273
|
+
) -> None:
|
|
274
|
+
example_people_writer = csv.writer(
|
|
275
|
+
people_file,
|
|
276
|
+
delimiter=",",
|
|
277
|
+
quotechar='"',
|
|
278
|
+
quoting=csv.QUOTE_MINIMAL,
|
|
279
|
+
)
|
|
280
|
+
example_people_writer.writerow([settings.id_column, *settings.columns_to_keep, *features.feature_names])
|
|
281
|
+
weighted = WeightedSample(features)
|
|
282
|
+
for x in range(number_people_example_file):
|
|
283
|
+
row = [
|
|
284
|
+
f"p{x}",
|
|
285
|
+
*[f"{col} {x}" for col in settings.columns_to_keep],
|
|
286
|
+
*[weighted.value_for(f) for f in features.feature_names],
|
|
287
|
+
]
|
|
288
|
+
example_people_writer.writerow(row)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from attrs import define, field, validators
|
|
6
|
+
from cattrs import structure
|
|
7
|
+
|
|
8
|
+
SELECTION_ALGORITHMS = ("legacy", "maximin", "nash")
|
|
9
|
+
|
|
10
|
+
DEFAULT_SETTINGS = """
|
|
11
|
+
# #####################################################################
|
|
12
|
+
#
|
|
13
|
+
# IF YOU EDIT THIS FILE YOU NEED TO RESTART THE APPLICATION
|
|
14
|
+
#
|
|
15
|
+
# #####################################################################
|
|
16
|
+
|
|
17
|
+
# this is written in TOML - https://github.com/toml-lang/toml
|
|
18
|
+
|
|
19
|
+
# this is the name of the (unique) field for each person
|
|
20
|
+
id_column = "nationbuilder_id"
|
|
21
|
+
|
|
22
|
+
# if check_same_address is true, then no 2 people from the same address will be selected
|
|
23
|
+
# the comparison checks if the TWO fields listed here are the same for any person
|
|
24
|
+
check_same_address = true
|
|
25
|
+
check_same_address_columns = [
|
|
26
|
+
"primary_address1",
|
|
27
|
+
"zip_royal_mail"
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
max_attempts = 100
|
|
31
|
+
columns_to_keep = [
|
|
32
|
+
"first_name",
|
|
33
|
+
"last_name",
|
|
34
|
+
"mobile_number",
|
|
35
|
+
"email",
|
|
36
|
+
"primary_address1",
|
|
37
|
+
"primary_address2",
|
|
38
|
+
"primary_city",
|
|
39
|
+
"zip_royal_mail",
|
|
40
|
+
"tag_list",
|
|
41
|
+
"age",
|
|
42
|
+
"gender"
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
# selection_algorithm can either be "legacy", "maximin", "leximin", or "nash"
|
|
46
|
+
selection_algorithm = "maximin"
|
|
47
|
+
|
|
48
|
+
# random number seed - if this is NOT zero then it is used to set the random number generator seed
|
|
49
|
+
random_number_seed = 0
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check_columns_for_same_address(instance: "Settings", attribute: Any, value: Any) -> None:
|
|
54
|
+
if not isinstance(value, list):
|
|
55
|
+
raise TypeError("check_same_address_columns must be a LIST of strings")
|
|
56
|
+
if len(value) not in (0, 2):
|
|
57
|
+
raise ValueError("check_same_address_columns must be a list of ZERO OR TWO strings")
|
|
58
|
+
if not all(isinstance(element, str) for element in value):
|
|
59
|
+
raise TypeError("check_same_address_columns must be a list of STRINGS")
|
|
60
|
+
if len(value) == 0 and instance.check_same_address:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"check_same_address is TRUE but there are no columns listed to check! FIX THIS and RESTART this program!"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@define
|
|
67
|
+
class Settings:
|
|
68
|
+
id_column: str = field(validator=validators.instance_of(str))
|
|
69
|
+
columns_to_keep: list[str] = field()
|
|
70
|
+
check_same_address: bool = field(validator=validators.instance_of(bool))
|
|
71
|
+
check_same_address_columns: list[str] = field(validator=check_columns_for_same_address)
|
|
72
|
+
max_attempts: int = field(validator=validators.instance_of(int))
|
|
73
|
+
selection_algorithm: str = field()
|
|
74
|
+
random_number_seed: int = field(validator=validators.instance_of(int))
|
|
75
|
+
|
|
76
|
+
@columns_to_keep.validator
|
|
77
|
+
def check_columns_to_keep(self, attribute: Any, value: Any) -> None:
|
|
78
|
+
if not isinstance(value, list):
|
|
79
|
+
raise TypeError("columns_to_keep must be a LIST of strings")
|
|
80
|
+
if not all(isinstance(element, str) for element in value):
|
|
81
|
+
raise TypeError("columns_to_keep must be a list of STRINGS")
|
|
82
|
+
|
|
83
|
+
@selection_algorithm.validator
|
|
84
|
+
def check_selection_algorithm(self, attribute: Any, value: str) -> None:
|
|
85
|
+
if value not in SELECTION_ALGORITHMS:
|
|
86
|
+
raise ValueError(f"selection_algorithm {value} is not one of: {', '.join(SELECTION_ALGORITHMS)}")
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def load_from_file(
|
|
90
|
+
cls,
|
|
91
|
+
*,
|
|
92
|
+
settings_file_path: Path,
|
|
93
|
+
) -> tuple["Settings", str]:
|
|
94
|
+
messages: list[str] = []
|
|
95
|
+
if not settings_file_path.is_file():
|
|
96
|
+
with open(settings_file_path, "w", encoding="utf-8") as settings_file:
|
|
97
|
+
settings_file.write(DEFAULT_SETTINGS)
|
|
98
|
+
messages.append(
|
|
99
|
+
f"Wrote default settings to '{settings_file_path.absolute()}' "
|
|
100
|
+
"- if editing is required, restart this app."
|
|
101
|
+
)
|
|
102
|
+
with open(settings_file_path, "rb") as settings_file:
|
|
103
|
+
settings = tomllib.load(settings_file)
|
|
104
|
+
# you can't check an address if there is no info about which columns to check...
|
|
105
|
+
if settings["check_same_address"] is False:
|
|
106
|
+
messages.append(
|
|
107
|
+
"<b>WARNING</b>: Settings file is such that we do NOT check if respondents have same address."
|
|
108
|
+
)
|
|
109
|
+
settings["check_same_address_columns"] = []
|
|
110
|
+
return structure(settings, cls), "\n".join(messages)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import secrets
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from _typeshed import SupportsLenAndGetItem
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def print_ret(message: str) -> str:
|
|
12
|
+
"""Print and return a message for output collection."""
|
|
13
|
+
# TODO: should we replace this with logging or similar?
|
|
14
|
+
print(message)
|
|
15
|
+
return message
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def strip_str_int(value: str | int | float) -> str:
|
|
19
|
+
return str(value).strip()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StrippedDict:
|
|
23
|
+
"""
|
|
24
|
+
Wraps a dict, and whenever we get a value from it, we convert to str and
|
|
25
|
+
strip() whitespace
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, raw_dict: Mapping[str, str] | Mapping[str, str | int]) -> None:
|
|
29
|
+
self.raw_dict = raw_dict
|
|
30
|
+
|
|
31
|
+
def __getitem__(self, key: str) -> str:
|
|
32
|
+
return strip_str_int(self.raw_dict[key])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RandomProvider(ABC):
|
|
36
|
+
"""
|
|
37
|
+
This is something of a hack. Mostly we want to use the `secrets` module.
|
|
38
|
+
But for repeatable testing we might want to set the random.seed sometimes.
|
|
39
|
+
|
|
40
|
+
So we have a global `_random_provider` which can be switched between an
|
|
41
|
+
instance of this class that uses the `secrets` module and an instance that
|
|
42
|
+
uses `random` with a seed. The switch is done by the `set_random_provider()`
|
|
43
|
+
function.
|
|
44
|
+
|
|
45
|
+
Then every time we want some randomness, we call `random_provider()` to get
|
|
46
|
+
the current version of the global.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def uniform(cls, lower: float, upper: float) -> float: ...
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
@abstractmethod
|
|
55
|
+
def randbelow(cls, upper: int) -> int: ...
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def choice(cls, seq: "SupportsLenAndGetItem[str]") -> str: ...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class GenRandom(RandomProvider):
|
|
63
|
+
def __init__(self, seed: int) -> None:
|
|
64
|
+
random.seed(seed)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def uniform(cls, lower: float, upper: float) -> float:
|
|
68
|
+
return random.uniform(lower, upper) # noqa: S311
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def randbelow(cls, upper: int) -> int:
|
|
72
|
+
return random.randrange(upper) # noqa: S311
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def choice(cls, seq: "SupportsLenAndGetItem[str]") -> str:
|
|
76
|
+
return random.choice(seq) # noqa: S311
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class GenSecrets(RandomProvider):
|
|
80
|
+
@classmethod
|
|
81
|
+
def uniform(cls, lower: float, upper: float) -> float:
|
|
82
|
+
assert upper > lower
|
|
83
|
+
diff = upper - lower
|
|
84
|
+
rand_int = secrets.randbelow(1_000_000)
|
|
85
|
+
return lower + (rand_int * diff / 1_000_000)
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def randbelow(cls, upper: int) -> int:
|
|
89
|
+
return secrets.randbelow(upper)
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def choice(cls, seq: "SupportsLenAndGetItem[str]") -> str:
|
|
93
|
+
return secrets.choice(seq)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_random_provider: RandomProvider = GenSecrets()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def set_random_provider(seed: int | None = None) -> None:
|
|
100
|
+
global _random_provider
|
|
101
|
+
if seed:
|
|
102
|
+
_random_provider = GenRandom(seed)
|
|
103
|
+
_random_provider = GenSecrets()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def random_provider() -> RandomProvider:
|
|
107
|
+
return _random_provider
|