chkit-py 0.1.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.
- chkit/__init__.py +62 -0
- chkit/cli/__init__.py +1 -0
- chkit/cli/commands/__init__.py +5 -0
- chkit/cli/commands/check.py +59 -0
- chkit/cli/commands/drift.py +63 -0
- chkit/cli/commands/generate.py +79 -0
- chkit/cli/commands/init.py +68 -0
- chkit/cli/commands/migrate.py +83 -0
- chkit/cli/commands/status.py +51 -0
- chkit/cli/config_loader.py +47 -0
- chkit/cli/main.py +47 -0
- chkit/cli/migration_store.py +84 -0
- chkit/cli/schema_loader.py +61 -0
- chkit/clickhouse/__init__.py +5 -0
- chkit/clickhouse/client.py +92 -0
- chkit/core/__init__.py +128 -0
- chkit/core/canonical.py +234 -0
- chkit/core/codec.py +263 -0
- chkit/core/diff_primitives.py +104 -0
- chkit/core/flags.py +129 -0
- chkit/core/key_clause.py +55 -0
- chkit/core/model.py +755 -0
- chkit/core/planner.py +584 -0
- chkit/core/snapshot.py +19 -0
- chkit/core/sql.py +309 -0
- chkit/core/sql_normalizer.py +21 -0
- chkit/core/sql_splitter.py +104 -0
- chkit/core/validate.py +277 -0
- chkit/py.typed +0 -0
- chkit_py-0.1.0.dist-info/METADATA +64 -0
- chkit_py-0.1.0.dist-info/RECORD +33 -0
- chkit_py-0.1.0.dist-info/WHEEL +4 -0
- chkit_py-0.1.0.dist-info/entry_points.txt +2 -0
chkit/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""chkit — ClickHouse schema and migration toolkit."""
|
|
2
|
+
|
|
3
|
+
from chkit.core import (
|
|
4
|
+
ChxResolvedClickHouseConfig,
|
|
5
|
+
ChxResolvedConfig,
|
|
6
|
+
ChxUserClickHouseConfig,
|
|
7
|
+
ChxUserConfig,
|
|
8
|
+
ChxValidationError,
|
|
9
|
+
ColumnDefinition,
|
|
10
|
+
MaterializedViewDefinition,
|
|
11
|
+
MaterializedViewRefresh,
|
|
12
|
+
MigrationOperation,
|
|
13
|
+
MigrationPlan,
|
|
14
|
+
ProjectionDefinition,
|
|
15
|
+
SchemaDefinition,
|
|
16
|
+
TableDefinition,
|
|
17
|
+
TableRef,
|
|
18
|
+
ValidationIssue,
|
|
19
|
+
ViewDefinition,
|
|
20
|
+
canonicalize_definitions,
|
|
21
|
+
define_config,
|
|
22
|
+
materialized_view,
|
|
23
|
+
plan_diff,
|
|
24
|
+
resolve_config,
|
|
25
|
+
schema,
|
|
26
|
+
table,
|
|
27
|
+
to_create_sql,
|
|
28
|
+
validate_definitions,
|
|
29
|
+
view,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.0"
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"ChxResolvedClickHouseConfig",
|
|
36
|
+
"ChxResolvedConfig",
|
|
37
|
+
"ChxUserClickHouseConfig",
|
|
38
|
+
"ChxUserConfig",
|
|
39
|
+
"ChxValidationError",
|
|
40
|
+
"ColumnDefinition",
|
|
41
|
+
"MaterializedViewDefinition",
|
|
42
|
+
"MaterializedViewRefresh",
|
|
43
|
+
"MigrationOperation",
|
|
44
|
+
"MigrationPlan",
|
|
45
|
+
"ProjectionDefinition",
|
|
46
|
+
"SchemaDefinition",
|
|
47
|
+
"TableDefinition",
|
|
48
|
+
"TableRef",
|
|
49
|
+
"ValidationIssue",
|
|
50
|
+
"ViewDefinition",
|
|
51
|
+
"__version__",
|
|
52
|
+
"canonicalize_definitions",
|
|
53
|
+
"define_config",
|
|
54
|
+
"materialized_view",
|
|
55
|
+
"plan_diff",
|
|
56
|
+
"resolve_config",
|
|
57
|
+
"schema",
|
|
58
|
+
"table",
|
|
59
|
+
"to_create_sql",
|
|
60
|
+
"validate_definitions",
|
|
61
|
+
"view",
|
|
62
|
+
]
|
chkit/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""chkit CLI."""
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""`chkit check` — pre-flight gate for CI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from chkit.cli.config_loader import load_config
|
|
12
|
+
from chkit.cli.migration_store import list_migrations, read_snapshot
|
|
13
|
+
from chkit.cli.schema_loader import load_schema
|
|
14
|
+
from chkit.core.canonical import canonicalize_definitions
|
|
15
|
+
from chkit.core.planner import plan_diff
|
|
16
|
+
from chkit.core.validate import validate_definitions
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run(
|
|
20
|
+
config_path: Annotated[
|
|
21
|
+
Path | None, typer.Option("--config", "-c", help="Path to chkit.config.py.")
|
|
22
|
+
] = None,
|
|
23
|
+
output_json: Annotated[
|
|
24
|
+
bool, typer.Option("--json", help="Emit a JSON-formatted summary.")
|
|
25
|
+
] = False,
|
|
26
|
+
) -> None:
|
|
27
|
+
config = load_config(config_path)
|
|
28
|
+
schema_defs = canonicalize_definitions(load_schema(config.schema_))
|
|
29
|
+
|
|
30
|
+
issues = [issue.model_dump(mode="json") for issue in validate_definitions(schema_defs)]
|
|
31
|
+
pending = [m.stem for m in list_migrations(Path(config.migrations_dir))]
|
|
32
|
+
snapshot = read_snapshot(Path(config.meta_dir))
|
|
33
|
+
drift_ops: list[str] = []
|
|
34
|
+
if snapshot is not None:
|
|
35
|
+
plan = plan_diff(list(snapshot.definitions), schema_defs)
|
|
36
|
+
drift_ops = [op.key for op in plan.operations]
|
|
37
|
+
|
|
38
|
+
failed = False
|
|
39
|
+
if config.check.fail_on_pending and pending:
|
|
40
|
+
failed = True
|
|
41
|
+
if config.check.fail_on_drift and drift_ops:
|
|
42
|
+
failed = True
|
|
43
|
+
if issues:
|
|
44
|
+
failed = True
|
|
45
|
+
|
|
46
|
+
summary = {
|
|
47
|
+
"ok": not failed,
|
|
48
|
+
"issues": issues,
|
|
49
|
+
"pending_migrations": pending,
|
|
50
|
+
"drift_operations": drift_ops,
|
|
51
|
+
}
|
|
52
|
+
if output_json:
|
|
53
|
+
typer.echo(json.dumps(summary, indent=2))
|
|
54
|
+
else:
|
|
55
|
+
typer.echo(f"Validation issues: {len(issues)}")
|
|
56
|
+
typer.echo(f"Pending migrations: {len(pending)}")
|
|
57
|
+
typer.echo(f"Drift operations: {len(drift_ops)}")
|
|
58
|
+
if failed:
|
|
59
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""`chkit drift` — compare last snapshot vs. current schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from chkit.cli.config_loader import load_config
|
|
12
|
+
from chkit.cli.migration_store import read_snapshot
|
|
13
|
+
from chkit.cli.schema_loader import load_schema
|
|
14
|
+
from chkit.core.canonical import canonicalize_definitions
|
|
15
|
+
from chkit.core.planner import plan_diff
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run(
|
|
19
|
+
config_path: Annotated[
|
|
20
|
+
Path | None, typer.Option("--config", "-c", help="Path to chkit.config.py.")
|
|
21
|
+
] = None,
|
|
22
|
+
output_json: Annotated[
|
|
23
|
+
bool, typer.Option("--json", help="Emit a JSON-formatted summary.")
|
|
24
|
+
] = False,
|
|
25
|
+
) -> None:
|
|
26
|
+
config = load_config(config_path)
|
|
27
|
+
schema_defs = canonicalize_definitions(load_schema(config.schema_))
|
|
28
|
+
snapshot = read_snapshot(Path(config.meta_dir))
|
|
29
|
+
if snapshot is None:
|
|
30
|
+
typer.echo(
|
|
31
|
+
"No snapshot found — run `chkit generate` first."
|
|
32
|
+
if not output_json
|
|
33
|
+
else json.dumps({"status": "no-snapshot"})
|
|
34
|
+
)
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
plan = plan_diff(list(snapshot.definitions), schema_defs)
|
|
38
|
+
if not plan.operations:
|
|
39
|
+
typer.echo(
|
|
40
|
+
"No drift detected."
|
|
41
|
+
if not output_json
|
|
42
|
+
else json.dumps({"status": "no-drift"})
|
|
43
|
+
)
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
if output_json:
|
|
47
|
+
typer.echo(
|
|
48
|
+
json.dumps(
|
|
49
|
+
{
|
|
50
|
+
"status": "drift",
|
|
51
|
+
"operations": [op.model_dump(by_alias=True) for op in plan.operations],
|
|
52
|
+
"rename_suggestions": [
|
|
53
|
+
s.model_dump(by_alias=True) for s in plan.rename_suggestions
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
indent=2,
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
typer.secho(f"Drift detected: {len(plan.operations)} operation(s)", fg=typer.colors.YELLOW)
|
|
62
|
+
for op in plan.operations:
|
|
63
|
+
typer.echo(f" [{op.risk}] {op.type} {op.key}")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""`chkit generate` — diff current schema vs. last snapshot and emit a migration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from chkit.cli.config_loader import load_config
|
|
12
|
+
from chkit.cli.migration_store import read_snapshot, write_migration, write_snapshot
|
|
13
|
+
from chkit.cli.schema_loader import load_schema
|
|
14
|
+
from chkit.core.canonical import canonicalize_definitions
|
|
15
|
+
from chkit.core.model import ChxValidationError
|
|
16
|
+
from chkit.core.planner import plan_diff
|
|
17
|
+
from chkit.core.snapshot import create_snapshot
|
|
18
|
+
from chkit.core.validate import validate_definitions
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run(
|
|
22
|
+
config_path: Annotated[
|
|
23
|
+
Path | None,
|
|
24
|
+
typer.Option("--config", "-c", help="Path to chkit.config.py."),
|
|
25
|
+
] = None,
|
|
26
|
+
label: Annotated[
|
|
27
|
+
str,
|
|
28
|
+
typer.Option("--label", "-l", help="Label suffix appended to the migration id."),
|
|
29
|
+
] = "auto",
|
|
30
|
+
output_json: Annotated[
|
|
31
|
+
bool,
|
|
32
|
+
typer.Option("--json", help="Emit a JSON-formatted summary."),
|
|
33
|
+
] = False,
|
|
34
|
+
) -> None:
|
|
35
|
+
config = load_config(config_path)
|
|
36
|
+
schema_globs = config.schema_
|
|
37
|
+
definitions = load_schema(schema_globs)
|
|
38
|
+
canonical = canonicalize_definitions(definitions)
|
|
39
|
+
|
|
40
|
+
issues = validate_definitions(canonical)
|
|
41
|
+
if issues:
|
|
42
|
+
raise ChxValidationError(issues)
|
|
43
|
+
|
|
44
|
+
meta_dir = Path(config.meta_dir)
|
|
45
|
+
migrations_dir = Path(config.migrations_dir)
|
|
46
|
+
|
|
47
|
+
previous = read_snapshot(meta_dir)
|
|
48
|
+
old_defs = list(previous.definitions) if previous is not None else []
|
|
49
|
+
|
|
50
|
+
plan = plan_diff(old_defs, canonical)
|
|
51
|
+
if not plan.operations:
|
|
52
|
+
message = {"status": "no-changes"}
|
|
53
|
+
typer.echo(json.dumps(message) if output_json else "No schema changes detected.")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
artifact = write_migration(migrations_dir, plan, label=label)
|
|
57
|
+
snapshot = create_snapshot(canonical)
|
|
58
|
+
snapshot_path = write_snapshot(meta_dir, snapshot)
|
|
59
|
+
|
|
60
|
+
summary = {
|
|
61
|
+
"status": "generated",
|
|
62
|
+
"migration_id": artifact.id,
|
|
63
|
+
"sql_path": str(artifact.sql_path),
|
|
64
|
+
"snapshot_path": str(snapshot_path),
|
|
65
|
+
"operations": len(plan.operations),
|
|
66
|
+
"risk_summary": plan.risk_summary.model_dump(),
|
|
67
|
+
}
|
|
68
|
+
if output_json:
|
|
69
|
+
typer.echo(json.dumps(summary, indent=2))
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
typer.secho(f"Generated migration {artifact.id}", fg=typer.colors.GREEN)
|
|
73
|
+
typer.echo(f" SQL: {artifact.sql_path}")
|
|
74
|
+
typer.echo(f" Snapshot: {snapshot_path}")
|
|
75
|
+
typer.echo(f" Operations: {len(plan.operations)}")
|
|
76
|
+
risks = plan.risk_summary
|
|
77
|
+
typer.echo(
|
|
78
|
+
f" Risk: safe={risks.safe} caution={risks.caution} danger={risks.danger}"
|
|
79
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""`chkit init` — scaffold a starter project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
_CONFIG_TEMPLATE = '''"""chkit configuration. Edit and re-run `chkit generate`."""
|
|
11
|
+
|
|
12
|
+
from chkit import ChxUserConfig
|
|
13
|
+
|
|
14
|
+
config = ChxUserConfig.model_validate(
|
|
15
|
+
{
|
|
16
|
+
"schema": ["./schema/**/*.py"],
|
|
17
|
+
"outDir": "./chkit",
|
|
18
|
+
"clickhouse": {
|
|
19
|
+
"url": "http://localhost:8123",
|
|
20
|
+
"username": "default",
|
|
21
|
+
"password": "",
|
|
22
|
+
"database": "default",
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
'''
|
|
27
|
+
|
|
28
|
+
_EXAMPLE_TEMPLATE = '''"""Example chkit schema."""
|
|
29
|
+
|
|
30
|
+
from chkit import ColumnDefinition, table
|
|
31
|
+
|
|
32
|
+
events = table(
|
|
33
|
+
database="default",
|
|
34
|
+
name="events",
|
|
35
|
+
engine="MergeTree",
|
|
36
|
+
columns=[
|
|
37
|
+
ColumnDefinition(name="ts", type="DateTime"),
|
|
38
|
+
ColumnDefinition(name="user_id", type="UInt64"),
|
|
39
|
+
ColumnDefinition(name="event", type="String"),
|
|
40
|
+
],
|
|
41
|
+
primary_key=["ts"],
|
|
42
|
+
order_by=["ts", "user_id"],
|
|
43
|
+
)
|
|
44
|
+
'''
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run(
|
|
48
|
+
out_dir: Annotated[
|
|
49
|
+
Path,
|
|
50
|
+
typer.Option("--out", "-o", help="Project root to scaffold into."),
|
|
51
|
+
] = Path("."),
|
|
52
|
+
) -> None:
|
|
53
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
config_path = out_dir / "chkit.config.py"
|
|
55
|
+
example_path = out_dir / "schema" / "events.py"
|
|
56
|
+
|
|
57
|
+
if config_path.exists():
|
|
58
|
+
typer.secho(f"Skipping: {config_path} already exists", fg=typer.colors.YELLOW)
|
|
59
|
+
else:
|
|
60
|
+
config_path.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
|
|
61
|
+
typer.secho(f"Created {config_path}", fg=typer.colors.GREEN)
|
|
62
|
+
|
|
63
|
+
example_path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
if example_path.exists():
|
|
65
|
+
typer.secho(f"Skipping: {example_path} already exists", fg=typer.colors.YELLOW)
|
|
66
|
+
else:
|
|
67
|
+
example_path.write_text(_EXAMPLE_TEMPLATE, encoding="utf-8")
|
|
68
|
+
typer.secho(f"Created {example_path}", fg=typer.colors.GREEN)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""`chkit migrate` — apply pending migrations to the target database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from chkit.cli.config_loader import load_config
|
|
12
|
+
from chkit.cli.migration_store import list_migrations
|
|
13
|
+
from chkit.clickhouse.client import ClickHouseClient
|
|
14
|
+
from chkit.core.sql_splitter import split_sql_statements
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _read_applied(meta_dir: Path) -> set[str]:
|
|
18
|
+
applied_file = meta_dir / "applied.json"
|
|
19
|
+
if not applied_file.exists():
|
|
20
|
+
return set()
|
|
21
|
+
payload = json.loads(applied_file.read_text(encoding="utf-8"))
|
|
22
|
+
return {str(item) for item in payload.get("ids", [])}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _write_applied(meta_dir: Path, ids: set[str]) -> None:
|
|
26
|
+
meta_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
applied_file = meta_dir / "applied.json"
|
|
28
|
+
applied_file.write_text(
|
|
29
|
+
json.dumps({"ids": sorted(ids)}, indent=2), encoding="utf-8"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run(
|
|
34
|
+
config_path: Annotated[
|
|
35
|
+
Path | None, typer.Option("--config", "-c", help="Path to chkit.config.py.")
|
|
36
|
+
] = None,
|
|
37
|
+
dry_run: Annotated[
|
|
38
|
+
bool, typer.Option("--dry-run", help="Print statements without executing.")
|
|
39
|
+
] = False,
|
|
40
|
+
output_json: Annotated[
|
|
41
|
+
bool, typer.Option("--json", help="Emit a JSON-formatted summary.")
|
|
42
|
+
] = False,
|
|
43
|
+
) -> None:
|
|
44
|
+
config = load_config(config_path)
|
|
45
|
+
if config.clickhouse is None:
|
|
46
|
+
msg = "chkit.config.py must include a `clickhouse` block to migrate."
|
|
47
|
+
raise typer.BadParameter(msg)
|
|
48
|
+
|
|
49
|
+
migrations_dir = Path(config.migrations_dir)
|
|
50
|
+
meta_dir = Path(config.meta_dir)
|
|
51
|
+
|
|
52
|
+
all_migrations = list_migrations(migrations_dir)
|
|
53
|
+
applied = _read_applied(meta_dir)
|
|
54
|
+
pending = [m for m in all_migrations if m.stem not in applied]
|
|
55
|
+
|
|
56
|
+
if not pending:
|
|
57
|
+
result = {"status": "up-to-date", "applied": sorted(applied)}
|
|
58
|
+
typer.echo(json.dumps(result) if output_json else "No pending migrations.")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
executed: list[str] = []
|
|
62
|
+
if dry_run:
|
|
63
|
+
for migration in pending:
|
|
64
|
+
typer.echo(f"-- {migration.name}")
|
|
65
|
+
typer.echo(migration.read_text(encoding="utf-8"))
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
with ClickHouseClient.connect(config.clickhouse) as client:
|
|
69
|
+
for migration in pending:
|
|
70
|
+
sql_text = migration.read_text(encoding="utf-8")
|
|
71
|
+
for statement in split_sql_statements(sql_text):
|
|
72
|
+
client.execute(statement)
|
|
73
|
+
executed.append(migration.stem)
|
|
74
|
+
applied.add(migration.stem)
|
|
75
|
+
_write_applied(meta_dir, applied)
|
|
76
|
+
|
|
77
|
+
summary = {"status": "applied", "executed": executed}
|
|
78
|
+
if output_json:
|
|
79
|
+
typer.echo(json.dumps(summary, indent=2))
|
|
80
|
+
return
|
|
81
|
+
typer.secho(f"Applied {len(executed)} migration(s).", fg=typer.colors.GREEN)
|
|
82
|
+
for migration_id in executed:
|
|
83
|
+
typer.echo(f" - {migration_id}")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""`chkit status` — show applied vs. pending migrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from chkit.cli.config_loader import load_config
|
|
12
|
+
from chkit.cli.migration_store import list_migrations
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _read_applied(meta_dir: Path) -> set[str]:
|
|
16
|
+
applied_file = meta_dir / "applied.json"
|
|
17
|
+
if not applied_file.exists():
|
|
18
|
+
return set()
|
|
19
|
+
payload = json.loads(applied_file.read_text(encoding="utf-8"))
|
|
20
|
+
return {str(item) for item in payload.get("ids", [])}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run(
|
|
24
|
+
config_path: Annotated[
|
|
25
|
+
Path | None, typer.Option("--config", "-c", help="Path to chkit.config.py.")
|
|
26
|
+
] = None,
|
|
27
|
+
output_json: Annotated[
|
|
28
|
+
bool, typer.Option("--json", help="Emit a JSON-formatted summary.")
|
|
29
|
+
] = False,
|
|
30
|
+
) -> None:
|
|
31
|
+
config = load_config(config_path)
|
|
32
|
+
migrations_dir = Path(config.migrations_dir)
|
|
33
|
+
meta_dir = Path(config.meta_dir)
|
|
34
|
+
all_migrations = list_migrations(migrations_dir)
|
|
35
|
+
applied = _read_applied(meta_dir)
|
|
36
|
+
pending = [m.stem for m in all_migrations if m.stem not in applied]
|
|
37
|
+
|
|
38
|
+
summary = {
|
|
39
|
+
"applied": sorted(applied),
|
|
40
|
+
"pending": pending,
|
|
41
|
+
"total": len(all_migrations),
|
|
42
|
+
}
|
|
43
|
+
if output_json:
|
|
44
|
+
typer.echo(json.dumps(summary, indent=2))
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
typer.echo(f"Total migrations: {len(all_migrations)}")
|
|
48
|
+
typer.echo(f"Applied: {len(applied)}")
|
|
49
|
+
typer.echo(f"Pending: {len(pending)}")
|
|
50
|
+
for migration_id in pending:
|
|
51
|
+
typer.echo(f" - {migration_id}")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Load and validate a user's ``chkit.config.py`` config file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from chkit.core.model import ChxResolvedConfig, ChxUserConfig, resolve_config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load_module(config_path: Path) -> Any:
|
|
14
|
+
spec = importlib.util.spec_from_file_location("chkit_user_config", config_path)
|
|
15
|
+
if spec is None or spec.loader is None:
|
|
16
|
+
msg = f"Unable to load config module from {config_path}"
|
|
17
|
+
raise RuntimeError(msg)
|
|
18
|
+
module = importlib.util.module_from_spec(spec)
|
|
19
|
+
sys.modules["chkit_user_config"] = module
|
|
20
|
+
spec.loader.exec_module(module)
|
|
21
|
+
return module
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_config(config_path: Path | None = None) -> ChxResolvedConfig:
|
|
25
|
+
"""Resolve a ``ChxResolvedConfig`` from a config file.
|
|
26
|
+
|
|
27
|
+
The default path is ``./chkit.config.py``. The module must export a
|
|
28
|
+
``config`` attribute of type ``ChxUserConfig`` (or a dict that validates
|
|
29
|
+
against ``ChxUserConfig``).
|
|
30
|
+
"""
|
|
31
|
+
path = config_path if config_path is not None else Path("chkit.config.py")
|
|
32
|
+
if not path.exists():
|
|
33
|
+
msg = f"Config file not found: {path}"
|
|
34
|
+
raise FileNotFoundError(msg)
|
|
35
|
+
|
|
36
|
+
module = _load_module(path)
|
|
37
|
+
raw_config = getattr(module, "config", None)
|
|
38
|
+
if raw_config is None:
|
|
39
|
+
msg = f"Config file {path} must export a `config` attribute"
|
|
40
|
+
raise AttributeError(msg)
|
|
41
|
+
|
|
42
|
+
user_config = (
|
|
43
|
+
raw_config
|
|
44
|
+
if isinstance(raw_config, ChxUserConfig)
|
|
45
|
+
else ChxUserConfig.model_validate(raw_config)
|
|
46
|
+
)
|
|
47
|
+
return resolve_config(user_config)
|
chkit/cli/main.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Top-level Typer app and command wiring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from chkit import __version__
|
|
8
|
+
from chkit.cli.commands import check, drift, generate, init, migrate, status
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(
|
|
11
|
+
name="chkit",
|
|
12
|
+
help="ClickHouse schema and migration toolkit",
|
|
13
|
+
add_completion=False,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
app.command("init", help="Create a starter chkit.config.py and example schema.")(init.run)
|
|
17
|
+
app.command("generate", help="Generate a new migration from the current schema.")(generate.run)
|
|
18
|
+
app.command("migrate", help="Apply pending migrations to the target database.")(migrate.run)
|
|
19
|
+
app.command("status", help="Show migration status and pending operations.")(status.run)
|
|
20
|
+
app.command("check", help="Run pre-flight checks (drift, checksums, pending).")(check.run)
|
|
21
|
+
app.command("drift", help="Compare the live database against the schema snapshot.")(drift.run)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.callback(invoke_without_command=True)
|
|
25
|
+
def _root(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
version: bool = typer.Option(
|
|
28
|
+
False, # noqa: FBT003 - typer positional default is part of its API
|
|
29
|
+
"--version",
|
|
30
|
+
"-V",
|
|
31
|
+
help="Show the chkit version and exit.",
|
|
32
|
+
),
|
|
33
|
+
) -> None:
|
|
34
|
+
if version:
|
|
35
|
+
typer.echo(__version__)
|
|
36
|
+
raise typer.Exit(code=0)
|
|
37
|
+
if ctx.invoked_subcommand is None:
|
|
38
|
+
typer.echo(ctx.get_help())
|
|
39
|
+
raise typer.Exit(code=0)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main() -> None:
|
|
43
|
+
app()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
main()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Filesystem layout for migrations and snapshots."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict
|
|
11
|
+
|
|
12
|
+
from chkit.core.model import MigrationPlan, Snapshot
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MigrationArtifact(BaseModel):
|
|
16
|
+
model_config = ConfigDict(frozen=True)
|
|
17
|
+
|
|
18
|
+
id: str
|
|
19
|
+
sql_path: Path
|
|
20
|
+
meta_path: Path
|
|
21
|
+
plan: MigrationPlan
|
|
22
|
+
checksum: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _timestamp_id() -> str:
|
|
26
|
+
return datetime.now(tz=UTC).strftime("%Y%m%d%H%M%S")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _hash_sql(sql_text: str) -> str:
|
|
30
|
+
return hashlib.sha256(sql_text.encode("utf-8")).hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def write_snapshot(meta_dir: Path, snapshot: Snapshot) -> Path:
|
|
34
|
+
meta_dir.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
snapshot_path = meta_dir / "snapshot.json"
|
|
36
|
+
snapshot_path.write_text(
|
|
37
|
+
json.dumps(snapshot.model_dump(mode="json", by_alias=True), indent=2),
|
|
38
|
+
encoding="utf-8",
|
|
39
|
+
)
|
|
40
|
+
return snapshot_path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_snapshot(meta_dir: Path) -> Snapshot | None:
|
|
44
|
+
snapshot_path = meta_dir / "snapshot.json"
|
|
45
|
+
if not snapshot_path.exists():
|
|
46
|
+
return None
|
|
47
|
+
raw = json.loads(snapshot_path.read_text(encoding="utf-8"))
|
|
48
|
+
return Snapshot.model_validate(raw)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def write_migration(
|
|
52
|
+
migrations_dir: Path, plan: MigrationPlan, label: str = "auto"
|
|
53
|
+
) -> MigrationArtifact:
|
|
54
|
+
migrations_dir.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
migration_id = f"{_timestamp_id()}_{label}"
|
|
56
|
+
sql_text = "\n\n".join(op.sql for op in plan.operations) + "\n"
|
|
57
|
+
sql_path = migrations_dir / f"{migration_id}.sql"
|
|
58
|
+
meta_path = migrations_dir / f"{migration_id}.json"
|
|
59
|
+
sql_path.write_text(sql_text, encoding="utf-8")
|
|
60
|
+
checksum = _hash_sql(sql_text)
|
|
61
|
+
meta_path.write_text(
|
|
62
|
+
json.dumps(
|
|
63
|
+
{
|
|
64
|
+
"id": migration_id,
|
|
65
|
+
"checksum": checksum,
|
|
66
|
+
"plan": plan.model_dump(mode="json", by_alias=True),
|
|
67
|
+
},
|
|
68
|
+
indent=2,
|
|
69
|
+
),
|
|
70
|
+
encoding="utf-8",
|
|
71
|
+
)
|
|
72
|
+
return MigrationArtifact(
|
|
73
|
+
id=migration_id,
|
|
74
|
+
sql_path=sql_path,
|
|
75
|
+
meta_path=meta_path,
|
|
76
|
+
plan=plan,
|
|
77
|
+
checksum=checksum,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def list_migrations(migrations_dir: Path) -> list[Path]:
|
|
82
|
+
if not migrations_dir.exists():
|
|
83
|
+
return []
|
|
84
|
+
return sorted(migrations_dir.glob("*.sql"))
|