aukeys-opscli 0.0.6__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.
- aukeys_opscli-0.0.6.dist-info/METADATA +15 -0
- aukeys_opscli-0.0.6.dist-info/RECORD +92 -0
- aukeys_opscli-0.0.6.dist-info/WHEEL +4 -0
- aukeys_opscli-0.0.6.dist-info/entry_points.txt +2 -0
- opscli/__init__.py +9 -0
- opscli/amazon/__init__.py +13 -0
- opscli/amazon/cli.py +5 -0
- opscli/amazon/client.py +5 -0
- opscli/amazon/commands/__init__.py +1 -0
- opscli/amazon/commands/cli.py +200 -0
- opscli/amazon/domain/__init__.py +25 -0
- opscli/amazon/domain/exceptions.py +73 -0
- opscli/amazon/domain/models.py +74 -0
- opscli/amazon/exceptions.py +21 -0
- opscli/amazon/manager.py +5 -0
- opscli/amazon/models.py +5 -0
- opscli/amazon/parser.py +5 -0
- opscli/amazon/scraper.py +5 -0
- opscli/amazon/scraping/__init__.py +12 -0
- opscli/amazon/scraping/parser.py +66 -0
- opscli/amazon/scraping/scraper.py +315 -0
- opscli/amazon/services/__init__.py +5 -0
- opscli/amazon/services/manager.py +105 -0
- opscli/amazon/transport/__init__.py +5 -0
- opscli/amazon/transport/client.py +77 -0
- opscli/auth/__init__.py +79 -0
- opscli/auth/cli.py +255 -0
- opscli/auth/commands/__init__.py +1 -0
- opscli/auth/commands/cli.py +5 -0
- opscli/auth/config.py +55 -0
- opscli/auth/core/__init__.py +1 -0
- opscli/auth/core/device_flow.py +80 -0
- opscli/auth/core/system_registry.py +89 -0
- opscli/auth/core/token_manager.py +167 -0
- opscli/auth/domain/__init__.py +23 -0
- opscli/auth/domain/exceptions.py +33 -0
- opscli/auth/exceptions.py +23 -0
- opscli/auth/storage/__init__.py +1 -0
- opscli/auth/storage/credential_store.py +163 -0
- opscli/auth/storage/crypto.py +67 -0
- opscli/cli.py +36 -0
- opscli/config.py +3 -0
- opscli/query/__init__.py +6 -0
- opscli/query/cli.py +5 -0
- opscli/query/client.py +5 -0
- opscli/query/commands/__init__.py +1 -0
- opscli/query/commands/cli.py +140 -0
- opscli/query/domain/__init__.py +23 -0
- opscli/query/domain/exceptions.py +73 -0
- opscli/query/domain/models.py +21 -0
- opscli/query/exceptions.py +21 -0
- opscli/query/manager.py +5 -0
- opscli/query/models.py +5 -0
- opscli/query/services/__init__.py +5 -0
- opscli/query/services/manager.py +373 -0
- opscli/query/transport/__init__.py +5 -0
- opscli/query/transport/client.py +57 -0
- opscli/skills/__init__.py +37 -0
- opscli/skills/cli.py +5 -0
- opscli/skills/commands/__init__.py +1 -0
- opscli/skills/commands/cli.py +389 -0
- opscli/skills/detector.py +5 -0
- opscli/skills/discovery/__init__.py +5 -0
- opscli/skills/discovery/detector.py +225 -0
- opscli/skills/domain/__init__.py +23 -0
- opscli/skills/domain/exceptions.py +51 -0
- opscli/skills/domain/models.py +144 -0
- opscli/skills/exceptions.py +5 -0
- opscli/skills/manager.py +5 -0
- opscli/skills/models.py +19 -0
- opscli/skills/services/__init__.py +5 -0
- opscli/skills/services/manager.py +276 -0
- opscli/skills/sync/__init__.py +5 -0
- opscli/skills/sync/updater.py +274 -0
- opscli/skills/templates/ops-amazon/SKILL.md +181 -0
- opscli/skills/templates/ops-amazon/data/VERSION.json +4 -0
- opscli/skills/templates/ops-auth/SKILL.md +466 -0
- opscli/skills/templates/ops-auth/data/VERSION.json +4 -0
- opscli/skills/templates/ops-dataset-query/SKILL.md +691 -0
- opscli/skills/templates/ops-dataset-query/data/VERSION.json +4 -0
- opscli/skills/templates/ops-dataset-query/data/dataset_fields.csv +1 -0
- opscli/skills/templates/ops-dataset-query/data/datasets.csv +1 -0
- opscli/skills/templates/ops-dataset-query/data/query_metadata.json +4 -0
- opscli/skills/templates/ops-dataset-query/references//346/225/260/346/215/256/346/237/245/350/257/242/346/234/215/345/212/241/345/274/200/345/217/221/350/257/264/346/230/216/346/226/207/346/241/243.md +1126 -0
- opscli/skills/templates/ops-dataset-query/scripts/core.py +140 -0
- opscli/skills/templates/ops-dataset-query/scripts/query.py +145 -0
- opscli/skills/templates/ops-dataset-query/scripts/search.py +36 -0
- opscli/skills/templates/ops-dataset-query/scripts/updater.py +106 -0
- opscli/skills/templates/ops-skills/SKILL.md +494 -0
- opscli/skills/templates/ops-skills/data/VERSION.json +4 -0
- opscli/skills/updater.py +5 -0
- opscli/version.py +19 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""auth 异常定义。"""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AuthError(Exception):
|
|
5
|
+
"""基础认证异常"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NotAuthenticatedError(AuthError):
|
|
9
|
+
"""未登录或 session 已失效"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SessionExpiredError(AuthError):
|
|
13
|
+
"""session_id 已过期"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TokenFetchError(AuthError):
|
|
17
|
+
"""从业务系统获取 JWT 失败"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SystemNotFoundError(AuthError):
|
|
21
|
+
"""系统别名未注册"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DeviceFlowError(AuthError):
|
|
25
|
+
"""Device Flow 授权流程异常"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DeviceFlowExpiredError(DeviceFlowError):
|
|
29
|
+
"""设备码已超时"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DeviceFlowDeniedError(DeviceFlowError):
|
|
33
|
+
"""用户拒绝授权"""
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""auth exceptions 兼容导出。"""
|
|
2
|
+
|
|
3
|
+
from opscli.auth.domain.exceptions import (
|
|
4
|
+
AuthError,
|
|
5
|
+
DeviceFlowDeniedError,
|
|
6
|
+
DeviceFlowError,
|
|
7
|
+
DeviceFlowExpiredError,
|
|
8
|
+
NotAuthenticatedError,
|
|
9
|
+
SessionExpiredError,
|
|
10
|
+
SystemNotFoundError,
|
|
11
|
+
TokenFetchError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AuthError",
|
|
16
|
+
"NotAuthenticatedError",
|
|
17
|
+
"SessionExpiredError",
|
|
18
|
+
"TokenFetchError",
|
|
19
|
+
"SystemNotFoundError",
|
|
20
|
+
"DeviceFlowError",
|
|
21
|
+
"DeviceFlowExpiredError",
|
|
22
|
+
"DeviceFlowDeniedError",
|
|
23
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# storage 模块
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""凭证存储模块。
|
|
2
|
+
|
|
3
|
+
采用双层存储策略(铁律5):
|
|
4
|
+
- 优先使用系统 Keychain(macOS 钥匙串 / Linux Secret Service)
|
|
5
|
+
- Keychain 不可用时兜底使用 AES-256-GCM 加密文件(credentials.bin)
|
|
6
|
+
|
|
7
|
+
存储结构(JSON):
|
|
8
|
+
{
|
|
9
|
+
"session_id": "...", # 登录 session
|
|
10
|
+
"device_code": "...", # Device Flow 设备码
|
|
11
|
+
"email": "...", # 用户邮箱
|
|
12
|
+
"session_expires_at": "...", # session 过期时间(ISO 格式)
|
|
13
|
+
"tokens": { # 各系统 JWT
|
|
14
|
+
"<system_key>": {
|
|
15
|
+
"jwt": "...",
|
|
16
|
+
"expires_at": "...", # JWT 过期时间(ISO 格式)
|
|
17
|
+
"saved_at": 1234567890
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
"""
|
|
22
|
+
import json
|
|
23
|
+
import time
|
|
24
|
+
import stat
|
|
25
|
+
from datetime import datetime, timezone, timedelta
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from cryptography.exceptions import InvalidTag
|
|
28
|
+
from opscli.auth.storage.crypto import Crypto
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import keyring
|
|
32
|
+
import keyring.errors
|
|
33
|
+
_KEYRING_AVAILABLE = True
|
|
34
|
+
except Exception:
|
|
35
|
+
_KEYRING_AVAILABLE = False
|
|
36
|
+
|
|
37
|
+
# Keychain 服务名和账户名(铁律5:不可随意更改,否则用户凭证丢失)
|
|
38
|
+
_KEYRING_SERVICE = "opscli-auth"
|
|
39
|
+
_KEYRING_ACCOUNT = "credentials"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CredentialStore:
|
|
43
|
+
"""凭证存储管理器,负责 session 和 JWT 的持久化读写。
|
|
44
|
+
|
|
45
|
+
存储策略:Keychain 优先,AES-256-GCM 加密文件兜底。
|
|
46
|
+
测试环境通过传入 base_dir 参数跳过 Keychain(铁律8)。
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, base_dir: Path | None = None):
|
|
50
|
+
"""
|
|
51
|
+
Args:
|
|
52
|
+
base_dir: 存储目录,传入时跳过 Keychain(用于测试),
|
|
53
|
+
默认使用 CONFIG_DIR(~/.config/opscli/)
|
|
54
|
+
"""
|
|
55
|
+
# 仅默认路径启用 Keychain;显式传入 base_dir(如测试)走文件存储
|
|
56
|
+
self._use_keyring = _KEYRING_AVAILABLE and base_dir is None
|
|
57
|
+
from opscli.config import CONFIG_DIR
|
|
58
|
+
self._dir = Path(base_dir or CONFIG_DIR)
|
|
59
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
self._path = self._dir / "credentials.bin"
|
|
61
|
+
self._crypto = Crypto(self._dir / ".key")
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def base_dir(self) -> Path:
|
|
65
|
+
"""返回存储目录路径,供 TokenManager 构建文件锁路径。"""
|
|
66
|
+
return self._dir
|
|
67
|
+
|
|
68
|
+
def load(self) -> dict | None:
|
|
69
|
+
"""加载凭证数据,返回完整凭证字典或 None(无凭证时)。
|
|
70
|
+
|
|
71
|
+
读取优先级:系统 Keychain > AES-256-GCM 加密文件。
|
|
72
|
+
加密文件解密失败(密钥不匹配)时自动清除损坏文件。
|
|
73
|
+
"""
|
|
74
|
+
# 优先从系统 Keychain 读取
|
|
75
|
+
if self._use_keyring:
|
|
76
|
+
try:
|
|
77
|
+
raw = keyring.get_password(_KEYRING_SERVICE, _KEYRING_ACCOUNT)
|
|
78
|
+
if raw:
|
|
79
|
+
return json.loads(raw)
|
|
80
|
+
except keyring.errors.NoKeyringError:
|
|
81
|
+
pass
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
# 兜底:从加密文件读取
|
|
86
|
+
if not self._path.exists():
|
|
87
|
+
return None
|
|
88
|
+
try:
|
|
89
|
+
return json.loads(self._crypto.decrypt(self._path.read_bytes()))
|
|
90
|
+
except (InvalidTag, Exception):
|
|
91
|
+
# 密钥与密文不匹配(如密钥轮换/环境变更),清除损坏的凭证文件重新开始
|
|
92
|
+
self._path.unlink(missing_ok=True)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def _save(self, data: dict):
|
|
96
|
+
"""持久化凭证数据,写入 Keychain 或加密文件。"""
|
|
97
|
+
raw = json.dumps(data)
|
|
98
|
+
# 优先写入系统 Keychain
|
|
99
|
+
if self._use_keyring:
|
|
100
|
+
try:
|
|
101
|
+
keyring.set_password(_KEYRING_SERVICE, _KEYRING_ACCOUNT, raw)
|
|
102
|
+
return
|
|
103
|
+
except keyring.errors.NoKeyringError:
|
|
104
|
+
pass
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
# 兜底:写入加密文件
|
|
109
|
+
self._path.write_bytes(self._crypto.encrypt(raw))
|
|
110
|
+
self._path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
111
|
+
|
|
112
|
+
def save_session(
|
|
113
|
+
self,
|
|
114
|
+
session_id: str,
|
|
115
|
+
email: str,
|
|
116
|
+
expires_at: str,
|
|
117
|
+
device_code: str | None = None,
|
|
118
|
+
):
|
|
119
|
+
"""保存登录 session 信息。
|
|
120
|
+
|
|
121
|
+
当登录态切换(如重新登录其他账号或新的 session)时,必须清空旧 JWT,
|
|
122
|
+
避免后续请求继续复用上一位用户的缓存 token。
|
|
123
|
+
"""
|
|
124
|
+
data = self.load() or {}
|
|
125
|
+
session_changed = (
|
|
126
|
+
(data.get("session_id") and data.get("session_id") != session_id)
|
|
127
|
+
or (data.get("email") and data.get("email") != email)
|
|
128
|
+
)
|
|
129
|
+
data.update({
|
|
130
|
+
"session_id": session_id,
|
|
131
|
+
"email": email,
|
|
132
|
+
"session_expires_at": expires_at,
|
|
133
|
+
"tokens": {} if session_changed else data.get("tokens", {}),
|
|
134
|
+
})
|
|
135
|
+
# device_code 缺失时保留旧值,兼容历史凭证数据
|
|
136
|
+
if device_code is not None:
|
|
137
|
+
data["device_code"] = device_code
|
|
138
|
+
self._save(data)
|
|
139
|
+
|
|
140
|
+
def save_token(self, system_key: str, jwt: str, expires_in: int):
|
|
141
|
+
"""保存指定系统的 JWT,将 expires_in(秒)转换为 ISO 格式的 expires_at。"""
|
|
142
|
+
data = self.load() or {}
|
|
143
|
+
data.setdefault("tokens", {})
|
|
144
|
+
expires_at = (
|
|
145
|
+
datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
|
146
|
+
).isoformat()
|
|
147
|
+
data["tokens"][system_key] = {
|
|
148
|
+
"jwt": jwt,
|
|
149
|
+
"expires_at": expires_at,
|
|
150
|
+
"saved_at": int(time.time()),
|
|
151
|
+
}
|
|
152
|
+
self._save(data)
|
|
153
|
+
|
|
154
|
+
def clear(self):
|
|
155
|
+
# 清除 Keychain
|
|
156
|
+
if self._use_keyring:
|
|
157
|
+
try:
|
|
158
|
+
keyring.delete_password(_KEYRING_SERVICE, _KEYRING_ACCOUNT)
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
# 清除加密文件
|
|
162
|
+
if self._path.exists():
|
|
163
|
+
self._path.unlink()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""AES-256-GCM 加密工具模块。
|
|
2
|
+
|
|
3
|
+
为凭证文件提供对称加密能力,密钥自动生成并存储在本地文件中。
|
|
4
|
+
加密格式:[12 字节随机 nonce] + [AES-GCM 密文],解密时按前 12 字节切割 nonce。
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Crypto:
|
|
14
|
+
"""AES-256-GCM 加密器,管理密钥的生成、加载和加解密操作。
|
|
15
|
+
|
|
16
|
+
密钥文件首次使用时自动生成(256 位),存储权限为 600(仅属主可读写)。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, key_path: Path):
|
|
20
|
+
"""
|
|
21
|
+
Args:
|
|
22
|
+
key_path: 密钥文件路径,不存在时自动创建
|
|
23
|
+
"""
|
|
24
|
+
self._key_path = Path(key_path)
|
|
25
|
+
self._key = self._load_or_create_key()
|
|
26
|
+
|
|
27
|
+
def _load_or_create_key(self) -> bytes:
|
|
28
|
+
"""加载已有密钥,或首次使用时生成 256 位 AES 密钥并持久化。"""
|
|
29
|
+
if self._key_path.exists():
|
|
30
|
+
return self._key_path.read_bytes()
|
|
31
|
+
# 生成 256 位随机密钥
|
|
32
|
+
key = AESGCM.generate_key(bit_length=256)
|
|
33
|
+
self._key_path.write_bytes(key)
|
|
34
|
+
# 权限设为 600(仅属主可读写),防止其他用户读取密钥
|
|
35
|
+
self._key_path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
36
|
+
return key
|
|
37
|
+
|
|
38
|
+
def encrypt(self, plaintext: str) -> bytes:
|
|
39
|
+
"""加密明文字符串。
|
|
40
|
+
|
|
41
|
+
使用 12 字节(96 位)随机 nonce,拼接格式为 nonce + ciphertext。
|
|
42
|
+
96 位 nonce 是 NIST 推荐的 GCM nonce 长度,可保证安全性和性能。
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
plaintext: 待加密的明文字符串
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
bytes: nonce(12B) + AES-GCM 密文
|
|
49
|
+
"""
|
|
50
|
+
nonce = os.urandom(12) # 96 位随机 nonce
|
|
51
|
+
return nonce + AESGCM(self._key).encrypt(nonce, plaintext.encode(), None)
|
|
52
|
+
|
|
53
|
+
def decrypt(self, ciphertext: bytes) -> str:
|
|
54
|
+
"""解密密文,还原为明文字符串。
|
|
55
|
+
|
|
56
|
+
按约定前 12 字节为 nonce,剩余部分为 AES-GCM 密文。
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
ciphertext: nonce(12B) + AES-GCM 密文
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
str: 解密后的明文
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
cryptography.exceptions.InvalidTag: 密钥不匹配或数据被篡改
|
|
66
|
+
"""
|
|
67
|
+
return AESGCM(self._key).decrypt(ciphertext[:12], ciphertext[12:], None).decode()
|
opscli/cli.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""opscli 顶级 CLI 入口。
|
|
2
|
+
|
|
3
|
+
基于 Typer 框架,注册所有子模块命令组(auth、skills 等)。
|
|
4
|
+
"""
|
|
5
|
+
import typer
|
|
6
|
+
from opscli.amazon.cli import app as amazon_app
|
|
7
|
+
from opscli.auth.cli import app as auth_app
|
|
8
|
+
from opscli.query.cli import app as query_app
|
|
9
|
+
from opscli.skills.cli import app as skills_app
|
|
10
|
+
from opscli.version import get_version
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _version_callback(value: bool):
|
|
14
|
+
"""处理 --version/-V 标志,打印版本号后立即退出。"""
|
|
15
|
+
if value:
|
|
16
|
+
typer.echo(f"opscli v{get_version()}")
|
|
17
|
+
raise typer.Exit()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(help="Aukeys 运营 CLI 工具集")
|
|
21
|
+
|
|
22
|
+
# 模块注册:每新增一个子模块只需在此追加一行(铁律1)
|
|
23
|
+
app.add_typer(auth_app, name="auth")
|
|
24
|
+
app.add_typer(amazon_app, name="amazon")
|
|
25
|
+
app.add_typer(query_app, name="query")
|
|
26
|
+
app.add_typer(skills_app, name="skills")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.callback()
|
|
30
|
+
def main(
|
|
31
|
+
version: bool = typer.Option(
|
|
32
|
+
None, "--version", "-V", help="显示版本信息",
|
|
33
|
+
callback=_version_callback, is_eager=True,
|
|
34
|
+
),
|
|
35
|
+
):
|
|
36
|
+
"""主回调:为顶级全局选项预留入口。"""
|
opscli/config.py
ADDED
opscli/query/__init__.py
ADDED
opscli/query/cli.py
ADDED
opscli/query/client.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""query 命令层。"""
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""query CLI 子命令定义。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from opscli.query.domain.exceptions import QueryError
|
|
10
|
+
from opscli.query.services.manager import QueryManager
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="数据查询入口,统一转发远端查询请求")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _emit(payload: dict, pretty: bool) -> None:
|
|
16
|
+
"""统一输出 JSON。"""
|
|
17
|
+
if pretty:
|
|
18
|
+
typer.echo(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
19
|
+
else:
|
|
20
|
+
typer.echo(json.dumps(payload, ensure_ascii=False))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _error_payload(command: str, exc: Exception) -> dict:
|
|
24
|
+
"""将异常统一映射为标准错误输出。"""
|
|
25
|
+
if isinstance(exc, QueryError):
|
|
26
|
+
error = exc.to_dict()
|
|
27
|
+
else:
|
|
28
|
+
error = {
|
|
29
|
+
"code": "QUERY_ERROR",
|
|
30
|
+
"message": str(exc),
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
"success": False,
|
|
34
|
+
"command": command,
|
|
35
|
+
"data": None,
|
|
36
|
+
"error": error,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command("metadata")
|
|
41
|
+
def metadata(
|
|
42
|
+
dataset: str | None = typer.Option(None, "--dataset", help="dataset_alias"),
|
|
43
|
+
table_id: int | None = typer.Option(None, "--table-id", help="table_id"),
|
|
44
|
+
skills_dir: str | None = typer.Option(None, "--skills-dir", help="指定 Skill 目录"),
|
|
45
|
+
pretty: bool = typer.Option(False, "--pretty", help="格式化输出"),
|
|
46
|
+
):
|
|
47
|
+
"""读取指定数据集的 query metadata。"""
|
|
48
|
+
manager = QueryManager()
|
|
49
|
+
try:
|
|
50
|
+
result = manager.metadata(dataset_alias=dataset, table_id=table_id, skills_dir=skills_dir)
|
|
51
|
+
payload = {
|
|
52
|
+
"success": True,
|
|
53
|
+
"command": "query metadata",
|
|
54
|
+
"data": result.to_dict(),
|
|
55
|
+
"error": None,
|
|
56
|
+
}
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
_emit(_error_payload("query metadata", exc), pretty)
|
|
59
|
+
raise typer.Exit(1)
|
|
60
|
+
|
|
61
|
+
_emit(payload, pretty)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command("run")
|
|
65
|
+
def run(
|
|
66
|
+
payload_path: str = typer.Option(..., "--payload", help="查询 JSON 文件路径"),
|
|
67
|
+
pretty: bool = typer.Option(False, "--pretty", help="格式化输出"),
|
|
68
|
+
):
|
|
69
|
+
"""执行查询并转发到服务端 cli-query。"""
|
|
70
|
+
manager = QueryManager()
|
|
71
|
+
try:
|
|
72
|
+
result = manager.run(payload_path=payload_path)
|
|
73
|
+
payload = {
|
|
74
|
+
"success": True,
|
|
75
|
+
"command": "query run",
|
|
76
|
+
"data": result,
|
|
77
|
+
"error": None,
|
|
78
|
+
}
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
_emit(_error_payload("query run", exc), pretty)
|
|
81
|
+
raise typer.Exit(1)
|
|
82
|
+
|
|
83
|
+
_emit(payload, pretty)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command("build")
|
|
87
|
+
def build(
|
|
88
|
+
dataset: str | None = typer.Option(None, "--dataset", help="dataset_alias"),
|
|
89
|
+
table_id: int | None = typer.Option(None, "--table-id", help="table_id"),
|
|
90
|
+
dimension: list[str] | None = typer.Option(None, "--dimension", help="维度定义:field_name[:alias]"),
|
|
91
|
+
metric: list[str] | None = typer.Option(None, "--metric", help="指标定义:field_name:aggregation[:alias]"),
|
|
92
|
+
where: list[str] | None = typer.Option(None, "--where", help="筛选条件:field|operator|value_json,可重复"),
|
|
93
|
+
where_json: str | None = typer.Option(None, "--where-json", help="where JSON 字符串"),
|
|
94
|
+
where_file: str | None = typer.Option(None, "--where-file", help="where JSON 文件路径"),
|
|
95
|
+
having: list[str] | None = typer.Option(None, "--having", help="having 条件:expr|operator|value_json,可重复"),
|
|
96
|
+
order_by: list[str] | None = typer.Option(None, "--order-by", help="排序定义:expr[:asc|desc]"),
|
|
97
|
+
limit: int = typer.Option(20, "--limit", help="limit,默认 20"),
|
|
98
|
+
offset: int = typer.Option(0, "--offset", help="offset,默认 0"),
|
|
99
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="仅生成 SQL,不执行查询"),
|
|
100
|
+
output: str | None = typer.Option(None, "--output", help="将 payload 写入指定文件"),
|
|
101
|
+
data_comparison: str | None = typer.Option(
|
|
102
|
+
None, "--data-comparison",
|
|
103
|
+
help="数据对比:field,start_date,end_date(例: date_id,2026-03-01,2026-03-22)",
|
|
104
|
+
),
|
|
105
|
+
run: bool = typer.Option(False, "--run", help="构造后立即执行查询"),
|
|
106
|
+
skills_dir: str | None = typer.Option(None, "--skills-dir", help="指定 Skill 目录"),
|
|
107
|
+
pretty: bool = typer.Option(False, "--pretty", help="格式化输出"),
|
|
108
|
+
):
|
|
109
|
+
"""基于简化参数构造标准 query payload。"""
|
|
110
|
+
manager = QueryManager()
|
|
111
|
+
try:
|
|
112
|
+
common_kwargs = {
|
|
113
|
+
"dataset_alias": dataset,
|
|
114
|
+
"table_id": table_id,
|
|
115
|
+
"dimensions": dimension,
|
|
116
|
+
"metrics": metric,
|
|
117
|
+
"where_conditions": where,
|
|
118
|
+
"where_json": where_json,
|
|
119
|
+
"where_file": where_file,
|
|
120
|
+
"having_conditions": having,
|
|
121
|
+
"order_by": order_by,
|
|
122
|
+
"limit": limit,
|
|
123
|
+
"offset": offset,
|
|
124
|
+
"dry_run": dry_run,
|
|
125
|
+
"output_path": output,
|
|
126
|
+
"data_comparison": data_comparison,
|
|
127
|
+
"skills_dir": skills_dir,
|
|
128
|
+
}
|
|
129
|
+
result = manager.build_and_run(**common_kwargs) if run else manager.build(**common_kwargs)
|
|
130
|
+
payload = {
|
|
131
|
+
"success": True,
|
|
132
|
+
"command": "query build-and-run" if run else "query build",
|
|
133
|
+
"data": result,
|
|
134
|
+
"error": None,
|
|
135
|
+
}
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
_emit(_error_payload("query build-and-run" if run else "query build", exc), pretty)
|
|
138
|
+
raise typer.Exit(1)
|
|
139
|
+
|
|
140
|
+
_emit(payload, pretty)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""query 领域模型与异常。"""
|
|
2
|
+
|
|
3
|
+
from opscli.query.domain.exceptions import (
|
|
4
|
+
BadRemoteJsonError,
|
|
5
|
+
DatasetNotFoundError,
|
|
6
|
+
InvalidPayloadError,
|
|
7
|
+
QueryError,
|
|
8
|
+
QueryMetadataNotReadyError,
|
|
9
|
+
RemoteBusinessError,
|
|
10
|
+
RemoteHttpError,
|
|
11
|
+
)
|
|
12
|
+
from opscli.query.domain.models import QueryMetadataResult
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"QueryError",
|
|
16
|
+
"InvalidPayloadError",
|
|
17
|
+
"DatasetNotFoundError",
|
|
18
|
+
"QueryMetadataNotReadyError",
|
|
19
|
+
"RemoteHttpError",
|
|
20
|
+
"RemoteBusinessError",
|
|
21
|
+
"BadRemoteJsonError",
|
|
22
|
+
"QueryMetadataResult",
|
|
23
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""query 模块异常定义。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class QueryError(Exception):
|
|
7
|
+
"""query 模块统一异常基类。"""
|
|
8
|
+
|
|
9
|
+
code = "QUERY_ERROR"
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.message = message
|
|
14
|
+
|
|
15
|
+
def to_dict(self) -> dict:
|
|
16
|
+
return {
|
|
17
|
+
"code": self.code,
|
|
18
|
+
"message": self.message,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InvalidPayloadError(QueryError):
|
|
23
|
+
"""请求 payload 不合法。"""
|
|
24
|
+
|
|
25
|
+
code = "INVALID_PAYLOAD"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DatasetNotFoundError(QueryError):
|
|
29
|
+
"""本地 metadata 中找不到目标数据集。"""
|
|
30
|
+
|
|
31
|
+
code = "DATASET_NOT_FOUND"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class QueryMetadataNotReadyError(QueryError):
|
|
35
|
+
"""本地 query metadata 尚未准备好。"""
|
|
36
|
+
|
|
37
|
+
code = "QUERY_METADATA_NOT_READY"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RemoteHttpError(QueryError):
|
|
41
|
+
"""远端 HTTP 请求失败。"""
|
|
42
|
+
|
|
43
|
+
code = "REMOTE_HTTP_ERROR"
|
|
44
|
+
|
|
45
|
+
def __init__(self, status_code: int, message: str):
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.status_code = status_code
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict:
|
|
50
|
+
payload = super().to_dict()
|
|
51
|
+
payload["status_code"] = self.status_code
|
|
52
|
+
return payload
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RemoteBusinessError(QueryError):
|
|
56
|
+
"""远端业务层返回失败。"""
|
|
57
|
+
|
|
58
|
+
code = "REMOTE_BUSINESS_ERROR"
|
|
59
|
+
|
|
60
|
+
def __init__(self, business_code: int | str, message: str):
|
|
61
|
+
super().__init__(message)
|
|
62
|
+
self.business_code = business_code
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict:
|
|
65
|
+
payload = super().to_dict()
|
|
66
|
+
payload["business_code"] = self.business_code
|
|
67
|
+
return payload
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class BadRemoteJsonError(QueryError):
|
|
71
|
+
"""远端返回非法 JSON。"""
|
|
72
|
+
|
|
73
|
+
code = "BAD_REMOTE_JSON"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""query 模块数据模型。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class QueryMetadataResult:
|
|
10
|
+
"""query metadata 查询结果。"""
|
|
11
|
+
|
|
12
|
+
dataset: dict
|
|
13
|
+
fields: list[dict]
|
|
14
|
+
source: str
|
|
15
|
+
|
|
16
|
+
def to_dict(self) -> dict:
|
|
17
|
+
return {
|
|
18
|
+
"dataset": self.dataset,
|
|
19
|
+
"fields": self.fields,
|
|
20
|
+
"source": self.source,
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""query exceptions 兼容导出。"""
|
|
2
|
+
|
|
3
|
+
from opscli.query.domain.exceptions import (
|
|
4
|
+
BadRemoteJsonError,
|
|
5
|
+
DatasetNotFoundError,
|
|
6
|
+
InvalidPayloadError,
|
|
7
|
+
QueryError,
|
|
8
|
+
QueryMetadataNotReadyError,
|
|
9
|
+
RemoteBusinessError,
|
|
10
|
+
RemoteHttpError,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"QueryError",
|
|
15
|
+
"InvalidPayloadError",
|
|
16
|
+
"DatasetNotFoundError",
|
|
17
|
+
"QueryMetadataNotReadyError",
|
|
18
|
+
"RemoteHttpError",
|
|
19
|
+
"RemoteBusinessError",
|
|
20
|
+
"BadRemoteJsonError",
|
|
21
|
+
]
|
opscli/query/manager.py
ADDED
opscli/query/models.py
ADDED