Skip to content

Commit b4b081b

Browse files
dani1005claude
andcommitted
docs+feat(client): make it discoverable which layer to call
Answers a question the SDK could not previously answer: given two layers — the hand-written facade and the generated typed clients — how does a reader find out which one to call? Tracing the four places a user actually lands showed two gaps. README mentioned "knowledge" and "task" zero times, and `pyproject` sets `readme = "README.md"`, so README IS the PyPI landing page. This release's headline feature would have shipped invisible there, and the one line describing the generated layer still named only `client.memory` / `client.storage`. Fixed: a knowledge-base snippet next to the memory one, and a short "Two ways to call the API" section that states the naming rule, names all four generated handles, and shows the same call at both layers side by side. I updated quickstart for this surface earlier and missed README; that was the gap. The second gap cannot be closed with documentation. `docs/` is 119 generated files — the largest body of reference material — and it describes ONLY the generated clients, with no mention that a facade exists. It is regenerated on every propose (not in the rsync exclude list), so it cannot carry a pointer. A reader who arrives there types `client.create_knowledge_base(...)` and gets a bare AttributeError. So the facade now answers at the point of confusion: `__getattr__` recognises a generated method name, names the facade equivalent when there is one, and always names the handle the generated method actually lives on. >>> client.create_knowledge_base AttributeError: 'EverOS' has no attribute 'create_knowledge_base' — that is a generated method name, not a facade one. The facade covers it as `client.kb_create(...)`. The generated client is at `client.knowledge.create_knowledge_base(...)`, which returns the response envelope rather than its `.data`. It reads `__dict__` directly rather than via getattr, so it is safe during `__init__` and cannot recurse; an unknown attribute still raises an ordinary AttributeError, and dunders fall through untouched (checked: copy/pickle probes are unaffected, and the pre-existing "cannot pickle RLock" behaviour of the underlying ApiClient is unchanged from 1.1.0rc1). Also corrects a rule I had stated wrongly in quickstart and the module docstring: "unprefixed means memory" is false — `presign` and `upload` are storage and carry no prefix either. The accurate statement is that the nine methods 1.0.0 shipped are bare verbs and cannot be renamed, and everything added since is prefixed; the two styles side by side are a consequence of that freeze, not a convention to copy. Verified: workflow's four steps (import+config, quickstart drift, 50 unit tests, sdist+wheel) plus the end-to-end run over real HTTP against the fake gateway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent af74ad4 commit b4b081b

4 files changed

Lines changed: 123 additions & 9 deletions

File tree

‎README.md‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ Add a conversation; EverOS turns it into structured, retrievable memory you can
3939
- **Structured memory, not chat logs** — extracts episodes, user profiles, and reusable
4040
agent cases & skills from raw conversations, so retrieval returns meaning, not transcripts.
4141
- **Retrieval that fits the query** — keyword, vector, hybrid (default), or agentic multi-step search.
42+
- **Knowledge bases** — ingest documents into a searchable topic library alongside
43+
conversational memory; ingest is async and reports progress through the task API.
4244
- **Multimodal** — attach images, audio, and documents to any message.
4345
- **Built for production** — fully managed (no vector DB or extraction pipeline to run),
4446
with low-latency retrieval and high-concurrency throughput. The engineering guarantees
@@ -74,9 +76,39 @@ with EverOS(api_key="sk-...") as client:
7476
print(results)
7577
```
7678

77-
**Full usage** — every memory operation, profile editing, and multimodal upload — is in
79+
Knowledge bases work the same way — ingest is asynchronous, so wait on the task:
80+
81+
```python
82+
kb = client.kb_create("Employee Handbook")
83+
ack = client.doc_ingest(kb.id, "Leave policy", "Employees accrue 20 days...")
84+
task = client.task_wait(ack.task_id) # polls until the document is queryable
85+
86+
hits = client.kb_search(kb.id, "how much leave do I get")
87+
```
88+
89+
**Full usage** — every memory operation, knowledge base, async task, profile editing, and
90+
multimodal upload — is in
7891
**[quickstart.md](https://github.com/EverMind-AI/everos-cloud-sdk-python/blob/v1/quickstart.md)**.
79-
Prefer typed control? The generated low-level client is exposed as `client.memory` / `client.storage`.
92+
93+
### Two ways to call the API
94+
95+
`EverOS` covers the common calls with plain kwargs in and the response's `.data` out.
96+
New methods are named `<resource>_<verb>` (`kb_create`, `doc_ingest`, `task_wait`,
97+
`tag_bind`), so typing `client.kb` lists the knowledge-base surface; the methods 1.0.0
98+
shipped are bare verbs (`add`, `search`, `get`, `flush`, `edit`, `delete`, `upload`).
99+
100+
Everything the API offers — all 31 operations, including knowledge-base categories and
101+
document topics — is on the generated typed clients, reachable as `client.memory`,
102+
`client.storage`, `client.knowledge`, `client.tasks`. Those take and return the full
103+
typed models, so responses arrive as an envelope you read `.data` from. The
104+
per-endpoint reference for them is under
105+
[`docs/`](https://github.com/EverMind-AI/everos-cloud-sdk-python/tree/v1/docs).
106+
107+
```python
108+
client.kb_create("Handbook") # facade -> KbData
109+
client.knowledge.create_knowledge_base({"name": "…"}) # generated -> envelope, .data
110+
client.knowledge.list_topics(kb_id, doc_id) # generated only
111+
```
80112

81113
## Documentation
82114

‎everos_cloud/client.py‎

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@
1111
client.add(session_id="s1", messages=[{"role": "user", "content": "I love hiking"}])
1212
hits = client.search("outdoor hobbies")
1313
14-
Naming: memory is the unprefixed default (``add`` / ``search`` / ``get`` / ``flush`` /
15-
``edit`` / ``delete``, frozen since 1.0.0); every other resource carries its own prefix
16-
— ``kb_`` / ``doc_`` / ``task_`` / ``tag_`` — so an editor's completion list groups by
17-
resource, and no facade method collides with the generated method it wraps.
14+
Naming: the nine methods 1.0.0 shipped are bare verbs and stay that way — ``add`` /
15+
``search`` / ``get`` / ``flush`` / ``edit`` / ``delete`` (memory), ``presign`` /
16+
``upload`` (storage), ``close``. They are public API and cannot be renamed. Everything
17+
added since carries its resource as a prefix — ``kb_`` / ``doc_`` / ``task_`` /
18+
``tag_`` — so completion groups by resource and no facade method collides with the
19+
generated method it wraps. The two styles sitting side by side is a consequence of that
20+
freeze, not a convention worth copying.
1821
1922
Errors: every failure raised by this facade derives from :class:`EverOSError` —
2023
``EverOSAPIError`` for HTTP errors, ``EverOSStorageError`` for object-upload failures,
@@ -69,6 +72,26 @@
6972
# on one blip is worse than useless — the caller has no way to resume it.
7073
_TRANSIENT_STATUS = frozenset({429, 500, 502, 503, 504})
7174

75+
# Generated method name -> the facade method that covers it. Used only to answer an
76+
# AttributeError helpfully: the generated per-endpoint docs (docs/*.md) are the largest
77+
# body of documentation and they describe ONLY the generated clients, so a reader who
78+
# arrives from there types `client.create_knowledge_base(...)` and would otherwise get a
79+
# bare AttributeError with nowhere to go.
80+
_FACADE_FOR = {
81+
"add_memory": "add", "search_memory": "search", "get_memory": "get",
82+
"flush_memory": "flush", "edit_profile": "edit", "delete_memory": "delete",
83+
"sign_objects": "presign",
84+
"bind_tags": "tag_bind", "unbind_tags": "tag_unbind", "replace_tags": "tag_replace",
85+
"create_knowledge_base": "kb_create", "list_knowledge_bases": "kb_list",
86+
"get_knowledge_base": "kb_get", "update_knowledge_base": "kb_update",
87+
"delete_knowledge_base": "kb_delete", "search_knowledge": "kb_search",
88+
"create_document": "doc_ingest", "replace_document": "doc_ingest",
89+
"list_documents": "doc_list", "get_document": "doc_get",
90+
"update_document": "doc_update", "delete_document": "doc_delete",
91+
"get_task_status": "task_get", "list_tasks": "task_list",
92+
}
93+
_API_ATTRS = ("memory", "storage", "knowledge", "tasks")
94+
7295
# Statuses the gateway reports as finished. Deliberately paired with a `finished_at`
7396
# check in `_task_finished`: `status` is an OPEN set on a response field (a new
7497
# terminal state such as `cancelled` must not make `task_wait` spin until timeout),
@@ -174,6 +197,26 @@ def __init__(
174197
self._project_id = project_id
175198
self._timeout = timeout
176199

200+
def __getattr__(self, name: str) -> Any:
201+
"""Point a generated method name at where it actually lives.
202+
203+
Python calls this only after normal lookup fails, so it costs nothing on the
204+
happy path. Reads ``__dict__`` directly rather than ``getattr`` to stay safe
205+
during ``__init__`` and to avoid recursing through itself.
206+
"""
207+
for attr in _API_ATTRS:
208+
api = self.__dict__.get(attr)
209+
if api is not None and hasattr(api, name):
210+
facade = _FACADE_FOR.get(name)
211+
hint = f"The facade covers it as `client.{facade}(...)`. " if facade else ""
212+
raise AttributeError(
213+
f"{type(self).__name__!r} has no attribute {name!r} — that is a "
214+
f"generated method name, not a facade one. {hint}"
215+
f"The generated client is at `client.{attr}.{name}(...)`, which "
216+
f"returns the response envelope rather than its `.data`."
217+
)
218+
raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}")
219+
177220
# -- lifecycle -----------------------------------------------------------
178221
def __enter__(self) -> "EverOS":
179222
return self

‎quickstart.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,11 @@ context manager (`with EverOS(...) as client:`) to release connections on exit.
143143

144144
## Method reference
145145

146-
Facade methods are named `<resource>_<verb>`, so typing `client.kb` / `client.doc` /
147-
`client.task` / `client.tag` lists everything for that resource. The six memory methods
148-
carry no prefix — an unprefixed verb always means memory.
146+
New facade methods are named `<resource>_<verb>`, so typing `client.kb` / `client.doc` /
147+
`client.task` / `client.tag` lists everything for that resource. The nine methods 1.0.0
148+
shipped are bare verbs with no prefix (`add` / `search` / `get` / `flush` / `edit` /
149+
`delete` for memory, `presign` / `upload` for storage, plus `close`) — that split is
150+
historical, not a rule: those names are public API since 1.0.0 and cannot be changed.
149151

150152
| Method | Endpoint | Notes |
151153
|---|---|---|

‎tests/test_client.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,3 +667,40 @@ def test_search_kb_does_materialize_its_defaults():
667667
c.kb_search("kb-1", "q")
668668
body = kb.search_knowledge.call_args.args[1].to_dict()
669669
assert body["method"] == "hybrid" and body["top_k"] == 10
670+
671+
672+
# ── discovery: a generated name typed on the facade must say where to go ─────
673+
def test_generated_name_on_the_facade_points_at_the_real_one():
674+
"""docs/*.md documents only the generated clients, so readers arrive with those
675+
names. A bare AttributeError leaves them stuck; this one carries the answer."""
676+
c = EverOS("sk-test")
677+
678+
with pytest.raises(AttributeError) as ei:
679+
c.create_knowledge_base
680+
msg = str(ei.value)
681+
assert "client.kb_create" in msg # facade equivalent
682+
assert "client.knowledge.create_knowledge_base" in msg # generated location
683+
684+
# a generated method the facade does NOT cover: still says where it lives
685+
with pytest.raises(AttributeError) as ei:
686+
c.list_topics
687+
assert "client.knowledge.list_topics" in str(ei.value)
688+
assert "facade covers it" not in str(ei.value)
689+
690+
# a genuinely unknown attribute stays an ordinary AttributeError
691+
with pytest.raises(AttributeError) as ei:
692+
c.totally_bogus
693+
assert "generated method name" not in str(ei.value)
694+
695+
696+
def test_getattr_hook_does_not_break_normal_lookup():
697+
c = EverOS("sk-test")
698+
assert hasattr(c, "kb_create") and hasattr(c, "knowledge")
699+
assert not hasattr(c, "nope")
700+
# Dunders that copy/pickle probe for must fall through to a PLAIN AttributeError,
701+
# never be answered with the generated-name hint. (`__getstate__` is not in this
702+
# list: object gained a real one in 3.11, so it resolves before __getattr__ runs.)
703+
for dunder in ("__deepcopy__", "__copy__", "__reduce_ex__x"):
704+
with pytest.raises(AttributeError) as ei:
705+
getattr(c, dunder)
706+
assert "generated method name" not in str(ei.value)

0 commit comments

Comments
 (0)