execsql2 2.23.0__py3-none-any.whl → 2.24.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.
Files changed (63) hide show
  1. execsql/cli/__init__.py +17 -586
  2. execsql/cli/application.py +309 -0
  3. execsql/cli/commands/__init__.py +13 -0
  4. execsql/cli/commands/config.py +205 -0
  5. execsql/cli/commands/format.py +100 -0
  6. execsql/cli/commands/init.py +135 -0
  7. execsql/cli/commands/lint.py +233 -0
  8. execsql/cli/commands/listing.py +81 -0
  9. execsql/cli/commands/run.py +451 -0
  10. execsql/cli/dispatch.py +66 -0
  11. execsql/cli/help.py +175 -24
  12. execsql/cli/lint.py +513 -76
  13. execsql/cli/options.py +108 -0
  14. execsql/cli/run.py +41 -14
  15. execsql/config.py +251 -154
  16. execsql/config_validation.py +282 -0
  17. execsql/data/execsql.conf.template +24 -1
  18. execsql/data/script_header.sql +23 -0
  19. execsql/db/access.py +22 -6
  20. execsql/db/base.py +105 -1
  21. execsql/db/dsn.py +2 -0
  22. execsql/db/duckdb.py +95 -2
  23. execsql/db/factory.py +2 -2
  24. execsql/db/mysql.py +11 -0
  25. execsql/db/oracle.py +12 -4
  26. execsql/db/sqlite.py +16 -0
  27. execsql/debug/repl.py +51 -193
  28. execsql/exceptions.py +9 -0
  29. execsql/format.py +87 -75
  30. execsql/interactive.py +232 -0
  31. execsql/manifest.py +189 -0
  32. execsql/metacommands/conditions.py +1 -1
  33. execsql/metacommands/control.py +2 -3
  34. execsql/metacommands/effects.py +124 -0
  35. execsql/script/__init__.py +0 -9
  36. execsql/script/ast.py +1 -1
  37. execsql/script/engine.py +6 -93
  38. execsql/script/executor.py +153 -34
  39. execsql/state.py +11 -2
  40. execsql/state.pyi +3 -0
  41. execsql/utils/color.py +18 -0
  42. execsql/utils/errors.py +11 -1
  43. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/README.md +1 -1
  44. {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/METADATA +111 -58
  45. {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/RECORD +62 -46
  46. execsql2-2.24.0.dist-info/entry_points.txt +2 -0
  47. execsql2-2.23.0.dist-info/entry_points.txt +0 -3
  48. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  49. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  50. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  51. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  52. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  53. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  54. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  55. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  56. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  57. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/script_template.sql +0 -0
  58. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  59. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  60. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  61. {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/WHEEL +0 -0
  62. {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/licenses/LICENSE.txt +0 -0
  63. {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,309 @@
1
+ """The execsql Typer app: command group, help rendering, global options.
2
+
3
+ Every command registers itself on :data:`app` from a module in
4
+ :mod:`execsql.cli.commands`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager
11
+ from typing import Any
12
+
13
+ import typer
14
+ from typer.core import TyperCommand, TyperGroup
15
+
16
+ from execsql import __version__
17
+ from execsql.cli.help import _console, _err_console
18
+ from execsql.utils.color import color_disabled_by_env
19
+
20
+ __all__ = ["ExecsqlCommand", "ExecsqlGroup", "ExecsqlSubGroup", "app"]
21
+
22
+
23
+ #: Options the app answers about itself rather than about a run, shown under
24
+ #: their own heading. ``--config`` is deliberately not here: only ``run``
25
+ #: reads an execsql config file, so listing it as global would be a lie.
26
+ _GLOBAL_OPTIONS = ("--online-help", "--version")
27
+
28
+ #: What to type instead of a flag that, used without a command, implies ``run``.
29
+ _REPLACEMENTS = {
30
+ "-m": "execsql list metacommands",
31
+ "--metacommands": "execsql list metacommands",
32
+ "-y": "execsql list encodings",
33
+ "--encodings": "execsql list encodings",
34
+ "--list-plugins": "execsql list plugins",
35
+ "--dump-keywords": "execsql list keywords --output-format json",
36
+ "--init-config": "execsql config --init",
37
+ "--ping": "execsql run --ping",
38
+ }
39
+
40
+
41
+ def deprecated_bare_form(head: str) -> str:
42
+ """The warning printed when ``run`` is implied rather than typed; *head* is the first argument."""
43
+ if head in _REPLACEMENTS:
44
+ return (
45
+ f"Deprecated: `execsql {head}` is now `{_REPLACEMENTS[head]}`; the old form stops working in execsql2 3.0."
46
+ )
47
+ return (
48
+ "Deprecated: running a script without the run command. Use `execsql run SCRIPT ...`; "
49
+ "`execsql SCRIPT ...` stops working in execsql2 3.0."
50
+ )
51
+
52
+
53
+ def _unescape(record: tuple[str, str]) -> tuple[str, str]:
54
+ """Undo Typer's Rich bracket escaping for plain help output.
55
+
56
+ Typer escapes ``[`` as ``\\[`` so Rich does not read ``[required]`` as
57
+ markup. With Rich rendering off those backslashes are literal text, so
58
+ every default and required marker would print as ``\\[default: 4]``.
59
+ """
60
+ name, help_text = record
61
+ return name.replace("\\[", "["), help_text.replace("\\[", "[")
62
+
63
+
64
+ # Help fills the terminal, as ruff's and uv's do. Click caps it at 80 columns
65
+ # unless told otherwise; piped help still wraps at 80, since there is no
66
+ # terminal to measure. The option column is wide enough for the longest
67
+ # names here ("--gui-framework {tkinter,textual}"), so their descriptions
68
+ # start on the same line instead of the next.
69
+ _HELP_MAX_WIDTH = 10_000
70
+ _OPTION_COLUMN = 36
71
+
72
+ # Help color follows uv and cargo: headings bold green, names cyan with the
73
+ # option flags bold. Click's echo drops ANSI when stdout is not a terminal,
74
+ # and its column widths ignore escape codes, so styling costs no alignment
75
+ # and never reaches a pipe; NO_COLOR is the one case left to handle here.
76
+
77
+
78
+ def _style(text: str, **styles: Any) -> str:
79
+ if not text or color_disabled_by_env():
80
+ return text
81
+ return typer.style(text, **styles)
82
+
83
+
84
+ @contextmanager
85
+ def _section(formatter: Any, name: str) -> Iterator[None]:
86
+ """``formatter.section`` with a colored heading, colon included."""
87
+ formatter.write_paragraph()
88
+ formatter.write(f"{'':>{formatter.current_indent}}{_style(name + ':', fg='green', bold=True)}\n")
89
+ formatter.indent()
90
+ try:
91
+ yield
92
+ finally:
93
+ formatter.dedent()
94
+
95
+
96
+ def _usage_prefix() -> str:
97
+ return _style("Usage:", fg="green", bold=True) + " "
98
+
99
+
100
+ def _styled_record(param: Any, record: tuple[str, str]) -> tuple[str, str]:
101
+ """Color one help row: the flags bold, any metavar after them plain."""
102
+ name, help_text = _unescape(record)
103
+ if param.param_type_name == "argument":
104
+ return _style(name, fg="cyan"), help_text
105
+ flags = [*param.opts, *param.secondary_opts]
106
+ end = max((name.find(flag) + len(flag) for flag in flags if flag in name), default=len(name))
107
+ return _style(name[:end], fg="cyan", bold=True) + _style(name[end:], fg="cyan"), help_text
108
+
109
+
110
+ # Typer 0.26 stopped depending on Click and vendors it as ``typer._click``, so
111
+ # ``click.Context`` is the wrong class on newer Typer and ``click`` may not be
112
+ # installed at all. The overrides below take ``ctx`` and ``formatter`` as
113
+ # ``Any`` because no public import names the right type across the supported
114
+ # Typer range; they rely only on methods both Click lineages provide.
115
+
116
+
117
+ class _PlainHelpMixin:
118
+ """Render option help without Typer's Rich escaping."""
119
+
120
+ def format_usage(self, ctx: Any, formatter: Any) -> None:
121
+ # Every argument here carries an explicit metavar, and it is printed as
122
+ # written. Typer 0.27 decorates it instead, wrapping required arguments
123
+ # in braces and optional ones in brackets, so the same command would
124
+ # print a different usage line depending on the installed Typer.
125
+ pieces = [self.options_metavar] if self.options_metavar else [] # type: ignore[attr-defined]
126
+ for param in self.get_params(ctx): # type: ignore[attr-defined]
127
+ if param.param_type_name == "argument" and param.metavar:
128
+ pieces.append(param.metavar)
129
+ else:
130
+ pieces.extend(param.get_usage_pieces(ctx))
131
+ formatter.write_usage(ctx.command_path, " ".join(pieces), prefix=_usage_prefix())
132
+
133
+ def format_arguments(self, ctx: Any, formatter: Any) -> None:
134
+ # Click 8.5 added this step, with its own "Positional arguments"
135
+ # heading. format_options below already renders the arguments, and a
136
+ # Typer built on the standalone Click would otherwise list them twice.
137
+ pass
138
+
139
+ def format_options(self, ctx: Any, formatter: Any) -> None:
140
+ args: list[tuple[str, str]] = []
141
+ opts: list[tuple[str, str]] = []
142
+ for param in self.get_params(ctx): # type: ignore[attr-defined]
143
+ record = param.get_help_record(ctx)
144
+ if not record:
145
+ continue
146
+ bucket = args if param.param_type_name == "argument" else opts
147
+ bucket.append(_styled_record(param, record))
148
+ if args:
149
+ with _section(formatter, "Arguments"):
150
+ formatter.write_dl(args, col_max=_OPTION_COLUMN)
151
+ if opts:
152
+ with _section(formatter, "Options"):
153
+ formatter.write_dl(opts, col_max=_OPTION_COLUMN)
154
+
155
+
156
+ class ExecsqlCommand(_PlainHelpMixin, TyperCommand):
157
+ """A command whose help is plain text, brackets and all."""
158
+
159
+
160
+ class ExecsqlGroup(TyperGroup):
161
+ """The top-level command group: default command, dual usage, grouped options.
162
+
163
+ Three departures from Click's defaults, each for a reason:
164
+
165
+ *Default command, deprecated.* ``execsql script.sql server db`` has been
166
+ the invocation since upstream v1.130.1 — it is in shell scripts and cron
167
+ entries — so an argument list carrying no command name still gets ``run``
168
+ inserted before the parser sees it, with a deprecation warning on stderr;
169
+ the form stops working in execsql2 3.0. Doing that here rather than in the
170
+ console-script wrapper means the app behaves the same however it is
171
+ reached: the entry point, ``python -m execsql``, or a test driving ``app``
172
+ directly.
173
+
174
+ *One usage line.* Help documents one form, ``execsql COMMAND``.
175
+
176
+ *Grouped options.* Click lists every option in one block. The four that
177
+ apply to the whole tool rather than to a run are worth separating from
178
+ ``--help``.
179
+ """
180
+
181
+ def parse_args(self, ctx: Any, args: list[str]) -> list[str]:
182
+ from execsql.cli.dispatch import normalize
183
+
184
+ if not args:
185
+ # Running execsql with no arguments is a usage error: the help goes
186
+ # to stdout, and the status is 2, as for any other usage error.
187
+ # Upstream exited 0 here; this is a recorded divergence
188
+ # (docs/about/divergence.md, "CLI Interface"). Click's own
189
+ # no_args_is_help exits 0 on Click 8.1 and 2 on 8.2+, so the status
190
+ # is set here rather than left to whichever Click is installed.
191
+ typer.echo(ctx.get_help(), color=ctx.color)
192
+ ctx.exit(2)
193
+ normalized = normalize(args)
194
+ if normalized is not args:
195
+ _err_console.print(f"[yellow]{deprecated_bare_form(args[0])}[/yellow]", highlight=False, soft_wrap=True)
196
+ return super().parse_args(ctx, normalized)
197
+
198
+ def list_commands(self, ctx: Any) -> list[str]:
199
+ # Typer registers command groups (``list``) after plain commands; help
200
+ # follows the order COMMANDS gives instead.
201
+ from execsql.cli.dispatch import COMMANDS
202
+
203
+ names = super().list_commands(ctx)
204
+ return sorted(names, key=lambda n: COMMANDS.index(n) if n in COMMANDS else len(COMMANDS))
205
+
206
+ def format_usage(self, ctx: Any, formatter: Any) -> None:
207
+ formatter.write_usage(ctx.command_path, "[OPTIONS] COMMAND [ARGS]...", prefix=_usage_prefix())
208
+
209
+ def format_options(self, ctx: Any, formatter: Any) -> None:
210
+ globals_: list[tuple[str, str]] = []
211
+ rest: list[tuple[str, str]] = []
212
+ for param in self.get_params(ctx):
213
+ record = param.get_help_record(ctx)
214
+ if not record:
215
+ continue
216
+ bucket = globals_ if any(o in param.opts for o in _GLOBAL_OPTIONS) else rest
217
+ bucket.append(_styled_record(param, record))
218
+
219
+ if globals_:
220
+ with _section(formatter, "Global options"):
221
+ formatter.write_dl(globals_, col_max=_OPTION_COLUMN)
222
+ if rest:
223
+ with _section(formatter, "Options"):
224
+ formatter.write_dl(rest, col_max=_OPTION_COLUMN)
225
+ self.format_commands(ctx, formatter)
226
+
227
+ def format_commands(self, ctx: Any, formatter: Any) -> None:
228
+ # Click's own version, with the command names colored.
229
+ commands = [
230
+ (name, cmd)
231
+ for name in self.list_commands(ctx)
232
+ if (cmd := self.get_command(ctx, name)) is not None and not cmd.hidden
233
+ ]
234
+ if not commands:
235
+ return
236
+ limit = formatter.width - 6 - max(len(name) for name, _ in commands)
237
+ with _section(formatter, "Commands"):
238
+ formatter.write_dl(
239
+ [(_style(name, fg="cyan", bold=True), cmd.get_short_help_str(limit)) for name, cmd in commands],
240
+ )
241
+
242
+
243
+ class ExecsqlSubGroup(ExecsqlGroup):
244
+ """A command with commands of its own (``execsql list``).
245
+
246
+ The app's help layout, without the app's ``run`` insertion; with no
247
+ arguments it prints its help and exits 2, as the app does.
248
+ """
249
+
250
+ def parse_args(self, ctx: Any, args: list[str]) -> list[str]:
251
+ if not args:
252
+ typer.echo(ctx.get_help(), color=ctx.color)
253
+ ctx.exit(2)
254
+ return TyperGroup.parse_args(self, ctx, args)
255
+
256
+ def list_commands(self, ctx: Any) -> list[str]:
257
+ return TyperGroup.list_commands(self, ctx)
258
+
259
+
260
+ app = typer.Typer(
261
+ cls=ExecsqlGroup,
262
+ name="execsql",
263
+ # Plain click rendering: no panels, and format_options below can group.
264
+ rich_markup_mode=None,
265
+ help="Run, format and lint SQL scripts with metacommands.",
266
+ add_completion=False,
267
+ no_args_is_help=True,
268
+ # Upstream's optparse answered -h as well as --help. Commands inherit this.
269
+ context_settings={"help_option_names": ["-h", "--help"], "max_content_width": _HELP_MAX_WIDTH},
270
+ )
271
+
272
+
273
+ def _version_callback(value: bool) -> None:
274
+ if value:
275
+ _console.print(f"execsql [bold cyan]{__version__}[/bold cyan]")
276
+ raise typer.Exit()
277
+
278
+
279
+ def _online_help_callback(value: bool) -> None:
280
+ if value:
281
+ import webbrowser
282
+
283
+ webbrowser.open("https://execsql2.readthedocs.io/en/latest/", new=2, autoraise=True)
284
+ raise typer.Exit()
285
+
286
+
287
+ @app.callback()
288
+ def _global_options(
289
+ online_help: bool = typer.Option(
290
+ False,
291
+ "-o",
292
+ "--online-help",
293
+ callback=_online_help_callback,
294
+ is_eager=True,
295
+ help="Open the online documentation in the default browser.",
296
+ ),
297
+ version: bool | None = typer.Option(
298
+ None,
299
+ "--version",
300
+ callback=_version_callback,
301
+ is_eager=True,
302
+ help="Show version and exit.",
303
+ ),
304
+ ) -> None:
305
+ """Options about execsql itself rather than about one run.
306
+
307
+ Both are eager and exit on their own, so neither needs to reach a
308
+ command; the callback body has nothing to do.
309
+ """
@@ -0,0 +1,13 @@
1
+ """execsql's commands, one module each.
2
+
3
+ Importing this package registers every command on
4
+ :data:`execsql.cli.application.app`. The import order is the order
5
+ ``execsql --help`` lists them in.
6
+ """
7
+
8
+ from execsql.cli.commands import run # noqa: F401
9
+ from execsql.cli.commands import format # noqa: F401, A004
10
+ from execsql.cli.commands import lint # noqa: F401
11
+ from execsql.cli.commands import config # noqa: F401
12
+ from execsql.cli.commands import listing # noqa: F401
13
+ from execsql.cli.commands import init # noqa: F401
@@ -0,0 +1,205 @@
1
+ """The ``config`` command: list config options, or print the template."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import typer
12
+ from rich.markup import escape
13
+
14
+ from execsql.cli.application import ExecsqlCommand, app
15
+ from execsql.cli.help import _console, _err_console, _init_config
16
+ from execsql.cli.options import ConfigFileOpt, OutputFormat, OutputFormatOpt
17
+
18
+ __all__ = ["config_cmd", "config_rows"]
19
+
20
+
21
+ def config_rows(script: str | None, config_file: str | None) -> tuple[list[str], list[dict[str, Any]]]:
22
+ """Resolve config the way ``execsql run SCRIPT`` would; describe every option.
23
+
24
+ Args:
25
+ script: A script path whose directory is searched for ``execsql.conf``,
26
+ as a run of that script would; ``None`` searches only the working
27
+ directory, user and system locations.
28
+ config_file: An extra file loaded last, as ``--config`` does for a run.
29
+
30
+ Returns:
31
+ The files read, in load order, and one dict per option with ``section``,
32
+ ``key``, ``type``, ``value``, ``default`` and ``source`` (the file that
33
+ set it, or ``None`` when the default applies). Password values are
34
+ replaced by ``"***"``.
35
+ """
36
+ from execsql.cli.run import _load_config, _seed_early_subvars
37
+ from execsql.config import ConfigData
38
+ from execsql.metacommands.debug import _SENSITIVE_ATTRS # what DEBUG WRITE CONFIG masks
39
+
40
+ conf = _load_config(script, _seed_early_subvars(), config_file)
41
+ rows: list[dict[str, Any]] = []
42
+ # Options read inline in ConfigData register after the rest, so group by
43
+ # section (in first-seen order) to keep each section in one block.
44
+ sections: dict[str, int] = {}
45
+ for section, _key, _type in ConfigData._schema.values():
46
+ sections.setdefault(section, len(sections))
47
+ ordered = sorted(ConfigData._schema.items(), key=lambda item: sections[item[1][0]])
48
+ for attr, (section, key, type_label) in ordered:
49
+ value = getattr(conf, attr, None)
50
+ default = conf.defaults.get(attr)
51
+ if attr in _SENSITIVE_ATTRS:
52
+ value = "***" if value else value
53
+ default = "***" if default else default
54
+ rows.append(
55
+ {
56
+ "section": section,
57
+ "key": key,
58
+ "type": type_label,
59
+ "value": value,
60
+ "default": default,
61
+ "source": conf.sources.get(attr),
62
+ },
63
+ )
64
+ return list(conf.files_read), rows
65
+
66
+
67
+ def _shown(value: Any) -> str:
68
+ if value is None:
69
+ return ""
70
+ if isinstance(value, bool):
71
+ return "yes" if value else "no"
72
+ return str(value)
73
+
74
+
75
+ def _print_text(files: list[str], rows: list[dict[str, Any]]) -> None:
76
+ short = _short
77
+
78
+ if files:
79
+ _console.print("[bold]Config files read, in order:[/bold]")
80
+ for f in files:
81
+ _console.print(f" {short(f)}", highlight=False, soft_wrap=True)
82
+ else:
83
+ _console.print("[bold]No config files found;[/bold] every option has its default.")
84
+ width_key = max(len(r["key"]) for r in rows)
85
+ width_val = min(40, max(len(_shown(r["value"])) for r in rows))
86
+ section = None
87
+ for r in rows:
88
+ if r["section"] != section:
89
+ section = r["section"]
90
+ _console.print(f"\n[bold green]\\[{section}][/bold green]")
91
+ source = short(r["source"]) if r["source"] else "[dim]default[/dim]"
92
+ _console.print(
93
+ f" [cyan]{r['key']:<{width_key}}[/cyan] {_shown(r['value']):<{width_val}} {source}",
94
+ highlight=False,
95
+ soft_wrap=True,
96
+ )
97
+
98
+
99
+ def _short(path: str) -> str:
100
+ home = str(Path.home())
101
+ return "~" + path[len(home) :] if path.startswith(home + os.sep) else path
102
+
103
+
104
+ def _print_problems(files: list[str], problems: list[Any]) -> None:
105
+ """Problems grouped under each file in line order, then one summary line — the lint layout."""
106
+ styles = {"error": "bold red", "warning": "yellow"}
107
+ width = max((len(str(p.line)) for p in problems), default=1)
108
+ for path in files:
109
+ mine = [p for p in problems if p.file == path]
110
+ if not mine:
111
+ continue
112
+ _console.print(f"[bold]{_short(path)}[/bold]", highlight=False, soft_wrap=True)
113
+ for p in mine:
114
+ line = str(p.line) if p.line else ""
115
+ style = styles[p.severity]
116
+ _console.print(
117
+ f" {line:>{width}} [{style}]{p.severity:<7}[/{style}] {escape(p.message)}",
118
+ highlight=False,
119
+ soft_wrap=True,
120
+ )
121
+ _console.print()
122
+ errors = sum(p.severity == "error" for p in problems)
123
+ warnings = len(problems) - errors
124
+ checked = f"{len(files)} config file{'s' if len(files) != 1 else ''} checked"
125
+ if not files:
126
+ _console.print("No config files found.")
127
+ elif not problems:
128
+ _console.print(f"No problems found ({checked}).")
129
+ else:
130
+ with_problems = len({p.file for p in problems})
131
+ parts = [f"{n} {word}{'s' if n != 1 else ''}" for n, word in ((errors, "error"), (warnings, "warning")) if n]
132
+ _console.print(
133
+ f"Found {len(problems)} problem{'s' if len(problems) != 1 else ''} in {with_problems} "
134
+ f"file{'s' if with_problems != 1 else ''}: {', '.join(parts)} ({checked})",
135
+ )
136
+
137
+
138
+ @app.command(cls=ExecsqlCommand, name="config")
139
+ def config_cmd(
140
+ script: str | None = typer.Argument(
141
+ None,
142
+ metavar="[SQL_SCRIPT]",
143
+ help=(
144
+ "Resolve config as a run of this script would, including the execsql.conf "
145
+ "in its directory. Without it, only the working directory, user and system "
146
+ "files are read."
147
+ ),
148
+ ),
149
+ init: bool = typer.Option(False, "--init", help="Print a default execsql.conf template to stdout and exit."),
150
+ validate: bool = typer.Option(
151
+ False,
152
+ "--validate",
153
+ help=(
154
+ "Check every config file a run would read: invalid values and unreadable files "
155
+ "are errors, unknown sections and keys are warnings. Exits 1 on any error."
156
+ ),
157
+ ),
158
+ config_file: ConfigFileOpt = None,
159
+ output_format: OutputFormatOpt = OutputFormat.text,
160
+ ) -> None:
161
+ """List config options with their values, defaults and sources.
162
+
163
+ Config files are read from the same places a run reads them. Passwords
164
+ are never shown.
165
+ """
166
+ if init and validate:
167
+ raise typer.BadParameter("--init and --validate cannot be combined.", param_hint="'--validate'")
168
+ if init:
169
+ _init_config()
170
+ raise typer.Exit()
171
+ if config_file and not Path(config_file).is_file():
172
+ _err_console.print(f"[bold red]Error:[/bold red] Config file {config_file!r} does not exist.")
173
+ raise typer.Exit(code=2)
174
+ if script and not Path(script).is_file():
175
+ _err_console.print(f'[bold red]Error:[/bold red] SQL script file "{script}" does not exist.')
176
+ raise typer.Exit(code=2)
177
+
178
+ if validate:
179
+ raise typer.Exit(code=_validate(script, config_file, output_format))
180
+
181
+ files, rows = config_rows(script, config_file)
182
+ if output_format is OutputFormat.json:
183
+ sys.stdout.write(json.dumps({"files_read": files, "options": rows}, indent=2, default=str) + "\n")
184
+ else:
185
+ _print_text(files, rows)
186
+
187
+
188
+ def _validate(script: str | None, config_file: str | None, output_format: OutputFormat) -> int:
189
+ """Run ``--validate``; return the exit code."""
190
+ from execsql.cli.run import _seed_early_subvars
191
+ from execsql.config_validation import validate_config
192
+
193
+ script_path = str(Path(script).resolve().parent) if script else os.getcwd()
194
+ files, problems = validate_config(script_path, _seed_early_subvars(), config_file)
195
+ if output_format is OutputFormat.json:
196
+ payload = {
197
+ "files_checked": files,
198
+ "problems": [
199
+ {"file": p.file, "line": p.line or None, "severity": p.severity, "message": p.message} for p in problems
200
+ ],
201
+ }
202
+ sys.stdout.write(json.dumps(payload, indent=2) + "\n")
203
+ else:
204
+ _print_problems(files, problems)
205
+ return 1 if any(p.severity == "error" for p in problems) else 0
@@ -0,0 +1,100 @@
1
+ """The ``format`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from execsql.cli.application import ExecsqlCommand, app
10
+ from execsql.cli.help import _err_console
11
+ from execsql.cli.options import ConfigFileOpt, ScriptEncodingOpt
12
+
13
+ __all__ = ["format_cmd"]
14
+
15
+
16
+ def format_cmd(
17
+ targets: list[Path] = typer.Argument(
18
+ ...,
19
+ metavar="FILE_OR_DIR...",
20
+ help=(
21
+ "Files or directories to format. Directories are searched recursively for *.sql files. "
22
+ "- reads one script from stdin and writes the result to stdout."
23
+ ),
24
+ ),
25
+ check: bool = typer.Option(False, "--check", help="Exit 1 if any file needs changes; write nothing."),
26
+ diff: bool = typer.Option(
27
+ False,
28
+ "--diff",
29
+ help="Print a unified diff of each file that would change; write nothing. Exits 1 if any would.",
30
+ ),
31
+ in_place: bool = typer.Option(False, "-i", "--in-place", help="Modify files in place."),
32
+ # The three layout options default to None — "not given" — so that
33
+ # [format] in a config file applies unless the command line says otherwise,
34
+ # in either direction.
35
+ sql: bool | None = typer.Option(
36
+ None,
37
+ "--sql/--no-sql",
38
+ help="Reformat SQL with sqlglot, or only metacommands. Default: [format] sql, else --sql.",
39
+ show_default=False,
40
+ ),
41
+ indent: int | None = typer.Option(
42
+ None,
43
+ "--indent",
44
+ metavar="N",
45
+ help="Spaces per indent level. Default: [format] indent, else 4.",
46
+ show_default=False,
47
+ ),
48
+ leading_comma: bool | None = typer.Option(
49
+ None,
50
+ "--leading-comma/--no-leading-comma",
51
+ help="Commas at the start of lines instead of the end. Default: [format] leading_comma, else off.",
52
+ show_default=False,
53
+ ),
54
+ script_encoding: ScriptEncodingOpt = None,
55
+ # The old spelling of --script-encoding, kept working but out of --help.
56
+ encoding: str | None = typer.Option(None, "--encoding", metavar="NAME", hidden=True),
57
+ config_file: ConfigFileOpt = None,
58
+ ) -> None:
59
+ """Normalize metacommand keywords, block indentation, and SQL layout.
60
+
61
+ SQL reformatting needs the [formatter] extra; --no-sql works without it.
62
+ Layout options not given here come from [format] in a config file.
63
+ """
64
+ from execsql.cli.run import _configured_script_encoding, _tool_config
65
+ from execsql.format import _require_sqlglot, run_formatter
66
+
67
+ if Path("-") in targets:
68
+ if len(targets) > 1:
69
+ raise typer.BadParameter("- (stdin) cannot be combined with other paths.", param_hint="FILE_OR_DIR")
70
+ if in_place:
71
+ raise typer.BadParameter("stdin cannot be formatted in place; drop -i.", param_hint="'-i'")
72
+ if diff and in_place:
73
+ raise typer.BadParameter("--diff writes nothing, so it cannot be combined with -i.", param_hint="'--diff'")
74
+ if config_file and not Path(config_file).is_file():
75
+ _err_console.print(f"[bold red]Error:[/bold red] Config file {config_file!r} does not exist.")
76
+ raise typer.Exit(code=2)
77
+
78
+ conf = _tool_config(config_file)
79
+ no_sql = not (conf.format_sql if sql is None else sql)
80
+ if not no_sql:
81
+ try:
82
+ _require_sqlglot()
83
+ except ImportError as exc:
84
+ _err_console.print(f"[bold red]Error:[/bold red] {exc}", highlight=False)
85
+ raise typer.Exit(code=1) from exc
86
+ raise typer.Exit(
87
+ code=run_formatter(
88
+ targets,
89
+ check=check,
90
+ diff=diff,
91
+ in_place=in_place,
92
+ no_sql=no_sql,
93
+ indent=conf.format_indent if indent is None else indent,
94
+ leading_comma=conf.format_leading_comma if leading_comma is None else leading_comma,
95
+ encoding=script_encoding or encoding or _configured_script_encoding(conf) or "utf-8",
96
+ ),
97
+ )
98
+
99
+
100
+ app.command(cls=ExecsqlCommand, name="format")(format_cmd)