small-toolkit 0.1.1__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.
@@ -0,0 +1,62 @@
1
+ """small_toolkit:纯标准库的通用小工具(产物目录 / 日志 / 命令行 / 终端表格)。
2
+
3
+ 分区::mod:`small_toolkit.outputs` 路径与落盘、:mod:`small_toolkit.logs` 日志初始化、
4
+ :mod:`small_toolkit.cli` 命令行参数、:mod:`small_toolkit.table` 终端表格。全部零第三方依赖、
5
+ 不读凭据、不含内网地址。
6
+ """
7
+
8
+ from .cli import (
9
+ add_grouped_subparsers,
10
+ add_parameter,
11
+ resolve_env_var,
12
+ resolve_parameters,
13
+ )
14
+ from .logs import (
15
+ DEBUG_HELP,
16
+ add_debug_argument,
17
+ enable_debug_logging,
18
+ set_log_env,
19
+ setup_job,
20
+ )
21
+ from .outputs import (
22
+ WORKSPACE_MARKERS,
23
+ display_path,
24
+ env_suffix,
25
+ get_output_dir,
26
+ output_dir,
27
+ output_root,
28
+ save_bytes,
29
+ save_json,
30
+ save_response,
31
+ tool_dir_name,
32
+ tool_output_dir,
33
+ write_json,
34
+ )
35
+ from .table import print_grouped_table, print_table, print_tree
36
+
37
+ __all__ = [
38
+ "DEBUG_HELP",
39
+ "WORKSPACE_MARKERS",
40
+ "add_debug_argument",
41
+ "add_grouped_subparsers",
42
+ "add_parameter",
43
+ "display_path",
44
+ "enable_debug_logging",
45
+ "env_suffix",
46
+ "get_output_dir",
47
+ "output_dir",
48
+ "output_root",
49
+ "print_grouped_table",
50
+ "print_table",
51
+ "print_tree",
52
+ "resolve_env_var",
53
+ "resolve_parameters",
54
+ "save_bytes",
55
+ "save_json",
56
+ "save_response",
57
+ "set_log_env",
58
+ "setup_job",
59
+ "tool_dir_name",
60
+ "tool_output_dir",
61
+ "write_json",
62
+ ]
small_toolkit/cli.py ADDED
@@ -0,0 +1,146 @@
1
+ """命令行辅助:分组子命令帮助、CLI / 占位符 / 环境变量 / 默认值的参数回退。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import ast
7
+ import os
8
+ import re
9
+ from collections.abc import Mapping, Sequence
10
+ from typing import Any
11
+
12
+ #: 平台代码参数占位符(形如 ``${name}``):解析时视为未设置
13
+ _PLACEHOLDER = re.compile(r"^\$\{[^{}]+\}$")
14
+
15
+ CommandGroups = Mapping[str, Sequence[tuple[str, str]]]
16
+
17
+
18
+ def add_grouped_subparsers(
19
+ parser: argparse.ArgumentParser,
20
+ groups: CommandGroups,
21
+ *,
22
+ dest: str = "command",
23
+ footer: str | None = None,
24
+ ) -> dict[str, argparse.ArgumentParser]:
25
+ """注册按领域分组的子命令,并将统一命令清单加入帮助描述。"""
26
+ commands = [(name, help_text) for items in groups.values() for name, help_text in items]
27
+ if not commands:
28
+ raise ValueError("命令分组不能为空")
29
+
30
+ width = max(len(name) for name, _ in commands)
31
+ lines = []
32
+ for title, items in groups.items():
33
+ if lines:
34
+ lines.append("")
35
+ lines.append(f"{title}:")
36
+ lines.extend(f" {name:<{width}} {help_text}" for name, help_text in items)
37
+ if footer:
38
+ lines.extend(("", footer))
39
+
40
+ description = parser.description or ""
41
+ parser.description = "\n\n".join(part for part in (description, "\n".join(lines)) if part)
42
+ parser.formatter_class = argparse.RawDescriptionHelpFormatter
43
+
44
+ subparsers = parser.add_subparsers(dest=dest, metavar="命令")
45
+ result = {}
46
+ for name, _ in commands:
47
+ sub = subparsers.add_parser(name)
48
+ # 缺必选参数时显示帮助而非 argparse 默认的 usage+error
49
+ sub.error = lambda msg, self=sub: (self.print_help(), self.exit(2))
50
+ result[name] = sub
51
+ return result
52
+
53
+
54
+ def add_parameter(
55
+ parser: argparse.ArgumentParser,
56
+ option: str,
57
+ *aliases: str,
58
+ default: Any = None,
59
+ parameter: Any = None,
60
+ env: str | None = None,
61
+ **kwargs: Any,
62
+ ) -> None:
63
+ """添加本地 CLI 参数,并登记平台代码参数占位符与环境变量回退。
64
+
65
+ ``parameter`` 为平台注入的代码参数值,``env`` 为环境变量名;取值优先级由
66
+ :func:`resolve_parameters` 统一处理(CLI → ``parameter`` → 环境变量 → ``default``)。
67
+ """
68
+ if not option.startswith("--"):
69
+ raise ValueError("参数必须使用长选项,例如 --database")
70
+
71
+ name = option[2:].replace("-", "_")
72
+ # 属性名沿用既有工具链(UI 扫描器按此名读取参数规格),勿改名
73
+ specs = getattr(parser, "_dataphin_parameter_specs", {})
74
+ if name in specs:
75
+ raise ValueError(f"重复的参数配置: {option}")
76
+
77
+ parser.add_argument(option, *aliases, default=None, **kwargs)
78
+ specs[name] = {"default": default, "parameter": parameter, "env": env, "type": kwargs.get("type")}
79
+ parser._dataphin_parameter_specs = specs
80
+ parser.set_defaults(_dataphin_parameter_specs=specs)
81
+
82
+
83
+ def resolve_parameters(args: argparse.Namespace) -> argparse.Namespace:
84
+ """按 CLI、平台代码参数、环境变量、默认值的顺序填充参数。"""
85
+ for name, spec in getattr(args, "_dataphin_parameter_specs", {}).items():
86
+ value = getattr(args, name)
87
+ if _is_unset(value):
88
+ value = _parameter_value(spec["parameter"])
89
+ if _is_unset(value) and spec["env"]:
90
+ value = os.getenv(spec["env"])
91
+ if _is_unset(value):
92
+ value = spec["default"]
93
+ elif spec["type"] and isinstance(value, str):
94
+ value = spec["type"](value)
95
+ setattr(args, name, value)
96
+ return args
97
+
98
+
99
+ def _is_unset(value: Any) -> bool:
100
+ return value is None or value == "" or (isinstance(value, str) and _PLACEHOLDER.fullmatch(value))
101
+
102
+
103
+ def _parameter_value(value: Any) -> Any:
104
+ if not isinstance(value, str) or _is_unset(value):
105
+ return value
106
+ if len(value) >= 2 and value[0] in "'\"" and value[-1] == value[0]:
107
+ try:
108
+ return ast.literal_eval(value)
109
+ except (SyntaxError, ValueError):
110
+ pass
111
+ return value
112
+
113
+
114
+ def resolve_env_var(env: str, base_name: str) -> str | None:
115
+ """按优先级解析多环境变量:
116
+
117
+ 1. ``{base_name}_LIST`` — 分号分隔的 ``env=value`` 格式(最高优先级)
118
+ 2. ``{base_name}_{ENV}`` — 单环境变量
119
+ 3. ``{base_name}`` — 仅 ``env=prod`` 时回退到原生变量名
120
+
121
+ 任一来源有值(含空字符串)即返回,全缺失返回 ``None``。非 prod 环境不会读原生变量,
122
+ 避免旧单环境凭据被误当作测试/开发环境配置。
123
+
124
+ 例(值由外部注入,本库不读写凭据)::
125
+
126
+ # _LIST = "tst=doris-tst:8030;prod=doris-prod:8030"
127
+ resolve_env_var("tst", "DW_doris_RW_url") # -> "doris-tst:8030"
128
+ """
129
+ # 1. LIST 格式
130
+ list_raw = os.environ.get(f"{base_name}_LIST", "").strip()
131
+ if list_raw:
132
+ for part in list_raw.split(";"):
133
+ part = part.strip()
134
+ if "=" in part:
135
+ k, v = part.split("=", 1)
136
+ if k.strip() == env:
137
+ return v.strip()
138
+ # 2. 单环境变量 {KEY}_{ENV}
139
+ env_val = os.environ.get(f"{base_name}_{env.upper()}", "").strip()
140
+ if env_val:
141
+ return env_val
142
+ # 3. 回退原生变量(仅 prod 环境)
143
+ if env.lower() == "prod":
144
+ fallback = os.environ.get(base_name, "").strip()
145
+ return fallback or None
146
+ return None
small_toolkit/logs.py ADDED
@@ -0,0 +1,223 @@
1
+ """日志初始化:控制台着色、按环境隔离的文件日志与 ``--debug`` 开关。
2
+
3
+ 默认只输出到控制台;``setup_job(..., log_file=True)`` 额外落盘到
4
+ ``<产物根>/output/logs/``。控制台着色只在真终端生效——管道、重定向、子进程捕获
5
+ 都不是真终端,一律纯文本;落盘日志始终纯文本。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import logging
12
+ import os
13
+ import re
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+
17
+ from .outputs import env_suffix, get_output_dir
18
+
19
+ #: ``--debug`` 的统一帮助文案(口径:输出调用摘要、不输出完整请求/响应正文)
20
+ DEBUG_HELP = "启用 DEBUG 诊断日志(输出调用摘要,不输出完整请求/响应正文)"
21
+
22
+ #: ANSI 控制序列:消息正文按级别 / 角色着色(时间戳与级别名前缀保持终端默认色)
23
+ _RESET = "\033[0m"
24
+
25
+ #: 日志级别 -> 消息正文颜色(INFO 留空 = 终端默认色,不加控制序列)
26
+ _LEVEL_COLORS = {
27
+ "DEBUG": "\033[36m",
28
+ "INFO": "",
29
+ "WARNING": "\033[33m",
30
+ "ERROR": "\033[31m",
31
+ "CRITICAL": "\033[31m",
32
+ }
33
+
34
+ #: INFO 行首角色标记 -> 整行颜色(允许缩进,判定前先 lstrip)。
35
+ #: ``▸`` 任务行保持默认色,故不登记。
36
+ _ROLE_COLORS = {
37
+ "━━": "\033[1m\033[36m", # 阶段标题:粗体青
38
+ "≠": "\033[33m", # 差异:黄
39
+ "⚠": "\033[1m\033[33m", # 告警:粗体黄
40
+ "✗": "\033[1m\033[31m", # 失败:粗体红
41
+ "·": "\033[2m", # 次级信息:暗淡
42
+ }
43
+
44
+ #: 行首「两空格 + 块内标签」只给标签着青色
45
+ _FIELD_LABELS = ("输入", "查询", "映射", "分组", "产物", "任务名模板", "管道目标", "结果")
46
+ _FIELD_LINE_RE = re.compile(
47
+ "( )(" + "|".join(sorted(_FIELD_LABELS, key=len, reverse=True)) + r")(\s)"
48
+ )
49
+
50
+
51
+ def _role_color(message: str) -> str:
52
+ """消息行首角色标记对应的整行颜色;无标记返回空串(保持终端默认色)。"""
53
+ stripped = message.lstrip()
54
+ for marker, color in _ROLE_COLORS.items():
55
+ if stripped.startswith(marker):
56
+ return color
57
+ return ""
58
+
59
+
60
+ def _message_offset(text: str, message: str) -> int | None:
61
+ """消息在整行渲染结果里的起始位置(默认格式 = 前缀 + 消息后缀)。"""
62
+ if not message or not text.endswith(message):
63
+ return None
64
+ return len(text) - len(message)
65
+
66
+
67
+ def _color_enabled(stream) -> bool:
68
+ """控制台是否着色:真终端,且未被 ``NO_COLOR`` / ``TERM=dumb`` 关闭。
69
+
70
+ 管道、重定向、子进程捕获、平台调度都不是真终端 → 一律纯文本。
71
+ ``NO_COLOR`` 按 no-color.org 约定:设了非空值即关闭。
72
+ """
73
+ if os.environ.get("NO_COLOR"):
74
+ return False
75
+ if os.environ.get("TERM", "").lower() == "dumb":
76
+ return False
77
+ isatty = getattr(stream, "isatty", None)
78
+ return bool(isatty and isatty())
79
+
80
+
81
+ class _ColorFormatter(logging.Formatter):
82
+ """给控制台的**消息正文**着色的 Formatter(时间戳 / 级别名前缀保持终端默认色)。
83
+
84
+ 角色标记(``━━`` / ``≠`` / ``⚠`` / ``✗`` / ``·``)优先于级别色——``⚠`` / ``✗``
85
+ 用角色表里的粗体色,其余按级别(WARNING 黄 / ERROR 红 / DEBUG 青);INFO 无角色
86
+ 标记时再看「两空格 + 块内标签」(如 ``输入`` / ``任务名模板``),只给标签着青色。
87
+
88
+ 落盘日志不挂本 Formatter(文件 handler 用默认 Formatter),文件里始终是纯文本。
89
+ """
90
+
91
+ def format(self, record: logging.LogRecord) -> str:
92
+ message = record.getMessage()
93
+ text = super().format(record)
94
+ offset = _message_offset(text, message)
95
+ if offset is None:
96
+ return text
97
+ color = _role_color(message) or _LEVEL_COLORS.get(record.levelname, "")
98
+ if color:
99
+ return f"{text[:offset]}{color}{text[offset:]}{_RESET}"
100
+ match = _FIELD_LINE_RE.match(message)
101
+ if match:
102
+ return (
103
+ f"{text[: offset + match.start(2)]}\033[36m{match.group(2)}\033[0m"
104
+ f"{text[offset + match.end(2):]}"
105
+ )
106
+ return text
107
+
108
+
109
+ class _LazyDirFileHandler(logging.FileHandler):
110
+ """延迟建目录的 FileHandler:首次 emit 才 ``mkdir``,import 期不产生空目录。"""
111
+
112
+ def __init__(self, filename: str | Path, encoding: str = "utf-8") -> None:
113
+ super().__init__(filename, encoding=encoding, delay=True)
114
+ self._dir_ready = False
115
+
116
+ def emit(self, record: logging.LogRecord) -> None:
117
+ if not self._dir_ready:
118
+ Path(self.baseFilename).parent.mkdir(parents=True, exist_ok=True)
119
+ self._dir_ready = True
120
+ super().emit(record)
121
+
122
+
123
+ def setup_job(
124
+ script_path: str | Path,
125
+ *,
126
+ log_file: bool | str | Path = False,
127
+ level: int = logging.INFO,
128
+ format: str = "%(asctime)s - %(levelname)s - %(message)s",
129
+ datefmt: str = "%m-%d %H:%M:%S",
130
+ ) -> logging.Logger:
131
+ """初始化脚本运行环境:配置 logging。
132
+
133
+ - 默认配置 StreamHandler(控制台输出)。
134
+ - 控制台是真终端时按级别 / 角色着色(只给消息正文上色);管道、重定向、子进程
135
+ 捕获下自动关闭,落盘文件始终是纯文本。
136
+ - ``log_file=True`` 时自动在 ``<产物根>/output/logs/`` 下创建 ``<脚本名>_<YYYYMMDD>.log``
137
+ 文件处理器(非 tst 环境由主流程解析参数后调 :func:`set_log_env` 改名)。
138
+ - ``log_file`` 为自定义字符串或 Path 时,创建对应文件处理器(相对路径基于 ``output/logs/``)。
139
+ - 目录在**首次落日志时**才创建(import 期调用不产生空目录)。
140
+ - 返回对应模块的 Logger。
141
+ """
142
+ console = logging.StreamHandler()
143
+ handlers: list[logging.Handler] = [console]
144
+ if log_file:
145
+ if isinstance(log_file, bool):
146
+ filename = f"{Path(script_path).stem}_{datetime.now():%Y%m%d}.log"
147
+ log_path = get_output_dir(script_path, "logs", create=False) / filename
148
+ else:
149
+ log_path = Path(log_file)
150
+ if not log_path.is_absolute():
151
+ log_path = get_output_dir(script_path, "logs", create=False) / log_path
152
+ handlers.append(_LazyDirFileHandler(log_path))
153
+
154
+ logging.basicConfig(level=level, format=format, datefmt=datefmt, handlers=handlers, force=True)
155
+
156
+ # 颜色只给控制台:文件日志保持 basicConfig 挂的默认 Formatter(纯文本)
157
+ if _color_enabled(console.stream):
158
+ console.setFormatter(_ColorFormatter(format, datefmt))
159
+ return logging.getLogger(Path(script_path).stem)
160
+
161
+
162
+ #: setup_job 自动日志文件名(``<脚本名>_<YYYYMMDD>.log``)的识别模式
163
+ _AUTO_LOG_NAME_RE = re.compile(r"(.+)_(\d{8})\.log$")
164
+
165
+
166
+ def set_log_env(env: str | None) -> Path | None:
167
+ """把 ``setup_job(log_file=True)`` 自动创建的文件日志按环境改名:非 tst 重写为
168
+ ``<脚本名>@<env 小写>_<YYYYMMDD>.log``(如 ``intake_tool@prod_20261031.log``)。
169
+
170
+ 在脚本 ``main()`` 解析出 ``-e/--env`` 后调用一次;tst(含未传,大小写不敏感)不动,
171
+ 保持 ``<脚本名>_<YYYYMMDD>.log``。只处理自动命名的 FileHandler;自定义
172
+ ``log_file=...`` 的不动。env 归一与产物目录同一套(:func:`env_suffix`)。
173
+ 调用前已写出的行留在原名文件里(handler 是 ``delay=True``,通常调用时文件尚未创建)。
174
+ 返回新日志路径;无需改名或没有自动命名 FileHandler 时返回 ``None``。
175
+ """
176
+ suffix = env_suffix(env)
177
+ if not suffix:
178
+ return None
179
+ root = logging.getLogger()
180
+ new_path: Path | None = None
181
+ for handler in root.handlers[:]:
182
+ if not isinstance(handler, logging.FileHandler):
183
+ continue
184
+ old_path = Path(handler.baseFilename)
185
+ match = _AUTO_LOG_NAME_RE.fullmatch(old_path.name)
186
+ if not match:
187
+ continue
188
+ # 先剥离可能已有的 @env 后缀,保证连续调用幂等(不叠成 @prod@uat@prod)
189
+ base = match.group(1).split("@", 1)[0]
190
+ candidate = old_path.with_name(f"{base}{suffix}_{match.group(2)}.log")
191
+ if candidate == old_path:
192
+ continue
193
+ replacement = _LazyDirFileHandler(candidate)
194
+ replacement.setFormatter(handler.formatter)
195
+ replacement.setLevel(handler.level)
196
+ root.removeHandler(handler)
197
+ handler.close()
198
+ root.addHandler(replacement)
199
+ new_path = candidate
200
+ return new_path
201
+
202
+
203
+ def add_debug_argument(parser: argparse.ArgumentParser, *, help: str = DEBUG_HELP) -> None:
204
+ """注册 ``--debug``:输出诊断日志与调用摘要(完整请求/响应正文仍不落日志)。
205
+
206
+ 默认 ``argparse.SUPPRESS``,因此带子命令的入口可在顶层与子命令各注册一次,
207
+ 使 ``--debug`` 放在子命令前或后都能生效;判断统一交给 :func:`enable_debug_logging`。
208
+ """
209
+ parser.add_argument("--debug", action="store_true", default=argparse.SUPPRESS, help=help)
210
+
211
+
212
+ def enable_debug_logging(args: argparse.Namespace, *, level: int = logging.DEBUG) -> None:
213
+ """``--debug`` 生效:root 与既有 handler 一并切到 ``level``;未开启时不动任何级别。
214
+
215
+ :func:`setup_job` 用 ``basicConfig(handlers=[...], force=True)`` 建好 handler,
216
+ 只改 root 级别不足以放行 DEBUG,故两层都改。
217
+ """
218
+ if not getattr(args, "debug", False):
219
+ return
220
+ root = logging.getLogger()
221
+ root.setLevel(level)
222
+ for handler in root.handlers:
223
+ handler.setLevel(level)
@@ -0,0 +1,185 @@
1
+ """产物目录与落盘:路径拼装、环境后缀、JSON 与原始字节写入。
2
+
3
+ 产物根默认是运行目录;从运行目录向上命中便携工作区标记(:data:`WORKSPACE_MARKERS`)
4
+ 时归到该目录,便于在工作区任意子目录启动脚本。所有落盘路径都经 :func:`output_dir`
5
+ 拼装,不在各脚本里手写 ``Path("output")``。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+
15
+ #: 便携工作区标记:从运行目录向上命中任一个即把产物根切到该目录所在层级。
16
+ #: ``.workspace-root`` 为通用标记;``.dataphin.jsonc`` 保留以兼容既有工作区。
17
+ WORKSPACE_MARKERS = (".workspace-root", ".dataphin.jsonc")
18
+
19
+
20
+ def output_root(start: str | Path | None = None) -> Path:
21
+ """产物根:默认运行目录(cwd);cwd 起向上命中便携标记时用它所在目录。
22
+
23
+ 便携工作区(根目录放 ``.workspace-root`` / ``.dataphin.jsonc``)里无论从哪一层
24
+ 子目录启动,产物都归到工作区根;普通目录仍是 cwd 本身(产物归到运行位置)。
25
+ """
26
+ base = Path(start) if start is not None else Path.cwd()
27
+ base = base.resolve()
28
+ for candidate in (base, *base.parents):
29
+ if any((candidate / marker).is_file() for marker in WORKSPACE_MARKERS):
30
+ return candidate
31
+ return base
32
+
33
+
34
+ def output_dir(*segments: str | Path, create: bool = False) -> Path:
35
+ """产物根下的 ``output/<segments...>``;``create=True`` 时确保目录存在。
36
+
37
+ 所有落盘路径的唯一拼装入点:分类、工具名、产物类型都作为片段传入。
38
+ """
39
+ directory = output_root() / "output"
40
+ for segment in segments:
41
+ if segment:
42
+ directory = directory / segment
43
+ if create:
44
+ directory.mkdir(parents=True, exist_ok=True)
45
+ return directory
46
+
47
+
48
+ def get_output_dir(
49
+ script_path: str | Path | None, *subdirs: str | Path, create: bool = True
50
+ ) -> Path:
51
+ """获取工具产物目录:``<产物根>/output/<subdirs...>``,默认确保目录存在。
52
+
53
+ ``script_path`` 仅作调用方自述(日志定位用),不参与路径计算;产物根见
54
+ :func:`output_root`,与脚本所在路径无关。
55
+
56
+ - get_output_dir(__file__) -> <root>/output
57
+ - get_output_dir(__file__, "intake_tool", tag) -> <root>/output/intake_tool/<tag>
58
+ - get_output_dir(__file__, create=False) -> 只算路径、不建目录(import 期用)
59
+
60
+ 带分类层与环境后缀的调用优先用 :func:`tool_output_dir`;本函数保留给仓库级
61
+ 构建/辅助产物(``output/_build/...``)。
62
+ """
63
+ return output_dir(*subdirs, create=create)
64
+
65
+
66
+ def tool_output_dir(
67
+ group: str, tool_name: str, *subdirs: str | Path, env: str | None = None, create: bool = False
68
+ ) -> Path:
69
+ """业务工具产物目录:``<产物根>/output/<group>/<tool>[@env]/<subdirs...>``。
70
+
71
+ ``group`` 是业务分类英文 slug,与调用方仓库的分类目录一一对应;
72
+ ``env`` 按 :func:`tool_dir_name` 规则加后缀。
73
+ """
74
+ return get_output_dir(None, group, tool_dir_name(tool_name, env), *subdirs, create=create)
75
+
76
+
77
+ def display_path(path: str | Path) -> str:
78
+ """日志里的文件路径展示:优先相对产物根,其次相对运行目录,都不在则用绝对路径。
79
+
80
+ 不产生 ``..``;输出保留本机分隔符(Windows 为 ``\\``)。所有落盘日志共用这一份规则。
81
+ """
82
+ absolute = Path(path).resolve()
83
+ for base in (output_root(), Path.cwd().resolve()):
84
+ try:
85
+ return str(absolute.relative_to(base))
86
+ except ValueError:
87
+ continue
88
+ return str(absolute)
89
+
90
+
91
+ def env_suffix(env: str | None = None) -> str:
92
+ """环境归一后缀:tst(含未传,大小写不敏感)为空串,其余环境 ``@<env 小写>``。
93
+
94
+ 产物目录(:func:`tool_dir_name`)与日志文件名(:func:`set_log_env`)共用这一套归一,
95
+ 避免出现两种写法;归一**只作用于本地目录/文件命名**,不改动传给外部系统的 env 原值。
96
+ """
97
+ return "" if not env or env.lower() == "tst" else f"@{env.lower()}"
98
+
99
+
100
+ def tool_dir_name(tool_name: str, env: str | None = None) -> str:
101
+ """产物工具目录名:默认环境 tst(含未传,大小写不敏感)不带后缀,其余环境
102
+ ``{tool_name}@<env 小写>``。
103
+
104
+ 默认环境存量路径零变化,其它环境在工具目录名后加 ``@<env 小写>`` 后缀,如
105
+ ``task_tool@prod``、``-e UAT`` → ``task_tool@uat``;显式传给 ``--output-dir``
106
+ 等的完整目录不套用本规则。
107
+ """
108
+ return tool_name + env_suffix(env)
109
+
110
+
111
+ def _dump_json(
112
+ path: str | Path,
113
+ data,
114
+ *,
115
+ indent: int,
116
+ newline: bool,
117
+ stringify: bool,
118
+ ) -> Path:
119
+ """JSON 落盘内核:自动建父目录,UTF-8 写出,可选末尾换行 / 非序列化对象转字符串。"""
120
+ path = Path(path)
121
+ path.parent.mkdir(parents=True, exist_ok=True)
122
+ text = json.dumps(
123
+ data, ensure_ascii=False, indent=indent, default=str if stringify else None
124
+ )
125
+ if newline:
126
+ text += "\n"
127
+ path.write_text(text, encoding="utf-8")
128
+ return path
129
+
130
+
131
+ def save_json(path: str | Path, data, *, indent: int = 2) -> Path:
132
+ """把数据按 UTF-8 JSON 写到 ``path``(自动建父目录),返回落盘路径。
133
+
134
+ 非序列化对象按 ``str()`` 落盘(如 datetime);不追加末尾换行。
135
+ """
136
+ return _dump_json(path, data, indent=indent, newline=False, stringify=True)
137
+
138
+
139
+ def write_json(path: str | Path, data, *, indent: int = 2) -> Path:
140
+ """把对象写成 UTF-8 JSON 文件(``ensure_ascii=False``、末尾换行),返回写入路径。
141
+
142
+ 目录不存在时自动创建。与 :func:`save_json` 的区别仅在末尾换行与是否容错非序列化对象。
143
+ """
144
+ return _dump_json(path, data, indent=indent, newline=True, stringify=False)
145
+
146
+
147
+ def save_bytes(path: str | Path, content: bytes) -> Path:
148
+ """把原始字节写到 ``path``(自动建父目录),返回落盘路径。"""
149
+ path = Path(path)
150
+ path.parent.mkdir(parents=True, exist_ok=True)
151
+ path.write_bytes(content)
152
+ return path
153
+
154
+
155
+ def save_response(
156
+ script_path: str,
157
+ category: str,
158
+ name: str,
159
+ data,
160
+ *,
161
+ output_dir: str | Path | None = None,
162
+ tool_name: str | None = None,
163
+ env: str | None = None,
164
+ group: str | None = None,
165
+ timestamp_fmt: str = "%Y%m%d",
166
+ ) -> Path | None:
167
+ """保存原始响应,默认路径为 ``<产物根>/output/[<分类>/]<tool名称[@{env}]/<子类别>``。
168
+
169
+ ``group`` 为业务分类 slug(见 :func:`tool_output_dir`);不传时保持扁平路径
170
+ ``output/<tool>/<子类别>``(仅兼容仓库级/未分类调用)。``data`` 为空时不落盘。
171
+ """
172
+ if not data:
173
+ return None
174
+ script = Path(script_path).resolve()
175
+ if output_dir is not None:
176
+ directory = Path(output_dir)
177
+ directory.mkdir(parents=True, exist_ok=True)
178
+ elif group:
179
+ directory = tool_output_dir(group, tool_name or script.stem, category, env=env, create=True)
180
+ else:
181
+ directory = get_output_dir(script, tool_dir_name(tool_name or script.stem, env), category)
182
+ path = directory / f"{name}_{datetime.now().strftime(timestamp_fmt)}.json"
183
+ save_json(path, data)
184
+ logging.getLogger(__name__).info(" 响应:%s", display_path(path))
185
+ return path
small_toolkit/table.py ADDED
@@ -0,0 +1,149 @@
1
+ """终端输出工具:按显示宽度对齐的表格与目录树。"""
2
+
3
+ import unicodedata
4
+
5
+
6
+ def _display_width(s: str) -> int:
7
+ """字符串显示宽度(中日韩 = 2,ASCII/box-drawing/emoji = 1)。"""
8
+ return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
9
+
10
+
11
+ def _widths(headers: list[str], values: list[list[str]]) -> list[int]:
12
+ """各列显示宽度:取表头与全部数据行的最大值。"""
13
+ widths = [_display_width(h) for h in headers]
14
+ for row in values:
15
+ for i, v in enumerate(row):
16
+ widths[i] = max(widths[i], _display_width(v))
17
+ return widths
18
+
19
+
20
+ def _fmt_row(row: list[str], widths: list[int]) -> str:
21
+ """按列宽补齐一行(中文算 2 宽,右侧补空格)。"""
22
+ return " | ".join(v + " " * (widths[i] - _display_width(v)) for i, v in enumerate(row))
23
+
24
+
25
+ def print_table(headers: list[str], rows: list[list[object]], *, border: bool = False) -> None:
26
+ """按显示宽度对齐输出表格(中文算2宽,ASCII算1宽)。
27
+
28
+ 版式:标题行下边一条 ``---+---`` 分隔线;``border=True`` 时表格最上与最下各加一条
29
+ 与行等宽的 ``--`` 线(默认不加,调用方按需开启);``==`` 线留给调用方做汇总级分隔。
30
+
31
+ Parameters
32
+ ----------
33
+ headers : list[str]
34
+ 表头。
35
+ rows : list[list[object]]
36
+ 数据行;``None`` 显示为空串。
37
+ border : bool, keyword-only
38
+ 是否加表格最上 / 最下的等宽边线,默认 ``False``(不加)。
39
+ """
40
+ values = [["" if v is None else str(v) for v in row] for row in rows]
41
+ widths = _widths(headers, values)
42
+
43
+ # 边线与 _fmt_row 行等宽:各列宽之和 + 列间 " | "(3 字符 × (列数-1))
44
+ edge = "-" * (sum(widths) + 3 * (len(widths) - 1))
45
+ if border:
46
+ print(edge)
47
+ print(_fmt_row(headers, widths))
48
+ print("-+-".join("-" * w for w in widths))
49
+ for row in values:
50
+ print(_fmt_row(row, widths))
51
+ if border:
52
+ print(edge)
53
+
54
+
55
+ def print_grouped_table(headers: list[str], groups: list[tuple[str, list[list[object]]]]) -> None:
56
+ """多组数据合成**一张表**:单一表头 + 每组一行 ``▸ 组名`` 标记行。
57
+
58
+ ``groups`` 为 ``[(组名, 行列表), ...]``,按传入顺序输出;组名行不占列宽,故表宽与
59
+ :func:`print_table` 同口径(不新增列)。各组数据参与同一次列宽计算,跨组同名列对齐;
60
+ 计划表按目录分组时用它替代「每组一张表」,避免表头重复和组间错位。
61
+ """
62
+ blocks = [[["" if v is None else str(v) for v in row] for row in rows] for _, rows in groups]
63
+ widths = _widths(headers, [row for block in blocks for row in block])
64
+ print(_fmt_row(headers, widths))
65
+ print("-+-".join("-" * w for w in widths))
66
+ for (label, _), block in zip(groups, blocks):
67
+ print(f"▸ {label}")
68
+ for row in block:
69
+ print(_fmt_row(row, widths))
70
+
71
+
72
+ def print_tree(
73
+ items: list[dict],
74
+ headers: list[str],
75
+ *,
76
+ parent_key: str = "Directory",
77
+ name_key: str = "Name",
78
+ container_type: str = "directory",
79
+ type_key: str = "FileType",
80
+ cols: list[str] | None = None,
81
+ ):
82
+ """按父子关系打印目录树,首列带树形缩进,其余列表格对齐。
83
+
84
+ Parameters
85
+ ----------
86
+ items : list[dict]
87
+ 扁平条目列表。
88
+ headers : list[str]
89
+ 表头(首列为名称列)。
90
+ parent_key : str
91
+ 父路径字段名。
92
+ name_key : str
93
+ 名称字段名。
94
+ container_type : str
95
+ 容器类型标记值。
96
+ type_key : str
97
+ 类型字段名。
98
+ cols : list[str] | None
99
+ 各列对应的字段名;不传时取 headers 小写。
100
+ """
101
+ cols = cols or [h.lower() for h in headers]
102
+
103
+ # 构建树(统一去掉尾斜杠,防止 API 返回的 "/视图登记/" 与拼接的 "/视图登记" 不匹配)
104
+ tree: dict[str, list[dict]] = {}
105
+ for f in items:
106
+ parent = f.get(parent_key, "/").rstrip("/") or "/"
107
+ tree.setdefault(parent, []).append(f)
108
+ for parent in tree:
109
+ tree[parent].sort(
110
+ key=lambda f: (
111
+ 0 if f.get(type_key) == container_type else 1,
112
+ (f.get(name_key) or "").lower(),
113
+ )
114
+ )
115
+
116
+ # 收集展平行(首列带缩进)
117
+ flat_rows: list[list[str]] = []
118
+
119
+ def _collect(parent: str, indent: str = ""):
120
+ children = tree.get(parent, [])
121
+ for i, f in enumerate(children):
122
+ is_last = i == len(children) - 1
123
+ name = f.get(name_key, "")
124
+ is_container = f.get(type_key) == container_type
125
+ tag = "📁 " if is_container else "📄 "
126
+ stem = "└── " if is_last else "├── "
127
+ ext = " " if is_last else "│ "
128
+ row = [indent + stem + tag + name]
129
+ for c in cols[1:]:
130
+ row.append(str(f.get(c, "")) if f.get(c) is not None else "")
131
+ flat_rows.append(row)
132
+ if is_container:
133
+ child_path = (parent.rstrip("/") + "/" + name).replace("//", "/")
134
+ _collect(child_path, indent + ext)
135
+
136
+ _collect("/")
137
+
138
+ widths = [_display_width(h) for h in headers]
139
+ for row in flat_rows:
140
+ for i, v in enumerate(row):
141
+ widths[i] = max(widths[i], _display_width(v))
142
+
143
+ def _fmt(row: list[str]) -> str:
144
+ return " | ".join(v + " " * (widths[i] - _display_width(v)) for i, v in enumerate(row))
145
+
146
+ print(_fmt(headers))
147
+ print("-+-".join("-" * w for w in widths))
148
+ for row in flat_rows:
149
+ print(_fmt(row))
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.5
2
+ Name: small_toolkit
3
+ Version: 0.1.1
4
+ Summary: 纯标准库的通用小工具:产物目录、日志初始化、命令行参数与终端表格
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+
8
+ # small_toolkit
9
+
10
+ 纯标准库的通用 Python 小工具集合:产物目录、日志初始化、命令行参数、终端表格。
11
+
12
+ ## 边界
13
+
14
+ - **零第三方依赖**:只用标准库(`pathlib` / `logging` / `argparse` / `unicodedata`)。
15
+ - **无凭据、无内网地址**:不读环境里的密钥,不内置任何内网域名;环境变量只做**名字**层面的回退。
16
+ - 只抽跨项目复用的通用能力,业务逻辑(平台 SDK、数据源客户端、领域规则)留在各自仓库。
17
+
18
+ ## 安装
19
+
20
+ 从 tag 安装(跨仓依赖固定到 tag,不跟浮动分支):
21
+
22
+ ```bash
23
+ uv add "small_toolkit @ git+https://github.com/jalousiex/py-toolkit.git@v0.1.1"
24
+ ```
25
+
26
+ 本地开发:
27
+
28
+ ```bash
29
+ uv sync
30
+ uv run pytest -q
31
+ ```
32
+
33
+ ## 分区
34
+
35
+ | 模块 | 内容 |
36
+ | --- | --- |
37
+ | `small_toolkit.outputs` | `output_root` / `output_dir` / `get_output_dir` / `tool_output_dir` / `display_path` / `env_suffix` / `tool_dir_name` / `save_json` / `write_json` / `save_bytes` / `save_response` |
38
+ | `small_toolkit.logs` | `setup_job`(控制台着色 + 延迟建目录的文件日志)/ `set_log_env` / `add_debug_argument` / `enable_debug_logging` |
39
+ | `small_toolkit.cli` | `add_grouped_subparsers` / `add_parameter` / `resolve_parameters` / `resolve_env_var` |
40
+ | `small_toolkit.table` | `print_table` / `print_grouped_table` / `print_tree` |
41
+
42
+ 顶层 `small_toolkit` 已重导出全部公开 API,`from small_toolkit import setup_job, tool_output_dir` 即可。
43
+
44
+ ## 产物目录约定
45
+
46
+ ```text
47
+ <产物根>/output/<分类 slug>/<工具名>[@<env 小写>]/<产物类型>/
48
+ ```
49
+
50
+ - 产物根:默认运行目录;从运行目录向上命中便携标记(`WORKSPACE_MARKERS`,默认
51
+ `.workspace-root` / `.dataphin.jsonc`)时归到该目录,工作区任意子目录启动都落到工作区根。
52
+ - 环境后缀:`tst`(含未传,大小写不敏感)无后缀;其它环境 `@<env 小写>`,同一套归一
53
+ 同时作用于产物目录与日志文件名。
54
+ - 落盘统一走 `save_json` / `write_json` / `save_bytes`:自动建父目录;`save_json` 不补末尾
55
+ 换行、非序列化对象按 `str()` 落盘,`write_json` 末尾补换行。
56
+
57
+ ```python
58
+ from small_toolkit import setup_job, tool_output_dir
59
+
60
+ setup_job(__file__, log_file=True)
61
+ out = tool_output_dir("data_dev", "intake_tool", env="prod", create=True)
62
+ ```
63
+
64
+ ## 日志与 `--debug`
65
+
66
+ `setup_job()` 只在**真终端**给消息正文着色;管道、重定向、子进程捕获、`NO_COLOR`
67
+ 下一律纯文本,落盘日志始终纯文本。`add_debug_argument()` 默认 `argparse.SUPPRESS`,
68
+ 顶层与子命令各注册一次也不会互相覆盖,判断统一交给 `enable_debug_logging()`。
69
+
70
+ ## 命令行回退顺序
71
+
72
+ `add_parameter()` 登记后,`resolve_parameters()` 按
73
+ **CLI → 平台代码参数(`parameter`)→ 环境变量(`env`)→ 默认值** 填充;
74
+ 平台占位符(`${name}`)按未设置处理。`resolve_env_var(env, base)` 依次读
75
+ `{base}_LIST`(`env=value;...`)、`{base}_{ENV}`,仅 `prod` 时回退原生 `{base}`。
76
+
77
+ ## 版本
78
+
79
+ `pyproject.toml` 的 `version` 为唯一来源;发版打 tag(`v0.1.1`),引用方按 tag 固定。
80
+
81
+ ## 发布
82
+
83
+ PyPI 走 GitHub Actions + Trusted Publishing(OIDC,不用 API token):
84
+
85
+ - 先手动跑 `publish-test`(`workflow_dispatch`)发 TestPyPI 验证,不占正式版本号;
86
+ - 正式发布:`pyproject.toml` 升版本 → 打 tag `v*` → push tag,`publish` 工作流构建并上传。
87
+ - 工作流会先校验 tag 与 `pyproject.toml` 的 `version` 一致,不一致直接失败(PyPI 同版本不可覆盖)。
88
+ - GitHub 仓库需要预先配置 PyPI 的 pending publisher(Owner / Repository / Workflow / Environment 四项与工作流一致)。
89
+
@@ -0,0 +1,8 @@
1
+ small_toolkit/__init__.py,sha256=InEs1gU2F84i48iHqDz4ljQjo4YymtVRjtmaaVCFfmw,1429
2
+ small_toolkit/cli.py,sha256=08Z2HHQgsEBQudDbbQGwcPE6IxJyl3CJhceUVVZej28,5452
3
+ small_toolkit/logs.py,sha256=UrRfYupVpSQQ2Uw-TvKiYyaWw_cpb-kTysNvfAwLSOM,9626
4
+ small_toolkit/outputs.py,sha256=l9h9Tmy_R1oMS5KDLhCD-fNJWLG7jqCdjsUXTRw-8P8,7460
5
+ small_toolkit/table.py,sha256=wJ2SZHagdnvUKKBtscSCfq1PT9cATn81JAQyMlst6xI,5620
6
+ small_toolkit-0.1.1.dist-info/METADATA,sha256=sl3WgqP6ayC1wNlsq2SPj4PZgdYAnBXnQ9G7s7r_W_I,3964
7
+ small_toolkit-0.1.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
8
+ small_toolkit-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any