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
execsql/utils/fileio.py CHANGED
@@ -7,7 +7,7 @@ Provides:
7
7
 
8
8
  - :class:`EncodedFile` — opens files with a specific encoding and error
9
9
  handler; supports the script-, output-, and import-encoding settings.
10
- - :class:`FileWriter` — asynchronous multiprocessing text-file writer
10
+ - :class:`FileWriter` — asynchronous text-file writer thread, one per run,
11
11
  used for export output to avoid blocking the main execution loop.
12
12
  - :class:`Logger` — wraps a log file with timestamped write methods;
13
13
  mirrors output to stderr when ``tee_write_log`` is set.
@@ -24,18 +24,18 @@ import codecs
24
24
  import collections
25
25
  import errno
26
26
  import io
27
- import multiprocessing
28
27
  import os
29
28
  import queue
30
29
  import re
31
30
  from pathlib import Path
32
31
  import stat
33
- import sys
34
32
  import tempfile
33
+ import threading
35
34
  import time
35
+ import weakref
36
36
  from collections.abc import Callable
37
37
  from encodings.aliases import aliases as codec_dict
38
- from typing import Any, cast
38
+ from typing import Any, NamedTuple, cast
39
39
 
40
40
  from execsql.exceptions import ErrInfo
41
41
 
@@ -43,6 +43,7 @@ __all__ = [
43
43
  "make_export_dirs",
44
44
  "check_dir",
45
45
  "FileWriter",
46
+ "LostOutput",
46
47
  "EncodedFile",
47
48
  "Logger",
48
49
  "TempFileMgr",
@@ -55,6 +56,8 @@ __all__ = [
55
56
  "filewriter_closeall",
56
57
  "filewriter_shutdown",
57
58
  "filewriter_end",
59
+ "redact",
60
+ "register_secret",
58
61
  ]
59
62
 
60
63
 
@@ -184,8 +187,21 @@ def safe_output_path(user_path: str, root: str | os.PathLike[str] | None) -> str
184
187
  return str(resolved)
185
188
 
186
189
 
187
- class FileWriter(multiprocessing.Process):
188
- # An object of this class is intended to be used as a subprocess.
190
+ class LostOutput(NamedTuple):
191
+ """Output a file never received, as the writer reports it."""
192
+
193
+ filename: str # "" for an error not tied to one file
194
+ dropped: int # writes discarded
195
+ error: str | None # the OS error; None for an open that timed out
196
+ during: str # "open", "write", "closed" (while still locked) or "writer"
197
+
198
+
199
+ class FileWriter(threading.Thread):
200
+ # Each run owns one of these, held on its RuntimeContext and running as a
201
+ # daemon thread. (Upstream used a subprocess; under the ``spawn`` start
202
+ # method that re-imported the caller's __main__, so a library caller's
203
+ # unguarded script ran a second time, and one writer was shared by every
204
+ # run in the process.)
189
205
  # All files that are to be written to are kept open until explicitly closed or the object is destroyed.
190
206
  # When writing to a file is requested, the file will be opened if it is not already open. If the
191
207
  # file cannot be opened for writing, the content to be written will be queued up and opening will
@@ -201,10 +217,12 @@ class FileWriter(multiprocessing.Process):
201
217
  CMD_CLOSE_ALL_AFTER_WRITE,
202
218
  CMD_CLOSED_STATUS,
203
219
  CMD_PING,
204
- ) = range(9)
220
+ CMD_OPEN_FAILURES,
221
+ ) = range(10)
205
222
 
206
223
  class FileControl:
207
- STATUS_OPEN, STATUS_WAITING, STATUS_UNOPENED, STATUS_CLOSED, STATUS_OPENFAILURE = range(5)
224
+ STATUS_OPEN, STATUS_WAITING, STATUS_UNOPENED, STATUS_CLOSED, STATUS_OPENFAILURE, STATUS_WRITEFAILURE = range(6)
225
+ FAILED = (STATUS_OPENFAILURE, STATUS_WRITEFAILURE)
208
226
 
209
227
  def __init__(self, fn: str, open_timeout: int, encoding: str = "utf-8") -> None:
210
228
  self.filename = fn
@@ -214,9 +232,17 @@ class FileWriter(multiprocessing.Process):
214
232
  self.handle: io.TextIOWrapper | None = None
215
233
  self.status = self.STATUS_UNOPENED
216
234
  self.open_start_time: float | None = None
217
- self.fail_message_written = False
218
235
  self.output_queue: collections.deque[str] = collections.deque()
219
236
  self.close_after_write = False
237
+ # Lines discarded because the file could not be opened within
238
+ # open_timeout, or could not be written; reported back to the
239
+ # caller, never silently lost.
240
+ self.dropped = 0
241
+ # The OS error that failed the file; None for an open that timed out.
242
+ self.error: str | None = None
243
+ # Lines discarded because the file was closed while still locked
244
+ # (a halt, or an exporter about to write the same path).
245
+ self.abandoned = 0
220
246
 
221
247
  def __del__(self) -> None:
222
248
  try:
@@ -224,11 +250,30 @@ class FileWriter(multiprocessing.Process):
224
250
  except Exception:
225
251
  pass # Best-effort cleanup at interpreter shutdown.
226
252
 
253
+ def fail(self, status: int, exc: BaseException) -> None:
254
+ """Give up on the file after *exc*: discard (and count) what is buffered, and buffer nothing more."""
255
+ self.status = status
256
+ self.error = str(exc)
257
+ self.dropped += len(self.output_queue)
258
+ self.output_queue.clear()
259
+ self.close_after_write = False
260
+ handle, self.handle = self.handle, None
261
+ if handle is not None:
262
+ try:
263
+ handle.close()
264
+ except OSError:
265
+ pass # The file has already failed; its error is the one to report.
266
+
227
267
  def write_queue(self) -> None:
228
268
  assert self.handle is not None
229
269
  while len(self.output_queue) > 0:
230
270
  m = self.output_queue.pop()
231
- self.handle.write(m)
271
+ try:
272
+ self.handle.write(m)
273
+ except OSError as exc: # disk full, a share that dropped, ...
274
+ self.dropped += 1
275
+ self.fail(self.STATUS_WRITEFAILURE, exc)
276
+ return
232
277
  if self.close_after_write:
233
278
  self.close_after_write = False
234
279
  self.close()
@@ -252,31 +297,41 @@ class FileWriter(multiprocessing.Process):
252
297
  errors="backslashreplace",
253
298
  ),
254
299
  )
255
- except Exception:
256
- self.status = self.STATUS_WAITING
300
+ except Exception as exc:
301
+ if _never_opens(self.filename, exc):
302
+ self.fail(self.STATUS_OPENFAILURE, exc)
303
+ else:
304
+ self.status = self.STATUS_WAITING
257
305
  else:
258
306
  self.status = self.STATUS_OPEN
259
307
  self.openmode = "a" # Return to default for next open command.
260
308
  self.open_start_time = None
261
- self.fail_message_written = False
262
309
  else:
310
+ # Give up: discard what was buffered (counting it so the
311
+ # caller can report the loss) and stop buffering.
263
312
  self.status = self.STATUS_OPENFAILURE
264
- if not self.fail_message_written:
265
- sys.stderr.write(
266
- f"Could not open {self.filename} for writing after retrying for {self.open_timeout} seconds",
267
- )
268
- self.fail_message_written = True
313
+ self.dropped += len(self.output_queue)
314
+ self.output_queue.clear()
315
+ self.close_after_write = False
269
316
 
270
317
  def close(self) -> None:
271
318
  if self.status == self.STATUS_WAITING:
272
- qlen = len(self.output_queue)
273
- sys.stderr.write(
274
- f"Closing {self.filename} while still trying to open it (locked by another process?) -- {qlen} item(s) in queue to be written",
275
- )
319
+ self.abandoned += len(self.output_queue)
320
+ self.output_queue.clear()
321
+ self.close_after_write = False
276
322
  if self.status == self.STATUS_OPEN and self.handle is not None:
277
323
  self.write_queue()
278
- self.handle.close()
324
+ if self.status == self.STATUS_WRITEFAILURE:
325
+ return
326
+ try:
327
+ self.handle.close()
328
+ except OSError as exc: # the final flush failed
329
+ self.handle = None
330
+ self.fail(self.STATUS_WRITEFAILURE, exc)
331
+ return
279
332
  self.handle = None
333
+ if self.status in self.FAILED:
334
+ return # Kept until the caller has been told about the loss.
280
335
  self.status = self.STATUS_CLOSED
281
336
  self.openmode = "a" # Return this to the default in case it had been changed for the last open.
282
337
 
@@ -287,26 +342,28 @@ class FileWriter(multiprocessing.Process):
287
342
  self.close()
288
343
 
289
344
  def write(self, content: str) -> None:
345
+ if self.status in self.FAILED:
346
+ self.dropped += 1
347
+ return
290
348
  self.output_queue.appendleft(content)
291
349
  self.try_open()
292
350
  if self.status == self.STATUS_OPEN:
293
351
  self.write_queue()
294
352
 
295
- def __init__(
296
- self,
297
- input_queue: multiprocessing.Queue,
298
- return_msg_queue: multiprocessing.Queue,
299
- file_encoding: str = "utf-8",
300
- open_timeout: int = 600,
301
- ) -> None:
353
+ def __init__(self, file_encoding: str = "utf-8", open_timeout: int = 600) -> None:
302
354
  # open_timeout is the maximum time, in seconds, that opening will be retried
303
355
  # if a file cannot be initially opened for writing.
304
- super().__init__()
305
- self.input_queue = input_queue
306
- self.return_msg_queue = return_msg_queue
356
+ # Daemon, so a run that never reaches filewriter_end() cannot hold the
357
+ # interpreter open; the atexit handlers flush before daemons stop.
358
+ super().__init__(name="execsql-filewriter", daemon=True)
359
+ self.input_queue: queue.Queue[tuple[int, tuple[Any, ...]]] = queue.Queue()
360
+ self.return_msg_queue: queue.Queue[Any] = queue.Queue()
307
361
  self.file_encoding = file_encoding
308
362
  self.open_timeout = open_timeout
309
363
  self.files: dict[str, FileWriter.FileControl] = {}
364
+ # Commands that raised something other than a file's own OSError,
365
+ # reported with the failed files.
366
+ self.errors: list[LostOutput] = []
310
367
  self.active = True
311
368
  # Functions in execvec must be in the same order as the CMD enums.
312
369
  self.execvec: tuple[Callable[..., None], ...] = (
@@ -319,6 +376,7 @@ class FileWriter(multiprocessing.Process):
319
376
  self.close_all_after_write,
320
377
  self.closed_status,
321
378
  self.ping,
379
+ self.open_failures,
322
380
  )
323
381
 
324
382
  def __del__(self) -> None:
@@ -327,15 +385,23 @@ class FileWriter(multiprocessing.Process):
327
385
  except Exception:
328
386
  pass # Best-effort cleanup at interpreter shutdown.
329
387
 
388
+ @staticmethod
389
+ def _on_file(fc: FileWriter.FileControl, action: Callable[[], None]) -> None:
390
+ """Run *action* on *fc*; anything it raises fails that file alone."""
391
+ try:
392
+ action()
393
+ except Exception as exc:
394
+ fc.fail(fc.STATUS_WRITEFAILURE, exc)
395
+
330
396
  def close_all(self) -> None:
331
397
  for fc in getattr(self, "files", {}).values():
332
- fc.close()
398
+ self._on_file(fc, fc.close)
333
399
 
334
400
  def close_if_open(self, fn: str) -> None:
335
401
  filename = str(Path(fn).resolve())
336
402
  if filename in self.files:
337
403
  fc = self.files[filename]
338
- fc.close()
404
+ self._on_file(fc, fc.close)
339
405
 
340
406
  def closed_status(self) -> None:
341
407
  # Return CLOSED for anything other than OPEN or WAITING
@@ -348,13 +414,54 @@ class FileWriter(multiprocessing.Process):
348
414
 
349
415
  def close_all_after_write(self) -> None:
350
416
  for fc in self.files.values():
351
- if fc.status != self.FileControl.STATUS_CLOSED:
352
- fc.clean_close()
417
+ if fc.status != self.FileControl.STATUS_CLOSED and fc.status not in fc.FAILED:
418
+ self._on_file(fc, fc.clean_close)
353
419
 
354
420
  def ping(self, token: object) -> None:
355
421
  """Echo *token* back on the return queue — used to synchronize callers."""
356
422
  self.return_msg_queue.put(token)
357
423
 
424
+ def retry_waiting(self) -> None:
425
+ """Re-attempt opening every file still waiting on a lock.
426
+
427
+ Called periodically from the writer loop, so a locked file is retried
428
+ (and its ``open_timeout`` honoured) even when the script writes nothing
429
+ more to it. Without this a waiting file never reached
430
+ ``STATUS_OPENFAILURE`` and a flush waited on it forever.
431
+ """
432
+ for fc in self.files.values():
433
+ if fc.status == self.FileControl.STATUS_WAITING:
434
+ self._on_file(fc, fc.try_open)
435
+ if fc.status == self.FileControl.STATUS_OPEN:
436
+ self._on_file(fc, fc.write_queue)
437
+
438
+ def open_failures(self) -> None:
439
+ """Report, and forget, every file whose output was lost.
440
+
441
+ Puts a list of :class:`LostOutput` on the return queue, then the
442
+ writer's own errors. The failed entries are removed so a later
443
+ write to the same path starts afresh.
444
+ """
445
+ failed = [
446
+ LostOutput(
447
+ fn,
448
+ fc.dropped,
449
+ fc.error,
450
+ "open" if fc.status == fc.STATUS_OPENFAILURE else "write",
451
+ )
452
+ for fn, fc in self.files.items()
453
+ if fc.status in fc.FAILED
454
+ ]
455
+ for lost in failed:
456
+ del self.files[lost.filename]
457
+ for fn, fc in self.files.items():
458
+ if fc.abandoned:
459
+ failed.append(LostOutput(fn, fc.abandoned, None, "closed"))
460
+ fc.abandoned = 0
461
+ failed += self.errors
462
+ self.errors = []
463
+ self.return_msg_queue.put(failed)
464
+
358
465
  def open_as_new(self, fn: str) -> None:
359
466
  filename = str(Path(fn).resolve())
360
467
  if filename not in self.files:
@@ -364,7 +471,7 @@ class FileWriter(multiprocessing.Process):
364
471
  encoding=self.file_encoding,
365
472
  )
366
473
  fc = self.files[filename]
367
- fc.open_as_new()
474
+ self._on_file(fc, fc.open_as_new)
368
475
 
369
476
  def status(self, fn: str) -> None:
370
477
  filename = str(Path(fn).resolve())
@@ -382,57 +489,143 @@ class FileWriter(multiprocessing.Process):
382
489
  encoding=self.file_encoding,
383
490
  )
384
491
  fc = self.files[filename]
385
- fc.write(content)
492
+ self._on_file(fc, lambda: fc.write(content))
386
493
 
387
494
  def shutdown(self) -> None:
388
495
  self.active = False
389
496
  self.close_all()
390
497
 
391
- def run(self) -> None: # pragma: no cover – runs in a subprocess
392
- # Ignore SIGINT in the child process — the parent owns Ctrl+C handling
393
- # and will shut us down via CMD_SHUTDOWN on the queue. Without this,
394
- # KeyboardInterrupt races through queue.get() and close_all(), producing
395
- # ugly tracebacks on stderr.
396
- import signal
397
-
398
- signal.signal(signal.SIGINT, signal.SIG_IGN)
399
-
498
+ def run(self) -> None:
400
499
  # Messages in the input queue consist of a 2-tuple, of which the first element
401
500
  # is a command and the second is a tuple of arguments for the function indicated
402
501
  # by that command.
502
+ # Waiting files are retried on a timer rather than only when the queue
503
+ # is idle: a caller polling CMD_CLOSED_STATUS keeps the queue busy.
504
+ last_retry = time.monotonic()
403
505
  while self.active:
404
506
  try:
405
507
  command, argtuple = self.input_queue.get(timeout=0.1)
406
508
  except queue.Empty:
407
- continue
408
- self.execvec[command](*argtuple)
509
+ pass
510
+ else:
511
+ try:
512
+ self.execvec[command](*argtuple)
513
+ except Exception as exc:
514
+ self._command_failed(command, argtuple, exc)
515
+ if time.monotonic() - last_retry >= 0.1:
516
+ try:
517
+ self.retry_waiting()
518
+ except Exception as exc:
519
+ self.errors.append(LostOutput("", 0, str(exc), "writer"))
520
+ last_retry = time.monotonic()
521
+
522
+ def _command_failed(self, command: int, argtuple: tuple[Any, ...], exc: Exception) -> None:
523
+ """Record a command that raised, and still answer it if the caller waits for an answer.
524
+
525
+ A file's own OSErrors are handled by its FileControl; this catches
526
+ the rest, so one bad command cannot stop the thread (and with it
527
+ every later write, to any file).
528
+ """
529
+ filename = argtuple[0] if command != self.CMD_PING and argtuple and isinstance(argtuple[0], str) else ""
530
+ lost = LostOutput(filename, 0, str(exc), "writer")
531
+ if command == self.CMD_OPEN_FAILURES:
532
+ self.return_msg_queue.put([lost])
533
+ return
534
+ self.errors.append(lost)
535
+ replies: dict[int, Any] = {
536
+ self.CMD_GET_STATUS: self.FileControl.STATUS_CLOSED,
537
+ self.CMD_CLOSED_STATUS: self.FileControl.STATUS_CLOSED,
538
+ self.CMD_PING: argtuple[0] if argtuple else None,
539
+ }
540
+ if command in replies:
541
+ self.return_msg_queue.put(replies[command])
542
+
543
+
544
+ def _never_opens(filename: str, exc: BaseException) -> bool:
545
+ """Whether an open that raised *exc* would fail however long it is retried.
546
+
547
+ Retrying exists for files another process holds for a moment (a backup
548
+ or sync tool's lock, which Windows reports as a PermissionError). A
549
+ directory in the target's place, a parent that is a file, a read-only
550
+ filesystem or an over-long name will not change by waiting. A missing
551
+ parent is still retried: on a network share it can be a dropped
552
+ connection that comes back.
553
+ """
554
+ if isinstance(exc, (IsADirectoryError, NotADirectoryError)):
555
+ return True
556
+ if isinstance(exc, OSError) and exc.errno in (errno.EROFS, errno.ENAMETOOLONG):
557
+ return True
558
+ return os.path.isdir(filename) # Windows reports opening a directory as PermissionError
559
+
560
+
561
+ def _writer_stopped(fw: FileWriter) -> bool:
562
+ """Whether *fw* was started and has ended without being shut down."""
563
+ return fw.ident is not None and fw.active and not fw.is_alive()
564
+
565
+
566
+ def _writer_stopped_error() -> ErrInfo:
567
+ return ErrInfo(
568
+ "error",
569
+ other_msg="File output lost: the file writer stopped unexpectedly; output to files could not be written.",
570
+ )
409
571
 
410
572
 
411
- # Subprocess objects for asynchronous writing to text files.
412
- # filewriter is initialized in main(), so it can take configurable arguments.
413
- filewriter: FileWriter | None = None
414
- fw_input: multiprocessing.Queue = multiprocessing.Queue()
415
- fw_output: multiprocessing.Queue = multiprocessing.Queue()
573
+ def _active_writer() -> FileWriter | None:
574
+ """The active run's writer if it is running, else ``None``.
416
575
 
576
+ Every entry point that puts a command on a writer's queue guards on this:
577
+ with no writer running (test contexts that bypass the CLI and
578
+ :func:`execsql.api.run`) a queued write would never be written, and a
579
+ wait for a reply would block forever. A writer that was started and
580
+ stopped without being shut down raises :class:`ErrInfo`: its run's
581
+ output is being lost, which is an error, not "no writer configured".
582
+ """
583
+ import execsql.state as _state
584
+
585
+ fw = _state.filewriter
586
+ if fw is not None and _writer_stopped(fw):
587
+ raise _writer_stopped_error()
588
+ return fw if fw is not None and fw.is_alive() else None
589
+
590
+
591
+ def _abspath(filename: str) -> str:
592
+ """*filename* made absolute against the working directory as it is now.
593
+
594
+ Resolved here, on the run's thread, rather than in the writer: a ``CD``
595
+ later in the script must not redirect output already queued.
596
+ """
597
+ return str(Path(filename).resolve())
598
+
599
+
600
+ #: Sentinel returned by :func:`_writer_reply` when the writer died before
601
+ #: answering.
602
+ _NO_REPLY = object()
417
603
 
418
- def _writer_alive() -> bool:
419
- """True if the FileWriter subprocess is running and consuming fw_input.
420
604
 
421
- Every entry-point that puts a command on ``fw_input`` should guard on this:
422
- if the subprocess isn't running (test contexts that bypass
423
- ``_state.initialize()``, or a subprocess that crashed), unbounded ``put()``
424
- calls eventually fill the OS pipe buffer (smaller on macOS than Linux) and
425
- deadlock the caller. ``fw_output.get()`` calls would block forever on a
426
- dead writer for the same reason.
605
+ def _writer_reply(fw: FileWriter) -> Any:
606
+ """Wait for *fw*'s answer to the command just sent.
607
+
608
+ Polls with a short timeout and re-checks liveness between polls, so a
609
+ writer that dies mid-request cannot block the caller forever. Returns
610
+ :data:`_NO_REPLY` if it died.
427
611
  """
428
- return filewriter is not None and filewriter.is_alive()
612
+ while True:
613
+ try:
614
+ return fw.return_msg_queue.get(timeout=2.0)
615
+ except queue.Empty:
616
+ if not fw.is_alive():
617
+ return _NO_REPLY
429
618
 
430
619
 
431
620
  def filewriter_filestatus(filename: str) -> int:
432
- if not _writer_alive():
621
+ fw = _active_writer()
622
+ if fw is None:
623
+ return FileWriter.FileControl.STATUS_CLOSED
624
+ fw.input_queue.put((FileWriter.CMD_GET_STATUS, (_abspath(filename),)))
625
+ reply = _writer_reply(fw)
626
+ if reply is _NO_REPLY:
433
627
  return FileWriter.FileControl.STATUS_CLOSED
434
- fw_input.put((FileWriter.CMD_GET_STATUS, (filename,)))
435
- return cast(int, fw_output.get())
628
+ return cast(int, reply)
436
629
 
437
630
 
438
631
  #: Set once the first dropped write has been reported, so a script writing
@@ -444,10 +637,9 @@ def _warn_write_dropped(filename: str) -> None:
444
637
  """Report, once per process, that file output is being discarded.
445
638
 
446
639
  Silently dropping a write is not a safe default: a script's logfile simply
447
- comes back empty and the run still reports success. The guard itself has
448
- to stay — putting on a queue no subprocess is draining fills the OS pipe
449
- buffer and deadlocks the caller — so the write is still dropped, but no
450
- longer without saying so.
640
+ comes back empty and the run still reports success. With no writer
641
+ running there is nothing to hand the write to, so it is still dropped,
642
+ but no longer without saying so.
451
643
  """
452
644
  global _drop_warned
453
645
  if _drop_warned:
@@ -458,8 +650,7 @@ def _warn_write_dropped(filename: str) -> None:
458
650
 
459
651
  write_warning(
460
652
  f'no file writer is running; output to "{filename}" and any later file '
461
- "is discarded. Start one with execsql.utils.fileio.FileWriter, or run "
462
- "the script through the CLI or execsql.api.run().",
653
+ "is discarded. Run the script through the CLI or execsql.api.run().",
463
654
  always=True,
464
655
  )
465
656
  except Exception:
@@ -467,78 +658,127 @@ def _warn_write_dropped(filename: str) -> None:
467
658
 
468
659
 
469
660
  def filewriter_write(filename: str, message: str) -> None:
470
- if not _writer_alive():
661
+ fw = _active_writer()
662
+ if fw is None:
471
663
  _warn_write_dropped(filename)
472
664
  return
473
- fw_input.put((FileWriter.CMD_WRITE, (filename, message)))
665
+ fw.input_queue.put((FileWriter.CMD_WRITE, (_abspath(filename), message)))
474
666
 
475
667
 
476
668
  def filewriter_open_as_new(filename: str) -> None:
477
669
  # FileWriter opens files in append mode ("a") by default. This ensures that it
478
670
  # will be opened in write mode ("w") instead. If the file is open, it will be closed.
479
- if not _writer_alive():
671
+ fw = _active_writer()
672
+ if fw is None:
480
673
  _warn_write_dropped(filename)
481
674
  return
482
- fw_input.put((FileWriter.CMD_OPEN_AS_NEW, (filename,)))
675
+ fw.input_queue.put((FileWriter.CMD_OPEN_AS_NEW, (_abspath(filename),)))
483
676
 
484
677
 
485
678
  def filewriter_close(filename: str) -> None:
486
- # This is intended to be used by the main process to ensure that a file
487
- # is closed before that process writes to it.
488
- if not _writer_alive():
679
+ # This is intended to be used by the executing thread to ensure that a
680
+ # file is closed before it writes to that file itself.
681
+ fw = _active_writer()
682
+ if fw is None:
489
683
  return
490
- fw_input.put((FileWriter.CMD_CLOSE_IF_OPEN, (filename,)))
684
+ filename = _abspath(filename)
685
+ fw.input_queue.put((FileWriter.CMD_CLOSE_IF_OPEN, (filename,)))
491
686
  while filewriter_filestatus(filename) == FileWriter.FileControl.STATUS_OPEN:
492
687
  time.sleep(0.05)
493
688
 
494
689
 
495
- def filewriter_close_all_after_write() -> None:
496
- if not _writer_alive():
690
+ def filewriter_close_all_after_write(max_wait: float | None = None) -> None:
691
+ """Flush and close every file the active run's writer holds, then report lost output.
692
+
693
+ Waits for files still locked by another process for up to the writer's
694
+ ``open_timeout`` (plus a small margin), or *max_wait* seconds if that is
695
+ shorter. A file still locked then is closed and its output discarded,
696
+ and this raises :class:`ErrInfo` naming each file whose output was lost
697
+ and how much — a lost write is an error, not a silent success.
698
+ """
699
+ fw = _active_writer()
700
+ if fw is None:
497
701
  return
498
- fw_input.put((FileWriter.CMD_CLOSE_ALL_AFTER_WRITE, ()))
499
- all_closed = False
500
- while not all_closed:
501
- # Re-check liveness on every iteration: if the subprocess dies
502
- # mid-loop, fw_output.get() would block forever otherwise.
503
- if not _writer_alive():
504
- return
505
- fw_input.put((FileWriter.CMD_CLOSED_STATUS, ()))
506
- try:
507
- close_status = fw_output.get(timeout=2.0)
508
- except queue.Empty:
509
- # Either the writer is too slow or it died after the alive
510
- # check above — recheck on the next iteration.
511
- continue
512
- all_closed = close_status == FileWriter.FileControl.STATUS_CLOSED
702
+ wait = fw.open_timeout + 5.0 if max_wait is None else min(fw.open_timeout + 5.0, max_wait)
703
+ deadline = time.monotonic() + wait
704
+ fw.input_queue.put((FileWriter.CMD_CLOSE_ALL_AFTER_WRITE, ()))
705
+ timed_out = False
706
+ while True:
707
+ if not fw.is_alive():
708
+ raise _writer_stopped_error()
709
+ fw.input_queue.put((FileWriter.CMD_CLOSED_STATUS, ()))
710
+ close_status = _writer_reply(fw)
711
+ if close_status is _NO_REPLY:
712
+ raise _writer_stopped_error()
713
+ if close_status == FileWriter.FileControl.STATUS_CLOSED:
714
+ break
715
+ if time.monotonic() > deadline:
716
+ timed_out = True
717
+ # Close what is still locked, so its discarded output is counted.
718
+ fw.input_queue.put((FileWriter.CMD_CLOSE_ALL, ()))
719
+ break
513
720
  time.sleep(0.05)
721
+ fw.input_queue.put((FileWriter.CMD_OPEN_FAILURES, ()))
722
+ failures = _writer_reply(fw)
723
+ if failures is _NO_REPLY:
724
+ raise _writer_stopped_error()
725
+ messages = [_lost_output_message(lost, fw.open_timeout) for lost in failures]
726
+ if timed_out and not messages:
727
+ messages.append(f"one or more output files were still locked after {wait:.0f} seconds")
728
+ if messages:
729
+ raise ErrInfo("error", other_msg="File output lost: " + "; ".join(messages) + ".")
730
+
731
+
732
+ def _lost_output_message(lost: LostOutput, open_timeout: int) -> str:
733
+ if lost.during == "open" and lost.error is None:
734
+ cause = f"could not open {lost.filename} for writing within {open_timeout} seconds"
735
+ elif lost.during == "open":
736
+ cause = f"could not open {lost.filename} for writing ({lost.error})"
737
+ elif lost.during == "write":
738
+ cause = f"could not write {lost.filename} ({lost.error})"
739
+ elif lost.during == "closed":
740
+ cause = f"{lost.filename} was still locked by another process when it was closed"
741
+ elif lost.filename:
742
+ cause = f"file writer error on {lost.filename} ({lost.error})"
743
+ else:
744
+ cause = f"file writer error ({lost.error})"
745
+ return f"{cause}; {lost.dropped} line(s) of output to it were discarded" if lost.dropped else cause
514
746
 
515
747
 
516
748
  def filewriter_closeall() -> None:
517
- if not _writer_alive():
749
+ fw = _active_writer()
750
+ if fw is None:
518
751
  return
519
- fw_input.put((FileWriter.CMD_CLOSE_ALL, ()))
752
+ fw.input_queue.put((FileWriter.CMD_CLOSE_ALL, ()))
520
753
 
521
754
 
522
755
  def filewriter_shutdown() -> None:
523
- if not _writer_alive():
756
+ fw = _active_writer()
757
+ if fw is None:
524
758
  return
525
- fw_input.put((FileWriter.CMD_SHUTDOWN, ()))
759
+ fw.input_queue.put((FileWriter.CMD_SHUTDOWN, ()))
760
+
526
761
 
762
+ def filewriter_end(writer: FileWriter | None = None) -> None:
763
+ """Close every file and stop *writer*, or the active run's writer if omitted.
764
+
765
+ Only that one writer is stopped: a run that halts leaves other runs'
766
+ output alone. The join is capped so an ``atexit`` handler cannot wedge
767
+ interpreter shutdown on a writer stuck opening a file.
768
+ """
769
+ import execsql.state as _state
527
770
 
528
- def filewriter_end() -> None:
529
- # join() with no timeout blocks forever if the subprocess is stuck;
530
- # cap it so atexit handlers can't wedge Python shutdown.
531
- if filewriter is None:
771
+ fw = writer if writer is not None else _state.filewriter
772
+ if fw is None:
532
773
  return
533
774
  try:
534
- if filewriter.is_alive():
535
- filewriter_shutdown()
536
- filewriter.join(timeout=5.0)
537
- if filewriter.is_alive():
538
- filewriter.terminate()
539
- filewriter.join(timeout=2.0)
775
+ if fw.is_alive():
776
+ fw.input_queue.put((FileWriter.CMD_SHUTDOWN, ()))
777
+ fw.join(timeout=5.0)
540
778
  except Exception:
541
779
  pass # Best-effort cleanup at interpreter shutdown.
780
+ if _state.filewriter is fw:
781
+ _state.filewriter = None
542
782
 
543
783
 
544
784
  class EncodedFile:
@@ -714,7 +954,7 @@ class Logger:
714
954
  self.user,
715
955
  ", ".join([f"{k}: {cmdline_options[k]}" for k in cmdline_options]),
716
956
  )
717
- self.writelog(msg)
957
+ self.writelog(self._redact(msg) or "")
718
958
  if server_name:
719
959
  msg = f"run_db_server\t{self.run_id}\t{server_name}\t{db_name}\n"
720
960
  else:
@@ -726,6 +966,7 @@ class Logger:
726
966
  self.exit_scriptfile: str | None = None
727
967
  self.exit_lno: int | None = None
728
968
  self.exit_description: str | None = None
969
+ self._exit_written = False
729
970
  atexit.register(self.log_exit)
730
971
 
731
972
  def _ts(self) -> str:
@@ -848,12 +1089,19 @@ class Logger:
848
1089
  self.exit_lno = line_no
849
1090
  self.exit_description = msg
850
1091
 
851
- def log_exit_exception(self, msg: str) -> None:
1092
+ def log_exit_interrupted(self) -> None:
1093
+ # Save values to be used by exit() function triggered on program exit
1094
+ self.exit_type = "interrupted"
1095
+ self.exit_scriptfile = None
1096
+ self.exit_lno = None
1097
+ self.exit_description = None
1098
+
1099
+ def log_exit_terminated(self) -> None:
852
1100
  # Save values to be used by exit() function triggered on program exit
853
- self.exit_type = "exception"
1101
+ self.exit_type = "terminated"
854
1102
  self.exit_scriptfile = None
855
1103
  self.exit_lno = None
856
- self.exit_description = self._redact(msg.replace("\n", ""))
1104
+ self.exit_description = None
857
1105
 
858
1106
  def log_exit_error(self, msg: str | None) -> None:
859
1107
  # Save values to be used by exit() function triggered on program exit
@@ -863,8 +1111,12 @@ class Logger:
863
1111
  self.exit_description = self._redact(None if not msg else msg.replace("\n", ""))
864
1112
 
865
1113
  def log_exit(self) -> None:
1114
+ """Write the run's ``exit`` record, once: ``exit_now`` writes it before closing the log, ``atexit`` otherwise."""
866
1115
  import datetime as _datetime
867
1116
 
1117
+ if self._exit_written:
1118
+ return
1119
+ self._exit_written = True
868
1120
  elapsed = (_datetime.datetime.now() - self.run_start).total_seconds()
869
1121
  wmsg = "exit\t{}\t{}\t{}({})\t{}\t{:.1f}s\n".format(
870
1122
  self.run_id,
@@ -877,6 +1129,29 @@ class Logger:
877
1129
  self.writelog(wmsg)
878
1130
 
879
1131
 
1132
+ def register_secret(value: str | None) -> None:
1133
+ """Keep *value* out of the active run's log from now on.
1134
+
1135
+ Called wherever a secret enters the run — a password typed at a prompt,
1136
+ one read from a config file, DSN or keyring, a decrypted value — so a
1137
+ later SQL statement, ``SYSTEM_CMD`` or error message that contains it is
1138
+ logged with ``***`` instead. A no-op when the run has no log.
1139
+ """
1140
+ import execsql.state as _state
1141
+
1142
+ add = getattr(_state.exec_log, "add_redaction_value", None)
1143
+ if add is not None:
1144
+ add(value)
1145
+
1146
+
1147
+ def redact(text: str) -> str:
1148
+ """*text* with the active run's registered secrets and common secret shapes replaced by ``***``."""
1149
+ import execsql.state as _state
1150
+
1151
+ redactor = getattr(_state.exec_log, "_redact", None)
1152
+ return redactor(text) if redactor is not None else text
1153
+
1154
+
880
1155
  class TempFileMgr:
881
1156
  def __repr__(self) -> str:
882
1157
  return "TempFileMgr()"
@@ -884,7 +1159,9 @@ class TempFileMgr:
884
1159
  def __init__(self) -> None:
885
1160
  # Initialize a list of temporary file names.
886
1161
  self.temp_file_names: list = []
887
- atexit.register(self.remove_all)
1162
+ # One process-wide exit hook cleans every live manager; registering a
1163
+ # hook per instance would add one for every execsql.run() call.
1164
+ _live_tempfile_mgrs.add(self)
888
1165
 
889
1166
  def new_temp_fn(self) -> str:
890
1167
  # Create a temp file securely via mkstemp (avoids TOCTOU race).
@@ -901,6 +1178,16 @@ class TempFileMgr:
901
1178
  os.unlink(fn)
902
1179
  except Exception:
903
1180
  pass
1181
+ self.temp_file_names = []
1182
+
1183
+
1184
+ _live_tempfile_mgrs: weakref.WeakSet[TempFileMgr] = weakref.WeakSet()
1185
+
1186
+
1187
+ @atexit.register
1188
+ def _remove_all_temp_files() -> None:
1189
+ for mgr in list(_live_tempfile_mgrs):
1190
+ mgr.remove_all()
904
1191
 
905
1192
 
906
1193
  def list_encodings() -> None: