xmemcli 0.0.1__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.
- xmemcli/__init__.py +3 -0
- xmemcli/_envelope.py +24 -0
- xmemcli/_help.py +720 -0
- xmemcli/_instance_schema.py +769 -0
- xmemcli/_login.py +593 -0
- xmemcli/_org_cluster.py +70 -0
- xmemcli/_output.py +274 -0
- xmemcli/_pretty.py +373 -0
- xmemcli/_rc.py +192 -0
- xmemcli/_version.py +4 -0
- xmemcli/_xmem_bridge.py +33 -0
- xmemcli/cli.py +1826 -0
- xmemcli-0.0.1.dist-info/METADATA +22 -0
- xmemcli-0.0.1.dist-info/RECORD +16 -0
- xmemcli-0.0.1.dist-info/WHEEL +4 -0
- xmemcli-0.0.1.dist-info/entry_points.txt +2 -0
xmemcli/_rc.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Project-local ``.xmemrc.json`` credential store.
|
|
2
|
+
|
|
3
|
+
The CLI resolves its API key and API origin from a **project-local**
|
|
4
|
+
``.xmemrc.json`` found by walking **up** from ``--rc-dir`` / ``$XMEM_RC_DIR``
|
|
5
|
+
(or the current directory). The search does **not** stop at the repository root
|
|
6
|
+
and is **ceilinged at ``$HOME``** — ``~/.xmemrc.json`` is the last fallback.
|
|
7
|
+
|
|
8
|
+
The file is stdlib JSON, strongly typed ``{email, api_url, api_key}`` (one
|
|
9
|
+
identity per file; extras / missing / non-string keys are rejected). The ``0600``
|
|
10
|
+
atomic writer is used by ``xmem auth login``; see ``cli/AUTH-FLOW.md``.
|
|
11
|
+
|
|
12
|
+
Resolution precedence (applied by ``cli/xmem.py``): ``--api-key`` → ``$XMEM_API_KEY`` → ``.xmemrc.json``; and
|
|
13
|
+
``--api-url`` → ``$XMEM_API_URL`` → the rc's ``api_url`` → the built-in default — **with one escape hatch**: when the
|
|
14
|
+
key comes from an explicit ``--api-key`` / ``$XMEM_API_KEY`` (not from the rc), the rc's ``api_url`` is **not**
|
|
15
|
+
consulted and the origin falls back straight to the built-in default. That keeps an explicitly supplied key from being
|
|
16
|
+
silently paired with an unrelated origin stored in a project ``.xmemrc.json``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import subprocess
|
|
22
|
+
import tempfile
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import NamedTuple
|
|
25
|
+
|
|
26
|
+
RC_FILENAME = ".xmemrc.json"
|
|
27
|
+
ENV_RC_DIR = "XMEM_RC_DIR"
|
|
28
|
+
|
|
29
|
+
# The exact, complete set of keys an `.xmemrc.json` must contain — no more, no
|
|
30
|
+
# fewer. Keeping it closed means a typo'd or extended file fails loudly instead
|
|
31
|
+
# of silently ignoring a field.
|
|
32
|
+
_RC_KEYS = ("email", "api_url", "api_key")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RcError(Exception):
|
|
36
|
+
"""An ``.xmemrc.json`` exists but is malformed (bad JSON or wrong shape)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RcRecord(NamedTuple):
|
|
40
|
+
"""One identity loaded from a project-local ``.xmemrc.json``.
|
|
41
|
+
|
|
42
|
+
A ``typing.NamedTuple`` (immutable, typed, attribute-accessed) rather than a
|
|
43
|
+
``@dataclass`` or the repo's ``TightBaseModel``: this CLI is stdlib-only and
|
|
44
|
+
standalone — it runs without a ``pip install`` of the ``xmemory`` library
|
|
45
|
+
(see ``cli/xmem.py``) — so it deliberately avoids the pydantic-based base
|
|
46
|
+
models, and ``NamedTuple`` gives a frozen record with no third-party dep.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
email: str
|
|
50
|
+
api_url: str
|
|
51
|
+
api_key: str
|
|
52
|
+
path: Path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _home() -> Path:
|
|
56
|
+
return Path(os.path.expanduser("~")).resolve()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _within_home(directory: Path, home: Path) -> bool:
|
|
60
|
+
"""True iff ``directory`` is ``$HOME`` itself or a descendant of it."""
|
|
61
|
+
return directory == home or home in directory.parents
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def find_rc(start_dir: Path) -> Path | None:
|
|
65
|
+
"""Return the nearest ``.xmemrc.json`` at or above ``start_dir``, ceilinged at ``$HOME``.
|
|
66
|
+
|
|
67
|
+
Walk upward from ``start_dir`` checking each directory, but **never ascend
|
|
68
|
+
past ``$HOME``**: a parent is visited only while it stays within the home
|
|
69
|
+
subtree. ``start_dir`` itself is always checked — so an explicitly chosen
|
|
70
|
+
``--rc-dir`` outside ``$HOME`` is still honoured — but from an out-of-home
|
|
71
|
+
start the walk does **not** climb into shared parents like ``/tmp`` or ``/``
|
|
72
|
+
(which is where a stray ``.xmemrc.json`` could otherwise be picked up). If
|
|
73
|
+
nothing is found, fall back to ``~/.xmemrc.json``; returns ``None`` when no
|
|
74
|
+
rc file exists.
|
|
75
|
+
"""
|
|
76
|
+
home = _home()
|
|
77
|
+
directory = start_dir.resolve()
|
|
78
|
+
while True:
|
|
79
|
+
candidate = directory / RC_FILENAME
|
|
80
|
+
if candidate.is_file():
|
|
81
|
+
return candidate
|
|
82
|
+
parent = directory.parent
|
|
83
|
+
# Stop at the home ceiling, at the filesystem root, or just before
|
|
84
|
+
# stepping outside the home subtree.
|
|
85
|
+
if directory == home or parent == directory or not _within_home(parent, home):
|
|
86
|
+
break
|
|
87
|
+
directory = parent
|
|
88
|
+
home_rc = home / RC_FILENAME
|
|
89
|
+
return home_rc if home_rc.is_file() else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_rc(path: Path) -> RcRecord:
|
|
93
|
+
"""Parse and validate an ``.xmemrc.json``; raise ``RcError`` if malformed."""
|
|
94
|
+
try:
|
|
95
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
96
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
97
|
+
raise RcError(f"{path}: could not read JSON ({exc})") from exc
|
|
98
|
+
if not isinstance(raw, dict):
|
|
99
|
+
raise RcError(f"{path}: expected a JSON object, got {type(raw).__name__}")
|
|
100
|
+
keys = set(raw.keys())
|
|
101
|
+
expected = set(_RC_KEYS)
|
|
102
|
+
if keys != expected:
|
|
103
|
+
missing = sorted(expected - keys)
|
|
104
|
+
extra = sorted(keys - expected)
|
|
105
|
+
detail = ", ".join(
|
|
106
|
+
part
|
|
107
|
+
for part in (
|
|
108
|
+
f"missing {missing}" if missing else "",
|
|
109
|
+
f"unexpected {extra}" if extra else "",
|
|
110
|
+
)
|
|
111
|
+
if part
|
|
112
|
+
)
|
|
113
|
+
raise RcError(f"{path}: must contain exactly {list(_RC_KEYS)} ({detail})")
|
|
114
|
+
for key in _RC_KEYS:
|
|
115
|
+
if not isinstance(raw[key], str):
|
|
116
|
+
raise RcError(f"{path}: '{key}' must be a string")
|
|
117
|
+
return RcRecord(email=raw["email"], api_url=raw["api_url"], api_key=raw["api_key"], path=path)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def resolve_rc(rc_dir: str | None) -> RcRecord | None:
|
|
121
|
+
"""Resolve the active ``.xmemrc.json`` honouring ``--rc-dir`` / ``$XMEM_RC_DIR``.
|
|
122
|
+
|
|
123
|
+
``rc_dir`` is the ``--rc-dir`` flag value (or ``None``); it falls back to
|
|
124
|
+
``$XMEM_RC_DIR`` and then the current working directory. Returns ``None``
|
|
125
|
+
when no rc file is found; raises ``RcError`` when one exists but is malformed.
|
|
126
|
+
"""
|
|
127
|
+
start = Path(rc_dir or os.environ.get(ENV_RC_DIR) or os.getcwd())
|
|
128
|
+
found = find_rc(start)
|
|
129
|
+
return load_rc(found) if found is not None else None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def write_rc(target_dir: Path, *, email: str, api_url: str, api_key: str) -> Path:
|
|
133
|
+
"""Write ``target_dir/.xmemrc.json`` at ``0600``, atomically."""
|
|
134
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
path = target_dir / RC_FILENAME
|
|
136
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(target_dir), prefix=".xmemrc.", suffix=".tmp")
|
|
137
|
+
fd_owned = True
|
|
138
|
+
success = False
|
|
139
|
+
try:
|
|
140
|
+
os.chmod(tmp_name, 0o600)
|
|
141
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
142
|
+
fd_owned = False
|
|
143
|
+
json.dump({"email": email, "api_url": api_url, "api_key": api_key}, fh, indent=2)
|
|
144
|
+
fh.write("\n")
|
|
145
|
+
os.replace(tmp_name, path)
|
|
146
|
+
success = True
|
|
147
|
+
finally:
|
|
148
|
+
if fd_owned:
|
|
149
|
+
os.close(fd)
|
|
150
|
+
if not success:
|
|
151
|
+
try:
|
|
152
|
+
os.unlink(tmp_name)
|
|
153
|
+
except FileNotFoundError:
|
|
154
|
+
pass
|
|
155
|
+
return path
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def remove_rc(path: Path) -> bool:
|
|
159
|
+
"""Delete the ``.xmemrc.json`` at ``path``. Returns ``True`` iff it existed."""
|
|
160
|
+
try:
|
|
161
|
+
path.unlink()
|
|
162
|
+
return True
|
|
163
|
+
except FileNotFoundError:
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def gitignore_warning(rc_path: Path) -> str | None:
|
|
168
|
+
"""Warn if ``rc_path`` is inside a git repo and not gitignored."""
|
|
169
|
+
parent = str(rc_path.parent)
|
|
170
|
+
try:
|
|
171
|
+
inside = subprocess.run(
|
|
172
|
+
["git", "-C", parent, "rev-parse", "--is-inside-work-tree"],
|
|
173
|
+
capture_output=True,
|
|
174
|
+
text=True,
|
|
175
|
+
check=False,
|
|
176
|
+
)
|
|
177
|
+
except (OSError, ValueError):
|
|
178
|
+
return None
|
|
179
|
+
if inside.returncode != 0 or inside.stdout.strip() != "true":
|
|
180
|
+
return None
|
|
181
|
+
ignored = subprocess.run(
|
|
182
|
+
["git", "-C", parent, "check-ignore", "-q", rc_path.name],
|
|
183
|
+
capture_output=True,
|
|
184
|
+
text=True,
|
|
185
|
+
check=False,
|
|
186
|
+
)
|
|
187
|
+
if ignored.returncode == 0:
|
|
188
|
+
return None
|
|
189
|
+
return (
|
|
190
|
+
f"{rc_path} holds a plaintext API key and is NOT gitignored. "
|
|
191
|
+
f"Add this line to .gitignore so it is never committed:\n {RC_FILENAME}"
|
|
192
|
+
)
|
xmemcli/_version.py
ADDED
xmemcli/_xmem_bridge.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Shared lazy binding to the active ``xmem`` command module."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
INSTANCE_FIELDS = ("id", "name", "description", "cluster_id")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def xmem_module() -> Any:
|
|
11
|
+
main_mod = sys.modules.get("__main__")
|
|
12
|
+
if main_mod is not None and hasattr(main_mod, "_require"):
|
|
13
|
+
return main_mod
|
|
14
|
+
try:
|
|
15
|
+
return importlib.import_module("xmem")
|
|
16
|
+
except ModuleNotFoundError:
|
|
17
|
+
return importlib.import_module("xmemcli.cli") # pyright: ignore[reportMissingImports]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def curate_instance(item: dict[str, Any], verbose: bool) -> dict[str, Any]:
|
|
21
|
+
"""Shared instance list/get curation for org and schema command modules."""
|
|
22
|
+
m = xmem_module()
|
|
23
|
+
curated = {k: item[k] for k in INSTANCE_FIELDS if k in item}
|
|
24
|
+
if verbose and "data_schema" in item:
|
|
25
|
+
curated["data_schema"] = item["data_schema"]
|
|
26
|
+
return m._add_verbose_extras(curated, item, verbose)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def http_to_exit(status: int | None) -> int:
|
|
30
|
+
m = xmem_module()
|
|
31
|
+
if status in (401, 403):
|
|
32
|
+
return m.EXIT_AUTH_ERROR
|
|
33
|
+
return m.EXIT_HTTP_ERROR
|