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,373 @@
|
|
|
1
|
+
"""query 模块业务编排层。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from opscli.query.domain.exceptions import DatasetNotFoundError, InvalidPayloadError, QueryMetadataNotReadyError
|
|
10
|
+
from opscli.query.domain.models import QueryMetadataResult
|
|
11
|
+
from opscli.query.transport.client import QueryClient
|
|
12
|
+
from opscli.skills.discovery.detector import SkillDetector
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class _FieldSpec:
|
|
17
|
+
field_name: str
|
|
18
|
+
alias: str
|
|
19
|
+
aggregation: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class QueryManager:
|
|
23
|
+
"""协调本地 metadata 与远端 query 执行。"""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self.detector = SkillDetector()
|
|
27
|
+
self.client = QueryClient()
|
|
28
|
+
self.template_dir = Path(__file__).resolve().parent.parent.parent / "skills" / "templates" / "ops-dataset-query" / "data"
|
|
29
|
+
|
|
30
|
+
def metadata(
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
dataset_alias: str | None = None,
|
|
34
|
+
table_id: int | None = None,
|
|
35
|
+
skills_dir: str | None = None,
|
|
36
|
+
cwd: Path | None = None,
|
|
37
|
+
) -> QueryMetadataResult:
|
|
38
|
+
"""按数据集别名或 table_id 查询本地 query metadata。"""
|
|
39
|
+
if not dataset_alias and table_id is None:
|
|
40
|
+
raise InvalidPayloadError("必须提供 --dataset 或 --table-id")
|
|
41
|
+
|
|
42
|
+
payload = self._load_query_metadata(skills_dir=skills_dir, cwd=cwd)
|
|
43
|
+
datasets = payload.get("datasets") or []
|
|
44
|
+
fields = payload.get("fields") or []
|
|
45
|
+
|
|
46
|
+
matched = None
|
|
47
|
+
if dataset_alias:
|
|
48
|
+
matched = next((item for item in datasets if item.get("dataset_alias") == dataset_alias), None)
|
|
49
|
+
elif table_id is not None:
|
|
50
|
+
matched = next((item for item in datasets if int(item.get("table_id", -1)) == int(table_id)), None)
|
|
51
|
+
|
|
52
|
+
if matched is None:
|
|
53
|
+
needle = dataset_alias if dataset_alias else str(table_id)
|
|
54
|
+
raise DatasetNotFoundError(f"未找到目标数据集: {needle}")
|
|
55
|
+
|
|
56
|
+
matched_fields = [
|
|
57
|
+
item for item in fields
|
|
58
|
+
if int(item.get("table_id", -1)) == int(matched.get("table_id", -1))
|
|
59
|
+
]
|
|
60
|
+
return QueryMetadataResult(dataset=matched, fields=matched_fields, source="local")
|
|
61
|
+
|
|
62
|
+
def run(self, *, payload_path: str) -> dict:
|
|
63
|
+
"""读取本地 payload 文件并转发执行查询。"""
|
|
64
|
+
payload_file = Path(payload_path).expanduser()
|
|
65
|
+
if not payload_file.exists():
|
|
66
|
+
raise InvalidPayloadError(f"payload 文件不存在: {payload_file}")
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
payload = json.loads(payload_file.read_text(encoding="utf-8"))
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
raise InvalidPayloadError(f"payload 不是合法 JSON: {payload_file}") from exc
|
|
72
|
+
|
|
73
|
+
self._validate_payload(payload)
|
|
74
|
+
return self.client.cli_query(payload)
|
|
75
|
+
|
|
76
|
+
def build(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
dataset_alias: str | None = None,
|
|
80
|
+
table_id: int | None = None,
|
|
81
|
+
dimensions: list[str] | None = None,
|
|
82
|
+
metrics: list[str] | None = None,
|
|
83
|
+
where_conditions: list[str] | None = None,
|
|
84
|
+
where_json: str | None = None,
|
|
85
|
+
where_file: str | None = None,
|
|
86
|
+
having_conditions: list[str] | None = None,
|
|
87
|
+
order_by: list[str] | None = None,
|
|
88
|
+
limit: int = 20,
|
|
89
|
+
offset: int = 0,
|
|
90
|
+
dry_run: bool = False,
|
|
91
|
+
skills_dir: str | None = None,
|
|
92
|
+
cwd: Path | None = None,
|
|
93
|
+
output_path: str | None = None,
|
|
94
|
+
data_comparison: str | None = None,
|
|
95
|
+
) -> dict:
|
|
96
|
+
"""基于简化参数构造标准 query payload。"""
|
|
97
|
+
if not dimensions and not metrics:
|
|
98
|
+
raise InvalidPayloadError("至少需要提供一个 --dimension 或 --metric")
|
|
99
|
+
where_source_count = sum(1 for item in (where_conditions, where_json, where_file) if item)
|
|
100
|
+
if where_source_count > 1:
|
|
101
|
+
raise InvalidPayloadError("--where、--where-json 和 --where-file 只能使用一种")
|
|
102
|
+
|
|
103
|
+
metadata = self.metadata(
|
|
104
|
+
dataset_alias=dataset_alias,
|
|
105
|
+
table_id=table_id,
|
|
106
|
+
skills_dir=skills_dir,
|
|
107
|
+
cwd=cwd,
|
|
108
|
+
)
|
|
109
|
+
dataset = metadata.dataset
|
|
110
|
+
field_index = {
|
|
111
|
+
str(item.get("field_name")): item
|
|
112
|
+
for item in metadata.fields
|
|
113
|
+
if item.get("field_name")
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
select_items: list[dict] = []
|
|
117
|
+
group_by: list[str] = []
|
|
118
|
+
|
|
119
|
+
for spec in dimensions or []:
|
|
120
|
+
item = self._parse_dimension_spec(spec)
|
|
121
|
+
self._ensure_field_exists(field_index, item.field_name)
|
|
122
|
+
select_items.append(
|
|
123
|
+
{
|
|
124
|
+
"expr": f"{dataset['dataset_alias']}.{item.field_name}",
|
|
125
|
+
"alias": item.alias,
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
group_by.append(item.alias)
|
|
129
|
+
|
|
130
|
+
for spec in metrics or []:
|
|
131
|
+
item = self._parse_metric_spec(spec)
|
|
132
|
+
self._ensure_field_exists(field_index, item.field_name)
|
|
133
|
+
select_row = {
|
|
134
|
+
"expr": f"{dataset['dataset_alias']}.{item.field_name}",
|
|
135
|
+
"alias": item.alias,
|
|
136
|
+
}
|
|
137
|
+
if item.aggregation:
|
|
138
|
+
select_row["aggregation"] = item.aggregation
|
|
139
|
+
select_items.append(select_row)
|
|
140
|
+
|
|
141
|
+
payload = {
|
|
142
|
+
"tableId": int(dataset["table_id"]),
|
|
143
|
+
"query": {
|
|
144
|
+
"select": select_items,
|
|
145
|
+
"groupBy": group_by,
|
|
146
|
+
"orderBy": self._build_order_by(order_by or []),
|
|
147
|
+
"limit": limit,
|
|
148
|
+
"offset": offset,
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
if dry_run:
|
|
152
|
+
payload["dryRun"] = True
|
|
153
|
+
|
|
154
|
+
where_payload = self._load_where_clause(
|
|
155
|
+
where_conditions=where_conditions or [],
|
|
156
|
+
where_json=where_json,
|
|
157
|
+
where_file=where_file,
|
|
158
|
+
dataset_alias=str(dataset["dataset_alias"]),
|
|
159
|
+
)
|
|
160
|
+
if where_payload is not None:
|
|
161
|
+
payload["query"]["where"] = where_payload
|
|
162
|
+
|
|
163
|
+
having_payload = self._build_having_clause(having_conditions or [])
|
|
164
|
+
if having_payload:
|
|
165
|
+
payload["query"]["having"] = having_payload
|
|
166
|
+
|
|
167
|
+
# 构造 dataComparison(数据对比)
|
|
168
|
+
if data_comparison:
|
|
169
|
+
payload["dataComparison"] = self._build_data_comparison(
|
|
170
|
+
data_comparison, dataset_alias=str(dataset["dataset_alias"]),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
self._validate_payload(payload)
|
|
174
|
+
if output_path:
|
|
175
|
+
output_file = Path(output_path).expanduser()
|
|
176
|
+
output_file.parent.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
output_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
"dataset": dataset,
|
|
181
|
+
"payload": payload,
|
|
182
|
+
"output": str(Path(output_path).expanduser()) if output_path else None,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
def build_and_run(self, **kwargs) -> dict:
|
|
186
|
+
"""先构造 payload,再立即执行查询。"""
|
|
187
|
+
build_result = self.build(**kwargs)
|
|
188
|
+
query_result = self.client.cli_query(build_result["payload"])
|
|
189
|
+
return {
|
|
190
|
+
**build_result,
|
|
191
|
+
"result": query_result,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
def _build_data_comparison(self, raw: str, *, dataset_alias: str) -> dict:
|
|
195
|
+
"""解析 dataComparison 定义:field,start_date,end_date。
|
|
196
|
+
|
|
197
|
+
格式示例:date_id,2026-03-01,2026-03-22
|
|
198
|
+
field 可以是纯字段名(自动加前缀)或含前缀的全名。
|
|
199
|
+
"""
|
|
200
|
+
parts = [item.strip() for item in raw.split(",")]
|
|
201
|
+
if len(parts) != 3 or not all(parts):
|
|
202
|
+
raise InvalidPayloadError(
|
|
203
|
+
"--data-comparison 格式: field,start_date,end_date"
|
|
204
|
+
"(例: date_id,2026-03-01,2026-03-22)"
|
|
205
|
+
)
|
|
206
|
+
field, start_date, end_date = parts
|
|
207
|
+
# 自动补全数据集别名前缀
|
|
208
|
+
if "." not in field:
|
|
209
|
+
field = f"{dataset_alias}.{field}"
|
|
210
|
+
return {
|
|
211
|
+
"switch": True,
|
|
212
|
+
"field": field,
|
|
213
|
+
"startDate": start_date,
|
|
214
|
+
"endDate": end_date,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
def _validate_payload(self, payload: dict) -> None:
|
|
218
|
+
"""校验最小请求结构。"""
|
|
219
|
+
if not isinstance(payload, dict):
|
|
220
|
+
raise InvalidPayloadError("payload 顶层必须是 JSON 对象")
|
|
221
|
+
if "tableId" not in payload:
|
|
222
|
+
raise InvalidPayloadError("payload 缺少 tableId")
|
|
223
|
+
if "query" not in payload or not isinstance(payload["query"], dict):
|
|
224
|
+
raise InvalidPayloadError("payload 缺少 query 对象")
|
|
225
|
+
|
|
226
|
+
def _load_query_metadata(self, *, skills_dir: str | None, cwd: Path | None) -> dict:
|
|
227
|
+
"""从已安装 Skill 或内置模板中读取 query_metadata.json。"""
|
|
228
|
+
metadata_file = self._resolve_metadata_file(skills_dir=skills_dir, cwd=cwd)
|
|
229
|
+
try:
|
|
230
|
+
payload = json.loads(metadata_file.read_text(encoding="utf-8"))
|
|
231
|
+
except Exception as exc:
|
|
232
|
+
raise QueryMetadataNotReadyError(f"query metadata 读取失败: {metadata_file}") from exc
|
|
233
|
+
|
|
234
|
+
if not isinstance(payload, dict):
|
|
235
|
+
raise QueryMetadataNotReadyError(f"query metadata 结构非法: {metadata_file}")
|
|
236
|
+
return payload
|
|
237
|
+
|
|
238
|
+
def _resolve_metadata_file(self, *, skills_dir: str | None, cwd: Path | None) -> Path:
|
|
239
|
+
"""优先使用已安装 Skill 的 metadata 文件,否则回退到内置模板。"""
|
|
240
|
+
records = self.detector.discover(skills_dir=skills_dir, cwd=cwd)
|
|
241
|
+
for record in records:
|
|
242
|
+
if record.name != "ops-dataset-query":
|
|
243
|
+
continue
|
|
244
|
+
metadata_file = record.root / "data" / "query_metadata.json"
|
|
245
|
+
if metadata_file.exists():
|
|
246
|
+
return metadata_file
|
|
247
|
+
|
|
248
|
+
fallback = self.template_dir / "query_metadata.json"
|
|
249
|
+
if fallback.exists():
|
|
250
|
+
return fallback
|
|
251
|
+
raise QueryMetadataNotReadyError("本地未找到 query_metadata.json,请先安装并升级 ops-dataset-query")
|
|
252
|
+
|
|
253
|
+
def _parse_dimension_spec(self, raw: str) -> _FieldSpec:
|
|
254
|
+
"""解析维度定义:field_name[:alias]。"""
|
|
255
|
+
parts = [item.strip() for item in raw.split(":")]
|
|
256
|
+
if len(parts) == 1 and parts[0]:
|
|
257
|
+
return _FieldSpec(field_name=parts[0], alias=parts[0])
|
|
258
|
+
if len(parts) == 2 and parts[0] and parts[1]:
|
|
259
|
+
return _FieldSpec(field_name=parts[0], alias=parts[1])
|
|
260
|
+
raise InvalidPayloadError(f"无效的 --dimension 定义: {raw}")
|
|
261
|
+
|
|
262
|
+
def _parse_metric_spec(self, raw: str) -> _FieldSpec:
|
|
263
|
+
"""解析指标定义:field_name:aggregation[:alias]。"""
|
|
264
|
+
parts = [item.strip() for item in raw.split(":")]
|
|
265
|
+
if len(parts) == 2 and parts[0] and parts[1]:
|
|
266
|
+
return _FieldSpec(field_name=parts[0], aggregation=parts[1].upper(), alias=parts[0])
|
|
267
|
+
if len(parts) == 3 and parts[0] and parts[1] and parts[2]:
|
|
268
|
+
return _FieldSpec(field_name=parts[0], aggregation=parts[1].upper(), alias=parts[2])
|
|
269
|
+
raise InvalidPayloadError(f"无效的 --metric 定义: {raw}")
|
|
270
|
+
|
|
271
|
+
def _build_order_by(self, items: list[str]) -> list[dict]:
|
|
272
|
+
"""解析排序定义:expr[:asc|desc]。"""
|
|
273
|
+
result: list[dict] = []
|
|
274
|
+
for raw in items:
|
|
275
|
+
parts = [item.strip() for item in raw.split(":")]
|
|
276
|
+
if len(parts) == 1 and parts[0]:
|
|
277
|
+
result.append({"expr": parts[0], "desc": False})
|
|
278
|
+
continue
|
|
279
|
+
if len(parts) == 2 and parts[0] and parts[1]:
|
|
280
|
+
direction = parts[1].lower()
|
|
281
|
+
if direction not in ("asc", "desc"):
|
|
282
|
+
raise InvalidPayloadError(f"无效的 --order-by 排序方向: {raw}")
|
|
283
|
+
result.append({"expr": parts[0], "desc": direction == "desc"})
|
|
284
|
+
continue
|
|
285
|
+
raise InvalidPayloadError(f"无效的 --order-by 定义: {raw}")
|
|
286
|
+
return result
|
|
287
|
+
|
|
288
|
+
def _load_where_clause(
|
|
289
|
+
self,
|
|
290
|
+
*,
|
|
291
|
+
where_conditions: list[str],
|
|
292
|
+
where_json: str | None,
|
|
293
|
+
where_file: str | None,
|
|
294
|
+
dataset_alias: str,
|
|
295
|
+
) -> dict | None:
|
|
296
|
+
"""从 JSON 字符串或文件中读取 where 结构。"""
|
|
297
|
+
if where_conditions:
|
|
298
|
+
payload = {
|
|
299
|
+
"operator": "AND",
|
|
300
|
+
"conditions": [self._parse_where_condition(item, dataset_alias=dataset_alias) for item in where_conditions],
|
|
301
|
+
}
|
|
302
|
+
elif where_json:
|
|
303
|
+
try:
|
|
304
|
+
payload = json.loads(where_json)
|
|
305
|
+
except Exception as exc:
|
|
306
|
+
raise InvalidPayloadError("--where-json 不是合法 JSON") from exc
|
|
307
|
+
elif where_file:
|
|
308
|
+
file_path = Path(where_file).expanduser()
|
|
309
|
+
if not file_path.exists():
|
|
310
|
+
raise InvalidPayloadError(f"where 文件不存在: {file_path}")
|
|
311
|
+
try:
|
|
312
|
+
payload = json.loads(file_path.read_text(encoding="utf-8"))
|
|
313
|
+
except Exception as exc:
|
|
314
|
+
raise InvalidPayloadError(f"where 文件不是合法 JSON: {file_path}") from exc
|
|
315
|
+
else:
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
if not isinstance(payload, dict):
|
|
319
|
+
raise InvalidPayloadError("where 必须是 JSON 对象")
|
|
320
|
+
return payload
|
|
321
|
+
|
|
322
|
+
def _parse_where_condition(self, raw: str, *, dataset_alias: str) -> dict:
|
|
323
|
+
"""解析 where 简写条件:field|operator|value_json。"""
|
|
324
|
+
parts = raw.split("|", 2)
|
|
325
|
+
if len(parts) != 3:
|
|
326
|
+
raise InvalidPayloadError(f"无效的 --where 定义: {raw}")
|
|
327
|
+
|
|
328
|
+
field, operator, value_raw = (item.strip() for item in parts)
|
|
329
|
+
if not field or not operator or not value_raw:
|
|
330
|
+
raise InvalidPayloadError(f"无效的 --where 定义: {raw}")
|
|
331
|
+
|
|
332
|
+
expr = field if "." in field else f"{dataset_alias}.{field}"
|
|
333
|
+
try:
|
|
334
|
+
value = json.loads(value_raw)
|
|
335
|
+
except Exception:
|
|
336
|
+
value = value_raw
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
"field": expr,
|
|
340
|
+
"operator": operator,
|
|
341
|
+
"value": value,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
def _build_having_clause(self, items: list[str]) -> list[dict]:
|
|
345
|
+
"""解析 having 简写条件:expr|operator|value_json。"""
|
|
346
|
+
result: list[dict] = []
|
|
347
|
+
for raw in items:
|
|
348
|
+
parts = raw.split("|", 2)
|
|
349
|
+
if len(parts) != 3:
|
|
350
|
+
raise InvalidPayloadError(f"无效的 --having 定义: {raw}")
|
|
351
|
+
|
|
352
|
+
expr, operator, value_raw = (item.strip() for item in parts)
|
|
353
|
+
if not expr or not operator or not value_raw:
|
|
354
|
+
raise InvalidPayloadError(f"无效的 --having 定义: {raw}")
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
value = json.loads(value_raw)
|
|
358
|
+
except Exception:
|
|
359
|
+
value = value_raw
|
|
360
|
+
|
|
361
|
+
result.append(
|
|
362
|
+
{
|
|
363
|
+
"field": expr,
|
|
364
|
+
"operator": operator,
|
|
365
|
+
"value": value,
|
|
366
|
+
}
|
|
367
|
+
)
|
|
368
|
+
return result
|
|
369
|
+
|
|
370
|
+
def _ensure_field_exists(self, field_index: dict[str, dict], field_name: str) -> None:
|
|
371
|
+
"""校验字段存在于 metadata 中。"""
|
|
372
|
+
if field_name not in field_index:
|
|
373
|
+
raise InvalidPayloadError(f"字段不存在于当前数据集 metadata 中: {field_name}")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""query 模块远端请求客户端。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from opscli.auth import AuthClient, OPS_URL
|
|
8
|
+
from opscli.query.domain.exceptions import BadRemoteJsonError, RemoteBusinessError, RemoteHttpError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class QueryClient:
|
|
12
|
+
"""统一封装 query 模块的远端请求。"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, auth_client: AuthClient | None = None) -> None:
|
|
15
|
+
self.auth_client = auth_client or AuthClient()
|
|
16
|
+
self.ops_url = OPS_URL.rstrip("/")
|
|
17
|
+
|
|
18
|
+
def cli_query(self, payload: dict) -> dict:
|
|
19
|
+
"""转发查询请求到 auto-scheduler 的 cli-query 接口。"""
|
|
20
|
+
headers, cookies = self.auth_client.build_request_auth("ops")
|
|
21
|
+
response = httpx.post(
|
|
22
|
+
f"{self.ops_url}/v1/data-metrics/cli-query",
|
|
23
|
+
json=payload,
|
|
24
|
+
headers=headers,
|
|
25
|
+
cookies=cookies,
|
|
26
|
+
timeout=30,
|
|
27
|
+
)
|
|
28
|
+
return self._parse_response(response)
|
|
29
|
+
|
|
30
|
+
def _parse_response(self, response: httpx.Response) -> dict:
|
|
31
|
+
"""统一解析 HTTP 响应,识别业务层错误。"""
|
|
32
|
+
try:
|
|
33
|
+
payload = response.json()
|
|
34
|
+
except Exception as exc:
|
|
35
|
+
raise BadRemoteJsonError("远端返回了无法解析的 JSON") from exc
|
|
36
|
+
|
|
37
|
+
if response.status_code >= 400:
|
|
38
|
+
message = self._extract_message(payload) or f"远端请求失败,HTTP {response.status_code}"
|
|
39
|
+
raise RemoteHttpError(response.status_code, message)
|
|
40
|
+
|
|
41
|
+
if isinstance(payload, dict):
|
|
42
|
+
business_code = payload.get("code")
|
|
43
|
+
if business_code not in (None, 0, 200):
|
|
44
|
+
message = self._extract_message(payload) or "远端业务执行失败"
|
|
45
|
+
raise RemoteBusinessError(business_code, message)
|
|
46
|
+
|
|
47
|
+
if not isinstance(payload, dict):
|
|
48
|
+
raise BadRemoteJsonError("远端返回结构不是 JSON 对象")
|
|
49
|
+
return payload
|
|
50
|
+
|
|
51
|
+
def _extract_message(self, payload: dict) -> str | None:
|
|
52
|
+
"""从远端返回中提取最有价值的错误信息。"""
|
|
53
|
+
for key in ("msg", "message", "error"):
|
|
54
|
+
value = payload.get(key)
|
|
55
|
+
if isinstance(value, str) and value.strip():
|
|
56
|
+
return value.strip()
|
|
57
|
+
return None
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""opscli skills 模块。
|
|
2
|
+
|
|
3
|
+
提供 Claude Code / OpenClaw 的 Skill 生命周期管理能力,包括:
|
|
4
|
+
- 发现:扫描本地已安装的 Skill
|
|
5
|
+
- 安装:从内置模板安装到指定目录
|
|
6
|
+
- 状态:查询本地和远端版本对比
|
|
7
|
+
- 升级:从运营系统后端拉取最新数据
|
|
8
|
+
|
|
9
|
+
典型工作流程:
|
|
10
|
+
1. 用户执行 `opscli skills install ops-dataset-query` 安装 Skill
|
|
11
|
+
2. Skill 被部署到 .claude/skills 或 .openclaw/skills
|
|
12
|
+
3. 用户执行 `opscli skills upgrade` 拉取远端最新数据
|
|
13
|
+
|
|
14
|
+
CLI 入口通过 cli.py 注册到 opscli 顶级命令:
|
|
15
|
+
- opscli skills list # 列出已安装的 Skill
|
|
16
|
+
- opscli skills install # 安装 Skill
|
|
17
|
+
- opscli skills status # 查看安装状态
|
|
18
|
+
- opscli skills upgrade # 升级到远端最新版本
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from opscli.skills.domain.models import (
|
|
22
|
+
SkillBatchInstallResult,
|
|
23
|
+
SkillBatchUpgradeResult,
|
|
24
|
+
SkillInstallResult,
|
|
25
|
+
SkillRecord,
|
|
26
|
+
SkillUpgradeResult,
|
|
27
|
+
)
|
|
28
|
+
from opscli.skills.services.manager import SkillsManager
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"SkillRecord",
|
|
32
|
+
"SkillInstallResult",
|
|
33
|
+
"SkillBatchInstallResult",
|
|
34
|
+
"SkillUpgradeResult",
|
|
35
|
+
"SkillBatchUpgradeResult",
|
|
36
|
+
"SkillsManager",
|
|
37
|
+
]
|
opscli/skills/cli.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""skills 命令层。"""
|