sc92-lib 0.1.0__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.
- sc92_lib/__init__.py +39 -0
- sc92_lib/check_running_lock.py +73 -0
- sc92_lib/enable_exception_logging.py +30 -0
- sc92_lib/init_logging_config.py +69 -0
- sc92_lib/is_worktime.py +129 -0
- sc92_lib/mqtt_publisher.py +92 -0
- sc92_lib/paths.py +57 -0
- sc92_lib/py.typed +0 -0
- sc92_lib/to_yaml.py +91 -0
- sc92_lib/yaml_config.py +141 -0
- sc92_lib-0.1.0.dist-info/METADATA +322 -0
- sc92_lib-0.1.0.dist-info/RECORD +15 -0
- sc92_lib-0.1.0.dist-info/WHEEL +5 -0
- sc92_lib-0.1.0.dist-info/licenses/LICENSE +21 -0
- sc92_lib-0.1.0.dist-info/top_level.txt +1 -0
sc92_lib/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""sc92_lib:单实例文件锁、轮转日志、全局异常钩子(主线程+子线程)、
|
|
2
|
+
一次性 MQTT 发布、YAML 配置文件读写、值转 YAML 文本、工作时段判断。"""
|
|
3
|
+
|
|
4
|
+
from .check_running_lock import check_running_lock, default_lock_file
|
|
5
|
+
from .init_logging_config import default_log_file, init_logging_config
|
|
6
|
+
from .enable_exception_logging import (
|
|
7
|
+
enable_exception_logging,
|
|
8
|
+
handle_global_exception,
|
|
9
|
+
handle_thread_exception,
|
|
10
|
+
)
|
|
11
|
+
from .mqtt_publisher import MqttPublisher
|
|
12
|
+
from .yaml_config import default_yaml_file, read_yaml, write_yaml
|
|
13
|
+
from .to_yaml import to_yaml
|
|
14
|
+
from .is_worktime import is_worktime
|
|
15
|
+
from .paths import script_dir, script_name
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
# 每次更新功能后,手动把这里改成当时的日期时间(YYYY-MM-DD HH:MM:SS)
|
|
19
|
+
__build_time__ = "2026-11-12 10:55:10"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"__version__", # 当前包版本号
|
|
23
|
+
"__build_time__", # 本版构建时间:YYYY-MM-DD HH:MM:SS(更新功能后手动改)
|
|
24
|
+
"script_dir", # 入口脚本所在目录(兼容 exe 与 python -m)
|
|
25
|
+
"script_name", # 入口脚本名(不含扩展名,兼容 exe 与 python -m)
|
|
26
|
+
"init_logging_config", # 初始化轮转文件日志(debug/info)+ 控制台输出
|
|
27
|
+
"default_log_file", # 推导默认日志路径前缀 <脚本目录>/logs/<脚本名>
|
|
28
|
+
"check_running_lock", # 单实例文件锁:抢不到锁按策略退出或返回 False
|
|
29
|
+
"default_lock_file", # 推导默认锁文件路径 <脚本目录>/<脚本名>.running.lock
|
|
30
|
+
"handle_global_exception", # sys.excepthook 实现:主线程未捕获异常写日志
|
|
31
|
+
"handle_thread_exception", # threading.excepthook 实现:子线程未捕获异常写日志
|
|
32
|
+
"enable_exception_logging", # 一键挂载主线程 + 子线程的全局异常钩子
|
|
33
|
+
"MqttPublisher", # 一次性 MQTT 发布器:连接、发一条、断开
|
|
34
|
+
"read_yaml", # 读 YAML 原样返回(不校验顶层类型),仅文件缺失时写模板并退出
|
|
35
|
+
"write_yaml", # 把 dict/list/str/int/float/bool/None 写成 YAML 文件(config 在前,路径可选)
|
|
36
|
+
"default_yaml_file", # 推导默认配置文件路径 <脚本目录>/configs/<脚本名>.config.yaml
|
|
37
|
+
"to_yaml", # 把值尽量转成 YAML 文本,失败则原样返回
|
|
38
|
+
"is_worktime", # 按星期几 + HHMM 时段判断当前时间是否在工作时间内
|
|
39
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""基于 filelock 的单实例运行检查。"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from .paths import script_dir, script_name
|
|
7
|
+
|
|
8
|
+
_held_locks: dict = {}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def default_lock_file() -> str:
|
|
12
|
+
"""<脚本目录>/<脚本名>.running.lock
|
|
13
|
+
|
|
14
|
+
换目录跑、或者把脚本改名/复制一份,推导出来的就是另一个文件(另一把锁),
|
|
15
|
+
细节见 check_running_lock 的「单实例的确切含义」。
|
|
16
|
+
"""
|
|
17
|
+
return str(script_dir() / f"{script_name()}.running.lock")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def check_running_lock(lock_file: str | None = None, on_conflict: str = "exit") -> bool:
|
|
21
|
+
"""尝试抢到"本脚本已在运行"这把独占锁。
|
|
22
|
+
|
|
23
|
+
lock_file 默认为入口脚本旁边的 <脚本名>.running.lock。
|
|
24
|
+
on_conflict "exit"(默认):锁被别人占着时,本进程以退出码 1 结束;
|
|
25
|
+
"return":返回 False,由调用方自己决定怎么善后。
|
|
26
|
+
|
|
27
|
+
抢到锁返回 True;on_conflict="return" 且被别人占用时返回 False。
|
|
28
|
+
|
|
29
|
+
── 只在脚本开头调用一次 ──
|
|
30
|
+
锁是"文件"级别的:操作系统认的就是那个文件,跟你写绝对路径、加 ".\\" 前缀还是改
|
|
31
|
+
大小写都无关。所以同一个进程里第二次抢同一个 lock_file 必然 Timeout —— 会被自己
|
|
32
|
+
上一次留下的锁当成"别人在跑",on_conflict="exit" 时当场把自己杀掉。
|
|
33
|
+
因此:只在 main 开头调一次,别塞进函数/类里,也别对同一个锁文件混用 "exit" 和
|
|
34
|
+
"return" 两种写法。(抢到的锁靠模块级 _held_locks 留住引用,FileLock 实例被回收
|
|
35
|
+
就等于解锁,所以必须留住。)
|
|
36
|
+
|
|
37
|
+
── "单实例"的确切含义 ──
|
|
38
|
+
是"这个目录下的这个脚本名"只允许跑一份:改名、复制一份、挪去别的目录跑,
|
|
39
|
+
推导出来的默认锁文件名就变了,那是另一把锁,检查绕得过去 —— 这是设计。
|
|
40
|
+
确实要跨目录/跨脚本名互斥,就显式传同一个绝对 lock_file。
|
|
41
|
+
|
|
42
|
+
── 副作用:锁文件的父目录会被创建 ──
|
|
43
|
+
filelock 在 acquire() 内部对锁文件所在目录做 mkdir(parents=True, exist_ok=True)
|
|
44
|
+
(构造 FileLock 对象时不建,实测确认)。传一个不存在的目录不会报错,而是把目录
|
|
45
|
+
顺手建出来;目录建了就不回收,锁文件本身释放后一般会被删掉。
|
|
46
|
+
|
|
47
|
+
锁文件是空文件、纯占位,抢锁失败的一方不会破坏正在持锁的那一份(实测确认)。
|
|
48
|
+
进程退出时锁由操作系统自动释放。filelock 4.x 以 DELETE_ON_CLOSE 方式打开锁
|
|
49
|
+
文件,最后一个持有者关闭句柄后文件一般就没了(还有别的线程持着句柄时可能残留;
|
|
50
|
+
残留与否都别依赖:残留的锁文件不影响下次抢锁)。
|
|
51
|
+
"""
|
|
52
|
+
from filelock import FileLock, Timeout
|
|
53
|
+
|
|
54
|
+
if on_conflict not in ("exit", "return"):
|
|
55
|
+
raise ValueError(f'on_conflict must be "exit" or "return", got {on_conflict!r}')
|
|
56
|
+
|
|
57
|
+
if lock_file is None:
|
|
58
|
+
lock_file = default_lock_file()
|
|
59
|
+
|
|
60
|
+
lock = FileLock(str(lock_file), timeout=0)
|
|
61
|
+
try:
|
|
62
|
+
lock.acquire(blocking=False)
|
|
63
|
+
except Timeout:
|
|
64
|
+
logging.error(f"脚本正在运行,本次运行退出(锁文件:{lock_file})")
|
|
65
|
+
if on_conflict == "exit":
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
# 留住引用,让锁在整个进程存活期间一直被持有(局部变量退出就会被释放),
|
|
70
|
+
# 设置一个全局字典,将lock作为值放到字典里。字典不释放,lock就不会被释放
|
|
71
|
+
_held_locks[str(lock_file)] = lock
|
|
72
|
+
logging.info(f"脚本未运行,继续执行(锁文件:{lock_file})")
|
|
73
|
+
return True
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""全局未捕获异常钩子:主线程 + 子线程都写进日志。"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import traceback
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def handle_global_exception(exc_type, exc_value, exc_traceback) -> None:
|
|
10
|
+
"""sys.excepthook 的实现:把未捕获异常写进日志。"""
|
|
11
|
+
tb_str = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
|
|
12
|
+
logging.error(f"全局异常捕获:\n{tb_str}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def handle_thread_exception(args) -> None:
|
|
16
|
+
"""threading.excepthook 的实现。
|
|
17
|
+
|
|
18
|
+
子线程不受 sys.excepthook 管辖,不挂这个钩子的话,线程里的 traceback 只会打到
|
|
19
|
+
stderr,永远进不了日志文件。
|
|
20
|
+
"""
|
|
21
|
+
tb_str = "".join(traceback.format_exception(
|
|
22
|
+
args.exc_type, args.exc_value, args.exc_traceback))
|
|
23
|
+
thread_name = getattr(args.thread, "name", "<unknown>")
|
|
24
|
+
logging.error(f"子线程未捕获异常(线程:{thread_name}):\n{tb_str}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def enable_exception_logging() -> None:
|
|
28
|
+
"""快捷写法,等价于:sys.excepthook / threading.excepthook = 上面两个钩子。"""
|
|
29
|
+
sys.excepthook = handle_global_exception
|
|
30
|
+
threading.excepthook = handle_thread_exception
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""轮转文件日志 + 控制台日志的初始化。"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import logging.handlers
|
|
5
|
+
import pathlib
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from .paths import script_dir, script_name
|
|
9
|
+
|
|
10
|
+
_FORMAT = "%(asctime)s [%(levelname)-8s] %(filename)12s:%(lineno)-4d %(funcName)-12s: %(message)s"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def default_log_file() -> str:
|
|
14
|
+
"""<脚本目录>/logs/<脚本名>(_debug.log / _info.log 后缀自动加)。"""
|
|
15
|
+
return str(script_dir() / "logs" / script_name())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def init_logging_config(
|
|
19
|
+
log_file: str | None = None,
|
|
20
|
+
max_bytes: int = 10 * 1024 * 1024,
|
|
21
|
+
backup_count: int = 7,
|
|
22
|
+
force: bool = False,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""配置根日志器:*_debug.log + *_info.log(轮转)+ 控制台。
|
|
25
|
+
|
|
26
|
+
log_file 默认为入口脚本旁边的 logs/<脚本名>。
|
|
27
|
+
如果根日志器已经有 handler(pytest、Jupyter,或别的库先配置过日志),
|
|
28
|
+
本函数不做任何事,并在 stderr 明确提示一句 —— 以前静默跳过等于
|
|
29
|
+
"日志一个都没落盘,还不知道为什么"。
|
|
30
|
+
确实要接管,就传 force=True,会先移除已有的 handler。
|
|
31
|
+
没有控制台时(pythonw.exe、PyInstaller --noconsole,此时 sys.stderr 为 None),
|
|
32
|
+
跳过控制台 handler,只留文件日志。
|
|
33
|
+
"""
|
|
34
|
+
if logging.root.handlers and not force:
|
|
35
|
+
if sys.stderr is not None:
|
|
36
|
+
print(
|
|
37
|
+
"[sc92_lib] 根日志器已有 handler,init_logging_config() 没有生效,"
|
|
38
|
+
"日志不会写到文件;确认要接管请传 force=True。",
|
|
39
|
+
file=sys.stderr,
|
|
40
|
+
)
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
for handler in logging.root.handlers[:]:
|
|
44
|
+
logging.root.removeHandler(handler)
|
|
45
|
+
|
|
46
|
+
if log_file is None:
|
|
47
|
+
log_file = default_log_file()
|
|
48
|
+
|
|
49
|
+
debug_path = pathlib.Path(f"{log_file}_debug.log")
|
|
50
|
+
info_path = pathlib.Path(f"{log_file}_info.log")
|
|
51
|
+
debug_path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
debug_handler = logging.handlers.RotatingFileHandler(
|
|
54
|
+
debug_path, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
|
|
55
|
+
)
|
|
56
|
+
debug_handler.setLevel(logging.DEBUG)
|
|
57
|
+
|
|
58
|
+
info_handler = logging.handlers.RotatingFileHandler(
|
|
59
|
+
info_path, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
|
|
60
|
+
)
|
|
61
|
+
info_handler.setLevel(logging.INFO)
|
|
62
|
+
|
|
63
|
+
handlers = [debug_handler, info_handler]
|
|
64
|
+
if sys.stderr is not None:
|
|
65
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
66
|
+
console_handler.setLevel(logging.INFO)
|
|
67
|
+
handlers.append(console_handler)
|
|
68
|
+
|
|
69
|
+
logging.basicConfig(level=logging.DEBUG, format=_FORMAT, handlers=handlers)
|
sc92_lib/is_worktime.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""工作时段判断:按星期几 + HHMM 时段判断当前时间是否在工作时间内。"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
_DAY_RE = re.compile(r"[1-7]") # 单个星期数字:1=周一 … 7=周日
|
|
9
|
+
_PERIOD_RE = re.compile(r"([0-9]{4})-([0-9]{4})") # 时段:"0900-1200",两端各 4 位数字
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_worktime(worktime_config: dict | None = None) -> bool:
|
|
13
|
+
"""判断当前时间是否在工作时间内。
|
|
14
|
+
|
|
15
|
+
1=周一, 7=周日。键只能写单个数字("3"),或 "起-止" 区间("1-5"):区间两端都必须是
|
|
16
|
+
1-7 且 起 <= 止,不支持跨周("6-2" 报错退出,请拆成 "6-7" 和 "1" 两行);
|
|
17
|
+
同一个星期写多次时以最后一次为准。
|
|
18
|
+
值是 "HHMM-HHMM" 字符串列表:两端各必须是 4 位数字且 起 <= 止,左闭右开 [起, 止),
|
|
19
|
+
所以 "0900-1300" 在 13:00 整就算下班;不支持跨零点("2200-0200" 报错退出,
|
|
20
|
+
请拆成 "2200-2400" 和 "0000-0200");这天不上班写空列表 []。
|
|
21
|
+
键或值的格式不合法:logging.error 说清是哪个键、哪个时段、错在哪里,然后 sys.exit(1),
|
|
22
|
+
不做任何猜测、不自动补全(整个配置都会校验,不是只校验今天那一行)。
|
|
23
|
+
配置为空、或者今天没有排班,都返回 False。
|
|
24
|
+
|
|
25
|
+
日志:每次调用只写一条 INFO 结论(星期几、几点、命中哪个时段 / 为什么不算);
|
|
26
|
+
逐时段的比较细节和时段列表写在 DEBUG,只在 *_debug.log 里,不会刷屏。
|
|
27
|
+
"""
|
|
28
|
+
weekday_names = {1: "一", 2: "二", 3: "三", 4: "四", 5: "五", 6: "六", 7: "日"}
|
|
29
|
+
|
|
30
|
+
if not worktime_config:
|
|
31
|
+
logging.warning("工作时段配置为空 → False")
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
if not isinstance(worktime_config, dict):
|
|
35
|
+
logging.error(
|
|
36
|
+
f"is_worktime 配置格式错误:worktime_config 是 {type(worktime_config).__name__},"
|
|
37
|
+
f"要求形如 {{星期: [\"HHMM-HHMM\", …]}} 的字典,脚本退出"
|
|
38
|
+
)
|
|
39
|
+
sys.exit(1)
|
|
40
|
+
|
|
41
|
+
expanded = {}
|
|
42
|
+
for key, periods in worktime_config.items():
|
|
43
|
+
key_text = str(key).strip()
|
|
44
|
+
|
|
45
|
+
# —— 校验键:单个 1-7,或 "起-止" 区间(起 <= 止)——
|
|
46
|
+
if "-" in key_text: # 区间写法,例如 "3-5" 表示星期三到星期五
|
|
47
|
+
head, _, tail = (part.strip() for part in key_text.partition("-"))
|
|
48
|
+
if not _DAY_RE.fullmatch(head) or not _DAY_RE.fullmatch(tail):
|
|
49
|
+
logging.error(
|
|
50
|
+
f"is_worktime 星期键格式错误:{key_text!r},区间写法必须是 \"起-止\","
|
|
51
|
+
f"两端都是 1-7 的单个数字(1=周一 … 7=周日),例如 \"1-5\",脚本退出"
|
|
52
|
+
)
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
first_day, last_day = int(head), int(tail)
|
|
55
|
+
if first_day > last_day:
|
|
56
|
+
logging.error(
|
|
57
|
+
f"is_worktime 星期键格式错误:{key_text!r},起 {first_day} 大于止 {last_day},"
|
|
58
|
+
f"不支持跨周——周六日再加周一请拆成 \"6-7\" 和 \"1\" 两行,脚本退出"
|
|
59
|
+
)
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
days = [str(day) for day in range(first_day, last_day + 1)]
|
|
62
|
+
elif _DAY_RE.fullmatch(key_text):
|
|
63
|
+
days = [key_text]
|
|
64
|
+
else:
|
|
65
|
+
logging.error(
|
|
66
|
+
f"is_worktime 星期键格式错误:{key_text!r},键只能是 1-7 的单个数字"
|
|
67
|
+
f"(1=周一 … 7=周日),或 \"1-5\" 这种起不大于止的区间,脚本退出"
|
|
68
|
+
)
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
|
|
71
|
+
# —— 校验值:空列表,或 "HHMM-HHMM" 字符串列表(起 <= 止)——
|
|
72
|
+
if not isinstance(periods, (list, tuple)):
|
|
73
|
+
logging.error(
|
|
74
|
+
f"is_worktime 时段格式错误:{key_text!r} 的值是 {type(periods).__name__},"
|
|
75
|
+
f"必须是 \"HHMM-HHMM\" 字符串组成的列表,这天不上班写空列表 [],脚本退出"
|
|
76
|
+
)
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
|
|
79
|
+
for period in periods:
|
|
80
|
+
matched = _PERIOD_RE.fullmatch(period) if isinstance(period, str) else None
|
|
81
|
+
if matched is None:
|
|
82
|
+
logging.error(
|
|
83
|
+
f"is_worktime 时段格式错误:{key_text!r} 里的 {period!r}"
|
|
84
|
+
f"{'' if isinstance(period, str) else f'({type(period).__name__})'},"
|
|
85
|
+
f"时段必须是 \"HHMM-HHMM\",两端各 4 位数字,例如 \"0900-1200\",脚本退出"
|
|
86
|
+
)
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
if int(matched.group(1)) > int(matched.group(2)):
|
|
89
|
+
logging.error(
|
|
90
|
+
f"is_worktime 时段格式错误:{key_text!r} 里的 {period!r},"
|
|
91
|
+
f"起 {matched.group(1)} 大于止 {matched.group(2)},不支持跨零点——"
|
|
92
|
+
f"22:00 上到次日 02:00 请拆成 \"2200-2400\" 和 \"0000-0200\",脚本退出"
|
|
93
|
+
)
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
|
|
96
|
+
for day in days:
|
|
97
|
+
expanded[day] = periods
|
|
98
|
+
|
|
99
|
+
now = datetime.now()
|
|
100
|
+
weekday = str(now.weekday() + 1)
|
|
101
|
+
current = now.hour * 100 + now.minute
|
|
102
|
+
today_periods = expanded.get(weekday, [])
|
|
103
|
+
day_label = f"星期{weekday_names[int(weekday)]} {now:%H:%M}"
|
|
104
|
+
|
|
105
|
+
if not today_periods:
|
|
106
|
+
logging.info(f"{day_label} 今天没有排班 → False")
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
logging.debug(f"{day_label} 时段列表 = {today_periods}")
|
|
110
|
+
|
|
111
|
+
for period in today_periods:
|
|
112
|
+
# 能走到这里说明上面已整体校验通过,period 必定是 "HHMM-HHMM",可以放心拆
|
|
113
|
+
start, end = period.split("-")
|
|
114
|
+
start_value, end_value = int(start), int(end)
|
|
115
|
+
hit = start_value <= current < end_value
|
|
116
|
+
logging.debug(
|
|
117
|
+
f"{day_label} 检查时段 {period} → "
|
|
118
|
+
f"{start_value:04d} <= {current:04d} < {end_value:04d} → "
|
|
119
|
+
f"{'命中' if hit else '未命中'}"
|
|
120
|
+
)
|
|
121
|
+
if hit:
|
|
122
|
+
logging.info(f"{day_label} 命中时段 {period} → True")
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
logging.info(
|
|
126
|
+
f"{day_label} 未命中任何时段 → False"
|
|
127
|
+
f"(今天排班:{'、'.join(today_periods)})"
|
|
128
|
+
)
|
|
129
|
+
return False
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""MQTT 发布器。凭据只能来自构造函数的显式参数。
|
|
2
|
+
|
|
3
|
+
绝不要把 host / username / password / topic 硬编码进这个源文件(本包会发布到
|
|
4
|
+
PyPI,源码里的任何真实凭据都会公开)。推荐做法:放在你自己的 YAML 配置文件里
|
|
5
|
+
(见 read_yaml),再把读到的值传进 MqttPublisher(...)。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
import paho.mqtt.client as mqtt
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MqttPublisher:
|
|
14
|
+
"""一次性 MQTT 发布器:连接、发一条消息、断开。
|
|
15
|
+
|
|
16
|
+
逻辑保持朴素:connect -> loop_start -> publish -> wait_for_publish -> 收尾。
|
|
17
|
+
connect_timeout / publish_timeout 直接用 paho 自带的超时属性,不再自己开线程。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
host: str,
|
|
23
|
+
port: int = 1883,
|
|
24
|
+
username: str | None = None,
|
|
25
|
+
password: str | None = None,
|
|
26
|
+
topic: str | None = None,
|
|
27
|
+
qos: int = 2,
|
|
28
|
+
client_id: str | None = None,
|
|
29
|
+
connect_timeout: float = 5,
|
|
30
|
+
publish_timeout: float = 10,
|
|
31
|
+
):
|
|
32
|
+
self.host = host
|
|
33
|
+
self.port = int(port)
|
|
34
|
+
self.username = username
|
|
35
|
+
self.password = password
|
|
36
|
+
self.topic = topic
|
|
37
|
+
self.qos = int(qos)
|
|
38
|
+
self.client_id = client_id
|
|
39
|
+
self.connect_timeout = connect_timeout
|
|
40
|
+
self.publish_timeout = publish_timeout
|
|
41
|
+
|
|
42
|
+
missing = [n for n, v in (("host", self.host), ("topic", self.topic)) if not v]
|
|
43
|
+
if missing:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"缺少 MQTT 参数: {', '.join(missing)}。请在调用脚本中显式传入,"
|
|
46
|
+
"凭据建议存放在本地 YAML 配置文件里用 read_yaml() 读取"
|
|
47
|
+
"(本库源码与 PyPI 发布物中不包含任何真实凭据)。"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def publish(self, msg, topic: str | None = None, qos: int | None = None) -> bool:
|
|
51
|
+
"""发布一条消息;只有 broker 真的收下了才返回 True。
|
|
52
|
+
|
|
53
|
+
QoS 1/2:True 表示 broker 已确认收到。
|
|
54
|
+
QoS 0 本身没有确认机制:True 只表示已写入 socket。
|
|
55
|
+
任何连接失败、超时、异常都记 logging.error 并返回 False,不抛出。
|
|
56
|
+
"""
|
|
57
|
+
topic = topic or self.topic
|
|
58
|
+
qos = self.qos if qos is None else int(qos)
|
|
59
|
+
payload = msg if isinstance(msg, (bytes, bytearray)) else str(msg)
|
|
60
|
+
|
|
61
|
+
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=self.client_id)
|
|
62
|
+
if self.username is not None:
|
|
63
|
+
client.username_pw_set(self.username, self.password)
|
|
64
|
+
# paho 内部的 socket.create_connection 用的就是这个属性,能真正限制 TCP 连接耗时
|
|
65
|
+
client.connect_timeout = self.connect_timeout
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
logging.info(f"正在连接 MQTT Broker {self.host}:{self.port} ...")
|
|
69
|
+
client.connect(self.host, self.port, keepalive=60)
|
|
70
|
+
client.loop_start()
|
|
71
|
+
|
|
72
|
+
info = client.publish(topic, payload=payload, qos=qos)
|
|
73
|
+
info.wait_for_publish(timeout=self.publish_timeout)
|
|
74
|
+
if info.rc == mqtt.MQTT_ERR_SUCCESS and info.is_published():
|
|
75
|
+
logging.info(f"消息已发布到主题 '{topic}' (QoS {qos}): {payload}")
|
|
76
|
+
return True
|
|
77
|
+
logging.error(
|
|
78
|
+
f"发布未被 broker 确认(超时 {self.publish_timeout}s):rc={info.rc} 主题={topic}"
|
|
79
|
+
)
|
|
80
|
+
return False
|
|
81
|
+
except Exception as e:
|
|
82
|
+
logging.error(f"MQTT 发布发生异常: {e}")
|
|
83
|
+
return False
|
|
84
|
+
finally:
|
|
85
|
+
try:
|
|
86
|
+
client.disconnect()
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
try:
|
|
90
|
+
client.loop_stop()
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
sc92_lib/paths.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""解析当前运行脚本所在目录与文件名,兼容 PyInstaller 打包后的 exe。"""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _is_frozen() -> bool:
|
|
8
|
+
return getattr(sys, "frozen", False)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _module_entry() -> str | None:
|
|
12
|
+
"""以模块方式启动时返回模块入口名,普通独立脚本返回 None。
|
|
13
|
+
|
|
14
|
+
`python -m pkg[.mod]`(以及手动去跑某个包的 __main__.py)没有自己的入口脚本
|
|
15
|
+
名:sys.argv[0] 只会给到一个 "__main__.py",而真正的模块通常躺在 site-packages
|
|
16
|
+
里 —— 那里不该被写日志/锁/配置文件。所以这些情况下返回模块名 / 包名。
|
|
17
|
+
"""
|
|
18
|
+
if _is_frozen():
|
|
19
|
+
return None
|
|
20
|
+
spec = getattr(sys.modules.get("__main__"), "__spec__", None)
|
|
21
|
+
name = getattr(spec, "name", None)
|
|
22
|
+
if name and name != "__main__":
|
|
23
|
+
suffix = ".__main__"
|
|
24
|
+
return name[: -len(suffix)] if name.endswith(suffix) else name
|
|
25
|
+
first = pathlib.Path(sys.argv[0])
|
|
26
|
+
if first.stem == "__main__" and first.parent != pathlib.Path():
|
|
27
|
+
return first.resolve().parent.name
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def script_dir() -> pathlib.Path:
|
|
32
|
+
"""入口脚本所在目录(被 PyInstaller 打包后是 exe 所在目录)。
|
|
33
|
+
|
|
34
|
+
`python -m ...` 跑的是模块不是脚本,所以用当前工作目录:模块自己的目录
|
|
35
|
+
往往就是 site-packages。
|
|
36
|
+
"""
|
|
37
|
+
if _is_frozen():
|
|
38
|
+
return pathlib.Path(sys.executable).parent
|
|
39
|
+
if _module_entry() is not None:
|
|
40
|
+
return pathlib.Path.cwd()
|
|
41
|
+
first = pathlib.Path(sys.argv[0])
|
|
42
|
+
if not first.name or first.name == "-c":
|
|
43
|
+
return pathlib.Path.cwd()
|
|
44
|
+
return first.resolve().parent
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def script_name() -> str:
|
|
48
|
+
"""入口脚本的文件名(不含扩展名):打包后取 exe 名,`python -m` 取模块/包名。"""
|
|
49
|
+
if _is_frozen():
|
|
50
|
+
return pathlib.Path(sys.executable).stem
|
|
51
|
+
module = _module_entry()
|
|
52
|
+
if module is not None:
|
|
53
|
+
return module
|
|
54
|
+
stem = pathlib.Path(sys.argv[0]).stem
|
|
55
|
+
if not stem or stem == "-c":
|
|
56
|
+
return pathlib.Path.cwd().name
|
|
57
|
+
return stem
|
sc92_lib/py.typed
ADDED
|
File without changes
|
sc92_lib/to_yaml.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""把值转成 YAML 文本;转换失败就原样返回。"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _safe_repr(value: object) -> str:
|
|
10
|
+
"""尽量把原值转成可读文本,repr/str 都失败时给一个占位说明。"""
|
|
11
|
+
try:
|
|
12
|
+
return repr(value)
|
|
13
|
+
except Exception:
|
|
14
|
+
pass
|
|
15
|
+
try:
|
|
16
|
+
return str(value)
|
|
17
|
+
except Exception:
|
|
18
|
+
return f"<{type(value).__name__} 对象,repr/str 均失败>"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def to_yaml(value: object) -> object:
|
|
22
|
+
"""尽可能输出 YAML 字符串,失败则原样返回。
|
|
23
|
+
|
|
24
|
+
只处理两类输入:
|
|
25
|
+
|
|
26
|
+
- dict / list:直接 safe_dump。
|
|
27
|
+
- 其它类型:先 str(value),再 json.loads;只有解析结果是 dict / list
|
|
28
|
+
才 safe_dump。
|
|
29
|
+
|
|
30
|
+
任何一步失败都返回原值,不抛异常。成功和失败都写 logging.debug。
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
# 字典、列表直接转,对于特殊的字典(例如字典内嵌套对象),依旧无法转换为yaml。转失败直接return
|
|
34
|
+
if isinstance(value, (dict, list)):
|
|
35
|
+
try:
|
|
36
|
+
result = yaml.safe_dump(
|
|
37
|
+
value,
|
|
38
|
+
allow_unicode=True,
|
|
39
|
+
sort_keys=False,
|
|
40
|
+
default_flow_style=False,
|
|
41
|
+
)
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
logging.debug(
|
|
44
|
+
f"to_yaml 无法转换 {type(value).__name__} 对象,"
|
|
45
|
+
f"原样返回({type(exc).__name__}: {exc}):{_safe_repr(value)}"
|
|
46
|
+
)
|
|
47
|
+
return value
|
|
48
|
+
log_text = result.rstrip("\n")
|
|
49
|
+
logging.debug(f"to_yaml 转换结果:\n{log_text}")
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
# 其他类型先转字符串,失败直接原样返回
|
|
53
|
+
try:
|
|
54
|
+
text = str(value)
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
logging.debug(
|
|
57
|
+
f"to_yaml 无法把 {type(value).__name__} 对象转成字符串,"
|
|
58
|
+
f"原样返回({type(exc).__name__}: {exc}):{_safe_repr(value)}"
|
|
59
|
+
)
|
|
60
|
+
return value
|
|
61
|
+
# 其他类型转字符串再用json解析,失败直接原样返回
|
|
62
|
+
try:
|
|
63
|
+
parsed = json.loads(text)
|
|
64
|
+
except Exception:
|
|
65
|
+
logging.debug(f"to_yaml JSON 解析字符串失败,原样返回:{_safe_repr(value)}")
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
if not isinstance(parsed, (dict, list)):
|
|
69
|
+
logging.debug(
|
|
70
|
+
f"to_yaml JSON 结果不是 dict/list({type(parsed).__name__}),"
|
|
71
|
+
f"原样返回:{_safe_repr(value)}"
|
|
72
|
+
)
|
|
73
|
+
return value
|
|
74
|
+
# 其他类型转字符串再用json解析再用yaml解析,失败直接原样返回
|
|
75
|
+
try:
|
|
76
|
+
result = yaml.safe_dump(
|
|
77
|
+
parsed,
|
|
78
|
+
allow_unicode=True,
|
|
79
|
+
sort_keys=False,
|
|
80
|
+
default_flow_style=False,
|
|
81
|
+
)
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
logging.debug(
|
|
84
|
+
f"to_yaml 无法转换 JSON 结果,"
|
|
85
|
+
f"原样返回({type(exc).__name__}: {exc}):{_safe_repr(value)}"
|
|
86
|
+
)
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
log_text = result.rstrip("\n")
|
|
90
|
+
logging.debug(f"to_yaml 转换结果:\n{log_text}")
|
|
91
|
+
return result
|
sc92_lib/yaml_config.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""YAML 配置文件读写。
|
|
2
|
+
|
|
3
|
+
原则:只有两种情况会停下等人工,其余一律原样返回。
|
|
4
|
+
文件不存在 -> 按 default_config 写出模板后 exit(1),让用户检查修改再重跑。
|
|
5
|
+
文件损坏(非 UTF-8 / -> logging.error 后 exit(1),坏文件原样保留交人工处置。
|
|
6
|
+
YAML 语法坏)
|
|
7
|
+
其余情况 -> 直接返回 yaml.safe_load 的结果,不校验顶层是不是 dict
|
|
8
|
+
(可以是 dict / list / 标量 / None / {})。
|
|
9
|
+
写文件 -> 原子落盘:先写同目录 .tmp 再 os.replace 换名,
|
|
10
|
+
崩溃/断电都不会把已有配置截断成半个 YAML。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import pathlib
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
from .paths import script_dir, script_name
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def default_yaml_file() -> str:
|
|
24
|
+
"""<脚本目录>/configs/<脚本名>.config.yaml(和日志一样各自归到子目录里)。"""
|
|
25
|
+
return str(script_dir() / "configs" / f"{script_name()}.config.yaml")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def write_yaml(config: object,
|
|
29
|
+
config_file: str | pathlib.Path | None = None) -> None:
|
|
30
|
+
"""把 config 写成 YAML 文件(utf-8,中文与插入顺序保留)。
|
|
31
|
+
|
|
32
|
+
config 必填、位置在前,只接受这几种类型,直接 safe_dump:
|
|
33
|
+
dict / list / str / int / float / bool / None
|
|
34
|
+
其它类型(如 tuple / set / 自定义对象)视为格式不正确,logging.error 后 exit(1),
|
|
35
|
+
交人工处理;safe_dump 本身抛异常也同样记错误后 exit(1)。
|
|
36
|
+
|
|
37
|
+
config_file 可省略,默认为 <脚本目录>/configs/<脚本名>.config.yaml。
|
|
38
|
+
用 safe_dump:保证写出的文件 read_yaml()(safe_load)一定能读回。
|
|
39
|
+
|
|
40
|
+
落盘是原子的:先写同目录的 <文件名>.tmp 并 fsync,再用 os.replace 换成正式名字。
|
|
41
|
+
所以就算写到一半被强杀、崩溃或断电,目标文件仍是上一次的完整内容,不会出现
|
|
42
|
+
"截断的半个 YAML 把原配置毁掉";中途失败留下的 .tmp 由 finally 清掉(被强杀或断电
|
|
43
|
+
来不及跑 finally 时会残留一个 .tmp,下次写入直接覆盖,不影响正确性)。
|
|
44
|
+
tmp 必须和目标同目录 —— 跨盘符的 replace 不保证原子,所以不复用系统临时目录。
|
|
45
|
+
|
|
46
|
+
Windows 特例:目标文件被别的程序以"不共享删除"的方式开着时(实测记事本、
|
|
47
|
+
PowerShell 都不挡,只有另一个 python 进程 open() 挂着句柄才挡),os.replace 会抛
|
|
48
|
+
PermissionError。这时记一条 WARNING 并退回直接覆盖写:这一趟没有原子保护,
|
|
49
|
+
但保证配置照样存得下去。连直接写也失败(真没权限/磁盘问题)则异常照常抛出。
|
|
50
|
+
"""
|
|
51
|
+
if not isinstance(config, (dict, list, str, int, float, bool, type(None))):
|
|
52
|
+
logging.error(
|
|
53
|
+
f"write_yaml 的 config 类型不支持:{type(config).__name__},"
|
|
54
|
+
f"只接受 dict/list/str/int/float/bool/None,请人工介入后重新运行,脚本退出"
|
|
55
|
+
)
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
text = yaml.safe_dump(
|
|
60
|
+
config, allow_unicode=True, sort_keys=False, default_flow_style=False
|
|
61
|
+
)
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
logging.error(
|
|
64
|
+
f"write_yaml 转成 YAML 失败:{type(exc).__name__}: {exc},"
|
|
65
|
+
f"请人工介入后重新运行,脚本退出"
|
|
66
|
+
)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
# 顶层是裸标量时 safe_dump 会在结尾补 "..."+换行 结束标记,去掉让文件更干净
|
|
70
|
+
if text.endswith("\n...\n"):
|
|
71
|
+
text = text[: -len("\n...\n")] + "\n"
|
|
72
|
+
|
|
73
|
+
if config_file is None:
|
|
74
|
+
config_file = default_yaml_file()
|
|
75
|
+
path = pathlib.Path(config_file)
|
|
76
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
|
|
78
|
+
# 原子落盘:写同目录临时文件 -> flush + fsync -> os.replace 换名。
|
|
79
|
+
# 中途被强杀/断电时,目标文件保持上一次的完整内容,不会留下半截 YAML。
|
|
80
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
81
|
+
try:
|
|
82
|
+
with tmp.open("w", encoding="utf-8") as f:
|
|
83
|
+
f.write(text)
|
|
84
|
+
f.flush()
|
|
85
|
+
os.fsync(f.fileno())
|
|
86
|
+
try:
|
|
87
|
+
os.replace(tmp, path)
|
|
88
|
+
except PermissionError as exc:
|
|
89
|
+
# Windows 上目标文件被别的程序以"不共享删除"的方式打开时,改名会被拒
|
|
90
|
+
# (实测:记事本、PowerShell 连 -Wait 都不挡;只有另一个 python open() 挂着句柄才挡)。
|
|
91
|
+
# 保存这件事还得继续,所以退回改前的直接覆盖写 —— 这一趟没有原子保护,但功能不丢。
|
|
92
|
+
logging.warning(
|
|
93
|
+
f"配置文件被占用,原子替换失败(WinError {exc.winerror}),"
|
|
94
|
+
f"退回直接覆盖写入: {path}"
|
|
95
|
+
)
|
|
96
|
+
with path.open("w", encoding="utf-8") as f:
|
|
97
|
+
f.write(text)
|
|
98
|
+
f.flush()
|
|
99
|
+
os.fsync(f.fileno())
|
|
100
|
+
finally:
|
|
101
|
+
tmp.unlink(missing_ok=True) # 成功时 tmp 已被 replace 走,这步是空操作
|
|
102
|
+
logging.info(f"配置已写入: {path}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def read_yaml(config_file: str | pathlib.Path | None = None,
|
|
106
|
+
default_config: dict = {}) -> object:
|
|
107
|
+
"""把 YAML 配置文件读成 Python 值。只有「文件不存在」和「文件损坏」会停下等人工,
|
|
108
|
+
其余一律把 yaml.safe_load 的结果原样返回,不校验顶层类型。
|
|
109
|
+
|
|
110
|
+
config_file 默认为 <脚本目录>/configs/<脚本名>.config.yaml。
|
|
111
|
+
文件不存在 -> 按 default_config 写出模板,logging.error,exit(1) 等人工复核。
|
|
112
|
+
加载 / 解析失败 -> logging.error(坏文件原样留着交人工处置),exit(1)。
|
|
113
|
+
其余 -> 原样返回 safe_load 的结果:dict / list / str / int / float /
|
|
114
|
+
bool / None / {}(空文件、纯注释、null 都归这里)。
|
|
115
|
+
|
|
116
|
+
default_config 默认 {},仅在「文件不存在」写模板时用到。写模板后脚本立刻退出,
|
|
117
|
+
所以直接把 default_config 交给 write_yaml,无需深拷贝。
|
|
118
|
+
"""
|
|
119
|
+
if config_file is None:
|
|
120
|
+
config_file = default_yaml_file()
|
|
121
|
+
path = pathlib.Path(config_file)
|
|
122
|
+
|
|
123
|
+
if not path.is_file():
|
|
124
|
+
write_yaml(default_config, path)
|
|
125
|
+
logging.error(
|
|
126
|
+
f"配置文件不存在: {path},已按默认配置生成模板,请检查修改后重新运行本脚本"
|
|
127
|
+
)
|
|
128
|
+
sys.exit(1)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
with path.open("r", encoding="utf-8") as f:
|
|
132
|
+
loaded = yaml.safe_load(f)
|
|
133
|
+
except (UnicodeDecodeError, yaml.YAMLError) as exc:
|
|
134
|
+
logging.error(
|
|
135
|
+
f"配置文件加载/解析失败: {path},错误信息: {exc},"
|
|
136
|
+
f"坏文件原样保留,请人工处置后重新运行,脚本退出"
|
|
137
|
+
)
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
|
|
140
|
+
logging.info(f"配置已加载: {path}")
|
|
141
|
+
return loaded
|
|
@@ -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,15 @@
|
|
|
1
|
+
sc92_lib/__init__.py,sha256=vOqOvDeVBuAIQYQgpW28LFn0a4o2Dp-qkwZelfyoCEk,2522
|
|
2
|
+
sc92_lib/check_running_lock.py,sha256=s_rfJJpMjc7CyQ1bLozE7UJpI62tUVVomFI_86pF94A,3668
|
|
3
|
+
sc92_lib/enable_exception_logging.py,sha256=1cj__WC0YeGoH8xLoRGxKis1TyVUiwEzPvukaKKGMDY,1162
|
|
4
|
+
sc92_lib/init_logging_config.py,sha256=rWeA0VzAYYQ6LUhvLT3dkkOw-IPmhFBU2T-BSacnmRI,2575
|
|
5
|
+
sc92_lib/is_worktime.py,sha256=Dhv5ZoDmhChLaERvMZ5nphpttx1n6KOSQjTtOEumGn0,6225
|
|
6
|
+
sc92_lib/mqtt_publisher.py,sha256=HF-152vMyzwA_Kp_oK_1ITAKSLkA4Q0LPQ35D3pX3Do,3713
|
|
7
|
+
sc92_lib/paths.py,sha256=38v7WUeFPYdZFPeGfvnLcbcXg60_emQc7PXfpfIwGug,2063
|
|
8
|
+
sc92_lib/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
sc92_lib/to_yaml.py,sha256=o-7xanLXJsMa_nW81xxtHo-93jrjp1wcZFwnZ0FlHh0,3013
|
|
10
|
+
sc92_lib/yaml_config.py,sha256=wT5ZNT4yyOuMxa7yWh8qw-dGr3L8Pa-I0PMOmyotRU4,6819
|
|
11
|
+
sc92_lib-0.1.0.dist-info/licenses/LICENSE,sha256=CDNGjpcAw0Yxah17hAt_jF3NWRI84b71n9cIe8hKWZM,1061
|
|
12
|
+
sc92_lib-0.1.0.dist-info/METADATA,sha256=R8OibbZescAH4O6iaeRWVo49hrrXXF4uOXntfBRQKrc,18917
|
|
13
|
+
sc92_lib-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
14
|
+
sc92_lib-0.1.0.dist-info/top_level.txt,sha256=eMScDdAipRv4w7ChdW2e9PZymV7zaJ4D4jTsaRskPuk,9
|
|
15
|
+
sc92_lib-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
sc92_lib
|