diff --git a/.gitignore b/.gitignore index f7b64769..da0ddbe9 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ x86/ pyodbc.conf tmp tags +xxx_* # The Access unit tests copy empty.accdb and empty.mdb to these names and use them. test.accdb diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 00000000..e079f8a6 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1 @@ +pytest diff --git a/tests3/run_tests.py b/tests3/run_tests.py index e2ca8944..a6526233 100644 --- a/tests3/run_tests.py +++ b/tests3/run_tests.py @@ -1,30 +1,109 @@ #!/usr/bin/python +import configparser import os import sys +from typing import List, Optional, Tuple import testutils +import pyodbc +import pytest -def main(sqlserver=None, postgresql=None, mysql=None, verbose=0): - # there is an assumption here about where this file is located +def option_transform(optionstr: str) -> str: + # the default ConfigParser() behavior is to lowercase key values, + # override this by simply returning the original key value + return optionstr + + +def generate_connection_string(attrs: List[Tuple[str, str]]) -> str: + attrs_str_list = [] + for key, value in attrs: + # escape/bookend values that include special characters + # ref: https://learn.microsoft.com/en-us/openspecs/sql_server_protocols/ms-odbcstr/348b0b4d-358a-41fb-9753-6351425809cb + if any(c in value for c in ';} '): + value = '{{{}}}'.format(value.replace('}', '}}')) + + attrs_str_list.append(f'{key}={value}') + + conn_str = ';'.join(attrs_str_list) + return conn_str + + +def read_db_config() -> Tuple[List[str], List[str], List[str]]: + sqlserver = [] + postgresql = [] + mysql = [] + + # get the filename of the database configuration file pyodbc_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + default_cfg_file = os.path.join(pyodbc_dir, 'tmp', 'database.cfg') + cfg_file = os.getenv('PYODBC_DATABASE_CFG', default_cfg_file) + + if os.path.exists(cfg_file): + print(f'Using database configuration file: {cfg_file}') + + # read the contents of the config file + config = configparser.ConfigParser() + config.optionxform = option_transform # prevents keys from being lowercased + config.read(cfg_file) + + # generate the connection strings + for section in config.sections(): + section_lower = section.lower() + if section_lower.startswith('sqlserver'): + conn_string = generate_connection_string(config.items(section)) + sqlserver.append(conn_string) + elif section_lower.startswith('postgres'): + conn_string = generate_connection_string(config.items(section)) + postgresql.append(conn_string) + elif section_lower.startswith('mysql'): + conn_string = generate_connection_string(config.items(section)) + mysql.append(conn_string) + else: + print(f'Database configuration file not found: {cfg_file}') + + return sqlserver, postgresql, mysql + + +def main(sqlserver: Optional[List[str]] = None, + postgresql: Optional[List[str]] = None, + mysql: Optional[List[str]] = None, + verbose: int = 0, + quiet: int = 0, + k_expression: Optional[str] = None) -> bool: + + # read from the config file if no connection strings provided + if not (sqlserver or postgresql or mysql): + sqlserver, postgresql, mysql = read_db_config() + + if not (sqlserver or postgresql or mysql): + print('No tests have been run because no database connection info was provided') + return False + + tests_dir = os.path.dirname(os.path.abspath(__file__)) databases = { 'SQL Server': { 'conn_strs': sqlserver or [], - 'discovery_start_dir': os.path.join(pyodbc_dir, 'tests3'), - 'discovery_pattern': 'sqlservertests.py', + 'discovery_patterns': [ + # FUTURE: point to dir specific to SQL Server - os.path.join(tests_dir, 'sqlserver'), + os.path.join(tests_dir, 'sqlservertests.py'), + ], }, 'PostgreSQL': { 'conn_strs': postgresql or [], - 'discovery_start_dir': os.path.join(pyodbc_dir, 'tests3'), - 'discovery_pattern': 'pgtests.py', + 'discovery_patterns': [ + # FUTURE: point to dir specific to PostgreSQL - os.path.join(tests_dir, 'postgresql'), + os.path.join(tests_dir, 'pgtests.py'), + ], }, 'MySQL': { 'conn_strs': mysql or [], - 'discovery_start_dir': os.path.join(pyodbc_dir, 'tests3'), - 'discovery_pattern': 'mysqltests.py', + 'discovery_patterns': [ + # FUTURE: point to dir specific to MySQL - os.path.join(tests_dir, 'mysql'), + os.path.join(tests_dir, 'mysqltests.py'), + ], }, } @@ -39,18 +118,30 @@ def main(sqlserver=None, postgresql=None, mysql=None, verbose=0): testutils.print_library_info(cnxn) cnxn.close() - # it doesn't seem to be possible to pass test parameters into the test - # discovery process, so the connection string will have to be passed to - # the test cases via an environment variable + # it doesn't seem to be easy to pass test parameters into the test + # discovery process, so the connection string will have to be passed + # to the test cases via an environment variable os.environ['PYODBC_CONN_STR'] = db_conn_str - result = testutils.discover_and_run( - top_level_dir=pyodbc_dir, - start_dir=db_attrs['discovery_start_dir'], - pattern=db_attrs['discovery_pattern'], - verbosity=verbose, - ) - if not result.wasSuccessful(): + # construct arguments for pytest + pytest_args = [] + + if verbose > 0: + pytest_args.extend(['-v'] * verbose) + elif quiet > 0: + pytest_args.extend(['-q'] * quiet) + + if k_expression: + pytest_args.extend(['-k', k_expression]) + + pytest_args.extend(db_attrs['discovery_patterns']) + + # run the tests + retcode = pytest.main(args=pytest_args) + if retcode == pytest.ExitCode.NO_TESTS_COLLECTED: + print('No tests collected during discovery') + overall_result = False + elif retcode != pytest.ExitCode.OK: overall_result = False return overall_result @@ -59,19 +150,16 @@ def main(sqlserver=None, postgresql=None, mysql=None, verbose=0): if __name__ == '__main__': from argparse import ArgumentParser parser = ArgumentParser() - parser.add_argument("--sqlserver", nargs='*', help="connection string(s) for SQL Server") - parser.add_argument("--postgresql", nargs='*', help="connection string(s) for PostgreSQL") - parser.add_argument("--mysql", nargs='*', help="connection string(s) for MySQL") - parser.add_argument("-v", "--verbose", action="count", default=0, help="increment test verbosity (can be used multiple times)") + parser.add_argument("--sqlserver", action="append", help="connection string for SQL Server") + parser.add_argument("--postgresql", action="append", help="connection string for PostgreSQL") + parser.add_argument("--mysql", action="append", help="connection string for MySQL") + parser.add_argument("-k", dest="k_expression", help="run tests whose names match the expression") + qv_group = parser.add_mutually_exclusive_group() + qv_group.add_argument("-q", "--quiet", action="count", default=0, help="decrease test verbosity (can be used multiple times)") + qv_group.add_argument("-v", "--verbose", action="count", default=0, help="increment test verbosity (can be used multiple times)") + # TODO: gather any remaining args and include in call to pytest??? i.e. known_args, other_args = parser.parse_known_args() args = parser.parse_args() - # add the build directory to the Python path so we're testing the latest - # build, not the pip-installed version - testutils.add_to_path() - - # only after setting the Python path, import the pyodbc module - import pyodbc - # run the tests passed = main(**vars(args)) sys.exit(0 if passed else 1) diff --git a/tox.ini b/tox.ini index c3fffeb4..2023706a 100644 --- a/tox.ini +++ b/tox.ini @@ -2,15 +2,35 @@ # First, install tox. Tox should typically be available from the command line so it # is recommended to install it using pipx (pipx install tox). # -# Run tests against multiple databases by providing connection strings as parameters, -# for example: -# tox -- --sqlserver "DSN=localhost18" --postgresql "DSN=pg11" --mysql "DSN=mysql57" +# Run tests against SQL Server, Postgres, and MySQL databases by providing connection +# strings as parameters, for example: +# tox -- --sqlserver "DSN=localhost19" --postgresql "DSN=pg11" --mysql "DSN=mysql57" # You can test against multiple versions of the same database, here with added verbosity: -# tox -- --sqlserver "DSN=localhost17" "DSN=localhost18" -v +# tox -- --sqlserver "DSN=localhost17" --sqlserver "DSN=localhost19" -v +# Run tests with specific names by using the -k option (per pytest), here in quiet mode: +# tox -- --sqlserver "DSN=localhost17" -k "unicode" -q # Note the use of "--" to separate the "tox" parameters from the parameters for the -# tests. Also, the databases must be up and available before running the tests. -# Currently, only SQL Server, Postgres, and MySQL are supported through tox. +# tests. # +# Alternatively, database connection info can be provided in a configuration file, in +# the standard INI format. The default filename is "tmp/database.cfg" within this repo, +# but this can be overriden with the PYODBC_DATABASE_CFG environment variable. Here +# is an example of a suitable configuration file: +# [sqlserver_2017] +# DSN=localhost17 +# +# [sqlserver_2019] +# DSN=localhost19 +# +# [POSTGRES] +# user = test +# password = test_password +# Any section names beginning with "sqlserver", "postgres" or "mysql" (case-insensitive) +# will be used for testing. The key/value pairs in each section will be used to connect +# to the relevant database. +# +# Naturally, test databases must be up and available before running the tests. +# Currently, only SQL Server, PostgreSQL, and MySQL are supported through tox. # Python 2.7 is not supported. [tox] @@ -18,8 +38,8 @@ skipsdist = true [testenv:unit_tests] description = Run the pyodbc unit tests -deps = pytest +deps = -r requirements-test.txt sitepackages = false commands = - python setup.py build + python -m pip install --force-reinstall --no-deps . python .{/}tests3{/}run_tests.py {posargs}