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.
- sc92_public_define-0.3.1/LICENSE +21 -0
- sc92_public_define-0.3.1/PKG-INFO +130 -0
- sc92_public_define-0.3.1/README.md +110 -0
- sc92_public_define-0.3.1/pyproject.toml +28 -0
- sc92_public_define-0.3.1/setup.cfg +4 -0
- sc92_public_define-0.3.1/src/sc92_public_define/__init__.py +33 -0
- sc92_public_define-0.3.1/src/sc92_public_define/excepthook.py +28 -0
- sc92_public_define-0.3.1/src/sc92_public_define/lock.py +48 -0
- sc92_public_define-0.3.1/src/sc92_public_define/log.py +69 -0
- sc92_public_define-0.3.1/src/sc92_public_define/mqtt_publisher.py +144 -0
- sc92_public_define-0.3.1/src/sc92_public_define/paths.py +59 -0
- sc92_public_define-0.3.1/src/sc92_public_define/py.typed +0 -0
- sc92_public_define-0.3.1/src/sc92_public_define/yaml_config.py +142 -0
- sc92_public_define-0.3.1/src/sc92_public_define.egg-info/PKG-INFO +130 -0
- sc92_public_define-0.3.1/src/sc92_public_define.egg-info/SOURCES.txt +16 -0
- sc92_public_define-0.3.1/src/sc92_public_define.egg-info/dependency_links.txt +1 -0
- sc92_public_define-0.3.1/src/sc92_public_define.egg-info/requires.txt +3 -0
- sc92_public_define-0.3.1/src/sc92_public_define.egg-info/top_level.txt +1 -0
|
@@ -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,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
|
|
File without changes
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sc92_public_define
|