sqlseed-web 0.2.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlseed_web/AGENTS.md +106 -0
- sqlseed_web/__init__.py +24 -0
- sqlseed_web/__main__.py +8 -0
- sqlseed_web/_application.py +205 -0
- sqlseed_web/ai_settings.py +290 -0
- sqlseed_web/api.py +1102 -0
- sqlseed_web/app.py +26 -0
- sqlseed_web/managed_worker.py +184 -0
- sqlseed_web/operation_errors.py +42 -0
- sqlseed_web/plugin_environment.py +231 -0
- sqlseed_web/plugin_management.py +322 -0
- sqlseed_web/plugin_process.py +131 -0
- sqlseed_web/runtime_lifecycle.py +130 -0
- sqlseed_web/runtime_session.py +97 -0
- sqlseed_web/settings_environment.py +368 -0
- sqlseed_web/sqlite_target.py +86 -0
- sqlseed_web/state.py +348 -0
- sqlseed_web/static/AGENTS.md +180 -0
- sqlseed_web/static/ai.css +57 -0
- sqlseed_web/static/configs.css +50 -0
- sqlseed_web/static/date-picker.css +230 -0
- sqlseed_web/static/disclosure.css +149 -0
- sqlseed_web/static/graph-clarity.css +103 -0
- sqlseed_web/static/index.html +29 -0
- sqlseed_web/static/js/api.js +228 -0
- sqlseed_web/static/js/app.js +97 -0
- sqlseed_web/static/js/dropdown.js +432 -0
- sqlseed_web/static/js/filepicker.js +203 -0
- sqlseed_web/static/js/genform.js +1066 -0
- sqlseed_web/static/js/labels.js +195 -0
- sqlseed_web/static/js/pages/browse.js +209 -0
- sqlseed_web/static/js/pages/configs.js +424 -0
- sqlseed_web/static/js/pages/connect.js +332 -0
- sqlseed_web/static/js/pages/heal.js +395 -0
- sqlseed_web/static/js/pages/meta.js +110 -0
- sqlseed_web/static/js/pages/runs.js +293 -0
- sqlseed_web/static/js/pages/settings.js +942 -0
- sqlseed_web/static/js/pages/wizard.js +751 -0
- sqlseed_web/static/js/pages/workbench.js +3123 -0
- sqlseed_web/static/js/tree.js +126 -0
- sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
- sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
- sqlseed_web/static/js/workbench/ai-stream.js +116 -0
- sqlseed_web/static/js/workbench/ai.js +888 -0
- sqlseed_web/static/js/workbench/connection.js +508 -0
- sqlseed_web/static/js/workbench/date-picker.js +445 -0
- sqlseed_web/static/js/workbench/dependency-view.js +119 -0
- sqlseed_web/static/js/workbench/editor.js +1236 -0
- sqlseed_web/static/js/workbench/focus.js +11 -0
- sqlseed_web/static/js/workbench/graph-layout.js +332 -0
- sqlseed_web/static/js/workbench/graph.js +970 -0
- sqlseed_web/static/js/workbench/guidance.js +29 -0
- sqlseed_web/static/js/workbench/model.js +124 -0
- sqlseed_web/static/js/workbench/plugin-management.js +512 -0
- sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
- sqlseed_web/static/js/workbench/preview.js +572 -0
- sqlseed_web/static/js/workbench/provider-guide.js +33 -0
- sqlseed_web/static/js/workbench/recovery.js +28 -0
- sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
- sqlseed_web/static/js/workbench/session.js +174 -0
- sqlseed_web/static/js/workbench/table-data.js +186 -0
- sqlseed_web/static/js/workbench/ui.js +262 -0
- sqlseed_web/static/navigation.css +92 -0
- sqlseed_web/static/preview.css +29 -0
- sqlseed_web/static/runs.css +53 -0
- sqlseed_web/static/scrollbars.css +42 -0
- sqlseed_web/static/settings.css +108 -0
- sqlseed_web/static/style.css +3382 -0
- sqlseed_web/static/table-data.css +27 -0
- sqlseed_web/static/workbench.css +509 -0
- sqlseed_web/supervised_plugins.py +173 -0
- sqlseed_web/supervisor.py +238 -0
- sqlseed_web/workbench.py +381 -0
- sqlseed_web/workbench_ai.py +887 -0
- sqlseed_web/workbench_ai_relations.py +285 -0
- sqlseed_web/workbench_ai_stream.py +172 -0
- sqlseed_web/workbench_data.py +163 -0
- sqlseed_web/workbench_execution.py +199 -0
- sqlseed_web/workbench_runtime.py +1218 -0
- sqlseed_web/workbench_schema.py +277 -0
- sqlseed_web/workbench_store.py +458 -0
- sqlseed_web/worker_control.py +192 -0
- sqlseed_web-0.2.4.dist-info/METADATA +105 -0
- sqlseed_web-0.2.4.dist-info/RECORD +87 -0
- sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
- sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
- sqlseed_web-0.2.4.dist-info/licenses/LICENSE +679 -0
sqlseed_web/app.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Public ASGI factory and console entry point for the Web workbench."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from sqlseed_web._application import create_app
|
|
8
|
+
|
|
9
|
+
__all__ = ["create_app", "main"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> None:
|
|
13
|
+
"""Run the dev server (``sqlseed-web`` console script)."""
|
|
14
|
+
import uvicorn
|
|
15
|
+
|
|
16
|
+
parser = argparse.ArgumentParser(description="Start the local sqlseed Web workbench.")
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--manage-plugins", action="store_true", help="Start plugin maintenance only; disable database and AI APIs."
|
|
19
|
+
)
|
|
20
|
+
args = parser.parse_args()
|
|
21
|
+
if args.manage_plugins:
|
|
22
|
+
uvicorn.run(create_app(manage_plugins=True), host="127.0.0.1", port=8630, log_level="info")
|
|
23
|
+
else:
|
|
24
|
+
from sqlseed_web.supervisor import run_supervised
|
|
25
|
+
|
|
26
|
+
run_supervised()
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Fresh business and maintenance workers controlled by the stable launcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import socket
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from multiprocessing.connection import Connection
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
12
|
+
|
|
13
|
+
from fastapi import HTTPException
|
|
14
|
+
|
|
15
|
+
from sqlseed_web.plugin_management import ExecuteRequest, PlanRequest
|
|
16
|
+
from sqlseed_web.worker_control import ControlChannel, ControlError, ControlMessageTooLarge, validate_control_result
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
import uvicorn
|
|
20
|
+
|
|
21
|
+
from sqlseed_web.runtime_lifecycle import RuntimeGate
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RemotePluginManager:
|
|
25
|
+
"""The HTTP worker cannot execute package operations itself."""
|
|
26
|
+
|
|
27
|
+
enabled = True
|
|
28
|
+
|
|
29
|
+
def __init__(self, channel: ControlChannel, token: str) -> None:
|
|
30
|
+
self.channel = channel
|
|
31
|
+
self.token = token
|
|
32
|
+
|
|
33
|
+
def _call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
34
|
+
try:
|
|
35
|
+
return self.channel.call(method, params)
|
|
36
|
+
except ControlError as exc:
|
|
37
|
+
raise HTTPException(exc.status_code, detail=exc.detail) from exc
|
|
38
|
+
except (OSError, RuntimeError) as exc:
|
|
39
|
+
raise HTTPException(
|
|
40
|
+
503, detail={"code": "supervisor_unavailable", "message": "服务正在切换,请稍后重试。"}
|
|
41
|
+
) from exc
|
|
42
|
+
|
|
43
|
+
def status(self) -> dict[str, Any]:
|
|
44
|
+
return self._call("management", {})
|
|
45
|
+
|
|
46
|
+
def plan(self, body: PlanRequest) -> dict[str, Any]:
|
|
47
|
+
return self._call("plan", body.model_dump())
|
|
48
|
+
|
|
49
|
+
def execute(self, body: ExecuteRequest) -> dict[str, Any]:
|
|
50
|
+
return self._call("execute", body.model_dump())
|
|
51
|
+
|
|
52
|
+
def task_snapshot(self, task_id: str | None = None) -> dict[str, Any]:
|
|
53
|
+
return self._call("task", {"task_id": task_id})
|
|
54
|
+
|
|
55
|
+
def recover(self) -> dict[str, Any]:
|
|
56
|
+
return self._call("recover", {})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class InheritedDescriptor(Protocol):
|
|
60
|
+
def detach(self) -> int: ...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def prepare_runtime_session() -> dict[str, Any]:
|
|
64
|
+
"""Pause only if the complete recovery snapshot can be transferred safely."""
|
|
65
|
+
from sqlseed_web.runtime_lifecycle import runtime_gate
|
|
66
|
+
from sqlseed_web.runtime_session import export_session
|
|
67
|
+
|
|
68
|
+
runtime_gate.pause_if_idle()
|
|
69
|
+
try:
|
|
70
|
+
snapshot = export_session()
|
|
71
|
+
try:
|
|
72
|
+
validate_control_result(snapshot)
|
|
73
|
+
except ControlMessageTooLarge as exc:
|
|
74
|
+
raise HTTPException(
|
|
75
|
+
409,
|
|
76
|
+
detail={
|
|
77
|
+
"code": "plugin_session_too_large",
|
|
78
|
+
"message": "当前连接与会话信息过多,无法安全暂存;请减少连接后再管理插件。",
|
|
79
|
+
},
|
|
80
|
+
) from exc
|
|
81
|
+
return snapshot
|
|
82
|
+
except Exception:
|
|
83
|
+
runtime_gate.resume()
|
|
84
|
+
raise
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _worker_control(method: str, mode: str, server: uvicorn.Server | None, gate: RuntimeGate) -> dict[str, Any]:
|
|
88
|
+
if method == "activity":
|
|
89
|
+
return gate.activity()
|
|
90
|
+
if method == "prepare":
|
|
91
|
+
return prepare_runtime_session()
|
|
92
|
+
if method == "resume":
|
|
93
|
+
gate.resume()
|
|
94
|
+
return {}
|
|
95
|
+
if method == "shutdown":
|
|
96
|
+
if mode == "business":
|
|
97
|
+
from sqlseed_web.runtime_session import close_session
|
|
98
|
+
|
|
99
|
+
gate.pause_if_idle()
|
|
100
|
+
close_session()
|
|
101
|
+
if server is not None:
|
|
102
|
+
server.should_exit = True
|
|
103
|
+
return {}
|
|
104
|
+
raise ValueError("unsupported worker control method")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _drain_worker_runtime(mode: str, gate: RuntimeGate) -> None:
|
|
108
|
+
"""Close admission and finish existing leases before closing database sessions."""
|
|
109
|
+
gate.close_admission()
|
|
110
|
+
while any(gate.activity().values()):
|
|
111
|
+
time.sleep(0.05)
|
|
112
|
+
if mode == "business":
|
|
113
|
+
from sqlseed_web.runtime_session import close_session
|
|
114
|
+
|
|
115
|
+
close_session()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def run_worker(
|
|
119
|
+
listener: socket.socket,
|
|
120
|
+
connection: Connection,
|
|
121
|
+
mode: str,
|
|
122
|
+
session: dict[str, Any],
|
|
123
|
+
token: str,
|
|
124
|
+
lock_descriptor: InheritedDescriptor | None = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Spawn target: the parent owns signals, listener lifetime, and package state."""
|
|
127
|
+
import uvicorn
|
|
128
|
+
|
|
129
|
+
from sqlseed_web._application import create_app
|
|
130
|
+
from sqlseed_web.runtime_lifecycle import runtime_gate
|
|
131
|
+
|
|
132
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
133
|
+
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
134
|
+
channel = ControlChannel(connection)
|
|
135
|
+
manager = RemotePluginManager(channel, token)
|
|
136
|
+
result: dict[str, Any] = {}
|
|
137
|
+
server: uvicorn.Server | None = None
|
|
138
|
+
thread: threading.Thread | None = None
|
|
139
|
+
lock_fd = lock_descriptor.detach() if lock_descriptor is not None else None
|
|
140
|
+
|
|
141
|
+
def control(method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
142
|
+
return _worker_control(method, mode, server, runtime_gate)
|
|
143
|
+
|
|
144
|
+
channel.start(control)
|
|
145
|
+
try:
|
|
146
|
+
runtime_gate.pause_if_idle()
|
|
147
|
+
if mode == "business":
|
|
148
|
+
from sqlseed_web.runtime_session import restore_session
|
|
149
|
+
|
|
150
|
+
result = (
|
|
151
|
+
restore_session(session)
|
|
152
|
+
if session
|
|
153
|
+
else {
|
|
154
|
+
"restored_connections": 0,
|
|
155
|
+
"failed_connections": [],
|
|
156
|
+
"ai_session_restored": True,
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
app = create_app(manage_plugins=mode == "maintenance", management_service=manager, supervised_worker=True)
|
|
160
|
+
config = uvicorn.Config(
|
|
161
|
+
app, log_level="warning", access_log=False, lifespan="on", timeout_graceful_shutdown=None
|
|
162
|
+
)
|
|
163
|
+
server = uvicorn.Server(config)
|
|
164
|
+
thread = threading.Thread(
|
|
165
|
+
target=server.run, kwargs={"sockets": [listener]}, daemon=False, name="sqlseed-http-worker"
|
|
166
|
+
)
|
|
167
|
+
thread.start()
|
|
168
|
+
while thread.is_alive() and not server.started:
|
|
169
|
+
time.sleep(0.01)
|
|
170
|
+
if server.started:
|
|
171
|
+
channel.call("worker_ready", {"mode": mode, "restoration": result})
|
|
172
|
+
while thread.is_alive():
|
|
173
|
+
if channel.wait_closed(0.1):
|
|
174
|
+
break
|
|
175
|
+
finally:
|
|
176
|
+
_drain_worker_runtime(mode, runtime_gate)
|
|
177
|
+
if server is not None:
|
|
178
|
+
server.should_exit = True
|
|
179
|
+
if thread is not None:
|
|
180
|
+
thread.join()
|
|
181
|
+
channel.close()
|
|
182
|
+
listener.close()
|
|
183
|
+
if lock_fd is not None:
|
|
184
|
+
os.close(lock_fd)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Recoverable generation failures, including the active connection's DBAPI errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sqlite3
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
10
|
+
from sqlseed.generators._protocol import ConfigurationError, GenerationError, UnknownGeneratorError
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from sqlseed.core.orchestrator import DataOrchestrator
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def generation_errors(
|
|
17
|
+
orch: DataOrchestrator | None = None, *, additional: tuple[type[Exception], ...] = ()
|
|
18
|
+
) -> tuple[type[Exception], ...]:
|
|
19
|
+
"""Describe core's validation/provider contract and the loaded database driver.
|
|
20
|
+
|
|
21
|
+
Raw cursor failures retain their native driver class. Reading that class
|
|
22
|
+
avoids importing optional drivers or opening a connection just to catch an
|
|
23
|
+
error. Programmer errors outside these operational contracts propagate.
|
|
24
|
+
"""
|
|
25
|
+
errors: tuple[type[Exception], ...] = (
|
|
26
|
+
ConfigurationError,
|
|
27
|
+
GenerationError,
|
|
28
|
+
UnknownGeneratorError,
|
|
29
|
+
ValueError,
|
|
30
|
+
TypeError,
|
|
31
|
+
RuntimeError,
|
|
32
|
+
OSError,
|
|
33
|
+
ArithmeticError,
|
|
34
|
+
re.error,
|
|
35
|
+
SQLAlchemyError,
|
|
36
|
+
sqlite3.Error,
|
|
37
|
+
)
|
|
38
|
+
if orch is not None and (engine := getattr(orch.database_adapter, "_engine", None)) is not None:
|
|
39
|
+
error_type = engine.dialect.loaded_dbapi.Error
|
|
40
|
+
if isinstance(error_type, type) and issubclass(error_type, Exception):
|
|
41
|
+
errors += (error_type,)
|
|
42
|
+
return errors + additional
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Interpreter-bound package metadata and lifetime environment admission."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import sysconfig
|
|
9
|
+
import tempfile
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from importlib import metadata
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import IO, Any
|
|
14
|
+
|
|
15
|
+
from packaging.requirements import Requirement
|
|
16
|
+
from packaging.utils import canonicalize_name
|
|
17
|
+
from packaging.version import Version
|
|
18
|
+
|
|
19
|
+
from sqlseed_web.settings_environment import AI_INSTALL_REQUIREMENT, _installer
|
|
20
|
+
|
|
21
|
+
COMPONENT_DISTRIBUTIONS = {
|
|
22
|
+
"ai": "sqlseed-ai",
|
|
23
|
+
"cli": "sqlseed-cli",
|
|
24
|
+
"mcp": "mcp-server-sqlseed",
|
|
25
|
+
"mimesis": "mimesis",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Environment:
|
|
31
|
+
"""A server-owned target; none of these fields are accepted from HTTP."""
|
|
32
|
+
|
|
33
|
+
prefix: Path
|
|
34
|
+
executable: str
|
|
35
|
+
tool: str | None
|
|
36
|
+
tool_executable: str | None
|
|
37
|
+
reason: str | None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _venv_directory_restriction(prefix: Path) -> str | None:
|
|
41
|
+
"""Check isolation and write access, retaining the last applicable restriction."""
|
|
42
|
+
reason = None
|
|
43
|
+
try:
|
|
44
|
+
configuration = (prefix / "pyvenv.cfg").read_text(encoding="utf-8")
|
|
45
|
+
if re.search(r"include-system-site-packages\s*=\s*true", configuration, re.IGNORECASE):
|
|
46
|
+
reason = "共享系统 site-packages 的环境不支持界面管理。"
|
|
47
|
+
locations = {
|
|
48
|
+
prefix,
|
|
49
|
+
Path(sysconfig.get_path("purelib")).resolve(),
|
|
50
|
+
Path(sysconfig.get_path("platlib")).resolve(),
|
|
51
|
+
}
|
|
52
|
+
for location in locations:
|
|
53
|
+
if not location.is_relative_to(prefix):
|
|
54
|
+
reason = "Python 安装目录位于 virtualenv 之外,不支持界面管理。"
|
|
55
|
+
else:
|
|
56
|
+
if (location / "EXTERNALLY-MANAGED").exists():
|
|
57
|
+
reason = "此环境由外部工具管理,请使用原环境管理工具。"
|
|
58
|
+
if not location.is_dir() or not os.access(location, os.W_OK) or not location.stat().st_mode & 0o222:
|
|
59
|
+
reason = "当前 Python 环境不可写,请使用原环境管理工具。"
|
|
60
|
+
if reason is None:
|
|
61
|
+
with tempfile.TemporaryFile(dir=prefix) as probe:
|
|
62
|
+
probe.write(b"sqlseed environment write probe")
|
|
63
|
+
probe.flush()
|
|
64
|
+
except (OSError, UnicodeError):
|
|
65
|
+
reason = "无法验证当前 Python 环境的写入权限。"
|
|
66
|
+
return reason
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _environment() -> Environment:
|
|
70
|
+
prefix = Path(sys.prefix).resolve()
|
|
71
|
+
reason = None
|
|
72
|
+
if sys.platform == "win32":
|
|
73
|
+
reason = "Windows 暂不支持界面组件管理;请使用页面提供的 PowerShell 命令手动管理环境。"
|
|
74
|
+
elif sys.prefix == sys.base_prefix or not (prefix / "pyvenv.cfg").is_file():
|
|
75
|
+
reason = "仅支持当前 Web 所在的独立 virtualenv;系统 Python 请使用原环境管理工具。"
|
|
76
|
+
elif (prefix / "EXTERNALLY-MANAGED").exists():
|
|
77
|
+
reason = "此环境由外部工具管理,请使用原环境管理工具。"
|
|
78
|
+
else:
|
|
79
|
+
reason = _venv_directory_restriction(prefix)
|
|
80
|
+
installer = _installer()
|
|
81
|
+
if reason is None and installer.tool is None:
|
|
82
|
+
reason = "当前 Python 环境没有可用的 pip 或 uv;请使用原环境管理工具。"
|
|
83
|
+
return Environment(prefix, sys.executable, installer.tool, installer.tool_executable, reason)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class EnvironmentLock:
|
|
87
|
+
"""Shared normal servers and an exclusive maintenance server cannot coexist.
|
|
88
|
+
|
|
89
|
+
The lock is held through shutdown, including any package worker. It never
|
|
90
|
+
removes the lock file, preventing old/new inode races between processes.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, prefix: Path, *, exclusive: bool) -> None:
|
|
94
|
+
self.path = prefix / ".sqlseed-web-environment.lock"
|
|
95
|
+
self.exclusive = exclusive
|
|
96
|
+
self._file: IO[bytes] | None = None
|
|
97
|
+
|
|
98
|
+
def acquire(self) -> None:
|
|
99
|
+
if self._file is not None:
|
|
100
|
+
return
|
|
101
|
+
handle = self.path.open("a+b")
|
|
102
|
+
try:
|
|
103
|
+
if sys.platform == "win32":
|
|
104
|
+
import msvcrt
|
|
105
|
+
|
|
106
|
+
handle.seek(0, os.SEEK_END)
|
|
107
|
+
if handle.tell() == 0:
|
|
108
|
+
handle.write(b"\0")
|
|
109
|
+
handle.flush()
|
|
110
|
+
handle.seek(0)
|
|
111
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK if self.exclusive else msvcrt.LK_NBRLCK, 1)
|
|
112
|
+
else:
|
|
113
|
+
import fcntl
|
|
114
|
+
|
|
115
|
+
fcntl.flock(handle.fileno(), (fcntl.LOCK_EX if self.exclusive else fcntl.LOCK_SH) | fcntl.LOCK_NB)
|
|
116
|
+
except OSError as exc:
|
|
117
|
+
handle.close()
|
|
118
|
+
raise RuntimeError("另一个 Web 进程正在使用此 Python 环境,请先停止它。") from exc
|
|
119
|
+
self._file = handle
|
|
120
|
+
|
|
121
|
+
def release(self) -> None:
|
|
122
|
+
if self._file is not None:
|
|
123
|
+
self._file.close()
|
|
124
|
+
self._file = None
|
|
125
|
+
|
|
126
|
+
def fileno(self) -> int:
|
|
127
|
+
"""Retain this same OS lock in an owned serving child until it exits."""
|
|
128
|
+
if self._file is None:
|
|
129
|
+
raise RuntimeError("环境锁尚未持有。")
|
|
130
|
+
return self._file.fileno()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class InstalledPackage:
|
|
135
|
+
"""Validated installed metadata, independent of already imported modules."""
|
|
136
|
+
|
|
137
|
+
version: str
|
|
138
|
+
requirements: tuple[str, ...]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _distribution_paths() -> list[str]:
|
|
142
|
+
return sys.path
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def installed_packages(prefix: Path) -> dict[str, InstalledPackage]:
|
|
146
|
+
result: dict[str, InstalledPackage] = {}
|
|
147
|
+
try:
|
|
148
|
+
for distribution in metadata.distributions(path=_distribution_paths()):
|
|
149
|
+
if not Path(str(distribution.locate_file(""))).resolve().is_relative_to(prefix.resolve()):
|
|
150
|
+
raise ValueError("distribution metadata is outside the managed environment")
|
|
151
|
+
name = distribution.metadata["Name"]
|
|
152
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", name):
|
|
153
|
+
raise ValueError("invalid distribution name")
|
|
154
|
+
normalized = canonicalize_name(name)
|
|
155
|
+
version = str(Version(distribution.version))
|
|
156
|
+
requirements = tuple(sorted(distribution.requires or []))
|
|
157
|
+
for requirement in requirements:
|
|
158
|
+
Requirement(requirement)
|
|
159
|
+
item = InstalledPackage(version, requirements)
|
|
160
|
+
if normalized in result and result[normalized] != item:
|
|
161
|
+
raise ValueError("conflicting distribution metadata")
|
|
162
|
+
result[normalized] = item
|
|
163
|
+
if not {"sqlseed", "sqlseed-web", "faker"}.issubset(result):
|
|
164
|
+
raise ValueError("required distribution metadata is absent")
|
|
165
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
166
|
+
raise RuntimeError("已安装组件的元数据无法安全解析;请先修复当前 Python 环境。") from exc
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def required_by(distribution: str, packages: dict[str, InstalledPackage]) -> list[str]:
|
|
171
|
+
"""Only active base requirements block removal; extras are not installed-state facts."""
|
|
172
|
+
result = []
|
|
173
|
+
for name, package in packages.items():
|
|
174
|
+
if name == distribution:
|
|
175
|
+
continue
|
|
176
|
+
for text in package.requirements:
|
|
177
|
+
requirement = Requirement(text)
|
|
178
|
+
if canonicalize_name(requirement.name) == distribution and (
|
|
179
|
+
requirement.marker is None or requirement.marker.evaluate({"extra": ""})
|
|
180
|
+
):
|
|
181
|
+
result.append(name)
|
|
182
|
+
break
|
|
183
|
+
return sorted(result)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def installer_arguments(environment: Environment, action: str, distribution: str, constraints: Path) -> list[str]:
|
|
187
|
+
"""Construct fixed argv with no shell and no configurable target or package."""
|
|
188
|
+
if distribution not in COMPONENT_DISTRIBUTIONS.values() or action not in {"install", "uninstall"}:
|
|
189
|
+
raise ValueError("unsupported component operation")
|
|
190
|
+
if environment.tool == "pip":
|
|
191
|
+
args = [environment.executable, "-m", "pip", "--isolated", action, "--disable-pip-version-check", "--no-input"]
|
|
192
|
+
if action == "install":
|
|
193
|
+
args += ["--constraint", str(constraints), "--only-binary=:all:"]
|
|
194
|
+
else:
|
|
195
|
+
args += ["--yes"]
|
|
196
|
+
elif environment.tool == "uv" and environment.tool_executable:
|
|
197
|
+
args = [
|
|
198
|
+
environment.tool_executable,
|
|
199
|
+
"--no-config",
|
|
200
|
+
"pip",
|
|
201
|
+
action,
|
|
202
|
+
"--python",
|
|
203
|
+
environment.executable,
|
|
204
|
+
"--no-python-downloads",
|
|
205
|
+
]
|
|
206
|
+
if action == "install":
|
|
207
|
+
args += ["--constraints", str(constraints), "--only-binary=:all:"]
|
|
208
|
+
else:
|
|
209
|
+
raise RuntimeError("没有可用的安装工具。")
|
|
210
|
+
requirement = AI_INSTALL_REQUIREMENT if action == "install" and distribution == "sqlseed-ai" else distribution
|
|
211
|
+
return [*args, requirement]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def package_status(packages: dict[str, InstalledPackage], unavailable: str | None) -> list[dict[str, Any]]:
|
|
215
|
+
result = []
|
|
216
|
+
for identifier, distribution in COMPONENT_DISTRIBUTIONS.items():
|
|
217
|
+
package = packages.get(distribution)
|
|
218
|
+
users = required_by(distribution, packages)
|
|
219
|
+
reason = unavailable or (f"由 {', '.join(users)} 使用,请先卸载这些可选组件。" if users else None)
|
|
220
|
+
result.append(
|
|
221
|
+
{
|
|
222
|
+
"id": identifier,
|
|
223
|
+
"installed": package is not None,
|
|
224
|
+
"version": package.version if package else None,
|
|
225
|
+
"can_install": not unavailable and package is None,
|
|
226
|
+
"can_uninstall": not unavailable and package is not None and not users,
|
|
227
|
+
"reason": reason,
|
|
228
|
+
"required_by": users,
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
return result
|