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/_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
@@ -0,0 +1,4 @@
1
+ """Release metadata for the xmemcli CLI (copied into standalone builds)."""
2
+
3
+ CLI_NAME = "xmemcli"
4
+ __version__ = "0.0.1"
@@ -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