summaryrefslogtreecommitdiffstats
path: root/flit_core/flit_core/common.py
diff options
context:
space:
mode:
Diffstat (limited to 'flit_core/flit_core/common.py')
-rw-r--r--flit_core/flit_core/common.py449
1 files changed, 449 insertions, 0 deletions
diff --git a/flit_core/flit_core/common.py b/flit_core/flit_core/common.py
new file mode 100644
index 0000000..68d91bb
--- /dev/null
+++ b/flit_core/flit_core/common.py
@@ -0,0 +1,449 @@
+import ast
+from contextlib import contextmanager
+import hashlib
+import logging
+import os
+import sys
+
+from pathlib import Path
+import re
+
+log = logging.getLogger(__name__)
+
+from .versionno import normalise_version
+
+class Module(object):
+ """This represents the module/package that we are going to distribute
+ """
+ in_namespace_package = False
+ namespace_package_name = None
+
+ def __init__(self, name, directory=Path()):
+ self.name = name
+
+ # It must exist either as a .py file or a directory, but not both
+ name_as_path = name.replace('.', os.sep)
+ pkg_dir = directory / name_as_path
+ py_file = directory / (name_as_path+'.py')
+ src_pkg_dir = directory / 'src' / name_as_path
+ src_py_file = directory / 'src' / (name_as_path+'.py')
+
+ existing = set()
+ if pkg_dir.is_dir():
+ self.path = pkg_dir
+ self.is_package = True
+ self.prefix = ''
+ existing.add(pkg_dir)
+ if py_file.is_file():
+ self.path = py_file
+ self.is_package = False
+ self.prefix = ''
+ existing.add(py_file)
+ if src_pkg_dir.is_dir():
+ self.path = src_pkg_dir
+ self.is_package = True
+ self.prefix = 'src'
+ existing.add(src_pkg_dir)
+ if src_py_file.is_file():
+ self.path = src_py_file
+ self.is_package = False
+ self.prefix = 'src'
+ existing.add(src_py_file)
+
+ if len(existing) > 1:
+ raise ValueError(
+ "Multiple files or folders could be module {}: {}"
+ .format(name, ", ".join([str(p) for p in sorted(existing)]))
+ )
+ elif not existing:
+ raise ValueError("No file/folder found for module {}".format(name))
+
+ self.source_dir = directory / self.prefix
+
+ if '.' in name:
+ self.namespace_package_name = name.rpartition('.')[0]
+ self.in_namespace_package = True
+
+ @property
+ def file(self):
+ if self.is_package:
+ return self.path / '__init__.py'
+ else:
+ return self.path
+
+ def iter_files(self):
+ """Iterate over the files contained in this module.
+
+ Yields absolute paths - caller may want to make them relative.
+ Excludes any __pycache__ and *.pyc files.
+ """
+ def _include(path):
+ name = os.path.basename(path)
+ if (name == '__pycache__') or name.endswith('.pyc'):
+ return False
+ return True
+
+ if self.is_package:
+ # Ensure we sort all files and directories so the order is stable
+ for dirpath, dirs, files in os.walk(str(self.path)):
+ for file in sorted(files):
+ full_path = os.path.join(dirpath, file)
+ if _include(full_path):
+ yield full_path
+
+ dirs[:] = [d for d in sorted(dirs) if _include(d)]
+
+ else:
+ yield str(self.path)
+
+class ProblemInModule(ValueError): pass
+class NoDocstringError(ProblemInModule): pass
+class NoVersionError(ProblemInModule): pass
+class InvalidVersion(ProblemInModule): pass
+
+class VCSError(Exception):
+ def __init__(self, msg, directory):
+ self.msg = msg
+ self.directory = directory
+
+ def __str__(self):
+ return self.msg + ' ({})'.format(self.directory)
+
+
+@contextmanager
+def _module_load_ctx():
+ """Preserve some global state that modules might change at import time.
+
+ - Handlers on the root logger.
+ """
+ logging_handlers = logging.root.handlers[:]
+ try:
+ yield
+ finally:
+ logging.root.handlers = logging_handlers
+
+def get_docstring_and_version_via_ast(target):
+ """
+ Return a tuple like (docstring, version) for the given module,
+ extracted by parsing its AST.
+ """
+ # read as bytes to enable custom encodings
+ with target.file.open('rb') as f:
+ node = ast.parse(f.read())
+ for child in node.body:
+ # Only use the version from the given module if it's a simple
+ # string assignment to __version__
+ is_version_str = (
+ isinstance(child, ast.Assign)
+ and any(
+ isinstance(target, ast.Name)
+ and target.id == "__version__"
+ for target in child.targets
+ )
+ and isinstance(child.value, ast.Str)
+ )
+ if is_version_str:
+ version = child.value.s
+ break
+ else:
+ version = None
+ return ast.get_docstring(node), version
+
+
+# To ensure we're actually loading the specified file, give it a unique name to
+# avoid any cached import. In normal use we'll only load one module per process,
+# so it should only matter for the tests, but we'll do it anyway.
+_import_i = 0
+
+
+def get_docstring_and_version_via_import(target):
+ """
+ Return a tuple like (docstring, version) for the given module,
+ extracted by importing the module and pulling __doc__ & __version__
+ from it.
+ """
+ global _import_i
+ _import_i += 1
+
+ log.debug("Loading module %s", target.file)
+ from importlib.util import spec_from_file_location, module_from_spec
+ mod_name = 'flit_core.dummy.import%d' % _import_i
+ spec = spec_from_file_location(mod_name, target.file)
+ with _module_load_ctx():
+ m = module_from_spec(spec)
+ # Add the module to sys.modules to allow relative imports to work.
+ # importlib has more code around this to handle the case where two
+ # threads are trying to load the same module at the same time, but Flit
+ # should always be running a single thread, so we won't duplicate that.
+ sys.modules[mod_name] = m
+ try:
+ spec.loader.exec_module(m)
+ finally:
+ sys.modules.pop(mod_name, None)
+
+ docstring = m.__dict__.get('__doc__', None)
+ version = m.__dict__.get('__version__', None)
+ return docstring, version
+
+
+def get_info_from_module(target, for_fields=('version', 'description')):
+ """Load the module/package, get its docstring and __version__
+ """
+ if not for_fields:
+ return {}
+
+ # What core metadata calls Summary, PEP 621 calls description
+ want_summary = 'description' in for_fields
+ want_version = 'version' in for_fields
+
+ log.debug("Loading module %s", target.file)
+
+ # Attempt to extract our docstring & version by parsing our target's
+ # AST, falling back to an import if that fails. This allows us to
+ # build without necessarily requiring that our built package's
+ # requirements are installed.
+ docstring, version = get_docstring_and_version_via_ast(target)
+ if (want_summary and not docstring) or (want_version and not version):
+ docstring, version = get_docstring_and_version_via_import(target)
+
+ res = {}
+
+ if want_summary:
+ if (not docstring) or not docstring.strip():
+ raise NoDocstringError(
+ 'Flit cannot package module without docstring, or empty docstring. '
+ 'Please add a docstring to your module ({}).'.format(target.file)
+ )
+ res['summary'] = docstring.lstrip().splitlines()[0]
+
+ if want_version:
+ res['version'] = check_version(version)
+
+ return res
+
+def check_version(version):
+ """
+ Check whether a given version string match PEP 440, and do normalisation.
+
+ Raise InvalidVersion/NoVersionError with relevant information if
+ version is invalid.
+
+ Log a warning if the version is not canonical with respect to PEP 440.
+
+ Returns the version in canonical PEP 440 format.
+ """
+ if not version:
+ raise NoVersionError('Cannot package module without a version string. '
+ 'Please define a `__version__ = "x.y.z"` in your module.')
+ if not isinstance(version, str):
+ raise InvalidVersion('__version__ must be a string, not {}.'
+ .format(type(version)))
+
+ # Import here to avoid circular import
+ version = normalise_version(version)
+
+ return version
+
+
+script_template = """\
+#!{interpreter}
+# -*- coding: utf-8 -*-
+import re
+import sys
+from {module} import {import_name}
+if __name__ == '__main__':
+ sys.argv[0] = re.sub(r'(-script\\.pyw|\\.exe)?$', '', sys.argv[0])
+ sys.exit({func}())
+"""
+
+def parse_entry_point(ep):
+ """Check and parse a 'package.module:func' style entry point specification.
+
+ Returns (modulename, funcname)
+ """
+ if ':' not in ep:
+ raise ValueError("Invalid entry point (no ':'): %r" % ep)
+ mod, func = ep.split(':')
+
+ for piece in func.split('.'):
+ if not piece.isidentifier():
+ raise ValueError("Invalid entry point: %r is not an identifier" % piece)
+ for piece in mod.split('.'):
+ if not piece.isidentifier():
+ raise ValueError("Invalid entry point: %r is not a module path" % piece)
+
+ return mod, func
+
+def write_entry_points(d, fp):
+ """Write entry_points.txt from a two-level dict
+
+ Sorts on keys to ensure results are reproducible.
+ """
+ for group_name in sorted(d):
+ fp.write(u'[{}]\n'.format(group_name))
+ group = d[group_name]
+ for name in sorted(group):
+ val = group[name]
+ fp.write(u'{}={}\n'.format(name, val))
+ fp.write(u'\n')
+
+def hash_file(path, algorithm='sha256'):
+ with open(path, 'rb') as f:
+ h = hashlib.new(algorithm, f.read())
+ return h.hexdigest()
+
+def normalize_file_permissions(st_mode):
+ """Normalize the permission bits in the st_mode field from stat to 644/755
+
+ Popular VCSs only track whether a file is executable or not. The exact
+ permissions can vary on systems with different umasks. Normalising
+ to 644 (non executable) or 755 (executable) makes builds more reproducible.
+ """
+ # Set 644 permissions, leaving higher bits of st_mode unchanged
+ new_mode = (st_mode | 0o644) & ~0o133
+ if st_mode & 0o100:
+ new_mode |= 0o111 # Executable: 644 -> 755
+ return new_mode
+
+class Metadata(object):
+
+ summary = None
+ home_page = None
+ author = None
+ author_email = None
+ maintainer = None
+ maintainer_email = None
+ license = None
+ description = None
+ keywords = None
+ download_url = None
+ requires_python = None
+ description_content_type = None
+
+ platform = ()
+ supported_platform = ()
+ classifiers = ()
+ provides = ()
+ requires = ()
+ obsoletes = ()
+ project_urls = ()
+ provides_dist = ()
+ requires_dist = ()
+ obsoletes_dist = ()
+ requires_external = ()
+ provides_extra = ()
+
+ metadata_version = "2.1"
+
+ def __init__(self, data):
+ data = data.copy()
+ self.name = data.pop('name')
+ self.version = data.pop('version')
+
+ for k, v in data.items():
+ assert hasattr(self, k), "data does not have attribute '{}'".format(k)
+ setattr(self, k, v)
+
+ def _normalise_name(self, n):
+ return n.lower().replace('-', '_')
+
+ def write_metadata_file(self, fp):
+ """Write out metadata in the email headers format"""
+ fields = [
+ 'Metadata-Version',
+ 'Name',
+ 'Version',
+ ]
+ optional_fields = [
+ 'Summary',
+ 'Home-page',
+ 'License',
+ 'Keywords',
+ 'Author',
+ 'Author-email',
+ 'Maintainer',
+ 'Maintainer-email',
+ 'Requires-Python',
+ 'Description-Content-Type',
+ ]
+
+ for field in fields:
+ value = getattr(self, self._normalise_name(field))
+ fp.write(u"{}: {}\n".format(field, value))
+
+ for field in optional_fields:
+ value = getattr(self, self._normalise_name(field))
+ if value is not None:
+ # TODO: verify which fields can be multiline
+ # The spec has multiline examples for Author, Maintainer &
+ # License (& Description, but we put that in the body)
+ # Indent following lines with 8 spaces:
+ value = '\n '.join(value.splitlines())
+ fp.write(u"{}: {}\n".format(field, value))
+
+ for clsfr in self.classifiers:
+ fp.write(u'Classifier: {}\n'.format(clsfr))
+
+ for req in self.requires_dist:
+ fp.write(u'Requires-Dist: {}\n'.format(req))
+
+ for url in self.project_urls:
+ fp.write(u'Project-URL: {}\n'.format(url))
+
+ for extra in self.provides_extra:
+ fp.write(u'Provides-Extra: {}\n'.format(extra))
+
+ if self.description is not None:
+ fp.write(u'\n' + self.description + u'\n')
+
+ @property
+ def supports_py2(self):
+ """Return True if Requires-Python indicates Python 2 support."""
+ for part in (self.requires_python or "").split(","):
+ if re.search(r"^\s*(>=?|~=|===?)?\s*[3-9]", part):
+ return False
+ return True
+
+
+def make_metadata(module, ini_info):
+ md_dict = {'name': module.name, 'provides': [module.name]}
+ md_dict.update(get_info_from_module(module, ini_info.dynamic_metadata))
+ md_dict.update(ini_info.metadata)
+ return Metadata(md_dict)
+
+
+
+def normalize_dist_name(name: str, version: str) -> str:
+ """Normalizes a name and a PEP 440 version
+
+ The resulting string is valid as dist-info folder name
+ and as first part of a wheel filename
+
+ See https://packaging.python.org/specifications/binary-distribution-format/#escaping-and-unicode
+ """
+ normalized_name = re.sub(r'[-_.]+', '_', name, flags=re.UNICODE).lower()
+ assert check_version(version) == version
+ assert '-' not in version, 'Normalized versions can’t have dashes'
+ return '{}-{}'.format(normalized_name, version)
+
+
+def dist_info_name(distribution, version):
+ """Get the correct name of the .dist-info folder"""
+ return normalize_dist_name(distribution, version) + '.dist-info'
+
+
+def walk_data_dir(data_directory):
+ """Iterate over the files in the given data directory.
+
+ Yields paths prefixed with data_directory - caller may want to make them
+ relative to that. Excludes any __pycache__ subdirectories.
+ """
+ if data_directory is None:
+ return
+
+ for dirpath, dirs, files in os.walk(data_directory):
+ for file in sorted(files):
+ full_path = os.path.join(dirpath, file)
+ yield full_path
+
+ dirs[:] = [d for d in sorted(dirs) if d != '__pycache__']