execsql2 2.1.2__py3-none-any.whl → 2.4.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 (94) hide show
  1. execsql/cli/__init__.py +436 -0
  2. execsql/cli/dsn.py +86 -0
  3. execsql/cli/help.py +140 -0
  4. execsql/{cli.py → cli/run.py} +14 -589
  5. execsql/config.py +65 -1
  6. execsql/db/access.py +27 -15
  7. execsql/db/base.py +328 -215
  8. execsql/db/dsn.py +10 -5
  9. execsql/db/duckdb.py +6 -2
  10. execsql/db/factory.py +21 -0
  11. execsql/db/firebird.py +27 -19
  12. execsql/db/mysql.py +12 -7
  13. execsql/db/oracle.py +15 -11
  14. execsql/db/postgres.py +31 -16
  15. execsql/db/sqlite.py +15 -11
  16. execsql/db/sqlserver.py +16 -5
  17. execsql/exceptions.py +25 -7
  18. execsql/exporters/base.py +12 -1
  19. execsql/exporters/delimited.py +80 -35
  20. execsql/exporters/duckdb.py +6 -2
  21. execsql/exporters/feather.py +10 -6
  22. execsql/exporters/html.py +89 -69
  23. execsql/exporters/json.py +52 -45
  24. execsql/exporters/latex.py +37 -27
  25. execsql/exporters/ods.py +32 -11
  26. execsql/exporters/parquet.py +5 -2
  27. execsql/exporters/pretty.py +16 -9
  28. execsql/exporters/raw.py +22 -16
  29. execsql/exporters/sqlite.py +6 -2
  30. execsql/exporters/templates.py +39 -21
  31. execsql/exporters/values.py +26 -20
  32. execsql/exporters/xls.py +30 -11
  33. execsql/exporters/xml.py +31 -13
  34. execsql/exporters/zip.py +15 -0
  35. execsql/importers/base.py +6 -4
  36. execsql/importers/csv.py +8 -6
  37. execsql/importers/feather.py +6 -4
  38. execsql/importers/ods.py +6 -4
  39. execsql/importers/xls.py +6 -4
  40. execsql/metacommands/__init__.py +208 -1548
  41. execsql/metacommands/conditions.py +101 -27
  42. execsql/metacommands/control.py +8 -4
  43. execsql/metacommands/data.py +6 -6
  44. execsql/metacommands/debug.py +6 -2
  45. execsql/metacommands/dispatch.py +2011 -0
  46. execsql/metacommands/io.py +67 -1310
  47. execsql/metacommands/io_export.py +442 -0
  48. execsql/metacommands/io_fileops.py +287 -0
  49. execsql/metacommands/io_import.py +398 -0
  50. execsql/metacommands/io_write.py +248 -0
  51. execsql/metacommands/prompt.py +22 -66
  52. execsql/metacommands/system.py +7 -2
  53. execsql/models.py +7 -0
  54. execsql/parser.py +10 -0
  55. execsql/py.typed +0 -0
  56. execsql/script/__init__.py +95 -0
  57. execsql/script/control.py +162 -0
  58. execsql/{script.py → script/engine.py} +184 -402
  59. execsql/script/variables.py +281 -0
  60. execsql/types.py +49 -20
  61. execsql/utils/auth.py +2 -0
  62. execsql/utils/crypto.py +4 -6
  63. execsql/utils/datetime.py +1 -0
  64. execsql/utils/errors.py +11 -0
  65. execsql/utils/fileio.py +33 -8
  66. execsql/utils/gui.py +46 -0
  67. execsql/utils/mail.py +7 -17
  68. execsql/utils/numeric.py +2 -0
  69. execsql/utils/regex.py +9 -0
  70. execsql/utils/strings.py +16 -0
  71. execsql/utils/timer.py +2 -0
  72. execsql2-2.4.0.data/data/execsql2_extras/README.md +65 -0
  73. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/execsql.conf +1 -1
  74. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/METADATA +13 -6
  75. execsql2-2.4.0.dist-info/RECORD +108 -0
  76. execsql2-2.1.2.data/data/execsql2_extras/READ_ME.rst +0 -127
  77. execsql2-2.1.2.dist-info/RECORD +0 -96
  78. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  79. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  80. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  81. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  82. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  83. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  84. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  85. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  86. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  87. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/script_template.sql +0 -0
  88. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  89. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  90. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  91. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/WHEEL +0 -0
  92. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/entry_points.txt +0 -0
  93. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/licenses/LICENSE.txt +0 -0
  94. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/licenses/NOTICE +0 -0
execsql/db/sqlite.py CHANGED
@@ -18,11 +18,14 @@ from execsql.exceptions import ErrInfo
18
18
  from execsql.utils.errors import exception_desc, fatal_error
19
19
  import execsql.state as _state
20
20
 
21
+ __all__ = ["SQLiteDatabase"]
21
22
 
22
23
  DEFAULT_CONNECT_TIMEOUT = 30 # seconds
23
24
 
24
25
 
25
26
  class SQLiteDatabase(Database):
27
+ """SQLite adapter using the Python standard-library sqlite3 module."""
28
+
26
29
  def __init__(self, SQLite_fn: str, timeout: float = DEFAULT_CONNECT_TIMEOUT) -> None:
27
30
  try:
28
31
  import sqlite3 # noqa: F401
@@ -54,12 +57,12 @@ class SQLiteDatabase(Database):
54
57
  self.conn = sqlite3.connect(self.db_name, timeout=self.timeout)
55
58
  except ErrInfo:
56
59
  raise
57
- except Exception:
60
+ except Exception as e:
58
61
  raise ErrInfo(
59
62
  type="exception",
60
63
  exception_msg=exception_desc(),
61
64
  other_msg=f"Can't open SQLite database {self.db_name}",
62
- )
65
+ ) from e
63
66
  pragma_cols, pragma_data = self.select_data("pragma encoding;")
64
67
  self.encoding = pragma_data[0][0]
65
68
 
@@ -82,14 +85,14 @@ class SQLiteDatabase(Database):
82
85
  curs.execute(sql, (table_name,))
83
86
  except ErrInfo:
84
87
  raise
85
- except Exception:
88
+ except Exception as e:
86
89
  self.rollback()
87
90
  raise ErrInfo(
88
91
  type="db",
89
92
  command_text=sql,
90
93
  exception_msg=exception_desc(),
91
94
  other_msg=f'Failed test for existence of SQLite table "{table_name}";',
92
- )
95
+ ) from e
93
96
  rows = curs.fetchall()
94
97
  return len(rows) > 0
95
98
 
@@ -110,14 +113,14 @@ class SQLiteDatabase(Database):
110
113
  curs.execute(sql)
111
114
  except ErrInfo:
112
115
  raise
113
- except Exception:
116
+ except Exception as e:
114
117
  self.rollback()
115
118
  raise ErrInfo(
116
119
  type="db",
117
120
  command_text=sql,
118
121
  exception_msg=exception_desc(),
119
122
  other_msg=f"Failed to get column names for table {table_name} of {self.name()}",
120
- )
123
+ ) from e
121
124
  return [d[0] for d in curs.description]
122
125
 
123
126
  def view_exists(self, view_name: str) -> bool:
@@ -127,14 +130,14 @@ class SQLiteDatabase(Database):
127
130
  curs.execute(sql, (view_name,))
128
131
  except ErrInfo:
129
132
  raise
130
- except Exception:
133
+ except Exception as e:
131
134
  self.rollback()
132
135
  raise ErrInfo(
133
136
  type="db",
134
137
  command_text=sql,
135
138
  exception_msg=exception_desc(),
136
139
  other_msg=f'Failed test for existence of SQLite view "{view_name}";',
137
- )
140
+ ) from e
138
141
  rows = curs.fetchall()
139
142
  return len(rows) > 0
140
143
 
@@ -204,14 +207,14 @@ class SQLiteDatabase(Database):
204
207
  curs.execute(sql, linedata)
205
208
  except ErrInfo:
206
209
  raise
207
- except Exception:
210
+ except Exception as e:
208
211
  self.rollback()
209
212
  raise ErrInfo(
210
213
  type="db",
211
214
  command_text=sql,
212
215
  exception_msg=exception_desc(),
213
216
  other_msg=f"Can't load data into table {sq_name} from line {{{line}}}",
214
- )
217
+ ) from e
215
218
  total_rows += 1
216
219
  interval = getattr(_state.conf, "import_progress_interval", 0)
217
220
  if _state.exec_log and interval > 0 and total_rows % interval == 0:
@@ -235,5 +238,6 @@ class SQLiteDatabase(Database):
235
238
  with open(file_name, "rb") as f:
236
239
  filedata = f.read()
237
240
  sq_name = self.schema_qualified_table_name(schema_name, table_name)
238
- sql = f"insert into {sq_name} ({column_name}) values ({self.paramsubs(1)});"
241
+ quoted_col = self.quote_identifier(column_name)
242
+ sql = f"insert into {sq_name} ({quoted_col}) values ({self.paramsubs(1)});"
239
243
  self.cursor().execute(sql, (sqlite3.Binary(filedata),))
execsql/db/sqlserver.py CHANGED
@@ -7,6 +7,7 @@ Implements :class:`SqlServerDatabase`, which connects to Microsoft SQL
7
7
  Server via ``pyodbc``. Corresponds to ``-t s`` on the CLI.
8
8
  """
9
9
 
10
+ import re
10
11
 
11
12
  from execsql.db.base import Database
12
13
  from execsql.exceptions import ErrInfo
@@ -14,8 +15,12 @@ from execsql.utils.errors import fatal_error
14
15
  from execsql.utils.auth import clear_stored_password, get_password, password_from_keyring
15
16
  import execsql.state as _state
16
17
 
18
+ __all__ = ["SqlServerDatabase"]
19
+
17
20
 
18
21
  class SqlServerDatabase(Database):
22
+ """Microsoft SQL Server adapter using pyodbc, trying drivers from newest to oldest."""
23
+
19
24
  def __init__(
20
25
  self,
21
26
  server_name: str,
@@ -96,9 +101,13 @@ class SqlServerDatabase(Database):
96
101
  try:
97
102
  self.conn = pyodbc.connect(connstr)
98
103
  except Exception:
99
- _state.exec_log.log_status_info(f"Could not connect using: {connstr}")
104
+ _state.exec_log.log_status_info(
105
+ f"Could not connect using: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
106
+ )
100
107
  else:
101
- _state.exec_log.log_status_info(f"Connected using: {connstr}")
108
+ _state.exec_log.log_status_info(
109
+ f"Connected using: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
110
+ )
102
111
  return True
103
112
  return False
104
113
 
@@ -141,7 +150,7 @@ class SqlServerDatabase(Database):
141
150
 
142
151
  def schema_exists(self, schema_name: str) -> bool:
143
152
  curs = self.cursor()
144
- curs.execute(f"select * from sys.schemas where name = '{schema_name}';")
153
+ curs.execute("select * from sys.schemas where name = ?;", (schema_name,))
145
154
  rows = curs.fetchall()
146
155
  curs.close()
147
156
  return len(rows) > 0
@@ -149,7 +158,8 @@ class SqlServerDatabase(Database):
149
158
  def role_exists(self, rolename: str) -> bool:
150
159
  curs = self.cursor()
151
160
  curs.execute(
152
- f"select name from sys.database_principals where type in ('R', 'S') and name = '{rolename}';",
161
+ "select name from sys.database_principals where type in ('R', 'S') and name = ?;",
162
+ (rolename,),
153
163
  )
154
164
  rows = curs.fetchall()
155
165
  curs.close()
@@ -172,5 +182,6 @@ class SqlServerDatabase(Database):
172
182
  with open(file_name, "rb") as f:
173
183
  filedata = f.read()
174
184
  sq_name = self.schema_qualified_table_name(schema_name, table_name)
175
- sql = f"insert into {sq_name} ({column_name}) values ({self.paramsubs(1)});"
185
+ quoted_col = self.quote_identifier(column_name)
186
+ sql = f"insert into {sq_name} ({quoted_col}) values ({self.paramsubs(1)});"
176
187
  self.cursor().execute(sql, (pyodbc.Binary(filedata),))
execsql/exceptions.py CHANGED
@@ -21,6 +21,24 @@ from a single location. Notable exceptions:
21
21
  - :class:`CondParserError` / :class:`NumericParserError` — parser failures.
22
22
  """
23
23
 
24
+ __all__ = [
25
+ "ExecSqlError",
26
+ "ConfigError",
27
+ "ExecSqlTimeoutError",
28
+ "ErrInfo",
29
+ "DataTypeError",
30
+ "DbTypeError",
31
+ "ColumnError",
32
+ "DataTableError",
33
+ "DatabaseNotImplementedError",
34
+ "OdsFileError",
35
+ "XlsFileError",
36
+ "XlsxFileError",
37
+ "ConsoleUIError",
38
+ "CondParserError",
39
+ "NumericParserError",
40
+ ]
41
+
24
42
 
25
43
  class ExecSqlError(Exception):
26
44
  """Base class for simple single-message execsql exceptions.
@@ -58,7 +76,7 @@ class ExecSqlTimeoutError(ExecSqlError):
58
76
  super().__init__(errmsg)
59
77
 
60
78
 
61
- class ErrInfo(Exception):
79
+ class ErrInfo(ExecSqlError):
62
80
  """Rich exception and error-data carrier for execsql.
63
81
 
64
82
  ``str(e)`` returns the most informative available message (``other_msg``,
@@ -152,11 +170,11 @@ class ErrInfo(Exception):
152
170
  return self.eval_err()
153
171
 
154
172
 
155
- class DataTypeError(Exception):
173
+ class DataTypeError(ExecSqlError):
156
174
  def __init__(self, data_type_name: str, error_msg: str) -> None:
157
175
  self.data_type_name = data_type_name or "Unspecified data type"
158
176
  self.error_msg = error_msg or "Unspecified error"
159
- super().__init__(str(self))
177
+ Exception.__init__(self, str(self))
160
178
 
161
179
  def __repr__(self) -> str:
162
180
  return f"DataTypeError({self.data_type_name!r}, {self.error_msg!r})"
@@ -165,12 +183,12 @@ class DataTypeError(Exception):
165
183
  return f"{self.data_type_name}: {self.error_msg}"
166
184
 
167
185
 
168
- class DbTypeError(Exception):
186
+ class DbTypeError(ExecSqlError):
169
187
  def __init__(self, dbms_id: str, data_type: object, error_msg: str) -> None:
170
188
  self.dbms_id = dbms_id
171
189
  self.data_type = data_type
172
190
  self.error_msg = error_msg or "Unspecified error"
173
- super().__init__(str(self))
191
+ Exception.__init__(self, str(self))
174
192
 
175
193
  def __repr__(self) -> str:
176
194
  return f"DbTypeError({self.dbms_id!r}, {self.data_type!r}, {self.error_msg!r})"
@@ -190,11 +208,11 @@ class DataTableError(ExecSqlError):
190
208
  """Raised for DataTable-level errors."""
191
209
 
192
210
 
193
- class DatabaseNotImplementedError(Exception):
211
+ class DatabaseNotImplementedError(ExecSqlError):
194
212
  def __init__(self, db_name: str, method: str) -> None:
195
213
  self.db_name = db_name
196
214
  self.method = method
197
- super().__init__(str(self))
215
+ Exception.__init__(self, str(self))
198
216
 
199
217
  def __repr__(self) -> str:
200
218
  return f"DatabaseNotImplementedError({self.db_name!r}, {self.method!r})"
execsql/exporters/base.py CHANGED
@@ -22,6 +22,8 @@ from execsql.script import current_script_line
22
22
  from execsql.utils.errors import file_size_date
23
23
  from execsql.utils.gui import ConsoleUIError
24
24
 
25
+ __all__ = ["WriteSpec", "ExportRecord", "ExportMetadata"]
26
+
25
27
 
26
28
  class ExportRecord:
27
29
  """Records the details of a single EXPORT operation for metadata tracking.
@@ -88,15 +90,18 @@ class ExportMetadata:
88
90
  self.recordlist: list[ExportRecord] = []
89
91
 
90
92
  def add(self, exp_record: ExportRecord) -> None:
93
+ """Append an export record to the collection."""
91
94
  self.recordlist.append(exp_record)
92
95
 
93
96
  def get(self):
97
+ """Return column headers and all not-yet-exported records, marking them exported."""
94
98
  recs = [er.record for er in self.recordlist if not er.exported]
95
99
  for er in self.recordlist:
96
100
  er.exported = True
97
101
  return self.colhdrs, recs
98
102
 
99
103
  def get_all(self):
104
+ """Return column headers and every record regardless of prior export state."""
100
105
  recs = [er.record for er in self.recordlist]
101
106
  for er in self.recordlist:
102
107
  er.exported = True
@@ -130,6 +135,7 @@ class WriteSpec:
130
135
  self.written = False
131
136
 
132
137
  def write(self) -> None:
138
+ """Execute the deferred write, expanding substitution variables first."""
133
139
  # Writes the message per the specifications given to '__init__()'. Substitution
134
140
  # variables are processed.
135
141
  # Inputs: no inputs.
@@ -143,7 +149,12 @@ class WriteSpec:
143
149
  if self.outfile:
144
150
  from execsql.utils.fileio import EncodedFile
145
151
 
146
- EncodedFile(self.outfile, conf.output_encoding).open("a").write(msg)
152
+ ef = EncodedFile(self.outfile, conf.output_encoding)
153
+ fh = ef.open("a")
154
+ try:
155
+ fh.write(msg)
156
+ finally:
157
+ fh.close()
147
158
  if (not self.outfile) or self.tee:
148
159
  try:
149
160
  _state.output.write(msg.encode(conf.output_encoding))
@@ -29,9 +29,14 @@ from execsql.utils.errors import exception_desc
29
29
  from execsql.utils.fileio import filewriter_close
30
30
  from execsql.utils.strings import clean_words, fold_words
31
31
 
32
+ __all__ = ["LineDelimiter", "CsvFile", "CsvWriter", "DelimitedWriter", "write_delimited_file"]
33
+
32
34
 
33
35
  class LineDelimiter:
36
+ """Encapsulates delimiter, quote character, and escape rules for a single line format."""
37
+
34
38
  def __init__(self, delim: str | None, quote: str | None, escchar: str | None) -> None:
39
+ """Initialise the line-format constants for a given delimiter/quote pair."""
35
40
  self.delimiter = delim
36
41
  self.joinchar = delim if delim else ""
37
42
  self.quotechar = quote
@@ -44,6 +49,7 @@ class LineDelimiter:
44
49
  self.quotedquote = None
45
50
 
46
51
  def delimited(self, datarow: Any, add_newline: bool = True) -> str:
52
+ """Format a sequence of values as a single delimited text line."""
47
53
  conf = _state.conf
48
54
  if self.quotechar:
49
55
  d_row = []
@@ -79,22 +85,30 @@ class LineDelimiter:
79
85
 
80
86
 
81
87
  class DelimitedWriter:
88
+ """Low-level writer that formats data rows as delimited text and sends them to an open file object."""
89
+
82
90
  def __init__(self, outfile: Any, delim: str | None, quote: str | None, escchar: str | None) -> None:
91
+ """Wrap an open file object with a :class:`LineDelimiter` for row-by-row writing."""
83
92
  self.outfile = outfile
84
93
  self.line_delimiter = LineDelimiter(delim, quote, escchar)
85
94
 
86
95
  def write(self, text_str: str) -> None:
96
+ """Write a raw string directly to the underlying file object."""
87
97
  self.outfile.write(text_str)
88
98
 
89
99
  def writerow(self, datarow: Any) -> None:
100
+ """Format one data row as delimited text and write it to the file."""
90
101
  self.outfile.write(self.line_delimiter.delimited(datarow))
91
102
 
92
103
  def writerows(self, datarows: Any) -> None:
104
+ """Write each row in an iterable of data rows to the file."""
93
105
  for row in datarows:
94
106
  self.writerow(row)
95
107
 
96
108
 
97
109
  class CsvWriter:
110
+ """Opens a named file (or stdout) and exposes a row-oriented delimited-text writing API."""
111
+
98
112
  def __init__(
99
113
  self,
100
114
  filename: str,
@@ -113,21 +127,32 @@ class CsvWriter:
113
127
  self.dwriter = DelimitedWriter(self.output, delim, quote, escchar)
114
128
 
115
129
  def write(self, text_str: str) -> None:
130
+ """Write a raw string to the output file."""
116
131
  self.dwriter.write(text_str)
117
132
 
118
133
  def writerow(self, datarow: Any) -> None:
134
+ """Format and write a single data row."""
119
135
  self.dwriter.writerow(datarow)
120
136
 
121
137
  def writerows(self, datarows: Any) -> None:
138
+ """Format and write each row in an iterable."""
122
139
  self.dwriter.writerows(datarows)
123
140
 
124
141
  def close(self) -> None:
142
+ """Close the underlying output file."""
125
143
  self.output.close()
126
144
  self.output = None
127
145
 
128
146
 
129
147
  class CsvFile(EncodedFile):
148
+ """Full delimited-file reader/writer with automatic format diagnosis.
149
+
150
+ Supports custom delimiters, quoting, encoding, junk-header skipping,
151
+ column-type inference, and ZIP output via :class:`CsvWriter`.
152
+ """
153
+
130
154
  def __init__(self, csvfname: str, file_encoding: str, junk_header_lines: int = 0) -> None:
155
+ """Open a CSV file path for later reading or writing."""
131
156
  super().__init__(csvfname, file_encoding)
132
157
  self.csvfname = csvfname
133
158
  self.junk_header_lines = junk_header_lines
@@ -143,6 +168,7 @@ class CsvFile(EncodedFile):
143
168
  return f"CsvFile({self.csvfname!r}, {self.encoding!r})"
144
169
 
145
170
  def openclean(self, mode: str) -> Any:
171
+ """Return an opened file object with the configured number of junk header lines skipped."""
146
172
  # Returns an opened file object with junk headers stripped.
147
173
  f = self.open(mode)
148
174
  for _ in range(self.junk_header_lines):
@@ -150,6 +176,7 @@ class CsvFile(EncodedFile):
150
176
  return f
151
177
 
152
178
  def lineformat(self, delimiter: str | None, quotechar: str | None, escapechar: str | None) -> None:
179
+ """Explicitly set the delimiter, quote character, and escape character."""
153
180
  # Specifies the format of a line.
154
181
  self.delimiter = delimiter
155
182
  self.quotechar = quotechar
@@ -413,15 +440,21 @@ class CsvFile(EncodedFile):
413
440
  )
414
441
 
415
442
  def evaluate_line_format(self) -> None:
443
+ """Scan the file to auto-detect the delimiter, quote character, and escape character."""
416
444
  # Scans the file to determine the delimiter, quote character, and escapechar.
417
445
  if not self.lineformat_set:
418
- self.delimiter, self.quotechar, self.escapechar = self.diagnose_delim(self.openclean("rt"))
446
+ f = self.openclean("rt")
447
+ try:
448
+ self.delimiter, self.quotechar, self.escapechar = self.diagnose_delim(f)
449
+ finally:
450
+ f.close()
419
451
  self.lineformat_set = True
420
452
 
421
453
  def _record_format_error(self, pos_no: int, errmsg: str) -> None:
422
454
  self.parse_errors.append(f"{errmsg} in position {pos_no}")
423
455
 
424
456
  def read_and_parse_line(self, f: Any) -> list:
457
+ """Read and parse one line from an open file, returning a list of field values."""
425
458
  # Returns a list of line elements, parsed according to the established delimiter and quotechar.
426
459
  elements = []
427
460
  eat_multiple_delims = self.delimiter == " "
@@ -567,30 +600,34 @@ class CsvFile(EncodedFile):
567
600
  return elements
568
601
 
569
602
  def reader(self) -> Any:
603
+ """Yield parsed rows from the file as lists of field values."""
570
604
  conf = _state.conf
571
605
  self.evaluate_line_format()
572
606
  f = self.openclean("rt")
573
607
  line_no = 0
574
- while True:
575
- line_no += 1
576
- try:
577
- elements = self.read_and_parse_line(f)
578
- except ErrInfo as e:
579
- raise ErrInfo("error", other_msg=f"{e.other} on line {line_no}.") from e
580
- except:
581
- raise
582
- if len(elements) > 0:
583
- if conf.del_empty_cols and len(self.blank_cols) > 0:
584
- blanks = copy.copy(self.blank_cols)
585
- while len(blanks) > 0:
586
- b = blanks.pop()
587
- del elements[b]
588
- yield elements
589
- else:
590
- break
591
- f.close()
608
+ try:
609
+ while True:
610
+ line_no += 1
611
+ try:
612
+ elements = self.read_and_parse_line(f)
613
+ except ErrInfo as e:
614
+ raise ErrInfo("error", other_msg=f"{e.other} on line {line_no}.") from e
615
+ except:
616
+ raise
617
+ if len(elements) > 0:
618
+ if conf.del_empty_cols and len(self.blank_cols) > 0:
619
+ blanks = copy.copy(self.blank_cols)
620
+ while len(blanks) > 0:
621
+ b = blanks.pop()
622
+ del elements[b]
623
+ yield elements
624
+ else:
625
+ break
626
+ finally:
627
+ f.close()
592
628
 
593
629
  def writer(self, append: bool = False) -> CsvWriter:
630
+ """Return a :class:`CsvWriter` configured with this file's format settings."""
594
631
  return CsvWriter(self.filename, self.encoding, self.delimiter, self.quotechar, self.escapechar, append)
595
632
 
596
633
  def _colhdrs(self, inf: Any) -> list[str]:
@@ -600,12 +637,12 @@ class CsvFile(EncodedFile):
600
637
  except ErrInfo as e:
601
638
  e.other = f"Can't read column header line from {self.filename}. {e.other or ''}"
602
639
  raise
603
- except Exception:
640
+ except Exception as e:
604
641
  raise ErrInfo(
605
642
  type="exception",
606
643
  exception_msg=exception_desc(),
607
644
  other_msg=f"Can't read column header line from {self.filename}",
608
- )
645
+ ) from e
609
646
  if any(x is None or len(x) == 0 for x in colnames):
610
647
  if conf.del_empty_cols:
611
648
  self.blank_cols = [
@@ -634,17 +671,20 @@ class CsvFile(EncodedFile):
634
671
  return colnames
635
672
 
636
673
  def column_headers(self) -> list[str]:
674
+ """Return the first row of the file as a list of column header strings."""
637
675
  if not self.lineformat_set:
638
676
  self.evaluate_line_format()
639
677
  inf = self.reader()
640
678
  return self._colhdrs(inf)
641
679
 
642
680
  def data_table_def(self) -> Any:
681
+ """Return a :class:`DataTable` describing the file's columns and types."""
643
682
  if self.table_data is None:
644
683
  self.evaluate_column_types()
645
684
  return self.table_data
646
685
 
647
686
  def evaluate_column_types(self) -> None:
687
+ """Scan the file and populate ``table_data`` with inferred column types."""
648
688
  if not self.lineformat_set:
649
689
  self.evaluate_line_format()
650
690
  inf = self.reader()
@@ -652,6 +692,7 @@ class CsvFile(EncodedFile):
652
692
  self.table_data = DataTable(colnames, inf)
653
693
 
654
694
  def create_table(self, database_type: Any, schemaname: str | None, tablename: str, pretty: bool = False) -> str:
695
+ """Generate a CREATE TABLE SQL statement for this file's inferred schema."""
655
696
  return self.table_data.create_table(database_type, schemaname, tablename, pretty)
656
697
 
657
698
 
@@ -664,6 +705,7 @@ def write_delimited_file(
664
705
  append: bool = False,
665
706
  zipfile: str | None = None,
666
707
  ) -> None:
708
+ """Write a query result set to a CSV, TSV, or other delimited text file."""
667
709
  delim = None
668
710
  quote = None
669
711
  escchar = None
@@ -700,18 +742,21 @@ def write_delimited_file(
700
742
  filewriter_close(outfile)
701
743
  ofile = EncodedFile(outfile, file_encoding).open(mode=fmode)
702
744
  fdesc = outfile
703
- if not (filefmt.lower() == "plain" or (append and zipfile is None)):
704
- datarow = line_delimiter.delimited(column_headers)
705
- ofile.write(datarow)
706
- for rec in rowsource:
707
- try:
708
- datarow = line_delimiter.delimited(rec)
745
+ try:
746
+ if not (filefmt.lower() == "plain" or (append and zipfile is None)):
747
+ datarow = line_delimiter.delimited(column_headers)
709
748
  ofile.write(datarow)
710
- except ErrInfo:
711
- raise
712
- except Exception:
713
- raise ErrInfo(
714
- "exception",
715
- exception_msg=exception_desc(),
716
- other_msg=f"Can't write output to file {fdesc}.",
717
- )
749
+ for rec in rowsource:
750
+ try:
751
+ datarow = line_delimiter.delimited(rec)
752
+ ofile.write(datarow)
753
+ except ErrInfo:
754
+ raise
755
+ except Exception as e:
756
+ raise ErrInfo(
757
+ "exception",
758
+ exception_msg=exception_desc(),
759
+ other_msg=f"Can't write output to file {fdesc}.",
760
+ ) from e
761
+ finally:
762
+ ofile.close()
@@ -15,6 +15,8 @@ from typing import Any
15
15
  from execsql.exceptions import ErrInfo
16
16
  from execsql.types import dbt_duckdb
17
17
 
18
+ __all__ = ["export_duckdb", "write_query_to_duckdb"]
19
+
18
20
 
19
21
  def export_duckdb(
20
22
  outfile: str,
@@ -23,6 +25,7 @@ def export_duckdb(
23
25
  append: bool,
24
26
  tablename: str,
25
27
  ) -> None:
28
+ """Write pre-fetched rows to a named table in a DuckDB database file."""
26
29
  try:
27
30
  import duckdb
28
31
  except Exception:
@@ -79,12 +82,13 @@ def write_query_to_duckdb(
79
82
  append: bool,
80
83
  tablename: str,
81
84
  ) -> None:
85
+ """Execute a SELECT and write the result set to a named table in a DuckDB database."""
82
86
  from execsql.utils.errors import exception_desc
83
87
 
84
88
  try:
85
89
  hdrs, rows = db.select_rowsource(select_stmt)
86
90
  except ErrInfo:
87
91
  raise
88
- except Exception:
89
- raise ErrInfo("db", select_stmt, exception_msg=exception_desc())
92
+ except Exception as e:
93
+ raise ErrInfo("db", select_stmt, exception_msg=exception_desc()) from e
90
94
  export_duckdb(outfile, hdrs, rows, append, tablename)
@@ -18,16 +18,19 @@ from execsql.types import DT_Boolean, DT_Date, DT_Timestamp, DT_TimestampTZ
18
18
  from execsql.utils.errors import exception_desc
19
19
  from execsql.utils.fileio import filewriter_close
20
20
 
21
+ __all__ = ["write_query_to_feather", "write_query_to_hdf5"]
22
+
21
23
 
22
24
  def write_query_to_feather(outfile: str, headers: list[str], rows: Any) -> None:
25
+ """Write a row source as an Apache Arrow Feather v2 file using polars."""
23
26
  try:
24
27
  import polars as pl
25
- except ImportError:
28
+ except ImportError as e:
26
29
  raise ErrInfo(
27
30
  "exception",
28
31
  exception_msg=exception_desc(),
29
32
  other_msg="The polars Python package must be installed to export data to the feather format.",
30
- )
33
+ ) from e
31
34
  rows_list = list(rows)
32
35
  if rows_list:
33
36
  df = pl.DataFrame(rows_list, schema=headers, orient="row")
@@ -45,20 +48,21 @@ def write_query_to_hdf5(
45
48
  append: bool = False,
46
49
  desc: str | None = None,
47
50
  ) -> None:
51
+ """Execute a SELECT and write the result set to an HDF5 file using the tables library."""
48
52
  try:
49
53
  import tables
50
- except ImportError:
54
+ except ImportError as e:
51
55
  raise ErrInfo(
52
56
  "exception",
53
57
  exception_msg=exception_desc(),
54
58
  other_msg="The tables Python library must be installed to export data to the HDF5 format.",
55
- )
59
+ ) from e
56
60
  try:
57
61
  hdrs, rows = db.select_rowsource(select_stmt)
58
62
  except ErrInfo:
59
63
  raise
60
- except Exception:
61
- raise ErrInfo("db", select_stmt, exception_msg=exception_desc())
64
+ except Exception as e:
65
+ raise ErrInfo("db", select_stmt, exception_msg=exception_desc()) from e
62
66
 
63
67
  def h5type(datatype, size):
64
68
  if datatype in (_state.DT_Varchar, _state.DT_Text):