memorysync-cli 1.3.0__tar.gz → 1.4.0__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.
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/PKG-INFO +1 -1
- memorysync_cli-1.4.0/src/memorysync_cli/_version.py +1 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/src/memorysync_cli/commands/memory.py +118 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/src/memorysync_cli/http.py +97 -1
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/src/memorysync_cli/main.py +1 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/src/memorysync_cli/registry.json +38 -3
- memorysync_cli-1.3.0/src/memorysync_cli/_version.py +0 -1
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/.gitignore +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/LICENSE +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/README.md +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/pyproject.toml +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/src/memorysync_cli/__init__.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/src/memorysync_cli/__main__.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/src/memorysync_cli/args.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/src/memorysync_cli/commands/__init__.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/src/memorysync_cli/commands/admin.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/src/memorysync_cli/commands/init.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/src/memorysync_cli/commands/source.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/src/memorysync_cli/commands/tooling.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/src/memorysync_cli/completions.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/src/memorysync_cli/config.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/src/memorysync_cli/credentials.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/src/memorysync_cli/errors.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/src/memorysync_cli/evaluation.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/src/memorysync_cli/output.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.0}/src/memorysync_cli/registry.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.4.0"
|
|
@@ -627,6 +627,25 @@ def import_memories(ctx: dict) -> dict:
|
|
|
627
627
|
"--resume.",
|
|
628
628
|
)
|
|
629
629
|
|
|
630
|
+
# --async hands the whole file to the server and returns a job id. The
|
|
631
|
+
# synchronous path below is capped at 50 records per request by the route, so a
|
|
632
|
+
# large file is thousands of round trips held open by one command; this is one
|
|
633
|
+
# upload and a poll.
|
|
634
|
+
if flags.get("async"):
|
|
635
|
+
job = api.create_import(
|
|
636
|
+
filename=Path(source_path).name,
|
|
637
|
+
content=raw.encode("utf-8"),
|
|
638
|
+
end_user_id=user,
|
|
639
|
+
resume=bool(flags.get("resume")),
|
|
640
|
+
continue_on_error=bool(flags.get("continue_on_error")),
|
|
641
|
+
)
|
|
642
|
+
waited = bool(flags.get("wait"))
|
|
643
|
+
finished = _poll_import(api, job["id"]) if waited else job
|
|
644
|
+
return {
|
|
645
|
+
"data": {"file": source_path, "user": user, **finished},
|
|
646
|
+
"text": lambda: _render_import_job(finished, waited=waited),
|
|
647
|
+
}
|
|
648
|
+
|
|
630
649
|
batch_size = _resolve_batch_size(flags)
|
|
631
650
|
batches = [records[i : i + batch_size] for i in range(0, len(records), batch_size)]
|
|
632
651
|
totals = {
|
|
@@ -738,6 +757,105 @@ def import_memories(ctx: dict) -> dict:
|
|
|
738
757
|
# higher ``--batch-size`` is clamped rather than sent and refused.
|
|
739
758
|
SERVER_BULK_MAX = 50
|
|
740
759
|
|
|
760
|
+
#: Terminal job states. Polling stops at any of these.
|
|
761
|
+
IMPORT_TERMINAL = frozenset({"completed", "failed", "cancelled"})
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _poll_import(api, job_id: str) -> dict:
|
|
765
|
+
"""Poll a job until it finishes.
|
|
766
|
+
|
|
767
|
+
Backs off from one second to five. A tight loop on a job that takes an hour is
|
|
768
|
+
thousands of pointless requests, and a fixed long interval makes a ten-second
|
|
769
|
+
job feel broken.
|
|
770
|
+
"""
|
|
771
|
+
import time
|
|
772
|
+
|
|
773
|
+
wait_s = 1.0
|
|
774
|
+
while True:
|
|
775
|
+
job = api.get_import(job_id) or {}
|
|
776
|
+
if job.get("status") in IMPORT_TERMINAL:
|
|
777
|
+
return job
|
|
778
|
+
time.sleep(wait_s)
|
|
779
|
+
wait_s = min(5.0, wait_s * 1.5)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _render_import_job(job: dict, *, waited: bool = False) -> str:
|
|
783
|
+
"""One job as a human-readable block.
|
|
784
|
+
|
|
785
|
+
Shared by ``import --async`` and ``import-status``.
|
|
786
|
+
"""
|
|
787
|
+
status = job.get("status") or "unknown"
|
|
788
|
+
if status == "completed":
|
|
789
|
+
label = style.green("Completed")
|
|
790
|
+
elif status == "failed":
|
|
791
|
+
label = style.red("Failed")
|
|
792
|
+
elif status == "cancelled":
|
|
793
|
+
label = style.yellow("Cancelled")
|
|
794
|
+
else:
|
|
795
|
+
label = style.dim(status)
|
|
796
|
+
|
|
797
|
+
memories = job.get("memories_created") or 0
|
|
798
|
+
lines = [f"{label} {style.dim(job.get('id') or '')}"]
|
|
799
|
+
if job.get("total_rows"):
|
|
800
|
+
lines.append(
|
|
801
|
+
f"{style.dim('progress')} {job.get('processed_rows')}/{job.get('total_rows')}"
|
|
802
|
+
f" ({job.get('progress_percentage')}%)"
|
|
803
|
+
)
|
|
804
|
+
lines.append(
|
|
805
|
+
f"{style.dim('imported')} {job.get('created') or 0} record(s), "
|
|
806
|
+
f"{memories} memor{'y' if memories == 1 else 'ies'}"
|
|
807
|
+
)
|
|
808
|
+
if job.get("already_imported"):
|
|
809
|
+
lines.append(
|
|
810
|
+
style.dim(f" {job['already_imported']} already imported on an earlier run")
|
|
811
|
+
)
|
|
812
|
+
if job.get("skipped"):
|
|
813
|
+
lines.append(style.dim(f" {job['skipped']} skipped as duplicate or low value"))
|
|
814
|
+
if job.get("failed"):
|
|
815
|
+
lines.append(style.red(f" {job['failed']} failed"))
|
|
816
|
+
for problem in (job.get("invalid_rows") or [])[:5]:
|
|
817
|
+
lines.append(style.dim(f" {problem}"))
|
|
818
|
+
if job.get("error_message"):
|
|
819
|
+
lines.append(style.red(job["error_message"]))
|
|
820
|
+
if not waited and status not in IMPORT_TERMINAL:
|
|
821
|
+
lines.append(style.dim(f"Check again with: memorysync import-status {job.get('id')}"))
|
|
822
|
+
return "\n".join(lines)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def import_status(ctx: dict) -> dict:
|
|
826
|
+
"""Poll, list, or cancel a background import."""
|
|
827
|
+
flags, positionals, api = ctx["flags"], ctx["positionals"], ctx["api"]
|
|
828
|
+
job_id = positionals[0] if positionals else None
|
|
829
|
+
|
|
830
|
+
if not job_id:
|
|
831
|
+
response = api.list_imports(flags.get("limit")) or {}
|
|
832
|
+
jobs = response.get("jobs") or []
|
|
833
|
+
return {
|
|
834
|
+
"data": {"jobs": jobs, "count": len(jobs)},
|
|
835
|
+
"text": lambda: (
|
|
836
|
+
style.dim("No imports yet.")
|
|
837
|
+
if not jobs
|
|
838
|
+
else "\n\n".join(_render_import_job(job) for job in jobs)
|
|
839
|
+
),
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if flags.get("cancel"):
|
|
843
|
+
job = api.cancel_import(job_id) or {}
|
|
844
|
+
return {
|
|
845
|
+
"data": job,
|
|
846
|
+
"text": lambda: "\n".join(
|
|
847
|
+
[
|
|
848
|
+
style.yellow("Cancellation requested."),
|
|
849
|
+
style.dim("Records already imported stay imported."),
|
|
850
|
+
_render_import_job(job),
|
|
851
|
+
]
|
|
852
|
+
),
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
waited = bool(flags.get("wait"))
|
|
856
|
+
job = _poll_import(api, job_id) if waited else (api.get_import(job_id) or {})
|
|
857
|
+
return {"data": job, "text": lambda: _render_import_job(job, waited=waited)}
|
|
858
|
+
|
|
741
859
|
|
|
742
860
|
def _pick_ref(entry: dict, ref_field: str | None) -> str | None:
|
|
743
861
|
"""The record's own id, for ``--resume``.
|
|
@@ -26,6 +26,8 @@ import time
|
|
|
26
26
|
import urllib.error
|
|
27
27
|
import urllib.parse
|
|
28
28
|
import urllib.request
|
|
29
|
+
import uuid
|
|
30
|
+
from dataclasses import dataclass, field
|
|
29
31
|
from typing import Any
|
|
30
32
|
|
|
31
33
|
from ._version import __version__
|
|
@@ -42,6 +44,50 @@ from .errors import (
|
|
|
42
44
|
END_USER_HEADER = "X-End-User-ID"
|
|
43
45
|
|
|
44
46
|
|
|
47
|
+
@dataclass
|
|
48
|
+
class MultipartBody:
|
|
49
|
+
"""One file plus simple text fields, for an upload."""
|
|
50
|
+
|
|
51
|
+
filename: str
|
|
52
|
+
content: bytes
|
|
53
|
+
fields: dict[str, str] = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _encode_multipart(part: MultipartBody) -> tuple[bytes, str]:
|
|
57
|
+
"""Build a ``multipart/form-data`` body by hand.
|
|
58
|
+
|
|
59
|
+
Written out rather than pulled from a library because this package has zero
|
|
60
|
+
dependencies, and the Node CLI's built-in ``FormData`` produces the same wire
|
|
61
|
+
format, so the two uploads are interchangeable.
|
|
62
|
+
|
|
63
|
+
The boundary is random per request. A fixed one would break the moment a
|
|
64
|
+
payload happened to contain it.
|
|
65
|
+
"""
|
|
66
|
+
boundary = f"----MemorySync{uuid.uuid4().hex}"
|
|
67
|
+
lines: list[bytes] = []
|
|
68
|
+
|
|
69
|
+
for name, value in part.fields.items():
|
|
70
|
+
lines.append(f"--{boundary}\r\n".encode())
|
|
71
|
+
lines.append(
|
|
72
|
+
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode()
|
|
73
|
+
)
|
|
74
|
+
lines.append(f"{value}\r\n".encode())
|
|
75
|
+
|
|
76
|
+
# The filename is quoted and any quote inside it escaped, so a file called
|
|
77
|
+
# `my"file.jsonl` cannot break out of the header.
|
|
78
|
+
safe_name = part.filename.replace('"', '\\"')
|
|
79
|
+
lines.append(f"--{boundary}\r\n".encode())
|
|
80
|
+
lines.append(
|
|
81
|
+
f'Content-Disposition: form-data; name="file"; filename="{safe_name}"\r\n'.encode()
|
|
82
|
+
)
|
|
83
|
+
lines.append(b"Content-Type: application/octet-stream\r\n\r\n")
|
|
84
|
+
lines.append(part.content)
|
|
85
|
+
lines.append(b"\r\n")
|
|
86
|
+
lines.append(f"--{boundary}--\r\n".encode())
|
|
87
|
+
|
|
88
|
+
return b"".join(lines), f"multipart/form-data; boundary={boundary}"
|
|
89
|
+
|
|
90
|
+
|
|
45
91
|
class ApiClient:
|
|
46
92
|
"""A thin, synchronous client over the MemorySync API."""
|
|
47
93
|
|
|
@@ -100,6 +146,7 @@ class ApiClient:
|
|
|
100
146
|
body: Any = None,
|
|
101
147
|
query: dict[str, Any] | None = None,
|
|
102
148
|
timeout: int | None = None,
|
|
149
|
+
multipart: "MultipartBody | None" = None,
|
|
103
150
|
) -> Any:
|
|
104
151
|
url = self.base_url + path
|
|
105
152
|
if query:
|
|
@@ -113,7 +160,10 @@ class ApiClient:
|
|
|
113
160
|
|
|
114
161
|
payload = None
|
|
115
162
|
headers = self.headers()
|
|
116
|
-
if
|
|
163
|
+
if multipart is not None:
|
|
164
|
+
payload, content_type = _encode_multipart(multipart)
|
|
165
|
+
headers["Content-Type"] = content_type
|
|
166
|
+
elif body is not None:
|
|
117
167
|
payload = json.dumps(body).encode("utf-8")
|
|
118
168
|
headers["Content-Type"] = "application/json"
|
|
119
169
|
|
|
@@ -368,6 +418,52 @@ class ApiClient:
|
|
|
368
418
|
"""
|
|
369
419
|
return self.request("GET", "/evaluation/usage")
|
|
370
420
|
|
|
421
|
+
# ── Asynchronous bulk import ──────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
def create_import(
|
|
424
|
+
self,
|
|
425
|
+
*,
|
|
426
|
+
filename: str,
|
|
427
|
+
content: bytes,
|
|
428
|
+
end_user_id: str | None,
|
|
429
|
+
resume: bool,
|
|
430
|
+
continue_on_error: bool,
|
|
431
|
+
) -> Any:
|
|
432
|
+
"""Upload a payload and get a job id back.
|
|
433
|
+
|
|
434
|
+
Sent as multipart rather than JSON because the payload can be tens of
|
|
435
|
+
megabytes and the server streams it out of storage rather than holding it.
|
|
436
|
+
"""
|
|
437
|
+
fields: dict[str, str] = {
|
|
438
|
+
"resume": "true" if resume else "false",
|
|
439
|
+
"continue_on_error": "true" if continue_on_error else "false",
|
|
440
|
+
}
|
|
441
|
+
if end_user_id:
|
|
442
|
+
fields["end_user_id"] = end_user_id
|
|
443
|
+
return self.request(
|
|
444
|
+
"POST",
|
|
445
|
+
"/imports",
|
|
446
|
+
multipart=MultipartBody(
|
|
447
|
+
fields=fields,
|
|
448
|
+
filename=filename,
|
|
449
|
+
content=content,
|
|
450
|
+
),
|
|
451
|
+
# An upload is not a 30-second request. The server validates the whole
|
|
452
|
+
# payload before answering.
|
|
453
|
+
timeout=300_000,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def get_import(self, job_id: str) -> Any:
|
|
457
|
+
return self.request("GET", f"/imports/{urllib.parse.quote(job_id, safe='')}")
|
|
458
|
+
|
|
459
|
+
def list_imports(self, limit: int | None = None) -> Any:
|
|
460
|
+
return self.request("GET", "/imports", query={"limit": limit})
|
|
461
|
+
|
|
462
|
+
def cancel_import(self, job_id: str) -> Any:
|
|
463
|
+
return self.request(
|
|
464
|
+
"POST", f"/imports/{urllib.parse.quote(job_id, safe='')}/cancel"
|
|
465
|
+
)
|
|
466
|
+
|
|
371
467
|
def bulk_add_memories(self, body: dict) -> Any:
|
|
372
468
|
"""Ingest many records in one request.
|
|
373
469
|
|
|
@@ -63,6 +63,7 @@ _HANDLERS: dict[str, Handler] = {
|
|
|
63
63
|
"update": memory.update,
|
|
64
64
|
"delete": memory.delete_memories,
|
|
65
65
|
"import": memory.import_memories,
|
|
66
|
+
"import-status": memory.import_status,
|
|
66
67
|
"export": memory.export_memories,
|
|
67
68
|
"quota": admin.quota,
|
|
68
69
|
"status": admin.status,
|
|
@@ -372,8 +372,8 @@
|
|
|
372
372
|
{
|
|
373
373
|
"name": "import",
|
|
374
374
|
"summary": "Bulk load memories from a JSON or JSONL file.",
|
|
375
|
-
"description": "Each record needs a text field, and may carry its own user and metadata. Validates the whole file before sending anything, and reports per-row errors with line numbers rather than failing on the first bad row. Pass --resume to make re-running the same file safe: each record is sent with an identifier the server remembers, so rows that already landed are reported as skipped instead of being stored a second time.",
|
|
376
|
-
"usage": "memorysync import <file> [--user <id>] [--batch-size <n>] [--resume]",
|
|
375
|
+
"description": "Each record needs a text field, and may carry its own user and metadata. Validates the whole file before sending anything, and reports per-row errors with line numbers rather than failing on the first bad row. Pass --resume to make re-running the same file safe: each record is sent with an identifier the server remembers, so rows that already landed are reported as skipped instead of being stored a second time. Pass --async for a large file: the file is uploaded once and processed in the background, and you get a job id to poll with `memorysync import-status`.",
|
|
376
|
+
"usage": "memorysync import <file> [--user <id>] [--batch-size <n>] [--resume] [--async]",
|
|
377
377
|
"flags": [
|
|
378
378
|
{
|
|
379
379
|
"name": "--user",
|
|
@@ -395,6 +395,14 @@
|
|
|
395
395
|
"value": "name",
|
|
396
396
|
"description": "Field holding each record’s own id, used by --resume. Default: id, then _id, then uuid."
|
|
397
397
|
},
|
|
398
|
+
{
|
|
399
|
+
"name": "--async",
|
|
400
|
+
"description": "Upload the file and return a job id instead of waiting. Use for large files."
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
"name": "--wait",
|
|
404
|
+
"description": "With --async, poll until the job finishes."
|
|
405
|
+
},
|
|
398
406
|
{
|
|
399
407
|
"name": "--continue-on-error",
|
|
400
408
|
"description": "Keep going after a failed batch."
|
|
@@ -406,11 +414,38 @@
|
|
|
406
414
|
],
|
|
407
415
|
"examples": [
|
|
408
416
|
"memorysync import memories.jsonl --user alice --dry-run",
|
|
409
|
-
"memorysync import memories.jsonl --user alice --resume"
|
|
417
|
+
"memorysync import memories.jsonl --user alice --resume",
|
|
418
|
+
"memorysync import memories.jsonl --user alice --resume --async --wait"
|
|
410
419
|
],
|
|
411
420
|
"requires_auth": true,
|
|
412
421
|
"consumes_quota": "add_requests"
|
|
413
422
|
},
|
|
423
|
+
{
|
|
424
|
+
"name": "import-status",
|
|
425
|
+
"summary": "Check a background import, or list recent ones.",
|
|
426
|
+
"description": "With a job id, reports progress and per-outcome counts. Without one, lists recent imports newest first. Pass --wait to poll until the job finishes, and --cancel to ask the worker to stop between batches — records already imported stay imported.",
|
|
427
|
+
"usage": "memorysync import-status [<job-id>] [--wait] [--cancel]",
|
|
428
|
+
"flags": [
|
|
429
|
+
{
|
|
430
|
+
"name": "--wait",
|
|
431
|
+
"description": "Poll until the job reaches a final state."
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
"name": "--cancel",
|
|
435
|
+
"description": "Ask the worker to stop between batches."
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
"name": "--limit",
|
|
439
|
+
"value": "n",
|
|
440
|
+
"description": "How many recent jobs to list. Default 20."
|
|
441
|
+
}
|
|
442
|
+
],
|
|
443
|
+
"examples": [
|
|
444
|
+
"memorysync import-status",
|
|
445
|
+
"memorysync import-status 3f9a1c7e-0b2d-4a51-9e88-1c2d3e4f5a6b --wait"
|
|
446
|
+
],
|
|
447
|
+
"requires_auth": true
|
|
448
|
+
},
|
|
414
449
|
{
|
|
415
450
|
"name": "export",
|
|
416
451
|
"summary": "Write a user's memories to a file or stdout.",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.3.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|