diff options
author | Eli Collins <elic@assurancetechnologies.com> | 2016-12-19 13:03:29 -0500 |
---|---|---|
committer | Eli Collins <elic@assurancetechnologies.com> | 2016-12-19 13:03:29 -0500 |
commit | 632f66738d070a815d2628ab92b5b9ad9fa33942 (patch) | |
tree | 0eb6f8c10407231e4bdd122c6e1eb79a9d13e68c | |
parent | 2b4b86a81a5b9845a92fab89e804d428a3d6da99 (diff) | |
parent | 11c6c6d9caa18ef792ee586afb02ae566f528e61 (diff) | |
download | passlib-632f66738d070a815d2628ab92b5b9ad9fa33942.tar.gz |
Merge from stable
-rwxr-xr-x | admin/upload.sh | 2 | ||||
-rw-r--r-- | docs/history/1.7.rst | 10 | ||||
-rw-r--r-- | docs/install.rst | 2 | ||||
-rw-r--r-- | passlib/_setup/docdist.py | 87 | ||||
-rw-r--r-- | passlib/_setup/stamp.py | 116 | ||||
-rw-r--r-- | passlib/pwd.py | 64 | ||||
-rw-r--r-- | passlib/tests/test_pwd.py | 8 | ||||
-rw-r--r-- | passlib/tests/utils.py | 19 | ||||
-rw-r--r-- | setup.py | 204 |
9 files changed, 273 insertions, 239 deletions
diff --git a/admin/upload.sh b/admin/upload.sh index 6d1dfda..5aaa053 100755 --- a/admin/upload.sh +++ b/admin/upload.sh @@ -10,7 +10,7 @@ SEP2="-----------------------------------------------------" # init config # -export PASSLIB_SETUP_TAG_RELEASE=no +export SETUP_TAG_RELEASE=no if [ -z "$DRY_RUN" ]; then echo "DRY_RUN not set" diff --git a/docs/history/1.7.rst b/docs/history/1.7.rst index 27ab820..449ee56 100644 --- a/docs/history/1.7.rst +++ b/docs/history/1.7.rst @@ -11,6 +11,8 @@ Passlib 1.7 keywords. This usage was deprecated in 1.7.0, but warning wasn't properly enabled. See :ref:`hash-configuring` for the preferred way to pass settings. +* bugfix: setup.py: prevent erroneous version strings when run from an sdist. + .. rst-class:: emphasize-children toc-always-open **1.7.0** (2016-11-22) @@ -177,7 +179,8 @@ Deprecations As part of a long-range plan to restructure and simplify both the API and the internals of Passlib, a number of methods have been deprecated & replaced. The eventually goal is a large cleanup and overhaul as part of Passlib 2.0. There will be at least one more 1.x version -before Passlib 2.0, to provide a final transitional release. +before Passlib 2.0, to provide a final transitional release +(see the `Passlib Roadmap <https://bitbucket.org/ecollins/passlib/wiki/Roadmap>`_). Password Hash API Deprecations .............................. @@ -203,11 +206,12 @@ Password Hash API Deprecations To provide settings such as ``rounds`` and ``salt_size``, callers should use the new :meth:`PasswordHash.using` method, which generates a new hasher with a customized configuration. + For example, instead of:: - >>> # for example, instead of this: >>> sha256_crypt.encrypt("secret", rounds=12345) - >>> # callers should now use: + ... applications should now use:: + >>> sha256_crypt.using(rounds=12345).hash("secret") Support for the old syntax will be removed in Passlib 2.0. diff --git a/docs/install.rst b/docs/install.rst index 2212383..2c4ec6a 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -144,5 +144,3 @@ you will need to: 4. From the Passlib source directory, run :samp:`python setup.py build_sphinx`. 5. Once Sphinx completes its run, point a web browser to the file at :samp:`{SOURCE}/build/sphinx/html/index.html` to access the Passlib documentation in html format. -6. Alternately, steps 4 & 5 can be replaced by running :samp:`python setup.py docdist`, - which will build a zip file of the documentation in :samp:`{SOURCE}/dist`. diff --git a/passlib/_setup/docdist.py b/passlib/_setup/docdist.py deleted file mode 100644 index 19c4dc1..0000000 --- a/passlib/_setup/docdist.py +++ /dev/null @@ -1,87 +0,0 @@ -"""custom command to build doc.zip file""" -#============================================================================= -# imports -#============================================================================= -# core -import os -from distutils import dir_util -from distutils.cmd import Command -from distutils.errors import * -from distutils.spawn import spawn -# local -__all__ = [ - "docdist" -] -#============================================================================= -# command -#============================================================================= -class docdist(Command): - - description = "create zip file containing standalone html docs" - - user_options = [ - ('build-dir=', None, 'Build directory'), - ('dist-dir=', 'd', - "directory to put the source distribution archive(s) in " - "[default: dist]"), - ('format=', 'f', - "archive format to create (tar, ztar, gztar, zip)"), - ('sign', 's', 'sign files using gpg'), - ('identity=', 'i', 'GPG identity used to sign files'), - ] - - def initialize_options(self): - self.build_dir = None - self.dist_dir = None - self.format = None - self.keep_temp = False - self.sign = False - self.identity = None - - def finalize_options(self): - if self.identity and not self.sign: - raise DistutilsOptionError( - "Must use --sign for --identity to have meaning" - ) - if self.build_dir is None: - cmd = self.get_finalized_command('build') - self.build_dir = os.path.join(cmd.build_base, 'docdist') - if not self.dist_dir: - self.dist_dir = "dist" - if not self.format: - self.format = "zip" - - def run(self): - # call build sphinx to build docs - self.run_command("build_sphinx") - cmd = self.get_finalized_command("build_sphinx") - source_dir = cmd.builder_target_dir - - # copy to directory with appropriate name - dist = self.distribution - arc_name = "%s-docs-%s" % (dist.get_name(), dist.get_version()) - tmp_dir = os.path.join(self.build_dir, arc_name) - if os.path.exists(tmp_dir): - dir_util.remove_tree(tmp_dir, dry_run=self.dry_run) - self.copy_tree(source_dir, tmp_dir, preserve_symlinks=True) - - # make archive from dir - arc_base = os.path.join(self.dist_dir, arc_name) - self.arc_filename = self.make_archive(arc_base, self.format, - self.build_dir) - - # Sign if requested - if self.sign: - gpg_args = ["gpg", "--detach-sign", "-a", self.arc_filename] - if self.identity: - gpg_args[2:2] = ["--local-user", self.identity] - spawn(gpg_args, - dry_run=self.dry_run) - - # cleanup - if not self.keep_temp: - dir_util.remove_tree(tmp_dir, dry_run=self.dry_run) - -#============================================================================= -# eof -#============================================================================= diff --git a/passlib/_setup/stamp.py b/passlib/_setup/stamp.py index d5e559f..2ce3eb3 100644 --- a/passlib/_setup/stamp.py +++ b/passlib/_setup/stamp.py @@ -2,16 +2,20 @@ #============================================================================= # imports #============================================================================= -from __future__ import with_statement +from __future__ import absolute_import, division, print_function # core +from distutils.dist import Distribution import os import re -from distutils.dist import Distribution +import subprocess +import time # pkg # local __all__ = [ "stamp_source", "stamp_distutils_output", + "append_hg_revision", + "as_bool", ] #============================================================================= # helpers @@ -19,19 +23,59 @@ __all__ = [ def get_command_class(opts, name): return opts['cmdclass'].get(name) or Distribution().get_command_class(name) +def get_command_options(opts, command): + return opts.setdefault("command_options", {}).setdefault(command, {}) + +def set_command_options(opts, command, _source_="setup.py", **kwds): + target = get_command_options(opts, command) + target.update( + (key, (_source_, value)) + for key, value in kwds.items() + ) + +def _get_file(path): + with open(path, "r") as fh: + return fh.read() + + +def _replace_file(path, content, dry_run=False): + if dry_run: + return + if os.path.exists(path): + # sdist likes to use hardlinks, have to remove them first, + # or we modify *source* file + os.unlink(path) + with open(path, "w") as fh: + fh.write(content) + + def stamp_source(base_dir, version, dry_run=False): - """update version string in passlib dist""" + """ + update version info in passlib source + """ + # + # update version string in toplevel package source + # path = os.path.join(base_dir, "passlib", "__init__.py") - with open(path) as fh: - input = fh.read() - output, count = re.subn('(?m)^__version__\s*=.*$', + content = _get_file(path) + content, count = re.subn('(?m)^__version__\s*=.*$', '__version__ = ' + repr(version), - input) + content) assert count == 1, "failed to replace version string" - if not dry_run: - os.unlink(path) # sdist likes to use hardlinks - with open(path, "w") as fh: - fh.write(output) + _replace_file(path, content, dry_run=dry_run) + + # + # update flag in setup.py + # (not present when called from bdist_wheel, etc) + # + path = os.path.join(base_dir, "setup.py") + if os.path.exists(path): + content = _get_file(path) + content, count = re.subn('(?m)^stamp_build\s*=.*$', + 'stamp_build = False', content) + assert count == 1, "failed to update 'stamp_build' flag" + _replace_file(path, content, dry_run=dry_run) + def stamp_distutils_output(opts, version): @@ -51,6 +95,56 @@ def stamp_distutils_output(opts, version): stamp_source(base_dir, version, self.dry_run) opts['cmdclass']['sdist'] = sdist + +def as_bool(value): + return (value or "").lower() in "yes y true t 1".split() + + +def append_hg_revision(version): + + # call HG via subprocess + # NOTE: for py26 compat, using Popen() instead of check_output() + try: + proc = subprocess.Popen(["hg", "tip", "--template", "{date(date, '%Y%m%d%H%M%S')}+hg.{node|short}"], + stdout=subprocess.PIPE) + stamp, _ = proc.communicate() + if proc.returncode: + raise subprocess.CalledProcessError(1, []) + stamp = stamp.decode("ascii") + except (OSError, subprocess.CalledProcessError): + # fallback - just use build date + stamp = time.strftime("%Y%m%d%H%M%S") + + # modify version + if version.endswith((".dev0", ".post0")): + version = version[:-1] + stamp + else: + version += ".post" + stamp + + return version + +def install_build_py_exclude(opts): + + _build_py = get_command_class(opts, "build_py") + + class build_py(_build_py): + + user_options = _build_py.user_options + [ + ("exclude-packages=", None, + "exclude packages from builds"), + ] + + exclude_packages = None + + def finalize_options(self): + _build_py.finalize_options(self) + target = self.packages + for package in self.exclude_packages or []: + if package in target: + target.remove(package) + + opts['cmdclass']['build_py'] = build_py + #============================================================================= # eof #============================================================================= diff --git a/passlib/pwd.py b/passlib/pwd.py index 274666a..52e1e64 100644 --- a/passlib/pwd.py +++ b/passlib/pwd.py @@ -26,6 +26,7 @@ __all__ = [ # constants #============================================================================= +# XXX: rename / publically document this map? entropy_aliases = dict( # barest protection from throttled online attack unsafe=12, @@ -435,21 +436,24 @@ def genword(entropy=None, length=None, returns=None, **kwds): '310f1a7ac793f' :param entropy: - Strength of resulting password, measured in bits of Shannon entropy - (defaults to 48). An appropriate **length** value will be calculated + Strength of resulting password, measured in 'guessing entropy' bits. + An appropriate **length** value will be calculated based on the requested entropy amount, and the size of the character set. - If both ``entropy`` and ``length`` are specified, - the stronger value will be used. - - This can also be one of a handful of aliases to predefined - entropy amounts: ``"weak"`` (24), ``"fair"`` (36), + This can be a positive integer, or one of the following preset + strings: ``"weak"`` (24), ``"fair"`` (36), ``"strong"`` (48), and ``"secure"`` (56). + If neither this or **length** is specified, **entropy** will default + to ``"strong"`` (48). + :param length: Size of resulting password, measured in characters. If omitted, the size is auto-calculated based on the **entropy** parameter. + If both **entropy** and **length** are specified, + the stronger value will be used. + :param returns: Controls what this function returns: @@ -457,9 +461,15 @@ def genword(entropy=None, length=None, returns=None, **kwds): * If an integer, this function will return a list containing that many passwords. * If the ``iter`` constant, will return an iterator that yields passwords. + :param chars: + + Optionally specify custom string of characters to use when randomly + generating a password. This option cannot be combined with **charset**. + :param charset: - The character set to draw from, if not specified explicitly by **chars**. - Can be any of: + + The predefined character set to draw from (if not specified by **chars**). + There are currently four presets available: * ``"ascii_62"`` (the default) -- all digits and ascii upper & lowercase letters. Provides ~5.95 entropy per character. @@ -472,11 +482,6 @@ def genword(entropy=None, length=None, returns=None, **kwds): * ``"hex"`` -- Lower case hexadecimal. Providers 4 bits of entropy per character. - :param chars: - - Optionally specify custom charset as a string of characters. - This option cannot be combined with **charset**. - :returns: :class:`!unicode` string containing randomly generated password; or list of 1+ passwords if :samp:`returns={int}` is specified. @@ -698,21 +703,24 @@ def genphrase(entropy=None, length=None, returns=None, **kwds): 'wheat dilemma reward rescue diary' :param entropy: - Strength of resulting password, measured in bits of Shannon entropy - (defaults to 48). An appropriate **length** value will be calculated - based on the requested entropy amount, and the size of the character set. + Strength of resulting password, measured in 'guessing entropy' bits. + An appropriate **length** value will be calculated + based on the requested entropy amount, and the size of the word set. - If both ``entropy`` and ``length`` are specified, - the stronger value will be used. - - This can also be one of a handful of aliases to predefined - entropy amounts: ``"weak"`` (24), ``"fair"`` (36), + This can be a positive integer, or one of the following preset + strings: ``"weak"`` (24), ``"fair"`` (36), ``"strong"`` (48), and ``"secure"`` (56). + If neither this or **length** is specified, **entropy** will default + to ``"strong"`` (48). + :param length: Length of resulting password, measured in words. If omitted, the size is auto-calculated based on the **entropy** parameter. + If both **entropy** and **length** are specified, + the stronger value will be used. + :param returns: Controls what this function returns: @@ -720,8 +728,14 @@ def genphrase(entropy=None, length=None, returns=None, **kwds): * If an integer, this function will return a list containing that many passwords. * If the ``iter`` builtin, will return an iterator that yields passwords. + :param words: + + Optionally specifies a list/set of words to use when randomly generating a passphrase. + This option cannot be combined with **wordset**. + :param wordset: - Optionally use a pre-defined word-set when generating a passphrase. + + The predefined word set to draw from (if not specified by **words**). There are currently four presets available: ``"eff_long"`` (the default) @@ -756,10 +770,6 @@ def genphrase(entropy=None, length=None, returns=None, **kwds): (at the cost of slightly less entropy); and much shorter than ``"eff_prefixed"`` (at the cost of a longer unique prefix). - :param words: - Optionally specifies a list/set of words to use when randomly generating a passphrase. - This option cannot be combined with **wordset**. - :param sep: Optional separator to use when joining words. Defaults to ``" "`` (a space), but can be an empty string, a hyphen, etc. diff --git a/passlib/tests/test_pwd.py b/passlib/tests/test_pwd.py index 6f6a9a5..2c983cd 100644 --- a/passlib/tests/test_pwd.py +++ b/passlib/tests/test_pwd.py @@ -65,6 +65,14 @@ class WordGeneratorTest(TestCase): """test generation routines""" descriptionPrefix = "passlib.pwd.genword()" + def setUp(self): + super(WordGeneratorTest, self).setUp() + + # patch some RNG references so they're reproducible. + from passlib.pwd import SequenceGenerator + self.patchAttr(SequenceGenerator, "rng", + self.getRandom("pwd generator")) + def assertResultContents(self, results, count, chars, unique=True): """check result list matches expected count & charset""" self.assertEqual(len(results), count) diff --git a/passlib/tests/utils.py b/passlib/tests/utils.py index 235fd76..62f3ab3 100644 --- a/passlib/tests/utils.py +++ b/passlib/tests/utils.py @@ -965,6 +965,10 @@ class HandlerCase(TestCase): self.addCleanup(handler.set_backend, handler.get_backend()) handler.set_backend(backend) + # patch some RNG references so they're reproducible. + from passlib.utils import handlers + self.patchAttr(handlers, "rng", self.getRandom("salt generator")) + #=================================================================== # basic tests #=================================================================== @@ -1254,21 +1258,22 @@ class HandlerCase(TestCase): """test hash() / genconfig() creates new salt each time""" self.require_salt() # odds of picking 'n' identical salts at random is '(.5**salt_bits)**n'. - # we want to pick the smallest N needed s.t. odds are <1/1000, just - # to eliminate false-positives. which works out to n>7-salt_bits. - # n=1 is sufficient for most hashes, but a few border cases (e.g. - # cisco_type7) have < 7 bits of salt, requiring more. - samples = max(1,7-self.salt_bits) + # we want to pick the smallest N needed s.t. odds are <1/10**d, just + # to eliminate false-positives. which works out to n>3.33+d-salt_bits. + # for 1/1e12 odds, n=1 is sufficient for most hashes, but a few border cases (e.g. + # cisco_type7) have < 16 bits of salt, requiring more. + samples = max(1, 4 + 12 - self.salt_bits) + def sampler(func): value1 = func() - for i in irange(samples): + for _ in irange(samples): value2 = func() if value1 != value2: return raise self.failureException("failed to find different salt after " "%d samples" % (samples,)) sampler(self.do_genconfig) - sampler(lambda : self.do_encrypt("stub")) + sampler(lambda: self.do_encrypt("stub")) def test_12_min_salt_size(self): """test hash() / genconfig() honors min_salt_size""" @@ -2,7 +2,7 @@ passlib setup script This script honors one environmental variable: -PASSLIB_SETUP_TAG_RELEASE +SETUP_TAG_RELEASE if "yes" (the default), revision tag is appended to version. for release, this is explicitly set to "no". """ @@ -16,67 +16,48 @@ os.chdir(root_dir) #============================================================================= # imports #============================================================================= -import re -from setuptools import setup, find_packages -import subprocess +import setuptools import sys -import time -PY3 = (sys.version_info[0] >= 3) #============================================================================= # init setup options #============================================================================= -opts = {"cmdclass": {}} -args = sys.argv[1:] - -#============================================================================= -# register docdist command (not required) -#============================================================================= -try: - from passlib._setup.docdist import docdist - opts['cmdclass']['docdist'] = docdist -except ImportError: - pass +opts = dict( + #================================================================== + # sources + #================================================================== + packages=setuptools.find_packages(root_dir), + package_data={ + "passlib.tests": ["*.cfg"], + "passlib": ["_data/wordsets/*.txt"], + }, + zip_safe=True, -#============================================================================= -# version string / datestamps -#============================================================================= + #================================================================== + # metadata + #================================================================== + name="passlib", + # NOTE: 'version' set below + author="Eli Collins", + author_email="elic@assurancetechnologies.com", + license="BSD", -# pull version string from passlib -from passlib import __version__ as version + url="https://bitbucket.org/ecollins/passlib", + # NOTE: 'download_url' set below -# by default, stamp HG revision to end of version -if os.environ.get("PASSLIB_SETUP_TAG_RELEASE", "y").lower() in "yes y true t 1".split(): - # call HG via subprocess - # NOTE: for py26 compat, using Popen() instead of check_output() - try: - proc = subprocess.Popen(["hg", "tip", "--template", "{date(date, '%Y%m%d%H%M%S')}+hg.{node|short}"], - stdout=subprocess.PIPE) - stamp, _ = proc.communicate() - if proc.returncode: - raise subprocess.CalledProcessError(1, []) - stamp = stamp.decode("ascii") - except (OSError, subprocess.CalledProcessError): - # fallback - just use build date - stamp = time.strftime("%Y%m%d%H%M%S") - - # modify version - if version.endswith((".dev0", ".post0")): - version = version[:-1] + stamp - else: - version += ".post" + stamp - - # subclass build_py & sdist so they rewrite passlib/__init__.py - # to have the correct version string - from passlib._setup.stamp import stamp_distutils_output - stamp_distutils_output(opts, version) + extras_require={ + "argon2": "argon2_cffi>=16.2", + "bcrypt": "bcrypt>=3.1.0", + "totp": "cryptography", + }, -#============================================================================= -# static text -#============================================================================= -SUMMARY = "comprehensive password hashing framework supporting over 30 schemes" + #================================================================== + # details + #================================================================== + description= + "comprehensive password hashing framework supporting over 30 schemes", -DESCRIPTION = """\ + long_description="""\ Passlib is a password hashing library for Python 2 & 3, which provides cross-platform implementations of over 30 password hashing algorithms, as well as a framework for managing existing password hashes. It's designed to be useful @@ -94,85 +75,106 @@ providing full-strength password hashing for multi-user applications. All releases are signed with the gpg key `4D8592DF4CE1ED31 <http://pgp.mit.edu:11371/pks/lookup?op=get&search=0x4D8592DF4CE1ED31>`_. -""" +""", -KEYWORDS = """\ + keywords="""\ password secret hash security crypt md5-crypt sha256-crypt sha512-crypt pbkdf2 argon2 scrypt bcrypt apache htpasswd htdigest totp 2fa -""" +""", -CLASSIFIERS = """\ + classifiers="""\ Intended Audience :: Developers License :: OSI Approved :: BSD License Natural Language :: English Operating System :: OS Independent +Programming Language :: Python :: 2 Programming Language :: Python :: 2.6 Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 +Programming Language :: Python :: 3.3 +Programming Language :: Python :: 3.4 +Programming Language :: Python :: 3.5 +Programming Language :: Python :: 3.6 Programming Language :: Python :: Implementation :: CPython Programming Language :: Python :: Implementation :: Jython Programming Language :: Python :: Implementation :: PyPy Topic :: Security :: Cryptography Topic :: Software Development :: Libraries -""".splitlines() +""".splitlines(), -# TODO: "Programming Language :: Python :: Implementation :: IronPython" -- issue 34 + # TODO: add "Programming Language :: Python :: Implementation :: IronPython" + # (blocked by issue 34) -is_release = False -if '.dev' in version: - CLASSIFIERS.append("Development Status :: 3 - Alpha") -elif '.post' in version: - CLASSIFIERS.append("Development Status :: 4 - Beta") -else: - is_release = True - CLASSIFIERS.append("Development Status :: 5 - Production/Stable") + #================================================================== + # testing + #================================================================== + tests_require='nose >= 1.1', + test_suite='nose.collector', + + #================================================================== + # custom setup + #================================================================== + script_args=sys.argv[1:], + cmdclass={}, +) #============================================================================= -# run setup +# set version string #============================================================================= -# XXX: could omit 'passlib._setup' from eggs, but not sdist -setup( - # package info - packages=find_packages(root_dir), - package_data={ - "passlib.tests": ["*.cfg"], - "passlib": ["_data/wordsets/*.txt"], - }, - zip_safe=True, - # metadata - name="passlib", - version=version, - author="Eli Collins", - author_email="elic@assurancetechnologies.com", - license="BSD", +# pull version string from passlib +from passlib import __version__ as version - url="https://bitbucket.org/ecollins/passlib", - download_url= - ("https://pypi.python.org/packages/source/p/passlib/passlib-" + version + ".tar.gz") - if is_release else None, +# append hg revision to builds +stamp_build = True # NOTE: modified by stamp_distutils_output() +if stamp_build: + from passlib._setup.stamp import ( + as_bool, append_hg_revision, stamp_distutils_output, + install_build_py_exclude, set_command_options + ) - description=SUMMARY, - long_description=DESCRIPTION, - keywords=KEYWORDS, - classifiers=CLASSIFIERS, + # add HG revision to end of version + if as_bool(os.environ.get("SETUP_TAG_RELEASE", "yes")): + version = append_hg_revision(version) - tests_require='nose >= 1.1', - test_suite='nose.collector', + # subclass build_py & sdist to rewrite source version string, + # and clears stamp_build flag so this doesn't run again. + stamp_distutils_output(opts, version) - extras_require={ - "argon2": "argon2_cffi>=16.2", - "bcrypt": "bcrypt>=3.1.0", - "totp": "cryptography", - }, + # exclude 'passlib._setup' from builds, only needed for sdist + install_build_py_exclude(opts) + set_command_options(opts, "build_py", + exclude_packages=["passlib._setup"], + ) - # extra opts - script_args=args, - **opts -) +opts['version'] = version + +#============================================================================= +# set release status +#============================================================================= + +if '.dev' in version: + status = "Development Status :: 3 - Alpha" +elif '.post' in version: + status = "Development Status :: 4 - Beta" +else: + status = "Development Status :: 5 - Production/Stable" + + # only list download url for final release + opts.update( + download_url=("https://pypi.python.org/packages/source/p/passlib/" + "passlib-" + version + ".tar.gz") + ) + +opts['classifiers'].append(status) + +#============================================================================= +# run setup +#============================================================================= +setuptools.setup(**opts) #============================================================================= # eof |