execsql2 2.17.3__py3-none-any.whl → 2.18.1__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 +15 -5
- execsql/cli/lint.py +296 -430
- execsql/cli/run.py +29 -2
- execsql/config.py +20 -0
- execsql/db/access.py +6 -0
- execsql/db/base.py +57 -1
- execsql/db/dsn.py +19 -9
- execsql/db/firebird.py +6 -0
- execsql/db/mysql.py +81 -0
- execsql/db/oracle.py +6 -0
- execsql/db/sqlite.py +37 -18
- execsql/db/sqlserver.py +31 -6
- execsql/exporters/base.py +1 -1
- execsql/exporters/duckdb.py +8 -4
- execsql/exporters/ods.py +11 -0
- execsql/exporters/sqlite.py +10 -3
- execsql/exporters/templates.py +10 -0
- execsql/exporters/xls.py +4 -0
- execsql/exporters/xlsx.py +9 -0
- execsql/importers/json.py +49 -32
- execsql/metacommands/conditions.py +7 -2
- execsql/metacommands/dispatch.py +5 -10
- execsql/metacommands/io_export.py +21 -26
- execsql/metacommands/io_fileops.py +21 -3
- execsql/metacommands/io_import.py +23 -3
- execsql/metacommands/script_ext.py +8 -7
- execsql/script/ast.py +8 -0
- execsql/script/engine.py +33 -12
- execsql/script/executor.py +12 -0
- execsql/script/variables.py +41 -15
- execsql/utils/auth.py +49 -1
- execsql/utils/fileio.py +120 -0
- execsql/utils/gui.py +11 -1
- {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/md_compare.sql +12 -12
- {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/md_glossary.sql +5 -5
- {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/md_upsert.sql +13 -13
- {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/pg_compare.sql +24 -24
- {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/pg_glossary.sql +5 -5
- {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/pg_upsert.sql +29 -29
- {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/script_template.sql +2 -2
- {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/ss_compare.sql +24 -24
- {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/ss_glossary.sql +6 -6
- {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/ss_upsert.sql +2917 -2917
- {execsql2-2.17.3.dist-info → execsql2-2.18.1.dist-info}/METADATA +47 -40
- {execsql2-2.17.3.dist-info → execsql2-2.18.1.dist-info}/RECORD +54 -55
- execsql/cli/lint_ast.py +0 -439
- {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/execsql.conf +0 -0
- {execsql2-2.17.3.data → execsql2-2.18.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.17.3.dist-info → execsql2-2.18.1.dist-info}/WHEEL +0 -0
- {execsql2-2.17.3.dist-info → execsql2-2.18.1.dist-info}/entry_points.txt +0 -0
- {execsql2-2.17.3.dist-info → execsql2-2.18.1.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.17.3.dist-info → execsql2-2.18.1.dist-info}/licenses/NOTICE +0 -0
execsql/cli/lint.py
CHANGED
|
@@ -1,114 +1,103 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
-
|
|
1
|
+
"""AST-based static analysis (``--lint``) for execsql scripts.
|
|
2
|
+
|
|
3
|
+
Operates on the :class:`~execsql.script.ast.Script` tree produced by
|
|
4
|
+
:func:`execsql.script.parser.parse_script` / ``parse_string``. Runs as
|
|
5
|
+
an early CLI exit — no DB connection and no ``_state`` initialisation
|
|
6
|
+
required.
|
|
7
|
+
|
|
8
|
+
Checks performed:
|
|
9
|
+
|
|
10
|
+
1. **Parse errors** — the AST parser rejects unmatched IF / LOOP /
|
|
11
|
+
BATCH / SCRIPT blocks at parse time with precise source spans;
|
|
12
|
+
``cli/__init__.py`` reports any parse failure as a lint error before
|
|
13
|
+
:func:`lint` is even called.
|
|
14
|
+
2. **Empty scripts** — warns when no nodes were parsed.
|
|
15
|
+
3. **Potentially undefined variables** — flags ``!!$VAR!!`` references
|
|
16
|
+
with no preceding ``SUB``-family definition, ignoring built-in
|
|
17
|
+
``$VAR`` names discovered from the package source and the
|
|
18
|
+
non-``$`` sigils (``~``, ``#``, ``+``, ``@``, ``&``) that resolve at
|
|
19
|
+
runtime.
|
|
20
|
+
4. **Missing INCLUDE files** — warns when the resolved target does not
|
|
21
|
+
exist on disk (skipped when ``IF EXISTS`` is present).
|
|
22
|
+
5. **EXECUTE SCRIPT target resolution** — warns when a target name does
|
|
23
|
+
not correspond to a :class:`ScriptBlock` in the same file (skipped
|
|
24
|
+
when ``IF EXISTS`` is present).
|
|
25
|
+
|
|
26
|
+
Public surface:
|
|
27
|
+
|
|
28
|
+
- :func:`lint` — entry point; returns a list of
|
|
29
|
+
``(severity, source, line_no, message)`` tuples.
|
|
30
|
+
- :func:`_print_lint_results` — Rich console formatter for those
|
|
31
|
+
tuples; returns the ``--lint`` process exit code (``1`` when any
|
|
32
|
+
error-severity issue is present, ``0`` otherwise).
|
|
33
|
+
- :data:`_Issue`, :func:`_error`, :func:`_warning` — tuple type alias
|
|
34
|
+
and constructors used by the walker and the formatter.
|
|
31
35
|
"""
|
|
32
36
|
|
|
33
37
|
from __future__ import annotations
|
|
34
38
|
|
|
35
39
|
import re
|
|
36
40
|
from pathlib import Path
|
|
37
|
-
from typing import TYPE_CHECKING
|
|
38
41
|
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
from execsql.script.ast import (
|
|
43
|
+
BatchBlock,
|
|
44
|
+
IfBlock,
|
|
45
|
+
IncludeDirective,
|
|
46
|
+
LoopBlock,
|
|
47
|
+
MetaCommandStatement,
|
|
48
|
+
Node,
|
|
49
|
+
Script,
|
|
50
|
+
ScriptBlock,
|
|
51
|
+
SqlBlock,
|
|
52
|
+
SqlStatement,
|
|
53
|
+
)
|
|
41
54
|
|
|
42
|
-
__all__ = ["
|
|
55
|
+
__all__ = ["_Issue", "_error", "_print_lint_results", "_warning", "lint"]
|
|
43
56
|
|
|
44
57
|
|
|
45
58
|
# ---------------------------------------------------------------------------
|
|
46
|
-
#
|
|
59
|
+
# Issue tuple type and constructors
|
|
47
60
|
# ---------------------------------------------------------------------------
|
|
48
61
|
|
|
49
|
-
# IF block — "IF(...)" block form (single-command, no ENDIF needed)
|
|
50
|
-
_RX_IF_INLINE = re.compile(
|
|
51
|
-
r"^\s*IF\s*\(\s*.+\s*\)\s*\{.+\}\s*$",
|
|
52
|
-
re.I,
|
|
53
|
-
)
|
|
54
|
-
# IF block form that opens a block requiring ENDIF
|
|
55
|
-
_RX_IF_BLOCK = re.compile(r"^\s*IF\s*\(\s*.+\s*\)\s*$", re.I)
|
|
56
|
-
_RX_ENDIF = re.compile(r"^\s*ENDIF\s*$", re.I)
|
|
57
|
-
_RX_ELSE = re.compile(r"^\s*ELSE\s*$", re.I)
|
|
58
|
-
_RX_ELSEIF = re.compile(r"^\s*ELSEIF\s*\(\s*.+\s*\)\s*$", re.I)
|
|
59
|
-
_RX_ANDIF = re.compile(r"^\s*ANDIF\s*\(\s*.+\s*\)\s*$", re.I)
|
|
60
|
-
_RX_ORIF = re.compile(r"^\s*ORIF\s*\(\s*.+\s*\)\s*$", re.I)
|
|
61
|
-
|
|
62
|
-
# LOOP … END LOOP
|
|
63
|
-
_RX_LOOP = re.compile(r"^\s*LOOP\s+(?:WHILE|UNTIL)\s*\(", re.I)
|
|
64
|
-
_RX_END_LOOP = re.compile(r"^\s*END\s+LOOP\s*$", re.I)
|
|
65
|
-
|
|
66
|
-
# BEGIN BATCH … END BATCH
|
|
67
|
-
_RX_BEGIN_BATCH = re.compile(r"^\s*BEGIN\s+BATCH\s*$", re.I)
|
|
68
|
-
_RX_END_BATCH = re.compile(r"^\s*END\s+BATCH\s*$", re.I)
|
|
69
|
-
|
|
70
|
-
# SUB <varname> <value> — defines a substitution variable
|
|
71
|
-
_RX_SUB = re.compile(r"^\s*SUB\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
72
62
|
|
|
73
|
-
|
|
74
|
-
_RX_SUB_EMPTY = re.compile(r"^\s*SUB_EMPTY\s+(?P<name>[+~]?\w+)\s*$", re.I)
|
|
63
|
+
_Issue = tuple[str, str, int, str] # (severity, source, line_no, message)
|
|
75
64
|
|
|
76
|
-
# SUB_ADD <varname> <expr> — increments a variable (implies it exists)
|
|
77
|
-
_RX_SUB_ADD = re.compile(r"^\s*SUB_ADD\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
78
65
|
|
|
79
|
-
|
|
80
|
-
|
|
66
|
+
def _error(source: str, line_no: int, message: str) -> _Issue:
|
|
67
|
+
return ("error", source, line_no, message)
|
|
81
68
|
|
|
82
|
-
# SUBDATA <varname> <datasource> — defines a variable from a query result
|
|
83
|
-
_RX_SUBDATA = re.compile(r"^\s*SUBDATA\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
84
69
|
|
|
85
|
-
|
|
70
|
+
def _warning(source: str, line_no: int, message: str) -> _Issue:
|
|
71
|
+
return ("warning", source, line_no, message)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Variable-related patterns
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
_RX_SUB = re.compile(r"^\s*SUB\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
79
|
+
_RX_SUB_EMPTY = re.compile(r"^\s*SUB_EMPTY\s+(?P<name>[+~]?\w+)\s*$", re.I)
|
|
80
|
+
_RX_SUB_ADD = re.compile(r"^\s*SUB_ADD\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
81
|
+
_RX_SUB_APPEND = re.compile(r"^\s*SUB_APPEND\s+(?P<name>[+~]?\w+)\s", re.I)
|
|
82
|
+
_RX_SUBDATA = re.compile(r"^\s*SUBDATA\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
86
83
|
_RX_SUB_INI = re.compile(
|
|
87
84
|
r'^\s*SUB_INI\s+(?:FILE\s+)?(?:"(?P<qfile>[^"]+)"|(?P<file>\S+))'
|
|
88
85
|
r"(?:\s+SECTION)?\s+(?P<section>\w+)\s*$",
|
|
89
86
|
re.I,
|
|
90
87
|
)
|
|
88
|
+
_RX_SELECTSUB = re.compile(r"^\s*(?:SELECT_?SUB|PROMPT\s+SELECT_?SUB)\s+", re.I)
|
|
89
|
+
_RX_SUB_LOCAL = re.compile(r"^\s*SUB_LOCAL\s+(?P<name>\w+)\s+", re.I)
|
|
90
|
+
_RX_SUB_TEMPFILE = re.compile(r"^\s*SUB_TEMPFILE\s+(?P<name>\w+)\s", re.I)
|
|
91
|
+
_RX_SUB_DECRYPT = re.compile(r"^\s*SUB_DECRYPT\s+(?P<name>\w+)\s+", re.I)
|
|
92
|
+
_RX_SUB_ENCRYPT = re.compile(r"^\s*SUB_ENCRYPT\s+(?P<name>\w+)\s+", re.I)
|
|
93
|
+
_RX_SUB_QUERYSTRING = re.compile(r"^\s*SUB_QUERYSTRING\s+(?P<name>\w+)\s+", re.I)
|
|
91
94
|
|
|
92
|
-
# EXECUTE SCRIPT / EXEC SCRIPT / RUN SCRIPT
|
|
93
|
-
_RX_EXEC_SCRIPT = re.compile(
|
|
94
|
-
r"^\s*(?:EXEC(?:UTE)?|RUN)\s+SCRIPT(?:\s+IF\s+EXISTS)?\s+(?P<script_id>\w+)",
|
|
95
|
-
re.I,
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
# INCLUDE <file>
|
|
99
|
-
_RX_INCLUDE = re.compile(
|
|
100
|
-
r"^\s*INCLUDE(?:\s+IF\s+EXISTS?)?\s+(?P<path>\S+.*?)\s*$",
|
|
101
|
-
re.I,
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
# Variable reference — !!name!! where name may start with $, @, &, ~, #, +
|
|
105
95
|
_RX_VAR_REF = re.compile(r"!!([$@&~#+]?\w+)!!", re.I)
|
|
106
96
|
|
|
107
|
-
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
# Variable names are stored upper-case without the leading ``$``.
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# Built-in variable discovery
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
112
101
|
|
|
113
102
|
|
|
114
103
|
def _discover_builtin_vars() -> frozenset[str]:
|
|
@@ -138,227 +127,122 @@ def _discover_builtin_vars() -> frozenset[str]:
|
|
|
138
127
|
return frozenset(names)
|
|
139
128
|
|
|
140
129
|
|
|
141
|
-
_BUILTIN_VARS: frozenset[str] =
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
# ---------------------------------------------------------------------------
|
|
145
|
-
# Issue tuple helpers
|
|
146
|
-
# ---------------------------------------------------------------------------
|
|
147
|
-
|
|
148
|
-
_Issue = tuple[str, str, int, str] # (severity, source, line_no, message)
|
|
130
|
+
_BUILTIN_VARS: frozenset[str] | None = None
|
|
149
131
|
|
|
150
132
|
|
|
151
|
-
def
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
return
|
|
133
|
+
def _get_builtin_vars() -> frozenset[str]:
|
|
134
|
+
"""Return the cached set of built-in variable names, discovering on first call."""
|
|
135
|
+
global _BUILTIN_VARS
|
|
136
|
+
if _BUILTIN_VARS is None:
|
|
137
|
+
_BUILTIN_VARS = _discover_builtin_vars()
|
|
138
|
+
return _BUILTIN_VARS
|
|
157
139
|
|
|
158
140
|
|
|
159
141
|
# ---------------------------------------------------------------------------
|
|
160
|
-
#
|
|
142
|
+
# AST walker helpers
|
|
161
143
|
# ---------------------------------------------------------------------------
|
|
162
144
|
|
|
163
145
|
|
|
164
|
-
def
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
) -> None:
|
|
172
|
-
"""Pass 1: walk *cmdlist* and collect all variable definitions into *defined_vars*.
|
|
173
|
-
|
|
174
|
-
This populates the set with every variable name that could be defined at
|
|
175
|
-
runtime — ``SUB``, ``SUB_EMPTY``, ``SUB_ADD``, ``SUB_APPEND``,
|
|
176
|
-
``SUBDATA``, and ``SUB_INI`` (by reading the INI file on disk). It also
|
|
177
|
-
descends into ``EXECUTE SCRIPT`` targets to collect their definitions.
|
|
146
|
+
def _collect_script_blocks(script: Script) -> dict[str, ScriptBlock]:
|
|
147
|
+
"""Build a name → ScriptBlock lookup from all ScriptBlock nodes in the tree."""
|
|
148
|
+
blocks: dict[str, ScriptBlock] = {}
|
|
149
|
+
for node in script.walk():
|
|
150
|
+
if isinstance(node, ScriptBlock):
|
|
151
|
+
blocks[node.name] = node
|
|
152
|
+
return blocks
|
|
178
153
|
|
|
179
|
-
No issues are reported; structural checks and variable-reference validation
|
|
180
|
-
happen in pass 2 (:func:`_lint_cmdlist`).
|
|
181
|
-
"""
|
|
182
|
-
visited = _visited_scripts if _visited_scripts is not None else set()
|
|
183
154
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if ini_file and not _RX_VAR_REF.search(ini_file):
|
|
207
|
-
_read_ini_vars(ini_file, ini_section, script_dir, defined_vars)
|
|
208
|
-
|
|
209
|
-
# EXECUTE SCRIPT — descend into named script block
|
|
210
|
-
exec_m = _RX_EXEC_SCRIPT.match(stmt)
|
|
211
|
-
if exec_m and _savedscripts is not None:
|
|
212
|
-
script_id = exec_m.group("script_id").lower()
|
|
213
|
-
if script_id in _savedscripts and script_id not in visited:
|
|
214
|
-
visited.add(script_id)
|
|
215
|
-
_collect_defined_vars(
|
|
216
|
-
_savedscripts[script_id],
|
|
155
|
+
def _collect_defined_vars_from_nodes(
|
|
156
|
+
nodes: list[Node],
|
|
157
|
+
script_blocks: dict[str, ScriptBlock],
|
|
158
|
+
script_dir: Path | None,
|
|
159
|
+
defined: set[str],
|
|
160
|
+
visited: set[str] | None = None,
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Walk nodes and collect variable definitions into *defined*."""
|
|
163
|
+
if visited is None:
|
|
164
|
+
visited = set()
|
|
165
|
+
|
|
166
|
+
for node in nodes:
|
|
167
|
+
if isinstance(node, MetaCommandStatement):
|
|
168
|
+
_extract_var_definition(node.command, script_dir, defined)
|
|
169
|
+
|
|
170
|
+
elif isinstance(node, IncludeDirective) and node.is_execute_script:
|
|
171
|
+
target = node.target.lower()
|
|
172
|
+
if target in script_blocks and target not in visited:
|
|
173
|
+
visited.add(target)
|
|
174
|
+
_collect_defined_vars_from_nodes(
|
|
175
|
+
script_blocks[target].body,
|
|
176
|
+
script_blocks,
|
|
217
177
|
script_dir,
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
_visited_scripts=visited,
|
|
178
|
+
defined,
|
|
179
|
+
visited,
|
|
221
180
|
)
|
|
222
181
|
|
|
182
|
+
# Recurse into block children
|
|
183
|
+
if isinstance(node, (IfBlock, LoopBlock, BatchBlock, ScriptBlock, SqlBlock)):
|
|
184
|
+
_collect_defined_vars_from_nodes(
|
|
185
|
+
list(node.children()),
|
|
186
|
+
script_blocks,
|
|
187
|
+
script_dir,
|
|
188
|
+
defined,
|
|
189
|
+
visited,
|
|
190
|
+
)
|
|
223
191
|
|
|
224
|
-
def _lint_cmdlist(
|
|
225
|
-
cmdlist: CommandList,
|
|
226
|
-
script_dir: Path | None,
|
|
227
|
-
defined_vars: set[str],
|
|
228
|
-
*,
|
|
229
|
-
_savedscripts: dict | None = None,
|
|
230
|
-
_visited_scripts: set[str] | None = None,
|
|
231
|
-
) -> list[_Issue]:
|
|
232
|
-
"""Pass 2: lint a :class:`CommandList` for structural and variable issues.
|
|
233
|
-
|
|
234
|
-
Args:
|
|
235
|
-
cmdlist: The parsed command list to analyse.
|
|
236
|
-
script_dir: Directory of the top-level script file, used for resolving
|
|
237
|
-
relative INCLUDE paths. ``None`` for inline (``-c``) scripts.
|
|
238
|
-
defined_vars: Set of variable names (without sigil) that have been
|
|
239
|
-
pre-collected by :func:`_collect_defined_vars`. This includes
|
|
240
|
-
*all* top-level and script-block definitions so that ordering
|
|
241
|
-
does not matter.
|
|
242
|
-
_savedscripts: Dictionary of named script blocks (from
|
|
243
|
-
``_state.savedscripts``). Passed explicitly so the function can
|
|
244
|
-
descend into EXECUTE SCRIPT targets.
|
|
245
|
-
_visited_scripts: Set of script IDs already descended into, shared
|
|
246
|
-
across recursive calls to prevent infinite recursion from circular
|
|
247
|
-
EXECUTE SCRIPT references.
|
|
248
|
-
|
|
249
|
-
Returns:
|
|
250
|
-
List of ``(severity, source, line_no, message)`` issue tuples.
|
|
251
|
-
"""
|
|
252
|
-
issues: list[_Issue] = []
|
|
253
|
-
|
|
254
|
-
if_depth = 0
|
|
255
|
-
if_open_locs: list[tuple[str, int]] = [] # (source, line_no) of unmatched IF
|
|
256
192
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
193
|
+
def _extract_var_definition(
|
|
194
|
+
command: str,
|
|
195
|
+
script_dir: Path | None,
|
|
196
|
+
defined: set[str],
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Extract variable name from a SUB-family metacommand into *defined*."""
|
|
199
|
+
for rx in (
|
|
200
|
+
_RX_SUB,
|
|
201
|
+
_RX_SUB_EMPTY,
|
|
202
|
+
_RX_SUB_ADD,
|
|
203
|
+
_RX_SUB_APPEND,
|
|
204
|
+
_RX_SUBDATA,
|
|
205
|
+
_RX_SUB_LOCAL,
|
|
206
|
+
_RX_SUB_TEMPFILE,
|
|
207
|
+
_RX_SUB_DECRYPT,
|
|
208
|
+
_RX_SUB_ENCRYPT,
|
|
209
|
+
_RX_SUB_QUERYSTRING,
|
|
210
|
+
):
|
|
211
|
+
m = rx.match(command)
|
|
212
|
+
if m:
|
|
213
|
+
defined.add(m.group("name").lstrip("+~").upper())
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
# SUB_INI bulk-defines from INI file — read keys at lint time
|
|
217
|
+
ini_m = _RX_SUB_INI.match(command)
|
|
218
|
+
if ini_m:
|
|
219
|
+
ini_file = ini_m.group("qfile") or ini_m.group("file")
|
|
220
|
+
ini_section = ini_m.group("section")
|
|
221
|
+
if ini_file and not _RX_VAR_REF.search(ini_file):
|
|
222
|
+
_read_ini_vars(ini_file, ini_section, script_dir, defined)
|
|
281
223
|
|
|
282
|
-
# -- IF block (opens a block requiring ENDIF) --
|
|
283
|
-
if _RX_IF_BLOCK.match(stmt) and not _RX_IF_INLINE.match(stmt):
|
|
284
|
-
if_depth += 1
|
|
285
|
-
if_open_locs.append((src, lno))
|
|
286
224
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if if_depth == 0:
|
|
296
|
-
kw = stmt.strip().split(None, 1)[0].upper()
|
|
297
|
-
issues.append(_error(src, lno, f"{kw} without a matching preceding IF"))
|
|
298
|
-
|
|
299
|
-
# -- LOOP --
|
|
300
|
-
elif _RX_LOOP.match(stmt):
|
|
301
|
-
loop_depth += 1
|
|
302
|
-
loop_open_locs.append((src, lno))
|
|
303
|
-
|
|
304
|
-
elif _RX_END_LOOP.match(stmt):
|
|
305
|
-
if loop_depth == 0:
|
|
306
|
-
issues.append(_error(src, lno, "END LOOP without a matching preceding LOOP"))
|
|
307
|
-
else:
|
|
308
|
-
loop_depth -= 1
|
|
309
|
-
loop_open_locs.pop()
|
|
225
|
+
def _read_ini_vars(
|
|
226
|
+
ini_file: str,
|
|
227
|
+
section: str,
|
|
228
|
+
script_dir: Path | None,
|
|
229
|
+
defined_vars: set[str],
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Read an INI file and register its section keys as defined variables."""
|
|
232
|
+
from configparser import ConfigParser
|
|
310
233
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
batch_open_locs.append((src, lno))
|
|
234
|
+
p = Path(ini_file)
|
|
235
|
+
if not p.is_absolute() and script_dir is not None:
|
|
236
|
+
p = script_dir / p
|
|
315
237
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
issues.append(_error(src, lno, "END BATCH without a matching preceding BEGIN BATCH"))
|
|
319
|
-
else:
|
|
320
|
-
batch_depth -= 1
|
|
321
|
-
batch_open_locs.pop()
|
|
322
|
-
|
|
323
|
-
# -- EXECUTE SCRIPT — descend into named script block --
|
|
324
|
-
exec_m = _RX_EXEC_SCRIPT.match(stmt)
|
|
325
|
-
if exec_m and _savedscripts is not None:
|
|
326
|
-
script_id = exec_m.group("script_id").lower()
|
|
327
|
-
if script_id not in _savedscripts:
|
|
328
|
-
# Warn unless it's EXECUTE SCRIPT IF EXISTS
|
|
329
|
-
if not re.search(r"\bIF\s+EXISTS\b", stmt, re.I):
|
|
330
|
-
issues.append(
|
|
331
|
-
_warning(src, lno, f"EXECUTE SCRIPT target not found: '{script_id}'"),
|
|
332
|
-
)
|
|
333
|
-
elif script_id not in visited_scripts:
|
|
334
|
-
visited_scripts.add(script_id)
|
|
335
|
-
sub_issues = _lint_cmdlist(
|
|
336
|
-
_savedscripts[script_id],
|
|
337
|
-
script_dir,
|
|
338
|
-
defined_vars,
|
|
339
|
-
_savedscripts=_savedscripts,
|
|
340
|
-
_visited_scripts=visited_scripts,
|
|
341
|
-
)
|
|
342
|
-
for sev, ssrc, slno, msg in sub_issues:
|
|
343
|
-
issues.append((sev, ssrc, slno, f"[script '{script_id}'] {msg}"))
|
|
344
|
-
|
|
345
|
-
# -- INCLUDE file existence --
|
|
346
|
-
inc_m = _RX_INCLUDE.match(stmt)
|
|
347
|
-
if inc_m:
|
|
348
|
-
raw_path = inc_m.group("path").strip().strip("\"'")
|
|
349
|
-
# Only check if no substitution variables are in the path
|
|
350
|
-
if not _RX_VAR_REF.search(raw_path):
|
|
351
|
-
_check_include_path(raw_path, script_dir, src, lno, stmt, issues)
|
|
352
|
-
|
|
353
|
-
# Report unclosed blocks at end of command list
|
|
354
|
-
for osrc, olno in if_open_locs:
|
|
355
|
-
issues.append(_error(osrc, olno, "IF without a matching ENDIF"))
|
|
356
|
-
for osrc, olno in loop_open_locs:
|
|
357
|
-
issues.append(_error(osrc, olno, "LOOP without a matching END LOOP"))
|
|
358
|
-
for osrc, olno in batch_open_locs:
|
|
359
|
-
issues.append(_error(osrc, olno, "BEGIN BATCH without a matching END BATCH"))
|
|
238
|
+
if not p.exists():
|
|
239
|
+
return
|
|
360
240
|
|
|
361
|
-
|
|
241
|
+
cp = ConfigParser()
|
|
242
|
+
cp.read(p)
|
|
243
|
+
if cp.has_section(section):
|
|
244
|
+
for key, _value in cp.items(section):
|
|
245
|
+
defined_vars.add(key.upper())
|
|
362
246
|
|
|
363
247
|
|
|
364
248
|
def _check_var_ref(
|
|
@@ -368,27 +252,14 @@ def _check_var_ref(
|
|
|
368
252
|
defined_vars: set[str],
|
|
369
253
|
issues: list[_Issue],
|
|
370
254
|
) -> None:
|
|
371
|
-
"""Emit a warning if *raw_name* looks like an undefined user variable.
|
|
372
|
-
|
|
373
|
-
Built-in system variables, environment-variable references (``&``-prefix),
|
|
374
|
-
column variables (``@``-prefix), counter variables (``@@``), parameter
|
|
375
|
-
variables (``#``-prefix), and ``$ARG_N`` are excluded from the check.
|
|
376
|
-
|
|
377
|
-
Args:
|
|
378
|
-
raw_name: Variable name token as captured from ``!!name!!`` (with sigil).
|
|
379
|
-
source: Source file name for the issue location.
|
|
380
|
-
line_no: Line number of the command containing the reference.
|
|
381
|
-
defined_vars: Set of variable names (upper-case, no sigil) that have
|
|
382
|
-
been defined by preceding SUB metacommands.
|
|
383
|
-
issues: Issue list to append to.
|
|
384
|
-
"""
|
|
255
|
+
"""Emit a warning if *raw_name* looks like an undefined user variable."""
|
|
385
256
|
if not raw_name:
|
|
386
257
|
return
|
|
387
258
|
|
|
388
259
|
sigil = raw_name[0] if raw_name[0] in ("$", "@", "&", "~", "#", "+") else ""
|
|
389
260
|
name = raw_name[len(sigil) :]
|
|
390
261
|
|
|
391
|
-
# Skip non-$ sigil prefixes —
|
|
262
|
+
# Skip non-$ sigil prefixes — resolved at runtime
|
|
392
263
|
if sigil in ("@", "&", "~", "#", "+"):
|
|
393
264
|
return
|
|
394
265
|
|
|
@@ -396,12 +267,12 @@ def _check_var_ref(
|
|
|
396
267
|
if re.match(r"^ARG_\d+$", name, re.I):
|
|
397
268
|
return
|
|
398
269
|
|
|
399
|
-
# $COUNTER_N is managed by CounterVars
|
|
270
|
+
# $COUNTER_N is managed by CounterVars
|
|
400
271
|
if re.match(r"^COUNTER_\d+$", name, re.I):
|
|
401
272
|
return
|
|
402
273
|
|
|
403
274
|
# Built-in system variables
|
|
404
|
-
if name.upper() in
|
|
275
|
+
if name.upper() in _get_builtin_vars():
|
|
405
276
|
return
|
|
406
277
|
|
|
407
278
|
# User-defined via SUB
|
|
@@ -418,172 +289,167 @@ def _check_var_ref(
|
|
|
418
289
|
)
|
|
419
290
|
|
|
420
291
|
|
|
421
|
-
def
|
|
422
|
-
|
|
423
|
-
section: str,
|
|
292
|
+
def _check_include_path(
|
|
293
|
+
raw_path: str,
|
|
424
294
|
script_dir: Path | None,
|
|
425
|
-
|
|
295
|
+
source: str,
|
|
296
|
+
line_no: int,
|
|
297
|
+
issues: list[_Issue],
|
|
426
298
|
) -> None:
|
|
427
|
-
"""
|
|
428
|
-
|
|
429
|
-
Mirrors what ``SUB_INI`` does at runtime: reads a
|
|
430
|
-
:class:`~configparser.ConfigParser` section and defines each key as a
|
|
431
|
-
substitution variable. If the file does not exist or the section is
|
|
432
|
-
missing, silently does nothing (the runtime handler behaves the same way).
|
|
433
|
-
"""
|
|
434
|
-
from configparser import ConfigParser
|
|
435
|
-
|
|
436
|
-
p = Path(ini_file)
|
|
299
|
+
"""Warn if the INCLUDE target does not exist on disk."""
|
|
300
|
+
p = Path(raw_path)
|
|
437
301
|
if not p.is_absolute() and script_dir is not None:
|
|
438
302
|
p = script_dir / p
|
|
439
303
|
|
|
440
304
|
if not p.exists():
|
|
441
|
-
|
|
305
|
+
issues.append(
|
|
306
|
+
_warning(source, line_no, f"INCLUDE target does not exist: {raw_path!r}"),
|
|
307
|
+
)
|
|
442
308
|
|
|
443
|
-
cp = ConfigParser()
|
|
444
|
-
cp.read(p)
|
|
445
|
-
if cp.has_section(section):
|
|
446
|
-
for key, _value in cp.items(section):
|
|
447
|
-
defined_vars.add(key.upper())
|
|
448
309
|
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
# Core lint walk
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
449
313
|
|
|
450
|
-
|
|
451
|
-
|
|
314
|
+
|
|
315
|
+
def _lint_nodes(
|
|
316
|
+
nodes: list[Node],
|
|
452
317
|
script_dir: Path | None,
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
stmt: str,
|
|
318
|
+
defined_vars: set[str],
|
|
319
|
+
script_blocks: dict[str, ScriptBlock],
|
|
456
320
|
issues: list[_Issue],
|
|
321
|
+
*,
|
|
322
|
+
visited_scripts: set[str] | None = None,
|
|
457
323
|
) -> None:
|
|
458
|
-
"""
|
|
324
|
+
"""Walk a list of AST nodes and collect lint issues."""
|
|
325
|
+
if visited_scripts is None:
|
|
326
|
+
visited_scripts = set()
|
|
459
327
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
path resolution. ``None`` for inline scripts.
|
|
464
|
-
source: Source file name for issue location.
|
|
465
|
-
line_no: Line number of the INCLUDE command.
|
|
466
|
-
stmt: Full metacommand statement text (for the IF EXISTS variant).
|
|
467
|
-
issues: Issue list to append to.
|
|
468
|
-
"""
|
|
469
|
-
# IF EXISTS variant — missing file is intentional; skip
|
|
470
|
-
if re.match(r"^\s*INCLUDE\s+IF\s+EXISTS?", stmt, re.I):
|
|
471
|
-
return
|
|
328
|
+
for node in nodes:
|
|
329
|
+
src = node.span.file
|
|
330
|
+
lno = node.span.start_line
|
|
472
331
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
332
|
+
# -- Variable references in SQL --
|
|
333
|
+
if isinstance(node, SqlStatement):
|
|
334
|
+
for m in _RX_VAR_REF.finditer(node.text):
|
|
335
|
+
_check_var_ref(m.group(1), src, lno, defined_vars, issues)
|
|
476
336
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
337
|
+
# -- Metacommand checks --
|
|
338
|
+
elif isinstance(node, MetaCommandStatement):
|
|
339
|
+
for m in _RX_VAR_REF.finditer(node.command):
|
|
340
|
+
_check_var_ref(m.group(1), src, lno, defined_vars, issues)
|
|
341
|
+
|
|
342
|
+
# -- IncludeDirective checks --
|
|
343
|
+
elif isinstance(node, IncludeDirective):
|
|
344
|
+
if node.is_execute_script:
|
|
345
|
+
target = node.target.lower()
|
|
346
|
+
if target not in script_blocks:
|
|
347
|
+
if not node.if_exists:
|
|
348
|
+
issues.append(
|
|
349
|
+
_warning(src, lno, f"EXECUTE SCRIPT target not found: '{target}'"),
|
|
350
|
+
)
|
|
351
|
+
elif target not in visited_scripts:
|
|
352
|
+
visited_scripts.add(target)
|
|
353
|
+
_lint_nodes(
|
|
354
|
+
script_blocks[target].body,
|
|
355
|
+
script_dir,
|
|
356
|
+
defined_vars,
|
|
357
|
+
script_blocks,
|
|
358
|
+
issues,
|
|
359
|
+
visited_scripts=visited_scripts,
|
|
360
|
+
)
|
|
361
|
+
else:
|
|
362
|
+
# INCLUDE file existence check
|
|
363
|
+
if not node.if_exists:
|
|
364
|
+
raw_path = node.target.strip().strip("\"'")
|
|
365
|
+
if not _RX_VAR_REF.search(raw_path):
|
|
366
|
+
_check_include_path(raw_path, script_dir, src, lno, issues)
|
|
367
|
+
|
|
368
|
+
# -- Recurse into block children --
|
|
369
|
+
if isinstance(node, IfBlock):
|
|
370
|
+
_lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
|
|
371
|
+
for clause in node.elseif_clauses:
|
|
372
|
+
_lint_nodes(
|
|
373
|
+
clause.body,
|
|
374
|
+
script_dir,
|
|
375
|
+
defined_vars,
|
|
376
|
+
script_blocks,
|
|
377
|
+
issues,
|
|
378
|
+
visited_scripts=visited_scripts,
|
|
379
|
+
)
|
|
380
|
+
_lint_nodes(
|
|
381
|
+
node.else_body,
|
|
382
|
+
script_dir,
|
|
383
|
+
defined_vars,
|
|
384
|
+
script_blocks,
|
|
385
|
+
issues,
|
|
386
|
+
visited_scripts=visited_scripts,
|
|
387
|
+
)
|
|
388
|
+
elif isinstance(node, (LoopBlock, BatchBlock, SqlBlock)):
|
|
389
|
+
_lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
|
|
390
|
+
elif isinstance(node, ScriptBlock):
|
|
391
|
+
# Lint script block body (structural errors already caught by parser)
|
|
392
|
+
if node.name not in visited_scripts:
|
|
393
|
+
visited_scripts.add(node.name)
|
|
394
|
+
sub_issues: list[_Issue] = []
|
|
395
|
+
_lint_nodes(
|
|
396
|
+
node.body,
|
|
397
|
+
script_dir,
|
|
398
|
+
defined_vars,
|
|
399
|
+
script_blocks,
|
|
400
|
+
sub_issues,
|
|
401
|
+
visited_scripts=visited_scripts,
|
|
402
|
+
)
|
|
403
|
+
for sev, ssrc, slno, msg in sub_issues:
|
|
404
|
+
issues.append((sev, ssrc, slno, f"[script '{node.name}'] {msg}"))
|
|
485
405
|
|
|
486
406
|
|
|
487
|
-
|
|
488
|
-
|
|
407
|
+
# ---------------------------------------------------------------------------
|
|
408
|
+
# Public entry point
|
|
409
|
+
# ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def lint(
|
|
413
|
+
script: Script,
|
|
489
414
|
script_path: str | None = None,
|
|
490
415
|
) -> list[_Issue]:
|
|
491
|
-
"""Perform static analysis on
|
|
492
|
-
|
|
493
|
-
Walks every :class:`~execsql.script.ScriptCmd` in *cmdlist* and any named
|
|
494
|
-
scripts accumulated in ``_state.savedscripts`` (those defined with
|
|
495
|
-
``BEGIN SCRIPT … END SCRIPT`` in the same source file).
|
|
416
|
+
"""Perform static analysis on an AST-parsed script.
|
|
496
417
|
|
|
497
418
|
Args:
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
script_path: Absolute or relative path to the SQL script file. Used
|
|
502
|
-
to resolve relative INCLUDE paths. Pass ``None`` for inline
|
|
503
|
-
(``-c``) scripts.
|
|
419
|
+
script: The parsed :class:`Script` tree.
|
|
420
|
+
script_path: Path to the source file (for resolving relative
|
|
421
|
+
INCLUDE paths). ``None`` for inline scripts.
|
|
504
422
|
|
|
505
423
|
Returns:
|
|
506
|
-
List of ``(severity, source, line_no, message)`` tuples
|
|
507
|
-
found. An empty list means the script is clean.
|
|
424
|
+
List of ``(severity, source, line_no, message)`` issue tuples.
|
|
508
425
|
"""
|
|
509
|
-
import execsql.state as _state
|
|
510
|
-
|
|
511
426
|
issues: list[_Issue] = []
|
|
512
427
|
|
|
513
|
-
if
|
|
428
|
+
if not script.body:
|
|
514
429
|
issues.append(_warning("<script>", 0, "Script is empty — no commands found"))
|
|
515
430
|
return issues
|
|
516
431
|
|
|
517
432
|
script_dir = Path(script_path).resolve().parent if script_path else None
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
#
|
|
521
|
-
# Pass 1: collect all variable definitions from the top-level script
|
|
522
|
-
# and all reachable script blocks. This ensures definition order does
|
|
523
|
-
# not matter — a script block executed early can reference variables
|
|
524
|
-
# defined later in the top-level script.
|
|
525
|
-
# ------------------------------------------------------------------
|
|
433
|
+
script_blocks = _collect_script_blocks(script)
|
|
434
|
+
|
|
435
|
+
# Pass 1: collect all variable definitions
|
|
526
436
|
all_defined: set[str] = set()
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
437
|
+
_collect_defined_vars_from_nodes(script.body, script_blocks, script_dir, all_defined)
|
|
438
|
+
|
|
439
|
+
# Pass 2: lint for variable and include issues
|
|
440
|
+
_lint_nodes(
|
|
441
|
+
script.body,
|
|
530
442
|
script_dir,
|
|
531
443
|
all_defined,
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
)
|
|
535
|
-
# Also collect from every saved script block (they may define vars
|
|
536
|
-
# referenced by other blocks). Share the visited set so each block
|
|
537
|
-
# is only traversed once (O(N) instead of O(N²)).
|
|
538
|
-
for saved_cl in savedscripts.values():
|
|
539
|
-
_collect_defined_vars(
|
|
540
|
-
saved_cl,
|
|
541
|
-
script_dir,
|
|
542
|
-
all_defined,
|
|
543
|
-
_savedscripts=savedscripts,
|
|
544
|
-
_visited_scripts=collect_visited,
|
|
545
|
-
)
|
|
546
|
-
|
|
547
|
-
# ------------------------------------------------------------------
|
|
548
|
-
# Pass 2: lint for structural issues and undefined-variable warnings
|
|
549
|
-
# using the complete variable set from pass 1.
|
|
550
|
-
# ------------------------------------------------------------------
|
|
551
|
-
# Shared visited-scripts tracker — prevents duplicate lint warnings
|
|
552
|
-
# when the same script block is reached via multiple paths.
|
|
553
|
-
visited: set[str] = set()
|
|
554
|
-
|
|
555
|
-
issues.extend(
|
|
556
|
-
_lint_cmdlist(
|
|
557
|
-
cmdlist,
|
|
558
|
-
script_dir,
|
|
559
|
-
all_defined,
|
|
560
|
-
_savedscripts=savedscripts,
|
|
561
|
-
_visited_scripts=visited,
|
|
562
|
-
),
|
|
444
|
+
script_blocks,
|
|
445
|
+
issues,
|
|
563
446
|
)
|
|
564
447
|
|
|
565
|
-
# Analyse each named SCRIPT block that was NOT already visited via
|
|
566
|
-
# EXECUTE SCRIPT (standalone analysis catches structural issues like
|
|
567
|
-
# unmatched IF/ENDIF in script blocks that are never executed).
|
|
568
|
-
for script_name, saved_cl in savedscripts.items():
|
|
569
|
-
if script_name in visited:
|
|
570
|
-
continue
|
|
571
|
-
visited.add(script_name)
|
|
572
|
-
saved_issues = _lint_cmdlist(
|
|
573
|
-
saved_cl,
|
|
574
|
-
script_dir,
|
|
575
|
-
set(all_defined),
|
|
576
|
-
_savedscripts=savedscripts,
|
|
577
|
-
_visited_scripts=visited,
|
|
578
|
-
)
|
|
579
|
-
for sev, src, lno, msg in saved_issues:
|
|
580
|
-
issues.append((sev, src, lno, f"[script '{script_name}'] {msg}"))
|
|
581
|
-
|
|
582
448
|
return issues
|
|
583
449
|
|
|
584
450
|
|
|
585
451
|
# ---------------------------------------------------------------------------
|
|
586
|
-
#
|
|
452
|
+
# Result printing
|
|
587
453
|
# ---------------------------------------------------------------------------
|
|
588
454
|
|
|
589
455
|
|