wondersearch-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wondersearch_cli/__init__.py +5 -0
- wondersearch_cli/__main__.py +3 -0
- wondersearch_cli/_messages.py +102 -0
- wondersearch_cli/_version.py +1 -0
- wondersearch_cli/api.py +283 -0
- wondersearch_cli/browser_login.py +331 -0
- wondersearch_cli/config.py +188 -0
- wondersearch_cli/error-catalogue.json +623 -0
- wondersearch_cli/errors.py +47 -0
- wondersearch_cli/main.py +947 -0
- wondersearch_cli/notices.py +59 -0
- wondersearch_cli/uploads.py +536 -0
- wondersearch_cli-0.1.0.dist-info/METADATA +149 -0
- wondersearch_cli-0.1.0.dist-info/RECORD +17 -0
- wondersearch_cli-0.1.0.dist-info/WHEEL +4 -0
- wondersearch_cli-0.1.0.dist-info/entry_points.txt +2 -0
- wondersearch_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Local error messages. Backend response messages are preserved verbatim."""
|
|
2
|
+
|
|
3
|
+
MESSAGES = {
|
|
4
|
+
"unreadable_file": "Cannot read file or directory.",
|
|
5
|
+
"invalid_signin_callback": "Invalid sign-in callback. Return to your terminal.",
|
|
6
|
+
"api_url_must_be_a_string": "API URL must be a string.",
|
|
7
|
+
"api_url_must_be_an_https_origin_http": "API URL must be an HTTPS origin (HTTP is permitted only on loopback).",
|
|
8
|
+
"configuration_recovery_paths_must_not_contain_symlinks": "Configuration/recovery paths must not contain symlinks.",
|
|
9
|
+
"invalid_profile_name": "Invalid profile name.",
|
|
10
|
+
"configuration_profiles_must_be_an_object": "Configuration profiles must be an object.",
|
|
11
|
+
"profile_settings_must_contain_string_values": "Profile settings must contain string values.",
|
|
12
|
+
"private_configuration_recovery_file_must_have_mode_0600": "Private configuration/recovery file must have mode 0600.",
|
|
13
|
+
"set_only_one_of_wondersearch_api_key_and": "Set only one of WONDERSEARCH_API_KEY and WONDERSEARCH_ACCESS_TOKEN.",
|
|
14
|
+
"invalid_saved_credential_record_log_in_again": "Invalid saved credential record. Log in again.",
|
|
15
|
+
"log_in_or_provide_credentials_for_this_api": "Log in or provide credentials for this API origin.",
|
|
16
|
+
"cannot_read_private_configuration_recovery_file": "Cannot read private configuration/recovery file.",
|
|
17
|
+
"saved_credential_belongs_to_a_different_api_origin": "Saved credential belongs to a different API origin. Log in for this origin first.",
|
|
18
|
+
"unlock_or_configure_your_operating_system_credential_store": "Unlock or configure your operating system credential store, then run wondersearch login again. For automation, use WONDERSEARCH_API_KEY.",
|
|
19
|
+
"the_saved_session_is_invalid_run_wondersearch_login": "The saved session is invalid. Run wondersearch login again.",
|
|
20
|
+
"credential_paths_must_not_contain_symlinks": "Credential paths must not contain symlinks.",
|
|
21
|
+
"the_server_returned_an_invalid_sign_in_address": "The server returned an invalid sign-in address.",
|
|
22
|
+
"sign_in_timed_out_run_wondersearch_login_again": "Sign-in timed out. Run wondersearch login again.",
|
|
23
|
+
"sign_in_cancelled": "Sign-in cancelled.",
|
|
24
|
+
"your_session_has_expired_run_wondersearch_login_to": "Your session has expired. Run wondersearch login to sign in again.",
|
|
25
|
+
"browser_sign_in_is_unavailable_on_this_server": "Browser sign-in is unavailable on this server. Try again shortly, or use wondersearch login --api-key.",
|
|
26
|
+
"could_not_complete_sign_in_check_your_connection": "Could not complete sign-in. Check your connection and try again.",
|
|
27
|
+
"could_not_save_your_session_in_the_operating": "Could not save your session in the operating system credential store.",
|
|
28
|
+
"run_wondersearch_login_to_sign_in": "Run wondersearch login to sign in.",
|
|
29
|
+
"could_not_read_your_saved_session_unlock_your": "Could not read your saved session. Unlock your operating system credential store and try again.",
|
|
30
|
+
"the_session_was_revoked_but_local_cleanup_failed": "The session was revoked, but local cleanup failed. Unlock your credential store and run wondersearch logout again.",
|
|
31
|
+
"this_server_requires_a_different_cli_api_major": "This server requires a different CLI API major.",
|
|
32
|
+
"upgrade_wondersearch_cli_to_at_least_0": "Upgrade wondersearch-cli to at least {0}.",
|
|
33
|
+
"server_response_is_missing_required_fields_or_has": "Server response is missing required fields or has invalid field types.",
|
|
34
|
+
"operation_timed_out_accepted_server_work_continues": "Operation timed out; accepted server work continues.",
|
|
35
|
+
"expected_a_resource_uuid": "Expected a resource UUID.",
|
|
36
|
+
"complete_account_setup_in_the_console_first": "Complete account setup in the Console first.",
|
|
37
|
+
"sign_in_with_a_user_session_to_run": "Sign in with a user session to run this command. API keys cannot be used here.",
|
|
38
|
+
"api_request_failed": "API request failed.",
|
|
39
|
+
"unsafe_transfer_destination": "Unsafe transfer destination.",
|
|
40
|
+
"invalid_api_path": "Invalid API path.",
|
|
41
|
+
"server_returned_an_invalid_response": "Server returned an invalid response.",
|
|
42
|
+
"server_returned_invalid_pagination_data": "Server returned invalid pagination data.",
|
|
43
|
+
"repeated_pagination_cursor": "Repeated pagination cursor.",
|
|
44
|
+
"resource_is_missing_its_concurrency_etag": "Resource is missing its concurrency ETag.",
|
|
45
|
+
"server_returned_a_non_json_response": "Server returned a non-JSON response.",
|
|
46
|
+
"network_request_failed": "Network request failed.",
|
|
47
|
+
"processing_failed": "Processing failed.",
|
|
48
|
+
"replace_requires_exactly_one_file_and_no_recursive": "--replace requires exactly one file and no --recursive.",
|
|
49
|
+
"directories_require_recursive": "Directories require --recursive.",
|
|
50
|
+
"recovery_record_does_not_match_this_profile_origin": "Recovery record does not match this profile, origin, identity, workspace, and drive.",
|
|
51
|
+
"invalid_upload_recovery_record_restore_the_original_record": "Invalid upload recovery record. Restore the original record before resuming.",
|
|
52
|
+
"expected_a_supported_regular_file_of_1_through": "Expected a supported regular file of 1 through 1,000,000,000 bytes.",
|
|
53
|
+
"upload_bytes_are_incomplete_use_upload_resume_with": "Upload bytes are incomplete. Use upload resume with the original run ID.",
|
|
54
|
+
"source_changed_since_reservation_cannot_resume_this_upload": "Source changed since reservation; cannot resume this upload.",
|
|
55
|
+
"ambiguous_destination_folder": "Ambiguous destination folder: ",
|
|
56
|
+
"destination_folder_could_not_be_created_or_resolved": "Destination folder could not be created or resolved.",
|
|
57
|
+
"transfer_headers_must_be_strings": "Transfer headers must be strings.",
|
|
58
|
+
"unknown_transfer_transport": "Unknown transfer transport.",
|
|
59
|
+
"cannot_read_source_file": "Cannot read source file.",
|
|
60
|
+
"unsupported_command": "Unsupported command.",
|
|
61
|
+
"incorrect_number_of_command_arguments_see_help": "Incorrect number of command arguments. See --help.",
|
|
62
|
+
"use_either_a_positional_drive_or_drive_not": "Use either a positional drive or --drive, not both.",
|
|
63
|
+
"use_either_a_positional_workspace_or_workspace_not": "Use either a positional workspace or --workspace, not both.",
|
|
64
|
+
"deletion_revocation_requires_confirmation_use_yes_in_scripts": "Deletion/revocation requires confirmation; use --yes in scripts.",
|
|
65
|
+
"deletion_revocation_was_not_confirmed": "Deletion/revocation was not confirmed.",
|
|
66
|
+
"allowed_config_keys": "Allowed config keys: ",
|
|
67
|
+
"": ", ",
|
|
68
|
+
"provide_a_configuration_value": "Provide a configuration value.",
|
|
69
|
+
"list_limit_must_be_between_1_and_100": "List limit must be between 1 and 100.",
|
|
70
|
+
"search_timeout_must_be_between_1_and_20000": "Search timeout must be between 1 and 20000 milliseconds.",
|
|
71
|
+
"missing_command_argument_see_help": "Missing command argument. See --help.",
|
|
72
|
+
"processing_timeout_requires_wait": "--processing-timeout requires --wait.",
|
|
73
|
+
"dry_run_cannot_be_combined_with_wait": "--dry-run cannot be combined with --wait.",
|
|
74
|
+
"0_is_not_supported_for_this_command": "--{0} is not supported for this command.",
|
|
75
|
+
"select_an_organization_with_organization_or_config_set": "Select an organization with --organization or config set organization.",
|
|
76
|
+
"drive_is_not_accessible_in_the_selected_workspace": "Drive is not accessible in the selected workspace.",
|
|
77
|
+
"profile_names_must_contain_1_through_120_characters": "Profile names must contain 1 through 120 characters.",
|
|
78
|
+
"unknown_profile_create_it_with_login_profile_name": "Unknown profile. Create it with login --profile NAME or config set KEY VALUE --profile NAME.",
|
|
79
|
+
"waiting_interrupted_drive_0_continues_provisioning_check_it": "Waiting interrupted; drive {0} continues provisioning. Check it with drive get {1}.",
|
|
80
|
+
"idempotency_key_must_be_1_128_visible_ascii": "Idempotency key must be 1-128 visible ASCII characters.",
|
|
81
|
+
"input_ended_before_a_credential_was_provided": "Input ended before a credential was provided.",
|
|
82
|
+
"cannot_read_write_a_local_file_or_parse": "Cannot read/write a local file or parse its contents. Check paths, permissions, and encoding.",
|
|
83
|
+
"timeout_applies_only_to_browser_login": "--timeout applies only to browser login.",
|
|
84
|
+
"timeout_requires_wait": "--timeout requires --wait.",
|
|
85
|
+
"0_name_must_match_exactly_one_accessible_resource": "{0} name must match exactly one accessible resource; use its UUID.",
|
|
86
|
+
"this_api_key_is_scoped_to_a_different": "This API key is scoped to a different workspace.",
|
|
87
|
+
"drive_provisioning_failed": "Drive provisioning failed.",
|
|
88
|
+
"unknown_drive_provisioning_status": "Unknown drive provisioning status.",
|
|
89
|
+
"use_workspace_default_drive_get_or_set_drive": "Use workspace default-drive get or set <drive>.",
|
|
90
|
+
"use_upload_status_upload_id": "Use upload status <upload-id>.",
|
|
91
|
+
"use_upload_resume_run_id_wait": "Use upload resume <run-id> [--wait].",
|
|
92
|
+
"document_text_exceeds_2_000_000_bytes": "Document text exceeds 2,000,000 bytes.",
|
|
93
|
+
"timeouts_must_be_finite_and_positive": "Timeouts must be finite and positive.",
|
|
94
|
+
"select_a_workspace_with_workspace_or_workspace_use": "Select a workspace with --workspace or workspace use.",
|
|
95
|
+
"select_the_profile_for_this_api_origin_before": "Select the profile for this API origin before signing out.",
|
|
96
|
+
"choose_browser_sign_in_or_one_credential_type": "Choose browser sign-in or one credential type. Browser timeout must be at most 300 seconds.",
|
|
97
|
+
"provide_a_nonempty_credential_without_whitespace": "Provide a nonempty credential without whitespace.",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def local_message(code: str, *values: object) -> str:
|
|
102
|
+
return MESSAGES[code].format(*values)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
wondersearch_cli/api.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Bounded API calls and credential-isolated streaming transfers."""
|
|
2
|
+
|
|
3
|
+
from ._messages import local_message
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from email.utils import parsedate_to_datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import quote, urlsplit
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from ._version import __version__
|
|
15
|
+
from .config import origin
|
|
16
|
+
from .errors import CLIError, usage
|
|
17
|
+
from .notices import notices
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def response_fields(value, **fields):
|
|
21
|
+
if not isinstance(value, dict) or any(
|
|
22
|
+
name not in value or not isinstance(value[name], expected)
|
|
23
|
+
for name, expected in fields.items()
|
|
24
|
+
):
|
|
25
|
+
raise CLIError(
|
|
26
|
+
"invalid_response",
|
|
27
|
+
local_message("server_response_is_missing_required_fields_or_has"),
|
|
28
|
+
kind="transport",
|
|
29
|
+
)
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ident(value: str) -> str:
|
|
34
|
+
try:
|
|
35
|
+
return str(uuid.UUID(value))
|
|
36
|
+
except (ValueError, TypeError, AttributeError):
|
|
37
|
+
raise usage(local_message("expected_a_resource_uuid")) from None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def segment(value: str) -> str:
|
|
41
|
+
return quote(value, safe="")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def remaining(deadline: float) -> float:
|
|
45
|
+
value = deadline - time.monotonic()
|
|
46
|
+
if value <= 0:
|
|
47
|
+
raise CLIError(
|
|
48
|
+
"timeout",
|
|
49
|
+
local_message("operation_timed_out_accepted_server_work_continues"),
|
|
50
|
+
exit_code=3,
|
|
51
|
+
)
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class API:
|
|
56
|
+
def __init__(self, base: str, token: str, kind: str, *, transport=None):
|
|
57
|
+
self.base, self.kind = origin(base), kind
|
|
58
|
+
self.headers = {
|
|
59
|
+
"Authorization": "Bearer " + token,
|
|
60
|
+
"User-Agent": f"wondersearch-cli/{__version__}",
|
|
61
|
+
"X-WonderSearch-CLI-Version": __version__,
|
|
62
|
+
"Accept-Encoding": "identity",
|
|
63
|
+
}
|
|
64
|
+
self.http = httpx.Client(
|
|
65
|
+
follow_redirects=False, transport=transport, trust_env=False
|
|
66
|
+
)
|
|
67
|
+
self.context: dict = {}
|
|
68
|
+
self.notices: list[dict] = []
|
|
69
|
+
|
|
70
|
+
def close(self):
|
|
71
|
+
self.http.close()
|
|
72
|
+
|
|
73
|
+
def discover(self):
|
|
74
|
+
self.context = self.request(
|
|
75
|
+
"GET", "/v1/sdk/context" if self.kind == "key" else "/v1/me"
|
|
76
|
+
)[0]
|
|
77
|
+
if self.kind == "key":
|
|
78
|
+
response_fields(self.context, workspace_id=str)
|
|
79
|
+
compatibility = self.context.get("compatibility")
|
|
80
|
+
self.notices = notices(
|
|
81
|
+
compatibility.get("cli") if isinstance(compatibility, dict) else None
|
|
82
|
+
)
|
|
83
|
+
if self.kind == "session" and not self.context.get("provisioned"):
|
|
84
|
+
raise CLIError(
|
|
85
|
+
"account_setup_required",
|
|
86
|
+
local_message("complete_account_setup_in_the_console_first"),
|
|
87
|
+
)
|
|
88
|
+
return self.context
|
|
89
|
+
|
|
90
|
+
def human(self):
|
|
91
|
+
if self.kind != "session":
|
|
92
|
+
raise CLIError(
|
|
93
|
+
"human_session_required",
|
|
94
|
+
local_message("sign_in_with_a_user_session_to_run"),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def request(
|
|
98
|
+
self,
|
|
99
|
+
method: str,
|
|
100
|
+
path: str,
|
|
101
|
+
body=None,
|
|
102
|
+
*,
|
|
103
|
+
params=None,
|
|
104
|
+
headers=None,
|
|
105
|
+
key: str | None = None,
|
|
106
|
+
deadline: float | None = None,
|
|
107
|
+
file: Path | None = None,
|
|
108
|
+
external=False,
|
|
109
|
+
raw=False,
|
|
110
|
+
):
|
|
111
|
+
deadline = deadline if deadline is not None else time.monotonic() + 60
|
|
112
|
+
if external:
|
|
113
|
+
p = urlsplit(path)
|
|
114
|
+
forbidden = {"authorization", "cookie", "host", "idempotency-key"}
|
|
115
|
+
if (
|
|
116
|
+
p.scheme != "https"
|
|
117
|
+
or not p.hostname
|
|
118
|
+
or p.username
|
|
119
|
+
or p.password
|
|
120
|
+
or p.fragment
|
|
121
|
+
or any(k.lower() in forbidden for k in headers or {})
|
|
122
|
+
):
|
|
123
|
+
raise CLIError(
|
|
124
|
+
"invalid_transfer", local_message("unsafe_transfer_destination")
|
|
125
|
+
)
|
|
126
|
+
url, merged = path, dict(headers or {})
|
|
127
|
+
else:
|
|
128
|
+
if not path.startswith("/v1/") or "?" in path or "#" in path:
|
|
129
|
+
raise usage(local_message("invalid_api_path"))
|
|
130
|
+
url, merged = self.base + path, {**self.headers, **(headers or {})}
|
|
131
|
+
if key:
|
|
132
|
+
merged["Idempotency-Key"] = key
|
|
133
|
+
safe = method == "GET" or bool(key) or external
|
|
134
|
+
for attempt in range(3):
|
|
135
|
+
try:
|
|
136
|
+
|
|
137
|
+
def chunks():
|
|
138
|
+
assert file is not None
|
|
139
|
+
with file.open("rb") as stream:
|
|
140
|
+
while True:
|
|
141
|
+
remaining(deadline)
|
|
142
|
+
data = stream.read(65536)
|
|
143
|
+
if not data:
|
|
144
|
+
return
|
|
145
|
+
yield data
|
|
146
|
+
|
|
147
|
+
# Construct directly, so response cookies can never leak across requests.
|
|
148
|
+
request = httpx.Request(
|
|
149
|
+
method,
|
|
150
|
+
url,
|
|
151
|
+
params=params,
|
|
152
|
+
headers=merged,
|
|
153
|
+
content=chunks() if file else None,
|
|
154
|
+
json=body if not file else None,
|
|
155
|
+
extensions={
|
|
156
|
+
"timeout": dict.fromkeys(
|
|
157
|
+
["connect", "read", "write", "pool"],
|
|
158
|
+
min(60, remaining(deadline)),
|
|
159
|
+
)
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
response = self.http.send(request, follow_redirects=False)
|
|
163
|
+
remaining(deadline)
|
|
164
|
+
except httpx.TransportError:
|
|
165
|
+
if attempt == 2 or not safe:
|
|
166
|
+
raise CLIError(
|
|
167
|
+
"transport_error",
|
|
168
|
+
local_message("network_request_failed"),
|
|
169
|
+
kind="transport",
|
|
170
|
+
) from None
|
|
171
|
+
time.sleep(min(0.25 * 2**attempt, remaining(deadline)))
|
|
172
|
+
continue
|
|
173
|
+
if raw and response.is_success:
|
|
174
|
+
return response.content, dict(response.headers)
|
|
175
|
+
try:
|
|
176
|
+
data = response.json() if response.content and not external else {}
|
|
177
|
+
except ValueError:
|
|
178
|
+
raise CLIError(
|
|
179
|
+
"invalid_response",
|
|
180
|
+
local_message("server_returned_a_non_json_response"),
|
|
181
|
+
kind="transport",
|
|
182
|
+
status=response.status_code,
|
|
183
|
+
) from None
|
|
184
|
+
if not isinstance(data, dict):
|
|
185
|
+
raise CLIError(
|
|
186
|
+
"invalid_response",
|
|
187
|
+
local_message("server_returned_an_invalid_response"),
|
|
188
|
+
kind="transport",
|
|
189
|
+
)
|
|
190
|
+
if response.is_success:
|
|
191
|
+
return data, dict(response.headers)
|
|
192
|
+
error = data.get("error", {})
|
|
193
|
+
if not isinstance(error, dict):
|
|
194
|
+
error = {}
|
|
195
|
+
code = error.get(
|
|
196
|
+
"code", "transfer_failed" if external else "request_failed"
|
|
197
|
+
)
|
|
198
|
+
if (
|
|
199
|
+
safe
|
|
200
|
+
and attempt < 2
|
|
201
|
+
and code != "search_replay_unavailable"
|
|
202
|
+
and (
|
|
203
|
+
response.status_code in {429, 502, 503, 504}
|
|
204
|
+
or code in {"request_in_progress", "search_request_in_progress"}
|
|
205
|
+
)
|
|
206
|
+
):
|
|
207
|
+
delay = 0.25 * 2**attempt
|
|
208
|
+
value = response.headers.get("retry-after")
|
|
209
|
+
if value:
|
|
210
|
+
try:
|
|
211
|
+
delay = float(value)
|
|
212
|
+
except ValueError:
|
|
213
|
+
try:
|
|
214
|
+
delay = (
|
|
215
|
+
parsedate_to_datetime(value)
|
|
216
|
+
- datetime.now(timezone.utc)
|
|
217
|
+
).total_seconds()
|
|
218
|
+
except (ValueError, TypeError, OverflowError):
|
|
219
|
+
pass
|
|
220
|
+
time.sleep(min(max(0, delay), remaining(deadline)))
|
|
221
|
+
continue
|
|
222
|
+
raise CLIError(
|
|
223
|
+
str(code),
|
|
224
|
+
str(error.get("message") or local_message("api_request_failed")),
|
|
225
|
+
kind="api" if not external else "transport",
|
|
226
|
+
status=response.status_code,
|
|
227
|
+
request_id=error.get("request_id")
|
|
228
|
+
or response.headers.get("x-request-id"),
|
|
229
|
+
details=error.get("details"),
|
|
230
|
+
)
|
|
231
|
+
raise AssertionError("unreachable")
|
|
232
|
+
|
|
233
|
+
def pages(self, path: str, params=None, *, all_pages=False):
|
|
234
|
+
params = dict(params or {})
|
|
235
|
+
result = None
|
|
236
|
+
seen = set()
|
|
237
|
+
while True:
|
|
238
|
+
page, _ = self.request("GET", path, params=params)
|
|
239
|
+
response_fields(page, data=list)
|
|
240
|
+
if any(
|
|
241
|
+
not isinstance(row, dict) or not isinstance(row.get("id"), str)
|
|
242
|
+
for row in page["data"]
|
|
243
|
+
) or (
|
|
244
|
+
page.get("next_cursor") is not None
|
|
245
|
+
and not isinstance(page["next_cursor"], str)
|
|
246
|
+
):
|
|
247
|
+
raise CLIError(
|
|
248
|
+
"invalid_response",
|
|
249
|
+
local_message("server_returned_invalid_pagination_data"),
|
|
250
|
+
kind="transport",
|
|
251
|
+
)
|
|
252
|
+
if result is None:
|
|
253
|
+
result = page
|
|
254
|
+
else:
|
|
255
|
+
result["data"].extend(page["data"])
|
|
256
|
+
result["next_cursor"] = page.get("next_cursor")
|
|
257
|
+
cursor = page.get("next_cursor")
|
|
258
|
+
if not all_pages or not cursor:
|
|
259
|
+
return result
|
|
260
|
+
if cursor in seen:
|
|
261
|
+
raise CLIError(
|
|
262
|
+
"invalid_response",
|
|
263
|
+
local_message("repeated_pagination_cursor"),
|
|
264
|
+
kind="transport",
|
|
265
|
+
)
|
|
266
|
+
seen.add(cursor)
|
|
267
|
+
params["cursor"] = cursor
|
|
268
|
+
|
|
269
|
+
def mutate(self, method, path, body=None, *, versioned=False, key=None):
|
|
270
|
+
headers = {}
|
|
271
|
+
if versioned:
|
|
272
|
+
current, read_headers = self.request("GET", path)
|
|
273
|
+
tag = read_headers.get("etag")
|
|
274
|
+
if not tag:
|
|
275
|
+
raise CLIError(
|
|
276
|
+
"invalid_response",
|
|
277
|
+
local_message("resource_is_missing_its_concurrency_etag"),
|
|
278
|
+
kind="transport",
|
|
279
|
+
)
|
|
280
|
+
headers["If-Match"] = tag
|
|
281
|
+
return self.request(
|
|
282
|
+
method, path, body, key=key or str(uuid.uuid4()), headers=headers
|
|
283
|
+
)[0]
|