world_sync/world_sync/pbl_runtime_tx_env.py
2026-09-20 16:09:34 +08:00

254 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""M11b-2 运行时单事务写入 —— 连接工厂解析conn_factory 三级优先级 + fail-closed
QC 退回意见 #5 已响应:**不再把 ``env/test.json`` 作为候选路径**。
按 project-directory-spec 8.1,部署环境信息唯一存放于 ``projects/{项目}/env/``
应用/模块仓库不得存放、亦不得自行读取 ``env/{test,prod}.json`` 凭据。
本模块的连接参数只允许来自以下三处(优先级从高到低):
1. **显式注入**:调用方(宿主应用)直接传 ``conn_factory=callable``
2. **ServerEnv 登记**:宿主应用 init() 里把 ``pbl_runtime_conn_factory`` /
``world_sync_conn_factory`` 挂到 ServerEnv本模块按 key 读取;
3. **模块自有配置**``conf/db.json``(模块仓库内,非部署环境凭据文件),
凭据字段一律走环境变量名间接引用(``"password_env": "PBL_DB_PASSWORD"``
配置文件里不出现明文口令;
4. 以上皆无 → 抛 :class:`ConnFactoryNotConfigured`fail-closed绝不偷偷连库、
绝不回落到「读一下 env/test.json 试试」)。
"""
import json
import os
from .pbl_runtime_errors import ConnFactoryNotConfigured, TxConfigError
__all__ = [
"CONN_FACTORY_ENV_KEYS",
"SERVER_ENV_KEYS",
"DB_CONFIG_RELATIVE",
"resolve_conn_factory",
"build_sqlite_conn_factory",
"build_mysql_conn_factory",
"load_db_config",
"module_conf_dir",
]
# 环境变量:值为 "sqlite:///path" 或 "mysql://user:pass@host:3306/db"(口令只在环境变量里,
# 不进任何配置文件),存在即优先级最高之一。
CONN_FACTORY_ENV_KEYS = ("PBL_RUNTIME_DB_URL", "WORLD_SYNC_DB_URL")
# ServerEnv 上的登记键(宿主注入),按顺序尝试。
SERVER_ENV_KEYS = ("pbl_runtime_conn_factory", "world_sync_conn_factory")
# 模块自有配置文件(相对模块仓库根),只放非敏感参数 + 环境变量名引用。
DB_CONFIG_RELATIVE = os.path.join("conf", "db.json")
_DB_URL_SCHEME_SQLITE = ("sqlite:///", "sqlite://")
_DB_URL_SCHEME_MYSQL = ("mysql://", "mysql+pymysql://")
def module_root():
"""模块仓库根(本文件位于 <root>/world_sync/pbl_runtime_tx_env.py"""
here = os.path.dirname(os.path.abspath(__file__))
return os.path.dirname(here)
def module_conf_dir():
"""模块自有 conf 目录绝对路径。"""
return os.path.join(module_root(), "conf")
def load_db_config(conf_dir=None):
"""读取模块自有 ``conf/db.json``;不存在返回 ``None``(不是错误,交给 fail-closed 判定)。
文件存在但不可解析 → 抛 :class:`TxConfigError`(不吞错误,不静默降级)。
"""
path = os.path.join(conf_dir or module_conf_dir(), "db.json")
if not os.path.isfile(path):
return None
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except ValueError as exc:
raise TxConfigError("cannot parse module conf db.json: %s" % path, path=path)
except (IOError, OSError) as exc:
raise TxConfigError("cannot read module conf db.json: %s" % path, path=path)
if not isinstance(data, dict):
raise TxConfigError("module conf db.json must be a JSON object", path=path)
return data
def _parse_db_url(url):
"""把 DB URL 解析成参数字典。口令只来自环境变量构造出的 URL 本身,不落盘。"""
url = (url or "").strip()
for scheme in _DB_URL_SCHEME_SQLITE:
if url.startswith(scheme):
return {"dialect": "sqlite", "path": url[len(scheme):] or ":memory:"}
for scheme in _DB_URL_SCHEME_MYSQL:
if url.startswith(scheme):
rest = url[len(scheme):]
userinfo, _, hostpart = rest.rpartition("@")
hostport, _, database = hostpart.partition("/")
host, _, port = hostport.partition(":")
user, _, password = userinfo.partition(":")
return {
"dialect": "mysql",
"host": host,
"port": int(port) if port else 3306,
"user": user,
"password": password,
"database": database.split("?")[0],
}
raise TxConfigError("unsupported db url scheme: %r" % url, url_scheme=url.split(":")[0])
def build_sqlite_conn_factory(path=":memory:"):
"""构造 sqlite 连接工厂(单测 / 本地)。
``isolation_level=None`` 关掉 sqlite3 自动插 BEGIN 的行为,
让事务边界完全由 :mod:`world_sync.pbl_runtime_tx` 控制(原子性可测)。
"""
import sqlite3
def _factory():
conn = sqlite3.connect(path)
conn.isolation_level = None # 显式事务控制
conn.execute("PRAGMA foreign_keys = ON")
return conn
return _factory
def build_mysql_conn_factory(params):
"""构造 MySQL 连接工厂(生产)。缺驱动 / 缺参数一律抛错,不降级到 sqlite。"""
try:
import pymysql
except ImportError as exc:
raise TxConfigError("pymysql not installed; cannot build mysql conn_factory")
host = params.get("host")
user = params.get("user")
database = params.get("database")
if not (host and user and database):
raise TxConfigError(
"incomplete mysql params", missing=[
k for k in ("host", "user", "database") if not params.get(k)
]
)
kw = {
"host": host,
"port": int(params.get("port") or 3306),
"user": user,
"password": params.get("password") or "",
"database": database,
"charset": params.get("charset") or "utf8mb4",
"autocommit": False,
}
def _factory():
return pymysql.connect(**kw)
return _factory
def _server_env():
"""取宿主 ServerEnv 实例;宿主框架不可用时返回 ``None``(不抛,交给 fail-closed"""
try:
from appPublic.serverEnv import ServerEnv # 宿主框架提供
except Exception:
return None
try:
return ServerEnv()
except Exception:
return None
def _from_server_env():
env = _server_env()
if env is None:
return None
for key in SERVER_ENV_KEYS:
getter = getattr(env, "get", None)
if getter is None:
break
try:
value = getter(key)
except Exception:
value = None
if callable(value):
return value
if isinstance(value, str) and value.strip():
return _factory_from_url(value)
return None
def _factory_from_url(url):
params = _parse_db_url(url)
if params["dialect"] == "sqlite":
return build_sqlite_conn_factory(params["path"])
return build_mysql_conn_factory(params)
def _from_env_vars():
for key in CONN_FACTORY_ENV_KEYS:
url = os.environ.get(key)
if url and url.strip():
return _factory_from_url(url.strip())
return None
def _from_module_conf():
"""模块自有 conf/db.json只允许环境变量名引用凭据禁止明文口令。"""
cfg = load_db_config()
if not cfg:
return None
dialect = (cfg.get("dialect") or "").lower()
if dialect == "sqlite":
return build_sqlite_conn_factory(cfg.get("path") or ":memory:")
if dialect in ("mysql", "mysql+pymysql"):
params = dict(cfg)
params["dialect"] = "mysql"
pw_env = cfg.get("password_env")
if pw_env:
params["password"] = os.environ.get(pw_env, "")
if cfg.get("password"):
raise TxConfigError(
"plaintext password is forbidden in module conf db.json; "
"use password_env to reference an environment variable"
)
return build_mysql_conn_factory(params)
raise TxConfigError("unknown dialect in module conf db.json", dialect=dialect)
def resolve_conn_factory(conn_factory=None):
"""按三级优先级解析出可用的 conn_factory全部落空则 fail-closed 抛错。
:param conn_factory: 优先级 1显式注入
:returns: 无参可调用对象,每次调用返回一个新的 DB-API 连接
:raises ConnFactoryNotConfigured: 三级皆无
"""
if callable(conn_factory):
return conn_factory
if conn_factory is not None and isinstance(conn_factory, str):
return _factory_from_url(conn_factory)
resolved = _from_server_env()
if resolved is not None:
return resolved
resolved = _from_env_vars()
if resolved is not None:
return resolved
resolved = _from_module_conf()
if resolved is not None:
return resolved
raise ConnFactoryNotConfigured(
"no conn_factory available: pass conn_factory=..., register "
"ServerEnv.pbl_runtime_conn_factory, or set %s / module conf/db.json. "
"Reading deployment env/{test,prod}.json from a module repository is "
"forbidden by project-directory-spec 8.1 (fail-closed)."
% ", ".join(CONN_FACTORY_ENV_KEYS),
tried=["explicit", "server_env", "env_vars", "module_conf/db.json"],
)