cortexdb-client 0.3.0__tar.gz → 0.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.
Files changed (37) hide show
  1. cortexdb_client-0.4.0/PKG-INFO +155 -0
  2. cortexdb_client-0.4.0/README.md +131 -0
  3. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/__init__.py +5 -1
  4. cortexdb_client-0.4.0/cortexdb_client/tabular.py +284 -0
  5. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/pyproject.toml +3 -1
  6. cortexdb_client-0.4.0/tests/test_tabular.py +296 -0
  7. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/uv.lock +642 -22
  8. cortexdb_client-0.3.0/PKG-INFO +0 -86
  9. cortexdb_client-0.3.0/README.md +0 -64
  10. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/.gitignore +0 -0
  11. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/MANIFEST.in +0 -0
  12. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/__init__.py +0 -0
  13. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/__init__.py +0 -0
  14. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/__init__.py +0 -0
  15. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/admin_pb2.py +0 -0
  16. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/admin_pb2_grpc.py +0 -0
  17. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/common_pb2.py +0 -0
  18. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/common_pb2_grpc.py +0 -0
  19. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/contract_pb2.py +0 -0
  20. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/contract_pb2_grpc.py +0 -0
  21. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/decision_pb2.py +0 -0
  22. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/decision_pb2_grpc.py +0 -0
  23. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/graph_pb2.py +0 -0
  24. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/graph_pb2_grpc.py +0 -0
  25. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/graphrag_pb2.py +0 -0
  26. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/graphrag_pb2_grpc.py +0 -0
  27. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/knowledge_pb2.py +0 -0
  28. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/knowledge_pb2_grpc.py +0 -0
  29. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/memory_pb2.py +0 -0
  30. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/memory_pb2_grpc.py +0 -0
  31. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/tools_pb2.py +0 -0
  32. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/tools_pb2_grpc.py +0 -0
  33. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/client.py +0 -0
  34. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/proto.py +0 -0
  35. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/examples/agent_memory.py +0 -0
  36. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/gen.sh +0 -0
  37. {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/tests/test_integration.py +0 -0
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.5
2
+ Name: cortexdb-client
3
+ Version: 0.4.0
4
+ Summary: Typed gRPC client for CortexDB (AI memory + knowledge graph sidecar)
5
+ Project-URL: Homepage, https://github.com/liliang-cn/cortexdb
6
+ Project-URL: Repository, https://github.com/liliang-cn/cortexdb
7
+ Author: Liang Li
8
+ License: MIT
9
+ Keywords: agent-memory,ai,cortexdb,grpc,knowledge-graph,rag
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Database
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.9
15
+ Requires-Dist: grpcio>=1.60
16
+ Requires-Dist: protobuf>=4.25
17
+ Provides-Extra: dev
18
+ Requires-Dist: build; extra == 'dev'
19
+ Requires-Dist: grpcio-tools>=1.60; extra == 'dev'
20
+ Requires-Dist: twine; extra == 'dev'
21
+ Provides-Extra: pandas
22
+ Requires-Dist: pandas>=1.5; extra == 'pandas'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # cortexdb-client (Python)
26
+
27
+ Typed gRPC client for [CortexDB](https://github.com/liliang-cn/cortexdb) — a
28
+ pure-Go, single-file AI memory and knowledge graph database, served as a
29
+ sidecar (`cortexdb-grpc`). Give your Python agent (e.g. **Hermes**) durable
30
+ memory **and** a queryable knowledge graph.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install cortexdb-client # or: uv add cortexdb-client
36
+ ```
37
+
38
+ Start the sidecar (one binary, one SQLite file):
39
+
40
+ ```bash
41
+ go install github.com/liliang-cn/cortexdb/v2/cmd/cortexdb-grpc@latest
42
+ CORTEXDB_PATH=agent.db CORTEXDB_GRPC_TOKEN=s3cret cortexdb-grpc
43
+ # listening on 127.0.0.1:47821
44
+ ```
45
+
46
+ ## Quick start
47
+
48
+ ```python
49
+ from cortexdb_client import CortexClient, proto
50
+
51
+ with CortexClient.connect("127.0.0.1:47821", token="s3cret") as client:
52
+ client.knowledge.SaveKnowledge(proto.SaveKnowledgeRequest(
53
+ knowledge_id="note-1",
54
+ content="The user is building an autonomous research agent in Python.",
55
+ ))
56
+ hits = client.knowledge.SearchKnowledge(proto.SearchKnowledgeRequest(
57
+ query="what is the user building?", top_k=3,
58
+ ))
59
+ for h in hits.results:
60
+ print(h.knowledge_id, h.score, h.snippet)
61
+ ```
62
+
63
+ Sub-clients mirror the Rust crate: `client.knowledge`, `client.memory`,
64
+ `client.graph` (SPARQL/RDF/SHACL/inference/ontology), `client.graphrag`,
65
+ `client.tools` (generic tool dispatch, same surface as MCP), `client.admin`.
66
+
67
+ ## Results as DataFrames or CSV
68
+
69
+ `client.tools.CallTool` answers with JSON. `cortexdb_client.tabular` turns the
70
+ row-shaped part of it into flat records, a pandas DataFrame or a CSV file:
71
+
72
+ ```python
73
+ import json
74
+ from cortexdb_client import CortexClient, proto, tabular
75
+
76
+ with CortexClient.connect("127.0.0.1:47821", token="s3cret") as client:
77
+ res = client.tools.CallTool(proto.CallToolRequest(
78
+ name="graph_cypher_query",
79
+ args_json=json.dumps({"query": "MATCH (a)-[r]->(b) RETURN a, r, b LIMIT 100"}),
80
+ ))
81
+
82
+ rows = tabular.records(res) # list of flat dicts: {"a.id": ..., "r.type": ..., "b.id": ...}
83
+ df = tabular.to_pandas(res) # needs the extra: pip install "cortexdb-client[pandas]"
84
+ tabular.to_csv(res, "edges.csv") # standard library only; to_csv(res) returns the text
85
+ ```
86
+
87
+ pandas is optional and imported only inside `to_pandas()`; the base install
88
+ stays `grpcio` + `protobuf`.
89
+
90
+ | Tool | Table(s) |
91
+ | --- | --- |
92
+ | `graph_cypher_query` | `rows` (default) — `columns` zipped with each row |
93
+ | `aggregate_metadata` | `results` (default) — one row per group |
94
+ | `memory_search`, `knowledge_search` | `results` (default); `knowledge_search` may also carry `chunks` |
95
+ | `knowledge_memory_recall` | `memories`, `knowledge`, `chunks`, `graph_facts` — pass `table=` |
96
+ | `memory_list_all` | `memories` (default) |
97
+ | `graph_list_all` | `nodes`, `edges` — pass `table=` |
98
+ | `search_text`, `recall_log_list`, `graph_property_values`, … | the one top-level list of objects (`chunks`, `recalls`, `values`) |
99
+
100
+ `tabular.tables(res)` names the tables a result has. A result with more than one
101
+ and no `rows` / `results` raises `ValueError` until you pick:
102
+ `tabular.records(res, "edges")`.
103
+
104
+ How a row becomes columns:
105
+
106
+ - a scalar is a column under its key;
107
+ - a nested object becomes one column per leaf, keys joined with `.`
108
+ (`metadata.project`, `n.properties.city`); `sep="__"` changes the separator;
109
+ - a list becomes its JSON text, or stays a list with `lists="keep"`
110
+ (for `DataFrame.explode`);
111
+ - the `keys` of an aggregate group and the `memory` of a memory hit are lifted,
112
+ so their fields lead the row unprefixed (`team`, `year`, `value`, `count`;
113
+ `id`, `content`, …, `score`). A lifted name that collides keeps its prefix
114
+ (`keys.count`). Every other key of a group is a column whatever it is called,
115
+ so new measures need no client change;
116
+ - rows need not share columns: CSV leaves the cell empty, pandas gives `NaN`.
117
+
118
+ Listings page with a cursor; collect the pages, then tabulate:
119
+
120
+ ```python
121
+ memories, cursor = [], ""
122
+ while True:
123
+ page = json.loads(client.tools.CallTool(proto.CallToolRequest(
124
+ name="memory_list_all", args_json=json.dumps({"cursor": cursor}))).result_json)
125
+ memories += tabular.records(page)
126
+ cursor = page.get("next_cursor", "")
127
+ if not cursor:
128
+ break
129
+ ```
130
+
131
+ To analyse the SQLite file itself with DuckDB, pandas or `sqlite3`, see
132
+ "Data analysis" in the [guide](https://github.com/liliang-cn/cortexdb/blob/main/docs/GUIDE.md).
133
+
134
+ ## Why a knowledge graph, not just vectors
135
+
136
+ Beyond semantic recall, the `graph` service lets an agent store and traverse
137
+ **entities and relations** — multi-hop questions like "who, among the people
138
+ Alice knows, works on X" — with SPARQL, RDFS-lite inference, and SHACL-lite
139
+ validation. That is the capability most agent-memory layers lack.
140
+
141
+ ## Embeddings
142
+
143
+ Lexical mode needs no keys. Point the sidecar at any OpenAI-compatible
144
+ embeddings endpoint (e.g. Ollama) to enable vector retrieval:
145
+
146
+ ```bash
147
+ OPENAI_BASE_URL=http://localhost:11434/v1 \
148
+ CORTEXDB_EMBED_MODEL=embeddinggemma CORTEXDB_EMBED_DIM=768 \
149
+ cortexdb-grpc
150
+ ```
151
+
152
+ ## Regenerating the protobuf code
153
+
154
+ Generated code is committed under `cortexdb_client/_pb`. To regenerate after a
155
+ proto change: `uv run --extra dev ./gen.sh`.
@@ -0,0 +1,131 @@
1
+ # cortexdb-client (Python)
2
+
3
+ Typed gRPC client for [CortexDB](https://github.com/liliang-cn/cortexdb) — a
4
+ pure-Go, single-file AI memory and knowledge graph database, served as a
5
+ sidecar (`cortexdb-grpc`). Give your Python agent (e.g. **Hermes**) durable
6
+ memory **and** a queryable knowledge graph.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ pip install cortexdb-client # or: uv add cortexdb-client
12
+ ```
13
+
14
+ Start the sidecar (one binary, one SQLite file):
15
+
16
+ ```bash
17
+ go install github.com/liliang-cn/cortexdb/v2/cmd/cortexdb-grpc@latest
18
+ CORTEXDB_PATH=agent.db CORTEXDB_GRPC_TOKEN=s3cret cortexdb-grpc
19
+ # listening on 127.0.0.1:47821
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ ```python
25
+ from cortexdb_client import CortexClient, proto
26
+
27
+ with CortexClient.connect("127.0.0.1:47821", token="s3cret") as client:
28
+ client.knowledge.SaveKnowledge(proto.SaveKnowledgeRequest(
29
+ knowledge_id="note-1",
30
+ content="The user is building an autonomous research agent in Python.",
31
+ ))
32
+ hits = client.knowledge.SearchKnowledge(proto.SearchKnowledgeRequest(
33
+ query="what is the user building?", top_k=3,
34
+ ))
35
+ for h in hits.results:
36
+ print(h.knowledge_id, h.score, h.snippet)
37
+ ```
38
+
39
+ Sub-clients mirror the Rust crate: `client.knowledge`, `client.memory`,
40
+ `client.graph` (SPARQL/RDF/SHACL/inference/ontology), `client.graphrag`,
41
+ `client.tools` (generic tool dispatch, same surface as MCP), `client.admin`.
42
+
43
+ ## Results as DataFrames or CSV
44
+
45
+ `client.tools.CallTool` answers with JSON. `cortexdb_client.tabular` turns the
46
+ row-shaped part of it into flat records, a pandas DataFrame or a CSV file:
47
+
48
+ ```python
49
+ import json
50
+ from cortexdb_client import CortexClient, proto, tabular
51
+
52
+ with CortexClient.connect("127.0.0.1:47821", token="s3cret") as client:
53
+ res = client.tools.CallTool(proto.CallToolRequest(
54
+ name="graph_cypher_query",
55
+ args_json=json.dumps({"query": "MATCH (a)-[r]->(b) RETURN a, r, b LIMIT 100"}),
56
+ ))
57
+
58
+ rows = tabular.records(res) # list of flat dicts: {"a.id": ..., "r.type": ..., "b.id": ...}
59
+ df = tabular.to_pandas(res) # needs the extra: pip install "cortexdb-client[pandas]"
60
+ tabular.to_csv(res, "edges.csv") # standard library only; to_csv(res) returns the text
61
+ ```
62
+
63
+ pandas is optional and imported only inside `to_pandas()`; the base install
64
+ stays `grpcio` + `protobuf`.
65
+
66
+ | Tool | Table(s) |
67
+ | --- | --- |
68
+ | `graph_cypher_query` | `rows` (default) — `columns` zipped with each row |
69
+ | `aggregate_metadata` | `results` (default) — one row per group |
70
+ | `memory_search`, `knowledge_search` | `results` (default); `knowledge_search` may also carry `chunks` |
71
+ | `knowledge_memory_recall` | `memories`, `knowledge`, `chunks`, `graph_facts` — pass `table=` |
72
+ | `memory_list_all` | `memories` (default) |
73
+ | `graph_list_all` | `nodes`, `edges` — pass `table=` |
74
+ | `search_text`, `recall_log_list`, `graph_property_values`, … | the one top-level list of objects (`chunks`, `recalls`, `values`) |
75
+
76
+ `tabular.tables(res)` names the tables a result has. A result with more than one
77
+ and no `rows` / `results` raises `ValueError` until you pick:
78
+ `tabular.records(res, "edges")`.
79
+
80
+ How a row becomes columns:
81
+
82
+ - a scalar is a column under its key;
83
+ - a nested object becomes one column per leaf, keys joined with `.`
84
+ (`metadata.project`, `n.properties.city`); `sep="__"` changes the separator;
85
+ - a list becomes its JSON text, or stays a list with `lists="keep"`
86
+ (for `DataFrame.explode`);
87
+ - the `keys` of an aggregate group and the `memory` of a memory hit are lifted,
88
+ so their fields lead the row unprefixed (`team`, `year`, `value`, `count`;
89
+ `id`, `content`, …, `score`). A lifted name that collides keeps its prefix
90
+ (`keys.count`). Every other key of a group is a column whatever it is called,
91
+ so new measures need no client change;
92
+ - rows need not share columns: CSV leaves the cell empty, pandas gives `NaN`.
93
+
94
+ Listings page with a cursor; collect the pages, then tabulate:
95
+
96
+ ```python
97
+ memories, cursor = [], ""
98
+ while True:
99
+ page = json.loads(client.tools.CallTool(proto.CallToolRequest(
100
+ name="memory_list_all", args_json=json.dumps({"cursor": cursor}))).result_json)
101
+ memories += tabular.records(page)
102
+ cursor = page.get("next_cursor", "")
103
+ if not cursor:
104
+ break
105
+ ```
106
+
107
+ To analyse the SQLite file itself with DuckDB, pandas or `sqlite3`, see
108
+ "Data analysis" in the [guide](https://github.com/liliang-cn/cortexdb/blob/main/docs/GUIDE.md).
109
+
110
+ ## Why a knowledge graph, not just vectors
111
+
112
+ Beyond semantic recall, the `graph` service lets an agent store and traverse
113
+ **entities and relations** — multi-hop questions like "who, among the people
114
+ Alice knows, works on X" — with SPARQL, RDFS-lite inference, and SHACL-lite
115
+ validation. That is the capability most agent-memory layers lack.
116
+
117
+ ## Embeddings
118
+
119
+ Lexical mode needs no keys. Point the sidecar at any OpenAI-compatible
120
+ embeddings endpoint (e.g. Ollama) to enable vector retrieval:
121
+
122
+ ```bash
123
+ OPENAI_BASE_URL=http://localhost:11434/v1 \
124
+ CORTEXDB_EMBED_MODEL=embeddinggemma CORTEXDB_EMBED_DIM=768 \
125
+ cortexdb-grpc
126
+ ```
127
+
128
+ ## Regenerating the protobuf code
129
+
130
+ Generated code is committed under `cortexdb_client/_pb`. To regenerate after a
131
+ proto change: `uv run --extra dev ./gen.sh`.
@@ -16,10 +16,14 @@ Example::
16
16
  The sub-client accessors mirror the Rust crate: ``client.knowledge``,
17
17
  ``client.memory``, ``client.graph``, ``client.graphrag``, ``client.tools``,
18
18
  ``client.admin``.
19
+
20
+ ``cortexdb_client.tabular`` turns a tool result into flat records, a pandas
21
+ DataFrame or CSV.
19
22
  """
20
23
 
21
24
  from .client import CortexClient
22
25
  from . import proto
26
+ from . import tabular
23
27
 
24
- __all__ = ["CortexClient", "proto"]
28
+ __all__ = ["CortexClient", "proto", "tabular"]
25
29
  __version__ = "0.1.0"
@@ -0,0 +1,284 @@
1
+ """Tool results as tables: flat records, a pandas DataFrame, or CSV.
2
+
3
+ A tool called through ``client.tools.CallTool`` answers with one JSON object.
4
+ The row-shaped part of it — a Cypher query's rows, the groups of
5
+ ``aggregate_metadata``, the hits of a search or a recall, a listing — is a
6
+ list under some key. :func:`records` picks that list and returns it as flat
7
+ ``dict`` records, one per row, ready for ``csv``, pandas, polars or DuckDB::
8
+
9
+ import json
10
+ from cortexdb_client import CortexClient, proto, tabular
11
+
12
+ with CortexClient.connect("127.0.0.1:47821") as client:
13
+ res = client.tools.CallTool(proto.CallToolRequest(
14
+ name="graph_cypher_query",
15
+ args_json=json.dumps({"query": "MATCH (n) RETURN n LIMIT 100"}),
16
+ ))
17
+ rows = tabular.records(res) # [{"n.id": "...", "n.labels": "[...]", ...}, ...]
18
+ df = tabular.to_pandas(res) # needs: pip install "cortexdb-client[pandas]"
19
+ tabular.to_csv(res, "nodes.csv")
20
+
21
+ Which list is the table
22
+ -----------------------
23
+
24
+ ``records(result, table=None)`` chooses, in this order:
25
+
26
+ 1. ``table``, when given — the name of a top-level key (``"memories"``,
27
+ ``"knowledge"``, ``"chunks"``, ``"graph_facts"``, ``"nodes"``, ``"edges"``,
28
+ ``"recalls"`` …). :func:`tables` lists the names a result has.
29
+ 2. ``rows``, when the result also has ``columns`` (``graph_cypher_query``).
30
+ 3. ``results``, when present (``aggregate_metadata``, ``memory_search``,
31
+ ``knowledge_search``).
32
+ 4. The only top-level list of objects, when there is exactly one
33
+ (``memory_list_all`` → ``memories``, ``search_text`` → ``chunks``).
34
+
35
+ Anything else raises ``ValueError`` naming the tables to choose from —
36
+ ``graph_list_all`` has ``nodes`` and ``edges``, ``knowledge_memory_recall`` has
37
+ ``memories``, ``knowledge``, ``chunks`` and ``graph_facts``.
38
+
39
+ How a row is flattened
40
+ ----------------------
41
+
42
+ * A scalar (string, number, bool, ``None``) is a column under its key.
43
+ * A nested object becomes one column per leaf, the keys joined with ``.``:
44
+ ``{"metadata": {"project": "x"}}`` → ``metadata.project``. An empty object
45
+ adds no column.
46
+ * A list becomes one column holding its compact JSON text (``lists="json"``,
47
+ the default — safe for CSV), or is kept as a Python list (``lists="keep"``,
48
+ for ``DataFrame.explode``).
49
+ * Two wrappers are lifted so their fields are columns in their own right:
50
+ the ``keys`` of an ``aggregate_metadata`` group (``{"keys": {"project":
51
+ "x"}, "count": 3}`` → ``project``, ``count``) and the ``memory`` of a memory
52
+ hit (``{"memory": {"id": "m1"}, "score": 0.4}`` → ``id``, ``score``).
53
+ Lifted columns come first. One that would collide with another column
54
+ keeps its prefix (``keys.count``). Every other key of a group — today
55
+ ``value`` and ``count`` — is a column whatever it is called.
56
+ * A Cypher row is its ``columns`` zipped with its values; a node or
57
+ relationship value is flattened under the column name (``n.id``,
58
+ ``n.properties.city``).
59
+
60
+ Rows need not share columns: a key absent from a row is absent from its
61
+ record. :func:`to_csv` and pandas fill the gap (empty cell, ``NaN``).
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import csv
67
+ import io
68
+ import json
69
+ import os
70
+ from typing import IO, TYPE_CHECKING, Any, Dict, Iterable, List, Mapping, Optional, Union
71
+
72
+ if TYPE_CHECKING: # pragma: no cover - typing only
73
+ import pandas
74
+
75
+ __all__ = ["Record", "flatten", "records", "tables", "columns", "to_pandas", "to_csv"]
76
+
77
+ #: One flat row: column name → scalar (or list, with ``lists="keep"``).
78
+ Record = Dict[str, Any]
79
+
80
+ # Wrappers whose fields are lifted to top-level columns (see module docstring).
81
+ _LIFTED = ("keys", "memory")
82
+
83
+ _LIST_MODES = ("json", "keep")
84
+
85
+
86
+ def _dumps(value: Any) -> str:
87
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True, default=str)
88
+
89
+
90
+ def flatten(obj: Mapping[str, Any], *, sep: str = ".", lists: str = "json", prefix: str = "") -> Record:
91
+ """Flatten one nested object into a single-level record.
92
+
93
+ Nested objects become ``parent<sep>child`` columns, lists become compact
94
+ JSON text (``lists="json"``) or stay lists (``lists="keep"``), scalars are
95
+ copied. No wrapper is lifted here; see :func:`records` for that.
96
+ """
97
+ if lists not in _LIST_MODES:
98
+ raise ValueError(f"lists must be one of {_LIST_MODES}, got {lists!r}")
99
+ out: Record = {}
100
+ _flatten_into(out, obj, prefix, sep, lists)
101
+ return out
102
+
103
+
104
+ def _flatten_into(out: Record, obj: Mapping[str, Any], prefix: str, sep: str, lists: str) -> None:
105
+ for key, value in obj.items():
106
+ name = f"{prefix}{sep}{key}" if prefix else str(key)
107
+ _put(out, name, value, sep, lists)
108
+
109
+
110
+ def _put(out: Record, name: str, value: Any, sep: str, lists: str) -> None:
111
+ if isinstance(value, Mapping):
112
+ _flatten_into(out, value, name, sep, lists)
113
+ elif isinstance(value, (list, tuple)):
114
+ out[name] = _dumps(value) if lists == "json" else list(value)
115
+ else:
116
+ out[name] = value
117
+
118
+
119
+ def _load(result: Any) -> Any:
120
+ """Decode what a tool call returned into plain Python data."""
121
+ payload = getattr(result, "result_json", None) # proto.CallToolResponse
122
+ if payload is not None:
123
+ result = payload
124
+ if isinstance(result, (bytes, bytearray)):
125
+ result = bytes(result).decode("utf-8")
126
+ if isinstance(result, str):
127
+ if not result.strip():
128
+ return {}
129
+ return json.loads(result)
130
+ return result
131
+
132
+
133
+ def _is_table(value: Any) -> bool:
134
+ return isinstance(value, list) and all(isinstance(item, Mapping) for item in value)
135
+
136
+
137
+ def _table_names(data: Mapping[str, Any]) -> List[str]:
138
+ names = [key for key, value in data.items() if key != "rows" and _is_table(value)]
139
+ if "columns" in data and "rows" in data:
140
+ names.insert(0, "rows")
141
+ return names
142
+
143
+
144
+ def tables(result: Any) -> List[str]:
145
+ """Names of the tables in a tool result, in the order the result has them.
146
+
147
+ A table is a top-level list of objects, or the ``rows`` of a Cypher
148
+ answer. A list the server left out because it was empty is not named.
149
+ """
150
+ data = _load(result)
151
+ if isinstance(data, list):
152
+ return []
153
+ if not isinstance(data, Mapping):
154
+ raise TypeError(f"expected a tool result object, got {type(data).__name__}")
155
+ return _table_names(data)
156
+
157
+
158
+ def _pick(data: Mapping[str, Any], table: Optional[str]) -> str:
159
+ if table is not None:
160
+ if table not in data:
161
+ raise ValueError(f"no table {table!r} in this result; it has {_table_names(data) or 'none'}")
162
+ return table
163
+ if "columns" in data and "rows" in data:
164
+ return "rows"
165
+ if "results" in data:
166
+ return "results"
167
+ names = _table_names(data)
168
+ if len(names) == 1:
169
+ return names[0]
170
+ if not names:
171
+ raise ValueError("this result has no list of rows to tabulate")
172
+ raise ValueError(f"this result has several tables, pass table=: {names}")
173
+
174
+
175
+ def _lifted_row(row: Mapping[str, Any], sep: str, lists: str) -> Record:
176
+ rest = {k: v for k, v in row.items() if not (k in _LIFTED and isinstance(v, Mapping))}
177
+ own = flatten(rest, sep=sep, lists=lists)
178
+ out: Record = {}
179
+ for wrapper in _LIFTED: # lifted fields lead: group keys, then measures
180
+ inner = row.get(wrapper)
181
+ if not isinstance(inner, Mapping):
182
+ continue
183
+ for name, value in flatten(inner, sep=sep, lists=lists).items():
184
+ out[f"{wrapper}{sep}{name}" if name in own or name in out else name] = value
185
+ out.update(own)
186
+ return out
187
+
188
+
189
+ def records(result: Any, table: Optional[str] = None, *, sep: str = ".", lists: str = "json") -> List[Record]:
190
+ """The rows of a tool result as flat records.
191
+
192
+ ``result`` is what ``client.tools.CallTool`` returned (a
193
+ ``CallToolResponse``), its ``result_json`` text, or the decoded object.
194
+ A bare list of objects is taken as the table itself. See the module
195
+ docstring for how the table is chosen and how a row is flattened.
196
+ """
197
+ if lists not in _LIST_MODES:
198
+ raise ValueError(f"lists must be one of {_LIST_MODES}, got {lists!r}")
199
+ data = _load(result)
200
+ if isinstance(data, list):
201
+ rows: Any = data
202
+ elif isinstance(data, Mapping):
203
+ name = _pick(data, table)
204
+ rows = data.get(name)
205
+ if name == "rows" and "columns" in data:
206
+ return _cypher_records(data.get("columns") or [], rows or [], sep, lists)
207
+ else:
208
+ raise TypeError(f"expected a tool result object, got {type(data).__name__}")
209
+ if rows is None: # a Go nil slice arrives as null
210
+ return []
211
+ if not _is_table(rows):
212
+ raise ValueError(f"table {table!r} is not a list of objects")
213
+ return [_lifted_row(row, sep, lists) for row in rows]
214
+
215
+
216
+ def _cypher_records(cols: Iterable[Any], rows: Iterable[Any], sep: str, lists: str) -> List[Record]:
217
+ names = [str(c) for c in cols]
218
+ out: List[Record] = []
219
+ for row in rows:
220
+ record: Record = {}
221
+ for name, value in zip(names, row):
222
+ _put(record, name, value, sep, lists)
223
+ out.append(record)
224
+ return out
225
+
226
+
227
+ def columns(rows: Iterable[Mapping[str, Any]]) -> List[str]:
228
+ """Every column of ``rows``, in the order each first appears."""
229
+ seen: Dict[str, None] = {}
230
+ for row in rows:
231
+ for name in row:
232
+ seen.setdefault(name, None)
233
+ return list(seen)
234
+
235
+
236
+ def to_pandas(result: Any, table: Optional[str] = None, *, sep: str = ".", lists: str = "json") -> "pandas.DataFrame":
237
+ """The rows of a tool result as a ``pandas.DataFrame``.
238
+
239
+ pandas is optional: ``pip install "cortexdb-client[pandas]"``. Columns
240
+ come in first-seen order; a key a row lacks is ``NaN`` there.
241
+ """
242
+ try:
243
+ import pandas as pd
244
+ except ImportError as exc:
245
+ raise ImportError(
246
+ "to_pandas() needs pandas, which cortexdb-client does not install by default. "
247
+ 'Install it with: pip install "cortexdb-client[pandas]" (or: uv add "cortexdb-client[pandas]"). '
248
+ "records() returns the same rows as plain dicts without it."
249
+ ) from exc
250
+ rows = records(result, table, sep=sep, lists=lists)
251
+ return pd.DataFrame.from_records(rows, columns=columns(rows))
252
+
253
+
254
+ def to_csv(
255
+ result: Any,
256
+ dest: Union[str, "os.PathLike[str]", IO[str], None] = None,
257
+ table: Optional[str] = None,
258
+ *,
259
+ sep: str = ".",
260
+ ) -> Optional[str]:
261
+ """Write the rows of a tool result as CSV, header first.
262
+
263
+ ``dest`` is a path, an open text file, or ``None`` to get the CSV back as
264
+ a string. Lists are written as JSON text and a missing value as an empty
265
+ cell. Uses only the standard library. A path is written as UTF-8.
266
+ """
267
+ rows = records(result, table, sep=sep, lists="json")
268
+ header = columns(rows)
269
+
270
+ def write(handle: IO[str]) -> None:
271
+ writer = csv.DictWriter(handle, fieldnames=header, restval="", lineterminator="\n")
272
+ writer.writeheader()
273
+ writer.writerows(rows)
274
+
275
+ if dest is None:
276
+ buffer = io.StringIO()
277
+ write(buffer)
278
+ return buffer.getvalue()
279
+ if isinstance(dest, (str, os.PathLike)):
280
+ with open(dest, "w", encoding="utf-8", newline="") as handle:
281
+ write(handle)
282
+ return None
283
+ write(dest)
284
+ return None
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cortexdb-client"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Typed gRPC client for CortexDB (AI memory + knowledge graph sidecar)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -24,6 +24,8 @@ Repository = "https://github.com/liliang-cn/cortexdb"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  dev = ["grpcio-tools>=1.60", "build", "twine"]
27
+ # tabular.to_pandas(); the rest of cortexdb_client.tabular needs nothing extra.
28
+ pandas = ["pandas>=1.5"]
27
29
 
28
30
  [build-system]
29
31
  requires = ["hatchling"]