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