anona-memory 0.27.0__py3-none-any.whl
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.
- anona/__init__.py +26 -0
- anona/_record_outcome.py +108 -0
- anona/cli/__init__.py +0 -0
- anona/cli/__main__.py +7 -0
- anona/cli/completion.py +157 -0
- anona/cli/config_env.py +75 -0
- anona/cli/credential.py +77 -0
- anona/cli/doctor.py +277 -0
- anona/cli/exits.py +67 -0
- anona/cli/guide.py +383 -0
- anona/cli/introspect.py +161 -0
- anona/cli/jobs.py +122 -0
- anona/cli/login.py +437 -0
- anona/cli/main.py +1132 -0
- anona/cli/memory.py +912 -0
- anona/cli/oauth.py +308 -0
- anona/cli/proxy.py +211 -0
- anona/cli/resources.py +1018 -0
- anona/cli/store.py +181 -0
- anona/cli/target.py +146 -0
- anona/cli/temp.py +122 -0
- anona/cli/tokens.py +230 -0
- anona/cli/verbs.py +1052 -0
- anona/client.py +3759 -0
- anona/integrations/__init__.py +16 -0
- anona/integrations/_core.py +309 -0
- anona/integrations/crewai.py +236 -0
- anona/integrations/google_adk.py +309 -0
- anona/integrations/langchain.py +229 -0
- anona/integrations/litellm.py +392 -0
- anona/integrations/llamaindex.py +271 -0
- anona/integrations/mcp.py +234 -0
- anona/integrations/ms_agent.py +234 -0
- anona/integrations/softmax.py +354 -0
- anona/integrations/strands.py +170 -0
- anona_memory-0.27.0.dist-info/METADATA +941 -0
- anona_memory-0.27.0.dist-info/RECORD +41 -0
- anona_memory-0.27.0.dist-info/WHEEL +5 -0
- anona_memory-0.27.0.dist-info/entry_points.txt +3 -0
- anona_memory-0.27.0.dist-info/licenses/LICENSE +21 -0
- anona_memory-0.27.0.dist-info/top_level.txt +1 -0
anona/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from .client import (
|
|
2
|
+
AnonaClient,
|
|
3
|
+
AnonaError,
|
|
4
|
+
RateLimit,
|
|
5
|
+
ReasonWithReceipt,
|
|
6
|
+
RetrieveResults,
|
|
7
|
+
RetrieveWithReceipt,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
# `Anona` is what the TypeScript package calls its client, and the two SDKs
|
|
11
|
+
# describing the same API under two different class names is a papercut every
|
|
12
|
+
# reader of both pays. The Python name stays canonical — it is what every
|
|
13
|
+
# existing import and every published example uses — and this is an alias, not
|
|
14
|
+
# a rename, so nothing breaks either way round.
|
|
15
|
+
Anona = AnonaClient
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Anona",
|
|
19
|
+
"AnonaClient",
|
|
20
|
+
"AnonaError",
|
|
21
|
+
"RateLimit",
|
|
22
|
+
"ReasonWithReceipt",
|
|
23
|
+
"RetrieveResults",
|
|
24
|
+
"RetrieveWithReceipt",
|
|
25
|
+
]
|
|
26
|
+
__version__ = "0.27.0"
|
anona/_record_outcome.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""What a `record` response actually says happened.
|
|
2
|
+
|
|
3
|
+
The API answers `201` for a write it accepted, and acceptance is not storage:
|
|
4
|
+
extraction runs over the content and often produces nothing, which is a
|
|
5
|
+
success with an empty result rather than an error. Roughly 44% of short,
|
|
6
|
+
context-free statements come back that way. A caller that reads only the
|
|
7
|
+
status code reports those as stored, which is silent data loss dressed as
|
|
8
|
+
success.
|
|
9
|
+
|
|
10
|
+
**The verdict is read off the ids, not off `status`** — the same decision the
|
|
11
|
+
server makes, and for the server's own stated reason: the engine has no
|
|
12
|
+
failure shape, so a status field has been wrong about this before and an
|
|
13
|
+
`"stored"` that stored nothing is exactly the bug being guarded against. A
|
|
14
|
+
guard that trusted the status would depend on the server-side fix for it
|
|
15
|
+
being deployed everywhere, which is the one thing it cannot assume. `status:
|
|
16
|
+
"nothing_extracted"` and empty ids always coincide, so believing the ids
|
|
17
|
+
costs nothing and survives a deployment that predates the status.
|
|
18
|
+
|
|
19
|
+
What the server IS believed about is the **wording**: `message` accompanies
|
|
20
|
+
that status and carries measured advice about what to do, so it is shown
|
|
21
|
+
verbatim where present and only restated here when absent.
|
|
22
|
+
|
|
23
|
+
This lives on its own, outside `integrations.mcp`, for two reasons. That
|
|
24
|
+
module imports FastMCP, which ships in the optional `[mcp]` extra, so the CLI
|
|
25
|
+
cannot import from it without making an optional dependency mandatory. And
|
|
26
|
+
the question "did this write store anything" has exactly one right answer, so
|
|
27
|
+
the two surfaces that ask it must not each carry their own copy to drift.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
# Said only when the server said nothing itself. The server sets `message` on
|
|
33
|
+
# exactly this status and its wording is the one to show; this is for a
|
|
34
|
+
# deployment that predates that field, so that an older server still produces
|
|
35
|
+
# an explanation rather than a bare non-zero exit.
|
|
36
|
+
NOTHING_EXTRACTED_FALLBACK = (
|
|
37
|
+
"Nothing was stored in space '{space_id}': no facts could be extracted from "
|
|
38
|
+
"that content. Short, context-free statements often extract to nothing — "
|
|
39
|
+
"re-record it with a sentence of context (what the thing is, or why it "
|
|
40
|
+
"matters), which usually fixes it."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Statuses that mean extraction has not run yet, so there is no outcome to
|
|
44
|
+
# report and no ids to name.
|
|
45
|
+
_QUEUED_STATUSES = ("processing", "pending", "queued")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def stored_ids(result: object) -> list[str]:
|
|
49
|
+
"""The memory ids a write created, in the order the API returned them.
|
|
50
|
+
|
|
51
|
+
``memory_ids`` is the full set: one item usually produces more than one
|
|
52
|
+
memory (the raw fact, plus any synthesis built on it), so the singular
|
|
53
|
+
``memory_id`` could only ever name part of what happened. It is read as
|
|
54
|
+
the fallback for an older response that carries only the one.
|
|
55
|
+
|
|
56
|
+
Empty for a write that stored nothing **and** for one that is still
|
|
57
|
+
queued — the two are different outcomes, so ask :func:`is_queued` before
|
|
58
|
+
reading an empty list as nothing.
|
|
59
|
+
"""
|
|
60
|
+
if not isinstance(result, dict):
|
|
61
|
+
return []
|
|
62
|
+
many = result.get("memory_ids")
|
|
63
|
+
if isinstance(many, list):
|
|
64
|
+
ids = [str(item) for item in many if item]
|
|
65
|
+
if ids:
|
|
66
|
+
return ids
|
|
67
|
+
one = result.get("memory_id")
|
|
68
|
+
return [str(one)] if one else []
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_queued(result: object) -> bool:
|
|
72
|
+
"""Whether the write was accepted for background extraction.
|
|
73
|
+
|
|
74
|
+
A queued write has not run extraction yet, so it has no ids and no outcome
|
|
75
|
+
to report. Reporting it as empty would be wrong in the other direction.
|
|
76
|
+
"""
|
|
77
|
+
if not isinstance(result, dict):
|
|
78
|
+
return False
|
|
79
|
+
return bool(result.get("job_id")) or result.get("status") in _QUEUED_STATUSES
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def stored_something(result: object) -> bool:
|
|
83
|
+
"""Whether a record persisted a memory, read off the API response.
|
|
84
|
+
|
|
85
|
+
The ids decide. `status` is deliberately not consulted: see the module
|
|
86
|
+
docstring — a status field has been wrong about this, and the ids cannot
|
|
87
|
+
be. A non-dict is treated as stored: this is a guard against an unexpected
|
|
88
|
+
response shape, and inventing a failure the API did not report would be
|
|
89
|
+
worse than the thing it protects against.
|
|
90
|
+
"""
|
|
91
|
+
if not isinstance(result, dict):
|
|
92
|
+
return True
|
|
93
|
+
return bool(stored_ids(result)) or is_queued(result)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def nothing_extracted_message(result: object, space_id: str) -> str:
|
|
97
|
+
"""Why nothing was stored, in the server's own words where it gave them.
|
|
98
|
+
|
|
99
|
+
The server sets `message` on exactly this outcome and it says what to do.
|
|
100
|
+
Preferring it means the advice can be improved server-side without a
|
|
101
|
+
client release, and it keeps this package from carrying another copy of
|
|
102
|
+
wording the server already owns.
|
|
103
|
+
"""
|
|
104
|
+
if isinstance(result, dict):
|
|
105
|
+
message = result.get("message")
|
|
106
|
+
if isinstance(message, str) and message.strip():
|
|
107
|
+
return message.strip()
|
|
108
|
+
return NOTHING_EXTRACTED_FALLBACK.format(space_id=space_id)
|
anona/cli/__init__.py
ADDED
|
File without changes
|
anona/cli/__main__.py
ADDED
anona/cli/completion.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""`anona-memory completion bash|zsh|fish`: a completion script generated from the parser.
|
|
2
|
+
|
|
3
|
+
Install:
|
|
4
|
+
bash anona-memory completion bash > ~/.local/share/bash-completion/completions/anona-memory
|
|
5
|
+
zsh anona-memory completion zsh > "${fpath[1]}/_anona-memory" (or eval it from ~/.zshrc)
|
|
6
|
+
fish anona-memory completion fish > ~/.config/fish/completions/anona-memory.fish
|
|
7
|
+
|
|
8
|
+
Generated, not hand-written, so a new verb or flag completes the moment it
|
|
9
|
+
exists. Commands and flags complete; flag values do not, except where a flag
|
|
10
|
+
has fixed choices (fish only) -- a filename is offered where nothing else
|
|
11
|
+
fits.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
|
|
18
|
+
from .introspect import Command, tree
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _quote(text: str) -> str:
|
|
22
|
+
"""Single-quoted for sh and fish: no expansion of any kind inside."""
|
|
23
|
+
return "'" + text.replace("'", "'\\''") + "'"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _paths(root: Command) -> list[Command]:
|
|
27
|
+
return root.walk()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _key(cmd: Command) -> str:
|
|
31
|
+
# The root's path is ["anona-memory"]; every other command's path starts at its own name.
|
|
32
|
+
return "" if cmd.path == ["anona-memory"] else " ".join(cmd.path)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _flags(cmd: Command, root: Command) -> list[str]:
|
|
36
|
+
flags = [f for o in cmd.options for f in o.flags]
|
|
37
|
+
# Global flags are accepted after any command too.
|
|
38
|
+
if cmd is not root:
|
|
39
|
+
flags += [f for o in root.options for f in o.flags if f != "--version"]
|
|
40
|
+
flags.append("--help")
|
|
41
|
+
return flags
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def bash(parser: argparse.ArgumentParser) -> str:
|
|
45
|
+
root = tree(parser)
|
|
46
|
+
children = []
|
|
47
|
+
options = []
|
|
48
|
+
for cmd in _paths(root):
|
|
49
|
+
key = _quote(_key(cmd))
|
|
50
|
+
if cmd.children:
|
|
51
|
+
children.append(f" {key}) echo {_quote(' '.join(c.path[-1] for c in cmd.children))};;")
|
|
52
|
+
options.append(f" {key}) echo {_quote(' '.join(_flags(cmd, root)))};;")
|
|
53
|
+
return "\n".join(
|
|
54
|
+
[
|
|
55
|
+
"# anona-memory completion for bash. Generated by `anona-memory completion bash`.",
|
|
56
|
+
"_anona_memory_children() {",
|
|
57
|
+
' case "$1" in',
|
|
58
|
+
*children,
|
|
59
|
+
" esac",
|
|
60
|
+
"}",
|
|
61
|
+
"_anona_memory_options() {",
|
|
62
|
+
' case "$1" in',
|
|
63
|
+
*options,
|
|
64
|
+
" esac",
|
|
65
|
+
"}",
|
|
66
|
+
"_anona_memory() {",
|
|
67
|
+
' local cur="${COMP_WORDS[COMP_CWORD]}" path="" w i kids',
|
|
68
|
+
" for ((i=1; i<COMP_CWORD; i++)); do",
|
|
69
|
+
' w="${COMP_WORDS[i]}"',
|
|
70
|
+
' case "$w" in -*) continue;; esac',
|
|
71
|
+
' kids=" $(_anona_memory_children "$path") "',
|
|
72
|
+
' if [[ "$kids" == *" $w "* ]]; then path="${path:+$path }$w"; fi',
|
|
73
|
+
" done",
|
|
74
|
+
' if [[ "$cur" == -* ]]; then',
|
|
75
|
+
' COMPREPLY=( $(compgen -W "$(_anona_memory_options "$path")" -- "$cur") )',
|
|
76
|
+
" else",
|
|
77
|
+
' kids="$(_anona_memory_children "$path")"',
|
|
78
|
+
' if [[ -n "$kids" ]]; then',
|
|
79
|
+
' COMPREPLY=( $(compgen -W "$kids" -- "$cur") )',
|
|
80
|
+
" else",
|
|
81
|
+
' COMPREPLY=( $(compgen -f -- "$cur") )',
|
|
82
|
+
" fi",
|
|
83
|
+
" fi",
|
|
84
|
+
"}",
|
|
85
|
+
"complete -o default -F _anona_memory anona-memory",
|
|
86
|
+
"",
|
|
87
|
+
]
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def zsh(parser: argparse.ArgumentParser) -> str:
|
|
92
|
+
# zsh runs the bash function through bashcompinit, which ships with zsh.
|
|
93
|
+
# One generator for two shells means the two cannot disagree.
|
|
94
|
+
return "\n".join(
|
|
95
|
+
[
|
|
96
|
+
"#compdef anona-memory",
|
|
97
|
+
"# anona-memory completion for zsh. Generated by `anona-memory completion zsh`.",
|
|
98
|
+
"autoload -U +X bashcompinit && bashcompinit",
|
|
99
|
+
bash(parser),
|
|
100
|
+
]
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def fish(parser: argparse.ArgumentParser) -> str:
|
|
105
|
+
root = tree(parser)
|
|
106
|
+
lines = ["# anona-memory completion for fish. Generated by `anona-memory completion fish`.", "complete -c anona-memory -f"]
|
|
107
|
+
top = " ".join(c.path[0] for c in root.children)
|
|
108
|
+
for cmd in root.children:
|
|
109
|
+
lines.append(
|
|
110
|
+
f"complete -c anona-memory -n {_quote('not __fish_seen_subcommand_from ' + top)} "
|
|
111
|
+
f"-a {_quote(cmd.path[0])} -d {_quote(cmd.help[:80])}"
|
|
112
|
+
)
|
|
113
|
+
for opt in root.options:
|
|
114
|
+
lines.extend(_fish_option(opt, None))
|
|
115
|
+
for cmd in root.walk():
|
|
116
|
+
if cmd is root:
|
|
117
|
+
continue
|
|
118
|
+
cond = " ; and ".join(f"__fish_seen_subcommand_from {p}" for p in cmd.path)
|
|
119
|
+
if cmd.children:
|
|
120
|
+
kids = " ".join(c.path[-1] for c in cmd.children)
|
|
121
|
+
for child in cmd.children:
|
|
122
|
+
lines.append(
|
|
123
|
+
f"complete -c anona-memory -n {_quote(cond + ' ; and not __fish_seen_subcommand_from ' + kids)} "
|
|
124
|
+
f"-a {_quote(child.path[-1])} -d {_quote(child.help[:80])}"
|
|
125
|
+
)
|
|
126
|
+
for opt in cmd.options:
|
|
127
|
+
lines.extend(_fish_option(opt, cond))
|
|
128
|
+
if any(p.name in ("PATHS", "PATH") or "path" in p.help.lower() or "file" in p.help.lower() for p in cmd.positionals):
|
|
129
|
+
lines.append(f"complete -c anona-memory -n {_quote(cond)} -F")
|
|
130
|
+
return "\n".join(lines) + "\n"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _fish_option(opt, cond: str | None) -> list[str]:
|
|
134
|
+
out = []
|
|
135
|
+
for flag in opt.flags:
|
|
136
|
+
if flag.startswith("--"):
|
|
137
|
+
spec = f"-l {_quote(flag[2:])}"
|
|
138
|
+
elif flag.startswith("-") and len(flag) == 2:
|
|
139
|
+
spec = f"-s {_quote(flag[1:])}"
|
|
140
|
+
else:
|
|
141
|
+
continue
|
|
142
|
+
line = "complete -c anona-memory"
|
|
143
|
+
if cond:
|
|
144
|
+
line += f" -n {_quote(cond)}"
|
|
145
|
+
line += f" {spec}"
|
|
146
|
+
if opt.takes_value:
|
|
147
|
+
line += " -r"
|
|
148
|
+
if opt.choices:
|
|
149
|
+
line += f" -a {_quote(' '.join(opt.choices))}"
|
|
150
|
+
if opt.help:
|
|
151
|
+
line += f" -d {_quote(opt.help[:80])}"
|
|
152
|
+
out.append(line)
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def script(parser: argparse.ArgumentParser, shell: str) -> str:
|
|
157
|
+
return {"bash": bash, "zsh": zsh, "fish": fish}[shell](parser)
|
anona/cli/config_env.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""`~/.anona/config.env`, read as data.
|
|
2
|
+
|
|
3
|
+
The skill installer (`skills/install.sh`) writes this file for the agent skill
|
|
4
|
+
and for `anona-memory-mcp`: `NAME='value'` lines, single-quoted, with a quote inside a
|
|
5
|
+
value written as '\\''. It is meant to be sourceable by a shell, which is
|
|
6
|
+
exactly why it must never be sourced or executed here: a value is whatever the
|
|
7
|
+
installer was given, and a hand-edited line can hold `$(...)` or backticks.
|
|
8
|
+
So it is parsed the way the installer's own `read_config` parses it -- the
|
|
9
|
+
three names below and nothing else, quoted or unquoted, never expanded.
|
|
10
|
+
|
|
11
|
+
Separate from `credentials.json` (store.py): this holds an API key, which the
|
|
12
|
+
REST API accepts, and `anona-memory logout` never touches it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import stat
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
NAMES = ("ANONA_API_KEY", "ANONA_SPACE_ID", "ANONA_BASE_URL")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def path() -> Path:
|
|
25
|
+
# The active profile's (store.anona_dir): ~/.anona/config.env by default.
|
|
26
|
+
from . import store
|
|
27
|
+
|
|
28
|
+
return store.anona_dir() / "config.env"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _unquote(value: str) -> str:
|
|
32
|
+
# The installer's `shq`: the whole value in single quotes, each embedded
|
|
33
|
+
# quote as '\''. Older installers wrote it unquoted, which is taken as is.
|
|
34
|
+
if len(value) >= 2 and value.startswith("'") and value.endswith("'"):
|
|
35
|
+
return value[1:-1].replace("'\\''", "'")
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse(text: str) -> dict[str, str]:
|
|
40
|
+
"""The recognised names in `text`. The first assignment of a name wins,
|
|
41
|
+
as in the installer's reader; an empty value counts as absent."""
|
|
42
|
+
found: dict[str, str] = {}
|
|
43
|
+
for line in text.splitlines():
|
|
44
|
+
line = line.rstrip("\r")
|
|
45
|
+
if line.startswith("export "):
|
|
46
|
+
line = line[len("export "):]
|
|
47
|
+
name, sep, value = line.partition("=")
|
|
48
|
+
if not sep or name not in NAMES or name in found:
|
|
49
|
+
continue
|
|
50
|
+
value = _unquote(value)
|
|
51
|
+
if value:
|
|
52
|
+
found[name] = value
|
|
53
|
+
return found
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load() -> dict[str, str]:
|
|
57
|
+
"""The recognised names in config.env, or {} when there is no file.
|
|
58
|
+
|
|
59
|
+
Warns on stderr -- naming the file, never a value -- when the file is
|
|
60
|
+
readable by anyone but its owner: it holds a key with full data-plane
|
|
61
|
+
reach. The installer writes it 600; this catches a hand-made copy.
|
|
62
|
+
"""
|
|
63
|
+
p = path()
|
|
64
|
+
try:
|
|
65
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
66
|
+
mode = p.stat().st_mode
|
|
67
|
+
except OSError:
|
|
68
|
+
return {}
|
|
69
|
+
if mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
70
|
+
print(
|
|
71
|
+
f"anona-memory: warning: {p} is readable by other users; "
|
|
72
|
+
f"run `chmod 600 {p}`.",
|
|
73
|
+
file=sys.stderr,
|
|
74
|
+
)
|
|
75
|
+
return parse(text)
|
anona/cli/credential.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Which API key `record`, `retrieve` and `reason` use, and where it came from.
|
|
2
|
+
|
|
3
|
+
Highest first: `--api-key`, then ANONA_API_KEY in the environment, then
|
|
4
|
+
ANONA_API_KEY in ~/.anona/config.env. With none of them the verbs fall back to
|
|
5
|
+
the command-line login (`anona-memory login --cli`), then to the stored
|
|
6
|
+
temporary-profile token; memory._token_and_space owns that half.
|
|
7
|
+
|
|
8
|
+
The MCP login is never one of these. It is MCP-scoped (`typ=mcp_access`) and
|
|
9
|
+
the REST API refuses it by design; MCP and the CLI's data verbs hold separate
|
|
10
|
+
credentials on purpose.
|
|
11
|
+
|
|
12
|
+
The source travels with the key because it decides where the key may be sent
|
|
13
|
+
(target.for_api_key) and is what `anona-memory status` names. The key itself is
|
|
14
|
+
never printed: `display()` is the only form that reaches output.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
_PREFIXES = ("anona_live_", "anona_test_")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ApiKey:
|
|
28
|
+
key: str = field(repr=False)
|
|
29
|
+
source: str # "flag", "env" or "config.env"
|
|
30
|
+
|
|
31
|
+
def display(self) -> str:
|
|
32
|
+
"""The key's prefix and a few characters, never enough to use it."""
|
|
33
|
+
for prefix in _PREFIXES:
|
|
34
|
+
if self.key.startswith(prefix):
|
|
35
|
+
rest = self.key[len(prefix):]
|
|
36
|
+
# A short key would give away a large share of itself.
|
|
37
|
+
return prefix + (rest[:4] if len(rest) >= 16 else "") + "…"
|
|
38
|
+
return (self.key[:4] if len(self.key) >= 24 else "") + "…"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def find_api_key(flag: str | None, config: dict[str, str]) -> ApiKey | None:
|
|
42
|
+
if flag:
|
|
43
|
+
return ApiKey(flag, "flag")
|
|
44
|
+
env = os.environ.get("ANONA_API_KEY")
|
|
45
|
+
if env:
|
|
46
|
+
return ApiKey(env, "env")
|
|
47
|
+
from_file = config.get("ANONA_API_KEY")
|
|
48
|
+
if from_file:
|
|
49
|
+
return ApiKey(from_file, "config.env")
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def find_base_url(flag: str | None, config: dict[str, str]) -> tuple[str | None, str | None]:
|
|
54
|
+
"""(base URL, its source), or (None, None) when nothing names one."""
|
|
55
|
+
if flag:
|
|
56
|
+
return flag, "flag"
|
|
57
|
+
env = os.environ.get("ANONA_BASE_URL")
|
|
58
|
+
if env:
|
|
59
|
+
return env, "env"
|
|
60
|
+
from_file = config.get("ANONA_BASE_URL")
|
|
61
|
+
if from_file:
|
|
62
|
+
return from_file, "config.env"
|
|
63
|
+
return None, None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def find_space(flag: str | None, config: dict[str, str]) -> str | None:
|
|
67
|
+
return flag or os.environ.get("ANONA_SPACE_ID") or config.get("ANONA_SPACE_ID") or None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Anything shaped like an Anona credential, wherever it turns up in text we
|
|
71
|
+
# did not write -- argparse echoes unrecognised arguments verbatim, so
|
|
72
|
+
# `anona-memory start --api-key <key>` would otherwise print the key back.
|
|
73
|
+
_CREDENTIAL = re.compile(r"\b(anona_(?:live|test|tmp)_)[A-Za-z0-9_\-]+")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def redact(text: str) -> str:
|
|
77
|
+
return _CREDENTIAL.sub(lambda m: m.group(1) + "…", text)
|