diff --git a/src/pyodbc.pyi b/src/pyodbc.pyi index 98a17d6a..ec818a6c 100644 --- a/src/pyodbc.pyi +++ b/src/pyodbc.pyi @@ -293,6 +293,9 @@ SQL_XOPEN_CLI_YEAR: int # pyodbc-specific constants BinaryNull: Any # to distinguish binary NULL values from char NULL values +UNICODE_SIZE: int +SQLWCHAR_SIZE: int + # module attributes # https://www.python.org/dev/peps/pep-0249/#globals diff --git a/tests3/__init__.py b/tests3/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests3/mysqltests.py b/tests3/mysqltests.py old mode 100755 new mode 100644 index 1d48123a..131d68f1 --- a/tests3/mysqltests.py +++ b/tests3/mysqltests.py @@ -22,7 +22,12 @@ from decimal import Decimal from datetime import datetime, date, time from os.path import join, getsize, dirname, abspath, basename -from testutils import * + +if __name__ != '__main__': + import pyodbc + +import testutils + _TESTSTR = '0123456789-abcdefghijklmnopqrstuvwxyz-' @@ -54,9 +59,14 @@ class MySqlTestCase(unittest.TestCase): STR_FENCEPOSTS = [ _generate_test_string(size) for size in SMALL_FENCEPOST_SIZES ] BLOB_FENCEPOSTS = STR_FENCEPOSTS + [ _generate_test_string(size) for size in LARGE_FENCEPOST_SIZES ] - def __init__(self, method_name, connection_string): + def __init__(self, method_name, connection_string=None): unittest.TestCase.__init__(self, method_name) - self.connection_string = connection_string + if connection_string is not None: + self.connection_string = connection_string + else: + # if the connection string cannot be provided directly here, it can be + # provided in an environment variable + self.connection_string = os.environ['PYODBC_CONN_STR'] def setUp(self): self.cnxn = pyodbc.connect(self.connection_string) @@ -752,42 +762,51 @@ def main(): parser.add_argument("-v", "--verbose", action="count", default=0, help="increment test verbosity (can be used multiple times)") parser.add_argument("-d", "--debug", action="store_true", default=False, help="print debugging items") parser.add_argument("-t", "--test", help="run only the named test") - parser.add_argument("conn_str", nargs="*", help="connection string for MySQL") - + parser.add_argument("--mysql", nargs='*', help="connection string(s) for MySQL") + # typically, the connection string is provided as the only parameter, so handle this case + parser.add_argument('conn_str', nargs='*', help="connection string for MySQL") args = parser.parse_args() if len(args.conn_str) > 1: parser.error('Only one argument is allowed. Do you need quotes around the connection string?') - if not args.conn_str: - filename = basename(sys.argv[0]) - assert filename.endswith('.py') - connection_string = load_setup_connection_string(filename[:-3]) - - if not connection_string: - parser.print_help() - raise SystemExit() + if args.mysql is not None: + connection_strings = args.mysql + elif len(args.conn_str) == 1 and args.conn_str[0]: + connection_strings = [args.conn_str[0]] else: - connection_string = args.conn_str[0] + config_conn_string = testutils.load_setup_connection_string('mysqltests') + if config_conn_string is None: + parser.print_help() + return True # no connection string, therefore nothing to do + else: + connection_strings = [config_conn_string] if args.verbose: - cnxn = pyodbc.connect(connection_string) - print_library_info(cnxn) + cnxn = pyodbc.connect(connection_strings[0]) + testutils.print_library_info(cnxn) cnxn.close() - suite = load_tests(MySqlTestCase, args.test, connection_string) + overall_result = True + for connection_string in connection_strings: + print(f'Running tests with connection string: {connection_string}') + suite = testutils.load_tests(MySqlTestCase, args.test, connection_string) + testRunner = unittest.TextTestRunner(verbosity=args.verbose) + result = testRunner.run(suite) + if not result.wasSuccessful(): + overall_result = False - testRunner = unittest.TextTestRunner(verbosity=args.verbose) - result = testRunner.run(suite) - - return result + return overall_result if __name__ == '__main__': - # Add the build directory to the path so we're testing the latest build, not the installed version. - - add_to_path() + # 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 - sys.exit(0 if main().wasSuccessful() else 1) + + # run the tests + sys.exit(0 if main() else 1) diff --git a/tests3/pgtests.py b/tests3/pgtests.py old mode 100755 new mode 100644 index fdf228ef..8724f884 --- a/tests3/pgtests.py +++ b/tests3/pgtests.py @@ -19,11 +19,17 @@ Note: Be sure to use the "Unicode" (not the "ANSI") version of the PostgreSQL ODBC driver. """ +import os import sys import uuid import unittest from decimal import Decimal -from testutils import * + +if __name__ != '__main__': + import pyodbc + +import testutils + _TESTSTR = '0123456789-abcdefghijklmnopqrstuvwxyz-' @@ -59,9 +65,14 @@ class PGTestCase(unittest.TestCase): SMALL_BYTES = bytes(SMALL_STRING, 'utf-8') LARGE_BYTES = bytes(LARGE_STRING, 'utf-8') - def __init__(self, connection_string, ansi, method_name): + def __init__(self, method_name, connection_string=None, ansi=False): unittest.TestCase.__init__(self, method_name) - self.connection_string = connection_string + if connection_string is not None: + self.connection_string = connection_string + else: + # if the connection string cannot be provided directly here, it can be + # provided in an environment variable + self.connection_string = os.environ['PYODBC_CONN_STR'] self.ansi = ansi def setUp(self): @@ -712,52 +723,68 @@ def main(): parser.add_argument("-v", "--verbose", action="count", default=0, help="increment test verbosity (can be used multiple times)") parser.add_argument("-d", "--debug", action="store_true", default=False, help="print debugging items") parser.add_argument("-t", "--test", help="run only the named test") - parser.add_argument("-a", "--ansi", help="ANSI only", default=False, action="store_true") - parser.add_argument("conn_str", nargs="*", help="connection string for PostgreSQL") - + parser.add_argument("-a", "--ansi", action="store_true", default=False, help="ANSI only") + parser.add_argument("--postgresql", nargs='*', help="connection string(s) for PostgreSQL") + # typically, the connection string is provided as the only parameter, so handle this case + parser.add_argument('conn_str', nargs='*', help="connection string for PostgreSQL") args = parser.parse_args() if len(args.conn_str) > 1: parser.error('Only one argument is allowed. Do you need quotes around the connection string?') - if not args.conn_str: - connection_string = load_setup_connection_string('pgtests') - - if not connection_string: - parser.print_help() - raise SystemExit() + if args.postgresql is not None: + connection_strings = args.postgresql + elif len(args.conn_str) == 1 and args.conn_str[0]: + connection_strings = [args.conn_str[0]] else: - connection_string = args.conn_str[0] + config_conn_string = testutils.load_setup_connection_string('pgtests') + if config_conn_string is None: + parser.print_help() + return True # no connection string, therefore nothing to do + else: + connection_strings = [config_conn_string] if args.verbose: - cnxn = pyodbc.connect(connection_string, ansi=args.ansi) - print_library_info(cnxn) + cnxn = pyodbc.connect(connection_strings[0], ansi=args.ansi) + testutils.print_library_info(cnxn) cnxn.close() - if args.test: - # Run a single test - if not args.test.startswith('test_'): - args.test = 'test_%s' % (args.test) + overall_result = True + for connection_string in connection_strings: + print(f'Running tests with connection string: {connection_string}') - s = unittest.TestSuite([ PGTestCase(connection_string, args.ansi, args.test) ]) - else: - # Run all tests in the class + if args.test: + # Run a single test + if not args.test.startswith('test_'): + args.test = 'test_%s' % (args.test) - methods = [ m for m in dir(PGTestCase) if m.startswith('test_') ] - methods.sort() - s = unittest.TestSuite([ PGTestCase(connection_string, args.ansi, m) for m in methods ]) + suite = unittest.TestSuite([ + PGTestCase(method_name=args.test, connection_string=connection_string, ansi=args.ansi) + ]) + else: + # Run all tests in the class + methods = [ m for m in dir(PGTestCase) if m.startswith('test_') ] + methods.sort() + suite = unittest.TestSuite([ + PGTestCase( method_name=m, connection_string=connection_string, ansi=args.ansi) for m in methods + ]) - testRunner = unittest.TextTestRunner(verbosity=args.verbose) - result = testRunner.run(s) + testRunner = unittest.TextTestRunner(verbosity=args.verbose) + result = testRunner.run(suite) + if not result.wasSuccessful(): + overall_result = False - return result + return overall_result if __name__ == '__main__': - # Add the build directory to the path so we're testing the latest build, not the installed version. - - add_to_path() + # 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 - sys.exit(0 if main().wasSuccessful() else 1) + + # run the tests + sys.exit(0 if main() else 1) diff --git a/tests3/run_tests.py b/tests3/run_tests.py new file mode 100644 index 00000000..e2ca8944 --- /dev/null +++ b/tests3/run_tests.py @@ -0,0 +1,77 @@ +#!/usr/bin/python +import os +import sys + +import testutils + + +def main(sqlserver=None, postgresql=None, mysql=None, verbose=0): + + # there is an assumption here about where this file is located + pyodbc_dir = os.path.dirname(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', + }, + 'PostgreSQL': { + 'conn_strs': postgresql or [], + 'discovery_start_dir': os.path.join(pyodbc_dir, 'tests3'), + 'discovery_pattern': 'pgtests.py', + }, + 'MySQL': { + 'conn_strs': mysql or [], + 'discovery_start_dir': os.path.join(pyodbc_dir, 'tests3'), + 'discovery_pattern': 'mysqltests.py', + }, + } + + overall_result = True + for db_name, db_attrs in databases.items(): + + for db_conn_str in db_attrs['conn_strs']: + print(f'Running tests against {db_name} with connection string: {db_conn_str}') + + if verbose > 0: + cnxn = pyodbc.connect(db_conn_str) + 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 + 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(): + overall_result = False + + return overall_result + + +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)") + 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/tests3/sqlservertests.py b/tests3/sqlservertests.py old mode 100755 new mode 100644 index 0ea67bc1..8c6655f6 --- a/tests3/sqlservertests.py +++ b/tests3/sqlservertests.py @@ -1,8 +1,6 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -x = 1 # Getting an error if starting with usage for some reason. - usage = """\ %(prog)s [options] connection_string @@ -34,7 +32,12 @@ from datetime import datetime, date, time from os.path import join, getsize, dirname, abspath from warnings import warn -from testutils import * + +if __name__ != '__main__': + import pyodbc + +import testutils + # Some tests have fallback code for known driver issues. # Change this value to False to bypass the fallback code, e.g., to see @@ -72,9 +75,14 @@ class SqlServerTestCase(unittest.TestCase): BYTE_FENCEPOSTS = [ bytes(s, 'ascii') for s in STR_FENCEPOSTS ] IMAGE_FENCEPOSTS = BYTE_FENCEPOSTS + [ bytes(_generate_test_string(size), 'ascii') for size in LARGE_FENCEPOST_SIZES ] - def __init__(self, method_name, connection_string): + def __init__(self, method_name, connection_string=None): unittest.TestCase.__init__(self, method_name) - self.connection_string = connection_string + if connection_string is not None: + self.connection_string = connection_string + else: + # if the connection string cannot be provided directly here, it can be + # provided in an environment variable + self.connection_string = os.environ['PYODBC_CONN_STR'] def driver_type_is(self, type_name): recognized_types = { @@ -1937,40 +1945,51 @@ def main(): parser.add_argument("-v", "--verbose", action="count", default=0, help="increment test verbosity (can be used multiple times)") parser.add_argument("-d", "--debug", action="store_true", default=False, help="print debugging items") parser.add_argument("-t", "--test", help="run only the named test") - parser.add_argument("conn_str", nargs="*", help="connection string for SQL Server") - + parser.add_argument("--sqlserver", nargs='*', help="connection string(s) for SQL Server") + # typically, the connection string is provided as the only parameter, so handle this case + parser.add_argument('conn_str', nargs='*', help="connection string for SQL Server") args = parser.parse_args() if len(args.conn_str) > 1: - parser.error('Only one argument is allowed. Do you need quotes around the connection string?') + parser.error('Only one positional argument is allowed. Do you need quotes around the connection string?') - if not args.conn_str: - connection_string = load_setup_connection_string('sqlservertests') - - if not connection_string: - parser.print_help() - raise SystemExit() + if args.sqlserver is not None: + connection_strings = args.sqlserver + elif len(args.conn_str) == 1 and args.conn_str[0]: + connection_strings = [args.conn_str[0]] else: - connection_string = args.conn_str[0] + config_conn_string = testutils.load_setup_connection_string('sqlservertests') + if config_conn_string is None: + parser.print_help() + return True # no connection string, therefore nothing to do + else: + connection_strings = [config_conn_string] if args.verbose: - cnxn = pyodbc.connect(connection_string) - print_library_info(cnxn) + cnxn = pyodbc.connect(connection_strings[0]) + testutils.print_library_info(cnxn) cnxn.close() - suite = load_tests(SqlServerTestCase, args.test, connection_string) + overall_result = True + for connection_string in connection_strings: + print(f'Running tests with connection string: {connection_string}') + suite = testutils.load_tests(SqlServerTestCase, args.test, connection_string) + testRunner = unittest.TextTestRunner(verbosity=args.verbose) + result = testRunner.run(suite) + if not result.wasSuccessful(): + overall_result = False - testRunner = unittest.TextTestRunner(verbosity=args.verbose) - result = testRunner.run(suite) - - return result + return overall_result if __name__ == '__main__': - # Add the build directory to the path so we're testing the latest build, not the installed version. - - add_to_path() + # 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 - sys.exit(0 if main().wasSuccessful() else 1) + + # run the tests + sys.exit(0 if main() else 1) diff --git a/tests3/testutils.py b/tests3/testutils.py index 809fb8cb..0f946340 100644 --- a/tests3/testutils.py +++ b/tests3/testutils.py @@ -1,40 +1,41 @@ -import os, sys, platform +from datetime import datetime +import importlib.machinery +import os from os.path import join, dirname, abspath +import platform +import sys import unittest -from distutils.util import get_platform + def add_to_path(): """ - Prepends the build directory to the path so that newly built pyodbc libraries are used, allowing it to be tested - without installing it. + Prepends the build directory to the path so that newly built pyodbc libraries are + used, allowing it to be tested without pip-installing it. """ - # Put the build directory into the Python path so we pick up the version we just built. - # - # To make this cross platform, we'll search the directories until we find the .pyd file. - - import imp - - library_exts = [ t[0] for t in imp.get_suffixes() if t[-1] == imp.C_EXTENSION ] - library_names = [ 'pyodbc%s' % ext for ext in library_exts ] - - # Only go into directories that match our version number. - - dir_suffix = '%s-%s.%s' % (get_platform(), sys.version_info[0], sys.version_info[1]) - - build = join(dirname(dirname(abspath(__file__))), 'build') - - for root, dirs, files in os.walk(build): - for d in dirs[:]: - if not d.endswith(dir_suffix): - dirs.remove(d) - - for name in library_names: - if name in files: - sys.path.insert(0, root) - print('Library:', join(root, name)) - return - - print('Did not find the pyodbc library in the build directory. Will use an installed version.') + # look for the suffixes used in the build filenames, e.g. ".cp38-win_amd64.pyd", + # ".cpython-38-darwin.so", ".cpython-38-x86_64-linux-gnu.so", etc. + library_exts = [ext for ext in importlib.machinery.EXTENSION_SUFFIXES if ext != '.pyd'] + # generate the name of the pyodbc build file(s) + library_names = ['pyodbc%s' % ext for ext in library_exts] + + # the build directory is assumed to be one directory up from this file + build_dir = join(dirname(dirname(abspath(__file__))), 'build') + + # find all the relevant pyodbc build files, and get their modified dates + file_info = [ + (os.path.getmtime(join(dirpath, file)), join(dirpath, file)) + for dirpath, dirs, files in os.walk(build_dir) + for file in files + if file in library_names + ] + if file_info: + file_info.sort() # put them in chronological order + library_modified_dt, library_path = file_info[-1] # use the latest one + # add the build directory to the Python path + sys.path.insert(0, dirname(library_path)) + print('Library: {} (last modified {})'.format(library_path, datetime.fromtimestamp(library_modified_dt))) + else: + print('Did not find the pyodbc library in the build directory. Will use the installed version.') def print_library_info(cnxn): @@ -58,6 +59,13 @@ def print_library_info(cnxn): print(' %s' % ' '.join([s for s in platform.win32_ver() if s])) +def discover_and_run(top_level_dir='.', start_dir='.', pattern='test*.py', verbosity=0): + """Finds all the test cases in the start directory and runs them""" + tests = unittest.defaultTestLoader.discover(top_level_dir=top_level_dir, start_dir=start_dir, pattern=pattern) + runner = unittest.TextTestRunner(verbosity=verbosity) + result = runner.run(tests) + return result + def load_tests(testclass, name, *args): """ diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..c3fffeb4 --- /dev/null +++ b/tox.ini @@ -0,0 +1,25 @@ +# USAGE +# 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" +# You can test against multiple versions of the same database, here with added verbosity: +# tox -- --sqlserver "DSN=localhost17" "DSN=localhost18" -v +# 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. +# +# Python 2.7 is not supported. + +[tox] +skipsdist = true + +[testenv:unit_tests] +description = Run the pyodbc unit tests +deps = pytest +sitepackages = false +commands = + python setup.py build + python .{/}tests3{/}run_tests.py {posargs}