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.
@@ -0,0 +1,5 @@
1
+ """WonderSearch command-line client."""
2
+
3
+ from ._version import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,3 @@
1
+ from .main import main
2
+
3
+ raise SystemExit(main())
@@ -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"
@@ -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]