execsql2 2.23.1__py3-none-any.whl → 2.25.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 (92) hide show
  1. execsql/api.py +169 -160
  2. execsql/cli/__init__.py +19 -586
  3. execsql/cli/application.py +309 -0
  4. execsql/cli/commands/__init__.py +13 -0
  5. execsql/cli/commands/config.py +205 -0
  6. execsql/cli/commands/format.py +100 -0
  7. execsql/cli/commands/init.py +135 -0
  8. execsql/cli/commands/lint.py +225 -0
  9. execsql/cli/commands/listing.py +81 -0
  10. execsql/cli/commands/run.py +459 -0
  11. execsql/cli/dispatch.py +66 -0
  12. execsql/cli/dsn.py +11 -4
  13. execsql/cli/help.py +175 -24
  14. execsql/cli/lint.py +780 -100
  15. execsql/cli/options.py +108 -0
  16. execsql/cli/run.py +96 -32
  17. execsql/config.py +256 -173
  18. execsql/config_validation.py +282 -0
  19. execsql/data/execsql.conf.template +38 -5
  20. execsql/data/script_header.sql +23 -0
  21. execsql/db/access.py +29 -8
  22. execsql/db/base.py +40 -8
  23. execsql/db/dsn.py +13 -4
  24. execsql/db/duckdb.py +3 -1
  25. execsql/db/factory.py +13 -4
  26. execsql/db/firebird.py +21 -7
  27. execsql/db/mysql.py +37 -16
  28. execsql/db/oracle.py +49 -16
  29. execsql/db/postgres.py +30 -6
  30. execsql/db/sqlite.py +7 -9
  31. execsql/db/sqlserver.py +25 -8
  32. execsql/debug/repl.py +51 -193
  33. execsql/exceptions.py +9 -0
  34. execsql/exporters/delimited.py +87 -28
  35. execsql/exporters/xlsx.py +24 -9
  36. execsql/exporters/zip.py +1 -2
  37. execsql/format.py +115 -76
  38. execsql/gui/desktop.py +1 -3
  39. execsql/gui/tui.py +2 -2
  40. execsql/importers/base.py +35 -1
  41. execsql/importers/csv.py +3 -1
  42. execsql/interactive.py +232 -0
  43. execsql/manifest.py +200 -0
  44. execsql/metacommands/conditions.py +7 -8
  45. execsql/metacommands/control.py +2 -3
  46. execsql/metacommands/data.py +4 -1
  47. execsql/metacommands/debug.py +2 -4
  48. execsql/metacommands/effects.py +124 -0
  49. execsql/metacommands/io_fileops.py +62 -37
  50. execsql/metacommands/io_import.py +0 -1
  51. execsql/metacommands/prompt.py +2 -0
  52. execsql/metacommands/upsert.py +5 -2
  53. execsql/models.py +3 -2
  54. execsql/parser.py +14 -5
  55. execsql/plugins.py +24 -0
  56. execsql/script/__init__.py +0 -9
  57. execsql/script/ast.py +1 -1
  58. execsql/script/engine.py +46 -104
  59. execsql/script/executor.py +162 -39
  60. execsql/script/parser.py +167 -118
  61. execsql/state.py +34 -11
  62. execsql/state.pyi +11 -0
  63. execsql/types.py +22 -13
  64. execsql/utils/auth.py +169 -24
  65. execsql/utils/color.py +18 -0
  66. execsql/utils/datetime.py +55 -17
  67. execsql/utils/errors.py +82 -10
  68. execsql/utils/fileio.py +404 -117
  69. execsql/utils/gui.py +3 -0
  70. execsql/utils/mail.py +37 -12
  71. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +1 -1
  72. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/METADATA +122 -63
  73. execsql2-2.25.0.dist-info/RECORD +140 -0
  74. execsql2-2.25.0.dist-info/entry_points.txt +2 -0
  75. execsql2-2.23.1.dist-info/RECORD +0 -124
  76. execsql2-2.23.1.dist-info/entry_points.txt +0 -3
  77. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  78. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  79. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  80. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  81. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  82. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  83. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  84. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  85. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  86. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
  87. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  88. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  89. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  90. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
  91. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
  92. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,135 @@
1
+ """The ``init`` command: set up execsql in a project directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import importlib.resources
7
+ import re
8
+ from pathlib import Path
9
+
10
+ import typer
11
+ from rich.markup import escape
12
+
13
+ from execsql import __version__
14
+ from execsql.cli.application import ExecsqlCommand, app
15
+ from execsql.cli.help import _console
16
+
17
+ __all__ = ["init_cmd", "pre_commit_snippet", "script_header"]
18
+
19
+ _HOOKS_REPO = "https://github.com/geocoug/execsql"
20
+
21
+
22
+ def script_header(name: str, today: datetime.date | None = None) -> str:
23
+ """The header a new script starts with: purpose, notes, project, authors and history."""
24
+ today = today or datetime.date.today()
25
+ text = importlib.resources.files("execsql.data").joinpath("script_header.sql").read_text(encoding="utf-8")
26
+ return text.replace("{name}", name).replace("{date}", today.isoformat()).replace("{year}", str(today.year))
27
+
28
+
29
+ def pre_commit_snippet(indent: str = " ") -> str:
30
+ """The ``repos:`` entry for the execsql-format and execsql-lint hooks, at *indent*."""
31
+ return (
32
+ f"{indent}- repo: {_HOOKS_REPO}\n"
33
+ f"{indent} rev: v{__version__}\n"
34
+ f"{indent} hooks:\n"
35
+ f"{indent} - id: execsql-format\n"
36
+ f"{indent} - id: execsql-lint\n"
37
+ )
38
+
39
+
40
+ def _add_hooks(path: Path) -> tuple[str, str]:
41
+ """Add the execsql hooks to a pre-commit config; return (action, detail).
42
+
43
+ A new file is written whole. An existing one is only appended to, and only
44
+ when ``repos:`` is its last top-level key, so its comments and layout are
45
+ untouched; anything else is reported with the snippet to add by hand.
46
+ """
47
+ if not path.exists():
48
+ path.write_text("repos:\n" + pre_commit_snippet(), encoding="utf-8")
49
+ return "created", ""
50
+ text = path.read_text(encoding="utf-8")
51
+ if _HOOKS_REPO in text or "geocoug/execsql" in text:
52
+ return "skipped", "already has the execsql hooks"
53
+ top_level = re.findall(r"^([A-Za-z_][\w-]*)\s*:", text, flags=re.M)
54
+ if not top_level or top_level[-1] != "repos":
55
+ return "manual", "repos: is not the last key"
56
+ item = re.search(r"^(\s*)- repo:", text, flags=re.M)
57
+ indent = item.group(1) if item else " "
58
+ path.write_text(text.rstrip("\n") + "\n" + pre_commit_snippet(indent), encoding="utf-8")
59
+ return "added", "the execsql-format and execsql-lint hooks"
60
+
61
+
62
+ def _write(path: Path, content: str, force: bool) -> str:
63
+ if path.exists() and not force:
64
+ return "skipped"
65
+ existed = path.exists()
66
+ path.parent.mkdir(parents=True, exist_ok=True)
67
+ path.write_text(content, encoding="utf-8")
68
+ return "overwrote" if existed else "created"
69
+
70
+
71
+ @app.command(cls=ExecsqlCommand, name="init")
72
+ def init_cmd(
73
+ directory: Path = typer.Argument(
74
+ Path("."),
75
+ metavar="[DIR]",
76
+ help="Project directory; created if missing. Default: the current directory.",
77
+ show_default=False,
78
+ ),
79
+ script: str = typer.Option(
80
+ "main.sql",
81
+ "--script",
82
+ metavar="NAME",
83
+ help="Name of the script to create, relative to DIR. .sql is added if missing.",
84
+ ),
85
+ no_script: bool = typer.Option(False, "--no-script", help="Do not create a script."),
86
+ no_config: bool = typer.Option(False, "--no-config", help="Do not create execsql.conf."),
87
+ no_pre_commit: bool = typer.Option(
88
+ False,
89
+ "--no-pre-commit",
90
+ help="Do not create or change .pre-commit-config.yaml.",
91
+ ),
92
+ force: bool = typer.Option(
93
+ False,
94
+ "--force",
95
+ help="Overwrite an existing execsql.conf or script. .pre-commit-config.yaml is never overwritten.",
96
+ ),
97
+ ) -> None:
98
+ """Set up execsql in a project: a config file, a script with a header, and pre-commit hooks.
99
+
100
+ Existing files are left alone unless --force is given. Run it again in an
101
+ existing project with --no-config --no-pre-commit --script NAME to add a
102
+ script.
103
+ """
104
+ from execsql.cli.help import _init_config_text
105
+
106
+ directory.mkdir(parents=True, exist_ok=True)
107
+ report: list[tuple[str, str, str]] = []
108
+
109
+ if not no_config:
110
+ path = directory / "execsql.conf"
111
+ report.append((_write(path, _init_config_text(), force), str(path), ""))
112
+
113
+ if not no_script:
114
+ name = script if Path(script).suffix else f"{script}.sql"
115
+ path = directory / name
116
+ report.append((_write(path, script_header(Path(name).name), force), str(path), ""))
117
+
118
+ if not no_pre_commit:
119
+ path = directory / ".pre-commit-config.yaml"
120
+ action, detail = _add_hooks(path)
121
+ report.append((action, str(path), detail))
122
+
123
+ styles = {"created": "green", "added": "green", "overwrote": "yellow", "skipped": "dim", "manual": "yellow"}
124
+ for action, path_text, detail in report:
125
+ style = styles[action]
126
+ label = "add by hand" if action == "manual" else action
127
+ note = f" ({escape(detail)})" if detail else ""
128
+ if action == "skipped" and not detail:
129
+ note = " (exists; --force to overwrite)"
130
+ _console.print(f" [{style}]{label:<11}[/{style}] {escape(path_text)}{note}", highlight=False, soft_wrap=True)
131
+ if action == "manual":
132
+ _console.print("\n Add this under repos: in that file:\n", highlight=False)
133
+ _console.print(escape(pre_commit_snippet(" ")), highlight=False, soft_wrap=True)
134
+ if not report:
135
+ _console.print("Nothing to do: --no-config, --no-script and --no-pre-commit were all given.")
@@ -0,0 +1,225 @@
1
+ """The ``lint`` command: check scripts for problems without connecting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from enum import Enum
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ from execsql.cli.application import ExecsqlCommand, app
12
+ from execsql.cli.help import _err_console
13
+ from execsql.cli.options import ConfigFileOpt, ScriptEncodingOpt
14
+
15
+ __all__ = ["LintFormat", "lint_cmd", "lint_paths", "sql_files"]
16
+
17
+
18
+ def sql_files(targets: list[str]) -> list[Path]:
19
+ """Every ``.sql`` file named by *targets*, directories walked recursively."""
20
+ found: list[Path] = []
21
+ for raw in targets:
22
+ p = Path(raw)
23
+ if p.is_dir():
24
+ found.extend(sorted(q for q in p.rglob("*.sql")))
25
+ else:
26
+ found.append(p)
27
+ return found
28
+
29
+
30
+ def lint_paths(
31
+ targets: list[str],
32
+ *,
33
+ select: tuple[str, ...] = (),
34
+ ignore: tuple[str, ...] = (),
35
+ output_format: str = "text",
36
+ statistics: bool = False,
37
+ encoding: str = "utf-8",
38
+ strict: bool = False,
39
+ ) -> int:
40
+ """Lint every script named by *targets*; return the process exit code.
41
+
42
+ Directories are searched recursively, so one call checks a whole script
43
+ library.
44
+
45
+ Args:
46
+ targets: Files and directories; directories are searched for ``*.sql``.
47
+ ``-`` reads one script from stdin, reported as ``<stdin>``.
48
+ select: Rule-code prefixes to report (empty means all), already
49
+ validated by :func:`execsql.cli.lint.resolve_selectors`.
50
+ ignore: Rule-code prefixes to drop; wins over *select*.
51
+ output_format: ``"text"`` (grouped by file), ``"concise"`` (one line
52
+ per issue), ``"github"`` (GitHub Actions workflow commands) or ``"json"``. JSON writes one array to stdout and
53
+ nothing else.
54
+ statistics: Report a count per rule instead of each issue.
55
+ encoding: Character encoding every script is read with. A script that
56
+ does not decode is reported as a parse error (``P001``).
57
+ strict: Warnings fail too: exit ``1`` when any issue is reported.
58
+
59
+ Returns:
60
+ ``1`` when any reported issue is an error or no ``.sql`` file was
61
+ found, otherwise ``0``.
62
+ """
63
+ import json
64
+
65
+ from execsql.cli.help import _err_console
66
+ from execsql.cli.lint import (
67
+ Issue,
68
+ _issue,
69
+ exit_code,
70
+ filter_issues,
71
+ lint_source,
72
+ print_concise,
73
+ render_github,
74
+ print_statistics,
75
+ print_text,
76
+ render_json,
77
+ rule_counts,
78
+ )
79
+
80
+ paths = sql_files(targets)
81
+ if not paths:
82
+ _err_console.print("[bold red]Error:[/bold red] no .sql files found in the given paths.")
83
+ return 1
84
+
85
+ per_file: list[tuple[str, list[Issue]]] = []
86
+ for path in paths:
87
+ stdin = str(path) == "-"
88
+ label = "<stdin>" if stdin else str(path)
89
+ try:
90
+ source = sys.stdin.buffer.read().decode(encoding) if stdin else path.read_text(encoding=encoding)
91
+ except UnicodeDecodeError as exc:
92
+ found = [_issue("P001", label, 0, f"cannot decode as {encoding} ({exc.reason}); set -f/--script-encoding")]
93
+ except OSError as exc:
94
+ found = [_issue("P001", label, 0, f"cannot read: {exc.strerror or exc}")]
95
+ else:
96
+ found = lint_source(source, label, script_path=None if stdin else label)
97
+ per_file.append((label, sorted(filter_issues(found, select, ignore), key=lambda i: (i.line, i.code))))
98
+
99
+ reported = [issue for _, issues in per_file for issue in issues]
100
+
101
+ if output_format == "json":
102
+ if statistics:
103
+ rows = [
104
+ {"code": rule.code, "rule": rule.name, "severity": rule.severity, "count": n}
105
+ for rule, n in rule_counts(reported)
106
+ ]
107
+ sys.stdout.write(json.dumps(rows, indent=2) + "\n")
108
+ else:
109
+ sys.stdout.write(render_json(reported) + "\n")
110
+ return exit_code(reported, strict=strict)
111
+
112
+ if statistics:
113
+ print_statistics(per_file, len(paths))
114
+ elif output_format == "concise":
115
+ print_concise(per_file, len(paths))
116
+ elif output_format == "github":
117
+ # Workflow commands only: any other line in the step's log is noise.
118
+ annotations = render_github(per_file)
119
+ if annotations:
120
+ sys.stdout.write(annotations + "\n")
121
+ else:
122
+ print_text(per_file, len(paths))
123
+ return exit_code(reported, strict=strict)
124
+
125
+
126
+ class LintFormat(str, Enum):
127
+ """Output formats for ``execsql lint``."""
128
+
129
+ text = "text"
130
+ concise = "concise"
131
+ json = "json"
132
+ github = "github"
133
+
134
+
135
+ @app.command(
136
+ cls=ExecsqlCommand,
137
+ name="lint",
138
+ help=(
139
+ "Statically check scripts for problems. No database connection is made.\n\n"
140
+ "Every issue names its rule code, which --select and --ignore accept, as a full "
141
+ "code (V001) or a prefix (V). Parse errors (P001) are always reported. Exits 1 "
142
+ "when any error is found; warnings alone exit 0 unless --strict is given.\n\n"
143
+ "Rules: https://execsql2.readthedocs.io/en/latest/reference/lint/"
144
+ ),
145
+ )
146
+ def lint_cmd(
147
+ targets: list[str] = typer.Argument(
148
+ ...,
149
+ metavar="FILE_OR_DIR...",
150
+ help=(
151
+ "Files or directories to check. Directories are searched recursively for *.sql files. "
152
+ "- reads one script from stdin."
153
+ ),
154
+ ),
155
+ select: list[str] | None = typer.Option(
156
+ None,
157
+ "--select",
158
+ metavar="CODES",
159
+ help="Report only these rules: codes or prefixes, comma-separated (e.g. V001,F). Repeatable.",
160
+ ),
161
+ ignore: list[str] | None = typer.Option(
162
+ None,
163
+ "--ignore",
164
+ metavar="CODES",
165
+ help="Do not report these rules: codes or prefixes, comma-separated. Wins over --select. Repeatable.",
166
+ ),
167
+ output_format: LintFormat = typer.Option(
168
+ LintFormat.text,
169
+ "--output-format",
170
+ help=(
171
+ "text groups issues by file; concise is one path:line line per issue; json is for tools; "
172
+ "github prints GitHub Actions annotations."
173
+ ),
174
+ ),
175
+ statistics: bool = typer.Option(
176
+ False,
177
+ "--statistics",
178
+ help="Show how many times each rule fired instead of listing every issue.",
179
+ ),
180
+ strict: bool | None = typer.Option(
181
+ None,
182
+ "--strict/--no-strict",
183
+ help="Exit 1 on warnings too, not only on errors. Default: [lint] strict, else off.",
184
+ show_default=False,
185
+ ),
186
+ script_encoding: ScriptEncodingOpt = None,
187
+ config_file: ConfigFileOpt = None,
188
+ ) -> None:
189
+ """The ``lint`` command; its user-facing help is the decorator's ``help``."""
190
+ from execsql.cli.lint import resolve_selectors
191
+ from execsql.cli.run import _configured_script_encoding, _tool_config
192
+
193
+ if "-" in targets and len(targets) > 1:
194
+ raise typer.BadParameter("- (stdin) cannot be combined with other paths.", param_hint="FILE_OR_DIR")
195
+ if config_file and not Path(config_file).is_file():
196
+ _err_console.print(f"[bold red]Error:[/bold red] Config file {config_file!r} does not exist.")
197
+ raise typer.Exit(code=2)
198
+
199
+ conf = _tool_config(config_file)
200
+ # A flag replaces the [lint] setting rather than adding to it, as in ruff.
201
+ try:
202
+ selected = resolve_selectors(select)
203
+ ignored = resolve_selectors(ignore)
204
+ except ValueError as exc:
205
+ raise typer.BadParameter(str(exc)) from exc
206
+ try:
207
+ if not select and conf.lint_select:
208
+ selected = resolve_selectors([conf.lint_select])
209
+ if not ignore and conf.lint_ignore:
210
+ ignored = resolve_selectors([conf.lint_ignore])
211
+ except ValueError as exc:
212
+ _err_console.print(f"[bold red]Error:[/bold red] \\[lint] in config: {exc}", highlight=False)
213
+ raise typer.Exit(code=2) from exc
214
+
215
+ raise typer.Exit(
216
+ code=lint_paths(
217
+ targets,
218
+ select=selected,
219
+ ignore=ignored,
220
+ output_format=output_format.value,
221
+ statistics=statistics,
222
+ encoding=script_encoding or _configured_script_encoding(conf) or "utf-8",
223
+ strict=conf.lint_strict if strict is None else strict,
224
+ ),
225
+ )
@@ -0,0 +1,81 @@
1
+ """The ``list`` command: reference lists, one subcommand each.
2
+
3
+ ``execsql list metacommands|encodings|plugins|keywords``. Each list is its own
4
+ command so it can take options of its own. The module is not named ``list``
5
+ so importing it never shadows the builtin.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+
13
+ import typer
14
+
15
+ from execsql.cli.application import ExecsqlCommand, ExecsqlSubGroup, app
16
+ from execsql.cli.help import (
17
+ _encoding_names,
18
+ _metacommand_rows,
19
+ _plugins_data,
20
+ _print_encodings,
21
+ _print_keywords_json,
22
+ _print_keywords_text,
23
+ _print_metacommands,
24
+ _print_plugins,
25
+ )
26
+ from execsql.cli.options import OutputFormat, OutputFormatOpt
27
+
28
+ __all__ = ["list_app"]
29
+
30
+ list_app = typer.Typer(
31
+ cls=ExecsqlSubGroup,
32
+ name="list",
33
+ help="List metacommands, encodings, plugins or keywords.",
34
+ rich_markup_mode=None,
35
+ add_completion=False,
36
+ context_settings={"help_option_names": ["-h", "--help"]},
37
+ )
38
+ app.add_typer(list_app, name="list")
39
+
40
+
41
+ def _json(data: object) -> None:
42
+ sys.stdout.write(json.dumps(data, indent=2) + "\n")
43
+
44
+
45
+ @list_app.command(cls=ExecsqlCommand, name="metacommands")
46
+ def metacommands_cmd(output_format: OutputFormatOpt = OutputFormat.text) -> None:
47
+ """Every metacommand and its syntax."""
48
+ if output_format is OutputFormat.json:
49
+ _json([{"name": name, "syntax": syntax} for name, syntax in _metacommand_rows()])
50
+ else:
51
+ _print_metacommands()
52
+
53
+
54
+ @list_app.command(cls=ExecsqlCommand, name="encodings")
55
+ def encodings_cmd(output_format: OutputFormatOpt = OutputFormat.text) -> None:
56
+ """Every character encoding name -e, -f, -g and -i accept."""
57
+ if output_format is OutputFormat.json:
58
+ _json(_encoding_names())
59
+ else:
60
+ _print_encodings()
61
+
62
+
63
+ @list_app.command(cls=ExecsqlCommand, name="plugins")
64
+ def plugins_cmd(output_format: OutputFormatOpt = OutputFormat.text) -> None:
65
+ """Installed plugins: metacommands, exporters, importers."""
66
+ if output_format is OutputFormat.json:
67
+ _json(_plugins_data())
68
+ else:
69
+ _print_plugins()
70
+
71
+
72
+ @list_app.command(cls=ExecsqlCommand, name="keywords")
73
+ def keywords_cmd(output_format: OutputFormatOpt = OutputFormat.text) -> None:
74
+ """The full vocabulary: metacommands by category, conditions, CONFIG options,
75
+ export formats, database types, variable patterns. Its JSON is what editor
76
+ tooling reads.
77
+ """
78
+ if output_format is OutputFormat.json:
79
+ _print_keywords_json()
80
+ else:
81
+ _print_keywords_text()