]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
- Improved the installation/test setup regarding Python 3,
authorMike Bayer <mike_mp@zzzcomputing.com>
Sat, 13 Feb 2010 01:42:52 +0000 (01:42 +0000)
committerMike Bayer <mike_mp@zzzcomputing.com>
Sat, 13 Feb 2010 01:42:52 +0000 (01:42 +0000)
now that Distribute runs on Py3k.   distribute_setup.py
is now included.  See README.py3k for Python 3 installation/
testing instructions.

CHANGES
README
README.py3k
distribute_setup.py [new file with mode: 0644]
sa2to3.py
setup.py

diff --git a/CHANGES b/CHANGES
index cc34dd8e697aa76289cd4237a621ca8e47ff4d02..5890a54f5e601ad6466844b1fb84e9171f27dc71 100644 (file)
--- a/CHANGES
+++ b/CHANGES
@@ -4,6 +4,12 @@
 CHANGES
 =======
 0.6beta2
+- py3k
+  - Improved the installation/test setup regarding Python 3,
+    now that Distribute runs on Py3k.   distribute_setup.py
+    is now included.  See README.py3k for Python 3 installation/
+    testing instructions.
+    
 - orm
   - Fixed bug in session.rollback() which involved not removing
     formerly "pending" objects from the session before
diff --git a/README b/README
index 2f903d057c0746551aaaeae171692a1c898c1ea7..7caaf2723c6b2ff2373e9619272765e12a59f941 100644 (file)
--- a/README
+++ b/README
@@ -10,6 +10,21 @@ SQLAlchemy requires Python 2.4 or higher.  One or more DB-API implementations
 are also required for database access.  See docs/intro.html for more
 information on supported DB-API implementations.
 
+Python 3 Compatibility
+----------------------
+
+Please see README.py3k for Python 3 installation and testing instructions.
+
+Installation Tools
+------------------
+
+Installation is supported with standard Python distutils, as well
+as with setuptools or Distribute.  Distribute is recommended.
+Distribute can be installed using the provided "distribute_setup.py" 
+script.  The original setuptools may be installed using the 
+"ez_setup.py" script if preferred, or simply do nothing and distutils
+will be used.
+
 Installing
 ----------
 
@@ -20,11 +35,17 @@ To install::
 To use without installation, include the ``lib`` directory in your Python
 path.
 
+Running Tests
+-------------
+
+Please see README.unittests for full instructions on running unit tests.
+
 Package Contents
 ----------------
 
   doc/
-     HTML documentation, including tutorials and API reference.
+     HTML documentation, including tutorials and API reference.  Point
+     a browser to the "index.html" to start.
 
   examples/
      Fully commented and executable implementations for a variety of tasks.
@@ -33,8 +54,7 @@ Package Contents
      SQLAlchemy.
 
   test/
-     Unit tests for SQLAlchemy.  See ``README.unittests`` for more
-     information.
+     Unit tests for SQLAlchemy.
 
 Help
 ----
index c52f56d3c1e1d53da0a9bfe88707f460c81daf62..28aaf3c84d73871ece767d36d1113b4fe31b928d 100644 (file)
@@ -5,41 +5,56 @@ PYTHON 3 SUPPORT
 Current Python 3k support in SQLAlchemy is provided by a customized
 2to3 script which wraps Python's 2to3 tool.
 
-This document will refer to the Python 2.6 interpreter binary as
-"python26" and the Python 3.xx interpreter binary as "python3".
+Installing Distribute
+---------------------
 
-To build the Python 3K version, use the Python 2.6 interpreter to
-run the 2to3 script on the lib/ directory, and optionally the test/
-directory.   The -w flag indicates that the new files should be
-written.
+Distribute should be installed with the Python3 installation.  The
+distribute bootloader is included.
 
-    python26 sa2to3.py ./lib/ ./test/ -w
+Running as a user with permission to modify the Python distribution,
+install Distribute:
 
-You now have a Python 3 version of SQLAlchemy in lib/.   
+    python3 distribute_setup.py
+    
 
-Current 3k Issues
------------------
+Installing SQLAlchemy in Python 3
+---------------------------------
 
-Current bugs and tickets related to Py3k are on the Py3k milestone in trac:
+Once Distribute is installed, SQLAlchemy can be installed directly.  
+The 2to3 process will kick in which takes several minutes:
 
-http://www.sqlalchemy.org/trac/query?status=new&status=assigned&status=reopened&milestone=py3k
+    python3 setup.py install
+
+Converting Tests, Examples, Source to Python 3
+----------------------------------------------
+
+To convert all files in the source distribution, run 
+SQLAlchemys "sa2to3.py" script, which monkeypatches a preprocessor
+onto the 2to3 tool:
+
+    python sa2to3.py ./lib/ ./test/ ./examples/ -w
+
+The above will rewrite all files in-place in Python 3 format.
 
 Running Tests
 -------------
 
-The unit test runner, described in README.unittests, is built on
-Nose, and uses a plugin that is ordinarily installed using setuptools
-entry points.  At the time of this writing setuptools isn't available 
-for Python 3 although the "Distribute" project does seem to provide support.
-Additionally, Nose itself is only available in an old version for Python 3,
-which is available at http://bitbucket.org/jpellerin/nose3/ .
+To run the unit tests, ensure Distribute is installed as above,
+and also that at least the ./lib/ and ./test/ directories have been converted
+to Python 3 using the source tool above.   A Python 3 version of Nose
+can be acquired from Bitbucket using Mercurial:
 
-To run the unit tests using the old version of nose and without the usage of 
-setuptools, use the "sqla_nose.py" script:
+    hg clone http://bitbucket.org/jpellerin/nose3/
+    cd nose3
+    python3 setup.py install
+
+The tests can then be run using the "nosetests3" script installed by the above,
+using the same instructions in README.unittests.
+
+Current 3k Issues
+-----------------
+
+Current bugs and tickets related to Py3k are on the Py3k milestone in trac:
+
+http://www.sqlalchemy.org/trac/query?status=new&status=assigned&status=reopened&milestone=py3k
 
-    python3 sqla_nose.py
-    
-When running with Python 3, lots of debug output is dumped to the console.
-This is due to hacking around the old version of Nose to support the 
-SQLAlchemy test plugin without setuptools (details at 
-http://groups.google.com/group/nose-dev/browse_thread/thread/c6a25531baaa2531).
\ No newline at end of file
diff --git a/distribute_setup.py b/distribute_setup.py
new file mode 100644 (file)
index 0000000..0021336
--- /dev/null
@@ -0,0 +1,477 @@
+#!python
+"""Bootstrap distribute installation
+
+If you want to use setuptools in your package's setup.py, just include this
+file in the same directory with it, and add this to the top of your setup.py::
+
+    from distribute_setup import use_setuptools
+    use_setuptools()
+
+If you want to require a specific version of setuptools, set a download
+mirror, or use an alternate download directory, you can do so by supplying
+the appropriate options to ``use_setuptools()``.
+
+This file can also be run as a script to install or upgrade setuptools.
+"""
+import os
+import sys
+import time
+import fnmatch
+import tempfile
+import tarfile
+from distutils import log
+
+try:
+    from site import USER_SITE
+except ImportError:
+    USER_SITE = None
+
+try:
+    import subprocess
+
+    def _python_cmd(*args):
+        args = (sys.executable,) + args
+        return subprocess.call(args) == 0
+
+except ImportError:
+    # will be used for python 2.3
+    def _python_cmd(*args):
+        args = (sys.executable,) + args
+        # quoting arguments if windows
+        if sys.platform == 'win32':
+            def quote(arg):
+                if ' ' in arg:
+                    return '"%s"' % arg
+                return arg
+            args = [quote(arg) for arg in args]
+        return os.spawnl(os.P_WAIT, sys.executable, *args) == 0
+
+DEFAULT_VERSION = "0.6.10"
+DEFAULT_URL = "http://pypi.python.org/packages/source/d/distribute/"
+SETUPTOOLS_FAKED_VERSION = "0.6c11"
+
+SETUPTOOLS_PKG_INFO = """\
+Metadata-Version: 1.0
+Name: setuptools
+Version: %s
+Summary: xxxx
+Home-page: xxx
+Author: xxx
+Author-email: xxx
+License: xxx
+Description: xxx
+""" % SETUPTOOLS_FAKED_VERSION
+
+
+def _install(tarball):
+    # extracting the tarball
+    tmpdir = tempfile.mkdtemp()
+    log.warn('Extracting in %s', tmpdir)
+    old_wd = os.getcwd()
+    try:
+        os.chdir(tmpdir)
+        tar = tarfile.open(tarball)
+        _extractall(tar)
+        tar.close()
+
+        # going in the directory
+        subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0])
+        os.chdir(subdir)
+        log.warn('Now working in %s', subdir)
+
+        # installing
+        log.warn('Installing Distribute')
+        if not _python_cmd('setup.py', 'install'):
+            log.warn('Something went wrong during the installation.')
+            log.warn('See the error message above.')
+    finally:
+        os.chdir(old_wd)
+
+
+def _build_egg(egg, tarball, to_dir):
+    # extracting the tarball
+    tmpdir = tempfile.mkdtemp()
+    log.warn('Extracting in %s', tmpdir)
+    old_wd = os.getcwd()
+    try:
+        os.chdir(tmpdir)
+        tar = tarfile.open(tarball)
+        _extractall(tar)
+        tar.close()
+
+        # going in the directory
+        subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0])
+        os.chdir(subdir)
+        log.warn('Now working in %s', subdir)
+
+        # building an egg
+        log.warn('Building a Distribute egg in %s', to_dir)
+        _python_cmd('setup.py', '-q', 'bdist_egg', '--dist-dir', to_dir)
+
+    finally:
+        os.chdir(old_wd)
+    # returning the result
+    log.warn(egg)
+    if not os.path.exists(egg):
+        raise IOError('Could not build the egg.')
+
+
+def _do_download(version, download_base, to_dir, download_delay):
+    egg = os.path.join(to_dir, 'distribute-%s-py%d.%d.egg'
+                       % (version, sys.version_info[0], sys.version_info[1]))
+    if not os.path.exists(egg):
+        tarball = download_setuptools(version, download_base,
+                                      to_dir, download_delay)
+        _build_egg(egg, tarball, to_dir)
+    sys.path.insert(0, egg)
+    import setuptools
+    setuptools.bootstrap_install_from = egg
+
+
+def use_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL,
+                   to_dir=os.curdir, download_delay=15, no_fake=True):
+    # making sure we use the absolute path
+    to_dir = os.path.abspath(to_dir)
+    was_imported = 'pkg_resources' in sys.modules or \
+        'setuptools' in sys.modules
+    try:
+        try:
+            import pkg_resources
+            if not hasattr(pkg_resources, '_distribute'):
+                if not no_fake:
+                    _fake_setuptools()
+                raise ImportError
+        except ImportError:
+            return _do_download(version, download_base, to_dir, download_delay)
+        try:
+            pkg_resources.require("distribute>="+version)
+            return
+        except pkg_resources.VersionConflict:
+            e = sys.exc_info()[1]
+            if was_imported:
+                sys.stderr.write(
+                "The required version of distribute (>=%s) is not available,\n"
+                "and can't be installed while this script is running. Please\n"
+                "install a more recent version first, using\n"
+                "'easy_install -U distribute'."
+                "\n\n(Currently using %r)\n" % (version, e.args[0]))
+                sys.exit(2)
+            else:
+                del pkg_resources, sys.modules['pkg_resources']    # reload ok
+                return _do_download(version, download_base, to_dir,
+                                    download_delay)
+        except pkg_resources.DistributionNotFound:
+            return _do_download(version, download_base, to_dir,
+                                download_delay)
+    finally:
+        if not no_fake:
+            _create_fake_setuptools_pkg_info(to_dir)
+
+def download_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL,
+                        to_dir=os.curdir, delay=15):
+    """Download distribute from a specified location and return its filename
+
+    `version` should be a valid distribute version number that is available
+    as an egg for download under the `download_base` URL (which should end
+    with a '/'). `to_dir` is the directory where the egg will be downloaded.
+    `delay` is the number of seconds to pause before an actual download
+    attempt.
+    """
+    # making sure we use the absolute path
+    to_dir = os.path.abspath(to_dir)
+    try:
+        from urllib.request import urlopen
+    except ImportError:
+        from urllib2 import urlopen
+    tgz_name = "distribute-%s.tar.gz" % version
+    url = download_base + tgz_name
+    saveto = os.path.join(to_dir, tgz_name)
+    src = dst = None
+    if not os.path.exists(saveto):  # Avoid repeated downloads
+        try:
+            log.warn("Downloading %s", url)
+            src = urlopen(url)
+            # Read/write all in one block, so we don't create a corrupt file
+            # if the download is interrupted.
+            data = src.read()
+            dst = open(saveto, "wb")
+            dst.write(data)
+        finally:
+            if src:
+                src.close()
+            if dst:
+                dst.close()
+    return os.path.realpath(saveto)
+
+
+def _patch_file(path, content):
+    """Will backup the file then patch it"""
+    existing_content = open(path).read()
+    if existing_content == content:
+        # already patched
+        log.warn('Already patched.')
+        return False
+    log.warn('Patching...')
+    _rename_path(path)
+    f = open(path, 'w')
+    try:
+        f.write(content)
+    finally:
+        f.close()
+    return True
+
+
+def _same_content(path, content):
+    return open(path).read() == content
+
+def _no_sandbox(function):
+    def __no_sandbox(*args, **kw):
+        try:
+            from setuptools.sandbox import DirectorySandbox
+            def violation(*args):
+                pass
+            DirectorySandbox._old = DirectorySandbox._violation
+            DirectorySandbox._violation = violation
+            patched = True
+        except ImportError:
+            patched = False
+
+        try:
+            return function(*args, **kw)
+        finally:
+            if patched:
+                DirectorySandbox._violation = DirectorySandbox._old
+                del DirectorySandbox._old
+
+    return __no_sandbox
+
+@_no_sandbox
+def _rename_path(path):
+    new_name = path + '.OLD.%s' % time.time()
+    log.warn('Renaming %s into %s', path, new_name)
+    os.rename(path, new_name)
+    return new_name
+
+def _remove_flat_installation(placeholder):
+    if not os.path.isdir(placeholder):
+        log.warn('Unkown installation at %s', placeholder)
+        return False
+    found = False
+    for file in os.listdir(placeholder):
+        if fnmatch.fnmatch(file, 'setuptools*.egg-info'):
+            found = True
+            break
+    if not found:
+        log.warn('Could not locate setuptools*.egg-info')
+        return
+
+    log.warn('Removing elements out of the way...')
+    pkg_info = os.path.join(placeholder, file)
+    if os.path.isdir(pkg_info):
+        patched = _patch_egg_dir(pkg_info)
+    else:
+        patched = _patch_file(pkg_info, SETUPTOOLS_PKG_INFO)
+
+    if not patched:
+        log.warn('%s already patched.', pkg_info)
+        return False
+    # now let's move the files out of the way
+    for element in ('setuptools', 'pkg_resources.py', 'site.py'):
+        element = os.path.join(placeholder, element)
+        if os.path.exists(element):
+            _rename_path(element)
+        else:
+            log.warn('Could not find the %s element of the '
+                     'Setuptools distribution', element)
+    return True
+
+
+def _after_install(dist):
+    log.warn('After install bootstrap.')
+    placeholder = dist.get_command_obj('install').install_purelib
+    _create_fake_setuptools_pkg_info(placeholder)
+
+@_no_sandbox
+def _create_fake_setuptools_pkg_info(placeholder):
+    if not placeholder or not os.path.exists(placeholder):
+        log.warn('Could not find the install location')
+        return
+    pyver = '%s.%s' % (sys.version_info[0], sys.version_info[1])
+    setuptools_file = 'setuptools-%s-py%s.egg-info' % \
+            (SETUPTOOLS_FAKED_VERSION, pyver)
+    pkg_info = os.path.join(placeholder, setuptools_file)
+    if os.path.exists(pkg_info):
+        log.warn('%s already exists', pkg_info)
+        return
+
+    log.warn('Creating %s', pkg_info)
+    f = open(pkg_info, 'w')
+    try:
+        f.write(SETUPTOOLS_PKG_INFO)
+    finally:
+        f.close()
+
+    pth_file = os.path.join(placeholder, 'setuptools.pth')
+    log.warn('Creating %s', pth_file)
+    f = open(pth_file, 'w')
+    try:
+        f.write(os.path.join(os.curdir, setuptools_file))
+    finally:
+        f.close()
+
+def _patch_egg_dir(path):
+    # let's check if it's already patched
+    pkg_info = os.path.join(path, 'EGG-INFO', 'PKG-INFO')
+    if os.path.exists(pkg_info):
+        if _same_content(pkg_info, SETUPTOOLS_PKG_INFO):
+            log.warn('%s already patched.', pkg_info)
+            return False
+    _rename_path(path)
+    os.mkdir(path)
+    os.mkdir(os.path.join(path, 'EGG-INFO'))
+    pkg_info = os.path.join(path, 'EGG-INFO', 'PKG-INFO')
+    f = open(pkg_info, 'w')
+    try:
+        f.write(SETUPTOOLS_PKG_INFO)
+    finally:
+        f.close()
+    return True
+
+
+def _before_install():
+    log.warn('Before install bootstrap.')
+    _fake_setuptools()
+
+
+def _under_prefix(location):
+    if 'install' not in sys.argv:
+        return True
+    args = sys.argv[sys.argv.index('install')+1:]
+    for index, arg in enumerate(args):
+        for option in ('--root', '--prefix'):
+            if arg.startswith('%s=' % option):
+                top_dir = arg.split('root=')[-1]
+                return location.startswith(top_dir)
+            elif arg == option:
+                if len(args) > index:
+                    top_dir = args[index+1]
+                    return location.startswith(top_dir)
+            elif option == '--user' and USER_SITE is not None:
+                return location.startswith(USER_SITE)
+    return True
+
+
+def _fake_setuptools():
+    log.warn('Scanning installed packages')
+    try:
+        import pkg_resources
+    except ImportError:
+        # we're cool
+        log.warn('Setuptools or Distribute does not seem to be installed.')
+        return
+    ws = pkg_resources.working_set
+    try:
+        setuptools_dist = ws.find(pkg_resources.Requirement.parse('setuptools',
+                                  replacement=False))
+    except TypeError:
+        # old distribute API
+        setuptools_dist = ws.find(pkg_resources.Requirement.parse('setuptools'))
+
+    if setuptools_dist is None:
+        log.warn('No setuptools distribution found')
+        return
+    # detecting if it was already faked
+    setuptools_location = setuptools_dist.location
+    log.warn('Setuptools installation detected at %s', setuptools_location)
+
+    # if --root or --preix was provided, and if
+    # setuptools is not located in them, we don't patch it
+    if not _under_prefix(setuptools_location):
+        log.warn('Not patching, --root or --prefix is installing Distribute'
+                 ' in another location')
+        return
+
+    # let's see if its an egg
+    if not setuptools_location.endswith('.egg'):
+        log.warn('Non-egg installation')
+        res = _remove_flat_installation(setuptools_location)
+        if not res:
+            return
+    else:
+        log.warn('Egg installation')
+        pkg_info = os.path.join(setuptools_location, 'EGG-INFO', 'PKG-INFO')
+        if (os.path.exists(pkg_info) and
+            _same_content(pkg_info, SETUPTOOLS_PKG_INFO)):
+            log.warn('Already patched.')
+            return
+        log.warn('Patching...')
+        # let's create a fake egg replacing setuptools one
+        res = _patch_egg_dir(setuptools_location)
+        if not res:
+            return
+    log.warn('Patched done.')
+    _relaunch()
+
+
+def _relaunch():
+    log.warn('Relaunching...')
+    # we have to relaunch the process
+    args = [sys.executable] + sys.argv
+    sys.exit(subprocess.call(args))
+
+
+def _extractall(self, path=".", members=None):
+    """Extract all members from the archive to the current working
+       directory and set owner, modification time and permissions on
+       directories afterwards. `path' specifies a different directory
+       to extract to. `members' is optional and must be a subset of the
+       list returned by getmembers().
+    """
+    import copy
+    import operator
+    from tarfile import ExtractError
+    directories = []
+
+    if members is None:
+        members = self
+
+    for tarinfo in members:
+        if tarinfo.isdir():
+            # Extract directories with a safe mode.
+            directories.append(tarinfo)
+            tarinfo = copy.copy(tarinfo)
+            tarinfo.mode = 448 # decimal for oct 0700
+        self.extract(tarinfo, path)
+
+    # Reverse sort directories.
+    if sys.version_info < (2, 4):
+        def sorter(dir1, dir2):
+            return cmp(dir1.name, dir2.name)
+        directories.sort(sorter)
+        directories.reverse()
+    else:
+        directories.sort(key=operator.attrgetter('name'), reverse=True)
+
+    # Set correct owner, mtime and filemode on directories.
+    for tarinfo in directories:
+        dirpath = os.path.join(path, tarinfo.name)
+        try:
+            self.chown(tarinfo, dirpath)
+            self.utime(tarinfo, dirpath)
+            self.chmod(tarinfo, dirpath)
+        except ExtractError:
+            e = sys.exc_info()[1]
+            if self.errorlevel > 1:
+                raise
+            else:
+                self._dbg(1, "tarfile: %s" % e)
+
+
+def main(argv, version=DEFAULT_VERSION):
+    """Install or upgrade setuptools and EasyInstall"""
+    tarball = download_setuptools()
+    _install(tarball)
+
+
+if __name__ == '__main__':
+    main(sys.argv[1:])
index 9c06dafabd3c5a779e21ac69d7a6580edcf85550..f2c5d9801d3a8d2afd6f537e59c71aff9c47a6f5 100644 (file)
--- a/sa2to3.py
+++ b/sa2to3.py
@@ -1,15 +1,9 @@
 """SQLAlchemy 2to3 tool.
 
-Relax !  This just calls the regular 2to3 tool with a preprocessor bolted onto it.
-
-
-I originally wanted to write a custom fixer to accomplish this
-but the Fixer classes seem like they can only understand 
-the grammar file included with 2to3, and the grammar does not
-seem to include Python comments (and of course, huge hacks needed
-to get out-of-package fixers in there).   While that may be
-an option later on this is a pretty simple approach for
-what is a pretty simple problem.
+This tool monkeypatches a preprocessor onto
+lib2to3.refactor.RefactoringTool, so that conditional
+sections can replace non-fixable Python 2 code sections
+for the appropriate Python 3 version before 2to3 is run.
 
 """
 
@@ -61,7 +55,7 @@ def preprocess(data):
 
     return "\n".join(consume_normal())
 
-old_refactor_string = main.StdoutRefactoringTool.refactor_string
+old_refactor_string = refactor.RefactoringTool.refactor_string
 
 def refactor_string(self, data, name):
     newdata = preprocess(data)
@@ -70,7 +64,9 @@ def refactor_string(self, data, name):
         if newdata != data:
             tree.was_changed = True
     return tree
-    
-main.StdoutRefactoringTool.refactor_string = refactor_string
 
-main.main("lib2to3.fixes")
+if __name__ == '__main__':
+    refactor.RefactoringTool.refactor_string = refactor_string
+    main.main("lib2to3.fixes")
+
+
index 392e702cc9e280cd66b5ed98db8c4d1fba24bf1d..3a1b5f1dd386821ba9434dbc7d256b4b9182879e 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -1,6 +1,19 @@
 import os
 import sys
 import re
+
+extra = {}
+if sys.version_info >= (3, 0):
+    # monkeypatch our preprocessor
+    # onto the 2to3 tool.  
+    from sa2to3 import refactor_string
+    from lib2to3.refactor import RefactoringTool
+    RefactoringTool.refactor_string = refactor_string
+
+    extra.update(
+        use_2to3=True,
+    )
+
 try:
     from setuptools import setup
 except ImportError:
@@ -14,7 +27,6 @@ def find_packages(dir_):
             packages.append(fragment.replace(os.sep, '.'))
     return packages
 
-
 if sys.version_info < (2, 4):
     raise Exception("SQLAlchemy requires Python 2.4 or higher.")
 
@@ -22,6 +34,7 @@ v = open(os.path.join(os.path.dirname(__file__), 'lib', 'sqlalchemy', '__init__.
 VERSION = re.compile(r".*__version__ = '(.*?)'", re.S).match(v.read()).group(1)
 v.close()
 
+
 setup(name = "SQLAlchemy",
       version = VERSION,
       description = "Database Abstraction Library",
@@ -77,5 +90,6 @@ SVN version:
         "Programming Language :: Python :: 3",
         "Topic :: Database :: Front-Ends",
         "Operating System :: OS Independent",
-        ]
+        ],
+        **extra
       )