qatlas-cli 0.22.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.
qatlas/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """
2
+ QuantumAtlas - AI 驱动的量子算法开发辅助系统
3
+
4
+ 核心功能:从论文到可执行量子代码的完整转化链路
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import tomllib
10
+ from importlib.metadata import PackageNotFoundError, version
11
+ from pathlib import Path
12
+
13
+
14
+ def _version_from_pyproject() -> str:
15
+ """Read [project].version from the nearest repo-root pyproject.toml.
16
+
17
+ Supports both the legacy flat layout (``qatlas/`` at the repo root,
18
+ pyproject at ``parents[1]``) and the src layout (``src/qatlas/``,
19
+ pyproject at ``parents[2]``).
20
+ """
21
+ here = Path(__file__).resolve()
22
+ for parent in (here.parents[1], here.parents[2]):
23
+ pyproject = parent / "pyproject.toml"
24
+ if not pyproject.is_file():
25
+ continue
26
+ with pyproject.open("rb") as f:
27
+ data = tomllib.load(f)
28
+ try:
29
+ return str(data["project"]["version"])
30
+ except KeyError:
31
+ continue
32
+ raise FileNotFoundError("no pyproject.toml with [project].version found")
33
+
34
+
35
+ def _resolve_version() -> str:
36
+ """Prefer repo pyproject when present, else installed distribution metadata."""
37
+ try:
38
+ return _version_from_pyproject()
39
+ except (FileNotFoundError, KeyError, OSError):
40
+ try:
41
+ return version("qatlas-cli")
42
+ except PackageNotFoundError:
43
+ return "0+unknown"
44
+
45
+
46
+ __version__ = _resolve_version()
47
+
48
+ __all__ = ["__version__"]
qatlas/cli.py ADDED
@@ -0,0 +1,218 @@
1
+ """Top-level command-line entry point for QuantumAtlas.
2
+
3
+ This module backs the ``qatlas`` console script declared in
4
+ ``pyproject.toml``. It intentionally delegates to the existing module CLIs so
5
+ their behavior stays identical to ``python -m qatlas.<module>``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import runpy
11
+ import sys
12
+ from dataclasses import dataclass
13
+ from typing import Mapping, Sequence
14
+
15
+ from qatlas import __version__
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Command:
20
+ """A delegated QuantumAtlas CLI command."""
21
+
22
+ module: str
23
+ summary: str
24
+ client_friendly: bool = True
25
+
26
+
27
+ COMMANDS: Mapping[str, Command] = {
28
+ "config": Command(
29
+ "qatlas.client.config",
30
+ "Manage the user-level config file (~/.config/qatlas/config.yaml)",
31
+ ),
32
+ "auth": Command(
33
+ "qatlas.client.auth",
34
+ "Manage saved PATs / session tokens per host (login, status, token, logout)",
35
+ ),
36
+ "paper": Command(
37
+ "qatlas.client.paper",
38
+ "Fetch paper PDF / markdown from the server (silent fetch + LRO polling for cache misses)",
39
+ ),
40
+ "contrib": Command(
41
+ "qatlas.client.contrib",
42
+ "Contributor workflows: upload PDFs (contrib pdf) or run local MinerU and push (contrib mineru)",
43
+ ),
44
+ "parser": Command("qatlas.parser.__main__", "Fetch and parse arXiv papers", False),
45
+ }
46
+
47
+ ALIASES: Mapping[str, str] = {
48
+ "papers": "paper",
49
+ "parse": "parser",
50
+ }
51
+
52
+
53
+ def _print_help() -> None:
54
+ """Print top-level CLI help."""
55
+
56
+ print(
57
+ """QuantumAtlas command line
58
+
59
+ Usage:
60
+ qatlas <command> [args...]
61
+ qatlas --version
62
+ qatlas --help
63
+
64
+ Commands:"""
65
+ )
66
+
67
+ print(" Client/operator commands:")
68
+ for name, command in COMMANDS.items():
69
+ if not command.client_friendly:
70
+ continue
71
+ print(f" {name:<10} {command.summary}")
72
+
73
+ print("\n Local workspace commands:")
74
+ for name, command in COMMANDS.items():
75
+ if command.client_friendly:
76
+ continue
77
+ print(f" {name:<10} {command.summary}")
78
+
79
+ # Plugin-contributed top-level commands (only those available in the
80
+ # current environment). No first-party plugins ship today; third-party
81
+ # plugins register via the ``qatlas.plugins`` entry-point group.
82
+ try:
83
+ from qatlas.client.plugins import registry
84
+
85
+ plugin_cmds = registry.top_level_commands()
86
+ except Exception:
87
+ plugin_cmds = {}
88
+ if plugin_cmds:
89
+ print("\n Plugin commands:")
90
+ for name in sorted(plugin_cmds):
91
+ print(f" {name:<10} {plugin_cmds[name].summary}")
92
+
93
+ print(
94
+ """
95
+ Aliases:
96
+ papers -> paper
97
+ parse -> parser
98
+
99
+ Examples:
100
+ qatlas paper get markdown quant-ph/9508027 --output paper.md
101
+ qatlas paper get pdf 10.1103/PhysRevLett.103.150502 -o paper.pdf
102
+ qatlas contrib pdf quant-ph/9508027v1 --pdf paper.pdf
103
+ qatlas contrib mineru 2501.00010v1
104
+ qatlas contrib mineru --watch
105
+
106
+ Use "qatlas <command> --help" for command-specific options."""
107
+ )
108
+
109
+
110
+ def _print_usage_error(message: str) -> None:
111
+ print(f"Error: {message}", file=sys.stderr)
112
+ print("Run 'qatlas --help' to see available commands.", file=sys.stderr)
113
+
114
+
115
+ def _print_search_plugin_hint() -> None:
116
+ """Explain that ``search`` ships as the standalone qatlas-search plugin."""
117
+
118
+ print(
119
+ "The 'search' command is provided by the standalone plugin "
120
+ "qatlas-search, which is not installed.\n"
121
+ "Install it with pip/uv from the private repository "
122
+ "IAI-USTC-Quantum/qatlas-search, e.g.:\n"
123
+ " uv tool install --from git+ssh://git@github.com/IAI-USTC-Quantum/qatlas-search.git qatlas-search\n"
124
+ "or, into the current environment:\n"
125
+ " uv pip install git+ssh://git@github.com/IAI-USTC-Quantum/qatlas-search.git",
126
+ file=sys.stderr,
127
+ )
128
+
129
+
130
+ def _exit_code(code: object) -> int:
131
+ """Normalize a child ``SystemExit.code`` value to an integer exit code."""
132
+
133
+ if code is None:
134
+ return 0
135
+ if isinstance(code, int):
136
+ return code
137
+ print(code, file=sys.stderr)
138
+ return 1
139
+
140
+
141
+ def _run_module(module: str, argv0: str, args: Sequence[str]) -> int:
142
+ """Run a child module as if it had been invoked with ``python -m``."""
143
+
144
+ original_argv = sys.argv[:]
145
+ sys.argv = [argv0, *args]
146
+ try:
147
+ try:
148
+ runpy.run_module(module, run_name="__main__")
149
+ except SystemExit as exc:
150
+ return _exit_code(exc.code)
151
+ return 0
152
+ finally:
153
+ sys.argv = original_argv
154
+
155
+
156
+ def main(argv: Sequence[str] | None = None) -> int:
157
+ """Run the QuantumAtlas CLI."""
158
+
159
+ args = list(sys.argv[1:] if argv is None else argv)
160
+
161
+ if not args or args[0] in {"-h", "--help"}:
162
+ _print_help()
163
+ return 0
164
+
165
+ if args[0] in {"-V", "--version"}:
166
+ print(f"qatlas {__version__}")
167
+ return 0
168
+
169
+ requested_command = args[0].replace("_", "-")
170
+ command_name = ALIASES.get(requested_command, requested_command)
171
+ command = COMMANDS.get(command_name)
172
+
173
+ # Plugin-contributed top-level commands fill gaps the static table
174
+ # doesn't cover. Built-ins always take precedence.
175
+ plugin_spec = None
176
+ if command is None:
177
+ try:
178
+ from qatlas.client.plugins import registry
179
+
180
+ plugin_spec = registry.top_level_commands().get(command_name)
181
+ except Exception:
182
+ plugin_spec = None
183
+ if plugin_spec is None:
184
+ if command_name == "search":
185
+ _print_search_plugin_hint()
186
+ return 2
187
+ _print_usage_error(f"unknown command '{args[0]}'")
188
+ return 2
189
+
190
+ # v0.17.0+: client config lives exclusively in
191
+ # ~/.config/qatlas/config.yaml. Ensure it exists on first run so
192
+ # the user can immediately edit it; idempotent on subsequent runs.
193
+ #
194
+ # Exception: skip for `qatlas config` itself — its `path` / `show`
195
+ # subcommands intentionally tolerate a missing file and would
196
+ # display misleading "auto-created on first read" behaviour
197
+ # otherwise.
198
+ if command_name != "config":
199
+ try:
200
+ from qatlas.config import ensure_default_config_exists
201
+ ensure_default_config_exists()
202
+ except Exception:
203
+ # Defensive: never block a subcommand on config-file IO;
204
+ # the embedded defaults work for any read-only command.
205
+ pass
206
+
207
+ if plugin_spec is not None:
208
+ return plugin_spec.handler(args[1:])
209
+
210
+ return _run_module(
211
+ command.module,
212
+ argv0=f"qatlas {command_name}",
213
+ args=args[1:],
214
+ )
215
+
216
+
217
+ if __name__ == "__main__":
218
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """HTTP client commands for QuantumAtlas."""
@@ -0,0 +1,231 @@
1
+ """Shared helpers for QuantumAtlas client-side CLIs.
2
+
3
+ Configuration source (v0.17.0+): every value comes from
4
+ ``~/.config/qatlas/config.yaml`` via ``qatlas.config.ServerConfig``.
5
+ No CLI flag, no OS env, no ``QATLAS_DOTENV``. ``qatlas auth login``
6
+ still maintains a separate per-host token file
7
+ (``~/.config/qatlas/hosts.yml``) used as the last-resort token
8
+ fallback when ``config.yaml`` has no ``token:``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import re
16
+ import sys
17
+ from typing import Any
18
+
19
+ import requests
20
+
21
+ from qatlas import __version__ as _CLIENT_VERSION
22
+ from qatlas.config import ServerConfig
23
+
24
+
25
+ def _client_config() -> ServerConfig:
26
+ """Build a fresh ServerConfig view of the current YAML.
27
+
28
+ Cheap (~ms), and re-reading per call means a `qatlas config set`
29
+ in between two `qatlas papers ...` invocations is picked up
30
+ immediately without process state.
31
+ """
32
+ return ServerConfig.from_env()
33
+
34
+
35
+ def default_base_url() -> str:
36
+ """Resolve the server base URL from ``config.yaml`` ``server_url``.
37
+
38
+ Falls back to ``http://127.0.0.1:8090`` (PocketBase default) when
39
+ unset so ``qatlas`` doesn't fatal on first run without a configured
40
+ server — useful for local dev.
41
+ """
42
+ cfg = _client_config()
43
+ server_url = cfg.get_server_url()
44
+ if server_url:
45
+ return server_url
46
+ return "http://127.0.0.1:8090"
47
+
48
+
49
+ def base_url_from_args(args: argparse.Namespace) -> str:
50
+ """Return the config-file server_url.
51
+
52
+ ``args`` is accepted for back-compat with the v0.16 signature; no
53
+ field is read from it anymore. Subcommands that need a different
54
+ server should set up a separate ``XDG_CONFIG_HOME``-isolated
55
+ config.
56
+ """
57
+ return default_base_url()
58
+
59
+
60
+ def request_verify(args: argparse.Namespace) -> bool:
61
+ """Honor ``insecure: true`` in config.yaml to disable TLS verification.
62
+
63
+ Same one-shot warning behaviour as before. ``args`` kept for
64
+ signature back-compat.
65
+ """
66
+ cfg = _client_config()
67
+ if not cfg.insecure:
68
+ return True
69
+ if not getattr(args, "_insecure_warning_shown", False):
70
+ requests.packages.urllib3.disable_warnings( # type: ignore[attr-defined]
71
+ category=requests.packages.urllib3.exceptions.InsecureRequestWarning
72
+ )
73
+ print("Warning: TLS certificate verification is disabled.", file=sys.stderr)
74
+ args._insecure_warning_shown = True
75
+ return False
76
+
77
+
78
+ def resolve_token(args: argparse.Namespace) -> str:
79
+ """Resolve the bearer credential from ``~/.config/qatlas/hosts.yml``.
80
+
81
+ The store is populated by ``qatlas auth login`` (browser OAuth /
82
+ ``--with-token`` from stdin). ``server_url:`` in config.yaml /
83
+ ``--server-url`` CLI flag picks WHICH host's token to use.
84
+
85
+ ``args`` kept for signature back-compat (used to honour
86
+ ``--token`` CLI flag, removed in v0.17.0; config.yaml ``token:``
87
+ field removed in v0.19.0 — it silently shadowed all per-host
88
+ tokens in hosts.yml).
89
+
90
+ An empty return value means no Authorization header will be set;
91
+ the server then either serves open reads or replies 401 for write
92
+ endpoints. The 401 body always points the user at ``/pat``
93
+ (top-level redirect to ``/<lang>/pat``, defined in
94
+ ``web/src/routes/pat.tsx``) regardless of language.
95
+ """
96
+ try:
97
+ from qatlas.client.auth import get_stored_token # local import to avoid cycle
98
+
99
+ return get_stored_token(default_base_url())
100
+ except Exception:
101
+ # Defensive: never let a config-file glitch break unrelated commands.
102
+ return ""
103
+
104
+
105
+ def auth_headers(args: argparse.Namespace) -> dict[str, str]:
106
+ """Build the Authorization header for a CLI request.
107
+
108
+ Returns an empty dict when no token is configured so callers can
109
+ safely splat ``{**auth_headers(args), ...other...}``.
110
+ """
111
+ token = resolve_token(args)
112
+ if not token:
113
+ return {}
114
+ return {"Authorization": f"Bearer {token}"}
115
+
116
+
117
+ def print_json(payload: dict[str, Any]) -> None:
118
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
119
+
120
+
121
+ def add_common_http_args(parser: argparse.ArgumentParser) -> None:
122
+ """Register the shared ``--request-timeout`` flag.
123
+
124
+ v0.17.0 removed ``--base-url`` / ``--token`` / ``--insecure``
125
+ flags — those fields now live exclusively in
126
+ ``~/.config/qatlas/config.yaml``. ``--request-timeout`` is kept
127
+ because it's an in-call ergonomic knob (raise the timeout for a
128
+ slow MinerU poll), not a persistent config concern.
129
+ """
130
+ parser.add_argument(
131
+ "--request-timeout",
132
+ type=float,
133
+ default=120.0,
134
+ help="HTTP request timeout in seconds (per-call override).",
135
+ )
136
+
137
+
138
+ def run_with_request_errors(func, *args, **kwargs) -> int:
139
+ """Convert ValueError / RequestException into standard CLI exit codes."""
140
+ try:
141
+ return func(*args, **kwargs)
142
+ except ValueError as exc:
143
+ print(f"Invalid input: {exc}", file=sys.stderr)
144
+ return 2
145
+ except requests.RequestException as exc:
146
+ print(f"Request failed: {exc}", file=sys.stderr)
147
+ return 1
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Client/server version negotiation (since v0.8.0)
152
+ # ---------------------------------------------------------------------------
153
+ #
154
+ # Contract: the client version MUST be >= the server version (major+minor
155
+ # semver). Rationale: when the server adds a new endpoint (e.g. the v0.8.0
156
+ # `upload-mineru` replacing `upload-markdown`), an old client doesn't know
157
+ # the new wire shape and will fail in confusing ways. Forcing client >=
158
+ # server prevents the silent broken state.
159
+ #
160
+ # Mechanism:
161
+ # 1. Every request adds `X-Qatlas-Client-Version: <version>` (Headers
162
+ # injected by client_version_headers()). The server logs / future
163
+ # rate-limit policies can use it.
164
+ # 2. Every response (when from a v0.8.0+ server) includes
165
+ # `X-Qatlas-Server-Version: <version>`. The client compares major+
166
+ # minor against its own __version__.
167
+ # 3. If server > client AND the call is a write op → raise SystemExit
168
+ # (hard fail). Read ops just emit a one-shot stderr warning.
169
+ # 4. If the header is absent (older server) the client treats the
170
+ # server as "unknown version" and silently skips negotiation —
171
+ # forward-compatible with pre-v0.8.0 deployments.
172
+ #
173
+ # Patch-level differences are ignored on purpose: a patch bump is supposed
174
+ # to be backwards-compatible bug-fix, so cross-patch usage is fine.
175
+
176
+ _VERSION_TUPLE_RE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?(?:[.+-].*)?$")
177
+
178
+
179
+ def _parse_semver(v: str) -> tuple[int, int] | None:
180
+ """Return (major, minor) for a semver string, or None if unparseable."""
181
+ if not v:
182
+ return None
183
+ m = _VERSION_TUPLE_RE.match(v.strip())
184
+ if not m:
185
+ return None
186
+ return (int(m.group(1)), int(m.group(2)))
187
+
188
+
189
+ def client_version_headers() -> dict[str, str]:
190
+ """Headers every outgoing request should include for version negotiation."""
191
+ return {"X-Qatlas-Client-Version": _CLIENT_VERSION}
192
+
193
+
194
+ _WARNED_OLDER_CLIENT: set[str] = set()
195
+
196
+
197
+ def check_response_version(response: requests.Response, *, write: bool) -> None:
198
+ """Compare X-Qatlas-Server-Version against this client's version.
199
+
200
+ * `write=True` callers (POST/PUT/PATCH/DELETE) hard-fail (SystemExit 4)
201
+ when the server is newer than the client at major+minor level — the
202
+ server's wire contract may have moved and silent breakage is the
203
+ worst failure mode.
204
+ * `write=False` callers (GET, status polls) only emit a one-shot
205
+ warning per unique server version, so read-mostly workflows still
206
+ function while signalling the upgrade is needed.
207
+
208
+ Older servers (pre-v0.8.0) don't send the header — we treat the
209
+ absence as "unknown" and do nothing, preserving the new client's
210
+ ability to talk to legacy deployments.
211
+ """
212
+ server_version = response.headers.get("X-Qatlas-Server-Version", "").strip()
213
+ if not server_version:
214
+ return # pre-v0.8.0 server, skip negotiation
215
+ server = _parse_semver(server_version)
216
+ client = _parse_semver(_CLIENT_VERSION)
217
+ if server is None or client is None:
218
+ return # unparseable on either side — fail open, don't block calls
219
+ if server <= client:
220
+ return # client >= server, contract satisfied
221
+ msg = (
222
+ f"server version {server_version} is newer than client {_CLIENT_VERSION}.\n"
223
+ f"This client may not understand new endpoints/fields. Upgrade with:\n"
224
+ f" pip install --upgrade quantum-atlas"
225
+ )
226
+ if write:
227
+ print(f"ERROR: {msg}", file=sys.stderr)
228
+ raise SystemExit(4)
229
+ if server_version not in _WARNED_OLDER_CLIENT:
230
+ print(f"WARNING: {msg}", file=sys.stderr)
231
+ _WARNED_OLDER_CLIENT.add(server_version)