execsql2 2.24.0__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.
- execsql/api.py +169 -160
- execsql/cli/__init__.py +2 -0
- execsql/cli/commands/lint.py +2 -10
- execsql/cli/commands/run.py +35 -27
- execsql/cli/dsn.py +11 -4
- execsql/cli/lint.py +271 -28
- execsql/cli/options.py +1 -1
- execsql/cli/run.py +56 -19
- execsql/config.py +5 -19
- execsql/data/execsql.conf.template +14 -4
- execsql/db/access.py +29 -8
- execsql/db/base.py +40 -8
- execsql/db/dsn.py +13 -4
- execsql/db/duckdb.py +3 -1
- execsql/db/factory.py +11 -2
- execsql/db/firebird.py +21 -7
- execsql/db/mysql.py +37 -16
- execsql/db/oracle.py +49 -16
- execsql/db/postgres.py +30 -6
- execsql/db/sqlite.py +7 -9
- execsql/db/sqlserver.py +25 -8
- execsql/exporters/delimited.py +87 -28
- execsql/exporters/xlsx.py +24 -9
- execsql/exporters/zip.py +1 -2
- execsql/format.py +29 -2
- execsql/gui/desktop.py +1 -3
- execsql/gui/tui.py +2 -2
- execsql/importers/base.py +35 -1
- execsql/importers/csv.py +3 -1
- execsql/manifest.py +33 -22
- execsql/metacommands/conditions.py +6 -7
- execsql/metacommands/data.py +4 -1
- execsql/metacommands/debug.py +2 -4
- execsql/metacommands/io_fileops.py +62 -37
- execsql/metacommands/io_import.py +0 -1
- execsql/metacommands/prompt.py +2 -0
- execsql/metacommands/upsert.py +5 -2
- execsql/models.py +3 -2
- execsql/parser.py +14 -5
- execsql/plugins.py +24 -0
- execsql/script/engine.py +40 -11
- execsql/script/executor.py +9 -5
- execsql/script/parser.py +167 -118
- execsql/state.py +23 -9
- execsql/state.pyi +8 -0
- execsql/types.py +22 -13
- execsql/utils/auth.py +169 -24
- execsql/utils/datetime.py +55 -17
- execsql/utils/errors.py +71 -9
- execsql/utils/fileio.py +404 -117
- execsql/utils/gui.py +3 -0
- execsql/utils/mail.py +37 -12
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/METADATA +18 -12
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/RECORD +72 -72
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.24.0.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
|
|
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
|
|
188
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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.
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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:
|
|
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
|
-
|
|
408
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
448
|
-
|
|
449
|
-
|
|
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.
|
|
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
|
-
|
|
661
|
+
fw = _active_writer()
|
|
662
|
+
if fw is None:
|
|
471
663
|
_warn_write_dropped(filename)
|
|
472
664
|
return
|
|
473
|
-
|
|
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
|
-
|
|
671
|
+
fw = _active_writer()
|
|
672
|
+
if fw is None:
|
|
480
673
|
_warn_write_dropped(filename)
|
|
481
674
|
return
|
|
482
|
-
|
|
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
|
|
487
|
-
# is closed before
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
if not
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
749
|
+
fw = _active_writer()
|
|
750
|
+
if fw is None:
|
|
518
751
|
return
|
|
519
|
-
|
|
752
|
+
fw.input_queue.put((FileWriter.CMD_CLOSE_ALL, ()))
|
|
520
753
|
|
|
521
754
|
|
|
522
755
|
def filewriter_shutdown() -> None:
|
|
523
|
-
|
|
756
|
+
fw = _active_writer()
|
|
757
|
+
if fw is None:
|
|
524
758
|
return
|
|
525
|
-
|
|
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
|
-
|
|
529
|
-
|
|
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
|
|
535
|
-
|
|
536
|
-
|
|
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
|
|
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 = "
|
|
1101
|
+
self.exit_type = "terminated"
|
|
854
1102
|
self.exit_scriptfile = None
|
|
855
1103
|
self.exit_lno = None
|
|
856
|
-
self.exit_description =
|
|
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
|
-
|
|
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:
|