locust-cloud 1.14.2__py3-none-any.whl → 1.14.3__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,108 @@
1
+ import logging
2
+ import os
3
+ import sys
4
+ import time
5
+
6
+ import jwt
7
+ import requests
8
+ from locust_cloud.common import VALID_REGIONS, __version__, get_api_url, read_cloud_config, write_cloud_config
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class ApiSession(requests.Session):
14
+ def __init__(self, non_interactive: bool) -> None:
15
+ super().__init__()
16
+ self.non_interactive = non_interactive
17
+
18
+ if non_interactive:
19
+ username = os.getenv("LOCUSTCLOUD_USERNAME")
20
+ password = os.getenv("LOCUSTCLOUD_PASSWORD")
21
+ region = os.getenv("LOCUSTCLOUD_REGION")
22
+
23
+ if not all([username, password, region]):
24
+ print(
25
+ "Running with --non-interactive requires that LOCUSTCLOUD_USERNAME, LOCUSTCLOUD_PASSWORD and LOCUSTCLOUD_REGION environment variables are set."
26
+ )
27
+ sys.exit(1)
28
+
29
+ if region not in VALID_REGIONS:
30
+ print("Environment variable LOCUSTCLOUD_REGION needs to be set to one of", ", ".join(VALID_REGIONS))
31
+ sys.exit(1)
32
+
33
+ self.__configure_for_region(region)
34
+ response = requests.post(
35
+ self.__login_url,
36
+ json={"username": username, "password": password},
37
+ headers={"X-Client-Version": __version__},
38
+ )
39
+ if not response.ok:
40
+ print(f"Authentication failed: {response.text}")
41
+ sys.exit(1)
42
+
43
+ self.__refresh_token = response.json()["refresh_token"]
44
+ id_token = response.json()["cognito_client_id_token"]
45
+
46
+ else:
47
+ config = read_cloud_config()
48
+
49
+ if config.refresh_token_expires < time.time() + 24 * 60 * 60:
50
+ message = "You need to authenticate before proceeding. Please run:\n locust-cloud --login"
51
+ print(message)
52
+ sys.exit(1)
53
+
54
+ assert config.region
55
+ self.__configure_for_region(config.region)
56
+ self.__refresh_token = config.refresh_token
57
+ id_token = config.id_token
58
+
59
+ assert id_token
60
+
61
+ decoded = jwt.decode(id_token, options={"verify_signature": False})
62
+ self.__expiry_time = decoded["exp"] - 60 # Refresh 1 minute before expiry
63
+ self.headers["Authorization"] = f"Bearer {id_token}"
64
+
65
+ self.__sub = decoded["sub"]
66
+ self.headers["X-Client-Version"] = __version__
67
+
68
+ def __configure_for_region(self, region: str) -> None:
69
+ self.__region = region
70
+ self.api_url = get_api_url(region)
71
+ self.__login_url = f"{self.api_url}/auth/login"
72
+
73
+ logger.debug(f"Lambda url: {self.api_url}")
74
+
75
+ def __ensure_valid_authorization_header(self) -> None:
76
+ if self.__expiry_time > time.time():
77
+ return
78
+
79
+ logger.info(f"Authenticating ({self.__region}, v{__version__})")
80
+
81
+ response = requests.post(
82
+ self.__login_url,
83
+ json={"user_sub_id": self.__sub, "refresh_token": self.__refresh_token},
84
+ headers={"X-Client-Version": __version__},
85
+ )
86
+
87
+ if not response.ok:
88
+ logger.error(f"Authentication failed: {response.text}")
89
+ sys.exit(1)
90
+
91
+ # TODO: Technically the /login endpoint can return a challenge for you
92
+ # to change your password.
93
+ # Now that we have a web based login flow we should force them to
94
+ # do a locust-cloud --login if we get that.
95
+
96
+ id_token = response.json()["cognito_client_id_token"]
97
+ decoded = jwt.decode(id_token, options={"verify_signature": False})
98
+ self.__expiry_time = decoded["exp"] - 60 # Refresh 1 minute before expiry
99
+ self.headers["Authorization"] = f"Bearer {id_token}"
100
+
101
+ if not self.non_interactive:
102
+ config = read_cloud_config()
103
+ config.id_token = id_token
104
+ write_cloud_config(config)
105
+
106
+ def request(self, method, url, *args, **kwargs) -> requests.Response:
107
+ self.__ensure_valid_authorization_header()
108
+ return super().request(method, f"{self.api_url}{url}", *args, **kwargs)
locust_cloud/args.py ADDED
@@ -0,0 +1,225 @@
1
+ import argparse
2
+ import base64
3
+ import gzip
4
+ import io
5
+ import os
6
+ import pathlib
7
+ import tomllib
8
+ from argparse import ArgumentTypeError, Namespace
9
+ from collections import OrderedDict
10
+ from collections.abc import Callable, Generator
11
+ from typing import IO, Any, cast
12
+ from zipfile import ZipFile
13
+
14
+ import configargparse
15
+
16
+ CWD = pathlib.Path.cwd()
17
+
18
+
19
+ class LocustTomlConfigParser(configargparse.TomlConfigParser):
20
+ def parse(self, stream: IO[str]) -> OrderedDict[str, Any]:
21
+ try:
22
+ config = tomllib.loads(stream.read())
23
+ except Exception as e:
24
+ raise configargparse.ConfigFileParserException(f"Couldn't parse TOML file: {e}")
25
+
26
+ result: OrderedDict[str, Any] = OrderedDict()
27
+
28
+ for section in self.sections:
29
+ data = configargparse.get_toml_section(config, section)
30
+ if data:
31
+ for key, value in data.items():
32
+ if isinstance(value, list):
33
+ result[key] = value
34
+ elif value is not None:
35
+ result[key] = str(value)
36
+ break
37
+
38
+ return result
39
+
40
+
41
+ def pipe(value: Any, *functions: Callable) -> Any:
42
+ for function in functions:
43
+ value = function(value)
44
+
45
+ return value
46
+
47
+
48
+ def valid_extra_files_path(file_path: str) -> pathlib.Path:
49
+ p = pathlib.Path(file_path).resolve()
50
+
51
+ if not CWD in p.parents:
52
+ raise ArgumentTypeError(f"Can only reference files under current working directory: {CWD}")
53
+ if not p.exists():
54
+ raise ArgumentTypeError(f"File not found: {file_path}")
55
+ return p
56
+
57
+
58
+ def transfer_encode(file_name: str, stream: IO[bytes]) -> dict[str, str]:
59
+ return {
60
+ "filename": file_name,
61
+ "data": pipe(
62
+ stream.read(),
63
+ gzip.compress,
64
+ base64.b64encode,
65
+ bytes.decode,
66
+ ),
67
+ }
68
+
69
+
70
+ def transfer_encoded_file(file_path: str) -> dict[str, str]:
71
+ try:
72
+ with open(file_path, "rb") as f:
73
+ return transfer_encode(file_path, f)
74
+ except FileNotFoundError:
75
+ raise ArgumentTypeError(f"File not found: {file_path}")
76
+
77
+
78
+ def expanded(paths: list[pathlib.Path]) -> Generator[pathlib.Path, None, None]:
79
+ for path in paths:
80
+ if path.is_dir():
81
+ for root, _, file_names in os.walk(path):
82
+ for file_name in file_names:
83
+ yield pathlib.Path(root) / file_name
84
+ else:
85
+ yield path
86
+
87
+
88
+ def transfer_encoded_extra_files(paths: list[pathlib.Path]) -> dict[str, str]:
89
+ buffer = io.BytesIO()
90
+
91
+ with ZipFile(buffer, "w") as zf:
92
+ for path in set(expanded(paths)):
93
+ zf.write(path.relative_to(CWD))
94
+
95
+ buffer.seek(0)
96
+ return transfer_encode("extra-files.zip", buffer)
97
+
98
+
99
+ class MergeToTransferEncodedZip(argparse.Action):
100
+ def __call__(self, parser, namespace, values, option_string=None):
101
+ paths = cast(list[pathlib.Path], values)
102
+ value = transfer_encoded_extra_files(paths)
103
+ setattr(namespace, self.dest, value)
104
+
105
+
106
+ parser = configargparse.ArgumentParser(
107
+ default_config_files=[
108
+ "~/.locust.conf",
109
+ "locust.conf",
110
+ "pyproject.toml",
111
+ "~/.cloud.conf",
112
+ "cloud.conf",
113
+ ],
114
+ auto_env_var_prefix="LOCUSTCLOUD_",
115
+ formatter_class=configargparse.RawTextHelpFormatter,
116
+ config_file_parser_class=configargparse.CompositeConfigParser(
117
+ [
118
+ LocustTomlConfigParser(["tool.locust"]),
119
+ configargparse.DefaultConfigFileParser,
120
+ ]
121
+ ),
122
+ description="""Launches a distributed Locust runs on locust.cloud infrastructure.
123
+
124
+ Example: locust-cloud -f my_locustfile.py --users 1000 ...""",
125
+ epilog="""Any parameters not listed here are forwarded to locust master unmodified, so go ahead and use things like --users, --host, --run-time, ...
126
+ Locust config can also be set using config file (~/.locust.conf, locust.conf, pyproject.toml, ~/.cloud.conf or cloud.conf).
127
+ Parameters specified on command line override env vars, which in turn override config files.""",
128
+ add_config_file_help=False,
129
+ add_env_var_help=False,
130
+ add_help=False,
131
+ )
132
+ parser.add_argument(
133
+ "-h",
134
+ "--help",
135
+ action="help",
136
+ help=configargparse.SUPPRESS,
137
+ )
138
+ parser.add_argument(
139
+ "-V",
140
+ "--version",
141
+ action="store_true",
142
+ help=configargparse.SUPPRESS,
143
+ )
144
+ parser.add_argument(
145
+ "-f",
146
+ "--locustfile",
147
+ metavar="<filename>",
148
+ default="locustfile.py",
149
+ help="The Python file that contains your test. Defaults to 'locustfile.py'.",
150
+ env_var="LOCUST_LOCUSTFILE",
151
+ type=transfer_encoded_file,
152
+ )
153
+ parser.add_argument(
154
+ "-u",
155
+ "--users",
156
+ type=int,
157
+ default=1,
158
+ help="Number of users to launch. This is the same as the regular Locust argument, but also affects how many workers to launch.",
159
+ env_var="LOCUST_USERS",
160
+ )
161
+ advanced = parser.add_argument_group("advanced")
162
+ advanced.add_argument(
163
+ "--loglevel",
164
+ "-L",
165
+ type=str.upper,
166
+ help="Set --loglevel DEBUG for extra info.",
167
+ choices=["DEBUG", "INFO", "WARNING", "ERROR"],
168
+ default="INFO",
169
+ )
170
+ advanced.add_argument(
171
+ "--requirements",
172
+ type=transfer_encoded_file,
173
+ help="Optional requirements.txt file that contains your external libraries.",
174
+ )
175
+ advanced.add_argument(
176
+ "--login",
177
+ action="store_true",
178
+ default=False,
179
+ help="Launch an interactive session to authenticate your user.\nOnce completed your credentials will be stored and automatically refreshed for quite a long time.\nOnce those expires you will be prompted to perform another login.",
180
+ )
181
+ advanced.add_argument(
182
+ "--non-interactive",
183
+ action="store_true",
184
+ default=False,
185
+ help="This can be set when, for example, running in a CI/CD environment to ensure no interactive steps while executing.\nRequires that LOCUSTCLOUD_USERNAME, LOCUSTCLOUD_PASSWORD and LOCUSTCLOUD_REGION environment variables are set.",
186
+ )
187
+ parser.add_argument(
188
+ "--workers",
189
+ type=int,
190
+ help="Number of workers to use for the deployment. Defaults to number of users divided by 500, but the default may be customized for your account.",
191
+ default=None,
192
+ )
193
+ parser.add_argument(
194
+ "--delete",
195
+ action="store_true",
196
+ help="Delete a running cluster. Useful if locust-cloud was killed/disconnected or if there was an error.",
197
+ )
198
+ parser.add_argument(
199
+ "--image-tag",
200
+ type=str,
201
+ default=None,
202
+ help=configargparse.SUPPRESS, # overrides the locust-cloud docker image tag. for internal use
203
+ )
204
+ parser.add_argument(
205
+ "--mock-server",
206
+ action="store_true",
207
+ default=False,
208
+ help="Start a demo mock service and set --host parameter to point Locust towards it",
209
+ )
210
+ parser.add_argument(
211
+ "--profile",
212
+ type=str,
213
+ help="Set a profile to group the testruns together",
214
+ )
215
+ parser.add_argument(
216
+ "--extra-files",
217
+ action=MergeToTransferEncodedZip,
218
+ nargs="*",
219
+ type=valid_extra_files_path,
220
+ help="A list of extra files or directories to upload. Space-separated, e.g. --extra-files testdata.csv *.py my-directory/",
221
+ )
222
+
223
+
224
+ def parse_known_args(args: Any | None = None) -> tuple[Namespace, list[str]]:
225
+ return parser.parse_known_args(args)
locust_cloud/cloud.py CHANGED
@@ -1,625 +1,31 @@
1
- import base64
2
- import gzip
3
- import importlib.metadata
4
- import io
5
- import json
6
1
  import logging
7
2
  import os
8
- import pathlib
9
3
  import sys
10
- import threading
11
- import time
12
- import tomllib
13
- import urllib.parse
14
- import webbrowser
15
- from argparse import ArgumentTypeError, Namespace
16
- from collections import OrderedDict
17
- from collections.abc import Generator
18
- from dataclasses import dataclass
19
- from typing import IO, Any
20
- from zipfile import ZipFile
21
4
 
22
- import configargparse
23
- import jwt
24
- import platformdirs
25
5
  import requests
26
- import socketio
27
- import socketio.exceptions
6
+ from locust_cloud.apisession import ApiSession
7
+ from locust_cloud.args import parse_known_args
8
+ from locust_cloud.common import __version__
9
+ from locust_cloud.web_login import web_login
10
+ from locust_cloud.websocket import SessionMismatchError, Websocket, WebsocketTimeout
28
11
 
29
- __version__ = importlib.metadata.version("locust-cloud")
30
- CWD = pathlib.Path.cwd()
31
-
32
-
33
- class LocustTomlConfigParser(configargparse.TomlConfigParser):
34
- def parse(self, stream: IO[str]) -> OrderedDict[str, Any]:
35
- try:
36
- config = tomllib.loads(stream.read())
37
- except Exception as e:
38
- raise configargparse.ConfigFileParserException(f"Couldn't parse TOML file: {e}")
39
-
40
- result: OrderedDict[str, Any] = OrderedDict()
41
-
42
- for section in self.sections:
43
- data = configargparse.get_toml_section(config, section)
44
- if data:
45
- for key, value in data.items():
46
- if isinstance(value, list):
47
- result[key] = value
48
- elif value is not None:
49
- result[key] = str(value)
50
- break
51
-
52
- return result
53
-
54
-
55
- def valid_extra_files_path(file_path: str) -> pathlib.Path:
56
- p = pathlib.Path(file_path).resolve()
57
-
58
- if not CWD in p.parents:
59
- raise ArgumentTypeError(f"Can only reference files under current working directory: {CWD}")
60
- if not p.exists():
61
- raise ArgumentTypeError(f"File not found: {file_path}")
62
- return p
63
-
64
-
65
- def transfer_encode(file_name: str, stream: IO[bytes]) -> dict[str, str]:
66
- return {
67
- "filename": file_name,
68
- "data": base64.b64encode(gzip.compress(stream.read())).decode(),
69
- }
70
-
71
-
72
- def transfer_encoded_file(file_path: str) -> dict[str, str]:
73
- try:
74
- with open(file_path, "rb") as f:
75
- return transfer_encode(file_path, f)
76
- except FileNotFoundError:
77
- raise ArgumentTypeError(f"File not found: {file_path}")
78
-
79
-
80
- def transfer_encoded_extra_files(paths: list[pathlib.Path]) -> dict[str, str]:
81
- def expanded(paths: list[pathlib.Path]) -> Generator[pathlib.Path, None, None]:
82
- for path in paths:
83
- if path.is_dir():
84
- for root, _, file_names in os.walk(path):
85
- for file_name in file_names:
86
- yield pathlib.Path(root) / file_name
87
- else:
88
- yield path
89
-
90
- buffer = io.BytesIO()
91
-
92
- with ZipFile(buffer, "w") as zf:
93
- for path in set(expanded(paths)):
94
- zf.write(path.relative_to(CWD))
95
-
96
- buffer.seek(0)
97
- return transfer_encode("extra-files.zip", buffer)
98
-
99
-
100
- parser = configargparse.ArgumentParser(
101
- default_config_files=[
102
- "~/.locust.conf",
103
- "locust.conf",
104
- "pyproject.toml",
105
- "~/.cloud.conf",
106
- "cloud.conf",
107
- ],
108
- auto_env_var_prefix="LOCUSTCLOUD_",
109
- formatter_class=configargparse.RawTextHelpFormatter,
110
- config_file_parser_class=configargparse.CompositeConfigParser(
111
- [
112
- LocustTomlConfigParser(["tool.locust"]),
113
- configargparse.DefaultConfigFileParser,
114
- ]
115
- ),
116
- description="""Launches a distributed Locust runs on locust.cloud infrastructure.
117
-
118
- Example: locust-cloud -f my_locustfile.py --users 1000 ...""",
119
- epilog="""Any parameters not listed here are forwarded to locust master unmodified, so go ahead and use things like --users, --host, --run-time, ...
120
- Locust config can also be set using config file (~/.locust.conf, locust.conf, pyproject.toml, ~/.cloud.conf or cloud.conf).
121
- Parameters specified on command line override env vars, which in turn override config files.""",
122
- add_config_file_help=False,
123
- add_env_var_help=False,
124
- add_help=False,
125
- )
126
- parser.add_argument(
127
- "-h",
128
- "--help",
129
- action="help",
130
- help=configargparse.SUPPRESS,
131
- )
132
- parser.add_argument(
133
- "-V",
134
- "--version",
135
- action="store_true",
136
- help=configargparse.SUPPRESS,
137
- )
138
- parser.add_argument(
139
- "-f",
140
- "--locustfile",
141
- metavar="<filename>",
142
- default="locustfile.py",
143
- help="The Python file that contains your test. Defaults to 'locustfile.py'.",
144
- env_var="LOCUST_LOCUSTFILE",
145
- type=transfer_encoded_file,
146
- )
147
- parser.add_argument(
148
- "-u",
149
- "--users",
150
- type=int,
151
- default=1,
152
- help="Number of users to launch. This is the same as the regular Locust argument, but also affects how many workers to launch.",
153
- env_var="LOCUST_USERS",
154
- )
155
- advanced = parser.add_argument_group("advanced")
156
- advanced.add_argument(
157
- "--loglevel",
158
- "-L",
159
- type=str,
160
- help="Set --loglevel DEBUG for extra info.",
161
- default="INFO",
162
- )
163
- advanced.add_argument(
164
- "--requirements",
165
- type=transfer_encoded_file,
166
- help="Optional requirements.txt file that contains your external libraries.",
167
- )
168
- advanced.add_argument(
169
- "--login",
170
- action="store_true",
171
- default=False,
172
- help="Launch an interactive session to authenticate your user.\nOnce completed your credentials will be stored and automatically refreshed for quite a long time.\nOnce those expires you will be prompted to perform another login.",
173
- )
174
- advanced.add_argument(
175
- "--non-interactive",
176
- action="store_true",
177
- default=False,
178
- help="This can be set when, for example, running in a CI/CD environment to ensure no interactive steps while executing.\nRequires that LOCUSTCLOUD_USERNAME, LOCUSTCLOUD_PASSWORD and LOCUSTCLOUD_REGION environment variables are set.",
179
- )
180
- parser.add_argument(
181
- "--workers",
182
- type=int,
183
- help="Number of workers to use for the deployment. Defaults to number of users divided by 500, but the default may be customized for your account.",
184
- default=None,
185
- )
186
- parser.add_argument(
187
- "--delete",
188
- action="store_true",
189
- help="Delete a running cluster. Useful if locust-cloud was killed/disconnected or if there was an error.",
190
- )
191
- parser.add_argument(
192
- "--image-tag",
193
- type=str,
194
- default=None,
195
- help=configargparse.SUPPRESS, # overrides the locust-cloud docker image tag. for internal use
196
- )
197
- parser.add_argument(
198
- "--mock-server",
199
- action="store_true",
200
- default=False,
201
- help="Start a demo mock service and set --host parameter to point Locust towards it",
202
- )
203
- parser.add_argument(
204
- "--profile",
205
- type=str,
206
- help="Set a profile to group the testruns together",
207
- )
208
- parser.add_argument(
209
- "--extra-files",
210
- nargs="*",
211
- type=valid_extra_files_path,
212
- help="A list of extra files or directories to upload. Space-separated, e.g. --extra-files testdata.csv *.py my-directory/",
213
- )
214
-
215
- parsed_args: tuple[Namespace, list[str]] = parser.parse_known_args()
216
- options, locust_options = parsed_args
217
-
218
- logging.basicConfig(
219
- format="[LOCUST-CLOUD] %(levelname)s: %(message)s",
220
- level=options.loglevel.upper(),
221
- )
222
12
  logger = logging.getLogger(__name__)
223
- # Restore log level for other libs. Yes, this can be done more nicely
224
- logging.getLogger("requests").setLevel(logging.INFO)
225
- logging.getLogger("urllib3").setLevel(logging.INFO)
226
-
227
- cloud_conf_file = pathlib.Path(platformdirs.user_config_dir(appname="locust-cloud")) / "config"
228
- valid_regions = ["us-east-1", "eu-north-1"]
229
-
230
-
231
- def get_api_url(region):
232
- return os.environ.get("LOCUSTCLOUD_DEPLOYER_URL", f"https://api.{region}.locust.cloud/1")
233
-
234
13
 
235
- @dataclass
236
- class CloudConfig:
237
- id_token: str | None = None
238
- refresh_token: str | None = None
239
- refresh_token_expires: int = 0
240
- region: str | None = None
241
14
 
242
-
243
- def read_cloud_config() -> CloudConfig:
244
- if cloud_conf_file.exists():
245
- with open(cloud_conf_file) as f:
246
- return CloudConfig(**json.load(f))
247
-
248
- return CloudConfig()
249
-
250
-
251
- def write_cloud_config(config: CloudConfig) -> None:
252
- cloud_conf_file.parent.mkdir(parents=True, exist_ok=True)
253
-
254
- with open(cloud_conf_file, "w") as f:
255
- json.dump(config.__dict__, f)
256
-
257
-
258
- def web_login() -> None:
259
- print("Enter the number for the region to authenticate against")
260
- print()
261
- for i, valid_region in enumerate(valid_regions, start=1):
262
- print(f" {i}. {valid_region}")
263
- print()
264
- choice = input("> ")
265
- try:
266
- region_index = int(choice) - 1
267
- assert 0 <= region_index < len(valid_regions)
268
- except (ValueError, AssertionError):
269
- print(f"Not a valid choice: '{choice}'")
270
- sys.exit(1)
271
-
272
- region = valid_regions[region_index]
273
-
274
- try:
275
- response = requests.post(f"{get_api_url(region)}/cli-auth")
276
- response.raise_for_status()
277
- response_data = response.json()
278
- authentication_url = response_data["authentication_url"]
279
- result_url = response_data["result_url"]
280
- except Exception as e:
281
- print("Something went wrong trying to authorize the locust-cloud CLI:", str(e))
282
- sys.exit(1)
283
-
284
- message = f"""
285
- Attempting to automatically open the SSO authorization page in your default browser.
286
- If the browser does not open or you wish to use a different device to authorize this request, open the following URL:
287
-
288
- {authentication_url}
289
- """.strip()
290
- print()
291
- print(message)
292
-
293
- webbrowser.open_new_tab(authentication_url)
294
-
295
- while True: # Should there be some kind of timeout?
296
- response = requests.get(result_url)
297
-
298
- if not response.ok:
299
- print("Oh no!")
300
- print(response.text)
301
- sys.exit(1)
302
-
303
- data = response.json()
304
-
305
- if data["state"] == "pending":
306
- time.sleep(1)
307
- continue
308
- elif data["state"] == "failed":
309
- print(f"\nFailed to authorize CLI: {data['reason']}")
310
- sys.exit(1)
311
- elif data["state"] == "authorized":
312
- print("\nAuthorization succeded")
313
- break
314
- else:
315
- print("\nGot unexpected response when authorizing CLI")
316
- sys.exit(1)
317
-
318
- config = CloudConfig(
319
- id_token=data["id_token"],
320
- refresh_token=data["refresh_token"],
321
- refresh_token_expires=data["refresh_token_expires"],
322
- region=region,
15
+ def configure_logging(loglevel: str) -> None:
16
+ logging.basicConfig(
17
+ format="[LOCUST-CLOUD] %(levelname)s: %(message)s",
18
+ level=loglevel,
323
19
  )
324
- write_cloud_config(config)
325
-
326
-
327
- class ApiSession(requests.Session):
328
- def __init__(self) -> None:
329
- super().__init__()
330
-
331
- if options.non_interactive:
332
- username = os.getenv("LOCUSTCLOUD_USERNAME")
333
- password = os.getenv("LOCUSTCLOUD_PASSWORD")
334
- region = os.getenv("LOCUSTCLOUD_REGION")
335
-
336
- if not all([username, password, region]):
337
- print(
338
- "Running with --non-interactive requires that LOCUSTCLOUD_USERNAME, LOCUSTCLOUD_PASSWORD and LOCUSTCLOUD_REGION environment variables are set."
339
- )
340
- sys.exit(1)
341
-
342
- if region not in valid_regions:
343
- print("Environment variable LOCUSTCLOUD_REGION needs to be set to one of", ", ".join(valid_regions))
344
- sys.exit(1)
345
-
346
- self.__configure_for_region(region)
347
- response = requests.post(
348
- self.__login_url,
349
- json={"username": username, "password": password},
350
- headers={"X-Client-Version": __version__},
351
- )
352
- if not response.ok:
353
- print(f"Authentication failed: {response.text}")
354
- sys.exit(1)
355
-
356
- self.__refresh_token = response.json()["refresh_token"]
357
- id_token = response.json()["cognito_client_id_token"]
358
-
359
- else:
360
- config = read_cloud_config()
361
-
362
- if config.refresh_token_expires < time.time() + 24 * 60 * 60:
363
- message = "You need to authenticate before proceeding. Please run:\n locust-cloud --login"
364
- print(message)
365
- sys.exit(1)
366
-
367
- assert config.region
368
- self.__configure_for_region(config.region)
369
- self.__refresh_token = config.refresh_token
370
- id_token = config.id_token
371
-
372
- assert id_token
373
-
374
- decoded = jwt.decode(id_token, options={"verify_signature": False})
375
- self.__expiry_time = decoded["exp"] - 60 # Refresh 1 minute before expiry
376
- self.headers["Authorization"] = f"Bearer {id_token}"
377
-
378
- self.__sub = decoded["sub"]
379
- self.headers["X-Client-Version"] = __version__
380
-
381
- def __configure_for_region(self, region: str) -> None:
382
- self.__region = region
383
- self.api_url = get_api_url(region)
384
- self.__login_url = f"{self.api_url}/auth/login"
385
-
386
- logger.debug(f"Lambda url: {self.api_url}")
387
-
388
- def __ensure_valid_authorization_header(self) -> None:
389
- if self.__expiry_time > time.time():
390
- return
391
-
392
- logger.info(f"Authenticating ({self.__region}, v{__version__})")
393
-
394
- response = requests.post(
395
- self.__login_url,
396
- json={"user_sub_id": self.__sub, "refresh_token": self.__refresh_token},
397
- headers={"X-Client-Version": __version__},
398
- )
399
-
400
- if not response.ok:
401
- logger.error(f"Authentication failed: {response.text}")
402
- sys.exit(1)
403
-
404
- # TODO: Technically the /login endpoint can return a challenge for you
405
- # to change your password.
406
- # Now that we have a web based login flow we should force them to
407
- # do a locust-cloud --login if we get that.
408
-
409
- id_token = response.json()["cognito_client_id_token"]
410
- decoded = jwt.decode(id_token, options={"verify_signature": False})
411
- self.__expiry_time = decoded["exp"] - 60 # Refresh 1 minute before expiry
412
- self.headers["Authorization"] = f"Bearer {id_token}"
413
-
414
- if not options.non_interactive:
415
- config = read_cloud_config()
416
- config.id_token = id_token
417
- write_cloud_config(config)
418
-
419
- def request(self, method, url, *args, **kwargs) -> requests.Response:
420
- self.__ensure_valid_authorization_header()
421
- return super().request(method, f"{self.api_url}{url}", *args, **kwargs)
422
-
423
-
424
- class SessionMismatchError(Exception):
425
- pass
426
-
427
-
428
- class WebsocketTimeout(Exception):
429
- pass
430
-
431
-
432
- class Websocket:
433
- def __init__(self) -> None:
434
- """
435
- This class was created to encapsulate all the logic involved in the websocket implementation.
436
- The behaviour of the socketio client once a connection has been established
437
- is to try to reconnect forever if the connection is lost.
438
- The way this can be canceled is by setting the _reconnect_abort (threading.Event) on the client
439
- in which case it will simply proceed with shutting down without giving any indication of an error.
440
- This class handles timeouts for connection attempts as well as some logic around when the
441
- socket can be shut down. See descriptions on the methods for further details.
442
- """
443
- self.__shutdown_allowed = threading.Event()
444
- self.__timeout_on_disconnect = True
445
- self.initial_connect_timeout = 120
446
- self.reconnect_timeout = 10
447
- self.wait_timeout = 0
448
- self.exception: None | Exception = None
449
-
450
- self.sio = socketio.Client(handle_sigint=False)
451
- self.sio._reconnect_abort = threading.Event()
452
- # The _reconnect_abort value on the socketio client will be populated with a newly created threading.Event if it's not already set.
453
- # There is no way to set this by passing it in the constructor.
454
- # This event is the only way to interupt the retry logic when the connection is attempted.
455
-
456
- self.sio.on("connect", self.__on_connect)
457
- self.sio.on("disconnect", self.__on_disconnect)
458
- self.sio.on("connect_error", self.__on_connect_error)
459
- self.sio.on("events", self.__on_events)
460
-
461
- self.__processed_events: set[int] = set()
462
-
463
- def __set_connection_timeout(self, timeout) -> None:
464
- """
465
- Start a threading.Timer that will set the threading.Event on the socketio client
466
- that aborts any further attempts to reconnect, sets an exception on the websocket
467
- that will be raised from the wait method and the threading.Event __shutdown_allowed
468
- on the websocket that tells the wait method that it should stop blocking.
469
- """
470
-
471
- def _timeout():
472
- logger.debug(f"Websocket connection timed out after {timeout} seconds")
473
- self.sio._reconnect_abort.set()
474
- self.exception = WebsocketTimeout("Timed out connecting to locust master")
475
- self.__shutdown_allowed.set()
476
-
477
- self.__connect_timeout_timer = threading.Timer(timeout, _timeout)
478
- self.__connect_timeout_timer.daemon = True
479
- logger.debug(f"Setting websocket connection timeout to {timeout} seconds")
480
- self.__connect_timeout_timer.start()
481
-
482
- def connect(self, url, *, auth) -> None:
483
- """
484
- Send along retry=True when initiating the socketio client connection
485
- to make it use it's builtin logic for retrying failed connections that
486
- is usually used for reconnections. This will retry forever.
487
- When connecting start a timer to trigger disabling the retry logic and
488
- raise a WebsocketTimeout exception.
489
- """
490
- ws_connection_info = urllib.parse.urlparse(url)
491
- self.__set_connection_timeout(self.initial_connect_timeout)
492
- try:
493
- self.sio.connect(
494
- f"{ws_connection_info.scheme}://{ws_connection_info.netloc}",
495
- auth=auth,
496
- retry=True,
497
- **{"socketio_path": ws_connection_info.path} if ws_connection_info.path else {},
498
- )
499
- except socketio.exceptions.ConnectionError:
500
- if self.exception:
501
- raise self.exception
502
-
503
- raise
504
-
505
- def shutdown(self) -> None:
506
- """
507
- When shutting down the socketio client a disconnect event will fire.
508
- Before doing so disable the behaviour of starting a threading.Timer
509
- to handle timeouts on attempts to reconnect since no further such attempts
510
- will be made.
511
- If such a timer is already running, cancel it since the client is being shutdown.
512
- """
513
- self.__timeout_on_disconnect = False
514
- if hasattr(self, "__connect_timeout_timer"):
515
- self.__connect_timeout_timer.cancel()
516
- self.sio.shutdown()
517
-
518
- def wait(self, timeout=False) -> bool:
519
- """
520
- Block until the threading.Event __shutdown_allowed is set, with a timeout if indicated.
521
- If an exception has been set on the websocket (from a connection timeout timer or the
522
- __on_connect_error method), raise it.
523
- """
524
- timeout = self.wait_timeout if timeout else None
525
- logger.debug(f"Waiting for shutdown for {str(timeout)+'s' if timeout else 'ever'}")
526
- res = self.__shutdown_allowed.wait(timeout)
527
- if self.exception:
528
- raise self.exception
529
- return res
530
-
531
- def __on_connect(self) -> None:
532
- """
533
- This gets events whenever a connection is successfully established.
534
- When this happens, cancel the running threading.Timer that would
535
- abort reconnect attempts and raise a WebsocketTimeout exception.
536
- The wait_timeout is originally set to zero when creating the websocket
537
- but once a connection has been established this is raised to ensure
538
- that the server is given the chance to send all the logs and an
539
- official shutdown event.
540
- """
541
- self.__connect_timeout_timer.cancel()
542
- self.wait_timeout = 90
543
- logger.debug("Websocket connected")
544
-
545
- def __on_disconnect(self) -> None:
546
- """
547
- This gets events whenever a connection is lost.
548
- The socketio client will try to reconnect forever so,
549
- unless the behaviour has been disabled, a threading.Timer
550
- is started that will abort reconnect attempts and raise a
551
- WebsocketTimeout exception.
552
- """
553
- if self.__timeout_on_disconnect:
554
- self.__set_connection_timeout(self.reconnect_timeout)
555
- logger.debug("Websocket disconnected")
556
-
557
- def __on_events(self, data):
558
- """
559
- This gets events explicitly sent by the websocket server.
560
- This will either be messages to print on stdout/stderr or
561
- an indication that the CLI can shut down in which case the
562
- threading.Event __shutdown_allowed gets set on the websocket
563
- that tells the wait method that it should stop blocking.
564
- """
565
- shutdown = False
566
- shutdown_message = ""
567
-
568
- if data["id"] in self.__processed_events:
569
- logger.debug(f"Got duplicate data on websocket, id {data['id']}")
570
- return
571
-
572
- self.__processed_events.add(data["id"])
573
-
574
- for event in data["events"]:
575
- type = event["type"]
576
-
577
- if type == "shutdown":
578
- shutdown = True
579
- shutdown_message = event["message"]
580
- elif type == "stdout":
581
- sys.stdout.write(event["message"])
582
- elif type == "stderr":
583
- sys.stderr.write(event["message"])
584
- else:
585
- raise Exception("Unexpected event type")
586
-
587
- if shutdown:
588
- logger.debug("Got shutdown from locust master")
589
- if shutdown_message:
590
- print(shutdown_message)
591
-
592
- self.__shutdown_allowed.set()
593
-
594
- def __on_connect_error(self, data) -> None:
595
- """
596
- This gets events whenever there's an error during connection attempts.
597
- The specific case that is handled below is triggered when the connection
598
- is made with the auth parameter not matching the session ID on the server.
599
- If this error occurs it's because the connection is attempted towards an
600
- instance of locust not started by this CLI.
601
-
602
- In that case:
603
- Cancel the running threading.Timer that would abort reconnect attempts
604
- and raise a WebsocketTimeout exception.
605
- Set an exception on the websocket that will be raised from the wait method.
606
- Cancel further reconnect attempts.
607
- Set the threading.Event __shutdown_allowed on the websocket that tells the
608
- wait method that it should stop blocking.
609
- """
610
- # Do nothing if it's not the specific case we know how to deal with
611
- if not (isinstance(data, dict) and data.get("message") == "Session mismatch"):
612
- return
613
-
614
- self.__connect_timeout_timer.cancel()
615
- self.exception = SessionMismatchError(
616
- "The session from this run of locust-cloud did not match the one on the server"
617
- )
618
- self.sio._reconnect_abort.set()
619
- self.__shutdown_allowed.set()
20
+ # Restore log level for other libs. Yes, this can be done more nicely
21
+ logging.getLogger("requests").setLevel(logging.INFO)
22
+ logging.getLogger("urllib3").setLevel(logging.INFO)
620
23
 
621
24
 
622
25
  def main() -> None:
26
+ options, locust_options = parse_known_args()
27
+ configure_logging(options.loglevel)
28
+
623
29
  if options.version:
624
30
  print(f"locust-cloud version {__version__}")
625
31
  sys.exit(0)
@@ -634,7 +40,7 @@ def main() -> None:
634
40
  pass
635
41
  sys.exit()
636
42
 
637
- session = ApiSession()
43
+ session = ApiSession(options.non_interactive)
638
44
  websocket = Websocket()
639
45
 
640
46
  if options.delete:
@@ -678,7 +84,7 @@ def main() -> None:
678
84
  payload["requirements"] = options.requirements
679
85
 
680
86
  if options.extra_files:
681
- payload["extra_files"] = transfer_encoded_extra_files(options.extra_files)
87
+ payload["extra_files"] = options.extra_files
682
88
 
683
89
  try:
684
90
  response = session.post("/deploy", json=payload)
locust_cloud/common.py ADDED
@@ -0,0 +1,40 @@
1
+ import importlib.metadata
2
+ import json
3
+ import os
4
+ import pathlib
5
+ from dataclasses import dataclass
6
+
7
+ import platformdirs
8
+
9
+ __version__ = importlib.metadata.version("locust-cloud")
10
+
11
+
12
+ VALID_REGIONS = ["us-east-1", "eu-north-1"]
13
+ CLOUD_CONF_FILE = pathlib.Path(platformdirs.user_config_dir(appname="locust-cloud")) / "config"
14
+
15
+
16
+ @dataclass
17
+ class CloudConfig:
18
+ id_token: str | None = None
19
+ refresh_token: str | None = None
20
+ refresh_token_expires: int = 0
21
+ region: str | None = None
22
+
23
+
24
+ def get_api_url(region):
25
+ return os.environ.get("LOCUSTCLOUD_DEPLOYER_URL", f"https://api.{region}.locust.cloud/1")
26
+
27
+
28
+ def read_cloud_config() -> CloudConfig:
29
+ if CLOUD_CONF_FILE.exists():
30
+ with open(CLOUD_CONF_FILE) as f:
31
+ return CloudConfig(**json.load(f))
32
+
33
+ return CloudConfig()
34
+
35
+
36
+ def write_cloud_config(config: CloudConfig) -> None:
37
+ CLOUD_CONF_FILE.parent.mkdir(parents=True, exist_ok=True)
38
+
39
+ with open(CLOUD_CONF_FILE, "w") as f:
40
+ json.dump(config.__dict__, f)
@@ -0,0 +1,77 @@
1
+ import sys
2
+ import time
3
+ import webbrowser
4
+
5
+ import requests
6
+ from locust_cloud.common import VALID_REGIONS, CloudConfig, get_api_url, write_cloud_config
7
+
8
+ POLLING_FREQUENCY = 1
9
+
10
+
11
+ def web_login() -> None:
12
+ print("Enter the number for the region to authenticate against")
13
+ print()
14
+ for i, valid_region in enumerate(VALID_REGIONS, start=1):
15
+ print(f" {i}. {valid_region}")
16
+ print()
17
+ choice = input("> ")
18
+ try:
19
+ region_index = int(choice) - 1
20
+ assert 0 <= region_index < len(VALID_REGIONS)
21
+ except (ValueError, AssertionError):
22
+ print(f"Not a valid choice: '{choice}'")
23
+ sys.exit(1)
24
+
25
+ region = VALID_REGIONS[region_index]
26
+
27
+ try:
28
+ response = requests.post(f"{get_api_url(region)}/cli-auth")
29
+ response.raise_for_status()
30
+ response_data = response.json()
31
+ authentication_url = response_data["authentication_url"]
32
+ result_url = response_data["result_url"]
33
+ except Exception as e:
34
+ print("Something went wrong trying to authorize the locust-cloud CLI:", str(e))
35
+ sys.exit(1)
36
+
37
+ message = f"""
38
+ Attempting to automatically open the SSO authorization page in your default browser.
39
+ If the browser does not open or you wish to use a different device to authorize this request, open the following URL:
40
+
41
+ {authentication_url}
42
+ """.strip()
43
+ print()
44
+ print(message)
45
+
46
+ webbrowser.open_new_tab(authentication_url)
47
+
48
+ while True: # Should there be some kind of timeout?
49
+ response = requests.get(result_url)
50
+
51
+ if not response.ok:
52
+ print("Oh no!")
53
+ print(response.text)
54
+ sys.exit(1)
55
+
56
+ data = response.json()
57
+
58
+ if data["state"] == "pending":
59
+ time.sleep(POLLING_FREQUENCY)
60
+ continue
61
+ elif data["state"] == "failed":
62
+ print(f"\nFailed to authorize CLI: {data['reason']}")
63
+ sys.exit(1)
64
+ elif data["state"] == "authorized":
65
+ print("\nAuthorization succeded. Now you can re-run locust-cloud without the --login flag.")
66
+ break
67
+ else:
68
+ print("\nGot unexpected response when authorizing CLI")
69
+ sys.exit(1)
70
+
71
+ config = CloudConfig(
72
+ id_token=data["id_token"],
73
+ refresh_token=data["refresh_token"],
74
+ refresh_token_expires=data["refresh_token_expires"],
75
+ region=region,
76
+ )
77
+ write_cloud_config(config)
@@ -0,0 +1,207 @@
1
+ import logging
2
+ import sys
3
+ import threading
4
+ import urllib.parse
5
+
6
+ import socketio
7
+ import socketio.exceptions
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class SessionMismatchError(Exception):
13
+ pass
14
+
15
+
16
+ class WebsocketTimeout(Exception):
17
+ pass
18
+
19
+
20
+ class Websocket:
21
+ def __init__(self) -> None:
22
+ """
23
+ This class was created to encapsulate all the logic involved in the websocket implementation.
24
+ The behaviour of the socketio client once a connection has been established
25
+ is to try to reconnect forever if the connection is lost.
26
+ The way this can be canceled is by setting the _reconnect_abort (threading.Event) on the client
27
+ in which case it will simply proceed with shutting down without giving any indication of an error.
28
+ This class handles timeouts for connection attempts as well as some logic around when the
29
+ socket can be shut down. See descriptions on the methods for further details.
30
+ """
31
+ self.__shutdown_allowed = threading.Event()
32
+ self.__timeout_on_disconnect = True
33
+ self.initial_connect_timeout = 120
34
+ self.reconnect_timeout = 10
35
+ self.wait_timeout = 0
36
+ self.exception: None | Exception = None
37
+
38
+ self.sio = socketio.Client(handle_sigint=False)
39
+ self.sio._reconnect_abort = threading.Event()
40
+ # The _reconnect_abort value on the socketio client will be populated with a newly created threading.Event if it's not already set.
41
+ # There is no way to set this by passing it in the constructor.
42
+ # This event is the only way to interupt the retry logic when the connection is attempted.
43
+
44
+ self.sio.on("connect", self.__on_connect)
45
+ self.sio.on("disconnect", self.__on_disconnect)
46
+ self.sio.on("connect_error", self.__on_connect_error)
47
+ self.sio.on("events", self.__on_events)
48
+
49
+ self.__processed_events: set[int] = set()
50
+
51
+ def __set_connection_timeout(self, timeout) -> None:
52
+ """
53
+ Start a threading.Timer that will set the threading.Event on the socketio client
54
+ that aborts any further attempts to reconnect, sets an exception on the websocket
55
+ that will be raised from the wait method and the threading.Event __shutdown_allowed
56
+ on the websocket that tells the wait method that it should stop blocking.
57
+ """
58
+
59
+ def _timeout():
60
+ logger.debug(f"Websocket connection timed out after {timeout} seconds")
61
+ self.sio._reconnect_abort.set()
62
+ self.exception = WebsocketTimeout("Timed out connecting to locust master")
63
+ self.__shutdown_allowed.set()
64
+
65
+ self.__connect_timeout_timer = threading.Timer(timeout, _timeout)
66
+ self.__connect_timeout_timer.daemon = True
67
+ logger.debug(f"Setting websocket connection timeout to {timeout} seconds")
68
+ self.__connect_timeout_timer.start()
69
+
70
+ def connect(self, url, *, auth) -> None:
71
+ """
72
+ Send along retry=True when initiating the socketio client connection
73
+ to make it use it's builtin logic for retrying failed connections that
74
+ is usually used for reconnections. This will retry forever.
75
+ When connecting start a timer to trigger disabling the retry logic and
76
+ raise a WebsocketTimeout exception.
77
+ """
78
+ ws_connection_info = urllib.parse.urlparse(url)
79
+ self.__set_connection_timeout(self.initial_connect_timeout)
80
+ try:
81
+ self.sio.connect(
82
+ f"{ws_connection_info.scheme}://{ws_connection_info.netloc}",
83
+ auth=auth,
84
+ retry=True,
85
+ **{"socketio_path": ws_connection_info.path} if ws_connection_info.path else {},
86
+ )
87
+ except socketio.exceptions.ConnectionError:
88
+ if self.exception:
89
+ raise self.exception
90
+
91
+ raise
92
+
93
+ def shutdown(self) -> None:
94
+ """
95
+ When shutting down the socketio client a disconnect event will fire.
96
+ Before doing so disable the behaviour of starting a threading.Timer
97
+ to handle timeouts on attempts to reconnect since no further such attempts
98
+ will be made.
99
+ If such a timer is already running, cancel it since the client is being shutdown.
100
+ """
101
+ self.__timeout_on_disconnect = False
102
+ if hasattr(self, "__connect_timeout_timer"):
103
+ self.__connect_timeout_timer.cancel()
104
+ self.sio.shutdown()
105
+
106
+ def wait(self, timeout=False) -> bool:
107
+ """
108
+ Block until the threading.Event __shutdown_allowed is set, with a timeout if indicated.
109
+ If an exception has been set on the websocket (from a connection timeout timer or the
110
+ __on_connect_error method), raise it.
111
+ """
112
+ timeout = self.wait_timeout if timeout else None
113
+ logger.debug(f"Waiting for shutdown for {str(timeout)+'s' if timeout else 'ever'}")
114
+ res = self.__shutdown_allowed.wait(timeout)
115
+ if self.exception:
116
+ raise self.exception
117
+ return res
118
+
119
+ def __on_connect(self) -> None:
120
+ """
121
+ This gets events whenever a connection is successfully established.
122
+ When this happens, cancel the running threading.Timer that would
123
+ abort reconnect attempts and raise a WebsocketTimeout exception.
124
+ The wait_timeout is originally set to zero when creating the websocket
125
+ but once a connection has been established this is raised to ensure
126
+ that the server is given the chance to send all the logs and an
127
+ official shutdown event.
128
+ """
129
+ self.__connect_timeout_timer.cancel()
130
+ self.wait_timeout = 90
131
+ logger.debug("Websocket connected")
132
+
133
+ def __on_disconnect(self) -> None:
134
+ """
135
+ This gets events whenever a connection is lost.
136
+ The socketio client will try to reconnect forever so,
137
+ unless the behaviour has been disabled, a threading.Timer
138
+ is started that will abort reconnect attempts and raise a
139
+ WebsocketTimeout exception.
140
+ """
141
+ if self.__timeout_on_disconnect:
142
+ self.__set_connection_timeout(self.reconnect_timeout)
143
+ logger.debug("Websocket disconnected")
144
+
145
+ def __on_events(self, data):
146
+ """
147
+ This gets events explicitly sent by the websocket server.
148
+ This will either be messages to print on stdout/stderr or
149
+ an indication that the CLI can shut down in which case the
150
+ threading.Event __shutdown_allowed gets set on the websocket
151
+ that tells the wait method that it should stop blocking.
152
+ """
153
+ shutdown = False
154
+ shutdown_message = ""
155
+
156
+ if data["id"] in self.__processed_events:
157
+ logger.debug(f"Got duplicate data on websocket, id {data['id']}")
158
+ return
159
+
160
+ self.__processed_events.add(data["id"])
161
+
162
+ for event in data["events"]:
163
+ type = event["type"]
164
+
165
+ if type == "shutdown":
166
+ shutdown = True
167
+ shutdown_message = event["message"]
168
+ elif type == "stdout":
169
+ sys.stdout.write(event["message"])
170
+ elif type == "stderr":
171
+ sys.stderr.write(event["message"])
172
+ else:
173
+ raise Exception("Unexpected event type")
174
+
175
+ if shutdown:
176
+ logger.debug("Got shutdown from locust master")
177
+ if shutdown_message:
178
+ print(shutdown_message)
179
+
180
+ self.__shutdown_allowed.set()
181
+
182
+ def __on_connect_error(self, data) -> None:
183
+ """
184
+ This gets events whenever there's an error during connection attempts.
185
+ The specific case that is handled below is triggered when the connection
186
+ is made with the auth parameter not matching the session ID on the server.
187
+ If this error occurs it's because the connection is attempted towards an
188
+ instance of locust not started by this CLI.
189
+
190
+ In that case:
191
+ Cancel the running threading.Timer that would abort reconnect attempts
192
+ and raise a WebsocketTimeout exception.
193
+ Set an exception on the websocket that will be raised from the wait method.
194
+ Cancel further reconnect attempts.
195
+ Set the threading.Event __shutdown_allowed on the websocket that tells the
196
+ wait method that it should stop blocking.
197
+ """
198
+ # Do nothing if it's not the specific case we know how to deal with
199
+ if not (isinstance(data, dict) and data.get("message") == "Session mismatch"):
200
+ return
201
+
202
+ self.__connect_timeout_timer.cancel()
203
+ self.exception = SessionMismatchError(
204
+ "The session from this run of locust-cloud did not match the one on the server"
205
+ )
206
+ self.sio._reconnect_abort.set()
207
+ self.__shutdown_allowed.set()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: locust-cloud
3
- Version: 1.14.2
3
+ Version: 1.14.3
4
4
  Summary: Locust Cloud
5
5
  Project-URL: Homepage, https://locust.cloud
6
6
  Requires-Python: >=3.11
@@ -0,0 +1,10 @@
1
+ locust_cloud/apisession.py,sha256=AoU0FGQbyH2qbaTmdyoIMBd_lwZimLtihK0gnpAV6c0,4091
2
+ locust_cloud/args.py,sha256=1SaMQ16f_3vaNqnAbjFiBL-6ietp1-qfV2LUjBk6b8k,7100
3
+ locust_cloud/cloud.py,sha256=Piq6J9pS6Tom1aoYwNmmt4Q9LrLFPKFZC6EqgOen3OE,5107
4
+ locust_cloud/common.py,sha256=cFrDVKpi9OEmH6giOuj9HoIUFSBArixNtNHzZIgDvPE,992
5
+ locust_cloud/web_login.py,sha256=1j2AQoEM6XVSDtE1q0Ryrs4jFEx07r9IQfZCoFAQXJg,2400
6
+ locust_cloud/websocket.py,sha256=lnVRsk0goAHIDNz9cT5xkYAjHSB5aqFyjR_m3X48qRM,8771
7
+ locust_cloud-1.14.3.dist-info/METADATA,sha256=zV3RuzeK3GU2qoW6TR7h38QfaAb2E-r28s9u-hXXYJY,497
8
+ locust_cloud-1.14.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
+ locust_cloud-1.14.3.dist-info/entry_points.txt,sha256=PGyAb4e3aTsGS3N3VGShDl6VzJaXy7QwsEgsLOC7V00,57
10
+ locust_cloud-1.14.3.dist-info/RECORD,,
@@ -1,5 +0,0 @@
1
- locust_cloud/cloud.py,sha256=ro6oWsd6SEjBHDUWH7vrnvo0wY1Ljz8zeiJwhlaIF-g,26873
2
- locust_cloud-1.14.2.dist-info/METADATA,sha256=it51qVDFVV6aTB0b3bAuIvF9xfIVdx6Wk6UB31PLzhs,497
3
- locust_cloud-1.14.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
4
- locust_cloud-1.14.2.dist-info/entry_points.txt,sha256=PGyAb4e3aTsGS3N3VGShDl6VzJaXy7QwsEgsLOC7V00,57
5
- locust_cloud-1.14.2.dist-info/RECORD,,