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/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
File without changes