locust 2.37.15.dev12__py3-none-any.whl → 2.37.15.dev26__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.
locust/__init__.py CHANGED
@@ -27,6 +27,7 @@ from .debug import run_single_user
27
27
  from .event import Events
28
28
  from .shape import LoadTestShape
29
29
  from .user import wait_time
30
+ from .user.markov_taskset import MarkovTaskSet, transition, transitions
30
31
  from .user.sequential_taskset import SequentialTaskSet
31
32
  from .user.task import TaskSet, tag, task
32
33
  from .user.users import HttpUser, User
@@ -36,6 +37,9 @@ events = Events()
36
37
 
37
38
  __all__ = (
38
39
  "SequentialTaskSet",
40
+ "MarkovTaskSet",
41
+ "transition",
42
+ "transitions",
39
43
  "wait_time",
40
44
  "task",
41
45
  "tag",
locust/_version.py CHANGED
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '2.37.15.dev12'
21
- __version_tuple__ = version_tuple = (2, 37, 15, 'dev12')
20
+ __version__ = version = '2.37.15.dev26'
21
+ __version_tuple__ = version_tuple = (2, 37, 15, 'dev26')
@@ -0,0 +1,322 @@
1
+ from locust.exception import LocustError
2
+ from locust.user.task import TaskSetMeta
3
+ from locust.user.users import TaskSet
4
+
5
+ import logging
6
+ import random
7
+ from collections.abc import Callable
8
+
9
+ MarkovTaskT = Callable[..., None]
10
+
11
+
12
+ class NoMarkovTasksError(LocustError):
13
+ """Raised when a MarkovTaskSet class doesn't define any Markov tasks."""
14
+
15
+ pass
16
+
17
+
18
+ class InvalidTransitionError(LocustError):
19
+ """Raised when a transition in a MarkovTaskSet points to a non-existent task."""
20
+
21
+ pass
22
+
23
+
24
+ class NonMarkovTaskTransitionError(LocustError):
25
+ """Raised when a transition in a MarkovTaskSet points to a task that doesn't define transitions."""
26
+
27
+ pass
28
+
29
+
30
+ class MarkovTaskTagError(LocustError):
31
+ """Raised when tags are used with Markov tasks, which is unsupported."""
32
+
33
+ pass
34
+
35
+
36
+ def is_markov_task(task: MarkovTaskT):
37
+ """
38
+ Determines if a task is a Markov task by checking if it has transitions defined.
39
+
40
+ :param task: The task to check
41
+ :return: True if the task is a Markov task, False otherwise
42
+ """
43
+ return "transitions" in dir(task)
44
+
45
+
46
+ def transition(func_name: str, weight: int = 1) -> Callable[[MarkovTaskT], MarkovTaskT]:
47
+ """
48
+ Decorator for adding a single transition to a Markov task.
49
+
50
+ This decorator allows you to define a transition from one task to another in a MarkovTaskSet,
51
+ with an associated weight that determines the probability of taking this transition.
52
+
53
+ :param func_name: The name of the target task function
54
+ :param weight: The weight of this transition (default: 1)
55
+ :return: The decorated function with the transition added
56
+
57
+ Example::
58
+
59
+ class UserBehavior(MarkovTaskSet):
60
+ @transition('browse_products')
61
+ def index(self):
62
+ self.client.get("/")
63
+
64
+ @transition('index', weight=3)
65
+ @transition('product_page', weight=1)
66
+ def browse_products(self):
67
+ self.client.get("/products/")
68
+ """
69
+
70
+ def decorator_func(decorated):
71
+ if not hasattr(decorated, "transitions"):
72
+ decorated.transitions = {}
73
+
74
+ decorated.transitions[func_name] = weight
75
+ return decorated
76
+
77
+ return decorator_func
78
+
79
+
80
+ def transitions(weights: dict[str, int] | list[tuple[str, int] | str]) -> Callable[[MarkovTaskT], MarkovTaskT]:
81
+ """
82
+ Decorator for adding multiple transitions to a Markov task at once.
83
+
84
+ This decorator allows you to define multiple transitions from one task to others in a MarkovTaskSet,
85
+ with associated weights that determine the probability of taking each transition.
86
+
87
+ :param weights: Either a dictionary mapping function names to weights, or a list of function names
88
+ (with default weight 1) or (function_name, weight) tuples
89
+ :return: The decorated function with the transitions added
90
+
91
+ Example::
92
+
93
+ class UserBehavior(MarkovTaskSet):
94
+ @transitions({'checkout': 1, 'browse_products': 3, 'index': 2})
95
+ def view_cart(self):
96
+ self.client.get("/cart/")
97
+
98
+ @transitions([
99
+ ('index', 2), # with weight 2
100
+ 'browse_products' # with default weight 1
101
+ ])
102
+ def checkout(self):
103
+ self.client.get("/checkout/")
104
+ """
105
+
106
+ def parse_list_item(item: tuple[str, int] | str) -> tuple[str, int]:
107
+ return item if isinstance(item, tuple) else (item, 1)
108
+
109
+ def decorator_func(decorated):
110
+ if not hasattr(decorated, "transitions"):
111
+ decorated.transitions = {}
112
+
113
+ decorated.transitions.update(
114
+ weights
115
+ if isinstance(weights, dict)
116
+ else {func_name: weight for func_name, weight in map(parse_list_item, weights)}
117
+ )
118
+
119
+ return decorated
120
+
121
+ return decorator_func
122
+
123
+
124
+ def get_markov_tasks(class_dict: dict) -> list:
125
+ """
126
+ Extracts all Markov tasks from a class dictionary.
127
+
128
+ This function is used internally by MarkovTaskSetMeta to find all methods
129
+ that have been decorated with @transition or @transitions.
130
+
131
+ :param class_dict: Dictionary containing class attributes and methods
132
+ :return: List of functions that are Markov tasks
133
+ """
134
+ return [fn for fn in class_dict.values() if is_markov_task(fn)]
135
+
136
+
137
+ def to_weighted_list(transitions: dict):
138
+ return [name for name in transitions.keys() for _ in range(transitions[name])]
139
+
140
+
141
+ def validate_has_markov_tasks(tasks: list, classname: str):
142
+ """
143
+ Validates that a MarkovTaskSet has at least one Markov task.
144
+
145
+ This function is used internally during MarkovTaskSet validation to ensure
146
+ that the class has at least one method decorated with @transition or @transitions.
147
+
148
+ :param tasks: List of tasks to validate
149
+ :param classname: Name of the class being validated (for error messages)
150
+ :raises NoMarkovTasksError: If no Markov tasks are found
151
+ """
152
+ if not tasks:
153
+ raise NoMarkovTasksError(
154
+ f"No Markov tasks defined in class {classname}. Use the @transition(s) decorators to define some."
155
+ )
156
+
157
+
158
+ def validate_transitions(tasks: list, class_dict: dict, classname: str):
159
+ """
160
+ Validates that all transitions in Markov tasks point to existing Markov tasks.
161
+
162
+ This function checks two conditions for each transition:
163
+ 1. The target task exists in the class
164
+ 2. The target task is also a Markov task (has transitions defined)
165
+
166
+ :param tasks: List of Markov tasks to validate
167
+ :param class_dict: Dictionary containing class attributes and methods
168
+ :param classname: Name of the class being validated (for error messages)
169
+ :raises InvalidTransitionError: If a transition points to a non-existent task
170
+ :raises NonMarkovTaskTransitionError: If a transition points to a task that isn't a Markov task
171
+ """
172
+ for task in tasks:
173
+ for dest in task.transitions.keys():
174
+ dest_task = class_dict.get(dest)
175
+ if not dest_task:
176
+ raise InvalidTransitionError(
177
+ f"Transition to {dest} from {task.__name__} is invalid since no such element exists on class {classname}"
178
+ )
179
+ if not is_markov_task(dest_task):
180
+ raise NonMarkovTaskTransitionError(
181
+ f"{classname}.{dest} cannot be used as a target for a transition since it does not define any transitions of its own."
182
+ + f"Used as a transition from {task.__name__}."
183
+ )
184
+
185
+
186
+ def validate_no_unreachable_tasks(tasks: list, class_dict: dict, classname: str):
187
+ """
188
+ Checks for and warns about unreachable Markov tasks in a MarkovTaskSet.
189
+
190
+ This function uses depth-first search (DFS) starting from the first task to identify
191
+ all reachable tasks. It then warns about any tasks that cannot be reached from the
192
+ starting task through the defined transitions.
193
+
194
+ :param tasks: List of Markov tasks to validate
195
+ :param class_dict: Dictionary containing class attributes and methods
196
+ :param classname: Name of the class being validated (for warning messages)
197
+ :return: The original list of tasks
198
+ """
199
+ visited = set()
200
+
201
+ def dfs(task_name):
202
+ visited.add(task_name)
203
+ # Convert to a weighted list first to handle bad weights
204
+ for dest in set(to_weighted_list(class_dict.get(task_name).transitions)):
205
+ if dest not in visited:
206
+ dfs(dest)
207
+
208
+ dfs(tasks[0].__name__)
209
+ unreachable = set([task.__name__ for task in tasks]) - visited
210
+
211
+ if len(unreachable) > 0:
212
+ logging.warning(f"The following markov tasks are unreachable in class {classname}: {unreachable}")
213
+
214
+ return tasks
215
+
216
+
217
+ def validate_no_tags(task, classname: str):
218
+ """
219
+ Validates that Markov tasks don't have tags, which are unsupported.
220
+
221
+ Tags are not supported for MarkovTaskSet because they can make the Markov chain invalid
222
+ by potentially filtering out tasks that are part of the chain.
223
+
224
+ :param task: The task to validate
225
+ :param classname: Name of the class being validated (for error messages)
226
+ :raises MarkovTaskTagError: If the task has tags
227
+ """
228
+ if "locust_tag_set" in dir(task):
229
+ raise MarkovTaskTagError(
230
+ "Tags are unsupported for MarkovTaskSet since they can make the markov chain invalid. "
231
+ + f"Tags detected on {classname}.{task.__name__}: {task.locust_tag_set}"
232
+ )
233
+
234
+
235
+ def validate_task_name(decorated_func):
236
+ """
237
+ Validates that certain method names aren't used as Markov tasks.
238
+
239
+ This function checks for special method names that shouldn't be used as Markov tasks:
240
+ - "on_stop" and "on_start": Using these as Markov tasks will cause them to be called
241
+ both as tasks AND on stop/start, which is usually not what the user intended.
242
+ - "run": This method is used internally by Locust and must not be overridden or
243
+ annotated with transitions.
244
+
245
+ :param decorated_func: The function to validate
246
+ :raises Exception: If the function name is "run"
247
+ """
248
+ if decorated_func.__name__ in ["on_stop", "on_start"]:
249
+ logging.warning(
250
+ "You have tagged your on_stop/start function with @transition. This will make the method get called both as a step AND on stop/start."
251
+ ) # this is usually not what the user intended
252
+ if decorated_func.__name__ == "run":
253
+ raise Exception(
254
+ "TaskSet.run() is a method used internally by Locust, and you must not override it or annotate it with transitions"
255
+ )
256
+
257
+
258
+ def validate_markov_chain(tasks: list, class_dict: dict, classname: str):
259
+ """
260
+ Runs all validation functions on a Markov chain.
261
+
262
+ :param tasks: List of Markov tasks to validate
263
+ :param class_dict: Dictionary containing class attributes and methods
264
+ :param classname: Name of the class being validated (for error/warning messages)
265
+ :raises: Various exceptions if validation fails
266
+ """
267
+ validate_has_markov_tasks(tasks, classname)
268
+ validate_transitions(tasks, class_dict, classname)
269
+ validate_no_unreachable_tasks(tasks, class_dict, classname)
270
+ for task in tasks:
271
+ validate_task_name(task)
272
+ validate_no_tags(task, classname)
273
+
274
+
275
+ class MarkovTaskSetMeta(TaskSetMeta):
276
+ """
277
+ Meta class for MarkovTaskSet. It's used to allow MarkovTaskSet classes to specify
278
+ task execution using the @transition(s) decorators
279
+ """
280
+
281
+ def __new__(mcs, classname, bases, class_dict):
282
+ if not class_dict.get("abstract"):
283
+ class_dict["abstract"] = False
284
+
285
+ tasks = get_markov_tasks(class_dict)
286
+ validate_markov_chain(tasks, class_dict, classname)
287
+ class_dict["current"] = tasks[0]
288
+ for task in tasks:
289
+ task.transitions = to_weighted_list(task.transitions)
290
+
291
+ return type.__new__(mcs, classname, bases, class_dict)
292
+
293
+
294
+ class MarkovTaskSet(TaskSet, metaclass=MarkovTaskSetMeta):
295
+ """
296
+ Class defining a probabilistic sequence of functions that a User will execute.
297
+ The sequence is defined by a Markov Chain to describe a user's load.
298
+ It holds a current state and a set of possible transitions for each state.
299
+ Every transition as an associated weight that defines how likely it is to be taken.
300
+ """
301
+
302
+ current: Callable | TaskSet
303
+
304
+ abstract: bool = True
305
+ """If abstract is True, the class is meant to be subclassed, and the markov chain won't be validated"""
306
+
307
+ def __init__(self, *args, **kwargs):
308
+ super().__init__(*args, **kwargs)
309
+
310
+ def get_next_task(self):
311
+ """
312
+ Gets the next task to execute based on the current state and transitions.
313
+
314
+ :return: The current task to execute
315
+ """
316
+ fn = self.current
317
+
318
+ transitions = getattr(fn, "transitions")
319
+ next = random.choice(transitions)
320
+ self.current = getattr(self, next)
321
+
322
+ return fn
locust/user/task.py CHANGED
@@ -165,6 +165,17 @@ def get_tasks_from_base_classes(bases, class_dict):
165
165
  return new_tasks
166
166
 
167
167
 
168
+ def is_markov_taskset(task: type):
169
+ """
170
+ Determines if a task is a MarkovTaskSet by checking its meta class
171
+ Defined here to avoid circular imports.
172
+
173
+ :param task: The task to check
174
+ :return: True if the task is a MarkovTaskSet, False otherwise
175
+ """
176
+ return task.__class__.__name__ == "MarkovTaskSetMeta"
177
+
178
+
168
179
  def filter_tasks_by_tags(
169
180
  task_holder: type[TaskHolder],
170
181
  tags: set[str] | None = None,
@@ -188,7 +199,7 @@ def filter_tasks_by_tags(
188
199
  passing = True
189
200
  if hasattr(task, "tasks"):
190
201
  filter_tasks_by_tags(task, tags, exclude_tags, checked)
191
- passing = len(task.tasks) > 0
202
+ passing = len(task.tasks) > 0 or is_markov_taskset(task)
192
203
  else:
193
204
  if tags is not None:
194
205
  passing &= "locust_tag_set" in dir(task) and len(task.locust_tag_set & tags) > 0
@@ -200,7 +211,7 @@ def filter_tasks_by_tags(
200
211
  checked[task] = passing
201
212
 
202
213
  task_holder.tasks = new_tasks
203
- if not new_tasks:
214
+ if not new_tasks and not is_markov_taskset(task_holder):
204
215
  logging.warning(f"{task_holder.__name__} had no tasks left after filtering, instantiating it will fail!")
205
216
 
206
217
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: locust
3
- Version: 2.37.15.dev12
3
+ Version: 2.37.15.dev26
4
4
  Summary: Developer-friendly load testing framework
5
5
  Project-URL: homepage, https://locust.io/
6
6
  Project-URL: repository, https://github.com/locustio/locust
@@ -1,6 +1,6 @@
1
- locust/__init__.py,sha256=aWeuBPUxONjwNm1xp4v8L4BO14SuYLjscIiwJVX1Ui4,1746
1
+ locust/__init__.py,sha256=HadpgGidiyCDPSKwkxrk1Qw6eB7dTmftNJVftuJzAiw,1876
2
2
  locust/__main__.py,sha256=vBQ82334kX06ImDbFlPFgiBRiLIinwNk3z8Khs6hd74,31
3
- locust/_version.py,sha256=_YlubE9gTCHP091wm4_Af7DV6i7rfcKVTF2vY3UsFDc,530
3
+ locust/_version.py,sha256=DkB4ScrxmqVBUFNYdeyBj2Ul9CzpfLwmPNmJYF3JQe0,530
4
4
  locust/argument_parser.py,sha256=oO2Hi13tp_vRqOCQoA44l9qwP1Zeg-0KcnkynvwDqBM,33083
5
5
  locust/clients.py,sha256=o-277lWQdpmPnoRTdf3IQVNPQT8LMFDtPtuxbLHQIIs,19286
6
6
  locust/debug.py,sha256=7CCm8bIg44uGH2wqBlo1rXBzV2VzwPicLxLewz8r5CQ,5099
@@ -27,8 +27,9 @@ locust/rpc/protocol.py,sha256=n-rb3GZQcAlldYDj4E4GuFGylYj_26GSS5U29meft5Y,1282
27
27
  locust/rpc/zmqrpc.py,sha256=tMeLQiLII8QP29lAHGZsj5Pf5FsTL-X4wM0DrtR3ALw,3214
28
28
  locust/user/__init__.py,sha256=RgdRCflP2dIDcvwVMdhPQHAMhWVwQarQ9wWjF9HKk0w,151
29
29
  locust/user/inspectuser.py,sha256=KgrWHyE5jhK6or58R7soLRf-_st42AaQrR72qbiXw9E,2641
30
+ locust/user/markov_taskset.py,sha256=eESre6OacbP7nTzZFwxUe7TO4X4l7WqOAEETtDzsIfU,11784
30
31
  locust/user/sequential_taskset.py,sha256=SbrrGU9HV2nEWe6zQVtjymn8NgPISP7QSNoVdyoXjYg,2687
31
- locust/user/task.py,sha256=tVooUkBTb2BXmO21zKI9Fq5v2GxsZE5-za-y97SMhZg,16753
32
+ locust/user/task.py,sha256=k7g86WYm1I-tuNm2nVI-3TZegGhmo-pIk7pPFILk3yc,17147
32
33
  locust/user/users.py,sha256=c3Dtldg5ypA0rAE4eBn1mCzFtJ-Nq9BPblqM67wEjSY,10016
33
34
  locust/user/wait_time.py,sha256=bGRKMVx4lom75sX3POYJUa1CPeME2bEAXG6CEgxSO5U,2675
34
35
  locust/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -52,8 +53,8 @@ locust/webui/dist/assets/index-WplK3h0d.js,sha256=DbHI-zvMirYSuqu826NB8euV0N9dWQ
52
53
  locust/webui/dist/assets/terminal.gif,sha256=iw80LO2u0dnf4wpGfFJZauBeKTcSpw9iUfISXT2nEF4,75302
53
54
  locust/webui/dist/assets/testruns-dark.png,sha256=G4p2VZSBuuqF4neqUaPSshIp5OKQJ_Bvb69Luj6XuVs,125231
54
55
  locust/webui/dist/assets/testruns-light.png,sha256=JinGDiiBPOkhpfF-XCbmQqhRInqItrjrBTLKt5MlqVI,130301
55
- locust-2.37.15.dev12.dist-info/METADATA,sha256=toecH0e_K9k4wMv2Wx50c-Y13cijcu8HW-kScTVIlfA,9405
56
- locust-2.37.15.dev12.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
57
- locust-2.37.15.dev12.dist-info/entry_points.txt,sha256=RAdt8Ku-56m7bFjmdj-MBhbF6h4NX7tVODR9QNnOg0E,44
58
- locust-2.37.15.dev12.dist-info/licenses/LICENSE,sha256=5hnz-Vpj0Z3kSCQl0LzV2hT1TLc4LHcbpBp3Cy-EuyM,1110
59
- locust-2.37.15.dev12.dist-info/RECORD,,
56
+ locust-2.37.15.dev26.dist-info/METADATA,sha256=-TDAJIb1shcinEGrDmm9UnGGQcG4lfodngx10GWCeAc,9405
57
+ locust-2.37.15.dev26.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
58
+ locust-2.37.15.dev26.dist-info/entry_points.txt,sha256=RAdt8Ku-56m7bFjmdj-MBhbF6h4NX7tVODR9QNnOg0E,44
59
+ locust-2.37.15.dev26.dist-info/licenses/LICENSE,sha256=5hnz-Vpj0Z3kSCQl0LzV2hT1TLc4LHcbpBp3Cy-EuyM,1110
60
+ locust-2.37.15.dev26.dist-info/RECORD,,