tinet-data-foundry-cli 0.1.3__tar.gz → 0.1.6__tar.gz
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.
- {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/PKG-INFO +39 -4
- tinet_data_foundry_cli-0.1.6/README.md +109 -0
- {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/pyproject.toml +2 -2
- tinet_data_foundry_cli-0.1.6/src/tinet_data_foundry_cli/__init__.py +1 -0
- {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/src/tinet_data_foundry_cli/cli.py +90 -18
- tinet_data_foundry_cli-0.1.6/src/tinet_data_foundry_cli/docs.py +290 -0
- {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/src/tinet_data_foundry_cli.egg-info/PKG-INFO +39 -4
- {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/src/tinet_data_foundry_cli.egg-info/SOURCES.txt +2 -0
- tinet_data_foundry_cli-0.1.6/tests/test_cli.py +98 -0
- tinet_data_foundry_cli-0.1.6/tests/test_docs.py +76 -0
- tinet_data_foundry_cli-0.1.3/README.md +0 -74
- tinet_data_foundry_cli-0.1.3/src/tinet_data_foundry_cli/__init__.py +0 -1
- tinet_data_foundry_cli-0.1.3/tests/test_cli.py +0 -26
- {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/setup.cfg +0 -0
- {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/src/tinet_data_foundry_cli/__main__.py +0 -0
- {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/src/tinet_data_foundry_cli/auth.py +0 -0
- {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/src/tinet_data_foundry_cli/client.py +0 -0
- {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/src/tinet_data_foundry_cli.egg-info/dependency_links.txt +0 -0
- {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/src/tinet_data_foundry_cli.egg-info/entry_points.txt +0 -0
- {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/src/tinet_data_foundry_cli.egg-info/top_level.txt +0 -0
- {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/tests/test_sign.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tinet-data-foundry-cli
|
|
3
|
-
Version: 0.1.
|
|
4
|
-
Summary: Tinet Data Foundry 取数 CLI(catalog / query / schema)
|
|
3
|
+
Version: 0.1.6
|
|
4
|
+
Summary: Tinet Data Foundry 取数 CLI(docs / catalog / query / schema)
|
|
5
5
|
Author: Tinet
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://data-foundry.tinetcloud.com
|
|
@@ -40,9 +40,23 @@ tdf query \
|
|
|
40
40
|
--filter '{"op":"eq","field":"id","value":"sample"}' \
|
|
41
41
|
--sort '[{"field":"id","direction":"DESC"}]' \
|
|
42
42
|
--page-no 1 \
|
|
43
|
-
--page-size 10
|
|
43
|
+
--page-size 10 \
|
|
44
|
+
--enterprise-range NORMAL \
|
|
45
|
+
--visitor '{"dingtalkUserId":"钉钉用户ID"}'
|
|
44
46
|
```
|
|
45
47
|
|
|
48
|
+
`--enterprise-range` 可省略,服务端默认 `NORMAL`(不含注销用户和集团用户)。`ALL` 两者都包含。应用凭证必须传 `--visitor`,与数据集是否配置行级列无关;JSON 里 `username`、`dingtalkUserId`、`dingtalkUserMobile`、`dingtalkUserEmail` 至少填一个。无行级列时仍须传入,但不加企业过滤。个人凭证不要传。
|
|
49
|
+
|
|
50
|
+
字符串字段可做模糊匹配,`value` 为非空字符串,`%` 和 `_` 按字面匹配:
|
|
51
|
+
|
|
52
|
+
| op | 含义 | 示例 |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| `starts_with` | 右模糊,匹配前缀 | `{"op":"starts_with","field":"name","value":"北京"}` |
|
|
55
|
+
| `ends_with` | 左模糊,匹配后缀 | `{"op":"ends_with","field":"name","value":"公司"}` |
|
|
56
|
+
| `contains` | 左右模糊 | `{"op":"contains","field":"name","value":"科技"}` |
|
|
57
|
+
|
|
58
|
+
仅按日分区表必须带分区条件。未传分区字段时,服务端按上海时区补当天闭区间,并在响应 `data.partitionWindow` 里返回实际 `field/from/to`。显式 `between` 不超过 30 天,否则 422 `PARTITION_RANGE_INVALID`。按月、按年分区字段不是必填条件。
|
|
59
|
+
|
|
46
60
|
若 pip 提示 scripts 目录不在 PATH,把该目录加入 PATH,或用 `python3 -m tinet_data_foundry_cli`。
|
|
47
61
|
|
|
48
62
|
## Windows(cmd.exe)
|
|
@@ -56,7 +70,7 @@ set DF_SK=你的_SecretKey
|
|
|
56
70
|
|
|
57
71
|
tdf catalog
|
|
58
72
|
tdf schema --dataset "DATASET_ID"
|
|
59
|
-
tdf query --dataset "DATASET_ID" --fields "id" --filter "{\"op\":\"eq\",\"field\":\"id\",\"value\":\"sample\"}" --sort "[{\"field\":\"id\",\"direction\":\"DESC\"}]" --page-no 1 --page-size 10
|
|
73
|
+
tdf query --dataset "DATASET_ID" --fields "id" --filter "{\"op\":\"eq\",\"field\":\"id\",\"value\":\"sample\"}" --sort "[{\"field\":\"id\",\"direction\":\"DESC\"}]" --page-no 1 --page-size 10 --enterprise-range NORMAL --visitor "{\"dingtalkUserId\":\"钉钉用户ID\"}"
|
|
60
74
|
```
|
|
61
75
|
|
|
62
76
|
环境变量只对当前 cmd 窗口有效。JSON 参数用 `\"` 转义,整条命令写在一行。
|
|
@@ -70,10 +84,31 @@ tdf catalog --insecure
|
|
|
70
84
|
|
|
71
85
|
| 命令 | 接口 |
|
|
72
86
|
| --- | --- |
|
|
87
|
+
| `tdf docs rev` | `GET /docs/credentials/{ak}?format=rev` |
|
|
88
|
+
| `tdf docs pull` | 按 `copy.catalog.policy` 只拉取变化的 `part=public` 或 `part=policy` |
|
|
73
89
|
| `tdf catalog` | `GET /data-api/v1/catalog` |
|
|
74
90
|
| `tdf query` | `POST /data-api/v1/datasets/{id}/query` |
|
|
75
91
|
| `tdf schema` | `GET /data-api/v1/datasets/{id}/schema` |
|
|
76
92
|
|
|
93
|
+
文档站点默认 `https://data-foundry.tinetcloud.com`,可用 `DF_DOCS_BASE` 覆盖。`tdf docs` 只需要 `DF_AK`。缓存位于 `~/.cache/tinet-data-foundry/docs/<AK 哈希>/`,可用 `DF_DOCS_CACHE` 改根目录。stdout 非终端时 `tdf catalog` 改为 JSON。
|
|
94
|
+
|
|
95
|
+
### Skill 查询大目录
|
|
96
|
+
|
|
97
|
+
`tdf docs pull` 把公共目录与授权策略缓存到本机。Skill / Agent 不应直接把大 `public.json` 读进上下文,而是通过以下命令只取需要的精简结果:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
tdf docs search --query "京东云消费"
|
|
101
|
+
tdf docs describe --dataset "DATASET_ID"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`search` 默认只返回有表权限的数据集;`describe` 只返回该表已授权字段及其脱敏规则。首次使用先执行 `tdf docs pull`。
|
|
105
|
+
|
|
106
|
+
Agent / Skill 传查询体用文件,避免 shell 转义:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
tdf query --dataset "DATASET_ID" --body-file query.json
|
|
110
|
+
```
|
|
111
|
+
|
|
77
112
|
签名:`canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + hex(SHA256(body))`,`X-DF-Signature = Base64(HMAC-SHA256(canonical, SK))`。GET 的 body 为空字节。PATH 不含域名、不含 query。
|
|
78
113
|
|
|
79
114
|
## 发布到 PyPI
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# tinet-data-foundry-cli
|
|
2
|
+
|
|
3
|
+
Tinet Data Foundry 取数 CLI。包名 `tinet-data-foundry-cli`,命令 **`tdf`**(避开系统磁盘命令 `df`)。
|
|
4
|
+
|
|
5
|
+
鉴权只用环境变量,**不要**把 AccessKey / SecretKey 写入本地文件。需要 Python **≥ 3.10**。
|
|
6
|
+
|
|
7
|
+
macOS / Linux 用 Terminal(bash / zsh)。Windows 用 **cmd.exe** 跑下面示例(不要用 PowerShell 传 JSON:`export` 不可用,引号也会被剥掉)。
|
|
8
|
+
|
|
9
|
+
## macOS / Linux
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install -U tinet-data-foundry-cli
|
|
13
|
+
|
|
14
|
+
export DF_ENDPOINT="https://data-foundry-data-api.tinetcloud.com"
|
|
15
|
+
export DF_AK="你的_AccessKey"
|
|
16
|
+
export DF_SK="你的_SecretKey"
|
|
17
|
+
|
|
18
|
+
tdf catalog
|
|
19
|
+
tdf schema --dataset "DATASET_ID"
|
|
20
|
+
tdf query \
|
|
21
|
+
--dataset "DATASET_ID" \
|
|
22
|
+
--fields "id" \
|
|
23
|
+
--filter '{"op":"eq","field":"id","value":"sample"}' \
|
|
24
|
+
--sort '[{"field":"id","direction":"DESC"}]' \
|
|
25
|
+
--page-no 1 \
|
|
26
|
+
--page-size 10 \
|
|
27
|
+
--enterprise-range NORMAL \
|
|
28
|
+
--visitor '{"dingtalkUserId":"钉钉用户ID"}'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`--enterprise-range` 可省略,服务端默认 `NORMAL`(不含注销用户和集团用户)。`ALL` 两者都包含。应用凭证必须传 `--visitor`,与数据集是否配置行级列无关;JSON 里 `username`、`dingtalkUserId`、`dingtalkUserMobile`、`dingtalkUserEmail` 至少填一个。无行级列时仍须传入,但不加企业过滤。个人凭证不要传。
|
|
32
|
+
|
|
33
|
+
字符串字段可做模糊匹配,`value` 为非空字符串,`%` 和 `_` 按字面匹配:
|
|
34
|
+
|
|
35
|
+
| op | 含义 | 示例 |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| `starts_with` | 右模糊,匹配前缀 | `{"op":"starts_with","field":"name","value":"北京"}` |
|
|
38
|
+
| `ends_with` | 左模糊,匹配后缀 | `{"op":"ends_with","field":"name","value":"公司"}` |
|
|
39
|
+
| `contains` | 左右模糊 | `{"op":"contains","field":"name","value":"科技"}` |
|
|
40
|
+
|
|
41
|
+
仅按日分区表必须带分区条件。未传分区字段时,服务端按上海时区补当天闭区间,并在响应 `data.partitionWindow` 里返回实际 `field/from/to`。显式 `between` 不超过 30 天,否则 422 `PARTITION_RANGE_INVALID`。按月、按年分区字段不是必填条件。
|
|
42
|
+
|
|
43
|
+
若 pip 提示 scripts 目录不在 PATH,把该目录加入 PATH,或用 `python3 -m tinet_data_foundry_cli`。
|
|
44
|
+
|
|
45
|
+
## Windows(cmd.exe)
|
|
46
|
+
|
|
47
|
+
```bat
|
|
48
|
+
pip install -U tinet-data-foundry-cli
|
|
49
|
+
|
|
50
|
+
set DF_ENDPOINT=https://data-foundry-data-api.tinetcloud.com
|
|
51
|
+
set DF_AK=你的_AccessKey
|
|
52
|
+
set DF_SK=你的_SecretKey
|
|
53
|
+
|
|
54
|
+
tdf catalog
|
|
55
|
+
tdf schema --dataset "DATASET_ID"
|
|
56
|
+
tdf query --dataset "DATASET_ID" --fields "id" --filter "{\"op\":\"eq\",\"field\":\"id\",\"value\":\"sample\"}" --sort "[{\"field\":\"id\",\"direction\":\"DESC\"}]" --page-no 1 --page-size 10 --enterprise-range NORMAL --visitor "{\"dingtalkUserId\":\"钉钉用户ID\"}"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
环境变量只对当前 cmd 窗口有效。JSON 参数用 `\"` 转义,整条命令写在一行。
|
|
60
|
+
|
|
61
|
+
公司代理 / 自签证书导致 `CERTIFICATE_VERIFY_FAILED` 时:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
tdf --insecure catalog
|
|
65
|
+
tdf catalog --insecure
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
| 命令 | 接口 |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| `tdf docs rev` | `GET /docs/credentials/{ak}?format=rev` |
|
|
71
|
+
| `tdf docs pull` | 按 `copy.catalog.policy` 只拉取变化的 `part=public` 或 `part=policy` |
|
|
72
|
+
| `tdf catalog` | `GET /data-api/v1/catalog` |
|
|
73
|
+
| `tdf query` | `POST /data-api/v1/datasets/{id}/query` |
|
|
74
|
+
| `tdf schema` | `GET /data-api/v1/datasets/{id}/schema` |
|
|
75
|
+
|
|
76
|
+
文档站点默认 `https://data-foundry.tinetcloud.com`,可用 `DF_DOCS_BASE` 覆盖。`tdf docs` 只需要 `DF_AK`。缓存位于 `~/.cache/tinet-data-foundry/docs/<AK 哈希>/`,可用 `DF_DOCS_CACHE` 改根目录。stdout 非终端时 `tdf catalog` 改为 JSON。
|
|
77
|
+
|
|
78
|
+
### Skill 查询大目录
|
|
79
|
+
|
|
80
|
+
`tdf docs pull` 把公共目录与授权策略缓存到本机。Skill / Agent 不应直接把大 `public.json` 读进上下文,而是通过以下命令只取需要的精简结果:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
tdf docs search --query "京东云消费"
|
|
84
|
+
tdf docs describe --dataset "DATASET_ID"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`search` 默认只返回有表权限的数据集;`describe` 只返回该表已授权字段及其脱敏规则。首次使用先执行 `tdf docs pull`。
|
|
88
|
+
|
|
89
|
+
Agent / Skill 传查询体用文件,避免 shell 转义:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
tdf query --dataset "DATASET_ID" --body-file query.json
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
签名:`canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + hex(SHA256(body))`,`X-DF-Signature = Base64(HMAC-SHA256(canonical, SK))`。GET 的 body 为空字节。PATH 不含域名、不含 query。
|
|
96
|
+
|
|
97
|
+
## 发布到 PyPI
|
|
98
|
+
|
|
99
|
+
版本号改 `pyproject.toml` 与 `src/tinet_data_foundry_cli/__init__.py`。
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
python -m pip install -U build twine
|
|
103
|
+
python -m build
|
|
104
|
+
python -m twine check dist/*
|
|
105
|
+
python -m twine upload --repository testpypi dist/*
|
|
106
|
+
python -m twine upload dist/*
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
已发布版本号不可覆盖。
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tinet-data-foundry-cli"
|
|
7
|
-
version = "0.1.
|
|
8
|
-
description = "Tinet Data Foundry 取数 CLI(catalog / query / schema)"
|
|
7
|
+
version = "0.1.6"
|
|
8
|
+
description = "Tinet Data Foundry 取数 CLI(docs / catalog / query / schema)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = { text = "MIT" }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.6"
|
{tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/src/tinet_data_foundry_cli/cli.py
RENAMED
|
@@ -105,7 +105,10 @@ def cmd_catalog(args: argparse.Namespace) -> int:
|
|
|
105
105
|
_exit_api(ex)
|
|
106
106
|
except TransportError as ex:
|
|
107
107
|
_exit_transport(ex)
|
|
108
|
-
|
|
108
|
+
fmt = args.format
|
|
109
|
+
if fmt == "table" and not sys.stdout.isatty():
|
|
110
|
+
fmt = "json"
|
|
111
|
+
if args.dry_run or fmt == "json":
|
|
109
112
|
_print_json(parsed)
|
|
110
113
|
return 0
|
|
111
114
|
data = _ok_data(parsed) or {}
|
|
@@ -114,23 +117,60 @@ def cmd_catalog(args: argparse.Namespace) -> int:
|
|
|
114
117
|
return 0
|
|
115
118
|
|
|
116
119
|
|
|
120
|
+
def _query_body(args: argparse.Namespace) -> Optional[dict]:
|
|
121
|
+
if args.body and args.body_file:
|
|
122
|
+
raise SystemExit("--body 与 --body-file 只能选一个")
|
|
123
|
+
raw: Optional[str] = None
|
|
124
|
+
if args.body == "-":
|
|
125
|
+
raw = sys.stdin.read()
|
|
126
|
+
elif args.body:
|
|
127
|
+
raw = args.body
|
|
128
|
+
elif args.body_file:
|
|
129
|
+
try:
|
|
130
|
+
with open(args.body_file, encoding="utf-8") as fh:
|
|
131
|
+
raw = fh.read()
|
|
132
|
+
except OSError as ex:
|
|
133
|
+
raise SystemExit(f"无法读取 --body-file:{ex}") from ex
|
|
134
|
+
if raw is None:
|
|
135
|
+
return None
|
|
136
|
+
for flag, value in (
|
|
137
|
+
("--filter", args.filter),
|
|
138
|
+
("--fields", args.fields),
|
|
139
|
+
("--sort", args.sort),
|
|
140
|
+
("--visitor", args.visitor),
|
|
141
|
+
("--enterprise-range", args.enterprise_range),
|
|
142
|
+
):
|
|
143
|
+
if value:
|
|
144
|
+
raise SystemExit(f"使用 --body / --body-file 时不要再传 {flag}")
|
|
145
|
+
parsed = _parse_json(raw, "--body")
|
|
146
|
+
if not isinstance(parsed, dict):
|
|
147
|
+
raise SystemExit("查询 body 必须是 JSON 对象")
|
|
148
|
+
return parsed
|
|
149
|
+
|
|
150
|
+
|
|
117
151
|
def cmd_query(args: argparse.Namespace) -> int:
|
|
118
152
|
base, ak, sk = credentials(args.endpoint)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
153
|
+
body_doc = _query_body(args)
|
|
154
|
+
if body_doc is not None:
|
|
155
|
+
payload: dict[str, Any] = body_doc
|
|
156
|
+
else:
|
|
157
|
+
payload = {
|
|
158
|
+
"pageNo": args.page_no,
|
|
159
|
+
"pageSize": args.page_size,
|
|
160
|
+
}
|
|
161
|
+
if args.fields:
|
|
162
|
+
payload["fields"] = [p.strip() for p in args.fields.split(",") if p.strip()]
|
|
163
|
+
filt = _parse_json(args.filter, "--filter")
|
|
164
|
+
if filt is not None:
|
|
165
|
+
payload["filter"] = filt
|
|
166
|
+
sort = _parse_json(args.sort, "--sort")
|
|
167
|
+
if sort is not None:
|
|
168
|
+
payload["sort"] = sort
|
|
169
|
+
visitor = _parse_json(args.visitor, "--visitor")
|
|
170
|
+
if visitor is not None:
|
|
171
|
+
payload["visitor"] = visitor
|
|
172
|
+
if args.enterprise_range:
|
|
173
|
+
payload["enterpriseRange"] = args.enterprise_range
|
|
134
174
|
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
135
175
|
path = f"/data-api/v1/datasets/{args.dataset}/query"
|
|
136
176
|
try:
|
|
@@ -212,14 +252,40 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
212
252
|
q = sub.add_parser("query", help="查询 Dataset")
|
|
213
253
|
_add_insecure(q, overlay=True)
|
|
214
254
|
q.add_argument("--dataset", required=True)
|
|
255
|
+
q.add_argument("--body", help="完整查询 JSON。- 表示从 stdin 读取。与 --filter 等参数互斥")
|
|
256
|
+
q.add_argument("--body-file", help="完整查询 JSON 文件。Agent / Skill 用此参数,避免 shell 转义")
|
|
215
257
|
q.add_argument("--fields", help="逗号分隔列名")
|
|
216
|
-
q.add_argument(
|
|
258
|
+
q.add_argument(
|
|
259
|
+
"--filter",
|
|
260
|
+
help=(
|
|
261
|
+
"filter AST JSON。字符串字段可 starts_with(右模糊)、ends_with(左模糊)、contains(左右模糊)。"
|
|
262
|
+
"仅按日分区必填:省略分区字段时按上海时区补当天,显式 between 不超过 30 天。"
|
|
263
|
+
"按月、按年分区字段不是必填条件"
|
|
264
|
+
),
|
|
265
|
+
)
|
|
217
266
|
q.add_argument("--sort", help="sort JSON 数组")
|
|
218
267
|
q.add_argument("--page-no", type=int, default=1)
|
|
219
268
|
q.add_argument("--page-size", type=int, default=10)
|
|
220
|
-
q.add_argument(
|
|
269
|
+
q.add_argument(
|
|
270
|
+
"--enterprise-range",
|
|
271
|
+
choices=["NORMAL", "ALL"],
|
|
272
|
+
help="检索企业范围。省略时服务端按 NORMAL(不含注销用户和集团用户);ALL 两者都包含",
|
|
273
|
+
)
|
|
274
|
+
q.add_argument(
|
|
275
|
+
"--visitor",
|
|
276
|
+
help="应用凭证查询身份 JSON。username、dingtalkUserId、dingtalkUserMobile、dingtalkUserEmail 至少填一个;个人凭证不要传",
|
|
277
|
+
)
|
|
221
278
|
q.set_defaults(func=cmd_query)
|
|
222
279
|
|
|
280
|
+
d = sub.add_parser("docs", help="核对数据集开发文档修订号,并在本地缓存中检索数据集")
|
|
281
|
+
_add_insecure(d, overlay=True)
|
|
282
|
+
d.add_argument("action", choices=["rev", "pull", "search", "describe"])
|
|
283
|
+
d.add_argument("--query", help="search:按名称、描述、表名或业务域检索本地缓存")
|
|
284
|
+
d.add_argument("--dataset", help="describe:输出指定数据集的已授权字段")
|
|
285
|
+
d.add_argument("--all", action="store_true", help="search:包含无表权限的数据集")
|
|
286
|
+
d.add_argument("--docs-base", help="覆盖文档站点。默认 DF_DOCS_BASE 或 https://data-foundry.tinetcloud.com")
|
|
287
|
+
d.set_defaults(func=cmd_docs)
|
|
288
|
+
|
|
223
289
|
s = sub.add_parser("schema", help="拉取 Dataset Schema")
|
|
224
290
|
_add_insecure(s, overlay=True)
|
|
225
291
|
s.add_argument("--dataset", required=True)
|
|
@@ -227,6 +293,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
227
293
|
return p
|
|
228
294
|
|
|
229
295
|
|
|
296
|
+
def cmd_docs(args: argparse.Namespace) -> int:
|
|
297
|
+
from tinet_data_foundry_cli.docs import run_docs
|
|
298
|
+
|
|
299
|
+
return run_docs(args)
|
|
300
|
+
|
|
301
|
+
|
|
230
302
|
def main(argv: Optional[List[str]] = None) -> int:
|
|
231
303
|
parser = build_parser()
|
|
232
304
|
args = parser.parse_args(argv)
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""数据集开发文档:修订号核对与分段缓存。文档接口不签名、不使用 SK。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import ssl
|
|
9
|
+
import sys
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
DEFAULT_DOCS_BASE = "https://data-foundry.tinetcloud.com"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def access_key() -> str:
|
|
20
|
+
ak = (os.environ.get("DF_AK") or "").strip()
|
|
21
|
+
if not ak:
|
|
22
|
+
raise SystemExit("缺少 DF_AK 环境变量(不要写入本地配置文件)")
|
|
23
|
+
return ak
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def docs_base(override: Optional[str] = None) -> str:
|
|
27
|
+
raw = (override or os.environ.get("DF_DOCS_BASE") or DEFAULT_DOCS_BASE).strip()
|
|
28
|
+
return raw.rstrip("/")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cache_dir(ak: str) -> Path:
|
|
32
|
+
digest = hashlib.sha256(ak.encode("utf-8")).hexdigest()[:16]
|
|
33
|
+
root = (os.environ.get("DF_DOCS_CACHE") or "").strip()
|
|
34
|
+
base = Path(root) if root else Path.home() / ".cache" / "tinet-data-foundry" / "docs"
|
|
35
|
+
return base / digest
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def docs_url(base: str, ak: str, fmt: str, part: Optional[str] = None) -> str:
|
|
39
|
+
path = "/docs/credentials/" + urllib.parse.quote(ak, safe="")
|
|
40
|
+
query = {"format": fmt}
|
|
41
|
+
if part:
|
|
42
|
+
query["part"] = part
|
|
43
|
+
return base + path + "?" + urllib.parse.urlencode(query)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_docs(args: Any) -> int:
|
|
47
|
+
ak = access_key()
|
|
48
|
+
base = docs_base(getattr(args, "docs_base", None))
|
|
49
|
+
folder = cache_dir(ak)
|
|
50
|
+
if args.action == "search":
|
|
51
|
+
return _search(folder, str(getattr(args, "query", "") or ""), bool(getattr(args, "all", False)))
|
|
52
|
+
if args.action == "describe":
|
|
53
|
+
return _describe(folder, str(getattr(args, "dataset", "") or ""))
|
|
54
|
+
url = docs_url(base, ak, "rev")
|
|
55
|
+
if args.dry_run:
|
|
56
|
+
_print({
|
|
57
|
+
"dryRun": True,
|
|
58
|
+
"action": args.action,
|
|
59
|
+
"url": url,
|
|
60
|
+
"cacheDir": str(folder),
|
|
61
|
+
})
|
|
62
|
+
return 0
|
|
63
|
+
if args.action == "rev":
|
|
64
|
+
status, payload = _get_json(url, None, args.timeout, args.insecure)
|
|
65
|
+
_ensure_ok(payload)
|
|
66
|
+
_print(payload if status != 304 else _read_json(folder / "rev.json"))
|
|
67
|
+
return 0
|
|
68
|
+
return _pull(folder, base, ak, args.timeout, args.insecure)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _cached_docs(folder: Path) -> tuple[Dict[str, Any], Dict[str, Any]]:
|
|
72
|
+
public = _read_json(folder / "public.json")
|
|
73
|
+
policy = _read_json(folder / "policy.json")
|
|
74
|
+
if not public or not policy:
|
|
75
|
+
raise SystemExit("本地文档缓存不存在,请先运行 tdf docs pull")
|
|
76
|
+
return public, policy
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _search(folder: Path, query: str, include_denied: bool) -> int:
|
|
80
|
+
public, policy = _cached_docs(folder)
|
|
81
|
+
needle = query.strip().casefold()
|
|
82
|
+
if not needle:
|
|
83
|
+
raise SystemExit("docs search 需要 --query")
|
|
84
|
+
grants = _index(policy)
|
|
85
|
+
matches: List[Dict[str, Any]] = []
|
|
86
|
+
for dataset in public.get("datasets") or []:
|
|
87
|
+
if not isinstance(dataset, dict):
|
|
88
|
+
continue
|
|
89
|
+
grant = grants.get(str(dataset.get("id") or ""), {})
|
|
90
|
+
if not include_denied and not grant.get("tableGranted"):
|
|
91
|
+
continue
|
|
92
|
+
haystack = " ".join(str(dataset.get(key) or "") for key in (
|
|
93
|
+
"id", "name", "chineseName", "tableComment", "description", "businessDomainName",
|
|
94
|
+
)).casefold()
|
|
95
|
+
if needle not in haystack:
|
|
96
|
+
continue
|
|
97
|
+
matches.append({
|
|
98
|
+
"id": dataset.get("id"),
|
|
99
|
+
"name": dataset.get("name"),
|
|
100
|
+
"chineseName": dataset.get("chineseName"),
|
|
101
|
+
"description": dataset.get("description"),
|
|
102
|
+
"businessDomainName": dataset.get("businessDomainName"),
|
|
103
|
+
"layer": dataset.get("layer"),
|
|
104
|
+
"schemaVersion": dataset.get("schemaVersion"),
|
|
105
|
+
"tableGranted": bool(grant.get("tableGranted")),
|
|
106
|
+
})
|
|
107
|
+
_print({"query": query, "datasets": matches})
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _describe(folder: Path, dataset_id: str) -> int:
|
|
112
|
+
public, policy = _cached_docs(folder)
|
|
113
|
+
if not dataset_id.strip():
|
|
114
|
+
raise SystemExit("docs describe 需要 --dataset")
|
|
115
|
+
datasets = _index(public)
|
|
116
|
+
dataset = datasets.get(dataset_id)
|
|
117
|
+
if not dataset:
|
|
118
|
+
raise SystemExit(f"缓存中没有数据集 {dataset_id};先执行 tdf docs search --query <关键词>")
|
|
119
|
+
grant = _index(policy).get(dataset_id, {})
|
|
120
|
+
rules = {
|
|
121
|
+
str(col.get("name")): col
|
|
122
|
+
for col in grant.get("columns") or []
|
|
123
|
+
if isinstance(col, dict) and col.get("granted") and col.get("name")
|
|
124
|
+
}
|
|
125
|
+
columns = []
|
|
126
|
+
for column in dataset.get("columns") or []:
|
|
127
|
+
if not isinstance(column, dict) or str(column.get("name")) not in rules:
|
|
128
|
+
continue
|
|
129
|
+
name = str(column["name"])
|
|
130
|
+
columns.append({
|
|
131
|
+
"name": name,
|
|
132
|
+
"type": column.get("type"),
|
|
133
|
+
"comment": column.get("comment"),
|
|
134
|
+
"pk": column.get("pk"),
|
|
135
|
+
"partition": column.get("partition"),
|
|
136
|
+
"partitionGrain": column.get("partitionGrain"),
|
|
137
|
+
"partitionRequired": column.get("partitionRequired"),
|
|
138
|
+
"outputRule": rules[name].get("outputRule"),
|
|
139
|
+
})
|
|
140
|
+
_print({
|
|
141
|
+
"id": dataset.get("id"),
|
|
142
|
+
"name": dataset.get("name"),
|
|
143
|
+
"chineseName": dataset.get("chineseName"),
|
|
144
|
+
"description": dataset.get("description"),
|
|
145
|
+
"businessDomainName": dataset.get("businessDomainName"),
|
|
146
|
+
"schemaVersion": dataset.get("schemaVersion"),
|
|
147
|
+
"primaryKey": dataset.get("primaryKey"),
|
|
148
|
+
"partitionFields": dataset.get("partitionFields"),
|
|
149
|
+
"tableGranted": bool(grant.get("tableGranted")),
|
|
150
|
+
"columns": columns,
|
|
151
|
+
})
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _pull(folder: Path, base: str, ak: str, timeout: float, insecure: bool) -> int:
|
|
156
|
+
local = _read_json(folder / "rev.json")
|
|
157
|
+
local_etag = str(local.get("etag") or "")
|
|
158
|
+
status, remote = _get_json(docs_url(base, ak, "rev"), local_etag or None, timeout, insecure)
|
|
159
|
+
if status == 304:
|
|
160
|
+
_print({
|
|
161
|
+
"status": "unchanged",
|
|
162
|
+
"etag": local_etag,
|
|
163
|
+
"changed": [],
|
|
164
|
+
"cacheDir": str(folder),
|
|
165
|
+
"summary": [],
|
|
166
|
+
})
|
|
167
|
+
return 0
|
|
168
|
+
_ensure_ok(remote)
|
|
169
|
+
etag = str(remote.get("etag") or "")
|
|
170
|
+
if etag and etag == local_etag and (folder / "public.json").is_file() and (folder / "policy.json").is_file():
|
|
171
|
+
_print({
|
|
172
|
+
"status": "unchanged",
|
|
173
|
+
"etag": etag,
|
|
174
|
+
"changed": [],
|
|
175
|
+
"cacheDir": str(folder),
|
|
176
|
+
"summary": [],
|
|
177
|
+
})
|
|
178
|
+
return 0
|
|
179
|
+
changed: List[str] = []
|
|
180
|
+
old_public = _read_json(folder / "public.json")
|
|
181
|
+
old_policy = _read_json(folder / "policy.json")
|
|
182
|
+
if not local_etag or local.get("copy") != remote.get("copy") or local.get("catalog") != remote.get("catalog"):
|
|
183
|
+
_, public = _get_json(docs_url(base, ak, "json", "public"), None, timeout, insecure)
|
|
184
|
+
_ensure_ok(public)
|
|
185
|
+
_write_json(folder / "public.json", public)
|
|
186
|
+
changed.append("public")
|
|
187
|
+
else:
|
|
188
|
+
public = old_public
|
|
189
|
+
if not local_etag or local.get("policy") != remote.get("policy"):
|
|
190
|
+
_, policy = _get_json(docs_url(base, ak, "json", "policy"), None, timeout, insecure)
|
|
191
|
+
_ensure_ok(policy)
|
|
192
|
+
_write_json(folder / "policy.json", policy)
|
|
193
|
+
changed.append("policy")
|
|
194
|
+
else:
|
|
195
|
+
policy = old_policy
|
|
196
|
+
_write_json(folder / "rev.json", {
|
|
197
|
+
"copy": remote.get("copy"),
|
|
198
|
+
"catalog": remote.get("catalog"),
|
|
199
|
+
"policy": remote.get("policy"),
|
|
200
|
+
"etag": etag,
|
|
201
|
+
})
|
|
202
|
+
_print({
|
|
203
|
+
"status": "updated",
|
|
204
|
+
"etag": etag,
|
|
205
|
+
"changed": changed,
|
|
206
|
+
"cacheDir": str(folder),
|
|
207
|
+
"summary": summarize(old_public, public, old_policy, policy) if changed else [],
|
|
208
|
+
})
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def summarize(old_public: Dict[str, Any], new_public: Dict[str, Any],
|
|
213
|
+
old_policy: Dict[str, Any], new_policy: Dict[str, Any]) -> List[str]:
|
|
214
|
+
if not old_public and not old_policy:
|
|
215
|
+
return ["首次拉取"]
|
|
216
|
+
lines: List[str] = []
|
|
217
|
+
old_ds = _index(old_public)
|
|
218
|
+
new_ds = _index(new_public)
|
|
219
|
+
for dataset_id in sorted(set(new_ds) - set(old_ds)):
|
|
220
|
+
lines.append(f"新增数据集 {dataset_id}")
|
|
221
|
+
for dataset_id in sorted(set(old_ds) - set(new_ds)):
|
|
222
|
+
lines.append(f"下线数据集 {dataset_id}")
|
|
223
|
+
for dataset_id in sorted(set(old_ds) & set(new_ds)):
|
|
224
|
+
if str(old_ds[dataset_id].get("schemaVersion") or "") != str(new_ds[dataset_id].get("schemaVersion") or ""):
|
|
225
|
+
lines.append(f"schemaVersion 变化 {dataset_id}")
|
|
226
|
+
old_grants = _index(old_policy)
|
|
227
|
+
new_grants = _index(new_policy)
|
|
228
|
+
for dataset_id in sorted(set(old_grants) | set(new_grants)):
|
|
229
|
+
before = bool(old_grants.get(dataset_id, {}).get("tableGranted"))
|
|
230
|
+
after = bool(new_grants.get(dataset_id, {}).get("tableGranted"))
|
|
231
|
+
if before != after:
|
|
232
|
+
lines.append(f"表权限变化 {dataset_id} {'有' if after else '无'}")
|
|
233
|
+
return lines[:30]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _index(doc: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
|
237
|
+
out: Dict[str, Dict[str, Any]] = {}
|
|
238
|
+
for row in doc.get("datasets") or []:
|
|
239
|
+
if isinstance(row, dict) and row.get("id"):
|
|
240
|
+
out[str(row["id"])] = row
|
|
241
|
+
return out
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _get_json(url: str, etag: Optional[str], timeout: float, insecure: bool):
|
|
245
|
+
headers = {"Accept": "application/json"}
|
|
246
|
+
if etag:
|
|
247
|
+
headers["If-None-Match"] = '"' + etag + '"'
|
|
248
|
+
req = urllib.request.Request(url, headers=headers, method="GET")
|
|
249
|
+
ctx = ssl._create_unverified_context() if insecure else None
|
|
250
|
+
try:
|
|
251
|
+
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
|
252
|
+
raw = resp.read()
|
|
253
|
+
payload = json.loads(raw.decode("utf-8")) if raw else {}
|
|
254
|
+
return resp.status, payload
|
|
255
|
+
except urllib.error.HTTPError as ex:
|
|
256
|
+
if ex.code == 304:
|
|
257
|
+
return 304, {}
|
|
258
|
+
raw = ex.read() if ex.fp else b""
|
|
259
|
+
try:
|
|
260
|
+
payload = json.loads(raw.decode("utf-8")) if raw else {"message": str(ex)}
|
|
261
|
+
except json.JSONDecodeError:
|
|
262
|
+
payload = {"message": raw.decode("utf-8", errors="replace")}
|
|
263
|
+
raise SystemExit(f"文档请求失败 HTTP {ex.code}: {payload.get('message', payload)}") from ex
|
|
264
|
+
except (urllib.error.URLError, ssl.SSLError, TimeoutError, OSError) as ex:
|
|
265
|
+
raise SystemExit(f"无法连接文档站点:{ex}") from ex
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _ensure_ok(payload: Any) -> None:
|
|
269
|
+
if isinstance(payload, dict) and payload.get("code") not in (None, 0, "0"):
|
|
270
|
+
raise SystemExit(str(payload.get("message") or payload))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _read_json(path: Path) -> Dict[str, Any]:
|
|
274
|
+
if not path.is_file():
|
|
275
|
+
return {}
|
|
276
|
+
try:
|
|
277
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
278
|
+
except (OSError, json.JSONDecodeError):
|
|
279
|
+
return {}
|
|
280
|
+
return data if isinstance(data, dict) else {}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _write_json(path: Path, payload: Any) -> None:
|
|
284
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
285
|
+
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _print(payload: Any) -> None:
|
|
289
|
+
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
|
|
290
|
+
sys.stdout.write("\n")
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tinet-data-foundry-cli
|
|
3
|
-
Version: 0.1.
|
|
4
|
-
Summary: Tinet Data Foundry 取数 CLI(catalog / query / schema)
|
|
3
|
+
Version: 0.1.6
|
|
4
|
+
Summary: Tinet Data Foundry 取数 CLI(docs / catalog / query / schema)
|
|
5
5
|
Author: Tinet
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://data-foundry.tinetcloud.com
|
|
@@ -40,9 +40,23 @@ tdf query \
|
|
|
40
40
|
--filter '{"op":"eq","field":"id","value":"sample"}' \
|
|
41
41
|
--sort '[{"field":"id","direction":"DESC"}]' \
|
|
42
42
|
--page-no 1 \
|
|
43
|
-
--page-size 10
|
|
43
|
+
--page-size 10 \
|
|
44
|
+
--enterprise-range NORMAL \
|
|
45
|
+
--visitor '{"dingtalkUserId":"钉钉用户ID"}'
|
|
44
46
|
```
|
|
45
47
|
|
|
48
|
+
`--enterprise-range` 可省略,服务端默认 `NORMAL`(不含注销用户和集团用户)。`ALL` 两者都包含。应用凭证必须传 `--visitor`,与数据集是否配置行级列无关;JSON 里 `username`、`dingtalkUserId`、`dingtalkUserMobile`、`dingtalkUserEmail` 至少填一个。无行级列时仍须传入,但不加企业过滤。个人凭证不要传。
|
|
49
|
+
|
|
50
|
+
字符串字段可做模糊匹配,`value` 为非空字符串,`%` 和 `_` 按字面匹配:
|
|
51
|
+
|
|
52
|
+
| op | 含义 | 示例 |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| `starts_with` | 右模糊,匹配前缀 | `{"op":"starts_with","field":"name","value":"北京"}` |
|
|
55
|
+
| `ends_with` | 左模糊,匹配后缀 | `{"op":"ends_with","field":"name","value":"公司"}` |
|
|
56
|
+
| `contains` | 左右模糊 | `{"op":"contains","field":"name","value":"科技"}` |
|
|
57
|
+
|
|
58
|
+
仅按日分区表必须带分区条件。未传分区字段时,服务端按上海时区补当天闭区间,并在响应 `data.partitionWindow` 里返回实际 `field/from/to`。显式 `between` 不超过 30 天,否则 422 `PARTITION_RANGE_INVALID`。按月、按年分区字段不是必填条件。
|
|
59
|
+
|
|
46
60
|
若 pip 提示 scripts 目录不在 PATH,把该目录加入 PATH,或用 `python3 -m tinet_data_foundry_cli`。
|
|
47
61
|
|
|
48
62
|
## Windows(cmd.exe)
|
|
@@ -56,7 +70,7 @@ set DF_SK=你的_SecretKey
|
|
|
56
70
|
|
|
57
71
|
tdf catalog
|
|
58
72
|
tdf schema --dataset "DATASET_ID"
|
|
59
|
-
tdf query --dataset "DATASET_ID" --fields "id" --filter "{\"op\":\"eq\",\"field\":\"id\",\"value\":\"sample\"}" --sort "[{\"field\":\"id\",\"direction\":\"DESC\"}]" --page-no 1 --page-size 10
|
|
73
|
+
tdf query --dataset "DATASET_ID" --fields "id" --filter "{\"op\":\"eq\",\"field\":\"id\",\"value\":\"sample\"}" --sort "[{\"field\":\"id\",\"direction\":\"DESC\"}]" --page-no 1 --page-size 10 --enterprise-range NORMAL --visitor "{\"dingtalkUserId\":\"钉钉用户ID\"}"
|
|
60
74
|
```
|
|
61
75
|
|
|
62
76
|
环境变量只对当前 cmd 窗口有效。JSON 参数用 `\"` 转义,整条命令写在一行。
|
|
@@ -70,10 +84,31 @@ tdf catalog --insecure
|
|
|
70
84
|
|
|
71
85
|
| 命令 | 接口 |
|
|
72
86
|
| --- | --- |
|
|
87
|
+
| `tdf docs rev` | `GET /docs/credentials/{ak}?format=rev` |
|
|
88
|
+
| `tdf docs pull` | 按 `copy.catalog.policy` 只拉取变化的 `part=public` 或 `part=policy` |
|
|
73
89
|
| `tdf catalog` | `GET /data-api/v1/catalog` |
|
|
74
90
|
| `tdf query` | `POST /data-api/v1/datasets/{id}/query` |
|
|
75
91
|
| `tdf schema` | `GET /data-api/v1/datasets/{id}/schema` |
|
|
76
92
|
|
|
93
|
+
文档站点默认 `https://data-foundry.tinetcloud.com`,可用 `DF_DOCS_BASE` 覆盖。`tdf docs` 只需要 `DF_AK`。缓存位于 `~/.cache/tinet-data-foundry/docs/<AK 哈希>/`,可用 `DF_DOCS_CACHE` 改根目录。stdout 非终端时 `tdf catalog` 改为 JSON。
|
|
94
|
+
|
|
95
|
+
### Skill 查询大目录
|
|
96
|
+
|
|
97
|
+
`tdf docs pull` 把公共目录与授权策略缓存到本机。Skill / Agent 不应直接把大 `public.json` 读进上下文,而是通过以下命令只取需要的精简结果:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
tdf docs search --query "京东云消费"
|
|
101
|
+
tdf docs describe --dataset "DATASET_ID"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`search` 默认只返回有表权限的数据集;`describe` 只返回该表已授权字段及其脱敏规则。首次使用先执行 `tdf docs pull`。
|
|
105
|
+
|
|
106
|
+
Agent / Skill 传查询体用文件,避免 shell 转义:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
tdf query --dataset "DATASET_ID" --body-file query.json
|
|
110
|
+
```
|
|
111
|
+
|
|
77
112
|
签名:`canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + hex(SHA256(body))`,`X-DF-Signature = Base64(HMAC-SHA256(canonical, SK))`。GET 的 body 为空字节。PATH 不含域名、不含 query。
|
|
78
113
|
|
|
79
114
|
## 发布到 PyPI
|
|
@@ -5,10 +5,12 @@ src/tinet_data_foundry_cli/__main__.py
|
|
|
5
5
|
src/tinet_data_foundry_cli/auth.py
|
|
6
6
|
src/tinet_data_foundry_cli/cli.py
|
|
7
7
|
src/tinet_data_foundry_cli/client.py
|
|
8
|
+
src/tinet_data_foundry_cli/docs.py
|
|
8
9
|
src/tinet_data_foundry_cli.egg-info/PKG-INFO
|
|
9
10
|
src/tinet_data_foundry_cli.egg-info/SOURCES.txt
|
|
10
11
|
src/tinet_data_foundry_cli.egg-info/dependency_links.txt
|
|
11
12
|
src/tinet_data_foundry_cli.egg-info/entry_points.txt
|
|
12
13
|
src/tinet_data_foundry_cli.egg-info/top_level.txt
|
|
13
14
|
tests/test_cli.py
|
|
15
|
+
tests/test_docs.py
|
|
14
16
|
tests/test_sign.py
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import unittest
|
|
3
|
+
from io import StringIO
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
from tinet_data_foundry_cli.cli import build_parser, main
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CliParserTest(unittest.TestCase):
|
|
10
|
+
def test_query_filter_help_mentions_string_match(self):
|
|
11
|
+
parser = build_parser()
|
|
12
|
+
with patch("sys.stdout", new_callable=StringIO) as out:
|
|
13
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
14
|
+
parser.parse_args(["query", "--help"])
|
|
15
|
+
self.assertEqual(ctx.exception.code, 0)
|
|
16
|
+
help_text = out.getvalue()
|
|
17
|
+
self.assertIn("starts_with", help_text)
|
|
18
|
+
self.assertIn("ends_with", help_text)
|
|
19
|
+
self.assertIn("contains", help_text)
|
|
20
|
+
self.assertIn("仅按日分区必填", help_text)
|
|
21
|
+
self.assertIn("30", help_text)
|
|
22
|
+
self.assertIn("当天", help_text)
|
|
23
|
+
self.assertIn("按月、按年分区字段不是必填条件", help_text)
|
|
24
|
+
self.assertNotIn("按月补当月", help_text)
|
|
25
|
+
|
|
26
|
+
def test_subcommands(self):
|
|
27
|
+
parser = build_parser()
|
|
28
|
+
catalog = parser.parse_args(["catalog", "--format", "json"])
|
|
29
|
+
self.assertEqual(catalog.cmd, "catalog")
|
|
30
|
+
query = parser.parse_args(["query", "--dataset", "ds_1", "--page-no", "1", "--page-size", "10"])
|
|
31
|
+
self.assertEqual(query.cmd, "query")
|
|
32
|
+
catalog_k = parser.parse_args(["catalog", "--insecure"])
|
|
33
|
+
self.assertTrue(catalog_k.insecure)
|
|
34
|
+
catalog_before = parser.parse_args(["--insecure", "catalog"])
|
|
35
|
+
self.assertTrue(catalog_before.insecure)
|
|
36
|
+
|
|
37
|
+
def test_query_enterprise_range_and_visitor(self):
|
|
38
|
+
parser = build_parser()
|
|
39
|
+
query = parser.parse_args([
|
|
40
|
+
"query", "--dataset", "ds_1", "--enterprise-range", "ALL",
|
|
41
|
+
"--visitor", "{\"dingtalkUserId\":\"dt-1\"}",
|
|
42
|
+
])
|
|
43
|
+
self.assertEqual(query.enterprise_range, "ALL")
|
|
44
|
+
env = {"DF_ENDPOINT": "https://example.test", "DF_AK": "ak", "DF_SK": "sk" * 8}
|
|
45
|
+
with patch.dict("os.environ", env, clear=False), patch("sys.stdout", new_callable=StringIO) as out:
|
|
46
|
+
code = main([
|
|
47
|
+
"--dry-run", "query", "--dataset", "ds_1", "--enterprise-range", "ALL",
|
|
48
|
+
"--visitor", "{\"dingtalkUserId\":\"dt-1\"}",
|
|
49
|
+
])
|
|
50
|
+
self.assertEqual(code, 0)
|
|
51
|
+
body = json.loads(json.loads(out.getvalue())["body"])
|
|
52
|
+
self.assertEqual(body["enterpriseRange"], "ALL")
|
|
53
|
+
self.assertEqual(body["visitor"]["dingtalkUserId"], "dt-1")
|
|
54
|
+
self.assertNotIn("enterpriseRange", json.loads(json.loads(
|
|
55
|
+
self._dry_query(["--dry-run", "query", "--dataset", "ds_1"])
|
|
56
|
+
)["body"]))
|
|
57
|
+
|
|
58
|
+
def _dry_query(self, argv):
|
|
59
|
+
env = {"DF_ENDPOINT": "https://example.test", "DF_AK": "ak", "DF_SK": "sk" * 8}
|
|
60
|
+
with patch.dict("os.environ", env, clear=False), patch("sys.stdout", new_callable=StringIO) as out:
|
|
61
|
+
self.assertEqual(main(argv), 0)
|
|
62
|
+
return out.getvalue()
|
|
63
|
+
|
|
64
|
+
def test_dry_run_catalog_does_not_need_network(self):
|
|
65
|
+
with patch.dict("os.environ", {"DF_AK": "ak", "DF_SK": "sk" * 8}, clear=False):
|
|
66
|
+
code = main(["--dry-run", "catalog"])
|
|
67
|
+
self.assertEqual(code, 0)
|
|
68
|
+
|
|
69
|
+
def test_query_body_file_is_the_post_body(self):
|
|
70
|
+
import tempfile
|
|
71
|
+
payload = {"fields": ["id"], "pageNo": 1, "pageSize": 5, "filter": {"op": "eq", "field": "id", "value": "a"}}
|
|
72
|
+
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as fh:
|
|
73
|
+
json.dump(payload, fh)
|
|
74
|
+
path = fh.name
|
|
75
|
+
env = {"DF_ENDPOINT": "https://example.test", "DF_AK": "ak", "DF_SK": "sk" * 8}
|
|
76
|
+
with patch.dict("os.environ", env, clear=False), patch("sys.stdout", new_callable=StringIO) as out:
|
|
77
|
+
code = main(["--dry-run", "query", "--dataset", "ds_1", "--body-file", path])
|
|
78
|
+
self.assertEqual(code, 0)
|
|
79
|
+
body = json.loads(json.loads(out.getvalue())["body"])
|
|
80
|
+
self.assertEqual(body["fields"], ["id"])
|
|
81
|
+
self.assertEqual(body["pageSize"], 5)
|
|
82
|
+
with patch.dict("os.environ", env, clear=False):
|
|
83
|
+
with self.assertRaises(SystemExit):
|
|
84
|
+
main(["--dry-run", "query", "--dataset", "ds_1", "--body-file", path, "--filter", "{}"])
|
|
85
|
+
|
|
86
|
+
def test_docs_pull_dry_run_does_not_need_network(self):
|
|
87
|
+
env = {"DF_AK": "DFAK_example"}
|
|
88
|
+
with patch.dict("os.environ", env, clear=False), patch("sys.stdout", new_callable=StringIO) as out:
|
|
89
|
+
code = main(["--dry-run", "docs", "pull"])
|
|
90
|
+
self.assertEqual(code, 0)
|
|
91
|
+
printed = json.loads(out.getvalue())
|
|
92
|
+
self.assertTrue(printed["dryRun"])
|
|
93
|
+
self.assertIn("/docs/credentials/DFAK_example?format=rev", printed["url"])
|
|
94
|
+
self.assertNotIn("sk", printed)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
unittest.main()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import tempfile
|
|
3
|
+
import unittest
|
|
4
|
+
from io import StringIO
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
from tinet_data_foundry_cli.docs import _describe, _search, summarize
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DocsSummaryTest(unittest.TestCase):
|
|
12
|
+
def test_search_and_describe_only_print_authorized_dataset_content(self):
|
|
13
|
+
with tempfile.TemporaryDirectory() as tmp, patch("sys.stdout", new_callable=StringIO) as out:
|
|
14
|
+
folder = Path(tmp)
|
|
15
|
+
(folder / "public.json").write_text(json.dumps({"datasets": [{
|
|
16
|
+
"id": "ds_bill", "name": "monthly_bill", "chineseName": "月账单",
|
|
17
|
+
"description": "京东云消费", "columns": [
|
|
18
|
+
{"name": "amount", "type": "decimal", "comment": "金额"},
|
|
19
|
+
{"name": "secret", "type": "text", "comment": "无权限"},
|
|
20
|
+
],
|
|
21
|
+
}]}), encoding="utf-8")
|
|
22
|
+
(folder / "policy.json").write_text(json.dumps({"datasets": [{
|
|
23
|
+
"id": "ds_bill", "tableGranted": True,
|
|
24
|
+
"columns": [{"name": "amount", "granted": True, "outputRule": "PLAIN"}],
|
|
25
|
+
}]}), encoding="utf-8")
|
|
26
|
+
self.assertEqual(_search(folder, "京东云", False), 0)
|
|
27
|
+
search = json.loads(out.getvalue())
|
|
28
|
+
self.assertEqual(search["datasets"][0]["id"], "ds_bill")
|
|
29
|
+
out.seek(0)
|
|
30
|
+
out.truncate(0)
|
|
31
|
+
self.assertEqual(_describe(folder, "ds_bill"), 0)
|
|
32
|
+
described = json.loads(out.getvalue())
|
|
33
|
+
self.assertEqual([col["name"] for col in described["columns"]], ["amount"])
|
|
34
|
+
|
|
35
|
+
def test_summarize_catalog_and_policy(self):
|
|
36
|
+
old_public = {"datasets": [{"id": "a", "schemaVersion": "1"}, {"id": "b", "schemaVersion": "1"}]}
|
|
37
|
+
new_public = {"datasets": [{"id": "b", "schemaVersion": "2"}, {"id": "c", "schemaVersion": "1"}]}
|
|
38
|
+
old_policy = {"datasets": [{"id": "a", "tableGranted": True}, {"id": "b", "tableGranted": False}]}
|
|
39
|
+
new_policy = {"datasets": [{"id": "b", "tableGranted": True}, {"id": "c", "tableGranted": False}]}
|
|
40
|
+
lines = summarize(old_public, new_public, old_policy, new_policy)
|
|
41
|
+
self.assertIn("新增数据集 c", lines)
|
|
42
|
+
self.assertIn("下线数据集 a", lines)
|
|
43
|
+
self.assertIn("schemaVersion 变化 b", lines)
|
|
44
|
+
self.assertIn("表权限变化 b 有", lines)
|
|
45
|
+
|
|
46
|
+
def test_pull_fetches_only_policy_when_catalog_unchanged(self):
|
|
47
|
+
from tinet_data_foundry_cli import docs as docs_mod
|
|
48
|
+
|
|
49
|
+
rev = {"copy": "c", "catalog": "k", "policy": "p2", "etag": "c.k.p2"}
|
|
50
|
+
calls = []
|
|
51
|
+
|
|
52
|
+
def fake_get(url, etag, timeout, insecure):
|
|
53
|
+
calls.append(url)
|
|
54
|
+
if "format=rev" in url:
|
|
55
|
+
return 200, rev
|
|
56
|
+
if "part=policy" in url:
|
|
57
|
+
return 200, {"credentialType": "PERSONAL", "datasets": [{"id": "a", "tableGranted": True, "columns": []}]}
|
|
58
|
+
raise AssertionError(url)
|
|
59
|
+
|
|
60
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
61
|
+
folder = Path(tmp)
|
|
62
|
+
(folder / "rev.json").write_text(json.dumps({
|
|
63
|
+
"copy": "c", "catalog": "k", "policy": "p1", "etag": "c.k.p1",
|
|
64
|
+
}), encoding="utf-8")
|
|
65
|
+
(folder / "public.json").write_text(json.dumps({"datasets": [{"id": "a"}]}), encoding="utf-8")
|
|
66
|
+
with patch.object(docs_mod, "_get_json", fake_get):
|
|
67
|
+
code = docs_mod._pull(folder, "https://docs.example", "DFAK_x", 5, False)
|
|
68
|
+
self.assertEqual(code, 0)
|
|
69
|
+
self.assertTrue(any("part=policy" in url for url in calls))
|
|
70
|
+
self.assertFalse(any("part=public" in url for url in calls))
|
|
71
|
+
saved = json.loads((folder / "rev.json").read_text(encoding="utf-8"))
|
|
72
|
+
self.assertEqual(saved["policy"], "p2")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
unittest.main()
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
# tinet-data-foundry-cli
|
|
2
|
-
|
|
3
|
-
Tinet Data Foundry 取数 CLI。包名 `tinet-data-foundry-cli`,命令 **`tdf`**(避开系统磁盘命令 `df`)。
|
|
4
|
-
|
|
5
|
-
鉴权只用环境变量,**不要**把 AccessKey / SecretKey 写入本地文件。需要 Python **≥ 3.10**。
|
|
6
|
-
|
|
7
|
-
macOS / Linux 用 Terminal(bash / zsh)。Windows 用 **cmd.exe** 跑下面示例(不要用 PowerShell 传 JSON:`export` 不可用,引号也会被剥掉)。
|
|
8
|
-
|
|
9
|
-
## macOS / Linux
|
|
10
|
-
|
|
11
|
-
```bash
|
|
12
|
-
pip install -U tinet-data-foundry-cli
|
|
13
|
-
|
|
14
|
-
export DF_ENDPOINT="https://data-foundry-data-api.tinetcloud.com"
|
|
15
|
-
export DF_AK="你的_AccessKey"
|
|
16
|
-
export DF_SK="你的_SecretKey"
|
|
17
|
-
|
|
18
|
-
tdf catalog
|
|
19
|
-
tdf schema --dataset "DATASET_ID"
|
|
20
|
-
tdf query \
|
|
21
|
-
--dataset "DATASET_ID" \
|
|
22
|
-
--fields "id" \
|
|
23
|
-
--filter '{"op":"eq","field":"id","value":"sample"}' \
|
|
24
|
-
--sort '[{"field":"id","direction":"DESC"}]' \
|
|
25
|
-
--page-no 1 \
|
|
26
|
-
--page-size 10
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
若 pip 提示 scripts 目录不在 PATH,把该目录加入 PATH,或用 `python3 -m tinet_data_foundry_cli`。
|
|
30
|
-
|
|
31
|
-
## Windows(cmd.exe)
|
|
32
|
-
|
|
33
|
-
```bat
|
|
34
|
-
pip install -U tinet-data-foundry-cli
|
|
35
|
-
|
|
36
|
-
set DF_ENDPOINT=https://data-foundry-data-api.tinetcloud.com
|
|
37
|
-
set DF_AK=你的_AccessKey
|
|
38
|
-
set DF_SK=你的_SecretKey
|
|
39
|
-
|
|
40
|
-
tdf catalog
|
|
41
|
-
tdf schema --dataset "DATASET_ID"
|
|
42
|
-
tdf query --dataset "DATASET_ID" --fields "id" --filter "{\"op\":\"eq\",\"field\":\"id\",\"value\":\"sample\"}" --sort "[{\"field\":\"id\",\"direction\":\"DESC\"}]" --page-no 1 --page-size 10
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
环境变量只对当前 cmd 窗口有效。JSON 参数用 `\"` 转义,整条命令写在一行。
|
|
46
|
-
|
|
47
|
-
公司代理 / 自签证书导致 `CERTIFICATE_VERIFY_FAILED` 时:
|
|
48
|
-
|
|
49
|
-
```bash
|
|
50
|
-
tdf --insecure catalog
|
|
51
|
-
tdf catalog --insecure
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
| 命令 | 接口 |
|
|
55
|
-
| --- | --- |
|
|
56
|
-
| `tdf catalog` | `GET /data-api/v1/catalog` |
|
|
57
|
-
| `tdf query` | `POST /data-api/v1/datasets/{id}/query` |
|
|
58
|
-
| `tdf schema` | `GET /data-api/v1/datasets/{id}/schema` |
|
|
59
|
-
|
|
60
|
-
签名:`canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + hex(SHA256(body))`,`X-DF-Signature = Base64(HMAC-SHA256(canonical, SK))`。GET 的 body 为空字节。PATH 不含域名、不含 query。
|
|
61
|
-
|
|
62
|
-
## 发布到 PyPI
|
|
63
|
-
|
|
64
|
-
版本号改 `pyproject.toml` 与 `src/tinet_data_foundry_cli/__init__.py`。
|
|
65
|
-
|
|
66
|
-
```bash
|
|
67
|
-
python -m pip install -U build twine
|
|
68
|
-
python -m build
|
|
69
|
-
python -m twine check dist/*
|
|
70
|
-
python -m twine upload --repository testpypi dist/*
|
|
71
|
-
python -m twine upload dist/*
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
已发布版本号不可覆盖。
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.3"
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import unittest
|
|
2
|
-
from unittest.mock import patch
|
|
3
|
-
|
|
4
|
-
from tinet_data_foundry_cli.cli import build_parser, main
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class CliParserTest(unittest.TestCase):
|
|
8
|
-
def test_subcommands(self):
|
|
9
|
-
parser = build_parser()
|
|
10
|
-
catalog = parser.parse_args(["catalog", "--format", "json"])
|
|
11
|
-
self.assertEqual(catalog.cmd, "catalog")
|
|
12
|
-
query = parser.parse_args(["query", "--dataset", "ds_1", "--page-no", "1", "--page-size", "10"])
|
|
13
|
-
self.assertEqual(query.cmd, "query")
|
|
14
|
-
catalog_k = parser.parse_args(["catalog", "--insecure"])
|
|
15
|
-
self.assertTrue(catalog_k.insecure)
|
|
16
|
-
catalog_before = parser.parse_args(["--insecure", "catalog"])
|
|
17
|
-
self.assertTrue(catalog_before.insecure)
|
|
18
|
-
|
|
19
|
-
def test_dry_run_catalog_does_not_need_network(self):
|
|
20
|
-
with patch.dict("os.environ", {"DF_AK": "ak", "DF_SK": "sk" * 8}, clear=False):
|
|
21
|
-
code = main(["--dry-run", "catalog"])
|
|
22
|
-
self.assertEqual(code, 0)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if __name__ == "__main__":
|
|
26
|
-
unittest.main()
|
|
File without changes
|
{tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/src/tinet_data_foundry_cli/__main__.py
RENAMED
|
File without changes
|
{tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/src/tinet_data_foundry_cli/auth.py
RENAMED
|
File without changes
|
{tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/src/tinet_data_foundry_cli/client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|