execsql2 2.23.1__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.
- execsql/cli/__init__.py +17 -586
- execsql/cli/application.py +309 -0
- execsql/cli/commands/__init__.py +13 -0
- execsql/cli/commands/config.py +205 -0
- execsql/cli/commands/format.py +100 -0
- execsql/cli/commands/init.py +135 -0
- execsql/cli/commands/lint.py +233 -0
- execsql/cli/commands/listing.py +81 -0
- execsql/cli/commands/run.py +451 -0
- execsql/cli/dispatch.py +66 -0
- execsql/cli/help.py +175 -24
- execsql/cli/lint.py +513 -76
- execsql/cli/options.py +108 -0
- execsql/cli/run.py +41 -14
- execsql/config.py +251 -154
- execsql/config_validation.py +282 -0
- execsql/data/execsql.conf.template +24 -1
- execsql/data/script_header.sql +23 -0
- execsql/db/factory.py +2 -2
- execsql/debug/repl.py +51 -193
- execsql/exceptions.py +9 -0
- execsql/format.py +87 -75
- execsql/interactive.py +232 -0
- execsql/manifest.py +189 -0
- execsql/metacommands/conditions.py +1 -1
- execsql/metacommands/control.py +2 -3
- execsql/metacommands/effects.py +124 -0
- execsql/script/__init__.py +0 -9
- execsql/script/ast.py +1 -1
- execsql/script/engine.py +6 -93
- execsql/script/executor.py +153 -34
- execsql/state.py +11 -2
- execsql/state.pyi +3 -0
- execsql/utils/color.py +18 -0
- execsql/utils/errors.py +11 -1
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/README.md +1 -1
- {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/METADATA +111 -58
- {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/RECORD +55 -39
- execsql2-2.24.0.dist-info/entry_points.txt +2 -0
- execsql2-2.23.1.dist-info/entry_points.txt +0 -3
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/WHEEL +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.23.1.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)
|