execsql2 2.0.1__py3-none-any.whl → 2.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. execsql/cli.py +322 -108
  2. execsql/config.py +134 -114
  3. execsql/db/access.py +89 -65
  4. execsql/db/base.py +97 -68
  5. execsql/db/dsn.py +45 -29
  6. execsql/db/duckdb.py +4 -5
  7. execsql/db/factory.py +27 -27
  8. execsql/db/firebird.py +30 -18
  9. execsql/db/mysql.py +38 -14
  10. execsql/db/oracle.py +58 -33
  11. execsql/db/postgres.py +68 -28
  12. execsql/db/sqlite.py +36 -27
  13. execsql/db/sqlserver.py +45 -30
  14. execsql/exceptions.py +68 -64
  15. execsql/exporters/__init__.py +1 -1
  16. execsql/exporters/base.py +42 -17
  17. execsql/exporters/delimited.py +60 -59
  18. execsql/exporters/duckdb.py +8 -12
  19. execsql/exporters/feather.py +32 -24
  20. execsql/exporters/html.py +33 -30
  21. execsql/exporters/json.py +18 -17
  22. execsql/exporters/latex.py +11 -13
  23. execsql/exporters/ods.py +50 -46
  24. execsql/exporters/parquet.py +32 -0
  25. execsql/exporters/pretty.py +16 -15
  26. execsql/exporters/raw.py +9 -11
  27. execsql/exporters/sqlite.py +38 -38
  28. execsql/exporters/templates.py +15 -72
  29. execsql/exporters/values.py +13 -12
  30. execsql/exporters/xls.py +26 -26
  31. execsql/exporters/xml.py +12 -12
  32. execsql/exporters/zip.py +0 -3
  33. execsql/gui/__init__.py +2 -2
  34. execsql/gui/console.py +0 -1
  35. execsql/gui/desktop.py +6 -7
  36. execsql/gui/tui.py +8 -14
  37. execsql/importers/base.py +6 -9
  38. execsql/importers/csv.py +10 -17
  39. execsql/importers/feather.py +16 -22
  40. execsql/importers/ods.py +3 -4
  41. execsql/importers/xls.py +5 -6
  42. execsql/metacommands/__init__.py +8 -8
  43. execsql/metacommands/conditions.py +41 -33
  44. execsql/metacommands/connect.py +113 -99
  45. execsql/metacommands/control.py +38 -26
  46. execsql/metacommands/data.py +35 -33
  47. execsql/metacommands/debug.py +13 -9
  48. execsql/metacommands/io.py +288 -229
  49. execsql/metacommands/prompt.py +179 -157
  50. execsql/metacommands/script_ext.py +11 -9
  51. execsql/metacommands/system.py +44 -25
  52. execsql/models.py +9 -16
  53. execsql/parser.py +10 -10
  54. execsql/script.py +183 -157
  55. execsql/state.py +170 -208
  56. execsql/types.py +46 -81
  57. execsql/utils/auth.py +114 -14
  58. execsql/utils/crypto.py +31 -4
  59. execsql/utils/datetime.py +7 -7
  60. execsql/utils/errors.py +34 -29
  61. execsql/utils/fileio.py +90 -55
  62. execsql/utils/gui.py +22 -23
  63. execsql/utils/mail.py +15 -17
  64. execsql/utils/numeric.py +2 -3
  65. execsql/utils/regex.py +9 -12
  66. execsql/utils/strings.py +10 -12
  67. execsql/utils/timer.py +0 -2
  68. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/execsql.conf +1 -1
  69. execsql2-2.1.2.dist-info/METADATA +300 -0
  70. execsql2-2.1.2.dist-info/RECORD +96 -0
  71. execsql2-2.0.1.dist-info/METADATA +0 -406
  72. execsql2-2.0.1.dist-info/RECORD +0 -95
  73. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/READ_ME.rst +0 -0
  74. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  75. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  76. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/make_config_db.sql +0 -0
  77. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/md_compare.sql +0 -0
  78. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/md_glossary.sql +0 -0
  79. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/md_upsert.sql +0 -0
  80. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/pg_compare.sql +0 -0
  81. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  82. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  83. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/script_template.sql +0 -0
  84. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/ss_compare.sql +0 -0
  85. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  86. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  87. {execsql2-2.0.1.dist-info → execsql2-2.1.2.dist-info}/WHEEL +0 -0
  88. {execsql2-2.0.1.dist-info → execsql2-2.1.2.dist-info}/entry_points.txt +0 -0
  89. {execsql2-2.0.1.dist-info → execsql2-2.1.2.dist-info}/licenses/LICENSE.txt +0 -0
  90. {execsql2-2.0.1.dist-info → execsql2-2.1.2.dist-info}/licenses/NOTICE +0 -0
execsql/types.py CHANGED
@@ -84,7 +84,7 @@ class DataType:
84
84
  # This method may be overridden in child classes.
85
85
  if data is None:
86
86
  raise DataTypeError(self.data_type_name, self._CONV_ERR % "NULL")
87
- if type(data) == self.data_type:
87
+ if type(data) is self.data_type:
88
88
  return data
89
89
  try:
90
90
  i = self.data_type(data)
@@ -115,10 +115,8 @@ class DT_TimestampTZ(DataType):
115
115
  def _is_match(self, data: object) -> bool:
116
116
  if data is None:
117
117
  return False
118
- if type(data) == datetime.datetime:
119
- if data.tzinfo is not None and data.tzinfo.utcoffset(data) is not None:
120
- return True
121
- return False
118
+ if isinstance(data, datetime.datetime):
119
+ return bool(data.tzinfo is not None and data.tzinfo.utcoffset(data) is not None)
122
120
  if not isinstance(data, str):
123
121
  return False
124
122
  try:
@@ -183,17 +181,20 @@ class DT_Date(DataType):
183
181
  data_type_name = "date"
184
182
  data_type = datetime.date
185
183
 
184
+ def __init__(self) -> None:
185
+ self._date_fmts = collections.deque(date_fmts)
186
+
186
187
  def __repr__(self) -> str:
187
188
  return "DT_Date()"
188
189
 
189
190
  def _from_data(self, data: object) -> object:
190
191
  if data is None:
191
192
  raise DataTypeError(self.data_type_name, self._CONV_ERR % "NULL")
192
- if type(data) == self.data_type:
193
+ if type(data) is self.data_type:
193
194
  return data
194
195
  if not isinstance(data, str):
195
196
  raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
196
- for i, f in enumerate(date_fmts):
197
+ for i, f in enumerate(self._date_fmts): # noqa: B007
197
198
  try:
198
199
  dt = datetime.datetime.strptime(data, f)
199
200
  dtt = datetime.date(dt.year, dt.month, dt.day)
@@ -203,8 +204,8 @@ class DT_Date(DataType):
203
204
  else:
204
205
  raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
205
206
  if i:
206
- del date_fmts[i]
207
- date_fmts.appendleft(f)
207
+ del self._date_fmts[i]
208
+ self._date_fmts.appendleft(f)
208
209
  return dtt
209
210
 
210
211
 
@@ -232,9 +233,9 @@ class DT_Time(DataType):
232
233
  def _from_data(self, data: object) -> object:
233
234
  if data is None:
234
235
  raise DataTypeError(self.data_type_name, self._CONV_ERR % "NULL")
235
- if type(data) == self.data_type:
236
+ if type(data) is self.data_type:
236
237
  return data
237
- if type(data) == datetime.datetime:
238
+ if isinstance(data, datetime.datetime):
238
239
  return datetime.time(data.hour, data.minute, data.second, data.microsecond)
239
240
  if not isinstance(data, str):
240
241
  raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
@@ -250,48 +251,11 @@ class DT_Time(DataType):
250
251
  return t
251
252
 
252
253
 
253
- class DT_Time_Oracle(DataType):
254
- data_type_name = "time"
255
- data_type = datetime.time
254
+ class DT_Time_Oracle(DT_Time):
255
+ """Oracle-specific time type stored as VARCHAR2 with length specification."""
256
+
256
257
  lenspec = True
257
258
  varlen = True
258
- time_fmts = (
259
- "%H:%M",
260
- "%H%M:%S",
261
- "%H%M:%S.%f",
262
- "%H:%M:%S",
263
- "%H:%M:%S.%f",
264
- "%I:%M%p",
265
- "%I:%M:%S%p",
266
- "%I:%M:%S.%f%p",
267
- "%I:%M %p",
268
- "%I:%M:%S %p",
269
- "%I:%M:%S.%f %p",
270
- "%X",
271
- )
272
-
273
- def __repr__(self) -> str:
274
- return "DT_Time()"
275
-
276
- def _from_data(self, data: object) -> object:
277
- if data is None:
278
- raise DataTypeError(self.data_type_name, self._CONV_ERR % "NULL")
279
- if type(data) == self.data_type:
280
- return data
281
- if type(data) == datetime.datetime:
282
- return datetime.time(data.hour, data.minute, data.second, data.microsecond)
283
- if not isinstance(data, str):
284
- raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
285
- for f in self.time_fmts:
286
- try:
287
- dt = datetime.datetime.strptime(data, f)
288
- t = datetime.time(dt.hour, dt.minute, dt.second, dt.microsecond)
289
- except Exception:
290
- continue
291
- break
292
- else:
293
- raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
294
- return t
295
259
 
296
260
 
297
261
  class DT_Boolean(DataType):
@@ -322,13 +286,14 @@ class DT_Boolean(DataType):
322
286
  if data is None:
323
287
  return False
324
288
  self.set_bool_matches()
325
- if type(data) == bool:
326
- return True
327
- elif conf.boolean_int and type(data) == int and data in (0, 1):
328
- return True
329
- elif isinstance(data, str) and data.lower() in self.bool_repr:
330
- return True
331
- return False
289
+ return bool(
290
+ isinstance(data, bool)
291
+ or conf.boolean_int
292
+ and type(data) is int
293
+ and data in (0, 1)
294
+ or isinstance(data, str)
295
+ and data.lower() in self.bool_repr,
296
+ )
332
297
 
333
298
  def _from_data(self, data: object) -> object:
334
299
  import execsql.state as _state
@@ -337,9 +302,9 @@ class DT_Boolean(DataType):
337
302
  if data is None:
338
303
  raise DataTypeError(self.data_type_name, self._CONV_ERR % "NULL")
339
304
  self.set_bool_matches()
340
- if type(data) == bool:
305
+ if isinstance(data, bool):
341
306
  return data
342
- elif conf.boolean_int and type(data) == int and data in (0, 1):
307
+ elif conf.boolean_int and type(data) is int and data in (0, 1):
343
308
  return data == 1
344
309
  elif isinstance(data, str) and data.lower() in self.bool_repr:
345
310
  return data.lower() in self.true
@@ -358,9 +323,9 @@ class DT_Integer(DataType):
358
323
  import execsql.state as _state
359
324
 
360
325
  conf = _state.conf
361
- if type(data) == int:
326
+ if type(data) is int:
362
327
  return data <= conf.max_int and data >= -1 * conf.max_int - 1
363
- elif type(data) == float:
328
+ elif isinstance(data, float):
364
329
  return False
365
330
  elif isinstance(data, str):
366
331
  if leading_zero_num(data):
@@ -378,9 +343,9 @@ class DT_Integer(DataType):
378
343
  def _from_data(self, data: object) -> object:
379
344
  if data is None:
380
345
  raise DataTypeError(self.data_type_name, self._CONV_ERR % "NULL")
381
- if type(data) == int:
346
+ if type(data) is int:
382
347
  return data
383
- if type(data) == float:
348
+ if isinstance(data, float):
384
349
  if int(data) == data:
385
350
  return int(data)
386
351
  else:
@@ -408,16 +373,16 @@ class DT_Long(DataType):
408
373
 
409
374
  if data is None:
410
375
  raise DataTypeError(self.data_type_name, self._CONV_ERR % "NULL")
411
- if type(data) == int:
376
+ if type(data) is int:
412
377
  return data
413
- if type(data) == float:
378
+ if isinstance(data, float):
414
379
  if _math.isnan(data):
415
380
  return None
416
381
  else:
417
382
  if int(data) == data:
418
383
  return int(data)
419
384
  raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
420
- if type(data) == Decimal:
385
+ if isinstance(data, Decimal):
421
386
  raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
422
387
  if leading_zero_num(data):
423
388
  raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
@@ -440,14 +405,14 @@ class DT_Float(DataType):
440
405
  def _is_match(self, data: object) -> bool:
441
406
  if data is None:
442
407
  return False
443
- if type(data) == float:
408
+ if isinstance(data, float):
444
409
  return True
445
410
  if leading_zero_num(data):
446
411
  return False
447
412
  if isinstance(data, str) and not re.match(r"^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$", data):
448
413
  return False
449
414
  try:
450
- i = float(data)
415
+ float(data)
451
416
  except Exception:
452
417
  return False
453
418
  return True
@@ -455,7 +420,7 @@ class DT_Float(DataType):
455
420
  def _from_data(self, data: object) -> object:
456
421
  if data is None:
457
422
  raise DataTypeError(self.data_type_name, self._CONV_ERR % "NULL")
458
- if type(data) == float:
423
+ if isinstance(data, float):
459
424
  return data
460
425
  if leading_zero_num(data):
461
426
  raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
@@ -491,7 +456,7 @@ class DT_Decimal(DataType):
491
456
  raise DataTypeError(self.data_type_name, self._CONV_ERR % "NULL")
492
457
  if leading_zero_num(data):
493
458
  raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
494
- if type(data) == Decimal:
459
+ if isinstance(data, Decimal):
495
460
  self.set_scale_prec(data)
496
461
  return data
497
462
  elif isinstance(data, str):
@@ -514,9 +479,9 @@ class DT_Character(DataType):
514
479
  return "DT_Character()"
515
480
 
516
481
  def _is_match(self, data: object) -> bool:
517
- if type(data) == bytearray:
482
+ if isinstance(data, bytearray):
518
483
  return False
519
- return super(DT_Character, self)._is_match(data)
484
+ return super()._is_match(data)
520
485
 
521
486
  def _from_data(self, data: object) -> object:
522
487
  # data must be non-null.
@@ -544,9 +509,9 @@ class DT_Varchar(DataType):
544
509
  return "DT_Varchar()"
545
510
 
546
511
  def _is_match(self, data: object) -> bool:
547
- if type(data) == bytearray:
512
+ if isinstance(data, bytearray):
548
513
  return False
549
- return super(DT_Varchar, self)._is_match(data)
514
+ return super()._is_match(data)
550
515
 
551
516
  def _from_data(self, data: object) -> object:
552
517
  # This varchar data type is the same as the character data type.
@@ -570,9 +535,9 @@ class DT_Text(DataType):
570
535
  return "DT_Text()"
571
536
 
572
537
  def _is_match(self, data: object) -> bool:
573
- if type(data) == bytearray:
538
+ if isinstance(data, bytearray):
574
539
  return False
575
- return super(DT_Text, self)._is_match(data)
540
+ return super()._is_match(data)
576
541
 
577
542
  def _from_data(self, data: object) -> object:
578
543
  if data is None:
@@ -724,10 +689,10 @@ dbt_sqlite.name_datatype(DT_Text, "varchar")
724
689
  dbt_sqlite.name_datatype(DT_Binary, "blob")
725
690
 
726
691
  dbt_duckdb = DbType("DuckDB")
727
- dbt_duckdb.name_datatype(DT_TimestampTZ, "TEXT")
728
- dbt_duckdb.name_datatype(DT_Timestamp, "TEXT")
729
- dbt_duckdb.name_datatype(DT_Date, "TEXT")
730
- dbt_duckdb.name_datatype(DT_Time, "TEXT")
692
+ dbt_duckdb.name_datatype(DT_TimestampTZ, "TIMESTAMPTZ")
693
+ dbt_duckdb.name_datatype(DT_Timestamp, "TIMESTAMP")
694
+ dbt_duckdb.name_datatype(DT_Date, "DATE")
695
+ dbt_duckdb.name_datatype(DT_Time, "TIME")
731
696
  dbt_duckdb.name_datatype(DT_Integer, "INTEGER")
732
697
  dbt_duckdb.name_datatype(DT_Long, "BIGINT")
733
698
  dbt_duckdb.name_datatype(DT_Float, "REAL")
execsql/utils/auth.py CHANGED
@@ -8,25 +8,121 @@ password on the terminal (via :func:`getpass.getpass`) or through the
8
8
  GUI console when running in GUI mode. The last entered password is
9
9
  cached in ``_state.upass`` so that re-prompting is suppressed when
10
10
  the same password is needed again within the same session.
11
+
12
+ When the ``keyring`` package is installed, :func:`get_password` checks
13
+ the OS credential store first (macOS Keychain, Windows Credential
14
+ Manager, or Linux SecretService). If a stored password is found it is
15
+ returned without prompting. After a successful interactive prompt the
16
+ password is offered for storage in the keyring for future use (console
17
+ mode only; GUI mode stores silently).
18
+
19
+ If the stored credential turns out to be stale (e.g. after a password
20
+ change), callers should use :func:`password_from_keyring` to detect
21
+ this, call :func:`clear_stored_password` to remove the bad entry, and
22
+ re-prompt with ``skip_keyring=True``.
23
+
24
+ Keyring service names follow the pattern
25
+ ``execsql/<db_type>/<server_or_file>/<database>``.
11
26
  """
12
27
 
13
28
  import getpass
14
- from typing import Optional
15
29
 
16
30
  import execsql.state as _state
17
31
 
32
+ # Tracks whether the most recent get_password() call returned a keyring-stored value.
33
+ _last_from_keyring: bool = False
34
+
35
+
36
+ def _keyring_service(dbms_name: str, database_name: str, server_name: str | None) -> str:
37
+ """Build a keyring service name from connection parameters."""
38
+ server_part = server_name or "local"
39
+ return f"execsql/{dbms_name}/{server_part}/{database_name}"
40
+
41
+
42
+ def _keyring_get(service: str, username: str) -> str | None:
43
+ """Try to retrieve a password from the OS keyring. Returns None on failure."""
44
+ try:
45
+ import keyring
46
+
47
+ return keyring.get_password(service, username)
48
+ except Exception:
49
+ return None
50
+
51
+
52
+ def _keyring_set(service: str, username: str, password: str) -> bool:
53
+ """Try to store a password in the OS keyring. Returns True on success."""
54
+ try:
55
+ import keyring
56
+
57
+ keyring.set_password(service, username, password)
58
+ return True
59
+ except Exception:
60
+ return False
61
+
62
+
63
+ def _keyring_delete(service: str, username: str) -> bool:
64
+ """Try to remove a password from the OS keyring. Returns True on success."""
65
+ try:
66
+ import keyring
67
+
68
+ keyring.delete_password(service, username)
69
+ return True
70
+ except Exception:
71
+ return False
72
+
73
+
74
+ def password_from_keyring() -> bool:
75
+ """Return True if the last :func:`get_password` call used a keyring-stored value."""
76
+ return _last_from_keyring
77
+
78
+
79
+ def clear_stored_password(
80
+ dbms_name: str,
81
+ database_name: str,
82
+ user_name: str,
83
+ server_name: str | None = None,
84
+ ) -> bool:
85
+ """Remove a stored password from the OS keyring. Returns True on success."""
86
+ service = _keyring_service(dbms_name, database_name, server_name)
87
+ return _keyring_delete(service, user_name)
88
+
18
89
 
19
90
  def get_password(
20
91
  dbms_name: str,
21
92
  database_name: str,
22
93
  user_name: str,
23
- server_name: Optional[str] = None,
24
- other_msg: Optional[str] = None,
94
+ server_name: str | None = None,
95
+ other_msg: str | None = None,
96
+ skip_keyring: bool = False,
25
97
  ) -> str:
26
- """Prompt the user for a database password, using the GUI if available."""
98
+ """Prompt the user for a database password, using the GUI if available.
99
+
100
+ When ``keyring`` is installed the OS credential store is checked first.
101
+ If a stored credential is found it is returned immediately.
102
+
103
+ Parameters
104
+ ----------
105
+ skip_keyring:
106
+ If True, bypass the keyring lookup and always prompt interactively.
107
+ Useful when a previously stored password is known to be invalid.
108
+ """
109
+ global _last_from_keyring
27
110
  # Deferred imports to avoid circular dependencies at import time.
28
111
  from execsql.utils.errors import exit_now
29
112
 
113
+ _last_from_keyring = False
114
+
115
+ # --- Keyring lookup (before any prompting) ---
116
+ conf = _state.conf
117
+ use_keyring = conf is None or getattr(conf, "use_keyring", True)
118
+ service = _keyring_service(dbms_name, database_name, server_name)
119
+ if use_keyring and not skip_keyring:
120
+ stored = _keyring_get(service, user_name)
121
+ if stored is not None:
122
+ _last_from_keyring = True
123
+ _state.upass = stored
124
+ return stored
125
+
30
126
  script_name = ""
31
127
  prompt = f"The execsql script {script_name} wants the {dbms_name} password for"
32
128
  if server_name is not None:
@@ -50,7 +146,7 @@ def get_password(
50
146
  user_response = return_queue.get(block=True)
51
147
  use_gui = user_response["console_running"]
52
148
  except Exception:
53
- pass
149
+ pass # GUI query failed; fall back to non-GUI password prompt.
54
150
 
55
151
  conf = _state.conf
56
152
  if use_gui or (conf is not None and conf.gui_level > 0):
@@ -73,15 +169,14 @@ def get_password(
73
169
  user_response = return_queue.get(block=True)
74
170
  btn = user_response["button"]
75
171
  passwd = user_response["return_value"]
76
- if not btn:
77
- if _state.status and _state.status.cancel_halt:
78
- if _state.exec_log:
79
- _state.exec_log.log_exit_halt(
80
- script_name,
81
- 0,
82
- f"Canceled on password prompt for {dbms_name} database {database_name}, user {user_name}",
83
- )
84
- exit_now(2, None)
172
+ if not btn and _state.status and _state.status.cancel_halt:
173
+ if _state.exec_log:
174
+ _state.exec_log.log_exit_halt(
175
+ script_name,
176
+ 0,
177
+ f"Canceled on password prompt for {dbms_name} database {database_name}, user {user_name}",
178
+ )
179
+ exit_now(2, None)
85
180
  except Exception:
86
181
  prompt_text = prompt.replace("\n", " ", 1).replace("\n", ", ") + " >"
87
182
  passwd = getpass.getpass(str(prompt_text))
@@ -90,4 +185,9 @@ def get_password(
90
185
  passwd = getpass.getpass(str(prompt_text))
91
186
 
92
187
  _state.upass = passwd
188
+
189
+ # --- Offer to store in keyring after interactive prompt ---
190
+ if use_keyring and passwd:
191
+ _keyring_set(service, user_name, passwd)
192
+
93
193
  return passwd
execsql/utils/crypto.py CHANGED
@@ -1,12 +1,23 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  """
4
- Simple reversible encryption for execsql configuration credentials.
4
+ Simple reversible obfuscation for execsql configuration credentials.
5
+
6
+ .. warning::
7
+
8
+ **This is obfuscation, not encryption.**
9
+
10
+ * The XOR keys are hardcoded in this source file; anyone with access to
11
+ the source code (or the installed package) can decode any password
12
+ produced by this module.
13
+ * Passwords stored via ``enc_password`` in ``execsql.conf`` should be
14
+ treated as **plaintext-equivalent**.
15
+ * For production deployments, prefer OS credential stores (e.g. macOS
16
+ Keychain, Windows Credential Manager, ``secret-tool`` on Linux) or
17
+ environment variables rather than relying on this obfuscation.
5
18
 
6
19
  Provides :class:`Encrypt` with ``encrypt()`` / ``decrypt()`` methods
7
20
  that use XOR against a fixed key table followed by base64 encoding.
8
- This is *not* cryptographically secure — it is intended only to prevent
9
- plaintext passwords from appearing verbatim in ``execsql.conf`` files.
10
21
  The monolith (line 2301) called this "SIMPLE ENCRYPTION".
11
22
  """
12
23
 
@@ -15,6 +26,21 @@ import uuid
15
26
 
16
27
 
17
28
  class Encrypt:
29
+ """Reversible XOR-based obfuscation for configuration file passwords.
30
+
31
+ .. warning::
32
+
33
+ This class provides **obfuscation only** — not real encryption.
34
+ The XOR keys are embedded in the source code, so any password
35
+ encrypted with this class can be trivially reversed by anyone who
36
+ can read the source or the installed package. Passwords stored in
37
+ ``execsql.conf`` using ``enc_password`` should be considered
38
+ plaintext-equivalent.
39
+
40
+ For meaningful credential protection, use OS-level credential
41
+ stores or environment variables instead.
42
+ """
43
+
18
44
  ky: dict = {}
19
45
  ky["0"] = "6f2bba010bdf450a99c1c324ace5d765"
20
46
  ky["3"] = "4a69dd15b6304ed491f10d0ebc7498cf"
@@ -32,7 +58,8 @@ class Encrypt:
32
58
  def __init__(self) -> None:
33
59
  global itertools
34
60
  global base64
35
- import itertools, base64
61
+ import itertools
62
+ import base64
36
63
 
37
64
  def xor(self, text: str, enckey: str) -> str:
38
65
  return "".join(chr(ord(t) ^ ord(k)) for t, k in zip(text, itertools.cycle(enckey)))
execsql/utils/datetime.py CHANGED
@@ -14,7 +14,7 @@ scanning imported data for type inference.
14
14
  import collections
15
15
  import datetime
16
16
  import re
17
- from typing import Any, Optional
17
+ from typing import Any
18
18
 
19
19
 
20
20
  dt_fmts = collections.deque(
@@ -95,8 +95,8 @@ dt_fmts = collections.deque(
95
95
  )
96
96
 
97
97
 
98
- def parse_datetime(datestr: Any) -> Optional[datetime.datetime]:
99
- if type(datestr) == datetime.datetime:
98
+ def parse_datetime(datestr: Any) -> datetime.datetime | None:
99
+ if isinstance(datestr, datetime.datetime):
100
100
  return datestr
101
101
  if not isinstance(datestr, str):
102
102
  try:
@@ -104,7 +104,7 @@ def parse_datetime(datestr: Any) -> Optional[datetime.datetime]:
104
104
  except Exception:
105
105
  return None
106
106
  dt = None
107
- for i, f in enumerate(dt_fmts):
107
+ for i, f in enumerate(dt_fmts): # noqa: B007
108
108
  try:
109
109
  dt = datetime.datetime.strptime(datestr, f)
110
110
  except Exception:
@@ -238,11 +238,11 @@ timestamptz_fmts = collections.deque(
238
238
  )
239
239
 
240
240
 
241
- def parse_datetimetz(data: Any) -> Optional[datetime.datetime]:
241
+ def parse_datetimetz(data: Any) -> datetime.datetime | None:
242
242
  # Import Tz locally to avoid circular imports
243
243
  from execsql.types import Tz
244
244
 
245
- if type(data) == type(datetime.datetime.now()):
245
+ if isinstance(data, datetime.datetime):
246
246
  if data.tzinfo is None or data.tzinfo.utcoffset(data) is None:
247
247
  return None
248
248
  return data
@@ -267,7 +267,7 @@ def parse_datetimetz(data: Any) -> Optional[datetime.datetime]:
267
267
  )
268
268
  except Exception:
269
269
  # Check for alphabetic timezone
270
- for i, f in enumerate(timestamptz_fmts):
270
+ for i, f in enumerate(timestamptz_fmts): # noqa: B007
271
271
  try:
272
272
  dt = datetime.datetime.strptime(data, f)
273
273
  except Exception: