Repository: takluyver/flit
Pull request #154 (2017-12-08 15:04:25)

More validation of packaging data
This should make it easier to catch mistakes in packaging sooner.

The design is slightly awkward: a series of validation functions build up a list of problems. These are logged at 'error' level, and then a ConfigError is raised. I'm doing it this way so that it's easy to see multiple problems at once, not just the first one it finds.


Patch:
diff --git a/doc/cmdline.rst b/doc/cmdline.rst
index 520b497..c2a1703 100644
--- a/doc/cmdline.rst
+++ b/doc/cmdline.rst
@@ -139,6 +139,19 @@ Environment variables
    See :ref:`uploading packages with environment variables <upload_envvars>`
    for more information.
 
+.. envvar:: FLIT_ALLOW_INVALID
+
+   .. versionadded:: 0.13
+
+   Setting this to any non-empty value tells Flit to continue if it detects
+   invalid metadata, instead of failing with an error. Problems will still be
+   reported in the logs, but won't cause Flit to stop.
+
+   If the metadata is invalid, uploading the package to PyPI may fail. This
+   environment variable provides an escape hatch in case Flit incorrectly
+   rejects your valid metadata. If you need to use it and you believe your
+   metadata is valid, please `open an issue <https://github.com/takluyver/flit/issues>`__.
+
 .. envvar:: SOURCE_DATE_EPOCH
 
    To make reproducible builds, set this to a timestamp as a number of seconds
diff --git a/flit/build.py b/flit/build.py
index a6e8559..ddf368d 100644
--- a/flit/build.py
+++ b/flit/build.py
@@ -7,7 +7,9 @@ from pathlib import Path
 import tarfile
 from tempfile import TemporaryDirectory
 from types import SimpleNamespace
+import sys
 
+from .inifile import ConfigError
 from .sdist import SdistBuilder
 from .wheel import wheel_main, make_wheel_in
 
@@ -33,19 +35,22 @@ def main(ini_file: Path, formats=None):
 
     sdist_info = wheel_info = None
 
-    if 'sdist' in formats:
-        sb = SdistBuilder(ini_file)
-        sdist_file = sb.build()
-        sdist_info = SimpleNamespace(builder=sb, file=sdist_file)
-        # When we're building both, build the wheel from the unpacked sdist.
-        # This helps ensure that the sdist contains all the necessary files.
-        if 'wheel' in formats:
-            dist_dir = ini_file.parent / 'dist'
-            with unpacked_tarball(sdist_file) as tmpdir:
-                log.debug('Building wheel from unpacked sdist %s', tmpdir)
-                tmp_ini_file = Path(tmpdir, ini_file.name)
-                wheel_info = make_wheel_in(tmp_ini_file, dist_dir)
-    elif 'wheel' in formats:
-        wheel_info = wheel_main(ini_file)
+    try:
+        if 'sdist' in formats:
+            sb = SdistBuilder(ini_file)
+            sdist_file = sb.build()
+            sdist_info = SimpleNamespace(builder=sb, file=sdist_file)
+            # When we're building both, build the wheel from the unpacked sdist.
+            # This helps ensure that the sdist contains all the necessary files.
+            if 'wheel' in formats:
+                dist_dir = ini_file.parent / 'dist'
+                with unpacked_tarball(sdist_file) as tmpdir:
+                    log.debug('Building wheel from unpacked sdist %s', tmpdir)
+                    tmp_ini_file = Path(tmpdir, ini_file.name)
+                    wheel_info = make_wheel_in(tmp_ini_file, dist_dir)
+        elif 'wheel' in formats:
+            wheel_info = wheel_main(ini_file)
+    except ConfigError as e:
+        sys.exit('Config error: {}'.format(e))
 
     return SimpleNamespace(wheel=wheel_info, sdist=sdist_info)
diff --git a/flit/common.py b/flit/common.py
index 9694ba6..833f31a 100644
--- a/flit/common.py
+++ b/flit/common.py
@@ -9,23 +9,6 @@ log = logging.getLogger(__name__)
 
 import re
 
-
-pep440re = re.compile('^'
-                   '([1-9]\d*!)?'
-                 '(0|[1-9]\d*)'
-               '(.(0|[1-9]\d*))*'
-        '((a|b|rc)(0|[1-9]\d*))?'
-          '(\.post(0|[1-9]\d*))?'
-           '(\.dev(0|[1-9]\d*))?'
-        '$')
-
-def _is_canonical(version)->bool:
-    """
-    Return whether or not the version string is canonical according to Pep 440
-    """
-    return pep440re.match(version) is not None
-
-
 class Module(object):
     """This represents the module/package that we are going to distribute
     """
@@ -131,7 +114,8 @@ def get_info_from_module(target):
         raise NoDocstringError('Cannot package module without docstring, or empty docstring. '
                                 'Please add a docstring to your module.')
 
-    check_version(version)
+
+    version = check_version(version)
 
     docstring_lines = docstring.lstrip().splitlines()
     return {'summary': docstring_lines[0],
@@ -139,14 +123,14 @@ def get_info_from_module(target):
 
 def check_version(version):
     """
-    Check whether a given version string match Pep 440
+    Check whether a given version string match PEP 440, and do normalisation.
 
-    Raise InvalidVersion/NoVersionError With relevant information if
+    Raise InvalidVersion/NoVersionError with relevant information if
     version is invalid.
 
-    Print a warning if the version is not canonical with respect to Pep440
+    Log a warning if the version is not canonical with respect to PEP 440.
 
-    Return whether the version is canonical with pep440
+    Returns the version in canonical PEP 440 format.
     """
     if not version:
         raise NoVersionError('Cannot package module without a version string. '
@@ -154,14 +138,12 @@ def check_version(version):
     if not isinstance(version, str):
         raise InvalidVersion('__version__ must be a string, not {}.'
                                 .format(type(version)))
-    if not version[0].isdigit():
-        raise InvalidVersion('__version__ must start with a number. It is {!r}.'
-                                .format(version))
-    canonical = _is_canonical(version)
-    if not canonical:
-        log.warning('Version string (%s) does not match Pep440.', version)
-
-    return canonical
+
+    # Import here to avoid circular import
+    from .validate import normalise_version
+    version = normalise_version(version)
+
+    return version
 
 
 script_template = """\
diff --git a/flit/inifile.py b/flit/inifile.py
index 5861cc6..3c1b9e0 100644
--- a/flit/inifile.py
+++ b/flit/inifile.py
@@ -3,11 +3,10 @@ import difflib
 import logging
 import os
 from pathlib import Path
-import sys
 
-import requests
 import pytoml as toml
 
+from .validate import validate_config
 from .vendorized.readme.rst import render
 import io
 
@@ -44,80 +43,6 @@ metadata_required_fields = {
     'home-page',
 }
 
-def get_cache_dir() -> Path:
-    """Locate a platform-appropriate cache directory for flit to use
-    
-    Does not ensure that the cache directory exists.
-    """
-    if os.name == 'posix' and sys.platform != 'darwin':
-        # Linux, Unix, AIX, etc.
-        # use ~/.cache if empty OR not set
-        xdg = os.environ.get("XDG_CACHE_HOME", None) or (os.path.expanduser('~/.cache'))
-        return Path(xdg, 'flit')
-
-    elif sys.platform == 'darwin':
-        return Path(os.path.expanduser('~'), 'Library/Caches/flit')
-
-    else:
-        # Windows (hopefully)
-        local = os.environ.get('LOCALAPPDATA', None) or (os.path.expanduser('~\\AppData\\Local'))
-        return Path(local, 'flit')
-
-def _verify_classifiers_cached(classifiers):
-    """Check classifiers against the downloaded list of known classifiers"""
-    with (get_cache_dir() / 'classifiers.lst').open(encoding='utf-8') as f:
-        valid_classifiers = set(l.strip() for l in f)
-
-    invalid = classifiers - valid_classifiers
-    if invalid:
-        raise ConfigError("Invalid classifiers:\n" +
-                          "\n".join(invalid))
-
-def _download_classifiers():
-    """Get the list of valid trove classifiers from PyPI"""
-    log.info('Fetching list of valid trove classifiers')
-    resp = requests.get('https://pypi.python.org/pypi?%3Aaction=list_classifiers')
-    resp.raise_for_status()
-
-    cache_dir = get_cache_dir()
-    try:
-        cache_dir.mkdir(parents=True)
-    except FileExistsError:
-        pass
-    with (get_cache_dir() / 'classifiers.lst').open('wb') as f:
-        f.write(resp.content)
-
-def verify_classifiers(classifiers):
-    """Verify trove classifiers from config file.
-    
-    Fetches and caches a list of known classifiers from PyPI. Setting the
-    environment variable FLIT_NO_NETWORK=1 will skip this if the classifiers
-    are not already cached.
-    """
-    classifiers = set(classifiers)
-    try:
-        _verify_classifiers_cached(classifiers)
-    except (FileNotFoundError, ConfigError) as e1:
-        # FileNotFoundError: We haven't yet got the classifiers cached
-        # ConfigError: At least one is invalid, but it may have been added since
-        #   last time we fetched them.
-
-        if os.environ.get('FLIT_NO_NETWORK', ''):
-            log.warning("Not checking classifiers, because FLIT_NO_NETWORK is set")
-            return
-
-        # Try to download up-to-date list of classifiers
-        try:
-            _download_classifiers()
-        except requests.ConnectionError:
-            # The error you get on a train, going through Oregon, without wifi
-            if isinstance(e1, ConfigError):
-                raise e1
-            else:
-                log.warning("Couldn't get list of valid classifiers to check against")
-        else:
-            _verify_classifiers_cached(classifiers)
-
 
 def read_pkg_ini(path: Path):
     """Read and check the `pyproject.toml` or `flit.ini` file with data about the package.
@@ -125,13 +50,20 @@ def read_pkg_ini(path: Path):
     if path.suffix == '.toml':
         with path.open() as f:
             d = toml.load(f)
-        return prep_toml_config(d, path)
+        res = prep_toml_config(d, path)
     else:
         # Treat all other extensions as the older flit.ini format
         cp = _read_pkg_ini(path)
-        return _validate_config(cp, path)
+        res = _validate_config(cp, path)
 
-class EntryPointsConflict(ValueError):
+    if validate_config(res):
+        if os.environ.get('FLIT_ALLOW_INVALID'):
+            log.warning("Allowing invalid data (FLIT_ALLOW_INVALID set). Uploads may still fail.")
+        else:
+            raise ConfigError("Invalid config values (see log)")
+    return res
+
+class EntryPointsConflict(ConfigError):
     def __str__(self):
         return ('Please specify console_scripts entry points, or [scripts] in '
             'flit config, not both.')
@@ -167,7 +99,6 @@ def prep_toml_config(d, path):
     else:
         entrypoints = {}
     _add_scripts_to_entrypoints(entrypoints, scripts_dict)
-    _validate_entrypoints(entrypoints)
 
     return {
         'module': module,
@@ -216,28 +147,6 @@ def _add_scripts_to_entrypoints(entrypoints, scripts_dict):
         else:
             entrypoints['console_scripts'] = scripts_dict
 
-def _validate_entrypoints(entrypoints):
-    """Check that the loaded entrypoints are valid.
-    
-    Expects a dict of dicts, e.g.::
-    
-        {'console_scripts': {'flit': 'flit:main'}}
-    """
-    def _is_identifier_attr(s):
-        return all(n.isidentifier() for n in s.split('.'))
-
-    for groupname, group in entrypoints.items():
-        for k, v in group.items():
-            if ':' in v:
-                mod, obj = v.split(':', 1)
-                valid = _is_identifier_attr(mod) and _is_identifier_attr(obj)
-            else:
-                valid = _is_identifier_attr(v)
-
-            if not valid:
-                raise ConfigError('Invalid entry point in group {}:\n'
-                                  '{} = {}'.format(groupname, k, v))
-
 
 def _read_pkg_ini(path):
     """Reads old-style flit.ini
@@ -365,9 +274,6 @@ def _validate_config(cp, path):
 
     md_dict, module = _prep_metadata(md_sect, path)
 
-    if 'classifiers' in md_dict:
-        verify_classifiers(md_dict['classifiers'])
-
     # Scripts ---------------
     if cp.has_section('scripts'):
         scripts_dict = dict(cp['scripts'])
@@ -375,7 +281,6 @@ def _validate_config(cp, path):
         scripts_dict = {}
 
     _add_scripts_to_entrypoints(entrypoints, scripts_dict)
-    _validate_entrypoints(entrypoints)
 
     return {
         'module': module,
diff --git a/flit/validate.py b/flit/validate.py
new file mode 100644
index 0000000..8438c0e
--- /dev/null
+++ b/flit/validate.py
@@ -0,0 +1,336 @@
+"""Validate various pieces of packaging data"""
+
+import logging
+import os
+from pathlib import Path
+import re
+import requests
+import sys
+
+from .common import InvalidVersion
+
+log = logging.getLogger(__name__)
+
+def get_cache_dir() -> Path:
+    """Locate a platform-appropriate cache directory for flit to use
+
+    Does not ensure that the cache directory exists.
+    """
+    # Linux, Unix, AIX, etc.
+    if os.name == 'posix' and sys.platform != 'darwin':
+        # use ~/.cache if empty OR not set
+        xdg = os.environ.get("XDG_CACHE_HOME", None) \
+              or os.path.expanduser('~/.cache')
+        return Path(xdg, 'flit')
+
+    # Mac OS
+    elif sys.platform == 'darwin':
+        return Path(os.path.expanduser('~'), 'Library/Caches/flit')
+
+    # Windows (hopefully)
+    else:
+        local = os.environ.get('LOCALAPPDATA', None) \
+                or os.path.expanduser('~\\AppData\\Local')
+        return Path(local, 'flit')
+
+def _verify_classifiers_cached(classifiers):
+    """Check classifiers against the downloaded list of known classifiers"""
+    with (get_cache_dir() / 'classifiers.lst').open(encoding='utf-8') as f:
+        valid_classifiers = set(l.strip() for l in f)
+
+    invalid = classifiers - valid_classifiers
+    return ["Unrecognised classifier: {!r}".format(c)
+            for c in sorted(invalid)]
+
+
+def _download_classifiers():
+    """Get the list of valid trove classifiers from PyPI"""
+    log.info('Fetching list of valid trove classifiers')
+    resp = requests.get(
+        'https://pypi.org/pypi?%3Aaction=list_classifiers')
+    resp.raise_for_status()
+
+    cache_dir = get_cache_dir()
+    try:
+        cache_dir.mkdir(parents=True)
+    except FileExistsError:
+        pass
+    with (get_cache_dir() / 'classifiers.lst').open('wb') as f:
+        f.write(resp.content)
+
+
+def validate_classifiers(classifiers):
+    """Verify trove classifiers from config file.
+
+    Fetches and caches a list of known classifiers from PyPI. Setting the
+    environment variable FLIT_NO_NETWORK=1 will skip this if the classifiers
+    are not already cached.
+    """
+    if not classifiers:
+        return []
+
+    problems = []
+    classifiers = set(classifiers)
+    try:
+        problems = _verify_classifiers_cached(classifiers)
+    except FileNotFoundError as e1:
+        # We haven't yet got the classifiers cached
+        pass
+    else:
+        if not problems:
+            return []
+
+    # Either we don't have the list, or there were unexpected classifiers
+    # which might have been added since we last fetched it. Fetch and cache.
+
+    if os.environ.get('FLIT_NO_NETWORK', ''):
+        log.warning(
+            "Not checking classifiers, because FLIT_NO_NETWORK is set")
+        return []
+
+    # Try to download up-to-date list of classifiers
+    try:
+        _download_classifiers()
+    except requests.ConnectionError:
+        # The error you get on a train, going through Oregon, without wifi
+        log.warning(
+            "Couldn't get list of valid classifiers to check against")
+        return problems
+    else:
+        return _verify_classifiers_cached(classifiers)
+
+
+def validate_entrypoints(entrypoints):
+    """Check that the loaded entrypoints are valid.
+
+    Expects a dict of dicts, e.g.::
+
+        {'console_scripts': {'flit': 'flit:main'}}
+    """
+
+    def _is_identifier_attr(s):
+        return all(n.isidentifier() for n in s.split('.'))
+
+    problems = []
+    for groupname, group in entrypoints.items():
+        for k, v in group.items():
+            if ':' in v:
+                mod, obj = v.split(':', 1)
+                valid = _is_identifier_attr(mod) and _is_identifier_attr(obj)
+            else:
+                valid = _is_identifier_attr(v)
+
+            if not valid:
+                problems.append('Invalid entry point in group {}: '
+                                '{} = {}'.format(groupname, k, v))
+    return problems
+
+# Distribution name, not quite the same as a Python identifier
+NAME = re.compile(r'^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$', re.IGNORECASE)
+r''
+VERSION_SPEC = re.compile(r'(~=|===?|!=|<=?|>=?)\s*[A-Z0-9\-_.*+!]+$', re.IGNORECASE)
+REQUIREMENT = re.compile(NAME.pattern[:-1] +  # Trim '$'
+     r"""\s*(?P<extras>\[.*\])?
+         \s*(?P<version>[(=~<>!][^;]*)?
+         \s*(?P<envmark>;.*)?
+     $""", re.IGNORECASE | re.VERBOSE)
+MARKER_OP = re.compile(r'(~=|===?|!=|<=?|>=?|\s+in\s+|\s+not in\s+)')
+
+def validate_name(metadata):
+    name = metadata.get('name', None)
+    if name is None or NAME.match(name):
+        return []
+    return ['Invalid name: {!r}'.format(name)]
+
+
+def _valid_version_specifier(s):
+    for clause in s.split(','):
+        if not VERSION_SPEC.match(clause.strip()):
+            return False
+    return True
+
+def validate_requires_python(metadata):
+    spec = metadata.get('requires_python', None)
+    if spec is None or _valid_version_specifier(spec):
+        return []
+    return ['Invalid requires-python: {!r}'.format(spec)]
+
+MARKER_VARS = {
+    'python_version', 'python_full_version', 'os_name', 'sys_platform',
+    'platform_release', 'platform_system', 'platform_version', 'platform_machine',
+    'platform_python_implementation', 'implementation_name',
+    'implementation_version', 'extra',
+}
+
+def validate_environment_marker(em):
+    clauses = re.split(r'\s+(?:and|or)\s+', em)
+    problems = []
+    for c in clauses:
+        parts = MARKER_OP.split(c)
+        if len(parts) != 3:
+            problems.append("Invalid expression in environment marker: {!r}".format(c))
+            continue
+        l, op, r = parts
+        for var in (l.strip(), r.strip()):
+            if var[:1] in {'"', "'"}:
+                if len(var) < 2 or var[-1:] != var[:1]:
+                    problems.append("Invalid string in environment marker: {}".format(var))
+            elif var not in MARKER_VARS:
+                problems.append("Invalid variable name in environment marker: {!r}".format(var))
+    return problems
+
+def validate_requires_dist(metadata):
+    probs = []
+    for req in metadata.get('requires_dist', []):
+        m = REQUIREMENT.match(req)
+        if not m:
+            probs.append("Could not parse requirement: {!r}".format(req))
+            continue
+
+        extras, version, envmark = m.group('extras', 'version', 'envmark')
+        if not (extras is None or all(NAME.match(e.strip())
+                                      for e in extras[1:-1].split(','))):
+            probs.append("Invalid extras in requirement: {!r}".format(req))
+        if version is not None:
+            if version.startswith('(') and version.endswith(')'):
+                version = version[1:-1]
+            if not _valid_version_specifier(version):
+                print((extras, version, envmark))
+                probs.append("Invalid version specifier {!r} in requirement {!r}"
+                             .format(version, req))
+        if envmark is not None:
+            probs.extend(validate_environment_marker(envmark[1:]))
+    return probs
+
+def validate_config(config_info):
+    i = config_info
+    problems = sum([
+        validate_classifiers(i['metadata'].get('classifiers')),
+        validate_entrypoints(i['entrypoints']),
+        validate_name(i['metadata']),
+        validate_requires_python(i['metadata']),
+        validate_requires_dist(i['metadata']),
+                   ], [])
+
+    for p in problems:
+        log.error(p)
+    return problems
+
+# Regex below from packaging, via PEP 440. BSD License:
+# Copyright (c) Donald Stufft and individual contributors.
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#     1. Redistributions of source code must retain the above copyright notice,
+#        this list of conditions and the following disclaimer.
+#
+#     2. Redistributions in binary form must reproduce the above copyright
+#        notice, this list of conditions and the following disclaimer in the
+#        documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+VERSION_PERMISSIVE = re.compile(r"""
+    \s*v?
+    (?:
+        (?:(?P<epoch>[0-9]+)!)?                           # epoch
+        (?P<release>[0-9]+(?:\.[0-9]+)*)                  # release segment
+        (?P<pre>                                          # pre-release
+            [-_\.]?
+            (?P<pre_l>(a|b|c|rc|alpha|beta|pre|preview))
+            [-_\.]?
+            (?P<pre_n>[0-9]+)?
+        )?
+        (?P<post>                                         # post release
+            (?:-(?P<post_n1>[0-9]+))
+            |
+            (?:
+                [-_\.]?
+                (?P<post_l>post|rev|r)
+                [-_\.]?
+                (?P<post_n2>[0-9]+)?
+            )
+        )?
+        (?P<dev>                                          # dev release
+            [-_\.]?
+            (?P<dev_l>dev)
+            [-_\.]?
+            (?P<dev_n>[0-9]+)?
+        )?
+    )
+    (?:\+(?P<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
+\s*$""", re.VERBOSE)
+
+pre_spellings = {
+    'a': 'a', 'alpha': 'a',
+    'b': 'b', 'beta': 'b',
+    'rc': 'rc', 'c': 'rc', 'pre': 'rc', 'preview': 'rc',
+}
+
+def normalise_version(orig_version):
+    """Normalise version number according to rules in PEP 440
+
+    Raises InvalidVersion if the version does not match PEP 440. This can be
+    overridden with the FLIT_ALLOW_INVALID environment variable.
+
+    https://www.python.org/dev/peps/pep-0440/#normalization
+    """
+    version = orig_version.lower()
+    m = VERSION_PERMISSIVE.match(version)
+    if not m:
+        if os.environ.get('FLIT_ALLOW_INVALID'):
+            log.warning("Invalid version number {!r} allowed by FLIT_ALLOW_INVALID"
+                        .format(orig_version))
+            return version
+        else:
+            raise InvalidVersion("Version number {!r} does not match PEP 440 rules"
+                                 .format(orig_version))
+
+    components = []
+    add = components.append
+
+    epoch, release = m.group('epoch', 'release')
+    if epoch is not None:
+        add(str(int(epoch)) + '!')
+    add('.'.join(str(int(rp)) for rp in release.split('.')))
+
+    pre_l, pre_n = m.group('pre_l', 'pre_n')
+    if pre_l is not None:
+        pre_l = pre_spellings[pre_l]
+        pre_n = '0' if pre_n is None else str(int(pre_n))
+        add(pre_l + pre_n)
+
+    post_n1, post_l, post_n2 = m.group('post_n1', 'post_l', 'post_n2')
+    if post_n1 is not None:
+        add('.post' + str(int(post_n1)))
+    elif post_l is not None:
+        post_n = '0' if post_n2 is None else str(int(post_n2))
+        add('.post' + str(int(post_n)))
+
+    dev_l, dev_n = m.group('dev_l', 'dev_n')
+    if dev_l is not None:
+        dev_n = '0' if dev_n is None else str(int(dev_n))
+        add('.dev' + dev_n)
+
+    local = m.group('local')
+    if local is not None:
+        local = local.replace('-', '.').replace('_', '.')
+        l = [str(int(c)) if c.isdigit() else c
+             for c in local.split('.')]
+        add('+' + '.'.join(l))
+
+    version = ''.join(components)
+    if version != orig_version:
+        log.warning("Version number normalised: {!r} -> {!r} (see PEP 440)"
+                    .format(orig_version, version))
+    return version


Test patch:
diff --git a/tests/test_build.py b/tests/test_build.py
new file mode 100644
index 0000000..552bd2a
--- /dev/null
+++ b/tests/test_build.py
@@ -0,0 +1,34 @@
+from pathlib import Path
+import shutil
+import sys
+from tempfile import TemporaryDirectory
+from testpath import assert_isdir, MockCommand
+
+from flit import build
+
+samples_dir = Path(__file__).parent / 'samples'
+
+LIST_FILES = """\
+#!{python}
+import sys
+from os.path import join
+if '--deleted' not in sys.argv:
+    print('pyproject.toml')
+    print('module1.py')
+    print('EG_README.rst')
+""".format(python=sys.executable)
+
+def test_build_main():
+    with TemporaryDirectory() as td:
+        pyproject = Path(td, 'pyproject.toml')
+        shutil.copy(str(samples_dir / 'module1-pkg.toml'), str(pyproject))
+        shutil.copy(str(samples_dir / 'module1.py'), td)
+        shutil.copy(str(samples_dir / 'EG_README.rst'), td)
+        Path(td, '.git').mkdir()   # Fake a git repo
+
+        with MockCommand('git', LIST_FILES):
+            res = build.main(pyproject)
+        assert res.wheel.file.suffix == '.whl'
+        assert res.sdist.file.name.endswith('.tar.gz')
+
+        assert_isdir(Path(td, 'dist'))
diff --git a/tests/test_common.py b/tests/test_common.py
index 0ea44f0..9b6b70d 100644
--- a/tests/test_common.py
+++ b/tests/test_common.py
@@ -3,7 +3,7 @@ from pathlib import Path
 from unittest import TestCase
 import pytest
 
-from flit.common import (Module, get_info_from_module, InvalidVersion,
+from flit.common import (Module, get_info_from_module, InvalidVersion, NoVersionError,
      check_version, normalize_file_permissions
 )
 
@@ -58,8 +58,17 @@ class ModuleTests(TestCase):
         with pytest.raises(InvalidVersion):
             check_version('a.1.0.beta0')
 
-        assert check_version('4.1.0b1') == True
-        assert check_version('4.1.0beta1') == False
+        with pytest.raises(InvalidVersion):
+            check_version('3!')
+
+        with pytest.raises(InvalidVersion):
+            check_version((1, 2))
+
+        with pytest.raises(NoVersionError):
+            check_version(None)
+
+        assert check_version('4.1.0beta1') == '4.1.0b1'
+        assert check_version('v1.2') == '1.2'
 
 def test_normalize_file_permissions():
     assert normalize_file_permissions(0o100664) == 0o100644 # regular file
diff --git a/tests/test_validate.py b/tests/test_validate.py
new file mode 100644
index 0000000..1427f88
--- /dev/null
+++ b/tests/test_validate.py
@@ -0,0 +1,115 @@
+import pytest
+from flit.common import InvalidVersion
+from flit import validate as fv
+
+def test_validate_entrypoints():
+    assert fv.validate_entrypoints(
+        {'console_scripts': {'flit': 'flit:main'}}) == []
+    assert fv.validate_entrypoints(
+        {'some.group': {'flit': 'flit.buildapi'}}) == []
+
+    res = fv.validate_entrypoints({'some.group': {'flit': 'a:b:c'}})
+    assert len(res) == 1
+
+def test_validate_name():
+    def check(name):
+        return fv.validate_name({'name': name})
+
+    assert check('foo.bar_baz') == []
+    assert check('5minus6') == []
+
+    assert len(check('_foo')) == 1  # Must start with alphanumeric
+    assert len(check('foo.')) == 1  # Must end with alphanumeric
+    assert len(check('Bücher')) == 1 # ASCII only
+
+def test_validate_requires_python():
+    assert fv.validate_requires_python({}) == []  # Not required
+
+    def check(spec):
+        return fv.validate_requires_python({'requires_python': spec})
+
+    assert check('>=3') == []
+    assert check('>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*') == []
+
+    assert len(check('3')) == 1
+    assert len(check('@12')) == 1
+    assert len(check('>=2.7; !=3.0.*')) == 1  # Comma separated, not semicolon
+
+def test_validate_requires_dist():
+    assert fv.validate_requires_dist({}) == []  # Not required
+
+    def check(spec):
+        return fv.validate_requires_dist({'requires_dist': [spec]})
+
+    assert check('requests') == []
+    assert check('requests[extra-foo]') == []
+    assert check('requests (>=2.14)') == []  # parentheses allowed but not recommended
+    assert check('requests >=2.14') == []
+    assert check('pexpect; sys_platform == "win32"') == []
+    # Altogether now
+    assert check('requests[extra-foo] >=2.14; python_version < "3.0"') == []
+
+    assert len(check('Bücher')) == 1
+    assert len(check('requests 2.14')) == 1
+    assert len(check('pexpect; sys.platform == "win32"')) == 1  # '.' -> '_'
+    # Several problems in one requirement
+    assert len(check('pexpect[_foo] =3; sys.platform == "win32"')) == 3
+
+def test_validate_environment_marker():
+    vem = fv.validate_environment_marker
+
+    assert vem('python_version >= "3" and os_name == \'posix\'') == []
+
+    res = vem('python_version >= "3')  # Unclosed string
+    assert len(res) == 1
+    assert res[0].startswith("Invalid string")
+
+    res = vem('python_verson >= "3"')  # Misspelled name
+    assert len(res) == 1
+    assert res[0].startswith("Invalid variable")
+
+    res = vem("os_name is 'posix'")  # No 'is' comparisons
+    assert len(res) == 1
+    assert res[0].startswith("Invalid expression")
+
+    res = vem("'2' < python_version < '4'")  # No chained comparisons
+    assert len(res) == 1
+    assert res[0].startswith("Invalid expression")
+
+    assert len(vem('os.name == "linux\'')) == 2
+
+def test_normalise_version():
+    nv = fv.normalise_version
+    assert nv('4.3.1') == '4.3.1'
+    assert nv('1.0b2') == '1.0b2'
+    assert nv('2!1.3') == '2!1.3'
+
+    # Prereleases
+    assert nv('1.0B2') == '1.0b2'
+    assert nv('1.0.b2') == '1.0b2'
+    assert nv('1.0beta2') == '1.0b2'
+    assert nv('1.01beta002') == '1.1b2'
+    assert nv('1.0-preview2') == '1.0rc2'
+    assert nv('1.0_c') == '1.0rc0'
+
+    # Post releases
+    assert nv('1.0post-2') == '1.0.post2'
+    assert nv('1.0post') == '1.0.post0'
+    assert nv('1.0-rev3') == '1.0.post3'
+    assert nv('1.0-2') == '1.0.post2'
+
+    # Development versions
+    assert nv('1.0dev-2') == '1.0.dev2'
+    assert nv('1.0dev') == '1.0.dev0'
+    assert nv('1.0-dev3') == '1.0.dev3'
+
+    assert nv('1.0+ubuntu-01') == '1.0+ubuntu.1'
+    assert nv('v1.3-pre2') == '1.3rc2'
+    assert nv(' 1.2.5.6\t') == '1.2.5.6'
+    assert nv('1.0-alpha3-post02+ubuntu_xenial_5') == '1.0a3.post2+ubuntu.xenial.5'
+
+    with pytest.raises(InvalidVersion):
+        nv('3!')
+
+    with pytest.raises(InvalidVersion):
+        nv('abc')