sc92-public-define 0.3.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,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,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: sc92-public-define
3
+ Version: 0.3.1
4
+ Summary: Personal shared utilities: file lock, logging, MQTT publishing and YAML config helpers
5
+ Author: sc92
6
+ License-Expression: MIT
7
+ Keywords: filelock,logging,mqtt,yaml,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: Operating System :: OS Independent
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: filelock>=4.0
17
+ Requires-Dist: paho-mqtt>=2.0
18
+ Requires-Dist: PyYAML>=6.0
19
+ Dynamic: license-file
20
+
21
+ # sc92-public-define
22
+
23
+ Personal shared utilities extracted from everyday scripts:
24
+
25
+ 1. **Single-instance file lock** — `check_running_lock()`: if the script is
26
+ already running elsewhere, exit (or return False) immediately.
27
+ 2. **Rotating logging setup** — `init_logging_config()`: one call to get
28
+ `*_debug.log` + `*_info.log` (10 MB × 7 backups) + console output. If another
29
+ library already configured the root logger it now warns on stderr instead of
30
+ silently doing nothing (pass `force=True` to take over); with no console
31
+ (`pythonw.exe`, PyInstaller `--noconsole`) the console handler is skipped.
32
+ 3. **Global exception hooks** — `enable_exception_logging()`: uncaught
33
+ exceptions are written to the log instead of only printed to stderr — for the
34
+ main thread **and for worker threads** (`threading.excepthook`, which
35
+ `sys.excepthook` never covers).
36
+ 4. **MQTT publisher** — `MqttPublisher.publish()`: one-shot connect →
37
+ publish → disconnect. Credentials are taken from **explicit constructor
38
+ args only** (no hardcoded defaults, no environment variables) — keep them
39
+ in your local YAML config file and pass the values in.
40
+ 5. **YAML config helpers** — `read_yaml()` / `write_yaml()`: first run writes
41
+ a default template into `configs/` next to the script and exits for review;
42
+ Chinese keys and insertion order are preserved, and a file saved as
43
+ ANSI/GBK or broken YAML now fails with a readable message instead of a raw
44
+ `UnicodeDecodeError`.
45
+
46
+ All helpers are PyInstaller-frozen aware and default to paths derived from
47
+ the entry script, so typical usage needs **zero arguments**.
48
+
49
+ The derived name is `<script dir>/…/<script name>…`, so one instance means
50
+ "one running copy of this script in this directory": a renamed or copied script
51
+ is a separate instance. `python -m pkg[.mod]` is handled as module `pkg[.mod]`
52
+ with the current working directory as its base — it never writes into
53
+ site-packages.
54
+
55
+ ## Install
56
+
57
+ ```bash
58
+ pip install sc92-public-define
59
+ ```
60
+
61
+ Requires Python >= 3.11 (`filelock>=4.0`, `paho-mqtt>=2.0`, `PyYAML>=6.0`).
62
+
63
+ ## Typical usage (top of any script)
64
+
65
+ ```python
66
+ import logging
67
+ from sc92_public_define import (
68
+ check_running_lock, # single-instance lock
69
+ init_logging_config, # rotating logs + console
70
+ enable_exception_logging, # uncaught exception -> log
71
+ )
72
+
73
+ init_logging_config() # logs/<script name>_debug.log / _info.log
74
+ check_running_lock() # <script name>.running.lock next to the script;
75
+ # exits with status 1 when another copy is running
76
+ enable_exception_logging()
77
+
78
+ logging.info("main started")
79
+ ```
80
+
81
+ ### MQTT publishing (credentials live in your YAML config, never in code)
82
+
83
+ ```python
84
+ from sc92_public_define import MqttPublisher, read_yaml
85
+
86
+ server = read_yaml()["mqtt服务器"] # nested dict in <script>.config.yaml
87
+ pub = MqttPublisher(host=server["地址"], port=server["端口"],
88
+ username=server["用户名"] or None,
89
+ password=server["密码"] or None,
90
+ topic=server["主题"])
91
+ pub.publish("hello") # uses instance topic/qos
92
+ pub.publish("hello", topic="other/t", qos=1) # per-call override
93
+ ```
94
+
95
+ ### YAML config
96
+
97
+ ```python
98
+ from sc92_public_define import read_yaml, write_yaml
99
+
100
+ cfg = read_yaml(default_config={"服务器": "127.0.0.1", "端口": 8080})
101
+ # first run: creates configs/<script>.config.yaml next to the script, logs a hint, exits
102
+ cfg["端口"] = 9090
103
+ write_yaml(config_dict=cfg) # writes back to the same default file
104
+ ```
105
+
106
+ ## API
107
+
108
+ | Function | Default | Notes |
109
+ |---|---|---|
110
+ | `check_running_lock(lock_file=None, on_conflict="exit")` | `<script dir>/<script>.running.lock` | `on_conflict="exit"` → logs an error and exits with status 1; `"return"` → returns `False`; `True` when acquired |
111
+ | `init_logging_config(log_file=None, max_bytes=10MB, backup_count=7, force=False)` | `<script dir>/logs/<script>` | warns on stderr and does nothing if the root logger already has handlers (`force=True` takes over); console handler skipped when there is no console |
112
+ | `enable_exception_logging()` / `handle_global_exception` / `handle_thread_exception` | — | sets `sys.excepthook` **and** `threading.excepthook`, writes tracebacks via `logging.error` |
113
+ | `MqttPublisher(host, port=1883, username=None, password=None, topic=None, qos=2, client_id=None, connect_timeout=5, publish_timeout=10)` | explicit args only | raises `ValueError` if host/topic missing; `connect_timeout` caps DNS + TCP + the wait for CONNACK |
114
+ | `MqttPublisher.publish(msg, topic=None, qos=None)` | instance defaults | `True` only when the message really got through: QoS 1/2 require the broker acknowledgement, QoS 0 has none (written to the socket only). Wrong password, missing CONNACK, an unacknowledged publish or any network error → `False` plus a `logging.error`, never raises. `bytes`/`bytearray` payloads are sent as-is, anything else is `str()`-ed |
115
+ | `read_yaml(config_file=None, default_config=None, on_missing="exit", merge_defaults=False)` | `<script dir>/configs/<script>.config.yaml` | missing → write template + exit(1); empty file or `{}` → overwrite with the template + exit(1); non-UTF-8 / broken YAML / top level not a mapping → `ValueError`; `merge_defaults=True` fills keys that only exist in `default_config` into the returned dict (the file on disk is untouched); the returned template is a deep copy |
116
+ | `write_yaml(config_file=None, config_dict=None)` | same default file | `config_dict` is required (`None` or a non-dict raises `ValueError`); utf-8, Chinese keys & order preserved |
117
+ | `script_dir()` / `script_name()` | — | entry-script path/name; frozen exe → exe folder/stem; `python -m pkg` → cwd + `pkg` |
118
+ | `default_lock_file()` / `default_log_file()` / `default_config_file()` | — | expose the derived default paths |
119
+
120
+ ## Notes
121
+
122
+ - The lock is released automatically by the OS when the process exits;
123
+ the lock **file** is left on disk (harmless, reuse next time).
124
+ - The first `read_yaml()` run on a machine without a config file deliberately
125
+ exits with status 1 after writing the template: edit it, then run again.
126
+ - `init_logging_config()` must come **before** `check_running_lock()` /
127
+ `read_yaml()`, otherwise their "抢锁结果 / 配置已加载" INFO lines are dropped
128
+ (only the ERROR lines still reach stderr through logging's last-resort handler).
129
+ - See the `demo/` folder in the source tree for runnable examples (it is not
130
+ shipped inside the wheel or sdist).
@@ -0,0 +1,110 @@
1
+ # sc92-public-define
2
+
3
+ Personal shared utilities extracted from everyday scripts:
4
+
5
+ 1. **Single-instance file lock** — `check_running_lock()`: if the script is
6
+ already running elsewhere, exit (or return False) immediately.
7
+ 2. **Rotating logging setup** — `init_logging_config()`: one call to get
8
+ `*_debug.log` + `*_info.log` (10 MB × 7 backups) + console output. If another
9
+ library already configured the root logger it now warns on stderr instead of
10
+ silently doing nothing (pass `force=True` to take over); with no console
11
+ (`pythonw.exe`, PyInstaller `--noconsole`) the console handler is skipped.
12
+ 3. **Global exception hooks** — `enable_exception_logging()`: uncaught
13
+ exceptions are written to the log instead of only printed to stderr — for the
14
+ main thread **and for worker threads** (`threading.excepthook`, which
15
+ `sys.excepthook` never covers).
16
+ 4. **MQTT publisher** — `MqttPublisher.publish()`: one-shot connect →
17
+ publish → disconnect. Credentials are taken from **explicit constructor
18
+ args only** (no hardcoded defaults, no environment variables) — keep them
19
+ in your local YAML config file and pass the values in.
20
+ 5. **YAML config helpers** — `read_yaml()` / `write_yaml()`: first run writes
21
+ a default template into `configs/` next to the script and exits for review;
22
+ Chinese keys and insertion order are preserved, and a file saved as
23
+ ANSI/GBK or broken YAML now fails with a readable message instead of a raw
24
+ `UnicodeDecodeError`.
25
+
26
+ All helpers are PyInstaller-frozen aware and default to paths derived from
27
+ the entry script, so typical usage needs **zero arguments**.
28
+
29
+ The derived name is `<script dir>/…/<script name>…`, so one instance means
30
+ "one running copy of this script in this directory": a renamed or copied script
31
+ is a separate instance. `python -m pkg[.mod]` is handled as module `pkg[.mod]`
32
+ with the current working directory as its base — it never writes into
33
+ site-packages.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install sc92-public-define
39
+ ```
40
+
41
+ Requires Python >= 3.11 (`filelock>=4.0`, `paho-mqtt>=2.0`, `PyYAML>=6.0`).
42
+
43
+ ## Typical usage (top of any script)
44
+
45
+ ```python
46
+ import logging
47
+ from sc92_public_define import (
48
+ check_running_lock, # single-instance lock
49
+ init_logging_config, # rotating logs + console
50
+ enable_exception_logging, # uncaught exception -> log
51
+ )
52
+
53
+ init_logging_config() # logs/<script name>_debug.log / _info.log
54
+ check_running_lock() # <script name>.running.lock next to the script;
55
+ # exits with status 1 when another copy is running
56
+ enable_exception_logging()
57
+
58
+ logging.info("main started")
59
+ ```
60
+
61
+ ### MQTT publishing (credentials live in your YAML config, never in code)
62
+
63
+ ```python
64
+ from sc92_public_define import MqttPublisher, read_yaml
65
+
66
+ server = read_yaml()["mqtt服务器"] # nested dict in <script>.config.yaml
67
+ pub = MqttPublisher(host=server["地址"], port=server["端口"],
68
+ username=server["用户名"] or None,
69
+ password=server["密码"] or None,
70
+ topic=server["主题"])
71
+ pub.publish("hello") # uses instance topic/qos
72
+ pub.publish("hello", topic="other/t", qos=1) # per-call override
73
+ ```
74
+
75
+ ### YAML config
76
+
77
+ ```python
78
+ from sc92_public_define import read_yaml, write_yaml
79
+
80
+ cfg = read_yaml(default_config={"服务器": "127.0.0.1", "端口": 8080})
81
+ # first run: creates configs/<script>.config.yaml next to the script, logs a hint, exits
82
+ cfg["端口"] = 9090
83
+ write_yaml(config_dict=cfg) # writes back to the same default file
84
+ ```
85
+
86
+ ## API
87
+
88
+ | Function | Default | Notes |
89
+ |---|---|---|
90
+ | `check_running_lock(lock_file=None, on_conflict="exit")` | `<script dir>/<script>.running.lock` | `on_conflict="exit"` → logs an error and exits with status 1; `"return"` → returns `False`; `True` when acquired |
91
+ | `init_logging_config(log_file=None, max_bytes=10MB, backup_count=7, force=False)` | `<script dir>/logs/<script>` | warns on stderr and does nothing if the root logger already has handlers (`force=True` takes over); console handler skipped when there is no console |
92
+ | `enable_exception_logging()` / `handle_global_exception` / `handle_thread_exception` | — | sets `sys.excepthook` **and** `threading.excepthook`, writes tracebacks via `logging.error` |
93
+ | `MqttPublisher(host, port=1883, username=None, password=None, topic=None, qos=2, client_id=None, connect_timeout=5, publish_timeout=10)` | explicit args only | raises `ValueError` if host/topic missing; `connect_timeout` caps DNS + TCP + the wait for CONNACK |
94
+ | `MqttPublisher.publish(msg, topic=None, qos=None)` | instance defaults | `True` only when the message really got through: QoS 1/2 require the broker acknowledgement, QoS 0 has none (written to the socket only). Wrong password, missing CONNACK, an unacknowledged publish or any network error → `False` plus a `logging.error`, never raises. `bytes`/`bytearray` payloads are sent as-is, anything else is `str()`-ed |
95
+ | `read_yaml(config_file=None, default_config=None, on_missing="exit", merge_defaults=False)` | `<script dir>/configs/<script>.config.yaml` | missing → write template + exit(1); empty file or `{}` → overwrite with the template + exit(1); non-UTF-8 / broken YAML / top level not a mapping → `ValueError`; `merge_defaults=True` fills keys that only exist in `default_config` into the returned dict (the file on disk is untouched); the returned template is a deep copy |
96
+ | `write_yaml(config_file=None, config_dict=None)` | same default file | `config_dict` is required (`None` or a non-dict raises `ValueError`); utf-8, Chinese keys & order preserved |
97
+ | `script_dir()` / `script_name()` | — | entry-script path/name; frozen exe → exe folder/stem; `python -m pkg` → cwd + `pkg` |
98
+ | `default_lock_file()` / `default_log_file()` / `default_config_file()` | — | expose the derived default paths |
99
+
100
+ ## Notes
101
+
102
+ - The lock is released automatically by the OS when the process exits;
103
+ the lock **file** is left on disk (harmless, reuse next time).
104
+ - The first `read_yaml()` run on a machine without a config file deliberately
105
+ exits with status 1 after writing the template: edit it, then run again.
106
+ - `init_logging_config()` must come **before** `check_running_lock()` /
107
+ `read_yaml()`, otherwise their "抢锁结果 / 配置已加载" INFO lines are dropped
108
+ (only the ERROR lines still reach stderr through logging's last-resort handler).
109
+ - See the `demo/` folder in the source tree for runnable examples (it is not
110
+ shipped inside the wheel or sdist).
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sc92-public-define"
7
+ version = "0.3.1"
8
+ description = "Personal shared utilities: file lock, logging, MQTT publishing and YAML config helpers"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [{ name = "sc92" }]
13
+ keywords = ["filelock", "logging", "mqtt", "yaml", "utility"]
14
+ dependencies = ["filelock>=4.0", "paho-mqtt>=2.0", "PyYAML>=6.0"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Operating System :: OS Independent",
21
+ ]
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["src"]
25
+ include = ["sc92_public_define*"]
26
+
27
+ [tool.setuptools.package-data]
28
+ sc92_public_define = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,33 @@
1
+ """sc92-public-define: single-instance file lock, rotating logging, global
2
+ exception hooks (main thread + worker threads), one-shot MQTT publishing and
3
+ YAML config helpers."""
4
+
5
+ from .lock import check_running_lock, default_lock_file
6
+ from .log import default_log_file, init_logging_config
7
+ from .excepthook import (
8
+ enable_exception_logging,
9
+ handle_global_exception,
10
+ handle_thread_exception,
11
+ )
12
+ from .mqtt_publisher import MqttPublisher
13
+ from .yaml_config import default_config_file, read_yaml, write_yaml
14
+ from .paths import script_dir, script_name
15
+
16
+ __version__ = "0.3.1"
17
+
18
+ __all__ = [
19
+ "check_running_lock",
20
+ "default_lock_file",
21
+ "init_logging_config",
22
+ "default_log_file",
23
+ "handle_global_exception",
24
+ "handle_thread_exception",
25
+ "enable_exception_logging",
26
+ "MqttPublisher",
27
+ "read_yaml",
28
+ "write_yaml",
29
+ "default_config_file",
30
+ "script_dir",
31
+ "script_name",
32
+ "__version__",
33
+ ]
@@ -0,0 +1,28 @@
1
+ """Global uncaught exception hooks: main thread and worker threads -> the log."""
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 implementation: write uncaught exception to the log."""
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 implementation: worker threads are not covered by
17
+ sys.excepthook, so without this their tracebacks only hit stderr and never
18
+ reach the log file."""
19
+ tb_str = "".join(traceback.format_exception(
20
+ args.exc_type, args.exc_value, args.exc_traceback))
21
+ thread_name = getattr(args.thread, "name", "<unknown>")
22
+ logging.error(f"子线程未捕获异常(线程:{thread_name}):\n{tb_str}")
23
+
24
+
25
+ def enable_exception_logging() -> None:
26
+ """Shortcut for: sys.excepthook / threading.excepthook = the two hooks above."""
27
+ sys.excepthook = handle_global_exception
28
+ threading.excepthook = handle_thread_exception
@@ -0,0 +1,48 @@
1
+ """Single-instance running check based on 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
+ """<script dir>/<script name>.running.lock"""
13
+ return str(script_dir() / f"{script_name()}.running.lock")
14
+
15
+
16
+ def check_running_lock(lock_file: str | None = None, on_conflict: str = "exit") -> bool:
17
+ """Try to acquire an exclusive lock meaning "this script is already running".
18
+
19
+ lock_file defaults to <script>.running.lock next to the entry script.
20
+ on_conflict "exit" (default): terminate this process with exit code 1 when
21
+ the lock is held elsewhere; "return": return False and let the
22
+ caller decide.
23
+
24
+ Returns True when the lock was acquired, False on conflict with
25
+ on_conflict="return". The OS releases the lock automatically on exit;
26
+ the lock FILE itself is left on disk.
27
+ """
28
+ from filelock import FileLock, Timeout
29
+
30
+ if on_conflict not in ("exit", "return"):
31
+ raise ValueError(f'on_conflict must be "exit" or "return", got {on_conflict!r}')
32
+
33
+ if lock_file is None:
34
+ lock_file = default_lock_file()
35
+
36
+ lock = FileLock(str(lock_file), timeout=0)
37
+ try:
38
+ lock.acquire(blocking=False)
39
+ except Timeout:
40
+ logging.error(f"脚本正在运行,本次运行退出(锁文件:{lock_file})")
41
+ if on_conflict == "exit":
42
+ sys.exit(1)
43
+ return False
44
+
45
+ # keep a reference so the lock is held for the whole life of the process
46
+ _held_locks[str(lock_file)] = lock
47
+ logging.info(f"脚本未运行,继续执行(锁文件:{lock_file})")
48
+ return True
@@ -0,0 +1,69 @@
1
+ """Rotating-file + console logging setup."""
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
+ """<script dir>/logs/<script name> (debug/info suffixes added automatically)."""
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
+ """Configure root logger: *_debug.log + *_info.log (rotating) + console.
25
+
26
+ log_file defaults to logs/<script name> next to the entry script.
27
+ If the root logger already has handlers (pytest, Jupyter, another library
28
+ configured logging first) this does nothing and warns on stderr, because
29
+ silently skipping it used to mean "no log file at all, no hint why".
30
+ Pass force=True to remove the existing handlers and take over.
31
+ The console handler is skipped when there is no console (pythonw.exe,
32
+ PyInstaller --noconsole), where sys.stderr is None.
33
+ """
34
+ if logging.root.handlers and not force:
35
+ if sys.stderr is not None:
36
+ print(
37
+ "[sc92_public_define] 根日志器已有 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)
@@ -0,0 +1,144 @@
1
+ """MQTT publisher. Credentials come from explicit constructor args ONLY.
2
+
3
+ NEVER hardcode host / username / password / topic in this source file.
4
+ Recommended: keep them in your own YAML config file (see read_yaml) and
5
+ pass the values into MqttPublisher(...).
6
+ """
7
+
8
+ import logging
9
+ import socket
10
+ import threading
11
+
12
+ import paho.mqtt.client as mqtt
13
+
14
+
15
+ class MqttPublisher:
16
+ """One-shot MQTT publisher: connect, publish one message, disconnect."""
17
+
18
+ def __init__(
19
+ self,
20
+ host: str,
21
+ port: int = 1883,
22
+ username: str | None = None,
23
+ password: str | None = None,
24
+ topic: str | None = None,
25
+ qos: int = 2,
26
+ client_id: str | None = None,
27
+ connect_timeout: float = 5,
28
+ publish_timeout: float = 10,
29
+ ):
30
+ self.host = host
31
+ self.port = int(port)
32
+ self.username = username
33
+ self.password = password
34
+ self.topic = topic
35
+ self.qos = int(qos)
36
+ self.client_id = client_id
37
+ self.connect_timeout = connect_timeout
38
+ self.publish_timeout = publish_timeout
39
+
40
+ missing = [n for n, v in (("host", self.host), ("topic", self.topic)) if not v]
41
+ if missing:
42
+ raise ValueError(
43
+ f"缺少 MQTT 参数: {', '.join(missing)}。请在调用脚本中显式传入,"
44
+ "凭据建议存放在本地 YAML 配置文件里用 read_yaml() 读取"
45
+ "(本库源码与 PyPI 发布物中不包含任何真实凭据)。"
46
+ )
47
+
48
+ def publish(self, msg, topic: str | None = None, qos: int | None = None) -> bool:
49
+ """Publish one message. Returns True only when the broker really took it.
50
+
51
+ QoS 1/2: True means the broker acknowledged the message.
52
+ QoS 0 has no acknowledgement: True only means it was written to the socket.
53
+ connect_timeout caps DNS lookup + TCP connect + the wait for CONNACK
54
+ (TCP connect alone is not enough: a wrong password still gives you a
55
+ working TCP connection).
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
+
65
+ connack = threading.Event()
66
+ reject: dict = {}
67
+ closed: dict = {}
68
+
69
+ def _on_connect(c, userdata, flags, reason_code, properties):
70
+ if getattr(reason_code, "is_failure", False):
71
+ reject["code"] = reason_code
72
+ connack.set()
73
+
74
+ def _on_disconnect(c, userdata, flags, reason_code, properties):
75
+ closed["code"] = reason_code
76
+ connack.set()
77
+
78
+ client.on_connect = _on_connect
79
+ client.on_disconnect = _on_disconnect
80
+
81
+ try:
82
+ logging.info(f"正在连接 MQTT Broker {self.host}:{self.port} ...")
83
+ box: dict = {}
84
+
85
+ def _do_connect() -> None:
86
+ old_timeout = socket.getdefaulttimeout()
87
+ socket.setdefaulttimeout(self.connect_timeout)
88
+ try:
89
+ client.connect(self.host, self.port, keepalive=60)
90
+ box["err"] = None
91
+ except Exception as exc:
92
+ box["err"] = exc
93
+ finally:
94
+ socket.setdefaulttimeout(old_timeout)
95
+
96
+ # connect() 里的 DNS 解析(getaddrinfo)不受 socket 超时约束,放到线程里
97
+ # join(timeout) 才能真正被 connect_timeout 封顶;超时后放弃(后台线程是
98
+ # daemon,卡住的解析随进程结束一起回收)。
99
+ connector = threading.Thread(target=_do_connect, daemon=True)
100
+ connector.start()
101
+ connector.join(self.connect_timeout)
102
+ if connector.is_alive():
103
+ logging.error(
104
+ f"MQTT 连接超时({self.connect_timeout}s,含 DNS 解析),放弃:"
105
+ f"{self.host}:{self.port}"
106
+ )
107
+ return False
108
+ if box.get("err") is not None:
109
+ raise box["err"]
110
+ client.loop_start()
111
+
112
+ if not connack.wait(self.connect_timeout):
113
+ logging.error(
114
+ f"MQTT 连接失败:{self.connect_timeout}s 内没收到 CONNACK({self.host}:{self.port})"
115
+ )
116
+ return False
117
+ if reject:
118
+ logging.error(f"MQTT 连接被 broker 拒绝:{reject['code']}")
119
+ return False
120
+ if closed:
121
+ logging.error(f"MQTT 连接在建立会话前被中断:{closed['code']}")
122
+ return False
123
+
124
+ info = client.publish(topic, payload=payload, qos=qos)
125
+ info.wait_for_publish(timeout=self.publish_timeout)
126
+ if info.rc == mqtt.MQTT_ERR_SUCCESS and info.is_published():
127
+ logging.info(f"消息已发布到主题 '{topic}' (QoS {qos}): {msg}")
128
+ return True
129
+ logging.error(
130
+ f"发布未被 broker 确认(超时 {self.publish_timeout}s):rc={info.rc} 主题={topic}"
131
+ )
132
+ return False
133
+ except Exception as e:
134
+ logging.error(f"MQTT 发布发生异常: {e}")
135
+ return False
136
+ finally:
137
+ try:
138
+ client.disconnect()
139
+ except Exception:
140
+ pass
141
+ try:
142
+ client.loop_stop()
143
+ except Exception:
144
+ pass
@@ -0,0 +1,59 @@
1
+ """Resolve the running script's directory and name, PyInstaller-frozen aware."""
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
+ """Module-style entry name, or None for a normal standalone script.
13
+
14
+ `python -m pkg[.mod]` (and running a package's __main__.py by hand) has no
15
+ entry-script name of its own: sys.argv[0] would give ".__main__" and the real
16
+ module usually sits inside site-packages, where logs/locks/config must not be
17
+ written. Those cases return the module / package name instead.
18
+ """
19
+ if _is_frozen():
20
+ return None
21
+ spec = getattr(sys.modules.get("__main__"), "__spec__", None)
22
+ name = getattr(spec, "name", None)
23
+ if name and name != "__main__":
24
+ suffix = ".__main__"
25
+ return name[: -len(suffix)] if name.endswith(suffix) else name
26
+ first = pathlib.Path(sys.argv[0])
27
+ if first.stem == "__main__" and first.parent != pathlib.Path():
28
+ return first.resolve().parent.name
29
+ return None
30
+
31
+
32
+ def script_dir() -> pathlib.Path:
33
+ """Directory of the entry script (or of the exe when frozen by PyInstaller).
34
+
35
+ `python -m ...` runs a module instead of a script, so the current working
36
+ directory is used: the module's own folder is often site-packages.
37
+ """
38
+ if _is_frozen():
39
+ return pathlib.Path(sys.executable).parent
40
+ if _module_entry() is not None:
41
+ return pathlib.Path.cwd()
42
+ first = pathlib.Path(sys.argv[0])
43
+ if not first.name or first.name == "-c":
44
+ return pathlib.Path.cwd()
45
+ return first.resolve().parent
46
+
47
+
48
+ def script_name() -> str:
49
+ """File name (without suffix) of the entry script (exe stem when frozen,
50
+ module/package name when started with python -m)."""
51
+ if _is_frozen():
52
+ return pathlib.Path(sys.executable).stem
53
+ module = _module_entry()
54
+ if module is not None:
55
+ return module
56
+ stem = pathlib.Path(sys.argv[0]).stem
57
+ if not stem or stem == "-c":
58
+ return pathlib.Path.cwd().name
59
+ return stem
@@ -0,0 +1,142 @@
1
+ """YAML config file read/write helpers.
2
+
3
+ Missing or empty file -> write a default template then exit(1) by default, so
4
+ the user can review the template and re-run (same workflow as the original
5
+ script). Pass on_missing="return" to continue programmatically instead.
6
+ """
7
+
8
+ import copy
9
+ import logging
10
+ import pathlib
11
+ import sys
12
+
13
+ import yaml
14
+
15
+ from .paths import script_dir, script_name
16
+
17
+
18
+ def default_config_file() -> str:
19
+ """<script dir>/configs/<script name>.config.yaml (like logs go into logs/)."""
20
+ return str(script_dir() / "configs" / f"{script_name()}.config.yaml")
21
+
22
+
23
+ def write_yaml(config_file: str | pathlib.Path | None = None,
24
+ config_dict: dict | None = None) -> None:
25
+ """Write a dict to a YAML file (utf-8, Chinese keys kept, insertion order kept).
26
+
27
+ config_file defaults to <script dir>/configs/<script name>.config.yaml.
28
+ config_dict is required: passing nothing used to write an empty file that
29
+ later read_yaml() calls would silently accept.
30
+ """
31
+ if config_dict is None:
32
+ raise ValueError("write_yaml 必须显式传入 config_dict(不传会写出空文件)")
33
+ if not isinstance(config_dict, dict):
34
+ raise ValueError(f"write_yaml 的 config_dict 必须是 dict,实际为 {type(config_dict).__name__}")
35
+ if config_file is None:
36
+ config_file = default_config_file()
37
+ path = pathlib.Path(config_file)
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ with path.open("w", encoding="utf-8") as f:
40
+ yaml.dump(
41
+ config_dict,
42
+ f,
43
+ allow_unicode=True,
44
+ default_flow_style=False,
45
+ sort_keys=False,
46
+ )
47
+ logging.info(f"配置已写入: {path}")
48
+
49
+
50
+ def _fill_missing(base: dict, defaults: dict, prefix: str = "") -> list:
51
+ """递归把 defaults 里 base 缺少的键补进 base(文件里的值优先),返回补齐的键路径。"""
52
+ added = []
53
+ for key, value in defaults.items():
54
+ if key not in base:
55
+ base[key] = copy.deepcopy(value)
56
+ added.append(f"{prefix}{key}")
57
+ elif isinstance(base[key], dict) and isinstance(value, dict):
58
+ added.extend(_fill_missing(base[key], value, f"{prefix}{key}."))
59
+ return added
60
+
61
+
62
+ def read_yaml(config_file: str | pathlib.Path | None = None,
63
+ default_config: dict | None = None,
64
+ on_missing: str = "exit",
65
+ merge_defaults: bool = False) -> dict:
66
+ """Read a YAML config file as a dict. Errors stop the script (never silent defaults).
67
+
68
+ config_file defaults to <script dir>/configs/<script name>.config.yaml.
69
+ File missing -> write default_config as a template, logging.error, exit(1).
70
+ File empty/`{}` -> OVERWRITE it with default_config, logging.error, exit(1).
71
+ Not UTF-8 -> logging.error, raise ValueError (file kept untouched).
72
+ YAML broken -> logging.error, raise ValueError (file kept untouched).
73
+ Not a mapping -> logging.error, raise ValueError (file kept untouched).
74
+ on_missing "exit" (default) or "return": with "return" the two template
75
+ cases above return a deep copy of default_config instead of
76
+ exiting.
77
+ merge_defaults True: after loading, fill keys that only exist in
78
+ default_config into the returned dict (file values win, the
79
+ file on disk is NOT rewritten).
80
+
81
+ Returns the loaded dict only when the file really contains a valid mapping.
82
+ """
83
+ if on_missing not in ("exit", "return"):
84
+ raise ValueError(f'on_missing must be "exit" or "return", got {on_missing!r}')
85
+
86
+ if config_file is None:
87
+ config_file = default_config_file()
88
+ path = pathlib.Path(config_file)
89
+
90
+ if default_config is None:
91
+ default_config = {}
92
+
93
+ if not path.is_file():
94
+ template = copy.deepcopy(default_config)
95
+ write_yaml(path, template)
96
+ logging.error(
97
+ f"配置文件不存在: {path},已按默认配置生成模板,请检查修改后重新运行本脚本"
98
+ )
99
+ if on_missing == "exit":
100
+ sys.exit(1)
101
+ return template
102
+
103
+ try:
104
+ with path.open("r", encoding="utf-8") as f:
105
+ loaded = yaml.safe_load(f)
106
+ except UnicodeDecodeError as exc:
107
+ logging.error(
108
+ f"配置文件不是 UTF-8 编码(多半是记事本另存为时选了 ANSI/GBK): {path},"
109
+ f"请用 UTF-8 重新保存后重试。错误信息: {exc}"
110
+ )
111
+ raise ValueError(f"配置文件编码不是 UTF-8: {path}") from exc
112
+ except yaml.YAMLError as exc:
113
+ logging.error(f"配置文件解析失败: {path},错误信息: {exc},脚本退出")
114
+ raise ValueError(f"配置文件解析失败: {path}") from exc
115
+
116
+ if loaded is None or loaded == {}:
117
+ template = copy.deepcopy(default_config)
118
+ write_yaml(path, template)
119
+ logging.error(
120
+ f"配置文件为空: {path},已按默认配置覆盖写入,请检查修改后重新运行本脚本"
121
+ )
122
+ if on_missing == "exit":
123
+ sys.exit(1)
124
+ return template
125
+
126
+ if not isinstance(loaded, dict):
127
+ logging.error(
128
+ f"配置文件顶层必须是键值对(key: value),实际为 {type(loaded).__name__}: "
129
+ f"{path},请修改后重新运行,脚本退出"
130
+ )
131
+ raise ValueError(f"配置文件顶层不是键值映射: {path}")
132
+
133
+ if merge_defaults and default_config:
134
+ added = _fill_missing(loaded, default_config)
135
+ if added:
136
+ logging.info(
137
+ f"配置文件缺少 {len(added)} 项,已按默认模板补齐(只补内存里的结果,"
138
+ f"不改动 {path}): {', '.join(added)}"
139
+ )
140
+
141
+ logging.info(f"配置已加载: {path}")
142
+ return loaded
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: sc92-public-define
3
+ Version: 0.3.1
4
+ Summary: Personal shared utilities: file lock, logging, MQTT publishing and YAML config helpers
5
+ Author: sc92
6
+ License-Expression: MIT
7
+ Keywords: filelock,logging,mqtt,yaml,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: Operating System :: OS Independent
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: filelock>=4.0
17
+ Requires-Dist: paho-mqtt>=2.0
18
+ Requires-Dist: PyYAML>=6.0
19
+ Dynamic: license-file
20
+
21
+ # sc92-public-define
22
+
23
+ Personal shared utilities extracted from everyday scripts:
24
+
25
+ 1. **Single-instance file lock** — `check_running_lock()`: if the script is
26
+ already running elsewhere, exit (or return False) immediately.
27
+ 2. **Rotating logging setup** — `init_logging_config()`: one call to get
28
+ `*_debug.log` + `*_info.log` (10 MB × 7 backups) + console output. If another
29
+ library already configured the root logger it now warns on stderr instead of
30
+ silently doing nothing (pass `force=True` to take over); with no console
31
+ (`pythonw.exe`, PyInstaller `--noconsole`) the console handler is skipped.
32
+ 3. **Global exception hooks** — `enable_exception_logging()`: uncaught
33
+ exceptions are written to the log instead of only printed to stderr — for the
34
+ main thread **and for worker threads** (`threading.excepthook`, which
35
+ `sys.excepthook` never covers).
36
+ 4. **MQTT publisher** — `MqttPublisher.publish()`: one-shot connect →
37
+ publish → disconnect. Credentials are taken from **explicit constructor
38
+ args only** (no hardcoded defaults, no environment variables) — keep them
39
+ in your local YAML config file and pass the values in.
40
+ 5. **YAML config helpers** — `read_yaml()` / `write_yaml()`: first run writes
41
+ a default template into `configs/` next to the script and exits for review;
42
+ Chinese keys and insertion order are preserved, and a file saved as
43
+ ANSI/GBK or broken YAML now fails with a readable message instead of a raw
44
+ `UnicodeDecodeError`.
45
+
46
+ All helpers are PyInstaller-frozen aware and default to paths derived from
47
+ the entry script, so typical usage needs **zero arguments**.
48
+
49
+ The derived name is `<script dir>/…/<script name>…`, so one instance means
50
+ "one running copy of this script in this directory": a renamed or copied script
51
+ is a separate instance. `python -m pkg[.mod]` is handled as module `pkg[.mod]`
52
+ with the current working directory as its base — it never writes into
53
+ site-packages.
54
+
55
+ ## Install
56
+
57
+ ```bash
58
+ pip install sc92-public-define
59
+ ```
60
+
61
+ Requires Python >= 3.11 (`filelock>=4.0`, `paho-mqtt>=2.0`, `PyYAML>=6.0`).
62
+
63
+ ## Typical usage (top of any script)
64
+
65
+ ```python
66
+ import logging
67
+ from sc92_public_define import (
68
+ check_running_lock, # single-instance lock
69
+ init_logging_config, # rotating logs + console
70
+ enable_exception_logging, # uncaught exception -> log
71
+ )
72
+
73
+ init_logging_config() # logs/<script name>_debug.log / _info.log
74
+ check_running_lock() # <script name>.running.lock next to the script;
75
+ # exits with status 1 when another copy is running
76
+ enable_exception_logging()
77
+
78
+ logging.info("main started")
79
+ ```
80
+
81
+ ### MQTT publishing (credentials live in your YAML config, never in code)
82
+
83
+ ```python
84
+ from sc92_public_define import MqttPublisher, read_yaml
85
+
86
+ server = read_yaml()["mqtt服务器"] # nested dict in <script>.config.yaml
87
+ pub = MqttPublisher(host=server["地址"], port=server["端口"],
88
+ username=server["用户名"] or None,
89
+ password=server["密码"] or None,
90
+ topic=server["主题"])
91
+ pub.publish("hello") # uses instance topic/qos
92
+ pub.publish("hello", topic="other/t", qos=1) # per-call override
93
+ ```
94
+
95
+ ### YAML config
96
+
97
+ ```python
98
+ from sc92_public_define import read_yaml, write_yaml
99
+
100
+ cfg = read_yaml(default_config={"服务器": "127.0.0.1", "端口": 8080})
101
+ # first run: creates configs/<script>.config.yaml next to the script, logs a hint, exits
102
+ cfg["端口"] = 9090
103
+ write_yaml(config_dict=cfg) # writes back to the same default file
104
+ ```
105
+
106
+ ## API
107
+
108
+ | Function | Default | Notes |
109
+ |---|---|---|
110
+ | `check_running_lock(lock_file=None, on_conflict="exit")` | `<script dir>/<script>.running.lock` | `on_conflict="exit"` → logs an error and exits with status 1; `"return"` → returns `False`; `True` when acquired |
111
+ | `init_logging_config(log_file=None, max_bytes=10MB, backup_count=7, force=False)` | `<script dir>/logs/<script>` | warns on stderr and does nothing if the root logger already has handlers (`force=True` takes over); console handler skipped when there is no console |
112
+ | `enable_exception_logging()` / `handle_global_exception` / `handle_thread_exception` | — | sets `sys.excepthook` **and** `threading.excepthook`, writes tracebacks via `logging.error` |
113
+ | `MqttPublisher(host, port=1883, username=None, password=None, topic=None, qos=2, client_id=None, connect_timeout=5, publish_timeout=10)` | explicit args only | raises `ValueError` if host/topic missing; `connect_timeout` caps DNS + TCP + the wait for CONNACK |
114
+ | `MqttPublisher.publish(msg, topic=None, qos=None)` | instance defaults | `True` only when the message really got through: QoS 1/2 require the broker acknowledgement, QoS 0 has none (written to the socket only). Wrong password, missing CONNACK, an unacknowledged publish or any network error → `False` plus a `logging.error`, never raises. `bytes`/`bytearray` payloads are sent as-is, anything else is `str()`-ed |
115
+ | `read_yaml(config_file=None, default_config=None, on_missing="exit", merge_defaults=False)` | `<script dir>/configs/<script>.config.yaml` | missing → write template + exit(1); empty file or `{}` → overwrite with the template + exit(1); non-UTF-8 / broken YAML / top level not a mapping → `ValueError`; `merge_defaults=True` fills keys that only exist in `default_config` into the returned dict (the file on disk is untouched); the returned template is a deep copy |
116
+ | `write_yaml(config_file=None, config_dict=None)` | same default file | `config_dict` is required (`None` or a non-dict raises `ValueError`); utf-8, Chinese keys & order preserved |
117
+ | `script_dir()` / `script_name()` | — | entry-script path/name; frozen exe → exe folder/stem; `python -m pkg` → cwd + `pkg` |
118
+ | `default_lock_file()` / `default_log_file()` / `default_config_file()` | — | expose the derived default paths |
119
+
120
+ ## Notes
121
+
122
+ - The lock is released automatically by the OS when the process exits;
123
+ the lock **file** is left on disk (harmless, reuse next time).
124
+ - The first `read_yaml()` run on a machine without a config file deliberately
125
+ exits with status 1 after writing the template: edit it, then run again.
126
+ - `init_logging_config()` must come **before** `check_running_lock()` /
127
+ `read_yaml()`, otherwise their "抢锁结果 / 配置已加载" INFO lines are dropped
128
+ (only the ERROR lines still reach stderr through logging's last-resort handler).
129
+ - See the `demo/` folder in the source tree for runnable examples (it is not
130
+ shipped inside the wheel or sdist).
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/sc92_public_define/__init__.py
5
+ src/sc92_public_define/excepthook.py
6
+ src/sc92_public_define/lock.py
7
+ src/sc92_public_define/log.py
8
+ src/sc92_public_define/mqtt_publisher.py
9
+ src/sc92_public_define/paths.py
10
+ src/sc92_public_define/py.typed
11
+ src/sc92_public_define/yaml_config.py
12
+ src/sc92_public_define.egg-info/PKG-INFO
13
+ src/sc92_public_define.egg-info/SOURCES.txt
14
+ src/sc92_public_define.egg-info/dependency_links.txt
15
+ src/sc92_public_define.egg-info/requires.txt
16
+ src/sc92_public_define.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ filelock>=4.0
2
+ paho-mqtt>=2.0
3
+ PyYAML>=6.0
@@ -0,0 +1 @@
1
+ sc92_public_define