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 +48 -0
- qatlas/cli.py +218 -0
- qatlas/client/__init__.py +1 -0
- qatlas/client/_common.py +231 -0
- qatlas/client/auth.py +791 -0
- qatlas/client/config.py +276 -0
- qatlas/client/contrib.py +180 -0
- qatlas/client/mineru.py +1345 -0
- qatlas/client/paper.py +620 -0
- qatlas/client/plugins/__init__.py +16 -0
- qatlas/client/plugins/base.py +40 -0
- qatlas/client/plugins/registry.py +67 -0
- qatlas/client/upload.py +331 -0
- qatlas/config.py +299 -0
- qatlas/config_yaml.py +136 -0
- qatlas/parser/__init__.py +16 -0
- qatlas/parser/__main__.py +99 -0
- qatlas/parser/arxiv_fetcher.py +242 -0
- qatlas/parser/keyring.py +135 -0
- qatlas/parser/mineru_client.py +529 -0
- qatlas/paths.py +70 -0
- qatlas/py.typed +0 -0
- qatlas_cli-0.22.0.dist-info/METADATA +114 -0
- qatlas_cli-0.22.0.dist-info/RECORD +26 -0
- qatlas_cli-0.22.0.dist-info/WHEEL +4 -0
- qatlas_cli-0.22.0.dist-info/entry_points.txt +2 -0
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."""
|
qatlas/client/_common.py
ADDED
|
@@ -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)
|