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.
Files changed (87) hide show
  1. sqlseed_web/AGENTS.md +106 -0
  2. sqlseed_web/__init__.py +24 -0
  3. sqlseed_web/__main__.py +8 -0
  4. sqlseed_web/_application.py +205 -0
  5. sqlseed_web/ai_settings.py +290 -0
  6. sqlseed_web/api.py +1102 -0
  7. sqlseed_web/app.py +26 -0
  8. sqlseed_web/managed_worker.py +184 -0
  9. sqlseed_web/operation_errors.py +42 -0
  10. sqlseed_web/plugin_environment.py +231 -0
  11. sqlseed_web/plugin_management.py +322 -0
  12. sqlseed_web/plugin_process.py +131 -0
  13. sqlseed_web/runtime_lifecycle.py +130 -0
  14. sqlseed_web/runtime_session.py +97 -0
  15. sqlseed_web/settings_environment.py +368 -0
  16. sqlseed_web/sqlite_target.py +86 -0
  17. sqlseed_web/state.py +348 -0
  18. sqlseed_web/static/AGENTS.md +180 -0
  19. sqlseed_web/static/ai.css +57 -0
  20. sqlseed_web/static/configs.css +50 -0
  21. sqlseed_web/static/date-picker.css +230 -0
  22. sqlseed_web/static/disclosure.css +149 -0
  23. sqlseed_web/static/graph-clarity.css +103 -0
  24. sqlseed_web/static/index.html +29 -0
  25. sqlseed_web/static/js/api.js +228 -0
  26. sqlseed_web/static/js/app.js +97 -0
  27. sqlseed_web/static/js/dropdown.js +432 -0
  28. sqlseed_web/static/js/filepicker.js +203 -0
  29. sqlseed_web/static/js/genform.js +1066 -0
  30. sqlseed_web/static/js/labels.js +195 -0
  31. sqlseed_web/static/js/pages/browse.js +209 -0
  32. sqlseed_web/static/js/pages/configs.js +424 -0
  33. sqlseed_web/static/js/pages/connect.js +332 -0
  34. sqlseed_web/static/js/pages/heal.js +395 -0
  35. sqlseed_web/static/js/pages/meta.js +110 -0
  36. sqlseed_web/static/js/pages/runs.js +293 -0
  37. sqlseed_web/static/js/pages/settings.js +942 -0
  38. sqlseed_web/static/js/pages/wizard.js +751 -0
  39. sqlseed_web/static/js/pages/workbench.js +3123 -0
  40. sqlseed_web/static/js/tree.js +126 -0
  41. sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
  42. sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
  43. sqlseed_web/static/js/workbench/ai-stream.js +116 -0
  44. sqlseed_web/static/js/workbench/ai.js +888 -0
  45. sqlseed_web/static/js/workbench/connection.js +508 -0
  46. sqlseed_web/static/js/workbench/date-picker.js +445 -0
  47. sqlseed_web/static/js/workbench/dependency-view.js +119 -0
  48. sqlseed_web/static/js/workbench/editor.js +1236 -0
  49. sqlseed_web/static/js/workbench/focus.js +11 -0
  50. sqlseed_web/static/js/workbench/graph-layout.js +332 -0
  51. sqlseed_web/static/js/workbench/graph.js +970 -0
  52. sqlseed_web/static/js/workbench/guidance.js +29 -0
  53. sqlseed_web/static/js/workbench/model.js +124 -0
  54. sqlseed_web/static/js/workbench/plugin-management.js +512 -0
  55. sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
  56. sqlseed_web/static/js/workbench/preview.js +572 -0
  57. sqlseed_web/static/js/workbench/provider-guide.js +33 -0
  58. sqlseed_web/static/js/workbench/recovery.js +28 -0
  59. sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
  60. sqlseed_web/static/js/workbench/session.js +174 -0
  61. sqlseed_web/static/js/workbench/table-data.js +186 -0
  62. sqlseed_web/static/js/workbench/ui.js +262 -0
  63. sqlseed_web/static/navigation.css +92 -0
  64. sqlseed_web/static/preview.css +29 -0
  65. sqlseed_web/static/runs.css +53 -0
  66. sqlseed_web/static/scrollbars.css +42 -0
  67. sqlseed_web/static/settings.css +108 -0
  68. sqlseed_web/static/style.css +3382 -0
  69. sqlseed_web/static/table-data.css +27 -0
  70. sqlseed_web/static/workbench.css +509 -0
  71. sqlseed_web/supervised_plugins.py +173 -0
  72. sqlseed_web/supervisor.py +238 -0
  73. sqlseed_web/workbench.py +381 -0
  74. sqlseed_web/workbench_ai.py +887 -0
  75. sqlseed_web/workbench_ai_relations.py +285 -0
  76. sqlseed_web/workbench_ai_stream.py +172 -0
  77. sqlseed_web/workbench_data.py +163 -0
  78. sqlseed_web/workbench_execution.py +199 -0
  79. sqlseed_web/workbench_runtime.py +1218 -0
  80. sqlseed_web/workbench_schema.py +277 -0
  81. sqlseed_web/workbench_store.py +458 -0
  82. sqlseed_web/worker_control.py +192 -0
  83. sqlseed_web-0.2.4.dist-info/METADATA +105 -0
  84. sqlseed_web-0.2.4.dist-info/RECORD +87 -0
  85. sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
  86. sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
  87. 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