execsql2 2.16.0__py3-none-any.whl → 2.16.2__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/run.py +333 -173
- execsql/config.py +1 -1
- execsql/script/executor.py +145 -47
- execsql/script/parser.py +9 -1
- execsql/state.py +72 -51
- {execsql2-2.16.0.dist-info → execsql2-2.16.2.dist-info}/METADATA +38 -29
- {execsql2-2.16.0.dist-info → execsql2-2.16.2.dist-info}/RECORD +26 -26
- {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/execsql.conf +0 -0
- {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.16.0.dist-info → execsql2-2.16.2.dist-info}/WHEEL +0 -0
- {execsql2-2.16.0.dist-info → execsql2-2.16.2.dist-info}/entry_points.txt +0 -0
- {execsql2-2.16.0.dist-info → execsql2-2.16.2.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.16.0.dist-info → execsql2-2.16.2.dist-info}/licenses/NOTICE +0 -0
execsql/script/executor.py
CHANGED
|
@@ -57,7 +57,7 @@ from execsql.script.ast import (
|
|
|
57
57
|
SqlBlock,
|
|
58
58
|
SqlStatement,
|
|
59
59
|
)
|
|
60
|
-
from execsql.script.engine import set_dynamic_system_vars, set_static_system_vars, substitute_vars
|
|
60
|
+
from execsql.script.engine import CommandList, set_dynamic_system_vars, set_static_system_vars, substitute_vars
|
|
61
61
|
from execsql.script.variables import SubVarSet
|
|
62
62
|
from execsql.state import RuntimeContext, active_context, get_context, xcmd_test
|
|
63
63
|
from execsql.utils.errors import exception_desc, exit_now, stamp_errinfo
|
|
@@ -80,6 +80,50 @@ _VARLIKE = re.compile(r"!![$@&~#]?\w+!!", re.I)
|
|
|
80
80
|
# on the RuntimeContext. Kept as a comment for grep-ability.
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
def _stack_localvars(ctx: RuntimeContext) -> SubVarSet | None:
|
|
84
|
+
"""Build the merged local+param variable overlay from the current stack frame.
|
|
85
|
+
|
|
86
|
+
Returns ``frame.localvars.merge(frame.paramvals)`` for the top frame on
|
|
87
|
+
``ctx.commandliststack``, or ``None`` if the stack is empty. This mirrors
|
|
88
|
+
the legacy ``CommandList.run_and_increment()`` pattern (line 517 of
|
|
89
|
+
engine.py) so that both ``substitute_vars`` and ``get_subvarset`` (used by
|
|
90
|
+
``x_sub``, ``x_rm_sub``, etc.) operate on the same scope.
|
|
91
|
+
"""
|
|
92
|
+
if ctx.commandliststack:
|
|
93
|
+
frame = ctx.commandliststack[-1]
|
|
94
|
+
return frame.localvars.merge(frame.paramvals)
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _push_frame(
|
|
99
|
+
ctx: RuntimeContext,
|
|
100
|
+
name: str,
|
|
101
|
+
source: str,
|
|
102
|
+
line_no: int = 0,
|
|
103
|
+
*,
|
|
104
|
+
paramnames: list[str] | None = None,
|
|
105
|
+
) -> CommandList:
|
|
106
|
+
"""Push a synthetic CommandList frame onto the command-list stack.
|
|
107
|
+
|
|
108
|
+
Creates a minimal frame with a sentinel :class:`ScriptCmd` so that
|
|
109
|
+
``current_script_line()`` and ``get_subvarset()`` work correctly.
|
|
110
|
+
Returns the frame so the caller can set ``paramvals`` or pre-populate
|
|
111
|
+
``localvars``.
|
|
112
|
+
"""
|
|
113
|
+
from execsql.script.engine import MetacommandStmt, ScriptCmd
|
|
114
|
+
|
|
115
|
+
sentinel = ScriptCmd(source, line_no, "cmd", MetacommandStmt(f"BEGIN SCRIPT {name}"))
|
|
116
|
+
frame = CommandList([sentinel], name, paramnames)
|
|
117
|
+
ctx.commandliststack.append(frame)
|
|
118
|
+
return frame
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _pop_frame(ctx: RuntimeContext) -> None:
|
|
122
|
+
"""Pop the top frame from the command-list stack."""
|
|
123
|
+
if ctx.commandliststack:
|
|
124
|
+
ctx.commandliststack.pop()
|
|
125
|
+
|
|
126
|
+
|
|
83
127
|
def _convert_deferred_vars(text: str) -> str:
|
|
84
128
|
"""Convert deferred substitution variables to regular ones.
|
|
85
129
|
|
|
@@ -96,12 +140,13 @@ def _eval_condition(
|
|
|
96
140
|
modifiers: list[ConditionModifier] | None = None,
|
|
97
141
|
) -> bool:
|
|
98
142
|
"""Evaluate a condition string with optional ANDIF/ORIF modifiers."""
|
|
99
|
-
|
|
143
|
+
effective_locals = _stack_localvars(ctx)
|
|
144
|
+
expanded = substitute_vars(condition, effective_locals, ctx=ctx)
|
|
100
145
|
result = xcmd_test(expanded)
|
|
101
146
|
|
|
102
147
|
if modifiers:
|
|
103
148
|
for mod in modifiers:
|
|
104
|
-
mod_expanded = substitute_vars(mod.condition, ctx=ctx)
|
|
149
|
+
mod_expanded = substitute_vars(mod.condition, effective_locals, ctx=ctx)
|
|
105
150
|
mod_result = xcmd_test(mod_expanded)
|
|
106
151
|
if mod.kind == "AND":
|
|
107
152
|
result = result and mod_result
|
|
@@ -143,7 +188,12 @@ def _exec_sql(
|
|
|
143
188
|
ctx.status.sql_error = False
|
|
144
189
|
if ctx.status.batch.in_batch():
|
|
145
190
|
ctx.status.batch.using_db(ctx.dbs.current())
|
|
146
|
-
|
|
191
|
+
# Build localvars from the command-list stack frame so that ~ and # vars
|
|
192
|
+
# written by x_sub/get_subvarset are visible. The stack frame is the
|
|
193
|
+
# canonical source; the `localvars` parameter is kept for backward compat
|
|
194
|
+
# but superseded when the stack is populated.
|
|
195
|
+
effective_locals = _stack_localvars(ctx) or localvars
|
|
196
|
+
cmd = substitute_vars(text, effective_locals, ctx=ctx)
|
|
147
197
|
if _VARLIKE.search(cmd):
|
|
148
198
|
ctx.output.write(
|
|
149
199
|
f"Warning: There is a potential un-substituted variable in the command\n {cmd}\n",
|
|
@@ -188,7 +238,9 @@ def _exec_metacommand(
|
|
|
188
238
|
localvars: SubVarSet | None = None,
|
|
189
239
|
) -> Any:
|
|
190
240
|
"""Dispatch a metacommand through the dispatch table."""
|
|
191
|
-
|
|
241
|
+
# Build localvars from the command-list stack frame (see _exec_sql comment).
|
|
242
|
+
effective_locals = _stack_localvars(ctx) or localvars
|
|
243
|
+
cmd = substitute_vars(command, effective_locals, ctx=ctx)
|
|
192
244
|
if _VARLIKE.search(cmd):
|
|
193
245
|
ctx.output.write(
|
|
194
246
|
f"Warning: There is a potential un-substituted variable in the command\n {cmd}\n",
|
|
@@ -293,7 +345,8 @@ def _execute_node(
|
|
|
293
345
|
if in_loop:
|
|
294
346
|
command = _convert_deferred_vars(command)
|
|
295
347
|
# Intercept BREAK before dispatch — it controls loop flow
|
|
296
|
-
|
|
348
|
+
effective_locals = _stack_localvars(ctx) or localvars
|
|
349
|
+
expanded = substitute_vars(command, effective_locals, ctx=ctx)
|
|
297
350
|
if _BREAK_RX.match(expanded):
|
|
298
351
|
raise _BreakLoop
|
|
299
352
|
ctx.last_command = _FakeScriptCmd(node)
|
|
@@ -337,7 +390,8 @@ def _execute_if(
|
|
|
337
390
|
|
|
338
391
|
# Try ELSEIF clauses
|
|
339
392
|
for clause in node.elseif_clauses:
|
|
340
|
-
|
|
393
|
+
effective_locals = _stack_localvars(ctx)
|
|
394
|
+
expanded = substitute_vars(clause.condition, effective_locals, ctx=ctx)
|
|
341
395
|
if xcmd_test(expanded):
|
|
342
396
|
_execute_nodes(ctx, clause.body, node.span.file, localvars, in_loop=in_loop)
|
|
343
397
|
return
|
|
@@ -358,7 +412,8 @@ def _execute_loop(
|
|
|
358
412
|
|
|
359
413
|
if node.loop_type == "WHILE":
|
|
360
414
|
while True:
|
|
361
|
-
|
|
415
|
+
effective_locals = _stack_localvars(ctx)
|
|
416
|
+
expanded = substitute_vars(condition, effective_locals, ctx=ctx)
|
|
362
417
|
if not xcmd_test(expanded):
|
|
363
418
|
break
|
|
364
419
|
try:
|
|
@@ -371,7 +426,8 @@ def _execute_loop(
|
|
|
371
426
|
_execute_nodes(ctx, node.body, node.span.file, localvars, in_loop=True)
|
|
372
427
|
except _BreakLoop:
|
|
373
428
|
break
|
|
374
|
-
|
|
429
|
+
effective_locals = _stack_localvars(ctx)
|
|
430
|
+
expanded = substitute_vars(condition, effective_locals, ctx=ctx)
|
|
375
431
|
if xcmd_test(expanded):
|
|
376
432
|
break
|
|
377
433
|
|
|
@@ -586,23 +642,35 @@ def _execute_script_native(
|
|
|
586
642
|
script_block: ScriptBlock,
|
|
587
643
|
localvars: SubVarSet | None = None,
|
|
588
644
|
) -> None:
|
|
589
|
-
"""Execute a SCRIPT block natively through the AST executor.
|
|
645
|
+
"""Execute a SCRIPT block natively through the AST executor.
|
|
646
|
+
|
|
647
|
+
Pushes a :class:`CommandList` frame onto ``ctx.commandliststack`` so that
|
|
648
|
+
legacy metacommand handlers (``x_sub``, ``x_rm_sub``, ``xf_sub_defined``,
|
|
649
|
+
prompt handlers, etc.) can access ``~`` local variables and ``#`` script
|
|
650
|
+
arguments via ``get_subvarset()`` / ``commandliststack[-1]``. The frame
|
|
651
|
+
is popped on exit (including on error) via ``try/finally``.
|
|
652
|
+
"""
|
|
590
653
|
from execsql.script.variables import ScriptArgSubVarSet
|
|
591
654
|
from execsql.utils.strings import wo_quotes
|
|
592
655
|
|
|
593
656
|
# Parse arguments (replicates ScriptExecSpec logic)
|
|
594
|
-
|
|
657
|
+
# Expand the argument string in the *caller's* scope so that references
|
|
658
|
+
# like val=!!#parent_param!! or val=!!~parent_local!! resolve correctly
|
|
659
|
+
# before we push a new scope for this script.
|
|
660
|
+
paramvals: ScriptArgSubVarSet | None = None
|
|
595
661
|
if node.arguments is not None:
|
|
662
|
+
caller_locals = _stack_localvars(ctx)
|
|
663
|
+
expanded_args = substitute_vars(node.arguments, caller_locals, ctx=ctx)
|
|
596
664
|
args_rx = re.compile(
|
|
597
665
|
r'(?P<param>#?\w+)\s*=\s*(?P<arg>(?:(?:[^"\'\[][^,\)]*)|(?:"[^"]*")|(?:\'[^\']*\')|(?:\[[^\]]*\])))',
|
|
598
666
|
re.I,
|
|
599
667
|
)
|
|
600
|
-
all_args = re.findall(args_rx,
|
|
668
|
+
all_args = re.findall(args_rx, expanded_args)
|
|
601
669
|
all_cleaned_args = [(ae[0], wo_quotes(ae[1])) for ae in all_args]
|
|
602
670
|
all_prepared_args = [(ae[0] if ae[0][0] == "#" else "#" + ae[0], ae[1]) for ae in all_cleaned_args]
|
|
603
|
-
|
|
671
|
+
paramvals = ScriptArgSubVarSet()
|
|
604
672
|
for param, arg in all_prepared_args:
|
|
605
|
-
|
|
673
|
+
paramvals.add_substitution(param, arg)
|
|
606
674
|
|
|
607
675
|
# Validate parameter names match
|
|
608
676
|
if script_block.param_names is not None:
|
|
@@ -612,7 +680,6 @@ def _execute_script_native(
|
|
|
612
680
|
"error",
|
|
613
681
|
other_msg=f"Formal and actual parameter name mismatch in call to {script_block.name}.",
|
|
614
682
|
)
|
|
615
|
-
script_localvars = scriptvarset
|
|
616
683
|
else:
|
|
617
684
|
if script_block.param_names is not None:
|
|
618
685
|
raise ErrInfo(
|
|
@@ -623,39 +690,57 @@ def _execute_script_native(
|
|
|
623
690
|
),
|
|
624
691
|
)
|
|
625
692
|
|
|
626
|
-
#
|
|
627
|
-
|
|
693
|
+
# Push a CommandList frame onto the stack so that:
|
|
694
|
+
# - get_subvarset() can find ~local and +outer-scope variables
|
|
695
|
+
# - xf_sub_defined/xf_sub_empty can check ~local and #param variables
|
|
696
|
+
# - current_script_line() returns meaningful source location
|
|
697
|
+
# - REPL .vars/.stack commands show the correct scope
|
|
698
|
+
frame = _push_frame(
|
|
699
|
+
ctx,
|
|
700
|
+
script_block.name,
|
|
701
|
+
script_block.span.file,
|
|
702
|
+
script_block.span.start_line,
|
|
703
|
+
paramnames=script_block.param_names,
|
|
704
|
+
)
|
|
705
|
+
if paramvals is not None:
|
|
706
|
+
frame.paramvals = paramvals
|
|
628
707
|
|
|
629
|
-
|
|
630
|
-
# Deep-copy the body to avoid mutation across iterations
|
|
631
|
-
body = copy.deepcopy(script_block.body)
|
|
632
|
-
_execute_nodes(ctx, body, script_block.span.file, merged, in_loop=False)
|
|
633
|
-
|
|
634
|
-
# Handle WHILE/UNTIL loops
|
|
635
|
-
# Convert deferred vars once — node.loop_condition is immutable after parsing
|
|
636
|
-
if node.loop_type is not None:
|
|
637
|
-
condition = _convert_deferred_vars(node.loop_condition)
|
|
708
|
+
try:
|
|
638
709
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
710
|
+
def _run_body() -> None:
|
|
711
|
+
# Deep-copy the body to avoid mutation across iterations
|
|
712
|
+
body = copy.deepcopy(script_block.body)
|
|
713
|
+
_execute_nodes(ctx, body, script_block.span.file, in_loop=False)
|
|
714
|
+
|
|
715
|
+
# Handle WHILE/UNTIL loops
|
|
716
|
+
# Convert deferred vars once — node.loop_condition is immutable after parsing
|
|
717
|
+
if node.loop_type is not None:
|
|
718
|
+
condition = _convert_deferred_vars(node.loop_condition)
|
|
719
|
+
|
|
720
|
+
if node.loop_type == "WHILE":
|
|
721
|
+
while True:
|
|
722
|
+
effective_locals = _stack_localvars(ctx)
|
|
723
|
+
expanded = substitute_vars(condition, effective_locals, ctx=ctx)
|
|
724
|
+
if not xcmd_test(expanded):
|
|
725
|
+
break
|
|
726
|
+
try:
|
|
727
|
+
_run_body()
|
|
728
|
+
except _BreakLoop:
|
|
729
|
+
break
|
|
730
|
+
elif node.loop_type == "UNTIL":
|
|
731
|
+
while True:
|
|
732
|
+
try:
|
|
733
|
+
_run_body()
|
|
734
|
+
except _BreakLoop:
|
|
735
|
+
break
|
|
736
|
+
effective_locals = _stack_localvars(ctx)
|
|
737
|
+
expanded = substitute_vars(condition, effective_locals, ctx=ctx)
|
|
738
|
+
if xcmd_test(expanded):
|
|
739
|
+
break
|
|
740
|
+
else:
|
|
741
|
+
_run_body()
|
|
742
|
+
finally:
|
|
743
|
+
_pop_frame(ctx)
|
|
659
744
|
|
|
660
745
|
|
|
661
746
|
def _execute_include_native(
|
|
@@ -672,7 +757,13 @@ def _execute_include_native(
|
|
|
672
757
|
from execsql.utils.errors import file_size_date
|
|
673
758
|
|
|
674
759
|
# Substitute variables in the target path
|
|
675
|
-
|
|
760
|
+
effective_locals = _stack_localvars(ctx) or localvars
|
|
761
|
+
target = substitute_vars(node.target, effective_locals, ctx=ctx).strip()
|
|
762
|
+
|
|
763
|
+
# Strip surrounding quotes — the AST parser captures the full target
|
|
764
|
+
# including any quotes, but the legacy dispatch regex stripped them.
|
|
765
|
+
if len(target) >= 2 and target[0] in ('"', "'") and target[-1] == target[0]:
|
|
766
|
+
target = target[1:-1]
|
|
676
767
|
|
|
677
768
|
# Tilde expansion (matches x_include legacy handler)
|
|
678
769
|
if len(target) > 1 and target[0] == "~" and target[1] == os.sep:
|
|
@@ -824,6 +915,11 @@ def execute(script: Script, *, ctx: RuntimeContext | None = None) -> None:
|
|
|
824
915
|
except (OSError, ValueError):
|
|
825
916
|
ctx.include_chain.append(script.source)
|
|
826
917
|
set_static_system_vars(ctx)
|
|
918
|
+
# Push a root frame so commandliststack is never empty during AST
|
|
919
|
+
# execution. This ensures get_subvarset(), current_script_line(),
|
|
920
|
+
# xf_sub_defined(), the REPL, and all other commandliststack readers
|
|
921
|
+
# work correctly even at the top level.
|
|
922
|
+
_push_frame(ctx, "<main>", script.source)
|
|
827
923
|
try:
|
|
828
924
|
_execute_nodes(ctx, script.body, script.source)
|
|
829
925
|
except _BreakLoop as exc:
|
|
@@ -831,3 +927,5 @@ def execute(script: Script, *, ctx: RuntimeContext | None = None) -> None:
|
|
|
831
927
|
type="cmd",
|
|
832
928
|
other_msg="BREAK metacommand outside of a LOOP block.",
|
|
833
929
|
) from exc
|
|
930
|
+
finally:
|
|
931
|
+
_pop_frame(ctx)
|
execsql/script/parser.py
CHANGED
|
@@ -95,6 +95,14 @@ _INCLUDE_RX = re.compile(
|
|
|
95
95
|
re.I,
|
|
96
96
|
)
|
|
97
97
|
|
|
98
|
+
|
|
99
|
+
def _strip_quotes(s: str) -> str:
|
|
100
|
+
"""Strip a matching pair of surrounding quotes from *s*."""
|
|
101
|
+
if len(s) >= 2 and s[0] in ('"', "'") and s[-1] == s[0]:
|
|
102
|
+
return s[1:-1]
|
|
103
|
+
return s
|
|
104
|
+
|
|
105
|
+
|
|
98
106
|
_EXEC_SCRIPT_RX = re.compile(
|
|
99
107
|
r"^\s*(?:EXEC(?:UTE)?|RUN)\s+SCRIPT"
|
|
100
108
|
r"(?P<exists>\s+IF\s+EXISTS)?"
|
|
@@ -545,7 +553,7 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
|
|
|
545
553
|
_current_body().append(
|
|
546
554
|
IncludeDirective(
|
|
547
555
|
span=SourceSpan(source_name, file_lineno),
|
|
548
|
-
target=m.group("target").strip(),
|
|
556
|
+
target=_strip_quotes(m.group("target").strip()),
|
|
549
557
|
if_exists=m.group("exists") is not None,
|
|
550
558
|
),
|
|
551
559
|
)
|
execsql/state.py
CHANGED
|
@@ -13,16 +13,15 @@ inside function/method bodies — never at class-definition time — to avoid
|
|
|
13
13
|
circular-import issues at load time.
|
|
14
14
|
|
|
15
15
|
Internally, all mutable state lives on a :class:`RuntimeContext` instance
|
|
16
|
-
|
|
16
|
+
stored in a :class:`threading.local` so that each thread has its own
|
|
17
|
+
isolated context. The module's ``__class__`` is swapped to a custom
|
|
17
18
|
:class:`types.ModuleType` subclass that transparently proxies attribute
|
|
18
|
-
reads and writes to the active context
|
|
19
|
+
reads and writes to the active thread's context via :func:`_get_ctx`.
|
|
19
20
|
|
|
20
21
|
- External code continues to use ``_state.conf``, ``_state.subvars = ...``,
|
|
21
22
|
etc. with zero changes.
|
|
22
|
-
- Functions *within* this module use ``
|
|
23
|
-
|
|
24
|
-
access ``__dict__`` directly and do not trigger ``__getattr__`` /
|
|
25
|
-
``__setattr__`` on the module class.
|
|
23
|
+
- Functions *within* this module use ``_get_ctx()`` to obtain the active
|
|
24
|
+
context, ensuring thread-safe access.
|
|
26
25
|
|
|
27
26
|
Use :func:`get_context` / :func:`set_context` to obtain or replace the
|
|
28
27
|
active context programmatically.
|
|
@@ -30,12 +29,12 @@ active context programmatically.
|
|
|
30
29
|
|
|
31
30
|
import re
|
|
32
31
|
import sys
|
|
32
|
+
import threading
|
|
33
33
|
import types
|
|
34
34
|
from typing import TYPE_CHECKING, Any
|
|
35
35
|
|
|
36
36
|
if TYPE_CHECKING:
|
|
37
37
|
import multiprocessing as _mp
|
|
38
|
-
import threading as _threading
|
|
39
38
|
|
|
40
39
|
from execsql.config import ConfigData, StatObj, WriteHooks
|
|
41
40
|
from execsql.db.base import DatabasePool
|
|
@@ -306,7 +305,7 @@ class RuntimeContext:
|
|
|
306
305
|
# GUI
|
|
307
306
|
self.gui_console: Any = None
|
|
308
307
|
self.gui_manager_queue: _mp.Queue | None = None
|
|
309
|
-
self.gui_manager_thread:
|
|
308
|
+
self.gui_manager_thread: threading.Thread | None = None
|
|
310
309
|
|
|
311
310
|
# Profiling — None means profiling is disabled; a list means it is enabled.
|
|
312
311
|
# Each entry: (source, line_no, command_type, elapsed_secs, command_text_preview)
|
|
@@ -330,12 +329,12 @@ class _StateModule(types.ModuleType):
|
|
|
330
329
|
|
|
331
330
|
def __getattr__(self, name: str) -> Any:
|
|
332
331
|
if name in _CONTEXT_ATTRS:
|
|
333
|
-
return getattr(
|
|
332
|
+
return getattr(_get_ctx(), name)
|
|
334
333
|
raise AttributeError(f"module {self.__name__!r} has no attribute {name!r}")
|
|
335
334
|
|
|
336
335
|
def __setattr__(self, name: str, value: Any) -> None:
|
|
337
336
|
if name in _CONTEXT_ATTRS:
|
|
338
|
-
setattr(
|
|
337
|
+
setattr(_get_ctx(), name, value)
|
|
339
338
|
else:
|
|
340
339
|
super().__setattr__(name, value)
|
|
341
340
|
|
|
@@ -345,12 +344,12 @@ class _StateModule(types.ModuleType):
|
|
|
345
344
|
# unittest.mock.patch compatibility: patch checks
|
|
346
345
|
# ``name in target.__dict__`` to decide whether to restore
|
|
347
346
|
# via setattr (local) or delattr (non-local). Since context
|
|
348
|
-
# attrs live on
|
|
349
|
-
# path. We reset to the default rather than truly
|
|
350
|
-
# Instantiate a fresh context each time to avoid
|
|
351
|
-
# mutable defaults (lists/dicts) with a cached snapshot.
|
|
347
|
+
# attrs live on the RuntimeContext, not __dict__, patch takes
|
|
348
|
+
# the delattr path. We reset to the default rather than truly
|
|
349
|
+
# deleting. Instantiate a fresh context each time to avoid
|
|
350
|
+
# sharing mutable defaults (lists/dicts) with a cached snapshot.
|
|
352
351
|
fresh = RuntimeContext()
|
|
353
|
-
setattr(
|
|
352
|
+
setattr(_get_ctx(), name, getattr(fresh, name))
|
|
354
353
|
else:
|
|
355
354
|
super().__delattr__(name)
|
|
356
355
|
|
|
@@ -359,7 +358,7 @@ class _StateModule(types.ModuleType):
|
|
|
359
358
|
|
|
360
359
|
|
|
361
360
|
# ---------------------------------------------------------------------------
|
|
362
|
-
# Utility functions
|
|
361
|
+
# Utility functions
|
|
363
362
|
# ---------------------------------------------------------------------------
|
|
364
363
|
|
|
365
364
|
|
|
@@ -378,11 +377,12 @@ def endloop() -> None:
|
|
|
378
377
|
"""Complete the current loop being compiled and push it onto the command stack."""
|
|
379
378
|
import execsql.exceptions as _exc
|
|
380
379
|
|
|
381
|
-
|
|
380
|
+
ctx = _get_ctx()
|
|
381
|
+
if len(ctx.loopcommandstack) == 0:
|
|
382
382
|
raise _exc.ErrInfo("error", other_msg="END LOOP metacommand without a matching preceding LOOP metacommand.")
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
383
|
+
ctx.compiling_loop = False
|
|
384
|
+
ctx.commandliststack.append(ctx.loopcommandstack[-1])
|
|
385
|
+
ctx.loopcommandstack.pop()
|
|
386
386
|
|
|
387
387
|
|
|
388
388
|
# ---------------------------------------------------------------------------
|
|
@@ -391,19 +391,18 @@ def endloop() -> None:
|
|
|
391
391
|
|
|
392
392
|
|
|
393
393
|
def get_context() -> RuntimeContext:
|
|
394
|
-
"""Return the active :class:`RuntimeContext
|
|
395
|
-
return
|
|
394
|
+
"""Return the active :class:`RuntimeContext` for the current thread."""
|
|
395
|
+
return _get_ctx()
|
|
396
396
|
|
|
397
397
|
|
|
398
398
|
def set_context(ctx: RuntimeContext) -> None:
|
|
399
|
-
"""Replace the active :class:`RuntimeContext
|
|
399
|
+
"""Replace the active :class:`RuntimeContext` for the current thread.
|
|
400
400
|
|
|
401
401
|
Args:
|
|
402
402
|
ctx: The new context to install. All subsequent ``_state.foo``
|
|
403
|
-
accesses will resolve against this instance.
|
|
403
|
+
accesses **in this thread** will resolve against this instance.
|
|
404
404
|
"""
|
|
405
|
-
|
|
406
|
-
_ctx = ctx
|
|
405
|
+
_tls.ctx = ctx
|
|
407
406
|
|
|
408
407
|
|
|
409
408
|
class active_context:
|
|
@@ -413,12 +412,9 @@ class active_context:
|
|
|
413
412
|
the given context. The previous context is restored on exit, even if
|
|
414
413
|
an exception occurs.
|
|
415
414
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
variable. If two threads call ``execute()`` concurrently, they
|
|
420
|
-
will overwrite each other's context. For thread-level isolation,
|
|
421
|
-
use thread-local storage (a future enhancement for PARALLEL blocks).
|
|
415
|
+
Thread-safe: each thread has its own context via :data:`threading.local`,
|
|
416
|
+
so concurrent ``active_context`` blocks in different threads do not
|
|
417
|
+
interfere with each other.
|
|
422
418
|
|
|
423
419
|
Usage::
|
|
424
420
|
|
|
@@ -433,14 +429,15 @@ class active_context:
|
|
|
433
429
|
|
|
434
430
|
def __init__(self, ctx: RuntimeContext) -> None:
|
|
435
431
|
self._ctx = ctx
|
|
436
|
-
self._prev: RuntimeContext =
|
|
432
|
+
self._prev: RuntimeContext | None = None
|
|
437
433
|
|
|
438
434
|
def __enter__(self) -> RuntimeContext:
|
|
439
|
-
|
|
435
|
+
self._prev = _get_ctx()
|
|
436
|
+
_tls.ctx = self._ctx
|
|
440
437
|
return self._ctx
|
|
441
438
|
|
|
442
439
|
def __exit__(self, *exc: Any) -> None:
|
|
443
|
-
|
|
440
|
+
_tls.ctx = self._prev
|
|
444
441
|
|
|
445
442
|
|
|
446
443
|
# ---------------------------------------------------------------------------
|
|
@@ -455,20 +452,21 @@ def reset() -> None:
|
|
|
455
452
|
preserving only the ``filewriter`` subprocess (which is ``atexit``-managed
|
|
456
453
|
and must not be discarded while alive).
|
|
457
454
|
"""
|
|
458
|
-
|
|
455
|
+
ctx = _get_ctx()
|
|
459
456
|
|
|
460
457
|
# Preserve filewriter — it's atexit-managed and must survive resets.
|
|
461
|
-
old_fw =
|
|
458
|
+
old_fw = ctx.filewriter
|
|
462
459
|
|
|
463
460
|
# Close open database connections before discarding the pool.
|
|
464
|
-
if
|
|
461
|
+
if ctx.dbs is not None:
|
|
465
462
|
try:
|
|
466
|
-
|
|
463
|
+
ctx.dbs.closeall()
|
|
467
464
|
except Exception:
|
|
468
465
|
pass
|
|
469
466
|
|
|
470
|
-
|
|
471
|
-
|
|
467
|
+
new_ctx = RuntimeContext()
|
|
468
|
+
new_ctx.filewriter = old_fw
|
|
469
|
+
_tls.ctx = new_ctx
|
|
472
470
|
|
|
473
471
|
|
|
474
472
|
def initialize(
|
|
@@ -502,15 +500,16 @@ def initialize(
|
|
|
502
500
|
import execsql.utils.fileio as _fileio_mod
|
|
503
501
|
import execsql.utils.timer as _timer_mod
|
|
504
502
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
503
|
+
ctx = _get_ctx()
|
|
504
|
+
ctx.conf = config
|
|
505
|
+
ctx.if_stack = _script.IfLevels()
|
|
506
|
+
ctx.counters = _script.CounterVars()
|
|
507
|
+
ctx.timer = _timer_mod.Timer()
|
|
508
|
+
ctx.dbs = _db_base.DatabasePool()
|
|
509
|
+
ctx.tempfiles = _fileio_mod.TempFileMgr()
|
|
510
|
+
ctx.export_metadata = _exporters_base.ExportMetadata()
|
|
511
|
+
ctx.metacommandlist = dispatch_table
|
|
512
|
+
ctx.conditionallist = conditional_table
|
|
514
513
|
|
|
515
514
|
# Discover and register metacommand plugins via entry points.
|
|
516
515
|
# Runs here (not at import time) to avoid I/O side effects during import.
|
|
@@ -519,9 +518,31 @@ def initialize(
|
|
|
519
518
|
discover_metacommand_plugins(dispatch_table)
|
|
520
519
|
|
|
521
520
|
|
|
521
|
+
# ---------------------------------------------------------------------------
|
|
522
|
+
# Thread-local storage and context helper
|
|
523
|
+
# ---------------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
_tls = threading.local()
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _get_ctx() -> RuntimeContext:
|
|
529
|
+
"""Return the current thread's :class:`RuntimeContext`, creating one if needed.
|
|
530
|
+
|
|
531
|
+
Each thread gets its own isolated context via :data:`threading.local`.
|
|
532
|
+
The first access in a new thread lazily creates a fresh
|
|
533
|
+
:class:`RuntimeContext` so that code which reads ``_state.foo`` never
|
|
534
|
+
encounters an ``AttributeError``.
|
|
535
|
+
"""
|
|
536
|
+
ctx = getattr(_tls, "ctx", None)
|
|
537
|
+
if ctx is None:
|
|
538
|
+
ctx = RuntimeContext()
|
|
539
|
+
_tls.ctx = ctx
|
|
540
|
+
return ctx
|
|
541
|
+
|
|
542
|
+
|
|
522
543
|
# ---------------------------------------------------------------------------
|
|
523
544
|
# Bootstrap — create the initial context and swap the module class
|
|
524
545
|
# ---------------------------------------------------------------------------
|
|
525
546
|
|
|
526
|
-
|
|
547
|
+
_tls.ctx = RuntimeContext()
|
|
527
548
|
sys.modules[__name__].__class__ = _StateModule
|