memorysync-cli 1.1.4__tar.gz → 1.2.1__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.4 → memorysync_cli-1.2.1}/PKG-INFO +1 -1
  2. memorysync_cli-1.2.1/src/memorysync_cli/_version.py +1 -0
  3. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/commands/memory.py +203 -39
  4. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/http.py +9 -0
  5. memorysync_cli-1.1.4/src/memorysync_cli/_version.py +0 -1
  6. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/.gitignore +0 -0
  7. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/LICENSE +0 -0
  8. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/README.md +0 -0
  9. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/pyproject.toml +0 -0
  10. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/__init__.py +0 -0
  11. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/__main__.py +0 -0
  12. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/args.py +0 -0
  13. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/commands/__init__.py +0 -0
  14. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/commands/admin.py +0 -0
  15. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/commands/init.py +0 -0
  16. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/commands/source.py +0 -0
  17. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/commands/tooling.py +0 -0
  18. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/completions.py +0 -0
  19. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/config.py +0 -0
  20. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/credentials.py +0 -0
  21. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/errors.py +0 -0
  22. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/evaluation.py +0 -0
  23. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/main.py +0 -0
  24. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/output.py +0 -0
  25. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/registry.json +0 -0
  26. {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/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.4
3
+ Version: 1.2.1
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.2.1"
@@ -505,11 +505,34 @@ def import_memories(ctx: dict) -> dict:
505
505
  try:
506
506
  raw = Path(source_path).read_text(encoding="utf-8")
507
507
  except OSError as error:
508
- raise usage_error(f"Could not read {source_path}: {error}") from None
508
+ raise usage_error(
509
+ f"Could not read {source_path}: {_describe_read_failure(error)}"
510
+ ) from None
509
511
 
510
512
  records: list[dict] = []
511
513
  errors: list[str] = []
512
514
 
515
+ def _validate(number: int, entry: Any) -> None:
516
+ """Record one candidate as either usable or an error, in line order."""
517
+ if not isinstance(entry, dict):
518
+ errors.append(f"line {number}: expected an object")
519
+ return
520
+ text = entry.get("text") or entry.get("memory") or entry.get("content")
521
+ # Has to be a non-blank string, not merely truthy. ``{"text": 123}``
522
+ # would otherwise be stringified into a memory here and refused by the
523
+ # Node CLI, and the two must agree on what the file means.
524
+ if not isinstance(text, str) or not text.strip():
525
+ errors.append(f"line {number}: no text, memory or content field")
526
+ return
527
+ record: dict[str, Any] = {
528
+ "text": text.strip(),
529
+ "source": entry.get("source") or "cli-import",
530
+ "line": number,
531
+ }
532
+ if isinstance(entry.get("metadata"), dict):
533
+ record["metadata"] = entry["metadata"]
534
+ records.append(record)
535
+
513
536
  stripped = raw.strip()
514
537
  if stripped.startswith("["):
515
538
  try:
@@ -518,66 +541,207 @@ def import_memories(ctx: dict) -> dict:
518
541
  raise usage_error(f"{source_path} is not valid JSON: {error}") from None
519
542
  if not isinstance(parsed, list):
520
543
  raise usage_error(f"{source_path} must contain a JSON array.")
521
- candidates = list(enumerate(parsed, start=1))
544
+ for number, entry in enumerate(parsed, start=1):
545
+ _validate(number, entry)
522
546
  else:
523
- candidates = []
547
+ # One ordered pass. Collecting parse failures first and field failures
548
+ # afterwards reported a bad line 3 ahead of a bad line 2, which the Node
549
+ # CLI does not do and which reads as though the file were shuffled.
524
550
  for number, line in enumerate(stripped.splitlines(), start=1):
525
551
  if not line.strip():
526
552
  continue
527
553
  try:
528
- candidates.append((number, json.loads(line)))
554
+ entry = json.loads(line)
529
555
  except ValueError as error:
530
556
  errors.append(f"line {number}: {error}")
557
+ continue
558
+ _validate(number, entry)
531
559
 
532
- for number, entry in candidates:
533
- if not isinstance(entry, dict):
534
- errors.append(f"line {number}: expected an object")
535
- continue
536
- text = entry.get("text") or entry.get("memory") or entry.get("content")
537
- if not text:
538
- errors.append(f"line {number}: no text, memory or content field")
539
- continue
540
- record: dict[str, Any] = {"text": str(text), "source": entry.get("source") or "import"}
541
- if isinstance(entry.get("metadata"), dict):
542
- record["metadata"] = entry["metadata"]
543
- records.append(record)
560
+ # The dry run reports problems; it does not refuse because of them. Checking
561
+ # this before the error gate is the whole point of the flag: someone running
562
+ # --dry-run on a file they suspect is malformed wants the list of what is
563
+ # wrong, not a single error telling them to fix it first.
564
+ if flags.get("dry_run"):
565
+ preview = list(errors[:10])
566
+ trailer = len(errors) - len(preview)
567
+ return {
568
+ "data": {
569
+ "file": source_path,
570
+ "valid": len(records),
571
+ "invalid": len(errors),
572
+ "errors": errors,
573
+ },
574
+ "text": lambda: "\n".join(
575
+ line
576
+ for line in [
577
+ style.yellow("Dry run. Nothing was sent."),
578
+ f"{style.dim('valid ')} {len(records)}",
579
+ f"{style.dim('invalid ')} {len(errors)}",
580
+ *[style.dim(f" {message}") for message in preview],
581
+ style.dim(f" ... and {trailer} more") if trailer > 0 else "",
582
+ style.yellow(
583
+ "A real run would refuse this file. Pass --continue-on-error "
584
+ "to skip bad rows."
585
+ )
586
+ if errors and not flags.get("continue_on_error")
587
+ else "",
588
+ ]
589
+ if line
590
+ ),
591
+ }
544
592
 
545
- if errors:
593
+ if errors and not flags.get("continue_on_error"):
546
594
  raise usage_error(
547
- f"{source_path} has {len(errors)} invalid row(s); nothing was imported.",
548
- "; ".join(errors[:5]) + ("; ..." if len(errors) > 5 else ""),
595
+ f"{len(errors)} record(s) in {source_path} are not usable.",
596
+ f"First problem: {errors[0]}. Run again with --dry-run to see them all, "
597
+ "or --continue-on-error to skip bad rows.",
549
598
  )
550
599
  if not records:
551
600
  raise usage_error(f"{source_path} contained no records.")
552
601
 
553
- if flags.get("dry_run"):
554
- return {
555
- "data": {"would_import": len(records), "user": user},
556
- "text": lambda: style.yellow(
557
- f"Dry run. {len(records)} record(s) validated. Nothing was imported."
558
- ),
559
- }
602
+ batch_size = _resolve_batch_size(flags)
603
+ batches = [records[i : i + batch_size] for i in range(0, len(records), batch_size)]
604
+ totals = {
605
+ "total": len(records),
606
+ "imported": 0,
607
+ "memory_ids": [],
608
+ "skipped": 0,
609
+ "failed": 0,
610
+ "unreported": 0,
611
+ "failures": [],
612
+ "batches": len(batches),
613
+ "batch_size": batch_size,
614
+ }
560
615
 
561
- stored, skipped = [], 0
562
- for record in records:
563
- created = api.add_memory(record)
564
- memory = _normalize_memory(created)
565
- if memory["id"]:
566
- stored.append(memory["id"])
567
- else:
568
- skipped += 1
616
+ for batch in batches:
617
+ before = totals["failed"]
618
+ items = [
619
+ {
620
+ key: value
621
+ for key, value in record.items()
622
+ if key in {"text", "source", "metadata"}
623
+ }
624
+ for record in batch
625
+ ]
626
+ try:
627
+ response = api.bulk_add_memories({"items": items}) or {}
628
+ except Exception as error: # noqa: BLE001 — reported, not raised
629
+ # The whole batch never reached the server, so every record in it is
630
+ # unaccounted for. Reported as a line range because that is what the
631
+ # operator needs to retry.
632
+ totals["failed"] += len(batch)
633
+ totals["failures"].append(
634
+ f"lines {batch[0]['line']}-{batch[-1]['line']}: {error}"
635
+ )
636
+ if not flags.get("continue_on_error"):
637
+ break
638
+ continue
639
+
640
+ per_item = response.get("results") if isinstance(response, dict) else None
641
+ if not isinstance(per_item, list):
642
+ # The server accepted the batch without per-item detail. Counted as
643
+ # unreported rather than imported: claiming these were stored would
644
+ # be a guess, and a wrong one whenever the batch was not written.
645
+ totals["unreported"] += len(batch)
646
+ continue
647
+
648
+ for item in per_item:
649
+ index = item.get("index")
650
+ record = batch[index] if isinstance(index, int) and 0 <= index < len(batch) else None
651
+ where = f"line {record['line']}" if record else f"item {index}"
652
+ status = item.get("status")
653
+ if status == "created":
654
+ totals["imported"] += 1
655
+ totals["memory_ids"].extend(item.get("memory_ids") or [])
656
+ elif status == "skipped":
657
+ totals["skipped"] += 1
658
+ else:
659
+ totals["failed"] += 1
660
+ totals["failures"].append(f"{where}: {item.get('reason') or 'rejected'}")
661
+
662
+ if totals["failed"] > before and not flags.get("continue_on_error"):
663
+ break
569
664
 
570
665
  return {
571
- "data": {"imported": stored, "count": len(stored), "skipped": skipped, "user": user},
666
+ "data": {"file": source_path, "user": user, **totals},
572
667
  "text": lambda: "\n".join(
573
- [
574
- f"{style.green('Imported')} {len(stored)} of {len(records)}",
575
- style.dim(f" {skipped} skipped as duplicate or low value") if skipped else "",
668
+ line
669
+ for line in [
670
+ f"{style.green('Imported')} {totals['imported']} of {totals['total']}",
671
+ style.dim(f" {totals['skipped']} skipped as duplicate or low value")
672
+ if totals["skipped"]
673
+ else "",
674
+ style.dim(
675
+ f" {totals['unreported']} accepted without a per-record result"
676
+ )
677
+ if totals["unreported"]
678
+ else "",
679
+ style.red(f" {totals['failed']} failed") if totals["failed"] else "",
680
+ *[style.dim(f" {f}") for f in totals["failures"][:5]],
681
+ style.dim(f" ... and {len(totals['failures']) - 5} more")
682
+ if len(totals["failures"]) > 5
683
+ else "",
576
684
  ]
577
- ).strip(),
685
+ if line
686
+ ),
578
687
  }
579
688
 
580
689
 
690
+ # Records the server accepts in one ``/memory/bulk-add`` request. The route
691
+ # rejects a larger payload outright, so this is a ceiling and not a preference: a
692
+ # higher ``--batch-size`` is clamped rather than sent and refused.
693
+ SERVER_BULK_MAX = 50
694
+
695
+
696
+ def _describe_read_failure(error: OSError) -> str:
697
+ """Why a file could not be read, worded identically to the Node CLI.
698
+
699
+ ``str(error)`` cannot be used directly: Python says
700
+ ``[Errno 2] No such file or directory: 'relative'`` and Node says
701
+ ``ENOENT: no such file or directory, open 'C:\\abs\\path'`` — different
702
+ wording, different casing, and Node leaks an absolute path the user did not
703
+ type. The two packages are published as interchangeable, so the common cases
704
+ are named here and the rest fall back to a sentence neither runtime supplies.
705
+ """
706
+ import errno
707
+
708
+ if isinstance(error, IsADirectoryError) or error.errno == errno.EISDIR:
709
+ return "that is a directory, not a file."
710
+ if isinstance(error, PermissionError) or error.errno in {errno.EACCES, errno.EPERM}:
711
+ return "permission denied."
712
+ if isinstance(error, FileNotFoundError) or error.errno == errno.ENOENT:
713
+ return "no such file or directory."
714
+ return "the file could not be opened."
715
+
716
+
717
+ def _resolve_batch_size(flags: dict) -> int:
718
+ """Effective batch size for this run.
719
+
720
+ Defaults to the server maximum, which is what the flag documents. A value
721
+ above the maximum is clamped instead of rejected — batch size is a transport
722
+ detail, and failing a 10,000-row import because someone asked for 500 per
723
+ request would be pedantry. The effective value is reported back in the result
724
+ so the clamp is visible rather than silent.
725
+ """
726
+ requested = flags.get("batch_size")
727
+ if requested is None:
728
+ return SERVER_BULK_MAX
729
+ try:
730
+ value = int(str(requested).strip())
731
+ except (TypeError, ValueError):
732
+ value = 0
733
+ if value < 1:
734
+ # Quoted by hand rather than with ``!r``. ``repr`` gives single quotes and
735
+ # the Node CLI's idiom gives double ones, and the two are published as
736
+ # interchangeable.
737
+ raise usage_error(
738
+ "--batch-size must be a whole number of at least 1.",
739
+ f'Got "{requested}". The server accepts at most '
740
+ f"{SERVER_BULK_MAX} records per request.",
741
+ )
742
+ return min(value, SERVER_BULK_MAX)
743
+
744
+
581
745
  def export_memories(ctx: dict) -> dict:
582
746
  user = _require_user(ctx)
583
747
  flags, api = ctx["flags"], ctx["api"]
@@ -368,6 +368,15 @@ class ApiClient:
368
368
  """
369
369
  return self.request("GET", "/evaluation/usage")
370
370
 
371
+ def bulk_add_memories(self, body: dict) -> Any:
372
+ """Ingest many records in one request.
373
+
374
+ Answers ``207`` with a per-item outcome for each record, so a batch is
375
+ routinely part success. The caller has to read ``results`` rather than
376
+ the status code to know what happened to any given record.
377
+ """
378
+ return self.request("POST", "/memory/bulk-add", body=body)
379
+
371
380
  def add_memory(self, body: dict) -> Any:
372
381
  return self.request("POST", "/memory/add", body=body)
373
382
 
@@ -1 +0,0 @@
1
- __version__ = "1.1.4"
File without changes
File without changes