Skip to content

Instantly share code, notes, and snippets.

@dstufft
Created September 2, 2012 15:18
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dstufft/3600388 to your computer and use it in GitHub Desktop.
Save dstufft/3600388 to your computer and use it in GitHub Desktop.
_pep345.py is a monkeypatcher and utility function to bring PEP345 (metadata 1.2) to distutils1/setuptools (Metadata only, no additional functionality)
import copy
# ----- START [distutils2.version] ----- #
import re
_FINAL_MARKER = ('z',)
_VERSION_RE = re.compile(r'''
^
(?P<version>\d+\.\d+) # minimum 'N.N'
(?P<extraversion>(?:\.\d+)*) # any number of extra '.N' segments
(?:
(?P<prerel>[abc]|rc) # 'a'=alpha, 'b'=beta, 'c'=release candidate
# 'rc'= alias for release candidate
(?P<prerelversion>\d+(?:\.\d+)*)
)?
(?P<postdev>(\.post(?P<post>\d+))?(\.dev(?P<dev>\d+))?)?
$''', re.VERBOSE)
class IrrationalVersionError(Exception):
"""This is an irrational version."""
pass
class HugeMajorVersionNumError(IrrationalVersionError):
"""An irrational version because the major version number is huge
(often because a year or date was used).
See `error_on_huge_major_num` option in `NormalizedVersion` for details.
This guard can be disabled by setting that option False.
"""
pass
class NormalizedVersion(object):
"""A rational version.
Good:
1.2 # equivalent to "1.2.0"
1.2.0
1.2a1
1.2.3a2
1.2.3b1
1.2.3c1
1.2.3.4
TODO: fill this out
Bad:
1 # mininum two numbers
1.2a # release level must have a release serial
1.2.3b
"""
def __init__(self, s, error_on_huge_major_num=True,
drop_trailing_zeros=False):
"""Create a NormalizedVersion instance from a version string.
@param s {str} The version string.
@param error_on_huge_major_num {bool} Whether to consider an
apparent use of a year or full date as the major version number
an error. Default True. One of the observed patterns on PyPI before
the introduction of `NormalizedVersion` was version numbers like
this:
2009.01.03
20040603
2005.01
This guard is here to strongly encourage the package author to
use an alternate version, because a release deployed into PyPI
and, e.g. downstream Linux package managers, will forever remove
the possibility of using a version number like "1.0" (i.e.
where the major number is less than that huge major number).
@param drop_trailing_zeros {bool} Whether to drop trailing zeros
from the returned list. Default True.
"""
self.is_final = True # by default, consider a version as final.
self.drop_trailing_zeros = drop_trailing_zeros
self._parse(s, error_on_huge_major_num)
@classmethod
def from_parts(cls, version, prerelease=_FINAL_MARKER, devpost=_FINAL_MARKER):
return cls(cls.parts_to_str((version, prerelease, devpost)))
def _parse(self, s, error_on_huge_major_num=True):
"""Parses a string version into parts."""
match = _VERSION_RE.search(s)
if not match:
raise IrrationalVersionError(s)
groups = match.groupdict()
parts = []
# main version
block = self._parse_numdots(groups['version'], s, 2)
extraversion = groups.get('extraversion')
if extraversion not in ('', None):
block += self._parse_numdots(extraversion[1:], s)
parts.append(tuple(block))
# prerelease
prerel = groups.get('prerel')
if prerel is not None:
block = [prerel]
block += self._parse_numdots(groups.get('prerelversion'), s,
pad_zeros_length=1)
parts.append(tuple(block))
self.is_final = False
else:
parts.append(_FINAL_MARKER)
# postdev
if groups.get('postdev'):
post = groups.get('post')
dev = groups.get('dev')
postdev = []
if post is not None:
postdev.extend((_FINAL_MARKER[0], 'post', int(post)))
if dev is None:
postdev.append(_FINAL_MARKER[0])
if dev is not None:
postdev.extend(('dev', int(dev)))
self.is_final = False
parts.append(tuple(postdev))
else:
parts.append(_FINAL_MARKER)
self.parts = tuple(parts)
if error_on_huge_major_num and self.parts[0][0] > 1980:
raise HugeMajorVersionNumError("huge major version number, %r, "
"which might cause future problems: %r" % (self.parts[0][0], s))
def _parse_numdots(self, s, full_ver_str, pad_zeros_length=0):
"""Parse 'N.N.N' sequences, return a list of ints.
@param s {str} 'N.N.N...' sequence to be parsed
@param full_ver_str {str} The full version string from which this
comes. Used for error strings.
@param pad_zeros_length {int} The length to which to pad the
returned list with zeros, if necessary. Default 0.
"""
nums = []
for n in s.split("."):
if len(n) > 1 and n[0] == '0':
raise IrrationalVersionError("cannot have leading zero in "
"version number segment: '%s' in %r" % (n, full_ver_str))
nums.append(int(n))
if self.drop_trailing_zeros:
while nums and nums[-1] == 0:
nums.pop()
while len(nums) < pad_zeros_length:
nums.append(0)
return nums
def __str__(self):
return self.parts_to_str(self.parts)
@classmethod
def parts_to_str(cls, parts):
"""Transforms a version expressed in tuple into its string
representation."""
# XXX This doesn't check for invalid tuples
main, prerel, postdev = parts
s = '.'.join(str(v) for v in main)
if prerel is not _FINAL_MARKER:
s += prerel[0]
s += '.'.join(str(v) for v in prerel[1:])
# XXX clean up: postdev is always true; code is obscure
if postdev and postdev is not _FINAL_MARKER:
if postdev[0] == _FINAL_MARKER[0]:
postdev = postdev[1:]
i = 0
while i < len(postdev):
if i % 2 == 0:
s += '.'
s += str(postdev[i])
i += 1
return s
def __repr__(self):
return "%s('%s')" % (self.__class__.__name__, self)
def _cannot_compare(self, other):
raise TypeError("cannot compare %s and %s"
% (type(self).__name__, type(other).__name__))
def __eq__(self, other):
if not isinstance(other, NormalizedVersion):
self._cannot_compare(other)
return self.parts == other.parts
def __lt__(self, other):
if not isinstance(other, NormalizedVersion):
self._cannot_compare(other)
return self.parts < other.parts
def __ne__(self, other):
return not self.__eq__(other)
def __gt__(self, other):
return not (self.__lt__(other) or self.__eq__(other))
def __le__(self, other):
return self.__eq__(other) or self.__lt__(other)
def __ge__(self, other):
return self.__eq__(other) or self.__gt__(other)
# See http://docs.python.org/reference/datamodel#object.__hash__
def __hash__(self):
return hash(self.parts)
_PREDICATE = re.compile(r"(?i)^\s*(\w[\s\w-]*(?:\.\w*)*)(.*)")
_VERSIONS = re.compile(r"^\s*\((?P<versions>.*)\)\s*$|^\s*(?P<versions2>.*)\s*$")
_SPLIT_CMP = re.compile(r"^\s*(<=|>=|<|>|!=|==)\s*([^\s,]+)\s*$")
def _split_predicate(predicate):
match = _SPLIT_CMP.match(predicate)
if match is None:
# probably no op, we'll use "=="
comp, version = '==', predicate
else:
comp, version = match.groups()
return comp, version # NormalizedVersion(version)
class VersionPredicate(object):
"""Defines a predicate: ProjectName (>ver1,ver2, ..)"""
_operators = {"<": lambda x, y: x < y,
">": lambda x, y: x > y,
"<=": lambda x, y: str(x).startswith(str(y)) or x < y,
">=": lambda x, y: str(x).startswith(str(y)) or x > y,
"==": lambda x, y: str(x).startswith(str(y)),
"!=": lambda x, y: not str(x).startswith(str(y)),
}
def __init__(self, predicate):
self._string = predicate
predicate = predicate.strip()
match = _PREDICATE.match(predicate)
if match is None:
raise ValueError('Bad predicate "%s"' % predicate)
name, predicates = match.groups()
self.name = name.strip()
self.predicates = []
if predicates is None:
return
predicates = _VERSIONS.match(predicates.strip())
if predicates is None:
return
predicates = predicates.groupdict()
if predicates['versions'] is not None:
versions = predicates['versions']
else:
versions = predicates.get('versions2')
if versions is not None:
for version in versions.split(','):
if version.strip() == '':
continue
self.predicates.append(_split_predicate(version))
def match(self, version):
"""Check if the provided version matches the predicates."""
# if isinstance(version, basestring):
# version = NormalizedVersion(version)
for operator, predicate in self.predicates:
if not self._operators[operator](version, predicate):
return False
return True
def __repr__(self):
return self._string
# ----- END [distutils2.version] ----- #
# ----- START [distutils.dist] ----- #
import distutils.dist
from distutils.util import rfc822_escape
_DistributionMetadata = copy.deepcopy(distutils.dist.DistributionMetadata)
class DistributionMetadata(_DistributionMetadata):
_METHOD_BASENAMES = _DistributionMetadata._METHOD_BASENAMES + (
"requires_python", "requires_external",
"requires_dist", "provides_dist", "obsoletes_dist",
"project_url",
)
def __init__(self):
_DistributionMetadata.__init__(self)
# PEP 345
self.requires_python = None
self.requires_external = None
self.requires_dist = None
self.provides_dist = None
self.obsoletes_dist = None
self.project_url = None
def write_pkg_file(self, file):
"""
Write the PKG-INFO format data to a file object.
"""
version = "1.0"
if (self.provides or self.requires or self.obsoletes or self.classifiers or self.download_url):
version = "1.1"
if (self.requires_python or self.requires_external or self.requires_dist or self.provides_dist or self.obsoletes_dist or self.project_url):
version = "1.2"
file.write("Metadata-Version: %s\n" % version)
file.write("Name: %s\n" % self.get_name())
file.write("Version: %s\n" % self.get_version())
file.write("Summary: %s\n" % self.get_description())
file.write("Home-page: %s\n" % self.get_url())
file.write("Author: %s\n" % self.get_contact())
file.write("Author-email: %s\n" % self.get_contact_email())
file.write("License: %s\n" % self.get_license())
if self.download_url:
file.write("Download-URL: %s\n" % self.download_url)
long_desc = rfc822_escape(self.get_long_description())
file.write("Description: %s\n" % long_desc)
keywords = ",".join(self.get_keywords())
if keywords:
file.write("Keywords: %s\n" % keywords)
self._write_list(file, "Platform", self.get_platforms())
self._write_list(file, "Classifier", self.get_classifiers())
# PEP 314
self._write_list(file, "Requires", self.get_requires())
self._write_list(file, "Provides", self.get_provides())
self._write_list(file, "Obsoletes", self.get_obsoletes())
# PEP 345
if self.requires_python:
file.write("Requires-Python: %s\n" % self.get_requires_python())
self._write_list(file, "Requires-External", self.get_requires_external())
self._write_list(file, "Requires-Dist", self.get_requires_dist())
self._write_list(file, "Provides-Dist", self.get_provides_dist())
self._write_list(file, "Obsoletes-Dist", self.get_obsoletes_dist())
self._write_list(file, "Project-URL", self.get_project_url())
# PEP 345
def get_requires_python(self):
# @@@ Should there be any sort of semantic checks here?
return self.requires_python
def get_requires_external(self):
return self.requires_external or []
def get_requires_dist(self):
# @@@ Should there be any sort of semantic checks here?
return self.requires_dist or []
def get_provides_dist(self):
# @@@ Should there be any sort of semantic checks here?
provides = self.provides_dist or []
current = "%s (%s)" % (self.get_name(), self.get_version())
if not current in provides:
provides = [current] + provides
return provides
def get_obsoletes_dist(self):
# @@@ Should there be any sort of semantic checks here?
return self.obsoletes_dist or []
def get_project_url(self):
# @@@ Should there be any sort of semantic checks here?
project_urls = self.project_url or {}
return [",".join(x) for x in project_urls.items()]
distutils.dist.DistributionMetadata = DistributionMetadata
# ----- END [distutils.dist] ----- #
# ----- START [distutils.command.check] ----- #
try:
import distutils.command.check
except ImportError:
has_check = False
else:
has_check = True
import distutils.errors
if has_check:
_check = copy.deepcopy(distutils.command.check.check)
class check(_check):
def check_metadata(self):
_check.check_metadata(self)
metadata = self.distribution.metadata
# Check that Version matches PEP 346
try:
NormalizedVersion(metadata.get_version())
except IrrationalVersionError:
raise distutils.errors.DistutilsSetupError("Version must conform to PEP386 - http://www.python.org/dev/peps/pep-0386/")
distutils.command.check.check = check
# ----- END [distutils.command.check] ----- #
# ----- START [distutils.command.register] ----- #
import distutils.command.register
_register = copy.deepcopy(distutils.command.register.register)
class register(_register):
def check_metadata(self):
if not has_check:
metadata = self.distribution.metadata
# Check that Version matches PEP 346
try:
NormalizedVersion(metadata.get_version())
except IrrationalVersionError:
raise distutils.errors.DistutilsSetupError("Version must conform to PEP386 - http://www.python.org/dev/peps/pep-0386/")
return _register.check_metadata(self)
def build_post_data(self, action):
data = _register.build_post_data(self, action)
# PEP 345
meta = self.distribution.metadata
if meta.requires_python:
data["requires_python"] = meta.get_requires_python()
data["requires_external"] = meta.get_requires_external()
data["requires_dist"] = meta.get_requires_dist()
data["provides_dist"] = meta.get_provides_dist()
data["obsoletes_dist"] = meta.get_obsoletes_dist()
data["project_url"] = meta.get_project_url()
if data["requires_python"] or data["requires_external"] or data["requires_dist"] or data["provides_dist"] or data["obsoletes_dist"] or data["project_url"]:
data['metadata_version'] = "1.2"
return data
distutils.command.register.register = register
# ----- END [distutils.command.register] ----- #
# ----- START [distutils.command.sdist] ----- #
import distutils.command.sdist
import os
_sdist = copy.deepcopy(distutils.command.sdist.sdist)
class sdist(_sdist):
def add_defaults(self):
_sdist.add_defaults(self)
# Add _pep345.py if it exists
if os.path.exists("_pep345.py"):
self.filelist.append("_pep345.py")
distutils.command.sdist.sdist = sdist
# ----- END [distutils.command.sdist] ----- #
# ----- START [setuptools.command.sdist] ----- #
try:
import setuptools.command.sdist
except ImportError:
pass
else:
import os
_setuptools_sdist = copy.deepcopy(setuptools.command.sdist.sdist)
class sdist(_setuptools_sdist):
def add_defaults(self):
_setuptools_sdist.add_defaults(self)
# Add _pep345.py if it exists
if os.path.exists("_pep345.py"):
self.filelist.append("_pep345.py")
setuptools.command.sdist.sdist = sdist
# ----- END [setuptools.command.sdist] ----- #
# ----- START [markerlib] ----- #
from ast import Compare, BoolOp, Attribute, Name, Load, Str, cmpop, boolop
from ast import parse, copy_location, NodeTransformer
import os
import platform
import sys
import weakref
from platform import python_implementation
# restricted set of variables
_VARS = {'sys.platform': sys.platform,
'python_version': '%s.%s' % sys.version_info[:2],
# FIXME parsing sys.platform is not reliable, but there is no other
# way to get e.g. 2.7.2+, and the PEP is defined with sys.version
'python_full_version': sys.version.split(' ', 1)[0],
'os.name': os.name,
'platform.version': platform.version(),
'platform.machine': platform.machine(),
'platform.python_implementation': python_implementation(),
'extra': None # wheel extension
}
def default_environment():
"""Return copy of default PEP 385 globals dictionary."""
return dict(_VARS)
class ASTWhitelist(NodeTransformer):
def __init__(self, statement):
self.statement = statement # for error messages
ALLOWED = (Compare, BoolOp, Attribute, Name, Load, Str, cmpop, boolop)
def visit(self, node):
"""Ensure statement only contains allowed nodes."""
if not isinstance(node, self.ALLOWED):
raise SyntaxError('Not allowed in environment markers.\n%s\n%s' %
(self.statement,
(' ' * node.col_offset) + '^'))
return NodeTransformer.visit(self, node)
def visit_Attribute(self, node):
"""Flatten one level of attribute access."""
new_node = Name("%s.%s" % (node.value.id, node.attr), node.ctx)
return copy_location(new_node, node)
def parse_marker(marker):
tree = parse(marker, mode='eval')
new_tree = ASTWhitelist(marker).generic_visit(tree)
return new_tree
def compile_marker(parsed_marker):
return compile(parsed_marker, '<environment marker>', 'eval',
dont_inherit=True)
_cache = weakref.WeakValueDictionary()
def mcompile(marker):
"""Return compiled marker as a function accepting an environment dict."""
try:
return _cache[marker]
except KeyError:
pass
if not marker.strip():
def marker_fn(environment=None, override=None):
""""""
return True
else:
compiled_marker = compile_marker(parse_marker(marker))
def marker_fn(environment=None, override=None):
"""override updates environment"""
if override is None:
override = {}
if environment is None:
environment = default_environment()
environment.update(override)
return eval(compiled_marker, environment)
marker_fn.__doc__ = marker
_cache[marker] = marker_fn
return _cache[marker]
def interpret(marker, environment=None):
return mcompile(marker)(environment)
# ----- END [markerlib] ----- #
# ----- START [MAIN] ----- #
def pep345_to_setuptools(requirements):
setuptools_requirements = []
for req in requirements:
if ";" in req:
req, environment = req.split(";")
environment = environment.strip()
else:
environment = ""
if interpret(environment):
vp = VersionPredicate(req)
versions = ",".join(["".join(p) for p in vp.predicates])
setuptools_requirements.append("".join([vp.name, versions]))
return setuptools_requirements
# ----- END [MAIN] ----- #
#!/usr/bin/env python
from setuptools import setup, find_packages
from _pep345 import pep345_to_setuptools
REQUIREMENTS = [
"warehouse",
"Flask (0.9)",
"Flask-Script (==0.4.0)",
"Flask-Testing (<1.0)",
"Flask-Exceptional (>0.1)",
"Flask-Mail (<=0.7.0)",
"Flask-SQLAlchemy (>=0.15)",
"python-dateutil (!=2.0)",
"requests (>=0.12,!=0.12.3)",
"simplejson; python_version < '2.6'",
]
PROVIDES = [
"wat (2.1)",
]
OBSOLETES = [
"urgh (1.0)",
]
setup(
name="crate-test",
version="2.2",
author="Donald Stufft",
author_email="donald.stufft@gmail.com",
url="https://crate.io/",
description="A Test Package :D",
long_description="Tests Packaging Features",
packages=find_packages(exclude=("tests",)),
zip_safe=False,
include_package_data=True,
# PEP 345
requires_python=">=2.4",
requires_external=["C", "libpng (>=1.5)"],
requires_dist=REQUIREMENTS,
provides_dist=PROVIDES,
obsoletes_dist=OBSOLETES,
project_url={
"test": "https://crate.io/",
},
# Backwards compat for setuptools
install_requires=pep345_to_setuptools(REQUIREMENTS),
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment