sc92-lib 0.1.0__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.
sc92_lib-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sc92
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,322 @@
1
+ Metadata-Version: 2.4
2
+ Name: sc92-lib
3
+ Version: 0.1.0
4
+ Summary: 个人自用工具箱:单实例文件锁、轮转日志、全局异常钩子、MQTT 发布、YAML 配置读写、值转 YAML 文本、工作时段判断
5
+ Author: sc92
6
+ License-Expression: MIT
7
+ Keywords: filelock,logging,mqtt,yaml,worktime,utility
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Typing :: Typed
14
+ Classifier: Operating System :: OS Independent
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: filelock>=4.0
19
+ Requires-Dist: paho-mqtt>=2.0
20
+ Requires-Dist: PyYAML>=6.0
21
+ Dynamic: license-file
22
+
23
+ # sc92-lib
24
+
25
+ 从日常脚本里抽出来的个人自用工具箱:**单实例文件锁、轮转日志、全局异常钩子(主线程 +
26
+ 子线程)、一次性 MQTT 发布、YAML 配置读写、值转 YAML 文本、工作时段判断**。
27
+
28
+ 默认路径全部从入口脚本推导,所以典型用法**一个参数都不用传**;打包成 exe 之后照样写在
29
+ exe 旁边,不会往 `site-packages` 里丢文件。
30
+
31
+ 出错风格是**大声停住等人工**,不静默兜底:绝大多数失败都是 `logging.error` 之后
32
+ `sys.exit(1)`,只有 `to_yaml()` 和 `MqttPublisher.publish()` 这两个刻意做成"永不抛异常"。
33
+
34
+ - 版本 `0.1.0`,构建时间 `2026-11-12 10:55:10`(对应 `__version__` / `__build_time__`)
35
+ - Python >= 3.11;依赖 `filelock>=4.0`、`paho-mqtt>=2.0`、`PyYAML>=6.0`
36
+
37
+ ## 安装
38
+
39
+ ```bash
40
+ pip install sc92-lib
41
+ ```
42
+
43
+ 用国内镜像时注意:清华源可能比官方 PyPI 慢一两个版本,装不到就先切官方源
44
+ (`pip install -i https://pypi.org/simple sc92-lib`)。
45
+
46
+ ## 快速开始(放在任意脚本开头)
47
+
48
+ ```python
49
+ import logging
50
+ from sc92_lib import (
51
+ init_logging_config, # ① 轮转日志 + 控制台
52
+ check_running_lock, # ② 单实例锁
53
+ enable_exception_logging, # ③ 未捕获异常 -> 写日志
54
+ )
55
+
56
+ init_logging_config() # logs/<脚本名>_debug.log / _info.log
57
+ check_running_lock() # 脚本旁边的 <脚本名>.running.lock;别人在跑就退出码 1
58
+ enable_exception_logging()
59
+
60
+ logging.info("main started")
61
+ ```
62
+
63
+ 顺序必须是 ①→②→③:锁和 YAML 都用 `logging.info/error` 汇报结果,日志没先初始化,
64
+ INFO 会被根日志器的默认级别丢掉(只有 ERROR 还能靠 `logging.lastResort` 兜到 stderr)。
65
+
66
+ 上面这些片段就是本包的全部对外用法;我自己在用的全流程演示脚本(每个功能、每种写法都
67
+ 跑一遍)**不随 wheel/sdist 发布**,装完的包里找不到它,是正常的。
68
+
69
+ ## 什么情况下脚本会 `exit(1)`
70
+
71
+ | 触发点 | 日志 |
72
+ |---|---|
73
+ | `check_running_lock()` 没抢到锁(`on_conflict="exit"`,默认) | ERROR |
74
+ | `read_yaml()` 配置文件不存在(先写模板再退) | ERROR |
75
+ | `read_yaml()` 配置文件非 UTF-8 / YAML 语法坏 | ERROR |
76
+ | `write_yaml()` 的 config 类型不支持、或 `safe_dump` 失败 | ERROR |
77
+ | `is_worktime()` 的键或值写法不合法(不猜、不补全) | ERROR |
78
+ | 未捕获异常 | ERROR(钩子只记日志,退出码 1 是解释器给的) |
79
+
80
+ `to_yaml()`、`MqttPublisher.publish()` 不参与这个约定:它们只返回原值 / `False`,绝不退出。
81
+
82
+ ## 模块结构
83
+
84
+ 文件名按该模块的**核心功能**命名,公开 API 一律从包根 `sc92_lib` 导入。
85
+
86
+ | 文件 | 导出 |
87
+ |---|---|
88
+ | `paths.py` | `script_dir()` `script_name()`(其它模块的默认路径都靠它俩) |
89
+ | `init_logging_config.py` | `init_logging_config()` `default_log_file()` |
90
+ | `check_running_lock.py` | `check_running_lock()` `default_lock_file()` |
91
+ | `enable_exception_logging.py` | `enable_exception_logging()` `handle_global_exception` `handle_thread_exception` |
92
+ | `yaml_config.py` | `read_yaml()` `write_yaml()` `default_yaml_file()` |
93
+ | `to_yaml.py` | `to_yaml()` |
94
+ | `is_worktime.py` | `is_worktime()` |
95
+ | `mqtt_publisher.py` | `MqttPublisher` |
96
+
97
+ ## 1. 默认路径怎么来的:`script_dir()` / `script_name()`
98
+
99
+ 锁、日志、配置三样的默认位置都是 `<script_dir()>/<子目录>/<script_name()>.…`。
100
+
101
+ | 启动方式 | `script_name()` | `script_dir()` |
102
+ |---|---|---|
103
+ | `python D:\a\b\tool.py` | `tool` | `D:\a\b` |
104
+ | PyInstaller 打包的 `tool.exe` | `tool` | exe 所在目录 |
105
+ | `python -m pkg`(跑的是 `pkg/__main__.py`) | `pkg`(自动去掉 `.__main__`) | **当前工作目录**(不往 `site-packages` 写) |
106
+ | `python -m pkg.mod`(模块自己带 `if __name__ == "__main__"`) | `pkg.mod`(原样,带点) | 当前工作目录 |
107
+ | `python -c "…"` | 当前目录名 | 当前工作目录 |
108
+ | 管道喂给 `python -` | `-` | 当前工作目录 |
109
+
110
+ ```python
111
+ from sc92_lib import script_dir, script_name
112
+ script_dir() / "data" / "x.txt" # 返回 pathlib.Path,可以直接用 / 拼
113
+ ```
114
+
115
+ ## 2. 日志:`init_logging_config()`
116
+
117
+ ```python
118
+ init_logging_config() # 全默认
119
+ init_logging_config(log_file=r"D:\somewhere\logs\myapp") # 指定前缀
120
+ init_logging_config(max_bytes=50 * 1024 * 1024, backup_count=30)
121
+ init_logging_config(force=True) # 强行接管已有 handler
122
+ ```
123
+
124
+ - 一次调用得到 `<前缀>_debug.log`(DEBUG 全量)+ `<前缀>_info.log`(INFO 及以上)+ 控制台
125
+ (stderr,INFO 及以上),默认每个 10 MB、留 7 份备份。
126
+ - 默认前缀是 `<脚本目录>/logs/<脚本名>`,目录自动创建。
127
+ - **根日志器已经有 handler 时(pytest、Jupyter、或别的库先配置过)本函数不做任何事**,
128
+ 只往 stderr 提示一句 —— 因为静默失效等于"日志一个都没落盘,还不知道为什么"。
129
+ 确实要接管就传 `force=True`,它会先移除已有 handler。
130
+ - 没有控制台时(`pythonw.exe`、PyInstaller `--noconsole`,此时 `sys.stderr is None`)
131
+ 自动跳过控制台 handler,只留文件日志。
132
+ - 格式:`时间 [级别] 文件名:行号 函数名: 消息`。
133
+ - 嫌第三方库吵就单独抬级别,paho 2.x 的内部 logger 名是 `paho.mqtt.client`:
134
+ `logging.getLogger("paho.mqtt.client").setLevel(logging.WARNING)`。
135
+
136
+ ## 3. 单实例锁:`check_running_lock()`
137
+
138
+ ```python
139
+ check_running_lock() # 默认 <脚本名>.running.lock,抢不到就 exit(1)
140
+ if not check_running_lock(lock_file=r"D:\tmp\myapp.lock", on_conflict="return"):
141
+ logging.warning("已有实例在运行,本次退出") # 自己决定怎么善后
142
+ raise SystemExit
143
+ ```
144
+
145
+ - **只在脚本开头调用一次。** 锁按"文件"判定,同一进程里第二次抢同一个 `lock_file`
146
+ 必然失败(哪怕路径写法不同、加了 `.\` 或改了大小写 —— 实测都一样),于是被自己
147
+ 上一次留下的锁当成"别人在跑"而当场退出。
148
+ - **"单实例"的含义是"这个目录下的这个脚本名只跑一份"**:改名、复制一份、换目录跑
149
+ 就是另一把锁,检查绕得过去 —— 这是设计。要跨目录互斥就显式传同一个绝对路径。
150
+ - filelock 在 `acquire()` 时会顺手创建锁文件的**父目录**(构造对象时不建),传个不存在
151
+ 的目录不会报错;目录不会被回收。
152
+ - 锁文件是 **0 字节空文件**,纯占位。持锁期间别去读它 —— 实测同一进程和别的进程读都是
153
+ `PermissionError`。抢锁失败的一方不会破坏正在持锁的那一份。
154
+ - 进程退出(正常结束、`sys.exit`、未捕获异常都算)时操作系统随句柄一起释放锁。
155
+ filelock 4.x 以 DELETE_ON_CLOSE 打开,最后一个持有者关句柄后文件一般就消失了;
156
+ 多线程里还有别的句柄开着时可能残留。**别依赖锁文件是否存在**:残留不影响下次抢锁。
157
+
158
+ ## 4. 未捕获异常:`enable_exception_logging()`
159
+
160
+ ```python
161
+ enable_exception_logging()
162
+ # 等价于:
163
+ # sys.excepthook = handle_global_exception
164
+ # threading.excepthook = handle_thread_exception
165
+ ```
166
+
167
+ 子线程不受 `sys.excepthook` 管辖,不挂 `threading.excepthook` 的话线程里的 traceback 只会
168
+ 打到 stderr,永远进不了日志文件 —— 所以两个都挂。
169
+
170
+ 注意:`sys.exit()` / `SystemExit` / `os._exit()` 不算未捕获异常,不会走钩子;
171
+ 日志本身写不出去(例如盘满)时,Python 只会抱怨一句 `Error in sys.excepthook`。
172
+
173
+ ## 5. YAML 配置:`read_yaml()` / `write_yaml()`
174
+
175
+ ```python
176
+ from sc92_lib import read_yaml, write_yaml
177
+
178
+ cfg = read_yaml(default_config={"服务器": "127.0.0.1", "端口": 8080})
179
+ # 首次运行:在脚本旁生成 configs/<脚本名>.config.yaml,提示后以退出码 1 结束
180
+ cfg["端口"] = 9090
181
+ write_yaml(cfg) # 写回同一个默认文件(config 在前,路径可选在后)
182
+ ```
183
+
184
+ **`read_yaml(config_file=None, default_config={})`**
185
+
186
+ - 默认路径 `<脚本目录>/configs/<脚本名>.config.yaml`。
187
+ - 文件不存在 → 按 `default_config` 写出模板 + ERROR + `exit(1)`,让你检查修改后再跑
188
+ (**故意**的,不是崩溃)。
189
+ - 非 UTF-8(记事本存成 ANSI/GBK)/ YAML 语法坏 → ERROR + `exit(1)`,坏文件原样保留交人工。
190
+ - 其余情况把 `yaml.safe_load` 的结果**原样返回**,不校验顶层类型:`dict` / `list` / 标量 /
191
+ `None` / `{}` 都可能,空文件和纯注释都返回 `None`。需要当 dict 用就自己收窄。
192
+
193
+ **`write_yaml(config, config_file=None)`**
194
+
195
+ - `config` 必填、位置在前,只接受 `dict` / `list` / `str` / `int` / `float` / `bool` / `None`;
196
+ `tuple` / `set` / `date` / 自定义对象一律 ERROR + `exit(1)`。
197
+ - 用 `safe_dump` + `allow_unicode` + `sort_keys=False`:中文和插入顺序保留,且写出的文件
198
+ `read_yaml()` 必定读得回来。顶层是裸标量时会去掉 YAML 的 `...` 结束标记。
199
+ - **原子落盘**:先写同目录 `<文件名>.tmp` 并 `fsync`,再用 `os.replace` 换成正式名字。
200
+ 所以写到一半被强杀 / 崩溃 / 断电,目标文件仍是上一次的完整内容,不会出现"半截 YAML
201
+ 把原配置毁掉"。异常路径由 `finally` 清掉 `.tmp`;被强杀或断电来不及跑 `finally` 时会
202
+ 残留一个 `.tmp`,下次写入直接覆盖,不影响正确性。
203
+ - **Windows 特例**:目标文件被别的进程以"不共享删除"方式开着时,改名会被拒
204
+ (实测:记事本、PowerShell 都不挡,只有另一个 Python 进程 `open()` 挂着句柄才挡)。
205
+ 这时记一条 WARNING 并**退回直接覆盖写** —— 这一趟没有原子保护,但保证配置照样存得下。
206
+ 连直接写也失败(真没权限 / 磁盘问题)则异常照常抛出。
207
+
208
+ ## 6. 值转 YAML 文本:`to_yaml()`
209
+
210
+ ```python
211
+ to_yaml({"姓名": "张三", "年龄": 25}) # "姓名: 张三\n年龄: 25\n"
212
+ to_yaml('{"姓名": "李四", "年龄": 30}') # "姓名: 李四\n年龄: 30\n"
213
+ to_yaml("[1, 2, 3]") # "- 1\n- 2\n- 3\n"
214
+
215
+ to_yaml("普通字符串") # 原字符串(JSON 解析失败)
216
+ to_yaml("123") # 原字符串(JSON 标量,不是 dict/list)
217
+ to_yaml("姓名: 王五\n年龄: 40") # 原字符串(不按 YAML 解析)
218
+ to_yaml(object()) # 原对象(是同一个对象)
219
+ ```
220
+
221
+ 规则:`dict` / `list` 直接 `safe_dump`;其它类型先 `str(value)` 再 `json.loads`,只有解析
222
+ 结果是 dict/list 才 dump;**任一步失败都原样返回入参,绝不抛异常**。成功和失败都写
223
+ `logging.debug`(失败那条会尽量带上原值的 repr)。
224
+
225
+ 本函数**不解析 YAML 文本**,也不会把 `"123"`、`'"abc"'` 这类 JSON 标量转成 YAML;
226
+ pandas 的 `str(df)` 不是 JSON,也会原样返回 —— 要转就先传 `df.to_dict()` / `df.to_json()`。
227
+ 返回的 YAML 文本保留 `safe_dump` 自带的末尾换行(`print` 会多一个空行,可用 `end=""`)。
228
+
229
+ ## 7. 工作时段判断:`is_worktime()`
230
+
231
+ ```python
232
+ from sc92_lib import is_worktime
233
+
234
+ 工作时间 = {
235
+ "1-5": ["0900-1200", "1330-1800"], # 周一到周五,两段班
236
+ "6": ["0900-1200"], # 周六上午
237
+ "7": [], # 周日休息
238
+ }
239
+ is_worktime(工作时间) # True / False
240
+ is_worktime() # False(没给配置)
241
+ ```
242
+
243
+ - **键**:`1`=周一 … `7`=周日,只能是单个数字或 `"起-止"` 区间(两端都是 1–7 且 **起 ≤ 止**)。
244
+ **不支持跨周**:`"6-2"` 报错退出,周六日再加周一请拆成 `"6-7"` 和 `"1-2"`。
245
+ 同一个星期写多次时以**最后一次**为准。
246
+ - **值**:`"HHMM-HHMM"` 字符串列表,横杠两端**各必须是 4 位数字**且 **起 ≤ 止**;
247
+ 这天不上班写空列表 `[]`。时段**左闭右开** `[起, 止)`,所以 `"0900-1300"` 在 13:00 整算下班。
248
+ - **写法不合法**(键、值任何一个)→ ERROR 说清是哪个键、哪个时段、错在哪,然后 `exit(1)`。
249
+ 不猜、不补全(不会把 `"900"` 补成 `"0900"`,也不会把裸字符串当成单元素列表)。
250
+ **整个配置都会校验**,不是只看今天那一行 —— 周日写错了,周二运行同样当场退出。
251
+ - 传 `None` 或 `{}` → WARNING 后返回 `False`;今天没排班 → `False`。
252
+ - **不支持跨零点**:`"2200-0200"` 报错退出,22:00 上到次日 02:00 请在 `"1-5"` 写
253
+ `"2200-2400"`、在 `"2-6"` 写 `"0000-0200"`。
254
+ - 只管形状不管语义:`"2400-2500"`、`"0961"` 这种 4 位数字但不是合法时刻的写法会放行,
255
+ 然后因为永远匹配不上而一直返回 `False`。
256
+
257
+ ```python
258
+ is_worktime({"6-2": ["0900-1200"]}) # ERROR 起 6 大于止 2,不支持跨周 → exit(1)
259
+ is_worktime({"1-8": ["0900-1200"]}) # ERROR 两端都要是 1-7 的数字 → exit(1)
260
+ is_worktime({"1": [900]}) # ERROR 900 是 int,不是 "HHMM-HHMM" 字符串 → exit(1)
261
+ is_worktime({"1": ["09-17"]}) # ERROR 横杠两端各要 4 位数字 → exit(1)
262
+ is_worktime({"1": ["1200-0900"]}) # ERROR 起大于止,不支持跨零点 → exit(1)
263
+ ```
264
+
265
+ 日志:每次调用**只写一条 INFO 结论**(例:`星期二 10:30 命中时段 0900-1200 → True`),
266
+ 时段列表和逐时段比较过程写在 DEBUG,只进 `*_debug.log`,不会刷屏。
267
+
268
+ ## 8. MQTT 发布:`MqttPublisher`
269
+
270
+ 凭据**只从构造函数的显式参数来**,源码里没有任何硬编码默认值、也不读环境变量 ——
271
+ 放在你自己的 `configs/<脚本名>.config.yaml` 里,用 `read_yaml()` 读出来再传进来。
272
+ (本包会发布到 PyPI,源码里的任何真实凭据都会公开。)
273
+
274
+ ```python
275
+ from sc92_lib import MqttPublisher, read_yaml
276
+
277
+ server = read_yaml()["mqtt服务器"] # 嵌套字典
278
+ pub = MqttPublisher(host=server["地址"], port=server["端口"],
279
+ username=server["用户名"] or None, # 留空 → None = 匿名连接
280
+ password=server["密码"] or None,
281
+ topic=server["主题"], qos=server["QoS"])
282
+ pub.publish("hello") # 用实例上的 topic/qos
283
+ pub.publish("hello", topic="other/t", qos=1) # 单次调用覆盖
284
+ ```
285
+
286
+ 流程刻意保持朴素:`connect` → `loop_start` → `publish` → `wait_for_publish` → 收尾
287
+ `disconnect` / `loop_stop`,一条消息一次连接。要知道的边界:
288
+
289
+ - 返回值:QoS 1/2 拿到 broker 的 PUBACK/PUBCOMP 才算 `True`;**QoS 0 没有确认机制**,
290
+ `True` 只表示已写入 socket。连不上、密码被拒、发不出确认、任何网络异常 → `False` +
291
+ ERROR,**不抛异常**。
292
+ - `connect_timeout` 交给 paho 的 `client.connect_timeout`,实测只封顶 TCP 连接建立;
293
+ paho 2.x 的 `connect()` 不等 CONNACK,所以**密码写错要等 `publish_timeout` 才失败**,
294
+ 错误信息是"发布未被 broker 确认"。
295
+ - 不支持 TLS,也没有 `protocol` 参数(走 paho 默认的 MQTT 3.1.1)。
296
+ - `qos` 不在构造时校验 0–2,写错要等 broker 拒绝才看得出来。
297
+ - **整条消息载荷会写进 INFO 日志**(`*_info.log` 和 `*_debug.log` 都有)—— 别用它发
298
+ 明文密码/token,否则等于把凭据落盘。
299
+ - `bytes` / `bytearray` 原样发送,其它类型走 `str()`(`None` 会发成字符串 `"None"`)。
300
+
301
+ ## API 一览
302
+
303
+ | 名称 | 默认值 | 一句话 | 详见 |
304
+ |---|---|---|---|
305
+ | `__version__` / `__build_time__` | — | 版本号;手写的构建时间字符串 `YYYY-MM-DD HH:MM:SS`,改功能后手动更新,不参与构建 | — |
306
+ | `script_dir()` / `script_name()` | — | 入口脚本目录/文件名,兼容 exe 与 `python -m` | §1 |
307
+ | `init_logging_config(log_file=None, max_bytes=10MB, backup_count=7, force=False)` / `default_log_file()` | `<脚本目录>/logs/<脚本名>` | 装 debug+info 双轮转文件日志和控制台;根日志器已有 handler 时只提示不接管 | §2 |
308
+ | `check_running_lock(lock_file=None, on_conflict="exit")` / `default_lock_file()` | `<脚本目录>/<脚本名>.running.lock` | 抢"本脚本已在运行"这把独占锁;每进程只调一次 | §3 |
309
+ | `enable_exception_logging()` / `handle_global_exception` / `handle_thread_exception` | — | 主线程 + 子线程的未捕获异常都写进日志 | §4 |
310
+ | `read_yaml(config_file=None, default_config={})` | `<脚本目录>/configs/<脚本名>.config.yaml` | 读 YAML 原样返回;只有"文件不存在"和"文件损坏"会 `exit(1)` | §5 |
311
+ | `write_yaml(config, config_file=None)` | 同上默认文件 | `safe_dump` 原子落盘;目标被占用时退回直写(Windows) | §5 |
312
+ | `to_yaml(value)` | — | 值转 YAML 文本,失败原样返回,不抛异常 | §6 |
313
+ | `is_worktime(worktime_config=None)` | — | 按星期几 + `HHMM-HHMM` 判断现在是否在工作时段,写法不合法就 `exit(1)` | §7 |
314
+ | `MqttPublisher(host, port=1883, username=None, password=None, topic=None, qos=2, client_id=None, connect_timeout=5, publish_timeout=10)` | 只认显式参数 | 一次性发布器;`host`/`topic` 缺失抛 `ValueError` | §8 |
315
+ | `MqttPublisher.publish(msg, topic=None, qos=None)` | 用实例默认值 | 发一条;成功 `True`,失败 `False` 不抛 | §8 |
316
+
317
+ ## 变更记录
318
+
319
+ ### 0.1.0(2026-11-12)
320
+
321
+ 首次发布。单实例文件锁、轮转日志、全局异常钩子(主线程 + 子线程)、一次性 MQTT 发布、
322
+ YAML 配置读写、值转 YAML 文本、工作时段判断,全部从包根 `sc92_lib` 导入。
@@ -0,0 +1,300 @@
1
+ # sc92-lib
2
+
3
+ 从日常脚本里抽出来的个人自用工具箱:**单实例文件锁、轮转日志、全局异常钩子(主线程 +
4
+ 子线程)、一次性 MQTT 发布、YAML 配置读写、值转 YAML 文本、工作时段判断**。
5
+
6
+ 默认路径全部从入口脚本推导,所以典型用法**一个参数都不用传**;打包成 exe 之后照样写在
7
+ exe 旁边,不会往 `site-packages` 里丢文件。
8
+
9
+ 出错风格是**大声停住等人工**,不静默兜底:绝大多数失败都是 `logging.error` 之后
10
+ `sys.exit(1)`,只有 `to_yaml()` 和 `MqttPublisher.publish()` 这两个刻意做成"永不抛异常"。
11
+
12
+ - 版本 `0.1.0`,构建时间 `2026-11-12 10:55:10`(对应 `__version__` / `__build_time__`)
13
+ - Python >= 3.11;依赖 `filelock>=4.0`、`paho-mqtt>=2.0`、`PyYAML>=6.0`
14
+
15
+ ## 安装
16
+
17
+ ```bash
18
+ pip install sc92-lib
19
+ ```
20
+
21
+ 用国内镜像时注意:清华源可能比官方 PyPI 慢一两个版本,装不到就先切官方源
22
+ (`pip install -i https://pypi.org/simple sc92-lib`)。
23
+
24
+ ## 快速开始(放在任意脚本开头)
25
+
26
+ ```python
27
+ import logging
28
+ from sc92_lib import (
29
+ init_logging_config, # ① 轮转日志 + 控制台
30
+ check_running_lock, # ② 单实例锁
31
+ enable_exception_logging, # ③ 未捕获异常 -> 写日志
32
+ )
33
+
34
+ init_logging_config() # logs/<脚本名>_debug.log / _info.log
35
+ check_running_lock() # 脚本旁边的 <脚本名>.running.lock;别人在跑就退出码 1
36
+ enable_exception_logging()
37
+
38
+ logging.info("main started")
39
+ ```
40
+
41
+ 顺序必须是 ①→②→③:锁和 YAML 都用 `logging.info/error` 汇报结果,日志没先初始化,
42
+ INFO 会被根日志器的默认级别丢掉(只有 ERROR 还能靠 `logging.lastResort` 兜到 stderr)。
43
+
44
+ 上面这些片段就是本包的全部对外用法;我自己在用的全流程演示脚本(每个功能、每种写法都
45
+ 跑一遍)**不随 wheel/sdist 发布**,装完的包里找不到它,是正常的。
46
+
47
+ ## 什么情况下脚本会 `exit(1)`
48
+
49
+ | 触发点 | 日志 |
50
+ |---|---|
51
+ | `check_running_lock()` 没抢到锁(`on_conflict="exit"`,默认) | ERROR |
52
+ | `read_yaml()` 配置文件不存在(先写模板再退) | ERROR |
53
+ | `read_yaml()` 配置文件非 UTF-8 / YAML 语法坏 | ERROR |
54
+ | `write_yaml()` 的 config 类型不支持、或 `safe_dump` 失败 | ERROR |
55
+ | `is_worktime()` 的键或值写法不合法(不猜、不补全) | ERROR |
56
+ | 未捕获异常 | ERROR(钩子只记日志,退出码 1 是解释器给的) |
57
+
58
+ `to_yaml()`、`MqttPublisher.publish()` 不参与这个约定:它们只返回原值 / `False`,绝不退出。
59
+
60
+ ## 模块结构
61
+
62
+ 文件名按该模块的**核心功能**命名,公开 API 一律从包根 `sc92_lib` 导入。
63
+
64
+ | 文件 | 导出 |
65
+ |---|---|
66
+ | `paths.py` | `script_dir()` `script_name()`(其它模块的默认路径都靠它俩) |
67
+ | `init_logging_config.py` | `init_logging_config()` `default_log_file()` |
68
+ | `check_running_lock.py` | `check_running_lock()` `default_lock_file()` |
69
+ | `enable_exception_logging.py` | `enable_exception_logging()` `handle_global_exception` `handle_thread_exception` |
70
+ | `yaml_config.py` | `read_yaml()` `write_yaml()` `default_yaml_file()` |
71
+ | `to_yaml.py` | `to_yaml()` |
72
+ | `is_worktime.py` | `is_worktime()` |
73
+ | `mqtt_publisher.py` | `MqttPublisher` |
74
+
75
+ ## 1. 默认路径怎么来的:`script_dir()` / `script_name()`
76
+
77
+ 锁、日志、配置三样的默认位置都是 `<script_dir()>/<子目录>/<script_name()>.…`。
78
+
79
+ | 启动方式 | `script_name()` | `script_dir()` |
80
+ |---|---|---|
81
+ | `python D:\a\b\tool.py` | `tool` | `D:\a\b` |
82
+ | PyInstaller 打包的 `tool.exe` | `tool` | exe 所在目录 |
83
+ | `python -m pkg`(跑的是 `pkg/__main__.py`) | `pkg`(自动去掉 `.__main__`) | **当前工作目录**(不往 `site-packages` 写) |
84
+ | `python -m pkg.mod`(模块自己带 `if __name__ == "__main__"`) | `pkg.mod`(原样,带点) | 当前工作目录 |
85
+ | `python -c "…"` | 当前目录名 | 当前工作目录 |
86
+ | 管道喂给 `python -` | `-` | 当前工作目录 |
87
+
88
+ ```python
89
+ from sc92_lib import script_dir, script_name
90
+ script_dir() / "data" / "x.txt" # 返回 pathlib.Path,可以直接用 / 拼
91
+ ```
92
+
93
+ ## 2. 日志:`init_logging_config()`
94
+
95
+ ```python
96
+ init_logging_config() # 全默认
97
+ init_logging_config(log_file=r"D:\somewhere\logs\myapp") # 指定前缀
98
+ init_logging_config(max_bytes=50 * 1024 * 1024, backup_count=30)
99
+ init_logging_config(force=True) # 强行接管已有 handler
100
+ ```
101
+
102
+ - 一次调用得到 `<前缀>_debug.log`(DEBUG 全量)+ `<前缀>_info.log`(INFO 及以上)+ 控制台
103
+ (stderr,INFO 及以上),默认每个 10 MB、留 7 份备份。
104
+ - 默认前缀是 `<脚本目录>/logs/<脚本名>`,目录自动创建。
105
+ - **根日志器已经有 handler 时(pytest、Jupyter、或别的库先配置过)本函数不做任何事**,
106
+ 只往 stderr 提示一句 —— 因为静默失效等于"日志一个都没落盘,还不知道为什么"。
107
+ 确实要接管就传 `force=True`,它会先移除已有 handler。
108
+ - 没有控制台时(`pythonw.exe`、PyInstaller `--noconsole`,此时 `sys.stderr is None`)
109
+ 自动跳过控制台 handler,只留文件日志。
110
+ - 格式:`时间 [级别] 文件名:行号 函数名: 消息`。
111
+ - 嫌第三方库吵就单独抬级别,paho 2.x 的内部 logger 名是 `paho.mqtt.client`:
112
+ `logging.getLogger("paho.mqtt.client").setLevel(logging.WARNING)`。
113
+
114
+ ## 3. 单实例锁:`check_running_lock()`
115
+
116
+ ```python
117
+ check_running_lock() # 默认 <脚本名>.running.lock,抢不到就 exit(1)
118
+ if not check_running_lock(lock_file=r"D:\tmp\myapp.lock", on_conflict="return"):
119
+ logging.warning("已有实例在运行,本次退出") # 自己决定怎么善后
120
+ raise SystemExit
121
+ ```
122
+
123
+ - **只在脚本开头调用一次。** 锁按"文件"判定,同一进程里第二次抢同一个 `lock_file`
124
+ 必然失败(哪怕路径写法不同、加了 `.\` 或改了大小写 —— 实测都一样),于是被自己
125
+ 上一次留下的锁当成"别人在跑"而当场退出。
126
+ - **"单实例"的含义是"这个目录下的这个脚本名只跑一份"**:改名、复制一份、换目录跑
127
+ 就是另一把锁,检查绕得过去 —— 这是设计。要跨目录互斥就显式传同一个绝对路径。
128
+ - filelock 在 `acquire()` 时会顺手创建锁文件的**父目录**(构造对象时不建),传个不存在
129
+ 的目录不会报错;目录不会被回收。
130
+ - 锁文件是 **0 字节空文件**,纯占位。持锁期间别去读它 —— 实测同一进程和别的进程读都是
131
+ `PermissionError`。抢锁失败的一方不会破坏正在持锁的那一份。
132
+ - 进程退出(正常结束、`sys.exit`、未捕获异常都算)时操作系统随句柄一起释放锁。
133
+ filelock 4.x 以 DELETE_ON_CLOSE 打开,最后一个持有者关句柄后文件一般就消失了;
134
+ 多线程里还有别的句柄开着时可能残留。**别依赖锁文件是否存在**:残留不影响下次抢锁。
135
+
136
+ ## 4. 未捕获异常:`enable_exception_logging()`
137
+
138
+ ```python
139
+ enable_exception_logging()
140
+ # 等价于:
141
+ # sys.excepthook = handle_global_exception
142
+ # threading.excepthook = handle_thread_exception
143
+ ```
144
+
145
+ 子线程不受 `sys.excepthook` 管辖,不挂 `threading.excepthook` 的话线程里的 traceback 只会
146
+ 打到 stderr,永远进不了日志文件 —— 所以两个都挂。
147
+
148
+ 注意:`sys.exit()` / `SystemExit` / `os._exit()` 不算未捕获异常,不会走钩子;
149
+ 日志本身写不出去(例如盘满)时,Python 只会抱怨一句 `Error in sys.excepthook`。
150
+
151
+ ## 5. YAML 配置:`read_yaml()` / `write_yaml()`
152
+
153
+ ```python
154
+ from sc92_lib import read_yaml, write_yaml
155
+
156
+ cfg = read_yaml(default_config={"服务器": "127.0.0.1", "端口": 8080})
157
+ # 首次运行:在脚本旁生成 configs/<脚本名>.config.yaml,提示后以退出码 1 结束
158
+ cfg["端口"] = 9090
159
+ write_yaml(cfg) # 写回同一个默认文件(config 在前,路径可选在后)
160
+ ```
161
+
162
+ **`read_yaml(config_file=None, default_config={})`**
163
+
164
+ - 默认路径 `<脚本目录>/configs/<脚本名>.config.yaml`。
165
+ - 文件不存在 → 按 `default_config` 写出模板 + ERROR + `exit(1)`,让你检查修改后再跑
166
+ (**故意**的,不是崩溃)。
167
+ - 非 UTF-8(记事本存成 ANSI/GBK)/ YAML 语法坏 → ERROR + `exit(1)`,坏文件原样保留交人工。
168
+ - 其余情况把 `yaml.safe_load` 的结果**原样返回**,不校验顶层类型:`dict` / `list` / 标量 /
169
+ `None` / `{}` 都可能,空文件和纯注释都返回 `None`。需要当 dict 用就自己收窄。
170
+
171
+ **`write_yaml(config, config_file=None)`**
172
+
173
+ - `config` 必填、位置在前,只接受 `dict` / `list` / `str` / `int` / `float` / `bool` / `None`;
174
+ `tuple` / `set` / `date` / 自定义对象一律 ERROR + `exit(1)`。
175
+ - 用 `safe_dump` + `allow_unicode` + `sort_keys=False`:中文和插入顺序保留,且写出的文件
176
+ `read_yaml()` 必定读得回来。顶层是裸标量时会去掉 YAML 的 `...` 结束标记。
177
+ - **原子落盘**:先写同目录 `<文件名>.tmp` 并 `fsync`,再用 `os.replace` 换成正式名字。
178
+ 所以写到一半被强杀 / 崩溃 / 断电,目标文件仍是上一次的完整内容,不会出现"半截 YAML
179
+ 把原配置毁掉"。异常路径由 `finally` 清掉 `.tmp`;被强杀或断电来不及跑 `finally` 时会
180
+ 残留一个 `.tmp`,下次写入直接覆盖,不影响正确性。
181
+ - **Windows 特例**:目标文件被别的进程以"不共享删除"方式开着时,改名会被拒
182
+ (实测:记事本、PowerShell 都不挡,只有另一个 Python 进程 `open()` 挂着句柄才挡)。
183
+ 这时记一条 WARNING 并**退回直接覆盖写** —— 这一趟没有原子保护,但保证配置照样存得下。
184
+ 连直接写也失败(真没权限 / 磁盘问题)则异常照常抛出。
185
+
186
+ ## 6. 值转 YAML 文本:`to_yaml()`
187
+
188
+ ```python
189
+ to_yaml({"姓名": "张三", "年龄": 25}) # "姓名: 张三\n年龄: 25\n"
190
+ to_yaml('{"姓名": "李四", "年龄": 30}') # "姓名: 李四\n年龄: 30\n"
191
+ to_yaml("[1, 2, 3]") # "- 1\n- 2\n- 3\n"
192
+
193
+ to_yaml("普通字符串") # 原字符串(JSON 解析失败)
194
+ to_yaml("123") # 原字符串(JSON 标量,不是 dict/list)
195
+ to_yaml("姓名: 王五\n年龄: 40") # 原字符串(不按 YAML 解析)
196
+ to_yaml(object()) # 原对象(是同一个对象)
197
+ ```
198
+
199
+ 规则:`dict` / `list` 直接 `safe_dump`;其它类型先 `str(value)` 再 `json.loads`,只有解析
200
+ 结果是 dict/list 才 dump;**任一步失败都原样返回入参,绝不抛异常**。成功和失败都写
201
+ `logging.debug`(失败那条会尽量带上原值的 repr)。
202
+
203
+ 本函数**不解析 YAML 文本**,也不会把 `"123"`、`'"abc"'` 这类 JSON 标量转成 YAML;
204
+ pandas 的 `str(df)` 不是 JSON,也会原样返回 —— 要转就先传 `df.to_dict()` / `df.to_json()`。
205
+ 返回的 YAML 文本保留 `safe_dump` 自带的末尾换行(`print` 会多一个空行,可用 `end=""`)。
206
+
207
+ ## 7. 工作时段判断:`is_worktime()`
208
+
209
+ ```python
210
+ from sc92_lib import is_worktime
211
+
212
+ 工作时间 = {
213
+ "1-5": ["0900-1200", "1330-1800"], # 周一到周五,两段班
214
+ "6": ["0900-1200"], # 周六上午
215
+ "7": [], # 周日休息
216
+ }
217
+ is_worktime(工作时间) # True / False
218
+ is_worktime() # False(没给配置)
219
+ ```
220
+
221
+ - **键**:`1`=周一 … `7`=周日,只能是单个数字或 `"起-止"` 区间(两端都是 1–7 且 **起 ≤ 止**)。
222
+ **不支持跨周**:`"6-2"` 报错退出,周六日再加周一请拆成 `"6-7"` 和 `"1-2"`。
223
+ 同一个星期写多次时以**最后一次**为准。
224
+ - **值**:`"HHMM-HHMM"` 字符串列表,横杠两端**各必须是 4 位数字**且 **起 ≤ 止**;
225
+ 这天不上班写空列表 `[]`。时段**左闭右开** `[起, 止)`,所以 `"0900-1300"` 在 13:00 整算下班。
226
+ - **写法不合法**(键、值任何一个)→ ERROR 说清是哪个键、哪个时段、错在哪,然后 `exit(1)`。
227
+ 不猜、不补全(不会把 `"900"` 补成 `"0900"`,也不会把裸字符串当成单元素列表)。
228
+ **整个配置都会校验**,不是只看今天那一行 —— 周日写错了,周二运行同样当场退出。
229
+ - 传 `None` 或 `{}` → WARNING 后返回 `False`;今天没排班 → `False`。
230
+ - **不支持跨零点**:`"2200-0200"` 报错退出,22:00 上到次日 02:00 请在 `"1-5"` 写
231
+ `"2200-2400"`、在 `"2-6"` 写 `"0000-0200"`。
232
+ - 只管形状不管语义:`"2400-2500"`、`"0961"` 这种 4 位数字但不是合法时刻的写法会放行,
233
+ 然后因为永远匹配不上而一直返回 `False`。
234
+
235
+ ```python
236
+ is_worktime({"6-2": ["0900-1200"]}) # ERROR 起 6 大于止 2,不支持跨周 → exit(1)
237
+ is_worktime({"1-8": ["0900-1200"]}) # ERROR 两端都要是 1-7 的数字 → exit(1)
238
+ is_worktime({"1": [900]}) # ERROR 900 是 int,不是 "HHMM-HHMM" 字符串 → exit(1)
239
+ is_worktime({"1": ["09-17"]}) # ERROR 横杠两端各要 4 位数字 → exit(1)
240
+ is_worktime({"1": ["1200-0900"]}) # ERROR 起大于止,不支持跨零点 → exit(1)
241
+ ```
242
+
243
+ 日志:每次调用**只写一条 INFO 结论**(例:`星期二 10:30 命中时段 0900-1200 → True`),
244
+ 时段列表和逐时段比较过程写在 DEBUG,只进 `*_debug.log`,不会刷屏。
245
+
246
+ ## 8. MQTT 发布:`MqttPublisher`
247
+
248
+ 凭据**只从构造函数的显式参数来**,源码里没有任何硬编码默认值、也不读环境变量 ——
249
+ 放在你自己的 `configs/<脚本名>.config.yaml` 里,用 `read_yaml()` 读出来再传进来。
250
+ (本包会发布到 PyPI,源码里的任何真实凭据都会公开。)
251
+
252
+ ```python
253
+ from sc92_lib import MqttPublisher, read_yaml
254
+
255
+ server = read_yaml()["mqtt服务器"] # 嵌套字典
256
+ pub = MqttPublisher(host=server["地址"], port=server["端口"],
257
+ username=server["用户名"] or None, # 留空 → None = 匿名连接
258
+ password=server["密码"] or None,
259
+ topic=server["主题"], qos=server["QoS"])
260
+ pub.publish("hello") # 用实例上的 topic/qos
261
+ pub.publish("hello", topic="other/t", qos=1) # 单次调用覆盖
262
+ ```
263
+
264
+ 流程刻意保持朴素:`connect` → `loop_start` → `publish` → `wait_for_publish` → 收尾
265
+ `disconnect` / `loop_stop`,一条消息一次连接。要知道的边界:
266
+
267
+ - 返回值:QoS 1/2 拿到 broker 的 PUBACK/PUBCOMP 才算 `True`;**QoS 0 没有确认机制**,
268
+ `True` 只表示已写入 socket。连不上、密码被拒、发不出确认、任何网络异常 → `False` +
269
+ ERROR,**不抛异常**。
270
+ - `connect_timeout` 交给 paho 的 `client.connect_timeout`,实测只封顶 TCP 连接建立;
271
+ paho 2.x 的 `connect()` 不等 CONNACK,所以**密码写错要等 `publish_timeout` 才失败**,
272
+ 错误信息是"发布未被 broker 确认"。
273
+ - 不支持 TLS,也没有 `protocol` 参数(走 paho 默认的 MQTT 3.1.1)。
274
+ - `qos` 不在构造时校验 0–2,写错要等 broker 拒绝才看得出来。
275
+ - **整条消息载荷会写进 INFO 日志**(`*_info.log` 和 `*_debug.log` 都有)—— 别用它发
276
+ 明文密码/token,否则等于把凭据落盘。
277
+ - `bytes` / `bytearray` 原样发送,其它类型走 `str()`(`None` 会发成字符串 `"None"`)。
278
+
279
+ ## API 一览
280
+
281
+ | 名称 | 默认值 | 一句话 | 详见 |
282
+ |---|---|---|---|
283
+ | `__version__` / `__build_time__` | — | 版本号;手写的构建时间字符串 `YYYY-MM-DD HH:MM:SS`,改功能后手动更新,不参与构建 | — |
284
+ | `script_dir()` / `script_name()` | — | 入口脚本目录/文件名,兼容 exe 与 `python -m` | §1 |
285
+ | `init_logging_config(log_file=None, max_bytes=10MB, backup_count=7, force=False)` / `default_log_file()` | `<脚本目录>/logs/<脚本名>` | 装 debug+info 双轮转文件日志和控制台;根日志器已有 handler 时只提示不接管 | §2 |
286
+ | `check_running_lock(lock_file=None, on_conflict="exit")` / `default_lock_file()` | `<脚本目录>/<脚本名>.running.lock` | 抢"本脚本已在运行"这把独占锁;每进程只调一次 | §3 |
287
+ | `enable_exception_logging()` / `handle_global_exception` / `handle_thread_exception` | — | 主线程 + 子线程的未捕获异常都写进日志 | §4 |
288
+ | `read_yaml(config_file=None, default_config={})` | `<脚本目录>/configs/<脚本名>.config.yaml` | 读 YAML 原样返回;只有"文件不存在"和"文件损坏"会 `exit(1)` | §5 |
289
+ | `write_yaml(config, config_file=None)` | 同上默认文件 | `safe_dump` 原子落盘;目标被占用时退回直写(Windows) | §5 |
290
+ | `to_yaml(value)` | — | 值转 YAML 文本,失败原样返回,不抛异常 | §6 |
291
+ | `is_worktime(worktime_config=None)` | — | 按星期几 + `HHMM-HHMM` 判断现在是否在工作时段,写法不合法就 `exit(1)` | §7 |
292
+ | `MqttPublisher(host, port=1883, username=None, password=None, topic=None, qos=2, client_id=None, connect_timeout=5, publish_timeout=10)` | 只认显式参数 | 一次性发布器;`host`/`topic` 缺失抛 `ValueError` | §8 |
293
+ | `MqttPublisher.publish(msg, topic=None, qos=None)` | 用实例默认值 | 发一条;成功 `True`,失败 `False` 不抛 | §8 |
294
+
295
+ ## 变更记录
296
+
297
+ ### 0.1.0(2026-11-12)
298
+
299
+ 首次发布。单实例文件锁、轮转日志、全局异常钩子(主线程 + 子线程)、一次性 MQTT 发布、
300
+ YAML 配置读写、值转 YAML 文本、工作时段判断,全部从包根 `sc92_lib` 导入。