xcorecli 1.0.0__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.
- xcli/__init__.py +0 -0
- xcli/_run.py +14 -0
- xcli/_xcore.py +56 -0
- xcli/init/__init__.py +0 -0
- xcli/init/cli.py +11 -0
- xcli/init/manager.py +141 -0
- xcli/init/upgrade.py +198 -0
- xcli/main.py +64 -0
- xcli/manager/__init__.py +0 -0
- xcli/manager/cli.py +537 -0
- xcli/marketplace/__init__.py +0 -0
- xcli/marketplace/cli.py +44 -0
- xcli/migrations/__init__.py +3 -0
- xcli/migrations/cli.py +292 -0
- xcli/migrations/runtime.py +198 -0
- xcli/plugin/__init__.py +0 -0
- xcli/plugin/cli.py +538 -0
- xcli/plugin/scaffold.py +213 -0
- xcli/sandbox/__init__.py +0 -0
- xcli/sandbox/cli.py +124 -0
- xcli/worker/__init__.py +0 -0
- xcli/worker/cli.py +569 -0
- xcli/worker/worker.py +565 -0
- xcorecli-1.0.0.dist-info/METADATA +11 -0
- xcorecli-1.0.0.dist-info/RECORD +27 -0
- xcorecli-1.0.0.dist-info/WHEEL +4 -0
- xcorecli-1.0.0.dist-info/entry_points.txt +3 -0
xcli/migrations/cli.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from alembic import command
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from typer import Typer
|
|
9
|
+
|
|
10
|
+
from xcli.migrations.runtime import (
|
|
11
|
+
create_alembic_config,
|
|
12
|
+
discover_models,
|
|
13
|
+
get_database_url,
|
|
14
|
+
plugins_root,
|
|
15
|
+
project_root,
|
|
16
|
+
render_discovery_summary,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_CTX = {"help_option_names": ["-h", "--help"]}
|
|
20
|
+
app = Typer(help="Alembic migrations for xcore plugins.", context_settings=_CTX)
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
_ENV_TEMPLATE = """from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from logging.config import fileConfig
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
from alembic import context
|
|
30
|
+
from sqlalchemy import engine_from_config, pool
|
|
31
|
+
|
|
32
|
+
config = context.config
|
|
33
|
+
ROOT = Path(config.config_file_name).resolve().parent if config.config_file_name else Path.cwd()
|
|
34
|
+
if str(ROOT) not in sys.path:
|
|
35
|
+
sys.path.insert(0, str(ROOT))
|
|
36
|
+
|
|
37
|
+
from xcli.migrations.runtime import discover_models, get_database_url
|
|
38
|
+
|
|
39
|
+
if config.config_file_name is not None:
|
|
40
|
+
fileConfig(config.config_file_name)
|
|
41
|
+
|
|
42
|
+
config.set_main_option("sqlalchemy.url", get_database_url())
|
|
43
|
+
discovery = discover_models()
|
|
44
|
+
target_metadata = discovery.target_metadata
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run_migrations_offline() -> None:
|
|
48
|
+
context.configure(
|
|
49
|
+
url=get_database_url(),
|
|
50
|
+
target_metadata=target_metadata,
|
|
51
|
+
literal_binds=True,
|
|
52
|
+
dialect_opts={"paramstyle": "named"},
|
|
53
|
+
compare_type=True,
|
|
54
|
+
compare_server_default=True,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
with context.begin_transaction():
|
|
58
|
+
context.run_migrations()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_migrations_online() -> None:
|
|
62
|
+
configuration = config.get_section(config.config_ini_section, {})
|
|
63
|
+
configuration["sqlalchemy.url"] = get_database_url()
|
|
64
|
+
|
|
65
|
+
connectable = engine_from_config(
|
|
66
|
+
configuration,
|
|
67
|
+
prefix="sqlalchemy.",
|
|
68
|
+
poolclass=pool.NullPool,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
with connectable.connect() as connection:
|
|
72
|
+
context.configure(
|
|
73
|
+
connection=connection,
|
|
74
|
+
target_metadata=target_metadata,
|
|
75
|
+
compare_type=True,
|
|
76
|
+
compare_server_default=True,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
with context.begin_transaction():
|
|
80
|
+
context.run_migrations()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if context.is_offline_mode():
|
|
84
|
+
run_migrations_offline()
|
|
85
|
+
else:
|
|
86
|
+
run_migrations_online()
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
_SCRIPT_TEMPLATE = """\"\"\"${message}
|
|
90
|
+
|
|
91
|
+
Revision ID: ${up_revision}
|
|
92
|
+
Revises: ${down_revision | comma,n}
|
|
93
|
+
Create Date: ${create_date}
|
|
94
|
+
\"\"\"
|
|
95
|
+
from __future__ import annotations
|
|
96
|
+
|
|
97
|
+
from alembic import op
|
|
98
|
+
import sqlalchemy as sa
|
|
99
|
+
${imports if imports else ""}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# revision identifiers, used by Alembic.
|
|
103
|
+
revision = ${repr(up_revision)}
|
|
104
|
+
down_revision = ${repr(down_revision)}
|
|
105
|
+
branch_labels = ${repr(branch_labels)}
|
|
106
|
+
depends_on = ${repr(depends_on)}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def upgrade() -> None:
|
|
110
|
+
${upgrades if upgrades else "pass"}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def downgrade() -> None:
|
|
114
|
+
${downgrades if downgrades else "pass"}
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
_INI_TEMPLATE = """[alembic]
|
|
118
|
+
script_location = {script_location}
|
|
119
|
+
sqlalchemy.url = {database_url}
|
|
120
|
+
|
|
121
|
+
[loggers]
|
|
122
|
+
keys = root,sqlalchemy,alembic
|
|
123
|
+
|
|
124
|
+
[handlers]
|
|
125
|
+
keys = console
|
|
126
|
+
|
|
127
|
+
[formatters]
|
|
128
|
+
keys = generic
|
|
129
|
+
|
|
130
|
+
[logger_root]
|
|
131
|
+
level = WARN
|
|
132
|
+
handlers = console
|
|
133
|
+
|
|
134
|
+
[logger_sqlalchemy]
|
|
135
|
+
level = WARN
|
|
136
|
+
handlers =
|
|
137
|
+
qualname = sqlalchemy.engine
|
|
138
|
+
|
|
139
|
+
[logger_alembic]
|
|
140
|
+
level = INFO
|
|
141
|
+
handlers =
|
|
142
|
+
qualname = alembic
|
|
143
|
+
|
|
144
|
+
[handler_console]
|
|
145
|
+
class = StreamHandler
|
|
146
|
+
args = (sys.stderr,)
|
|
147
|
+
level = NOTSET
|
|
148
|
+
formatter = generic
|
|
149
|
+
|
|
150
|
+
[formatter_generic]
|
|
151
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
152
|
+
datefmt = %H:%M:%S
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _alembic_dir(directory: str) -> Path:
|
|
157
|
+
return (project_root() / directory).resolve()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _ensure_initialized(directory: str) -> None:
|
|
161
|
+
if not _alembic_dir(directory).exists():
|
|
162
|
+
console.print(
|
|
163
|
+
"[red]Alembic is not initialized yet.[/red] Run [cyan]xcli migration init[/cyan] first."
|
|
164
|
+
)
|
|
165
|
+
raise typer.Exit(1)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.command("init")
|
|
169
|
+
def init(
|
|
170
|
+
directory: str = typer.Option("alembic", "--dir", help="Alembic directory to create."),
|
|
171
|
+
force: bool = typer.Option(False, "--force", help="Overwrite generated files if they already exist."),
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Create an Alembic workspace wired to integration.yaml and plugin models."""
|
|
174
|
+
root = project_root()
|
|
175
|
+
alembic_dir = _alembic_dir(directory)
|
|
176
|
+
versions_dir = alembic_dir / "versions"
|
|
177
|
+
ini_path = root / "alembic.ini"
|
|
178
|
+
|
|
179
|
+
if alembic_dir.exists() and not force:
|
|
180
|
+
console.print(
|
|
181
|
+
f"[yellow]Alembic directory already exists:[/yellow] [cyan]{alembic_dir}[/cyan] "
|
|
182
|
+
"[dim](use --force to overwrite generated files)[/dim]"
|
|
183
|
+
)
|
|
184
|
+
raise typer.Exit(1)
|
|
185
|
+
|
|
186
|
+
discovery = discover_models()
|
|
187
|
+
|
|
188
|
+
alembic_dir.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
versions_dir.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
|
|
191
|
+
(alembic_dir / "README").write_text(
|
|
192
|
+
"Auto-generated by xcli migration init. Revision files live in versions/.\n",
|
|
193
|
+
encoding="utf-8",
|
|
194
|
+
)
|
|
195
|
+
(alembic_dir / "env.py").write_text(_ENV_TEMPLATE, encoding="utf-8")
|
|
196
|
+
(alembic_dir / "script.py.mako").write_text(_SCRIPT_TEMPLATE, encoding="utf-8")
|
|
197
|
+
(alembic_dir / "__init__.py").write_text("", encoding="utf-8")
|
|
198
|
+
ini_path.write_text(
|
|
199
|
+
_INI_TEMPLATE.format(script_location=directory, database_url=get_database_url()),
|
|
200
|
+
encoding="utf-8",
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
console.print(f"[green]✓[/green] Alembic initialized in [cyan]{alembic_dir}[/cyan]")
|
|
204
|
+
console.print(f"[green]✓[/green] Config written to [cyan]{ini_path}[/cyan]")
|
|
205
|
+
console.print(f"[dim]Plugins root:[/dim] {plugins_root()}")
|
|
206
|
+
console.print(f"[dim]Discovery:[/dim] {render_discovery_summary(discovery)}")
|
|
207
|
+
|
|
208
|
+
if discovery.modules:
|
|
209
|
+
console.print("[dim]Modules scanned:[/dim]")
|
|
210
|
+
for module in discovery.modules:
|
|
211
|
+
console.print(f" - {module}")
|
|
212
|
+
|
|
213
|
+
if discovery.warnings:
|
|
214
|
+
console.print("[yellow]Warnings while scanning models:[/yellow]")
|
|
215
|
+
for warning in discovery.warnings:
|
|
216
|
+
console.print(f" - {warning}")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@app.command("revision")
|
|
220
|
+
def revision(
|
|
221
|
+
message: str = typer.Option(..., "--message", "-m", help="Revision message."),
|
|
222
|
+
autogenerate: bool = typer.Option(
|
|
223
|
+
True,
|
|
224
|
+
"--autogenerate/--empty",
|
|
225
|
+
help="Generate migration operations from discovered SQLAlchemy metadata.",
|
|
226
|
+
),
|
|
227
|
+
directory: str = typer.Option("alembic", "--dir", help="Alembic directory."),
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Create a new Alembic revision."""
|
|
230
|
+
_ensure_initialized(directory)
|
|
231
|
+
cfg = create_alembic_config(directory)
|
|
232
|
+
command.revision(cfg, message=message, autogenerate=autogenerate)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@app.command("upgrade")
|
|
236
|
+
def upgrade(
|
|
237
|
+
revision: str = typer.Argument("head", help="Target revision, for example head or +1."),
|
|
238
|
+
directory: str = typer.Option("alembic", "--dir", help="Alembic directory."),
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Apply migrations."""
|
|
241
|
+
_ensure_initialized(directory)
|
|
242
|
+
command.upgrade(create_alembic_config(directory), revision)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@app.command("downgrade")
|
|
246
|
+
def downgrade(
|
|
247
|
+
revision: str = typer.Argument(..., help="Target revision, for example -1 or base."),
|
|
248
|
+
directory: str = typer.Option("alembic", "--dir", help="Alembic directory."),
|
|
249
|
+
) -> None:
|
|
250
|
+
"""Rollback migrations."""
|
|
251
|
+
_ensure_initialized(directory)
|
|
252
|
+
command.downgrade(create_alembic_config(directory), revision)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@app.command("current")
|
|
256
|
+
def current(
|
|
257
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed revision info."),
|
|
258
|
+
directory: str = typer.Option("alembic", "--dir", help="Alembic directory."),
|
|
259
|
+
) -> None:
|
|
260
|
+
"""Show the current database revision."""
|
|
261
|
+
_ensure_initialized(directory)
|
|
262
|
+
command.current(create_alembic_config(directory), verbose=verbose)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@app.command("history")
|
|
266
|
+
def history(
|
|
267
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed history."),
|
|
268
|
+
directory: str = typer.Option("alembic", "--dir", help="Alembic directory."),
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Show migration history."""
|
|
271
|
+
_ensure_initialized(directory)
|
|
272
|
+
command.history(create_alembic_config(directory), verbose=verbose)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@app.command("heads")
|
|
276
|
+
def heads(
|
|
277
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed head info."),
|
|
278
|
+
directory: str = typer.Option("alembic", "--dir", help="Alembic directory."),
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Show migration heads."""
|
|
281
|
+
_ensure_initialized(directory)
|
|
282
|
+
command.heads(create_alembic_config(directory), verbose=verbose)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@app.command("stamp")
|
|
286
|
+
def stamp(
|
|
287
|
+
revision: str = typer.Argument(..., help="Revision to stamp without applying migrations."),
|
|
288
|
+
directory: str = typer.Option("alembic", "--dir", help="Alembic directory."),
|
|
289
|
+
) -> None:
|
|
290
|
+
"""Stamp the database with a revision without running upgrade/downgrade."""
|
|
291
|
+
_ensure_initialized(directory)
|
|
292
|
+
command.stamp(create_alembic_config(directory), revision)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import inspect
|
|
5
|
+
import sys
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import ModuleType
|
|
10
|
+
from typing import Iterator
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from alembic.config import Config
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from sqlalchemy import MetaData
|
|
16
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
_CONFIG_CANDIDATES = (
|
|
21
|
+
"integration.yaml",
|
|
22
|
+
"integration.json",
|
|
23
|
+
"config/integration.yaml",
|
|
24
|
+
"config/integration.json",
|
|
25
|
+
)
|
|
26
|
+
_IGNORED_PARTS = {
|
|
27
|
+
".git",
|
|
28
|
+
".hg",
|
|
29
|
+
".svn",
|
|
30
|
+
".venv",
|
|
31
|
+
"__pycache__",
|
|
32
|
+
"node_modules",
|
|
33
|
+
"dist",
|
|
34
|
+
"build",
|
|
35
|
+
"data",
|
|
36
|
+
"alembic",
|
|
37
|
+
"migrations",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class DiscoveryResult:
|
|
43
|
+
target_metadata: list[MetaData]
|
|
44
|
+
modules: list[str]
|
|
45
|
+
warnings: list[str]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def require_config_path() -> Path:
|
|
49
|
+
for candidate in _CONFIG_CANDIDATES:
|
|
50
|
+
path = Path(candidate)
|
|
51
|
+
if path.exists():
|
|
52
|
+
return path.resolve()
|
|
53
|
+
console.print("[yellow]⚠[/yellow] No integration.yaml — run [cyan]xcli init[/cyan] first.")
|
|
54
|
+
raise SystemExit(1)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_config() -> dict:
|
|
58
|
+
path = require_config_path()
|
|
59
|
+
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def project_root() -> Path:
|
|
63
|
+
return require_config_path().parent.resolve()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def plugins_root() -> Path:
|
|
67
|
+
cfg = load_config()
|
|
68
|
+
raw = cfg.get("plugins", {}).get("directory", "./app")
|
|
69
|
+
root = Path(raw)
|
|
70
|
+
if not root.is_absolute():
|
|
71
|
+
root = project_root() / root
|
|
72
|
+
return root.resolve()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_database_url() -> str:
|
|
76
|
+
cfg = load_config()
|
|
77
|
+
databases = cfg.get("services", {}).get("databases", {})
|
|
78
|
+
default = databases.get("default", {})
|
|
79
|
+
url = default.get("url")
|
|
80
|
+
if not url:
|
|
81
|
+
console.print(
|
|
82
|
+
"[red]No database URL found in services.databases.default.url inside integration.yaml[/red]"
|
|
83
|
+
)
|
|
84
|
+
raise SystemExit(1)
|
|
85
|
+
return str(url)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def create_alembic_config(directory: str = "alembic") -> Config:
|
|
89
|
+
root = project_root()
|
|
90
|
+
alembic_dir = root / directory
|
|
91
|
+
cfg = Config()
|
|
92
|
+
cfg.set_main_option("script_location", str(alembic_dir))
|
|
93
|
+
cfg.set_main_option("sqlalchemy.url", get_database_url())
|
|
94
|
+
cfg.config_file_name = str(root / "alembic.ini")
|
|
95
|
+
return cfg
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def discover_models() -> DiscoveryResult:
|
|
99
|
+
root = plugins_root()
|
|
100
|
+
if not root.exists():
|
|
101
|
+
console.print(f"[red]Plugins directory not found: [cyan]{root}[/cyan][/red]")
|
|
102
|
+
raise SystemExit(1)
|
|
103
|
+
|
|
104
|
+
metadata_by_id: dict[int, MetaData] = {}
|
|
105
|
+
loaded_modules: list[str] = []
|
|
106
|
+
warnings: list[str] = []
|
|
107
|
+
|
|
108
|
+
for plugin_dir in sorted(p for p in root.iterdir() if p.is_dir() and not p.name.startswith('.')):
|
|
109
|
+
for py_file in _iter_python_files(plugin_dir):
|
|
110
|
+
try:
|
|
111
|
+
module = _load_module(py_file, plugin_dir)
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
warnings.append(f"{py_file}: {exc}")
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
loaded_modules.append(str(py_file.relative_to(project_root())))
|
|
117
|
+
for metadata in _extract_metadata(module):
|
|
118
|
+
metadata_by_id.setdefault(id(metadata), metadata)
|
|
119
|
+
|
|
120
|
+
return DiscoveryResult(
|
|
121
|
+
target_metadata=list(metadata_by_id.values()),
|
|
122
|
+
modules=loaded_modules,
|
|
123
|
+
warnings=warnings,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def render_discovery_summary(result: DiscoveryResult) -> str:
|
|
128
|
+
parts = [
|
|
129
|
+
f"loaded={len(result.modules)} module(s)",
|
|
130
|
+
f"metadata={len(result.target_metadata)}",
|
|
131
|
+
f"warnings={len(result.warnings)}",
|
|
132
|
+
]
|
|
133
|
+
return ", ".join(parts)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _iter_python_files(plugin_dir: Path) -> Iterator[Path]:
|
|
137
|
+
for path in plugin_dir.rglob("*.py"):
|
|
138
|
+
if any(part in _IGNORED_PARTS for part in path.parts):
|
|
139
|
+
continue
|
|
140
|
+
yield path
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@contextmanager
|
|
144
|
+
def _sys_path(*paths: Path) -> Iterator[None]:
|
|
145
|
+
original = list(sys.path)
|
|
146
|
+
inserts = [str(path.resolve()) for path in paths if path.exists()]
|
|
147
|
+
for entry in reversed(inserts):
|
|
148
|
+
if entry not in sys.path:
|
|
149
|
+
sys.path.insert(0, entry)
|
|
150
|
+
try:
|
|
151
|
+
yield
|
|
152
|
+
finally:
|
|
153
|
+
sys.path[:] = original
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _load_module(py_file: Path, plugin_dir: Path) -> ModuleType:
|
|
157
|
+
module_name = "xcli_migrations_" + "_".join(py_file.with_suffix("").parts)
|
|
158
|
+
spec = importlib.util.spec_from_file_location(module_name, py_file)
|
|
159
|
+
if spec is None or spec.loader is None:
|
|
160
|
+
raise ImportError("unable to build module spec")
|
|
161
|
+
|
|
162
|
+
module = importlib.util.module_from_spec(spec)
|
|
163
|
+
sys.modules[module_name] = module
|
|
164
|
+
try:
|
|
165
|
+
with _sys_path(project_root(), plugin_dir, plugin_dir / "src"):
|
|
166
|
+
spec.loader.exec_module(module)
|
|
167
|
+
finally:
|
|
168
|
+
sys.modules.pop(module_name, None)
|
|
169
|
+
return module
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _extract_metadata(module: ModuleType) -> list[MetaData]:
|
|
173
|
+
found: dict[int, MetaData] = {}
|
|
174
|
+
for obj in vars(module).values():
|
|
175
|
+
metadata = _metadata_from_object(obj)
|
|
176
|
+
if metadata is not None:
|
|
177
|
+
found.setdefault(id(metadata), metadata)
|
|
178
|
+
return list(found.values())
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _metadata_from_object(obj: object) -> MetaData | None:
|
|
182
|
+
if isinstance(obj, MetaData):
|
|
183
|
+
return obj
|
|
184
|
+
|
|
185
|
+
if inspect.isclass(obj):
|
|
186
|
+
if obj is not DeclarativeBase and issubclass(obj, DeclarativeBase):
|
|
187
|
+
return getattr(obj, "metadata", None)
|
|
188
|
+
|
|
189
|
+
metadata = getattr(obj, "metadata", None)
|
|
190
|
+
registry = getattr(obj, "registry", None)
|
|
191
|
+
if isinstance(metadata, MetaData) and registry is not None:
|
|
192
|
+
return metadata
|
|
193
|
+
|
|
194
|
+
metadata = getattr(obj, "metadata", None)
|
|
195
|
+
if isinstance(metadata, MetaData) and getattr(obj, "__table__", None) is not None:
|
|
196
|
+
return metadata
|
|
197
|
+
|
|
198
|
+
return None
|
xcli/plugin/__init__.py
ADDED
|
File without changes
|