ch-migrate-cli 0.5.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.
ch_migrate/sql.py ADDED
@@ -0,0 +1,216 @@
1
+ """SQL files as migrations: placeholder rendering, statement splitting, execution.
2
+
3
+ The ClickHouse HTTP interface accepts one statement per request, so a SQL file
4
+ holding several statements is split here and run one statement at a time.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from itertools import chain
12
+ from pathlib import Path
13
+ from typing import Any, Iterator
14
+
15
+ _PLACEHOLDER = re.compile(r"\{(\w+)\}")
16
+ _HEREDOC_OPEN = re.compile(r"\$(\w*)\$")
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Statement:
21
+ """One statement from a SQL file.
22
+
23
+ Attributes:
24
+ sql: The statement text, without its trailing semicolon or the comments
25
+ above it.
26
+ line: 1-based line in the file where the statement text begins.
27
+ comments: Comment lines directly above the statement, without their
28
+ comment markers. Waivers are written here.
29
+ """
30
+
31
+ sql: str
32
+ line: int
33
+ comments: tuple[str, ...] = ()
34
+
35
+
36
+ class SqlStatementError(RuntimeError):
37
+ """A statement in a SQL migration file failed.
38
+
39
+ The message names the file, the statement and its line, then the first line
40
+ of the database's own error, so `ch-migrate up` can show it without a traceback.
41
+ """
42
+
43
+
44
+
45
+ def run_sql(path: str, **values: Any) -> None:
46
+ """Run every statement in a SQL file under migrations/sql/, one at a time.
47
+
48
+ `{db}`, `{cluster}` and `{on_cluster}` are filled in from the environment;
49
+ keyword arguments add to or override them. Other braces are left alone.
50
+
51
+ Example:
52
+ run_sql("history/tables/logs/2026_10_02_1430_a1b2c3_add_status.up.sql")
53
+ """
54
+ from alembic import context, op
55
+
56
+ statements = load_statements(path, **values)
57
+ if not statements:
58
+ # An empty file is almost always a migration someone forgot to fill in.
59
+ # Applying it would record the revision as done with nothing changed.
60
+ raise ValueError(f"SQL file has no statements: migrations/sql/{path}")
61
+ if context.is_offline_mode():
62
+ for statement in statements:
63
+ op.execute(statement.sql.replace(":", r"\:"))
64
+ else:
65
+ connection = op.get_bind()
66
+ for index, statement in enumerate(statements, start=1):
67
+ try:
68
+ connection.exec_driver_sql(statement.sql.replace("%", "%%"))
69
+ except Exception as exc:
70
+ where = f"statement {index} of {len(statements)}, line {statement.line}"
71
+ raise SqlStatementError(
72
+ f"migrations/sql/{path} ({where}): {_database_reason(exc)}"
73
+ ) from exc
74
+
75
+
76
+ def load_statements(path: str, **values: Any) -> list[Statement]:
77
+ """Read, render and split a SQL file under migrations/sql/."""
78
+ sql_path = Path.cwd() / "migrations" / "sql" / path
79
+ if not sql_path.exists():
80
+ raise FileNotFoundError(f"SQL file not found: {sql_path}")
81
+ return split_statements(render_placeholders(sql_path.read_text(), default_values(values)))
82
+
83
+
84
+ def default_values(overrides: dict[str, Any]) -> dict[str, Any]:
85
+ """Placeholder values from the environment, with explicit overrides applied."""
86
+ from ch_migrate.helpers import get_cluster, get_db, on_cluster
87
+
88
+ values: dict[str, Any] = {
89
+ "db": get_db(),
90
+ "cluster": get_cluster() or "",
91
+ "on_cluster": on_cluster(),
92
+ }
93
+ values.update(overrides)
94
+ return values
95
+
96
+
97
+ def render_placeholders(sql: str, values: dict[str, Any]) -> str:
98
+ """Replace `{name}` for each known name; leave every other brace as written."""
99
+
100
+ def replace(match: re.Match[str]) -> str:
101
+ name = match.group(1)
102
+ return str(values[name]) if name in values else match.group(0)
103
+
104
+ return _PLACEHOLDER.sub(replace, sql)
105
+
106
+
107
+ def split_statements(sql: str) -> list[Statement]:
108
+ """Split SQL text into statements on semicolons outside quotes and comments."""
109
+ statements: list[Statement] = []
110
+ start, line = 0, 1
111
+ for end in chain(_statement_ends(sql), (len(sql),)):
112
+ chunk = sql[start:end]
113
+ offset, comments = _leading_comments(chunk)
114
+ text = chunk[offset:].strip()
115
+ if text:
116
+ statements.append(Statement(text, line + chunk.count("\n", 0, offset), tuple(comments)))
117
+ line += sql.count("\n", start, end + 1)
118
+ start = end + 1
119
+ return statements
120
+
121
+
122
+ def clean_database_error(message: str) -> str:
123
+ """Drop the driver's "Orig exception:" wrapper and the trailing server version."""
124
+ message = re.sub(r"^Orig exception:\s*", "", message.strip())
125
+ return re.sub(r"\s*\(version \d[^()]*(?:\([^()]*\))?\)\s*$", "", message)
126
+
127
+
128
+ def _database_reason(exc: Exception) -> str:
129
+ """First line of a driver error, without the wrapper and server-version noise."""
130
+ lines = str(exc).strip().splitlines()
131
+ return clean_database_error(lines[0]) if lines else type(exc).__name__
132
+
133
+
134
+ def _statement_ends(sql: str) -> Iterator[int]:
135
+ """Offsets of the semicolons that end statements."""
136
+ i = 0
137
+ while i < len(sql):
138
+ skip_to = _skip_quoted_or_comment(sql, i)
139
+ if skip_to is not None:
140
+ i = skip_to
141
+ continue
142
+ if sql[i] == ";":
143
+ yield i
144
+ i += 1
145
+
146
+
147
+ def _skip_quoted_or_comment(sql: str, i: int) -> int | None:
148
+ """If a string, quoted identifier, comment or heredoc starts at i, return its end."""
149
+ char = sql[i]
150
+ if char in "'\"`":
151
+ return _end_of_quoted(sql, i, char)
152
+ if sql.startswith("--", i) or sql.startswith("#!", i) or sql.startswith("# ", i):
153
+ newline = sql.find("\n", i)
154
+ return len(sql) if newline == -1 else newline + 1
155
+ if sql.startswith("/*", i):
156
+ close = sql.find("*/", i + 2)
157
+ return len(sql) if close == -1 else close + 2
158
+ if char == "$":
159
+ match = _HEREDOC_OPEN.match(sql, i)
160
+ if match:
161
+ close = sql.find(match.group(0), match.end())
162
+ return len(sql) if close == -1 else close + len(match.group(0))
163
+ return None
164
+
165
+
166
+ def _end_of_quoted(sql: str, i: int, quote: str) -> int:
167
+ """End offset (exclusive) of a quoted run, honouring backslash and doubled quotes."""
168
+ j = i + 1
169
+ while j < len(sql):
170
+ if sql[j] == "\\":
171
+ j += 2
172
+ continue
173
+ if sql[j] == quote:
174
+ if sql.startswith(quote * 2, j):
175
+ j += 2
176
+ continue
177
+ return j + 1
178
+ j += 1
179
+ return len(sql)
180
+
181
+
182
+ def _leading_comments(chunk: str) -> tuple[int, list[str]]:
183
+ """Skip whitespace and comments at the start of chunk.
184
+
185
+ Returns the offset of the first SQL character and the comment texts seen,
186
+ keeping only the comments in the contiguous block directly above the SQL.
187
+ """
188
+ comments: list[str] = []
189
+ i = 0
190
+ at_line_start = True # a line comment consumes its own newline
191
+ while i < len(chunk):
192
+ run_end = i
193
+ while run_end < len(chunk) and chunk[run_end].isspace():
194
+ run_end += 1
195
+ newlines = chunk.count("\n", i, run_end)
196
+ if newlines >= (1 if at_line_start else 2):
197
+ comments = [] # a blank line separates a comment block from the SQL
198
+ i = run_end
199
+ end = _skip_quoted_or_comment(chunk, i) if i < len(chunk) else None
200
+ if end is None or chunk[i] in "'\"`$":
201
+ break
202
+ comments.extend(_comment_text(chunk[i:end]).splitlines())
203
+ at_line_start = chunk[end - 1] == "\n"
204
+ i = end
205
+ return i, comments
206
+
207
+
208
+ def _comment_text(comment: str) -> str:
209
+ """Strip comment markers from one comment."""
210
+ text = comment.strip()
211
+ for marker in ("--", "#!", "#"):
212
+ if text.startswith(marker):
213
+ return text[len(marker) :].strip()
214
+ if text.startswith("/*"):
215
+ return text[2:-2].strip() if text.endswith("*/") else text[2:].strip()
216
+ return text
@@ -0,0 +1,144 @@
1
+ """Extract migration SQL without importing or executing revision modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Literal
10
+
11
+ from ch_migrate.rebase import RevisionGraph
12
+ from ch_migrate.sql import split_statements
13
+
14
+ MigrationDirection = Literal["upgrade", "downgrade"]
15
+ _FSTRING_POSITIONS = sys.version_info >= (3, 12)
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class MigrationStatement:
20
+ sql: str
21
+ source: str
22
+ line: int
23
+ comments: tuple[str, ...]
24
+ direction: MigrationDirection
25
+
26
+
27
+ def migration_statements(path: Path) -> list[MigrationStatement]:
28
+ """Collect literal SQL in upgrade/downgrade bodies, preserving its source."""
29
+ content = path.read_text()
30
+ root = path.parent.parent
31
+ if root.name == "migrations":
32
+ root = root.parent
33
+ source = _Source(path, root, content, content.splitlines())
34
+ result: list[MigrationStatement] = []
35
+ for function in ast.parse(content).body:
36
+ if not isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)):
37
+ continue
38
+ if function.name not in ("upgrade", "downgrade"):
39
+ continue
40
+ direction: MigrationDirection = "upgrade" if function.name == "upgrade" else "downgrade"
41
+ calls = sorted(
42
+ (node for node in ast.walk(function) if isinstance(node, ast.Call)),
43
+ key=lambda node: (node.lineno, node.col_offset),
44
+ )
45
+ for call in calls:
46
+ if _call_name(call) in ("read_sql", "run_sql"):
47
+ result.extend(_file_statements(call, source, direction))
48
+ elif _call_name(call) == "op.execute":
49
+ result.extend(_inline_statements(call, source, direction))
50
+ return result
51
+
52
+
53
+ def pending_revisions(graph: RevisionGraph, heads: set[str]) -> set[str]:
54
+ """Subtract applied heads and their ancestors, as status resolves them."""
55
+ applied: set[str] = set()
56
+ unknown = heads - graph.migrations.keys()
57
+ if unknown:
58
+ raise ValueError("Database revisions are missing locally: " + ", ".join(sorted(unknown)))
59
+ for head in heads:
60
+ applied.update(graph.walk_to_root(head))
61
+ return graph.migrations.keys() - applied
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class _Source:
66
+ path: Path
67
+ root: Path
68
+ content: str
69
+ lines: list[str]
70
+
71
+
72
+ def _call_name(call: ast.Call) -> str:
73
+ if isinstance(call.func, ast.Name):
74
+ return call.func.id
75
+ if isinstance(call.func, ast.Attribute) and isinstance(call.func.value, ast.Name):
76
+ if call.func.attr in ("run_sql", "read_sql"):
77
+ return call.func.attr
78
+ return f"{call.func.value.id}.{call.func.attr}"
79
+ return ""
80
+
81
+
82
+ def _file_statements(
83
+ call: ast.Call, source: _Source, direction: MigrationDirection
84
+ ) -> list[MigrationStatement]:
85
+ if not call.args or not isinstance(call.args[0], ast.Constant):
86
+ return []
87
+ path_value = call.args[0].value
88
+ if not isinstance(path_value, str):
89
+ return []
90
+ sql_path = source.path.parent.parent / "sql" / path_value
91
+ statements = split_statements(sql_path.read_text())
92
+ filename = sql_path.relative_to(source.root).as_posix()
93
+ return [
94
+ MigrationStatement(item.sql, filename, item.line, item.comments, direction)
95
+ for item in statements
96
+ ]
97
+
98
+
99
+ def _inline_statements(
100
+ call: ast.Call, source: _Source, direction: MigrationDirection
101
+ ) -> list[MigrationStatement]:
102
+ if not call.args:
103
+ return []
104
+ text = _literal_sql(call.args[0], source.content)
105
+ if text is None:
106
+ return []
107
+ comments = _python_comments(source.lines, call.lineno)
108
+ filename = source.path.relative_to(source.root).as_posix()
109
+ result = []
110
+ for index, item in enumerate(split_statements(text)):
111
+ # Inline expressions point to their AST call site, including escaped-newline strings.
112
+ attached = comments + item.comments if index == 0 else item.comments
113
+ result.append(MigrationStatement(item.sql, filename, call.lineno, attached, direction))
114
+ return result
115
+
116
+
117
+ def _literal_sql(node: ast.AST, content: str) -> str | None:
118
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
119
+ return node.value
120
+ if not isinstance(node, ast.JoinedStr):
121
+ return None
122
+ pieces = []
123
+ for value in node.values:
124
+ if isinstance(value, ast.Constant):
125
+ pieces.append(value.value)
126
+ elif isinstance(value, ast.FormattedValue):
127
+ # Before 3.12, f-string expression nodes carry wrong source positions.
128
+ segment = ast.get_source_segment(content, value.value) if _FSTRING_POSITIONS else None
129
+ expression = segment or ast.unparse(value.value)
130
+ conversion = "" if value.conversion == -1 else "!" + chr(value.conversion)
131
+ format_spec = ""
132
+ if value.format_spec is not None:
133
+ format_spec = ":" + (_literal_sql(value.format_spec, content) or "")
134
+ pieces.append("{" + expression + conversion + format_spec + "}")
135
+ return "".join(pieces)
136
+
137
+
138
+ def _python_comments(lines: list[str], lineno: int) -> tuple[str, ...]:
139
+ comments = []
140
+ index = lineno - 2
141
+ while index >= 0 and lines[index].lstrip().startswith("#"):
142
+ comments.append(lines[index].lstrip()[1:].strip())
143
+ index -= 1
144
+ return tuple(reversed(comments))
@@ -0,0 +1,56 @@
1
+ -- Bootstrap SQL Reference
2
+ -- This file documents the SQL structure created by `ch-migrate bootstrap`
3
+ -- The actual SQL is generated dynamically by bootstrap.py
4
+ --
5
+ -- IMPORTANT: This file is NOT executed directly. It serves as documentation.
6
+ -- To run bootstrap, use: ch-migrate bootstrap <environment>
7
+
8
+ -- =============================================================================
9
+ -- DATABASE
10
+ -- =============================================================================
11
+
12
+ CREATE DATABASE IF NOT EXISTS {db};
13
+
14
+ -- =============================================================================
15
+ -- ROLES (always created)
16
+ -- =============================================================================
17
+
18
+ -- Migration role: full access for schema changes and data operations
19
+ CREATE ROLE IF NOT EXISTS {project}_migration_role;
20
+ GRANT ALL ON {db}.* TO {project}_migration_role;
21
+ GRANT CREATE TEMPORARY TABLE ON *.* TO {project}_migration_role;
22
+ GRANT CURRENT GRANTS(SELECT ON system.*) TO {project}_migration_role WITH GRANT OPTION;
23
+ -- ClickHouse Cloud requires explicit grants for individual system tables.
24
+ GRANT SELECT ON system.grants TO {project}_migration_role;
25
+
26
+ -- =============================================================================
27
+ -- USERS (always created)
28
+ -- =============================================================================
29
+
30
+ -- Migration user
31
+ CREATE USER IF NOT EXISTS {migration_user}
32
+ IDENTIFIED BY '{migration_password}';
33
+ GRANT {project}_migration_role TO {migration_user};
34
+
35
+ -- =============================================================================
36
+ -- OPTIONAL: MCP User (only if mcp_user_name configured in config.yaml)
37
+ -- =============================================================================
38
+
39
+ -- CREATE ROLE IF NOT EXISTS {project}_readonly_role;
40
+ -- GRANT SELECT ON {db}.* TO {project}_readonly_role;
41
+ -- GRANT SHOW TABLES ON {db}.* TO {project}_readonly_role;
42
+ --
43
+ -- CREATE USER IF NOT EXISTS {mcp_user_name}
44
+ -- IDENTIFIED BY '{mcp_password}';
45
+ -- GRANT {project}_readonly_role TO {mcp_user_name};
46
+
47
+ -- =============================================================================
48
+ -- OPTIONAL: Dict Reader (only if dict_reader_name configured in config.yaml)
49
+ -- =============================================================================
50
+
51
+ -- CREATE ROLE IF NOT EXISTS {project}_dict_role;
52
+ -- SELECT grants added per-table when dictionaries are created
53
+ --
54
+ -- CREATE USER IF NOT EXISTS {dict_reader_name}
55
+ -- IDENTIFIED BY '{dict_reader_password}';
56
+ -- GRANT {project}_dict_role TO {dict_reader_name};
@@ -0,0 +1,43 @@
1
+ # ClickHouse Alembic Configuration
2
+ # Generated by ch-migrate init
3
+
4
+ [alembic]
5
+ script_location = migrations
6
+ prepend_sys_path = .
7
+ version_path_separator = os
8
+ file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d_%%(slug)s
9
+
10
+ # Logging configuration
11
+ [loggers]
12
+ keys = root,sqlalchemy,alembic
13
+
14
+ [handlers]
15
+ keys = console
16
+
17
+ [formatters]
18
+ keys = generic
19
+
20
+ [logger_root]
21
+ level = WARN
22
+ handlers = console
23
+ qualname =
24
+
25
+ [logger_sqlalchemy]
26
+ level = WARN
27
+ handlers =
28
+ qualname = sqlalchemy.engine
29
+
30
+ [logger_alembic]
31
+ level = INFO
32
+ handlers =
33
+ qualname = alembic
34
+
35
+ [handler_console]
36
+ class = StreamHandler
37
+ args = (sys.stderr,)
38
+ level = NOTSET
39
+ formatter = generic
40
+
41
+ [formatter_generic]
42
+ format = %(levelname)-5.5s [%(name)s] %(message)s
43
+ datefmt = %H:%M:%S
@@ -0,0 +1,55 @@
1
+ # ClickHouse Migration Configuration
2
+ # Generated by ch-migrate init
3
+
4
+ project:
5
+ name: {project_name}
6
+
7
+ # Default settings inherited by all environments
8
+ defaults:
9
+ port: 8443
10
+ secure: true
11
+ admin_user: default
12
+ # Optional: cluster name for ON CLUSTER DDL (self-hosted deployments)
13
+ # cluster: my_cluster
14
+ # Optional: dict_reader for dictionary sources
15
+ # dict_reader_name: dict_reader
16
+ # Optional: mcp_user for read-only MCP tool access
17
+ # mcp_user_name: mcp_reader
18
+
19
+ # Environment-specific configuration
20
+ # Passwords loaded from:
21
+ # - SSM directly (if ssm: paths configured below)
22
+ # - .env.local / environment variables (otherwise)
23
+ environments:
24
+ dev:
25
+ host: your-dev-instance.clickhouse.cloud
26
+ database: {project_name}_dev
27
+ migration_user: migration_dev
28
+ # Optional: AWS region for SSM lookups (uses AWS default if not set)
29
+ # aws_region: us-east-1
30
+ # Optional SSM paths (if set, fetches from SSM directly)
31
+ # Supports JSON key extraction: /path/to/param#json_key
32
+ # ssm:
33
+ # admin_password: /{project_name}/dev/admin_password
34
+ # migration_password: /{project_name}/dev/credentials#password
35
+ # dict_reader_password: /{project_name}/dev/dict_reader_password
36
+ # mcp_password: /{project_name}/dev/mcp_password
37
+
38
+ staging:
39
+ host: your-staging-instance.clickhouse.cloud
40
+ database: {project_name}_staging
41
+ migration_user: migration_staging
42
+
43
+ production:
44
+ host: your-prod-instance.clickhouse.cloud
45
+ database: {project_name}
46
+ migration_user: migration_prod
47
+
48
+ # Optional: Pre/post migration hooks
49
+ # SQL statements executed before/after each migration run.
50
+ # Use {db} placeholder for the database name.
51
+ # hooks:
52
+ # pre_migrate:
53
+ # - "SELECT 1" # validation query
54
+ # post_migrate:
55
+ # - "SYSTEM RELOAD DICTIONARY {db}.dict_regions ON CLUSTER default"
@@ -0,0 +1,25 @@
1
+ # ClickHouse credentials - DO NOT COMMIT THIS FILE
2
+ # Copy to .env.local and fill in your passwords
3
+ #
4
+ # Alternative: Use SSM paths in config.yaml (fetches directly from SSM)
5
+ # See: https://github.com/DRYCodeWorks/ch-migrate#secrets
6
+
7
+ # Dev environment
8
+ CH_DEV_MIGRATION_PASSWORD=your-dev-migration-password
9
+ CH_DEV_ADMIN_PASSWORD=your-dev-admin-password
10
+ # Optional: only needed if dict_reader_name is configured
11
+ # CH_DEV_DICT_READER_PASSWORD=your-dev-dict-reader-password
12
+ # Optional: only needed if mcp_user_name is configured
13
+ # CH_DEV_MCP_PASSWORD=your-dev-mcp-password
14
+
15
+ # Staging environment
16
+ CH_STAGING_MIGRATION_PASSWORD=your-staging-migration-password
17
+ CH_STAGING_ADMIN_PASSWORD=your-staging-admin-password
18
+ # CH_STAGING_DICT_READER_PASSWORD=your-staging-dict-reader-password
19
+ # CH_STAGING_MCP_PASSWORD=your-staging-mcp-password
20
+
21
+ # Production environment
22
+ CH_PRODUCTION_MIGRATION_PASSWORD=your-prod-migration-password
23
+ CH_PRODUCTION_ADMIN_PASSWORD=your-prod-admin-password
24
+ # CH_PRODUCTION_DICT_READER_PASSWORD=your-prod-dict-reader-password
25
+ # CH_PRODUCTION_MCP_PASSWORD=your-prod-mcp-password
@@ -0,0 +1,30 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+ """
7
+
8
+ from alembic import op
9
+
10
+ from ch_migrate import get_db, read_sql
11
+
12
+ # revision identifiers
13
+ revision = ${repr(up_revision)}
14
+ down_revision = ${repr(down_revision)}
15
+ branch_labels = ${repr(branch_labels)}
16
+ depends_on = ${repr(depends_on)}
17
+
18
+
19
+ def upgrade() -> None:
20
+ db = get_db()
21
+ # TODO: Add your upgrade SQL here
22
+ # op.execute(read_sql("history/tables/my_table/001_{}.sql".format(revision), db=db))
23
+ pass
24
+
25
+
26
+ def downgrade() -> None:
27
+ db = get_db()
28
+ # TODO: Add your downgrade SQL here
29
+ # op.execute(f"DROP TABLE IF EXISTS {db}.my_table")
30
+ pass
ch_migrate/ui.py ADDED
@@ -0,0 +1,79 @@
1
+ """One output style for every command: steps, results, warnings and errors.
2
+
3
+ Each line starts with a fixed marker (→ step, ✓ done, ! warning, ✗ error) so the
4
+ output reads the same in a terminal, a CI log or a pipe. Rich drops colour when
5
+ output is not a terminal or NO_COLOR is set; the markers and words stay.
6
+
7
+ Text between backticks in steps, results and hints is shown as a command,
8
+ highlighted in a terminal, with the backticks removed. Errors, warnings and
9
+ details print exactly as written, because they often carry database messages
10
+ that use backticks for identifiers. Nothing is parsed as Rich markup.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ from typing import NoReturn
17
+
18
+ from rich.console import Console
19
+ from rich.text import Text
20
+
21
+ # soft_wrap: never hard-wrap at the terminal width, so paths and SQL stay greppable.
22
+ out = Console(highlight=False, soft_wrap=True)
23
+ err = Console(stderr=True, highlight=False, soft_wrap=True)
24
+
25
+ _COMMAND_STYLE = "bold cyan"
26
+
27
+
28
+ def step(message: str) -> None:
29
+ """Something is starting or in progress."""
30
+ out.print(_marked("→ ", "cyan", message))
31
+
32
+
33
+ def success(message: str) -> None:
34
+ """Something finished as intended."""
35
+ out.print(_marked("✓ ", "green", message))
36
+
37
+
38
+ def detail(message: str, *, stderr: bool = False) -> None:
39
+ """An indented line under the step, result or error above it."""
40
+ (err if stderr else out).print(Text(" " + message, style="dim"))
41
+
42
+
43
+ def hint(message: str, *, stderr: bool = False) -> None:
44
+ """What to do next; commands in backticks are highlighted."""
45
+ (err if stderr else out).print(_inline(message))
46
+
47
+
48
+ def warn(message: str) -> None:
49
+ """Worth reading, but the command carries on."""
50
+ err.print(Text.assemble(("! ", "yellow"), (message, "yellow")))
51
+
52
+
53
+ def error(message: str) -> None:
54
+ """The command could not do what was asked."""
55
+ err.print(Text.assemble(("✗ ", "bold red"), message))
56
+
57
+
58
+ def fail(message: str, *hints: str) -> NoReturn:
59
+ """Print an error, then any hints, and exit with status 1."""
60
+ error(message)
61
+ for line in hints:
62
+ hint(line, stderr=True)
63
+ sys.exit(1)
64
+
65
+
66
+ def _marked(marker: str, marker_style: str, message: str) -> Text:
67
+ # Text(marker, style=...) would make the style the base for the whole line.
68
+ text = Text()
69
+ text.append(marker, style=marker_style)
70
+ text.append_text(_inline(message))
71
+ return text
72
+
73
+
74
+ def _inline(message: str) -> Text:
75
+ """Render `command` spans highlighted, everything else as plain text."""
76
+ text = Text()
77
+ for index, part in enumerate(message.split("`")):
78
+ text.append(part, style=_COMMAND_STYLE if index % 2 else "")
79
+ return text