89 lines
3.2 KiB
Python
89 lines
3.2 KiB
Python
"""Local configuration snapshots and bounded, process-safe updates."""
|
|
import copy
|
|
import errno
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import time
|
|
from contextlib import contextmanager
|
|
|
|
|
|
class ConfigSnapshot(dict):
|
|
def __init__(self, values):
|
|
super().__init__(values)
|
|
self.original = copy.deepcopy(values)
|
|
|
|
|
|
@contextmanager
|
|
def file_lock(path, timeout=45):
|
|
# An OS lock is released even if the owning process crashes. Do not delete
|
|
# the lock file: replacing its inode would break coordination on POSIX.
|
|
with open(path, "a+b") as handle:
|
|
handle.seek(0, os.SEEK_END)
|
|
if handle.tell() == 0:
|
|
handle.write(b"\0")
|
|
handle.flush()
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
try:
|
|
if os.name == "nt":
|
|
import msvcrt
|
|
handle.seek(0)
|
|
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
else:
|
|
import fcntl
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
break
|
|
except OSError as exc:
|
|
if exc.errno not in (errno.EACCES, errno.EAGAIN, errno.EDEADLK):
|
|
raise
|
|
if time.monotonic() >= deadline:
|
|
raise TimeoutError("等待本机登录/配置锁超过 %s 秒" % timeout)
|
|
time.sleep(0.05)
|
|
try:
|
|
yield
|
|
finally:
|
|
if os.name == "nt":
|
|
handle.seek(0)
|
|
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
else:
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
|
|
|
|
def read_config(path):
|
|
with open(path, "r", encoding="utf-8-sig") as handle:
|
|
return ConfigSnapshot(json.load(handle))
|
|
|
|
|
|
def update_config(path, values, ignored=(), initialize=False):
|
|
"""Merge only changed fields into the latest file, then atomically replace it."""
|
|
original = getattr(values, "original", {})
|
|
changes = {key: value for key, value in values.items()
|
|
if key not in ignored and (key not in original or value != original[key])}
|
|
removed = {key for key in original if key not in values and key not in ignored}
|
|
with file_lock(path + ".lock"):
|
|
try:
|
|
latest = read_config(path)
|
|
if initialize:
|
|
return latest
|
|
except FileNotFoundError:
|
|
latest = {}
|
|
if not changes and not removed and os.path.exists(path):
|
|
return ConfigSnapshot(latest)
|
|
latest.update(changes)
|
|
for key in removed:
|
|
latest.pop(key, None)
|
|
fd, temporary = tempfile.mkstemp(prefix="config.json.", suffix=".tmp", dir=os.path.dirname(path))
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
json.dump(latest, handle, ensure_ascii=False, indent=2)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(temporary, path)
|
|
finally:
|
|
if os.path.exists(temporary):
|
|
os.unlink(temporary)
|
|
if isinstance(values, ConfigSnapshot):
|
|
values.original = copy.deepcopy(dict(values))
|
|
return ConfigSnapshot(latest)
|