feat(cli): Platform option parity across Python and Node CLIs (MEM-5893) (#6696)

This commit is contained in:
Kartik
2026-08-03 17:10:44 +05:30
committed by GitHub
parent 50bdaaea0c
commit c90bdbdce0
29 changed files with 2030 additions and 541 deletions
+85 -4
View File
@@ -273,7 +273,21 @@ def add(
no_infer: bool = typer.Option(False, "--no-infer", help="Skip inference, store raw."),
expires: str | None = typer.Option(None, "--expires", help="Expiration date (YYYY-MM-DD)."),
categories: str | None = typer.Option(
None, "--categories", help="Categories (JSON array or comma-separated)."
None, "--categories", help="Not supported on add, use --custom-categories instead."
),
custom_instructions: str | None = typer.Option(
None, "--custom-instructions", help="Custom instructions for fact extraction."
),
custom_categories: str | None = typer.Option(
None,
"--custom-categories",
help="Custom categories as a JSON array of {name: description} objects.",
),
structured_data_schema: str | None = typer.Option(
None, "--structured-data-schema", help="Schema for structured data extraction, as JSON."
),
timestamp: int | None = typer.Option(
None, "--timestamp", help="Unix timestamp for the memory."
),
output: str = typer.Option(
"text", "--output", "-o", help="Output format: text, json, quiet.", rich_help_panel="Output"
@@ -312,6 +326,10 @@ def add(
no_infer=no_infer,
expires=expires,
categories=categories,
custom_instructions=custom_instructions,
custom_categories=custom_categories,
structured_data_schema=structured_data_schema,
timestamp=timestamp,
output=output,
)
@@ -355,6 +373,21 @@ def search(
help="Specific fields to return (comma-separated).",
rich_help_panel="Search",
),
show_expired: bool = typer.Option(
False, "--show-expired", help="Include expired memories.", rich_help_panel="Search"
),
reference_date: str | None = typer.Option(
None,
"--reference-date",
help="Reference date for relative queries (YYYY-MM-DD or unix timestamp).",
rich_help_panel="Search",
),
latest_only: bool = typer.Option(
False,
"--latest-only",
help="Only return the latest version of each memory.",
rich_help_panel="Search",
),
output: str = typer.Option(
"text", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
),
@@ -398,6 +431,9 @@ def search(
keyword=keyword,
filter_json=filter_json,
fields=fields,
show_expired=show_expired,
reference_date=reference_date,
latest_only=latest_only,
output=output,
)
@@ -464,6 +500,15 @@ def list_cmd(
before: str | None = typer.Option(
None, "--before", help="Created before (YYYY-MM-DD).", rich_help_panel="Filters"
),
show_expired: bool = typer.Option(
False, "--show-expired", help="Include expired memories.", rich_help_panel="Filters"
),
latest_only: bool = typer.Option(
False,
"--latest-only",
help="Only return the latest version of each memory.",
rich_help_panel="Filters",
),
output: str = typer.Option(
"table", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
),
@@ -497,6 +542,8 @@ def list_cmd(
category=category,
after=after,
before=before,
show_expired=show_expired,
latest_only=latest_only,
output=output,
)
@@ -509,6 +556,10 @@ def update(
memory_id: str = typer.Argument(..., help="Memory ID to update."),
text: str | None = typer.Argument(None, help="New memory text."),
metadata: str | None = typer.Option(None, "--metadata", "-m", help="Update metadata (JSON)."),
expires: str | None = typer.Option(None, "--expires", help="Expiration date (YYYY-MM-DD)."),
timestamp: int | None = typer.Option(
None, "--timestamp", help="Unix timestamp for the memory."
),
output: str = typer.Option(
"text", "--output", "-o", help="Output: text, json, quiet.", rich_help_panel="Output"
),
@@ -537,7 +588,15 @@ def update(
text = _read_stdin()
backend = _get_backend(api_key, base_url)
cmd_update(backend, memory_id, text, metadata=metadata, output=output)
cmd_update(
backend,
memory_id,
text,
metadata=metadata,
expires=expires,
timestamp=timestamp,
output=output,
)
# ── Memory: delete ────────────────────────────────────────────────────────
@@ -559,6 +618,9 @@ def delete(
False, "--dry-run", help="Show what would be deleted without deleting."
),
force: bool = typer.Option(False, "--force", help="Skip confirmation."),
delete_linked: bool = typer.Option(
False, "--delete-linked", help="Also delete memories linked to this memory."
),
user_id: str | None = typer.Option(
None, "--user-id", "-u", help="Scope to user.", rich_help_panel="Scope"
),
@@ -616,7 +678,14 @@ def delete(
from mem0_cli.commands.memory import cmd_delete
backend = _get_backend(api_key, base_url)
cmd_delete(backend, memory_id, dry_run=dry_run, force=force, output=output)
cmd_delete(
backend,
memory_id,
dry_run=dry_run,
force=force,
delete_linked=delete_linked,
output=output,
)
elif all_:
_fire_telemetry("delete", {"delete_mode": "all"})
@@ -1068,7 +1137,11 @@ def _build_help_json() -> dict:
"--immutable": "Prevent future updates.",
"--no-infer": "Skip inference, store raw.",
"--expires": "Expiration date (YYYY-MM-DD).",
"--categories": "Categories (JSON array or comma-separated).",
"--categories": "Not supported on add, use --custom-categories instead.",
"--custom-instructions": "Custom instructions for fact extraction.",
"--custom-categories": "Custom categories as a JSON array of {name: description} objects.",
"--structured-data-schema": "Schema for structured data extraction, as JSON.",
"--timestamp": "Unix timestamp for the memory.",
"--graph": "Enable graph memory extraction.",
"--no-graph": "Disable graph memory extraction.",
"--output, -o": "Output format: text, json, quiet.",
@@ -1087,6 +1160,9 @@ def _build_help_json() -> dict:
"--keyword": "Use keyword search instead of semantic.",
"--filter": "Advanced filter expression (JSON).",
"--fields": "Specific fields to return (comma-separated).",
"--show-expired": "Include expired memories.",
"--reference-date": "Reference date for relative queries (YYYY-MM-DD or unix timestamp).",
"--latest-only": "Only return the latest version of each memory.",
"--graph": "Enable graph in search.",
"--no-graph": "Disable graph in search.",
"--output, -o": "Output format: text, json, table.",
@@ -1110,6 +1186,8 @@ def _build_help_json() -> dict:
"--category": "Filter by category.",
"--after": "Created after (YYYY-MM-DD).",
"--before": "Created before (YYYY-MM-DD).",
"--show-expired": "Include expired memories.",
"--latest-only": "Only return the latest version of each memory.",
"--graph": "Enable graph in listing.",
"--no-graph": "Disable graph in listing.",
"--output, -o": "Output format: text, json, table.",
@@ -1124,6 +1202,8 @@ def _build_help_json() -> dict:
},
"options": {
"--metadata, -m": "Update metadata (JSON).",
"--expires": "Expiration date (YYYY-MM-DD).",
"--timestamp": "Unix timestamp for the memory.",
"--output, -o": "Output format: text, json, quiet.",
},
},
@@ -1140,6 +1220,7 @@ def _build_help_json() -> dict:
"--all": "Delete all memories matching scope filters.",
"--entity": "Delete the entity itself and all its memories (cascade).",
"--project": "With --all: delete ALL memories project-wide.",
"--delete-linked": "Also delete memories linked to this memory.",
"--dry-run": "Show what would be deleted without deleting.",
"--force": "Skip confirmation.",
"--user-id, -u": "Scope to user.",
+17 -2
View File
@@ -25,7 +25,10 @@ class Backend(ABC):
immutable: bool = False,
infer: bool = True,
expires: str | None = None,
categories: list[str] | None = None,
custom_instructions: str | None = None,
custom_categories: list[dict] | None = None,
structured_data_schema: dict | None = None,
timestamp: int | None = None,
) -> dict: ...
@abstractmethod
@@ -43,6 +46,9 @@ class Backend(ABC):
keyword: bool = False,
filters: dict | None = None,
fields: list[str] | None = None,
show_expired: bool = False,
reference_date: str | None = None,
latest_only: bool = False,
) -> list[dict]: ...
@abstractmethod
@@ -61,11 +67,19 @@ class Backend(ABC):
category: str | None = None,
after: str | None = None,
before: str | None = None,
show_expired: bool = False,
latest_only: bool = False,
) -> list[dict]: ...
@abstractmethod
def update(
self, memory_id: str, content: str | None = None, metadata: dict | None = None
self,
memory_id: str,
content: str | None = None,
metadata: dict | None = None,
*,
expiration_date: str | None = None,
timestamp: int | None = None,
) -> dict: ...
@abstractmethod
@@ -78,6 +92,7 @@ class Backend(ABC):
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
delete_linked: bool = False,
) -> dict: ...
@abstractmethod
+43 -5
View File
@@ -87,7 +87,10 @@ class PlatformBackend(Backend):
immutable: bool = False,
infer: bool = True,
expires: str | None = None,
categories: list[str] | None = None,
custom_instructions: str | None = None,
custom_categories: list[dict] | None = None,
structured_data_schema: dict | None = None,
timestamp: int | None = None,
) -> dict:
payload: dict[str, Any] = {}
@@ -112,8 +115,14 @@ class PlatformBackend(Backend):
payload["infer"] = False
if expires:
payload["expiration_date"] = expires
if categories:
payload["categories"] = categories
if custom_instructions:
payload["custom_instructions"] = custom_instructions
if custom_categories:
payload["custom_categories"] = custom_categories
if structured_data_schema:
payload["structured_data_schema"] = structured_data_schema
if timestamp is not None:
payload["timestamp"] = timestamp
payload["source"] = "CLI"
return self._request("POST", "/v3/memories/add/", json=payload)
@@ -173,6 +182,9 @@ class PlatformBackend(Backend):
keyword: bool = False,
filters: dict | None = None,
fields: list[str] | None = None,
show_expired: bool = False,
reference_date: str | None = None,
latest_only: bool = False,
) -> list[dict]:
payload: dict[str, Any] = {"query": query, "top_k": top_k, "threshold": threshold}
@@ -191,6 +203,12 @@ class PlatformBackend(Backend):
payload["keyword_search"] = True
if fields:
payload["fields"] = fields
if show_expired:
payload["show_expired"] = True
if reference_date is not None:
payload["reference_date"] = reference_date
if latest_only:
payload["latest_only"] = True
payload["source"] = "CLI"
result = self._request("POST", "/v3/memories/search/", json=payload)
@@ -219,6 +237,8 @@ class PlatformBackend(Backend):
category: str | None = None,
after: str | None = None,
before: str | None = None,
show_expired: bool = False,
latest_only: bool = False,
) -> list[dict]:
payload: dict[str, Any] = {}
params = {"page": str(page), "page_size": str(page_size)}
@@ -241,6 +261,10 @@ class PlatformBackend(Backend):
)
if api_filters:
payload["filters"] = api_filters
if show_expired:
payload["show_expired"] = True
if latest_only:
payload["latest_only"] = True
payload["source"] = "CLI"
result = self._request("POST", "/v3/memories/", json=payload, params=params)
@@ -251,13 +275,23 @@ class PlatformBackend(Backend):
)
def update(
self, memory_id: str, content: str | None = None, metadata: dict | None = None
self,
memory_id: str,
content: str | None = None,
metadata: dict | None = None,
*,
expiration_date: str | None = None,
timestamp: int | None = None,
) -> dict:
payload: dict[str, Any] = {}
if content:
payload["text"] = content
if metadata:
payload["metadata"] = metadata
if expiration_date:
payload["expiration_date"] = expiration_date
if timestamp is not None:
payload["timestamp"] = timestamp
payload["source"] = "CLI"
return self._request(
"PUT",
@@ -274,6 +308,7 @@ class PlatformBackend(Backend):
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
delete_linked: bool = False,
) -> dict:
if all:
params: dict[str, str] = {"source": "CLI"}
@@ -287,10 +322,13 @@ class PlatformBackend(Backend):
params["run_id"] = run_id
return self._request("DELETE", "/v1/memories/", params=params)
elif memory_id:
params = {"source": "CLI"}
if delete_linked:
params["delete_linked"] = "true"
return self._request(
"DELETE",
f"/v1/memories/{_encode_path_segment(memory_id)}/",
params={"source": "CLI"},
params=params,
)
else:
raise ValueError("Either memory_id or --all is required")
+67 -20
View File
@@ -4,9 +4,11 @@ from __future__ import annotations
import json
import os
import re
import stat as _stat_mod
import sys
import time as _time
from datetime import date
from pathlib import Path
import typer
@@ -47,6 +49,18 @@ def _stdin_is_piped() -> bool:
return False
def _validate_expires(value: str) -> None:
"""Exit 1 if value is not a future YYYY-MM-DD date."""
if not re.match(r"^\d{4}-\d{2}-\d{2}$", value):
print_error(
err_console, "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31)."
)
raise typer.Exit(1)
if date.fromisoformat(value) <= date.today():
print_error(err_console, "--expires date must be in the future.")
raise typer.Exit(1)
def cmd_add(
backend: Backend,
text: str | None,
@@ -62,6 +76,10 @@ def cmd_add(
no_infer: bool,
expires: str | None,
categories: str | None,
custom_instructions: str | None = None,
custom_categories: str | None = None,
structured_data_schema: str | None = None,
timestamp: int | None = None,
output: str = "text",
) -> None:
"""Add a memory."""
@@ -70,6 +88,13 @@ def cmd_add(
set_current_command("add")
if is_agent_mode():
output = "agent"
if categories:
print_error(
err_console, "--categories is not supported on add. Use --custom-categories instead."
)
raise typer.Exit(1)
msgs = None
content = text
@@ -108,27 +133,24 @@ def cmd_add(
print_error(err_console, "Invalid JSON in --metadata.")
raise typer.Exit(1) from None
cats = None
if categories:
custom_cats = None
if custom_categories:
try:
cats = json.loads(categories)
custom_cats = json.loads(custom_categories)
except json.JSONDecodeError:
cats = [c.strip() for c in categories.split(",")]
print_error(err_console, "Invalid JSON in --custom-categories.")
raise typer.Exit(1) from None
schema = None
if structured_data_schema:
try:
schema = json.loads(structured_data_schema)
except json.JSONDecodeError:
print_error(err_console, "Invalid JSON in --structured-data-schema.")
raise typer.Exit(1) from None
# Validate --expires
if expires:
import re
if not re.match(r"^\d{4}-\d{2}-\d{2}$", expires):
print_error(
err_console, "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31)."
)
raise typer.Exit(1)
from datetime import date
if date.fromisoformat(expires) <= date.today():
print_error(err_console, "--expires date must be in the future.")
raise typer.Exit(1)
_validate_expires(expires)
with timed_status(err_console, "Adding memory...") as ts:
try:
@@ -143,7 +165,10 @@ def cmd_add(
immutable=immutable,
infer=not no_infer,
expires=expires,
categories=cats,
custom_instructions=custom_instructions,
custom_categories=custom_cats,
structured_data_schema=schema,
timestamp=timestamp,
)
except Exception as e:
ts.error_msg = str(e)
@@ -224,6 +249,9 @@ def cmd_search(
keyword: bool,
filter_json: str | None,
fields: str | None,
show_expired: bool = False,
reference_date: str | None = None,
latest_only: bool = False,
output: str = "text",
) -> None:
"""Search memories."""
@@ -266,6 +294,9 @@ def cmd_search(
keyword=keyword,
filters=filters,
fields=field_list,
show_expired=show_expired,
reference_date=reference_date,
latest_only=latest_only,
)
except Exception as e:
print_error(err_console, str(e))
@@ -352,6 +383,8 @@ def cmd_list(
category: str | None,
after: str | None,
before: str | None,
show_expired: bool = False,
latest_only: bool = False,
output: str = "table",
) -> None:
"""List memories."""
@@ -380,6 +413,8 @@ def cmd_list(
category=category,
after=after,
before=before,
show_expired=show_expired,
latest_only=latest_only,
)
except Exception as e:
print_error(err_console, str(e))
@@ -446,6 +481,8 @@ def cmd_update(
text: str | None,
*,
metadata: str | None,
expires: str | None = None,
timestamp: int | None = None,
output: str,
) -> None:
"""Update a memory."""
@@ -462,10 +499,19 @@ def cmd_update(
print_error(err_console, "Invalid JSON in --metadata.")
raise typer.Exit(1) from None
if expires:
_validate_expires(expires)
_start = _time.perf_counter()
with timed_status(err_console, "Updating memory...") as _ts:
try:
result = backend.update(memory_id, content=text, metadata=meta)
result = backend.update(
memory_id,
content=text,
metadata=meta,
expiration_date=expires,
timestamp=timestamp,
)
except Exception as e:
print_error(err_console, str(e))
raise typer.Exit(1) from None
@@ -490,6 +536,7 @@ def cmd_delete(
*,
dry_run: bool = False,
force: bool = False,
delete_linked: bool = False,
output: str,
) -> None:
"""Delete a single memory by ID."""
@@ -512,7 +559,7 @@ def cmd_delete(
_start = _time.perf_counter()
with timed_status(err_console, "Deleting...") as _ts:
try:
result = backend.delete(memory_id=memory_id)
result = backend.delete(memory_id=memory_id, delete_linked=delete_linked)
except Exception as e:
print_error(err_console, str(e))
raise typer.Exit(1) from None
+20 -4
View File
@@ -262,16 +262,32 @@ def sanitize_agent_data(command: str, data: Any) -> Any:
return result
if command == "search":
return [pick(r, ["id", "memory", "score", "created_at", "categories"]) for r in data]
return [
pick(r, ["id", "memory", "score", "created_at", "categories", "expiration_date"])
for r in data
]
if command == "list":
return [pick(r, ["id", "memory", "created_at", "categories"]) for r in data]
return [
pick(r, ["id", "memory", "created_at", "categories", "expiration_date"]) for r in data
]
if command == "get":
return pick(data, ["id", "memory", "created_at", "updated_at", "categories", "metadata"])
return pick(
data,
[
"id",
"memory",
"created_at",
"updated_at",
"categories",
"metadata",
"expiration_date",
],
)
if command == "update":
return pick(data, ["id", "memory"])
return pick(data, ["id", "memory", "expiration_date"])
if command in ("delete", "delete-all", "entity delete"):
return data
+177 -3
View File
@@ -252,12 +252,13 @@ class TestAddCommand:
)
mock_backend.add.assert_called_once()
def test_add_categories_csv(self, mock_backend):
def test_add_categories_rejected(self, mock_backend):
console, _buf = _make_console()
err_console, _err_buf = _make_err_console()
err_console, err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
pytest.raises((SystemExit, TyperExit)),
):
cmd_add(
mock_backend,
@@ -275,7 +276,93 @@ class TestAddCommand:
categories="health,prefs",
output="text",
)
mock_backend.add.assert_called_once()
assert "--custom-categories" in err_buf.getvalue()
mock_backend.add.assert_not_called()
def test_add_invalid_custom_categories_json(self, mock_backend):
console, _buf = _make_console()
err_console, err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
pytest.raises((SystemExit, TyperExit)),
):
cmd_add(
mock_backend,
"test",
user_id="alice",
agent_id=None,
app_id=None,
run_id=None,
messages=None,
file=None,
metadata=None,
immutable=False,
no_infer=False,
expires=None,
categories=None,
custom_categories="not-json",
output="text",
)
assert "--custom-categories" in err_buf.getvalue()
mock_backend.add.assert_not_called()
def test_add_invalid_structured_data_schema_json(self, mock_backend):
console, _buf = _make_console()
err_console, err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
pytest.raises((SystemExit, TyperExit)),
):
cmd_add(
mock_backend,
"test",
user_id="alice",
agent_id=None,
app_id=None,
run_id=None,
messages=None,
file=None,
metadata=None,
immutable=False,
no_infer=False,
expires=None,
categories=None,
structured_data_schema="not-json",
output="text",
)
assert "--structured-data-schema" in err_buf.getvalue()
mock_backend.add.assert_not_called()
def test_add_regression_metadata_expiration_custom_categories_together(self, mock_backend):
console, _buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_add(
mock_backend,
"test",
user_id="alice",
agent_id=None,
app_id=None,
run_id=None,
messages=None,
file=None,
metadata='{"source": "test"}',
immutable=False,
no_infer=False,
expires="2099-01-01",
categories=None,
custom_categories='[{"prefs": "user preferences"}]',
output="text",
)
call_kwargs = mock_backend.add.call_args.kwargs
assert call_kwargs["metadata"] == {"source": "test"}
assert call_kwargs["expires"] == "2099-01-01"
assert call_kwargs["custom_categories"] == [{"prefs": "user preferences"}]
class TestAddDeduplicatesPending:
@@ -464,6 +551,36 @@ class TestSearchCommand:
)
mock_backend.search.assert_called_once()
def test_search_new_flags_reach_backend(self, mock_backend):
console, _buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_search(
mock_backend,
"preferences",
user_id="alice",
agent_id=None,
app_id=None,
run_id=None,
top_k=10,
threshold=0.3,
rerank=False,
keyword=False,
filter_json=None,
fields=None,
show_expired=True,
reference_date="2024-01-01",
latest_only=True,
output="text",
)
call_kwargs = mock_backend.search.call_args.kwargs
assert call_kwargs["show_expired"] is True
assert call_kwargs["reference_date"] == "2024-01-01"
assert call_kwargs["latest_only"] is True
class TestGetCommand:
def test_get_text(self, mock_backend):
@@ -561,6 +678,32 @@ class TestListCommand:
output = buf.getvalue()
assert "No memories found" in output
def test_list_new_flags_reach_backend(self, mock_backend):
console, _buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_list(
mock_backend,
user_id="alice",
agent_id=None,
app_id=None,
run_id=None,
page=1,
page_size=100,
category=None,
after=None,
before=None,
show_expired=True,
latest_only=True,
output="table",
)
call_kwargs = mock_backend.list_memories.call_args.kwargs
assert call_kwargs["show_expired"] is True
assert call_kwargs["latest_only"] is True
class TestUpdateCommand:
def test_update(self, mock_backend):
@@ -585,6 +728,26 @@ class TestUpdateCommand:
output = buf.getvalue()
assert '"memory"' in output
def test_update_new_fields_reach_backend(self, mock_backend):
console, _buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_update(
mock_backend,
"abc-123",
"New text",
metadata=None,
expires="2099-01-01",
timestamp=1700000000,
output="text",
)
call_kwargs = mock_backend.update.call_args.kwargs
assert call_kwargs["expiration_date"] == "2099-01-01"
assert call_kwargs["timestamp"] == 1700000000
class TestDeleteCommand:
def test_delete_single(self, mock_backend):
@@ -610,6 +773,17 @@ class TestDeleteCommand:
assert "dry run" in output.lower()
mock_backend.delete.assert_not_called()
def test_delete_linked_reaches_backend(self, mock_backend):
console, _buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_delete(mock_backend, "abc-123", delete_linked=True, output="text")
call_kwargs = mock_backend.delete.call_args.kwargs
assert call_kwargs["delete_linked"] is True
class TestDeleteAllCommand:
def test_delete_all_force(self, mock_backend):
+78
View File
@@ -0,0 +1,78 @@
"""Drift test: every documented v3 add/search/list param must be reachable from the Python CLI."""
import json
from pathlib import Path
import typer.main
from mem0_cli.app import app
REPO_ROOT = Path(__file__).resolve().parents[3]
OPENAPI_PATH = REPO_ROOT / "docs" / "openapi.json"
KNOWN_UNSURFACED: dict[tuple[str, str], str] = {}
ADD_MAPPING: dict[str, list[str]] = {
"messages": ["messages", "file", "text"],
"user_id": ["user_id"],
"agent_id": ["agent_id"],
"run_id": ["run_id"],
"metadata": ["metadata"],
"expiration_date": ["expires"],
"custom_instructions": ["custom_instructions"],
"custom_categories": ["custom_categories"],
"infer": ["no_infer"],
}
SEARCH_MAPPING: dict[str, list[str]] = {
"query": ["query"],
"filters": ["filter_json", "user_id", "agent_id", "run_id"],
"show_expired": ["show_expired"],
"top_k": ["top_k"],
"threshold": ["threshold"],
"rerank": ["rerank"],
"reference_date": ["reference_date"],
}
LIST_MAPPING: dict[str, list[str]] = {
"filters": ["user_id", "agent_id", "run_id", "category", "after", "before"],
"show_expired": ["show_expired"],
}
def _documented_fields(endpoint: str) -> set[str]:
spec = json.loads(OPENAPI_PATH.read_text())
schema = spec["paths"][endpoint]["post"]["requestBody"]["content"]["application/json"]["schema"]
return set(schema["properties"])
def _cli_param_names(command_name: str) -> set[str]:
click_app = typer.main.get_command(app)
command = click_app.commands[command_name]
return {param.name for param in command.params}
def _assert_all_reachable(endpoint: str, mapping: dict[str, list[str]], command_name: str) -> None:
documented = _documented_fields(endpoint)
reachable = _cli_param_names(command_name)
for field in documented:
if (endpoint, field) in KNOWN_UNSURFACED:
continue
candidates = mapping.get(field)
assert candidates, (
f"{endpoint}: documented field {field!r} has no mapping entry for command {command_name!r}"
)
assert any(candidate in reachable for candidate in candidates), (
f"{endpoint}: documented field {field!r} not reachable via any of {candidates} on command {command_name!r}"
)
class TestOptionParity:
def test_add_covers_documented_fields(self):
_assert_all_reachable("/v3/memories/add/", ADD_MAPPING, "add")
def test_search_covers_documented_fields(self):
_assert_all_reachable("/v3/memories/search/", SEARCH_MAPPING, "search")
def test_list_covers_documented_fields(self):
_assert_all_reachable("/v3/memories/", LIST_MAPPING, "list")
@@ -0,0 +1,109 @@
"""Tests that the MEM-5893 option-parity flags reach the correct request payload/params."""
from __future__ import annotations
from unittest.mock import patch
from mem0_cli.backend.platform import PlatformBackend
from mem0_cli.config import PlatformConfig
def _make_backend() -> PlatformBackend:
return PlatformBackend(PlatformConfig(api_key="test-key", base_url="https://api.mem0.ai"))
class TestAddOptions:
def test_new_fields_and_existing_fields_land_in_payload_together(self):
backend = _make_backend()
with patch.object(backend, "_request", return_value={"results": []}) as mock_request:
backend.add(
content="hello",
user_id="alice",
metadata={"source": "test"},
expires="2099-01-01",
custom_instructions="Extract only preferences.",
custom_categories=[{"prefs": "user preferences"}],
structured_data_schema={"type": "object"},
timestamp=1700000000,
)
payload = mock_request.call_args.kwargs["json"]
assert payload["custom_instructions"] == "Extract only preferences."
assert payload["custom_categories"] == [{"prefs": "user preferences"}]
assert payload["structured_data_schema"] == {"type": "object"}
assert payload["timestamp"] == 1700000000
assert payload["metadata"] == {"source": "test"}
assert payload["expiration_date"] == "2099-01-01"
def test_omitted_fields_are_absent_from_payload(self):
backend = _make_backend()
with patch.object(backend, "_request", return_value={"results": []}) as mock_request:
backend.add(content="hello", user_id="alice")
payload = mock_request.call_args.kwargs["json"]
assert "custom_instructions" not in payload
assert "custom_categories" not in payload
assert "structured_data_schema" not in payload
assert "timestamp" not in payload
class TestSearchOptions:
def test_show_expired_reference_date_latest_only_reach_payload(self):
backend = _make_backend()
with patch.object(backend, "_request", return_value=[]) as mock_request:
backend.search(
"query",
show_expired=True,
reference_date="2024-01-01",
latest_only=True,
)
payload = mock_request.call_args.kwargs["json"]
assert payload["show_expired"] is True
assert payload["reference_date"] == "2024-01-01"
assert payload["latest_only"] is True
def test_keyword_and_fields_reach_payload(self):
backend = _make_backend()
with patch.object(backend, "_request", return_value=[]) as mock_request:
backend.search("query", keyword=True, fields=["memory", "score"])
payload = mock_request.call_args.kwargs["json"]
assert payload["keyword_search"] is True
assert payload["fields"] == ["memory", "score"]
def test_keyword_and_fields_omitted_are_absent_from_payload(self):
backend = _make_backend()
with patch.object(backend, "_request", return_value=[]) as mock_request:
backend.search("query")
payload = mock_request.call_args.kwargs["json"]
assert "keyword_search" not in payload
assert "fields" not in payload
class TestListOptions:
def test_show_expired_and_latest_only_are_top_level_not_in_filters(self):
backend = _make_backend()
with patch.object(backend, "_request", return_value=[]) as mock_request:
backend.list_memories(user_id="alice", show_expired=True, latest_only=True)
payload = mock_request.call_args.kwargs["json"]
assert payload["show_expired"] is True
assert payload["latest_only"] is True
assert "show_expired" not in payload.get("filters", {})
assert "latest_only" not in payload.get("filters", {})
class TestUpdateOptions:
def test_expires_and_timestamp_reach_payload(self):
backend = _make_backend()
with patch.object(backend, "_request", return_value={}) as mock_request:
backend.update("mem-123", expiration_date="2099-01-01", timestamp=1700000000)
payload = mock_request.call_args.kwargs["json"]
assert payload["expiration_date"] == "2099-01-01"
assert payload["timestamp"] == 1700000000
class TestDeleteOptions:
def test_delete_linked_is_a_query_param_not_json_body(self):
backend = _make_backend()
with patch.object(backend, "_request", return_value={}) as mock_request:
backend.delete(memory_id="mem-123", delete_linked=True)
call = mock_request.call_args
assert call.kwargs["params"]["delete_linked"] == "true"
assert "json" not in call.kwargs