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.
Files changed (21) hide show
  1. {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/PKG-INFO +39 -4
  2. tinet_data_foundry_cli-0.1.6/README.md +109 -0
  3. {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/pyproject.toml +2 -2
  4. tinet_data_foundry_cli-0.1.6/src/tinet_data_foundry_cli/__init__.py +1 -0
  5. {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/src/tinet_data_foundry_cli/cli.py +90 -18
  6. tinet_data_foundry_cli-0.1.6/src/tinet_data_foundry_cli/docs.py +290 -0
  7. {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
  8. {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
  9. tinet_data_foundry_cli-0.1.6/tests/test_cli.py +98 -0
  10. tinet_data_foundry_cli-0.1.6/tests/test_docs.py +76 -0
  11. tinet_data_foundry_cli-0.1.3/README.md +0 -74
  12. tinet_data_foundry_cli-0.1.3/src/tinet_data_foundry_cli/__init__.py +0 -1
  13. tinet_data_foundry_cli-0.1.3/tests/test_cli.py +0 -26
  14. {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/setup.cfg +0 -0
  15. {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/src/tinet_data_foundry_cli/__main__.py +0 -0
  16. {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/src/tinet_data_foundry_cli/auth.py +0 -0
  17. {tinet_data_foundry_cli-0.1.3 → tinet_data_foundry_cli-0.1.6}/src/tinet_data_foundry_cli/client.py +0 -0
  18. {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
  19. {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
  20. {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
  21. {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.3
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.3"
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"
@@ -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
- if args.dry_run or args.format == "json":
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
- payload: dict[str, Any] = {
120
- "pageNo": args.page_no,
121
- "pageSize": args.page_size,
122
- }
123
- if args.fields:
124
- payload["fields"] = [p.strip() for p in args.fields.split(",") if p.strip()]
125
- filt = _parse_json(args.filter, "--filter")
126
- if filt is not None:
127
- payload["filter"] = filt
128
- sort = _parse_json(args.sort, "--sort")
129
- if sort is not None:
130
- payload["sort"] = sort
131
- visitor = _parse_json(args.visitor, "--visitor")
132
- if visitor is not None:
133
- payload["visitor"] = visitor
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("--filter", help="filter AST JSON")
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("--visitor", help="应用凭证 visitor JSON")
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.3
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()