small-toolkit 0.1.1__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.
@@ -0,0 +1,34 @@
1
+ name: publish-test
2
+
3
+ on:
4
+ workflow_dispatch: # 手动触发:先发 TestPyPI 验证,不占正式版本号
5
+
6
+ jobs:
7
+ build:
8
+ runs-on: ubuntu-latest
9
+ permissions:
10
+ contents: read
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v5
14
+ - run: uv build
15
+ - uses: actions/upload-artifact@v4
16
+ with:
17
+ name: dist
18
+ path: dist/
19
+
20
+ publish:
21
+ needs: build
22
+ runs-on: ubuntu-latest
23
+ environment: testpypi
24
+ permissions:
25
+ id-token: write
26
+ steps:
27
+ - uses: actions/download-artifact@v4
28
+ with:
29
+ name: dist
30
+ path: dist/
31
+
32
+ - uses: pypa/gh-action-pypi-publish@release/v1
33
+ with:
34
+ repository-url: https://test.pypi.org/legacy/
@@ -0,0 +1,44 @@
1
+ name: publish
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - name: 校验 tag 与 pyproject 版本一致
16
+ # PyPI 同版本不可覆盖,tag/版本不一致会在上传时才报错,这里提前拦
17
+ run: |
18
+ version=$(python3 -c "import pathlib,tomllib;print(tomllib.loads(pathlib.Path('pyproject.toml').read_text(encoding='utf-8'))['project']['version'])")
19
+ test "v${version}" = "${GITHUB_REF_NAME}"
20
+
21
+ - uses: astral-sh/setup-uv@v5
22
+
23
+ - run: uv build
24
+
25
+ - uses: actions/upload-artifact@v4
26
+ with:
27
+ name: dist
28
+ path: dist/
29
+
30
+ publish:
31
+ needs: build
32
+ runs-on: ubuntu-latest
33
+ environment: pypi
34
+ permissions:
35
+ id-token: write # Trusted Publishing(OIDC),无需 API token
36
+ steps:
37
+ - uses: actions/download-artifact@v4
38
+ with:
39
+ name: dist
40
+ path: dist/
41
+
42
+ - uses: pypa/gh-action-pypi-publish@release/v1
43
+ with:
44
+ attestations: true
@@ -0,0 +1,16 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+
5
+ # 环境与缓存
6
+ .venv/
7
+ .pytest_cache/
8
+
9
+ # 构建
10
+ build/
11
+ dist/
12
+ *.egg-info/
13
+
14
+ # 运行时产物(本地)
15
+ output/
16
+ _tmp_*
@@ -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,82 @@
1
+ # small_toolkit
2
+
3
+ 纯标准库的通用 Python 小工具集合:产物目录、日志初始化、命令行参数、终端表格。
4
+
5
+ ## 边界
6
+
7
+ - **零第三方依赖**:只用标准库(`pathlib` / `logging` / `argparse` / `unicodedata`)。
8
+ - **无凭据、无内网地址**:不读环境里的密钥,不内置任何内网域名;环境变量只做**名字**层面的回退。
9
+ - 只抽跨项目复用的通用能力,业务逻辑(平台 SDK、数据源客户端、领域规则)留在各自仓库。
10
+
11
+ ## 安装
12
+
13
+ 从 tag 安装(跨仓依赖固定到 tag,不跟浮动分支):
14
+
15
+ ```bash
16
+ uv add "small_toolkit @ git+https://github.com/jalousiex/py-toolkit.git@v0.1.1"
17
+ ```
18
+
19
+ 本地开发:
20
+
21
+ ```bash
22
+ uv sync
23
+ uv run pytest -q
24
+ ```
25
+
26
+ ## 分区
27
+
28
+ | 模块 | 内容 |
29
+ | --- | --- |
30
+ | `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` |
31
+ | `small_toolkit.logs` | `setup_job`(控制台着色 + 延迟建目录的文件日志)/ `set_log_env` / `add_debug_argument` / `enable_debug_logging` |
32
+ | `small_toolkit.cli` | `add_grouped_subparsers` / `add_parameter` / `resolve_parameters` / `resolve_env_var` |
33
+ | `small_toolkit.table` | `print_table` / `print_grouped_table` / `print_tree` |
34
+
35
+ 顶层 `small_toolkit` 已重导出全部公开 API,`from small_toolkit import setup_job, tool_output_dir` 即可。
36
+
37
+ ## 产物目录约定
38
+
39
+ ```text
40
+ <产物根>/output/<分类 slug>/<工具名>[@<env 小写>]/<产物类型>/
41
+ ```
42
+
43
+ - 产物根:默认运行目录;从运行目录向上命中便携标记(`WORKSPACE_MARKERS`,默认
44
+ `.workspace-root` / `.dataphin.jsonc`)时归到该目录,工作区任意子目录启动都落到工作区根。
45
+ - 环境后缀:`tst`(含未传,大小写不敏感)无后缀;其它环境 `@<env 小写>`,同一套归一
46
+ 同时作用于产物目录与日志文件名。
47
+ - 落盘统一走 `save_json` / `write_json` / `save_bytes`:自动建父目录;`save_json` 不补末尾
48
+ 换行、非序列化对象按 `str()` 落盘,`write_json` 末尾补换行。
49
+
50
+ ```python
51
+ from small_toolkit import setup_job, tool_output_dir
52
+
53
+ setup_job(__file__, log_file=True)
54
+ out = tool_output_dir("data_dev", "intake_tool", env="prod", create=True)
55
+ ```
56
+
57
+ ## 日志与 `--debug`
58
+
59
+ `setup_job()` 只在**真终端**给消息正文着色;管道、重定向、子进程捕获、`NO_COLOR`
60
+ 下一律纯文本,落盘日志始终纯文本。`add_debug_argument()` 默认 `argparse.SUPPRESS`,
61
+ 顶层与子命令各注册一次也不会互相覆盖,判断统一交给 `enable_debug_logging()`。
62
+
63
+ ## 命令行回退顺序
64
+
65
+ `add_parameter()` 登记后,`resolve_parameters()` 按
66
+ **CLI → 平台代码参数(`parameter`)→ 环境变量(`env`)→ 默认值** 填充;
67
+ 平台占位符(`${name}`)按未设置处理。`resolve_env_var(env, base)` 依次读
68
+ `{base}_LIST`(`env=value;...`)、`{base}_{ENV}`,仅 `prod` 时回退原生 `{base}`。
69
+
70
+ ## 版本
71
+
72
+ `pyproject.toml` 的 `version` 为唯一来源;发版打 tag(`v0.1.1`),引用方按 tag 固定。
73
+
74
+ ## 发布
75
+
76
+ PyPI 走 GitHub Actions + Trusted Publishing(OIDC,不用 API token):
77
+
78
+ - 先手动跑 `publish-test`(`workflow_dispatch`)发 TestPyPI 验证,不占正式版本号;
79
+ - 正式发布:`pyproject.toml` 升版本 → 打 tag `v*` → push tag,`publish` 工作流构建并上传。
80
+ - 工作流会先校验 tag 与 `pyproject.toml` 的 `version` 一致,不一致直接失败(PyPI 同版本不可覆盖)。
81
+ - GitHub 仓库需要预先配置 PyPI 的 pending publisher(Owner / Repository / Workflow / Environment 四项与工作流一致)。
82
+
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "small_toolkit"
3
+ version = "0.1.1"
4
+ description = "纯标准库的通用小工具:产物目录、日志初始化、命令行参数与终端表格"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = []
8
+
9
+ [build-system]
10
+ requires = ["hatchling"]
11
+ build-backend = "hatchling.build"
12
+
13
+ [tool.hatch.build.targets.wheel]
14
+ packages = ["small_toolkit"]
15
+
16
+ [tool.uv]
17
+ package = true
18
+
19
+ [dependency-groups]
20
+ dev = ["pytest>=8"]
21
+
22
+ [tool.pytest.ini_options]
23
+ pythonpath = ["."]
24
+ testpaths = ["tests"]
@@ -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
+ ]
@@ -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