memorysync-cli 1.1.2__tar.gz → 1.1.3__tar.gz

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.
Files changed (26) hide show
  1. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/PKG-INFO +1 -1
  2. memorysync_cli-1.1.3/src/memorysync_cli/_version.py +1 -0
  3. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/src/memorysync_cli/http.py +149 -16
  4. memorysync_cli-1.1.2/src/memorysync_cli/_version.py +0 -1
  5. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/.gitignore +0 -0
  6. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/LICENSE +0 -0
  7. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/README.md +0 -0
  8. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/pyproject.toml +0 -0
  9. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/src/memorysync_cli/__init__.py +0 -0
  10. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/src/memorysync_cli/__main__.py +0 -0
  11. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/src/memorysync_cli/args.py +0 -0
  12. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/src/memorysync_cli/commands/__init__.py +0 -0
  13. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/src/memorysync_cli/commands/admin.py +0 -0
  14. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/src/memorysync_cli/commands/init.py +0 -0
  15. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/src/memorysync_cli/commands/memory.py +0 -0
  16. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/src/memorysync_cli/commands/source.py +0 -0
  17. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/src/memorysync_cli/commands/tooling.py +0 -0
  18. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/src/memorysync_cli/completions.py +0 -0
  19. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/src/memorysync_cli/config.py +0 -0
  20. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/src/memorysync_cli/credentials.py +0 -0
  21. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/src/memorysync_cli/errors.py +0 -0
  22. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/src/memorysync_cli/evaluation.py +0 -0
  23. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/src/memorysync_cli/main.py +0 -0
  24. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/src/memorysync_cli/output.py +0 -0
  25. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/src/memorysync_cli/registry.json +0 -0
  26. {memorysync_cli-1.1.2 → memorysync_cli-1.1.3}/src/memorysync_cli/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memorysync-cli
3
- Version: 1.1.2
3
+ Version: 1.1.3
4
4
  Summary: MemorySync from your terminal. Zero dependencies.
5
5
  Project-URL: Documentation, https://docs.memorysync.io/cli
6
6
  Project-URL: Homepage, https://memorysync.io/cli
@@ -0,0 +1 @@
1
+ __version__ = "1.1.3"
@@ -121,13 +121,25 @@ class ApiClient:
121
121
  started = time.monotonic()
122
122
  request = urllib.request.Request(url, data=payload, headers=headers, method=method)
123
123
 
124
+ # Headers are kept, not discarded: the API returns the request id in
125
+ # `x-request-id`, and it is the only handle support has on a failure. The
126
+ # Node CLI printed it and this one did not.
127
+ response_headers: Any = None
128
+ status_reason = ""
129
+
124
130
  try:
125
131
  with urllib.request.urlopen(request, timeout=limit_ms / 1000) as response:
126
132
  text = response.read().decode("utf-8", errors="replace")
127
133
  status = response.status
134
+ response_headers = response.headers
128
135
  except urllib.error.HTTPError as error:
129
136
  text = error.read().decode("utf-8", errors="replace")
130
137
  status = error.code
138
+ response_headers = error.headers
139
+ # The status phrase, so a body with no usable message reads
140
+ # "400 Bad Request" as it does under the Node CLI, rather than this
141
+ # CLI's own wording.
142
+ status_reason = str(getattr(error, "reason", "") or "")
131
143
  except TimeoutError:
132
144
  raise network_error(
133
145
  f"Request to {path} timed out after {limit_ms}ms.",
@@ -156,25 +168,129 @@ class ApiClient:
156
168
  if 200 <= status < 300:
157
169
  return parsed
158
170
 
159
- raise self._to_error(status, parsed)
171
+ raise self._to_error(status, parsed, response_headers, status_reason)
172
+
173
+ @staticmethod
174
+ def _request_id(payload: Any, headers: Any) -> str | None:
175
+ from_header = headers.get("x-request-id") if headers is not None else None
176
+ if from_header:
177
+ return from_header
178
+ if isinstance(payload, dict) and isinstance(payload.get("request_id"), str):
179
+ return payload["request_id"]
180
+ return None
181
+
182
+ def _from_oauth_error(
183
+ self, status: int, body: dict, request_id: str | None, headers: Any
184
+ ) -> CliError:
185
+ """An ``{error, error_description}`` body, as ``/evaluation/*`` returns.
186
+
187
+ The exit code comes from the code rather than the HTTP status, because the
188
+ status cannot separate these: a refused claim and an already-claimed
189
+ account are both 409, and only one of them is worth retrying with
190
+ different input. Mirrors ``#fromOAuthError`` in the Node CLI.
191
+ """
192
+ code = body["error"]
193
+ message = body["error_description"]
194
+
195
+ if code == "rate_limited":
196
+ retry_after = headers.get("retry-after") if headers is not None else None
197
+ return CliError(
198
+ message,
199
+ exit_code=Exit.NETWORK,
200
+ code=code,
201
+ hint=(
202
+ f"Wait {retry_after} seconds and try again."
203
+ if retry_after
204
+ else "Wait and try again."
205
+ ),
206
+ request_id=request_id,
207
+ )
208
+
209
+ # Wrong input the caller can correct, or a state it cannot argue with.
210
+ # Both are exit 2: the fix is a different command, not a retry.
211
+ correctable = {
212
+ "invalid_email",
213
+ "email_already_registered",
214
+ "invalid_code",
215
+ "code_expired",
216
+ "too_many_attempts",
217
+ "not_an_evaluation_key",
218
+ "already_claimed",
219
+ "unclaimable",
220
+ }
221
+ if code in correctable:
222
+ return CliError(
223
+ message, exit_code=Exit.USAGE, code=code, request_id=request_id
224
+ )
160
225
 
161
- def _to_error(self, status: int, payload: Any) -> CliError:
226
+ if status in (401, 403):
227
+ return auth_error(message)
228
+ if status == 404:
229
+ return not_found_error(message)
230
+ return CliError(
231
+ message, exit_code=Exit.FAILURE, code=code, request_id=request_id
232
+ )
233
+
234
+ def _to_error(
235
+ self,
236
+ status: int,
237
+ payload: Any,
238
+ headers: Any = None,
239
+ reason: str = "",
240
+ ) -> CliError:
162
241
  """Map a failed response to the taxonomy.
163
242
 
243
+ Mirrors ``#toError`` in ``sdk/cli/src/http.mjs`` step for step. The
244
+ unwrapping below is the part that had drifted: this CLI treated ``detail``
245
+ itself as the carrier and never looked inside ``detail.error``, so the
246
+ nested shape the API uses on several routes lost both its sentence and its
247
+ code and fell through to a generic "Request failed with status 400."
248
+
164
249
  The status alone is not enough for 429: a plan limit and a rate limit both
165
250
  arrive as 429 but mean different things to a script. Only the first is
166
251
  about the plan, and only the first is pointless to retry.
167
252
  """
168
- detail = payload.get("detail") if isinstance(payload, dict) else None
169
- inner = detail if isinstance(detail, dict) else None
170
- message = (
171
- (inner or {}).get("message")
172
- or (detail if isinstance(detail, str) else None)
173
- or (payload.get("message") if isinstance(payload, dict) else None)
174
- or f"Request failed with status {status}."
175
- )
176
- code = (inner or {}).get("code")
253
+ request_id = self._request_id(payload, headers)
254
+
255
+ # Node: `payload?.detail ?? payload`
256
+ detail: Any = payload
257
+ if isinstance(payload, dict) and payload.get("detail") is not None:
258
+ detail = payload["detail"]
259
+
260
+ # `/evaluation/*` answers in the OAuth error shape. Without this branch the
261
+ # sentence in `error_description` was dropped entirely and this CLI
262
+ # reported "Request failed with status 409." for a refused claim, while the
263
+ # Node CLI reported the bare code `email_already_registered`. Neither told
264
+ # the user that the address already has an account.
265
+ if (
266
+ isinstance(detail, dict)
267
+ and isinstance(detail.get("error"), str)
268
+ and isinstance(detail.get("error_description"), str)
269
+ ):
270
+ return self._from_oauth_error(status, detail, request_id, headers)
271
+
272
+ # Node: `detail?.error ?? detail`
273
+ inner: Any = detail
274
+ if isinstance(detail, dict) and detail.get("error") is not None:
275
+ inner = detail["error"]
276
+
277
+ message = inner if isinstance(inner, str) else None
278
+ if message is None and isinstance(inner, dict):
279
+ message = inner.get("message")
280
+ if not message and isinstance(payload, dict):
281
+ message = payload.get("message")
282
+ if not message:
283
+ message = f"{status} {reason}".strip() if reason else (
284
+ f"Request failed with status {status}."
285
+ )
177
286
 
287
+ code = inner.get("code") if isinstance(inner, dict) else None
288
+
289
+ # Every branch below matches the Node CLI's `#toError`, including exit
290
+ # codes. They had drifted: a 500 exited 5 here and 1 there, so a script
291
+ # retrying on "network" retried a server error under one CLI and gave up
292
+ # under the other. The fallback code was `api_error` here and
293
+ # `http_<status>` there, and the request id was dropped here entirely.
178
294
  if status in (401, 403):
179
295
  return auth_error(message)
180
296
  if status == 404:
@@ -188,17 +304,34 @@ class ApiClient:
188
304
  return CliError(
189
305
  message,
190
306
  exit_code=Exit.NETWORK,
191
- code="rate_limited",
307
+ code=code or "rate_limited",
192
308
  hint="Too many requests. Wait and retry.",
309
+ request_id=request_id,
310
+ )
311
+ if code == "MISSING_END_USER_ID":
312
+ return CliError(
313
+ "This command needs an end user.",
314
+ exit_code=Exit.USAGE,
315
+ code=code,
316
+ hint="Pass --user <id>, or set a default with `memorysync init --user <id>`.",
317
+ request_id=request_id,
193
318
  )
194
319
  if status >= 500:
195
320
  return CliError(
196
- message,
197
- exit_code=Exit.NETWORK,
321
+ f"The API returned {status}: {message}",
322
+ exit_code=Exit.FAILURE,
198
323
  code="server_error",
199
- hint="The API failed. Retry, and check status if it persists.",
324
+ hint=(
325
+ f"Quote request id {request_id} to support." if request_id else None
326
+ ),
327
+ request_id=request_id,
200
328
  )
201
- return CliError(message, exit_code=Exit.FAILURE, code=code or "api_error")
329
+ return CliError(
330
+ message,
331
+ exit_code=Exit.FAILURE,
332
+ code=code or f"http_{status}",
333
+ request_id=request_id,
334
+ )
202
335
 
203
336
  # -----------------------------------------------------------------------
204
337
  # Named endpoints, so commands never carry raw paths
@@ -1 +0,0 @@
1
- __version__ = "1.1.2"
File without changes
File without changes