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.
Files changed (26) hide show
  1. execsql/cli/run.py +333 -173
  2. execsql/config.py +1 -1
  3. execsql/script/executor.py +145 -47
  4. execsql/script/parser.py +9 -1
  5. execsql/state.py +72 -51
  6. {execsql2-2.16.0.dist-info → execsql2-2.16.2.dist-info}/METADATA +38 -29
  7. {execsql2-2.16.0.dist-info → execsql2-2.16.2.dist-info}/RECORD +26 -26
  8. {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/README.md +0 -0
  9. {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  10. {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  11. {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/execsql.conf +0 -0
  12. {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/make_config_db.sql +0 -0
  13. {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/md_compare.sql +0 -0
  14. {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/md_glossary.sql +0 -0
  15. {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/md_upsert.sql +0 -0
  16. {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/pg_compare.sql +0 -0
  17. {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  18. {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  19. {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/script_template.sql +0 -0
  20. {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/ss_compare.sql +0 -0
  21. {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  22. {execsql2-2.16.0.data → execsql2-2.16.2.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  23. {execsql2-2.16.0.dist-info → execsql2-2.16.2.dist-info}/WHEEL +0 -0
  24. {execsql2-2.16.0.dist-info → execsql2-2.16.2.dist-info}/entry_points.txt +0 -0
  25. {execsql2-2.16.0.dist-info → execsql2-2.16.2.dist-info}/licenses/LICENSE.txt +0 -0
  26. {execsql2-2.16.0.dist-info → execsql2-2.16.2.dist-info}/licenses/NOTICE +0 -0
@@ -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
- expanded = substitute_vars(condition, ctx=ctx)
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
- cmd = substitute_vars(text, localvars, ctx=ctx)
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
- cmd = substitute_vars(command, localvars, ctx=ctx)
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
- expanded = substitute_vars(command, localvars, ctx=ctx)
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
- expanded = substitute_vars(clause.condition, ctx=ctx)
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
- expanded = substitute_vars(condition, ctx=ctx)
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
- expanded = substitute_vars(condition, ctx=ctx)
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
- script_localvars = None
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, node.arguments)
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
- scriptvarset = ScriptArgSubVarSet()
671
+ paramvals = ScriptArgSubVarSet()
604
672
  for param, arg in all_prepared_args:
605
- scriptvarset.add_substitution(param, arg)
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
- # Merge script-local vars with any existing local vars
627
- merged = script_localvars
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
- def _run_body() -> None:
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
- if node.loop_type == "WHILE":
640
- while True:
641
- expanded = substitute_vars(condition, ctx=ctx)
642
- if not xcmd_test(expanded):
643
- break
644
- try:
645
- _run_body()
646
- except _BreakLoop:
647
- break
648
- elif node.loop_type == "UNTIL":
649
- while True:
650
- try:
651
- _run_body()
652
- except _BreakLoop:
653
- break
654
- expanded = substitute_vars(condition, ctx=ctx)
655
- if xcmd_test(expanded):
656
- break
657
- else:
658
- _run_body()
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
- target = substitute_vars(node.target, localvars, ctx=ctx).strip()
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
- (``_ctx``). The module's ``__class__`` is swapped to a custom
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. This means:
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 ``_ctx.conf``, ``_ctx.subvars``, etc.
23
- directly, because Python's ``LOAD_GLOBAL`` / ``STORE_GLOBAL`` bytecodes
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: _threading.Thread | None = None
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(self.__dict__["_ctx"], name)
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(self.__dict__["_ctx"], name, value)
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 _ctx, not __dict__, patch takes the delattr
349
- # path. We reset to the default rather than truly deleting.
350
- # Instantiate a fresh context each time to avoid sharing
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(self.__dict__["_ctx"], name, getattr(fresh, name))
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 — use _ctx directly (LOAD_GLOBAL bypasses the proxy)
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
- if len(_ctx.loopcommandstack) == 0:
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
- _ctx.compiling_loop = False
384
- _ctx.commandliststack.append(_ctx.loopcommandstack[-1])
385
- _ctx.loopcommandstack.pop()
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 _ctx
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
- global _ctx
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
- .. warning::
417
-
418
- **Not thread-safe.** ``set_context()`` modifies a module-level
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 = get_context()
432
+ self._prev: RuntimeContext | None = None
437
433
 
438
434
  def __enter__(self) -> RuntimeContext:
439
- set_context(self._ctx)
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
- set_context(self._prev)
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
- global _ctx
455
+ ctx = _get_ctx()
459
456
 
460
457
  # Preserve filewriter — it's atexit-managed and must survive resets.
461
- old_fw = _ctx.filewriter
458
+ old_fw = ctx.filewriter
462
459
 
463
460
  # Close open database connections before discarding the pool.
464
- if _ctx.dbs is not None:
461
+ if ctx.dbs is not None:
465
462
  try:
466
- _ctx.dbs.closeall()
463
+ ctx.dbs.closeall()
467
464
  except Exception:
468
465
  pass
469
466
 
470
- _ctx = RuntimeContext()
471
- _ctx.filewriter = old_fw
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
- _ctx.conf = config
506
- _ctx.if_stack = _script.IfLevels()
507
- _ctx.counters = _script.CounterVars()
508
- _ctx.timer = _timer_mod.Timer()
509
- _ctx.dbs = _db_base.DatabasePool()
510
- _ctx.tempfiles = _fileio_mod.TempFileMgr()
511
- _ctx.export_metadata = _exporters_base.ExportMetadata()
512
- _ctx.metacommandlist = dispatch_table
513
- _ctx.conditionallist = conditional_table
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
- _ctx = RuntimeContext()
547
+ _tls.ctx = RuntimeContext()
527
548
  sys.modules[__name__].__class__ = _StateModule