execsql2 2.22.8__tar.gz → 2.22.9__tar.gz
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.
- {execsql2-2.22.8 → execsql2-2.22.9}/CHANGELOG.md +9 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/PKG-INFO +2 -2
- {execsql2-2.22.8 → execsql2-2.22.9}/README.md +1 -1
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/about/divergence.md +1 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/guides/formatter.md +1 -1
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/reference/configuration.md +1 -1
- {execsql2-2.22.8 → execsql2-2.22.9}/pyproject.toml +6 -2
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/data/execsql.conf.template +1 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/utils/mail.py +9 -5
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_mail.py +190 -23
- {execsql2-2.22.8 → execsql2-2.22.9}/uv.lock +1 -1
- {execsql2-2.22.8 → execsql2-2.22.9}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/.github/dependabot.yml +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/.github/workflows/ci-cd.yml +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/.gitignore +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/.pre-commit-config.yaml +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/.pre-commit-hooks.yaml +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/.python-version +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/.readthedocs.yaml +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/CONTRIBUTING.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/LICENSE.txt +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/NOTICE +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/SECURITY.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/about/contributors.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/about/copyright.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/api/cli.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/api/db.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/api/exporters.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/api/importers.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/api/index.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/api/metacommands.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/dev/adding_db_adapters.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/dev/adding_exporters.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/dev/adding_importers.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/dev/adding_metacommands.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/dev/architecture.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/dev/releasing.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/getting-started/installation.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/getting-started/requirements.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/getting-started/syntax.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/guides/debugging.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/guides/documentation.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/guides/encoding.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/guides/examples.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/guides/logging.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/guides/sql_syntax.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/guides/usage.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/guides/using_scripts.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/Compare_planets.png +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/actions.png +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/actions2.png +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/checkboxes.png +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/connect.b64 +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/connect.png +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/create_conf.png +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/data_error1_screenshot.jpg +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/entry_form.png +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/execsql_console.png +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/execsql_logo_01.png +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/fatals.png +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/logo_small.png +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/pause_terminal.png +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/pause_terminal_sm.b64 +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/pause_terminal_sm.png +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/prompt_compare.png +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/set_build_commands.jpg +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/unit_conversions.b64 +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/unit_conversions_029.png +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/unmatched.png +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/images/vim_execsql_highlight.png +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/index.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/reference/metacommands.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/reference/security.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/docs/reference/substitution_vars.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/extras/plugin-template/README.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/extras/plugin-template/pyproject.toml +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/extras/plugin-template/src/execsql_plugin_YOURNAME/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/extras/plugin-template/tests/test_plugin.py.example +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/extras/vscode-execsql/README.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/extras/vscode-execsql/package.json +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/extras/vscode-execsql/syntaxes/execsql.tmLanguage.json +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/justfile +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/scripts/benchmarks/README.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/scripts/benchmarks/results/.gitignore +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/scripts/benchmarks/run_benchmarks.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/scripts/generate_vscode_grammar.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/__main__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/api.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/cli/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/cli/dsn.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/cli/help.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/cli/lint.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/cli/run.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/config.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/data/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/db/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/db/access.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/db/base.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/db/dsn.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/db/duckdb.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/db/factory.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/db/firebird.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/db/mysql.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/db/oracle.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/db/postgres.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/db/sqlite.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/db/sqlserver.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/debug/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/debug/repl.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exceptions.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/base.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/delimited.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/duckdb.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/feather.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/html.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/json.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/latex.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/markdown.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/ods.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/parquet.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/pretty.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/protocol.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/raw.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/sqlite.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/templates.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/values.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/xls.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/xlsx.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/xml.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/yaml.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/exporters/zip.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/format.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/gui/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/gui/base.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/gui/console.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/gui/desktop.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/gui/tui.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/importers/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/importers/base.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/importers/csv.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/importers/feather.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/importers/json.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/importers/ods.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/importers/xls.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/metacommands/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/metacommands/conditions.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/metacommands/connect.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/metacommands/control.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/metacommands/data.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/metacommands/debug.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/metacommands/dispatch.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/metacommands/io.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/metacommands/io_export.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/metacommands/io_fileops.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/metacommands/io_import.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/metacommands/io_write.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/metacommands/prompt.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/metacommands/script_ext.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/metacommands/system.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/metacommands/upsert.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/models.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/parser.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/plugins.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/py.typed +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/script/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/script/ast.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/script/control.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/script/engine.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/script/executor.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/script/parser.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/script/variables.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/state.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/state.pyi +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/types.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/utils/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/utils/auth.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/utils/crypto.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/utils/datetime.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/utils/errors.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/utils/fileio.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/utils/gui.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/utils/numeric.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/utils/regex.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/utils/strings.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/src/execsql/utils/timer.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/templates/README.md +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/templates/config_settings.sqlite +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/templates/example_config_prompt.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/templates/make_config_db.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/templates/md_compare.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/templates/md_glossary.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/templates/md_upsert.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/templates/pg_compare.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/templates/pg_glossary.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/templates/pg_upsert.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/templates/script_template.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/templates/ss_compare.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/templates/ss_glossary.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/templates/ss_upsert.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/cli/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/cli/test_cli.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/cli/test_cli_e2e.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/cli/test_cli_run.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/cli/test_lint.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/cli/test_ping.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/cli/test_profile.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/conftest.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/data/formatter_shapes/comment_anchoring.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/db/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/db/test_access_windows.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/db/test_base.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/db/test_db_adapters_mocked.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/db/test_db_adapters_mocked_extra.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/db/test_dsn.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/db/test_duckdb.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/db/test_factory.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/db/test_mysql_case_folding.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/db/test_mysql_inprocess.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/db/test_postgres.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/db/test_postgres_inprocess.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/db/test_sqlite.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/db/test_sqlite_extra.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/db/test_sqlserver_inprocess.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_base.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_db.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_delimited.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_duckdb_exporter.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_exporters.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_feather.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_html_extended.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_html_latex.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_json.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_json_extended.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_latex_extended.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_markdown.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_ods.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_ods_export.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_parquet.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_pretty_extended.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_raw_extended.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_sqlite_exporter.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_templates.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_templates_extended.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_values_extended.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_xls_xlsx.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_xlsx.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_xml.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_yaml.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/exporters/test_zip.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/gui/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/gui/test_backends.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/gui/test_backends_extended.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/gui/test_compare_stats.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/gui/test_compute_row_diffs.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/gui/test_desktop_dialogs.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/gui/test_format_table.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/gui/test_tui_pilot.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/gui/test_tui_pilot_complex.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/gui/test_utils_gui_extended.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/importers/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/importers/test_base_extended.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/importers/test_csv_edge_cases.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/importers/test_csv_importer.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/importers/test_feather_importer.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/importers/test_json_importer.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/importers/test_ods_importer.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/importers/test_xls_importer.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/integration/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/integration/conftest.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/integration/test_dsn.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/integration/test_duckdb.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/integration/test_mysql.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/integration/test_postgres.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/integration/test_sqlite.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_assert.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_breakpoint.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_conditions_extra.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_connect.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_io_export.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_io_import.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_metacommands.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_metacommands_connect.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_metacommands_data.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_metacommands_extended.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_metacommands_fileops_extra.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_metacommands_io.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_metacommands_io_write_extra.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_metacommands_script_ext.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_metacommands_system.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_metacommands_system_extra.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_pg_upsert.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_prompt.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_row_count.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/metacommands/test_show_scripts.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/audit_lint_bad.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/audit_smoke/sample.json +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/audit_smoke/sample.jsonl +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/audit_smoke.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/config_runtime.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/control_flow.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/counters_and_locals.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/error_handling.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/import_options/has_comments.csv +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/import_options.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/includes/helper.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/includes.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/io_formats.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/io_roundtrip.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/multi_db.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/parquet_feather_roundtrip.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/parse_only/parse_tree.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/predicates.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/scripts.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/smoke.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/subroutine_loops.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/subvars_advanced.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/template_export/greeting.tmpl +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/template_export.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/timer.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/transactions.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/write_create_table.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/fixtures/xlsx_ods_roundtrip.sql +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/scripts/test_sql_scripts.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/security/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/security/test_env_detection.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/security/test_expansion_bomb.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/security/test_import_regex_hardening.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/security/test_log_redaction.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/security/test_odbc_injection.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/security/test_path_containment.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/security/test_substitution_injection.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/security/test_zip_bomb.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_api.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_api_extended.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_ast.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_ast_parser.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_bundled_templates.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_config.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_config_data.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_config_extended.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_debug_repl.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_engine.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_error_messages.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_exceptions.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_executor.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_executor_inprocess.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_format.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_models.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_package.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_parser.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_parser_params.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_plugins.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_registry.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_script.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_state.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/test_types.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/utils/__init__.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/utils/test_auth.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/utils/test_auth_extra.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/utils/test_crypto.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/utils/test_datetime.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/utils/test_encodedfile_context.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/utils/test_errors.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/utils/test_errors_extra.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/utils/test_fileio.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/utils/test_fileio_extra.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/utils/test_numeric.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/utils/test_regex.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/utils/test_strings.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/utils/test_timer.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/tests/utils/test_timer_extra.py +0 -0
- {execsql2-2.22.8 → execsql2-2.22.9}/zensical.toml +0 -0
|
@@ -13,6 +13,15 @@ ______________________________________________________________________
|
|
|
13
13
|
|
|
14
14
|
______________________________________________________________________
|
|
15
15
|
|
|
16
|
+
## [2.22.9] - 2026-09-24
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- `EMAIL`, and the `ON ERROR_HALT EMAIL` and `ON CANCEL_HALT EMAIL` hooks, failed with `AttributeError: 'SMTP' object has no attribute 'ehlo_or_hello_if_needed'` instead of sending. Every send was affected, on every server and TLS setting.
|
|
21
|
+
- An `[email]` `username` with no `password` or `enc_password` now reports a configuration error instead of failing with a `TypeError` from `smtplib`.
|
|
22
|
+
|
|
23
|
+
______________________________________________________________________
|
|
24
|
+
|
|
16
25
|
## [2.22.8] - 2026-09-17
|
|
17
26
|
|
|
18
27
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: execsql2
|
|
3
|
-
Version: 2.22.
|
|
3
|
+
Version: 2.22.9
|
|
4
4
|
Summary: Runs a SQL script against a PostgreSQL, SQLite, MariaDB/MySQL, DuckDB, Firebird, MS-Access, MS-SQL-Server, or Oracle database, or an ODBC DSN. Provides metacommands to import and export data, copy data between databases, conditionally execute SQL and metacommands, and dynamically alter SQL and metacommands with substitution variables.
|
|
5
5
|
Project-URL: Homepage, https://execsql2.readthedocs.io
|
|
6
6
|
Project-URL: Repository, https://github.com/geocoug/execsql
|
|
@@ -384,7 +384,7 @@ execsql-format --no-sql --in-place scripts/
|
|
|
384
384
|
```yaml
|
|
385
385
|
repos:
|
|
386
386
|
- repo: https://github.com/geocoug/execsql
|
|
387
|
-
rev: v2.22.
|
|
387
|
+
rev: v2.22.9
|
|
388
388
|
hooks:
|
|
389
389
|
- id: execsql-format
|
|
390
390
|
```
|
|
@@ -325,6 +325,7 @@ These are behavioral changes driven by security or correctness issues in the ups
|
|
|
325
325
|
| Formatter dropped clauses from unparsable SQL | The sqlglot parse ran at `ErrorLevel.IGNORE`, which answers a statement it cannot parse with a partial AST rather than an error — the tokens it failed on are absent, and regenerating from that AST wrote back SQL the author never had. `INSERT ... CROSS JOIN LATERAL (VALUES ...) AS g(a, b) ON CONFLICT (a) DO NOTHING` came back without its `DO NOTHING`, a syntax error that `--in-place` reported as `reformatted`. Every guard passed it: one statement for one semicolon, 88% of the input's alphanumeric characters, and no string literals to round-trip. The guards are quantitative and the loss is qualitative — a dropped `DISTINCT`, `CASCADE`, `DESC`, or `NOT` is smaller still — so the parse now runs at `ErrorLevel.RAISE` and SQL that does not fully parse is emitted exactly as written. |
|
|
326
326
|
| Formatter moved a comment into the middle of an expression | Comments are hidden from sqlglot as `/* marker */ line`, and sqlglot attaches the marker to an AST node. When it then reflows that node across several lines the marker surfaces deep inside the expression — `COALESCE((a.meas_value).undetected, FALSE)` carried it onto the `).undetected, FALSE)` continuation line — so restoring the comment above the marker split the expression around it, and the resulting layout reformatted differently on the next run. Comments whose markers sqlglot dropped entirely (inside a `CASE`) were re-inserted by token matching, which picked continuation lines the same way. Both paths now place the comment above the line where its expression opens, found by walking back to the parenthesis depth the comment sat at in the input. |
|
|
327
327
|
| Formatter left a space before punctuation where a comment had been | Marker runs are replaced by the one space that keeps their neighbours apart, so `AND /* m */ RTRIM(` does not become `ANDRTRIM(`. Where sqlglot placed the run against a `;`, `,`, `)`, or `.` that space was residue rather than a separator: `1 = 1 /* m */;` came back as `1 = 1 ;`, normalised only on the next run, so `--check` failed on output the formatter had just produced. The run is now removed without a space at the ends of a line and against punctuation that binds to the token before it. |
|
|
328
|
+
| `EMAIL` SMTP login without a password | Upstream calls `smtplib.login()` with only a username when `[email] password` and `enc_password` are both unset, which raises `TypeError` from the stdlib — `login()` has no single-argument form. execsql2 reports a configuration error naming the missing password instead. |
|
|
328
329
|
|
|
329
330
|
______________________________________________________________________
|
|
330
331
|
|
|
@@ -266,7 +266,7 @@ The section and property names that may be used in a configuration file are list
|
|
|
266
266
|
|
|
267
267
|
`username`
|
|
268
268
|
|
|
269
|
-
: The name of the user if the SMTP server requires login authentication.
|
|
269
|
+
: The name of the user if the SMTP server requires login authentication. If `username` is set, either `password` or `enc_password` must also be set; execsql reports an error rather than attempting an incomplete login.
|
|
270
270
|
|
|
271
271
|
`password`
|
|
272
272
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "execsql2"
|
|
7
|
-
version = "2.22.
|
|
7
|
+
version = "2.22.9"
|
|
8
8
|
description = "Runs a SQL script against a PostgreSQL, SQLite, MariaDB/MySQL, DuckDB, Firebird, MS-Access, MS-SQL-Server, or Oracle database, or an ODBC DSN. Provides metacommands to import and export data, copy data between databases, conditionally execute SQL and metacommands, and dynamically alter SQL and metacommands with substitution variables."
|
|
9
9
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
10
|
license = { file = "LICENSE.txt" }
|
|
@@ -178,7 +178,7 @@ skip-magic-trailing-comma = false
|
|
|
178
178
|
line-ending = "auto"
|
|
179
179
|
|
|
180
180
|
[tool.bumpversion]
|
|
181
|
-
current_version = "2.22.
|
|
181
|
+
current_version = "2.22.9"
|
|
182
182
|
commit = true
|
|
183
183
|
tag = true
|
|
184
184
|
tag_name = "v{new_version}"
|
|
@@ -256,6 +256,10 @@ fo = "fo"
|
|
|
256
256
|
odf = "odf"
|
|
257
257
|
# Latin-1 test data: b"caf\xe9" decodes to "café"
|
|
258
258
|
caf = "caf"
|
|
259
|
+
# SMTP verb: EHLO-or-HELO, not the English word. Correcting it to "hello"
|
|
260
|
+
# renames smtplib.ehlo_or_helo_if_needed() into a method that does not exist,
|
|
261
|
+
# which is exactly how every EMAIL send broke in 2.22.9.
|
|
262
|
+
helo = "helo"
|
|
259
263
|
|
|
260
264
|
[tool.tox]
|
|
261
265
|
required = ["tox-uv"]
|
|
@@ -66,15 +66,19 @@ class Mailer:
|
|
|
66
66
|
self.smtpconn = smtplib.SMTP_SSL(conf.smtp_host, conf.smtp_port, timeout=smtp_timeout)
|
|
67
67
|
else:
|
|
68
68
|
self.smtpconn = smtplib.SMTP(conf.smtp_host, conf.smtp_port, timeout=smtp_timeout)
|
|
69
|
-
self.smtpconn.
|
|
69
|
+
self.smtpconn.ehlo_or_helo_if_needed()
|
|
70
70
|
if conf.smtp_tls:
|
|
71
71
|
self.smtpconn.starttls()
|
|
72
72
|
self.smtpconn.ehlo(conf.smtp_host)
|
|
73
73
|
if conf.smtp_username:
|
|
74
|
-
if conf.smtp_password:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
if not conf.smtp_password:
|
|
75
|
+
# smtplib.login() requires a password; calling it with only a
|
|
76
|
+
# username raises TypeError deep in the stdlib.
|
|
77
|
+
raise ErrInfo(
|
|
78
|
+
type="error",
|
|
79
|
+
other_msg="Can't send email; an SMTP username is configured but no password.",
|
|
80
|
+
)
|
|
81
|
+
self.smtpconn.login(conf.smtp_username, conf.smtp_password)
|
|
78
82
|
|
|
79
83
|
def sendmail(
|
|
80
84
|
self,
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Tests for execsql.utils.mail — MailSpec construction and Mailer config validation.
|
|
3
3
|
|
|
4
|
-
Mailer.__init__ establishes an SMTP connection
|
|
5
|
-
|
|
4
|
+
Mailer.__init__ establishes an SMTP connection. Configuration tests mock
|
|
5
|
+
smtplib (always with a spec, so an assertion cannot name a method smtplib
|
|
6
|
+
does not have); the round-trip tests drive Mailer against a real in-process
|
|
7
|
+
SMTP server on loopback. MailSpec is a pure data class needing no mocking.
|
|
6
8
|
"""
|
|
7
9
|
|
|
8
10
|
from __future__ import annotations
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
import base64
|
|
13
|
+
import smtplib
|
|
14
|
+
import socket
|
|
15
|
+
import socketserver
|
|
16
|
+
import threading
|
|
17
|
+
from typing import cast
|
|
18
|
+
from unittest.mock import create_autospec, patch
|
|
11
19
|
|
|
12
20
|
import pytest
|
|
13
21
|
|
|
@@ -15,6 +23,12 @@ import execsql.state as _state
|
|
|
15
23
|
from execsql.exceptions import ErrInfo
|
|
16
24
|
from execsql.utils.mail import Mailer, MailSpec
|
|
17
25
|
|
|
26
|
+
# Captured before any @patch("smtplib.SMTP") swaps the real class for a mock —
|
|
27
|
+
# create_autospec() cannot spec a Mock, and a spec'd connection is the whole
|
|
28
|
+
# point: it rejects an assertion naming a method smtplib does not have.
|
|
29
|
+
_REAL_SMTP = smtplib.SMTP
|
|
30
|
+
_REAL_SMTP_SSL = smtplib.SMTP_SSL
|
|
31
|
+
|
|
18
32
|
|
|
19
33
|
# ---------------------------------------------------------------------------
|
|
20
34
|
# MailSpec — construction and attributes
|
|
@@ -80,7 +94,7 @@ class TestMailerConfigValidation:
|
|
|
80
94
|
|
|
81
95
|
@patch("smtplib.SMTP")
|
|
82
96
|
def test_creates_smtp_connection(self, mock_smtp_cls, minimal_conf):
|
|
83
|
-
mock_conn =
|
|
97
|
+
mock_conn = create_autospec(_REAL_SMTP, instance=True)
|
|
84
98
|
mock_smtp_cls.return_value = mock_conn
|
|
85
99
|
_state.conf.smtp_host = "mail.example.com"
|
|
86
100
|
_state.conf.smtp_port = None
|
|
@@ -90,13 +104,13 @@ class TestMailerConfigValidation:
|
|
|
90
104
|
_state.conf.smtp_password = None
|
|
91
105
|
m = Mailer()
|
|
92
106
|
mock_smtp_cls.assert_called_once_with("mail.example.com", timeout=30)
|
|
93
|
-
mock_conn.
|
|
107
|
+
mock_conn.ehlo_or_helo_if_needed.assert_called_once()
|
|
94
108
|
# Clean up to avoid __del__ issues
|
|
95
109
|
del m.smtpconn
|
|
96
110
|
|
|
97
111
|
@patch("smtplib.SMTP")
|
|
98
112
|
def test_creates_smtp_connection_with_port(self, mock_smtp_cls, minimal_conf):
|
|
99
|
-
mock_conn =
|
|
113
|
+
mock_conn = create_autospec(_REAL_SMTP, instance=True)
|
|
100
114
|
mock_smtp_cls.return_value = mock_conn
|
|
101
115
|
_state.conf.smtp_host = "mail.example.com"
|
|
102
116
|
_state.conf.smtp_port = 587
|
|
@@ -110,7 +124,7 @@ class TestMailerConfigValidation:
|
|
|
110
124
|
|
|
111
125
|
@patch("smtplib.SMTP_SSL")
|
|
112
126
|
def test_creates_smtp_ssl_connection(self, mock_smtp_ssl_cls, minimal_conf):
|
|
113
|
-
mock_conn =
|
|
127
|
+
mock_conn = create_autospec(_REAL_SMTP_SSL, instance=True)
|
|
114
128
|
mock_smtp_ssl_cls.return_value = mock_conn
|
|
115
129
|
_state.conf.smtp_host = "mail.example.com"
|
|
116
130
|
_state.conf.smtp_port = None
|
|
@@ -124,7 +138,7 @@ class TestMailerConfigValidation:
|
|
|
124
138
|
|
|
125
139
|
@patch("smtplib.SMTP")
|
|
126
140
|
def test_starttls_called_when_tls_enabled(self, mock_smtp_cls, minimal_conf):
|
|
127
|
-
mock_conn =
|
|
141
|
+
mock_conn = create_autospec(_REAL_SMTP, instance=True)
|
|
128
142
|
mock_smtp_cls.return_value = mock_conn
|
|
129
143
|
_state.conf.smtp_host = "mail.example.com"
|
|
130
144
|
_state.conf.smtp_port = None
|
|
@@ -138,7 +152,7 @@ class TestMailerConfigValidation:
|
|
|
138
152
|
|
|
139
153
|
@patch("smtplib.SMTP")
|
|
140
154
|
def test_login_called_with_credentials(self, mock_smtp_cls, minimal_conf):
|
|
141
|
-
mock_conn =
|
|
155
|
+
mock_conn = create_autospec(_REAL_SMTP, instance=True)
|
|
142
156
|
mock_smtp_cls.return_value = mock_conn
|
|
143
157
|
_state.conf.smtp_host = "mail.example.com"
|
|
144
158
|
_state.conf.smtp_port = None
|
|
@@ -151,8 +165,9 @@ class TestMailerConfigValidation:
|
|
|
151
165
|
del m.smtpconn
|
|
152
166
|
|
|
153
167
|
@patch("smtplib.SMTP")
|
|
154
|
-
def
|
|
155
|
-
|
|
168
|
+
def test_username_without_password_raises(self, mock_smtp_cls, minimal_conf):
|
|
169
|
+
"""smtplib.login() has no single-argument form, so this must be reported, not attempted."""
|
|
170
|
+
mock_conn = create_autospec(_REAL_SMTP, instance=True)
|
|
156
171
|
mock_smtp_cls.return_value = mock_conn
|
|
157
172
|
_state.conf.smtp_host = "mail.example.com"
|
|
158
173
|
_state.conf.smtp_port = None
|
|
@@ -160,9 +175,9 @@ class TestMailerConfigValidation:
|
|
|
160
175
|
_state.conf.smtp_tls = False
|
|
161
176
|
_state.conf.smtp_username = "user"
|
|
162
177
|
_state.conf.smtp_password = None
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
178
|
+
with pytest.raises(ErrInfo, match="username is configured but no password"):
|
|
179
|
+
Mailer()
|
|
180
|
+
mock_conn.login.assert_not_called()
|
|
166
181
|
|
|
167
182
|
|
|
168
183
|
# ---------------------------------------------------------------------------
|
|
@@ -173,7 +188,7 @@ class TestMailerConfigValidation:
|
|
|
173
188
|
class TestMailerSendmail:
|
|
174
189
|
@patch("smtplib.SMTP")
|
|
175
190
|
def _make_mailer(self, mock_smtp_cls):
|
|
176
|
-
mock_conn =
|
|
191
|
+
mock_conn = create_autospec(_REAL_SMTP, instance=True)
|
|
177
192
|
mock_smtp_cls.return_value = mock_conn
|
|
178
193
|
_state.conf.smtp_host = "mail.example.com"
|
|
179
194
|
_state.conf.smtp_port = None
|
|
@@ -257,7 +272,7 @@ class TestMailerContextManager:
|
|
|
257
272
|
@patch("smtplib.SMTP")
|
|
258
273
|
def test_context_manager_returns_mailer_instance(self, mock_smtp_cls, minimal_conf):
|
|
259
274
|
"""__enter__ should return the Mailer itself."""
|
|
260
|
-
mock_smtp_cls.return_value =
|
|
275
|
+
mock_smtp_cls.return_value = create_autospec(_REAL_SMTP, instance=True)
|
|
261
276
|
_smtp_conf(minimal_conf)
|
|
262
277
|
with Mailer() as m:
|
|
263
278
|
assert isinstance(m, Mailer)
|
|
@@ -265,7 +280,7 @@ class TestMailerContextManager:
|
|
|
265
280
|
@patch("smtplib.SMTP")
|
|
266
281
|
def test_context_manager_exit_calls_close(self, mock_smtp_cls, minimal_conf):
|
|
267
282
|
"""__exit__ must call close(), which removes smtpconn."""
|
|
268
|
-
mock_conn =
|
|
283
|
+
mock_conn = create_autospec(_REAL_SMTP, instance=True)
|
|
269
284
|
mock_smtp_cls.return_value = mock_conn
|
|
270
285
|
_smtp_conf(minimal_conf)
|
|
271
286
|
m = Mailer()
|
|
@@ -277,7 +292,7 @@ class TestMailerContextManager:
|
|
|
277
292
|
@patch("smtplib.SMTP")
|
|
278
293
|
def test_context_manager_exit_called_on_with_block_exit(self, mock_smtp_cls, minimal_conf):
|
|
279
294
|
"""Leaving a `with` block must trigger __exit__ and remove smtpconn."""
|
|
280
|
-
mock_conn =
|
|
295
|
+
mock_conn = create_autospec(_REAL_SMTP, instance=True)
|
|
281
296
|
mock_smtp_cls.return_value = mock_conn
|
|
282
297
|
_smtp_conf(minimal_conf)
|
|
283
298
|
with Mailer() as m:
|
|
@@ -288,7 +303,7 @@ class TestMailerContextManager:
|
|
|
288
303
|
@patch("smtplib.SMTP")
|
|
289
304
|
def test_close_is_idempotent(self, mock_smtp_cls, minimal_conf):
|
|
290
305
|
"""Calling close() twice must not raise."""
|
|
291
|
-
mock_smtp_cls.return_value =
|
|
306
|
+
mock_smtp_cls.return_value = create_autospec(_REAL_SMTP, instance=True)
|
|
292
307
|
_smtp_conf(minimal_conf)
|
|
293
308
|
m = Mailer()
|
|
294
309
|
m.close()
|
|
@@ -297,7 +312,7 @@ class TestMailerContextManager:
|
|
|
297
312
|
@patch("smtplib.SMTP")
|
|
298
313
|
def test_close_calls_quit_on_smtpconn(self, mock_smtp_cls, minimal_conf):
|
|
299
314
|
"""close() should call smtpconn.quit() when a connection is open."""
|
|
300
|
-
mock_conn =
|
|
315
|
+
mock_conn = create_autospec(_REAL_SMTP, instance=True)
|
|
301
316
|
mock_smtp_cls.return_value = mock_conn
|
|
302
317
|
_smtp_conf(minimal_conf)
|
|
303
318
|
m = Mailer()
|
|
@@ -307,7 +322,7 @@ class TestMailerContextManager:
|
|
|
307
322
|
@patch("smtplib.SMTP")
|
|
308
323
|
def test_close_survives_quit_raising(self, mock_smtp_cls, minimal_conf):
|
|
309
324
|
"""close() must not propagate exceptions from smtpconn.quit()."""
|
|
310
|
-
mock_conn =
|
|
325
|
+
mock_conn = create_autospec(_REAL_SMTP, instance=True)
|
|
311
326
|
mock_conn.quit.side_effect = OSError("connection already closed")
|
|
312
327
|
mock_smtp_cls.return_value = mock_conn
|
|
313
328
|
_smtp_conf(minimal_conf)
|
|
@@ -323,7 +338,7 @@ class TestMailerContextManager:
|
|
|
323
338
|
@patch("smtplib.SMTP")
|
|
324
339
|
def test_del_does_not_raise_on_normal_instance(self, mock_smtp_cls, minimal_conf):
|
|
325
340
|
"""__del__ on a fully initialised (and already closed) Mailer must not raise."""
|
|
326
|
-
mock_smtp_cls.return_value =
|
|
341
|
+
mock_smtp_cls.return_value = create_autospec(_REAL_SMTP, instance=True)
|
|
327
342
|
_smtp_conf(minimal_conf)
|
|
328
343
|
m = Mailer()
|
|
329
344
|
m.close()
|
|
@@ -332,7 +347,159 @@ class TestMailerContextManager:
|
|
|
332
347
|
@patch("smtplib.SMTP")
|
|
333
348
|
def test_context_manager_exit_suppresses_no_exceptions(self, mock_smtp_cls, minimal_conf):
|
|
334
349
|
"""__exit__ returns None, so exceptions inside the block propagate normally."""
|
|
335
|
-
mock_smtp_cls.return_value =
|
|
350
|
+
mock_smtp_cls.return_value = create_autospec(_REAL_SMTP, instance=True)
|
|
336
351
|
_smtp_conf(minimal_conf)
|
|
337
352
|
with pytest.raises(ValueError, match="deliberate"), Mailer():
|
|
338
353
|
raise ValueError("deliberate")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
# Mailer — against a real in-process SMTP server
|
|
358
|
+
#
|
|
359
|
+
# No mock can catch a call to a method smtplib does not have, or a reply the
|
|
360
|
+
# protocol does not allow. These tests drive Mailer over a loopback socket
|
|
361
|
+
# against a minimal SMTP responder, so the whole connect/EHLO/MAIL/RCPT/DATA
|
|
362
|
+
# path is exercised as the stdlib actually implements it.
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class _CapturingSMTPHandler(socketserver.StreamRequestHandler):
|
|
367
|
+
"""Minimal SMTP responder: enough of RFC 5321 for smtplib to send a message."""
|
|
368
|
+
|
|
369
|
+
def handle(self) -> None:
|
|
370
|
+
self._reply("220 localhost execsql test server")
|
|
371
|
+
envelope: dict[str, object] = {"rcpt": []}
|
|
372
|
+
while True:
|
|
373
|
+
line = self.rfile.readline()
|
|
374
|
+
if not line:
|
|
375
|
+
return
|
|
376
|
+
command = line.decode("ascii", "replace").rstrip("\r\n")
|
|
377
|
+
verb = command.split(None, 1)[0].upper() if command else ""
|
|
378
|
+
if verb == "EHLO":
|
|
379
|
+
self._reply("250-localhost\r\n250-8BITMIME\r\n250 HELP")
|
|
380
|
+
elif verb == "HELO":
|
|
381
|
+
self._reply("250 localhost")
|
|
382
|
+
elif verb == "MAIL":
|
|
383
|
+
envelope["mail_from"] = command
|
|
384
|
+
self._reply("250 OK")
|
|
385
|
+
elif verb == "RCPT":
|
|
386
|
+
cast(list, envelope["rcpt"]).append(command)
|
|
387
|
+
self._reply("250 OK")
|
|
388
|
+
elif verb == "DATA":
|
|
389
|
+
self._reply("354 End data with <CR><LF>.<CR><LF>")
|
|
390
|
+
envelope["data"] = self._read_data()
|
|
391
|
+
self.server.received.append(envelope) # type: ignore[attr-defined]
|
|
392
|
+
self._reply("250 OK")
|
|
393
|
+
elif verb == "QUIT":
|
|
394
|
+
self._reply("221 Bye")
|
|
395
|
+
return
|
|
396
|
+
elif verb == "RSET":
|
|
397
|
+
self._reply("250 OK")
|
|
398
|
+
else:
|
|
399
|
+
self._reply("502 Command not implemented")
|
|
400
|
+
|
|
401
|
+
def _reply(self, text: str) -> None:
|
|
402
|
+
self.wfile.write(text.encode("ascii") + b"\r\n")
|
|
403
|
+
self.wfile.flush()
|
|
404
|
+
|
|
405
|
+
def _read_data(self) -> str:
|
|
406
|
+
lines: list[str] = []
|
|
407
|
+
while True:
|
|
408
|
+
line = self.rfile.readline()
|
|
409
|
+
if not line or line in (b".\r\n", b".\n"):
|
|
410
|
+
break
|
|
411
|
+
decoded = line.decode("utf-8", "replace").rstrip("\r\n")
|
|
412
|
+
# Undo dot-stuffing.
|
|
413
|
+
lines.append(decoded[1:] if decoded.startswith("..") else decoded)
|
|
414
|
+
return "\n".join(lines)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class _SMTPTestServer(socketserver.ThreadingTCPServer):
|
|
418
|
+
allow_reuse_address = True
|
|
419
|
+
daemon_threads = True
|
|
420
|
+
|
|
421
|
+
def __init__(self) -> None:
|
|
422
|
+
super().__init__(("127.0.0.1", 0), _CapturingSMTPHandler)
|
|
423
|
+
self.received: list[dict[str, object]] = []
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@pytest.fixture
|
|
427
|
+
def smtp_server(minimal_conf):
|
|
428
|
+
"""A loopback SMTP server, with _state.conf pointed at it."""
|
|
429
|
+
server = _SMTPTestServer()
|
|
430
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
431
|
+
thread.start()
|
|
432
|
+
_state.conf.smtp_host, _state.conf.smtp_port = server.server_address[:2]
|
|
433
|
+
_state.conf.smtp_ssl = False
|
|
434
|
+
_state.conf.smtp_tls = False
|
|
435
|
+
_state.conf.smtp_username = None
|
|
436
|
+
_state.conf.smtp_password = None
|
|
437
|
+
_state.conf.email_format = "text"
|
|
438
|
+
_state.conf.email_css = None
|
|
439
|
+
try:
|
|
440
|
+
yield server
|
|
441
|
+
finally:
|
|
442
|
+
server.shutdown()
|
|
443
|
+
server.server_close()
|
|
444
|
+
thread.join(timeout=5)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
class TestMailerAgainstRealServer:
|
|
448
|
+
def test_plain_text_message_round_trip(self, smtp_server):
|
|
449
|
+
with Mailer() as m:
|
|
450
|
+
m.sendmail("from@a.com", "to@b.com", "Round trip", "Body text")
|
|
451
|
+
assert len(smtp_server.received) == 1
|
|
452
|
+
envelope = smtp_server.received[0]
|
|
453
|
+
assert "from@a.com" in cast(str, envelope["mail_from"])
|
|
454
|
+
assert any("to@b.com" in r for r in cast(list, envelope["rcpt"]))
|
|
455
|
+
data = cast(str, envelope["data"])
|
|
456
|
+
assert "Subject: Round trip" in data
|
|
457
|
+
assert "Body text" in data
|
|
458
|
+
|
|
459
|
+
def test_multiple_recipients_each_get_an_rcpt(self, smtp_server):
|
|
460
|
+
with Mailer() as m:
|
|
461
|
+
m.sendmail("from@a.com", "b@x.com;c@x.com,d@x.com", "Sub", "Body")
|
|
462
|
+
rcpt = cast(list, smtp_server.received[0]["rcpt"])
|
|
463
|
+
assert len(rcpt) == 3
|
|
464
|
+
|
|
465
|
+
def test_content_file_and_attachment(self, smtp_server, tmp_path):
|
|
466
|
+
content_file = tmp_path / "body.txt"
|
|
467
|
+
content_file.write_text("Appended body line")
|
|
468
|
+
attach_file = tmp_path / "data.csv"
|
|
469
|
+
attach_file.write_bytes(b"col1,col2\n1,2\n")
|
|
470
|
+
with Mailer() as m:
|
|
471
|
+
m.sendmail(
|
|
472
|
+
"from@a.com",
|
|
473
|
+
"to@b.com",
|
|
474
|
+
"With files",
|
|
475
|
+
"Body",
|
|
476
|
+
content_filename=str(content_file),
|
|
477
|
+
attach_filename=str(attach_file),
|
|
478
|
+
)
|
|
479
|
+
data = cast(str, smtp_server.received[0]["data"])
|
|
480
|
+
assert "Appended body line" in data
|
|
481
|
+
assert 'filename="data.csv"' in data
|
|
482
|
+
# The attachment is base64-encoded, so the raw bytes must not appear.
|
|
483
|
+
assert base64.b64encode(b"col1,col2\n1,2\n").decode() in data.replace("\n", "")
|
|
484
|
+
|
|
485
|
+
def test_html_format_message(self, smtp_server):
|
|
486
|
+
_state.conf.email_format = "html"
|
|
487
|
+
_state.conf.email_css = "body { color: red; }"
|
|
488
|
+
with Mailer() as m:
|
|
489
|
+
m.sendmail("from@a.com", "to@b.com", "HTML", "<p>Hello</p>")
|
|
490
|
+
data = cast(str, smtp_server.received[0]["data"])
|
|
491
|
+
assert "Content-Type: text/html" in data
|
|
492
|
+
|
|
493
|
+
def test_unreachable_host_raises_oserror(self, minimal_conf):
|
|
494
|
+
"""A closed port must surface as a connection error, not hang."""
|
|
495
|
+
with socket.socket() as probe:
|
|
496
|
+
probe.bind(("127.0.0.1", 0))
|
|
497
|
+
dead_port = probe.getsockname()[1]
|
|
498
|
+
_state.conf.smtp_host = "127.0.0.1"
|
|
499
|
+
_state.conf.smtp_port = dead_port
|
|
500
|
+
_state.conf.smtp_ssl = False
|
|
501
|
+
_state.conf.smtp_tls = False
|
|
502
|
+
_state.conf.smtp_username = None
|
|
503
|
+
_state.conf.smtp_password = None
|
|
504
|
+
with pytest.raises(OSError):
|
|
505
|
+
Mailer()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|