Aluode/PerceptionLabPortable
0
1"""distutils.command.sdist2 3Implements the Distutils 'sdist' command (create a source distribution)."""4 5from __future__ import annotations6 7import os8import sys9from collections.abc import Callable10from distutils import archive_util, dir_util, file_util11from distutils._log import log12from glob import glob13from itertools import filterfalse14from typing import ClassVar15 16from ..core import Command17from ..errors import DistutilsOptionError, DistutilsTemplateError18from ..filelist import FileList19from ..text_file import TextFile20from ..util import convert_path21 22 23def show_formats():24 """Print all possible values for the 'formats' option (used by25 the "--help-formats" command-line option).26 """27 from ..archive_util import ARCHIVE_FORMATS28 from ..fancy_getopt import FancyGetopt29 30 formats = sorted(31 ("formats=" + format, None, ARCHIVE_FORMATS[format][2])32 for format in ARCHIVE_FORMATS.keys()33 )34 FancyGetopt(formats).print_help("List of available source distribution formats:")35 36 37class sdist(Command):38 description = "create a source distribution (tarball, zip file, etc.)"39 40 def checking_metadata(self) -> bool:41 """Callable used for the check sub-command.42 43 Placed here so user_options can view it"""44 return self.metadata_check45 46 user_options = [47 ('template=', 't', "name of manifest template file [default: MANIFEST.in]"),48 ('manifest=', 'm', "name of manifest file [default: MANIFEST]"),49 (50 'use-defaults',51 None,52 "include the default file set in the manifest "53 "[default; disable with --no-defaults]",54 ),55 ('no-defaults', None, "don't include the default file set"),56 (57 'prune',58 None,59 "specifically exclude files/directories that should not be "60 "distributed (build tree, RCS/CVS dirs, etc.) "61 "[default; disable with --no-prune]",62 ),63 ('no-prune', None, "don't automatically exclude anything"),64 (65 'manifest-only',66 'o',67 "just regenerate the manifest and then stop (implies --force-manifest)",68 ),69 (70 'force-manifest',71 'f',72 "forcibly regenerate the manifest and carry on as usual. "73 "Deprecated: now the manifest is always regenerated.",74 ),75 ('formats=', None, "formats for source distribution (comma-separated list)"),76 (77 'keep-temp',78 'k',79 "keep the distribution tree around after creating " + "archive file(s)",80 ),81 (82 'dist-dir=',83 'd',84 "directory to put the source distribution archive(s) in [default: dist]",85 ),86 (87 'metadata-check',88 None,89 "Ensure that all required elements of meta-data "90 "are supplied. Warn if any missing. [default]",91 ),92 (93 'owner=',94 'u',95 "Owner name used when creating a tar file [default: current user]",96 ),97 (98 'group=',99 'g',100 "Group name used when creating a tar file [default: current group]",101 ),102 ]103 104 boolean_options: ClassVar[list[str]] = [105 'use-defaults',106 'prune',107 'manifest-only',108 'force-manifest',109 'keep-temp',110 'metadata-check',111 ]112 113 help_options: ClassVar[list[tuple[str, str | None, str, Callable[[], object]]]] = [114 ('help-formats', None, "list available distribution formats", show_formats),115 ]116 117 negative_opt: ClassVar[dict[str, str]] = {118 'no-defaults': 'use-defaults',119 'no-prune': 'prune',120 }121 122 sub_commands = [('check', checking_metadata)]123 124 READMES: ClassVar[tuple[str, ...]] = ('README', 'README.txt', 'README.rst')125 126 def initialize_options(self):127 # 'template' and 'manifest' are, respectively, the names of128 # the manifest template and manifest file.129 self.template = None130 self.manifest = None131 132 # 'use_defaults': if true, we will include the default file set133 # in the manifest134 self.use_defaults = True135 self.prune = True136 137 self.manifest_only = False138 self.force_manifest = False139 140 self.formats = ['gztar']141 self.keep_temp = False142 self.dist_dir = None143 144 self.archive_files = None145 self.metadata_check = True146 self.owner = None147 self.group = None148 149 def finalize_options(self) -> None:150 if self.manifest is None:151 self.manifest = "MANIFEST"152 if self.template is None:153 self.template = "MANIFEST.in"154 155 self.ensure_string_list('formats')156 157 bad_format = archive_util.check_archive_formats(self.formats)158 if bad_format:159 raise DistutilsOptionError(f"unknown archive format '{bad_format}'")160 161 if self.dist_dir is None:162 self.dist_dir = "dist"163 164 def run(self) -> None:165 # 'filelist' contains the list of files that will make up the166 # manifest167 self.filelist = FileList()168 169 # Run sub commands170 for cmd_name in self.get_sub_commands():171 self.run_command(cmd_name)172 173 # Do whatever it takes to get the list of files to process174 # (process the manifest template, read an existing manifest,175 # whatever). File list is accumulated in 'self.filelist'.176 self.get_file_list()177 178 # If user just wanted us to regenerate the manifest, stop now.179 if self.manifest_only:180 return181 182 # Otherwise, go ahead and create the source distribution tarball,183 # or zipfile, or whatever.184 self.make_distribution()185 186 def get_file_list(self) -> None:187 """Figure out the list of files to include in the source188 distribution, and put it in 'self.filelist'. This might involve189 reading the manifest template (and writing the manifest), or just190 reading the manifest, or just using the default file set -- it all191 depends on the user's options.192 """193 # new behavior when using a template:194 # the file list is recalculated every time because195 # even if MANIFEST.in or setup.py are not changed196 # the user might have added some files in the tree that197 # need to be included.198 #199 # This makes --force the default and only behavior with templates.200 template_exists = os.path.isfile(self.template)201 if not template_exists and self._manifest_is_not_generated():202 self.read_manifest()203 self.filelist.sort()204 self.filelist.remove_duplicates()205 return206 207 if not template_exists:208 self.warn(209 ("manifest template '%s' does not exist " + "(using default file list)")210 % self.template211 )212 self.filelist.findall()213 214 if self.use_defaults:215 self.add_defaults()216 217 if template_exists:218 self.read_template()219 220 if self.prune:221 self.prune_file_list()222 223 self.filelist.sort()224 self.filelist.remove_duplicates()225 self.write_manifest()226 227 def add_defaults(self) -> None:228 """Add all the default files to self.filelist:229 - README or README.txt230 - setup.py231 - tests/test*.py and test/test*.py232 - all pure Python modules mentioned in setup script233 - all files pointed by package_data (build_py)234 - all files defined in data_files.235 - all files defined as scripts.236 - all C sources listed as part of extensions or C libraries237 in the setup script (doesn't catch C headers!)238 Warns if (README or README.txt) or setup.py are missing; everything239 else is optional.240 """241 self._add_defaults_standards()242 self._add_defaults_optional()243 self._add_defaults_python()244 self._add_defaults_data_files()245 self._add_defaults_ext()246 self._add_defaults_c_libs()247 self._add_defaults_scripts()248 249 @staticmethod250 def _cs_path_exists(fspath):251 """252 Case-sensitive path existence check253 254 >>> sdist._cs_path_exists(__file__)255 True256 >>> sdist._cs_path_exists(__file__.upper())257 False258 """259 if not os.path.exists(fspath):260 return False261 # make absolute so we always have a directory262 abspath = os.path.abspath(fspath)263 directory, filename = os.path.split(abspath)264 return filename in os.listdir(directory)265 266 def _add_defaults_standards(self):267 standards = [self.READMES, self.distribution.script_name]268 for fn in standards:269 if isinstance(fn, tuple):270 alts = fn271 got_it = False272 for fn in alts:273 if self._cs_path_exists(fn):274 got_it = True275 self.filelist.append(fn)276 break277 278 if not got_it:279 self.warn(280 "standard file not found: should have one of " + ', '.join(alts)281 )282 else:283 if self._cs_path_exists(fn):284 self.filelist.append(fn)285 else:286 self.warn(f"standard file '{fn}' not found")287 288 def _add_defaults_optional(self):289 optional = ['tests/test*.py', 'test/test*.py', 'setup.cfg']290 for pattern in optional:291 files = filter(os.path.isfile, glob(pattern))292 self.filelist.extend(files)293 294 def _add_defaults_python(self):295 # build_py is used to get:296 # - python modules297 # - files defined in package_data298 build_py = self.get_finalized_command('build_py')299 300 # getting python files301 if self.distribution.has_pure_modules():302 self.filelist.extend(build_py.get_source_files())303 304 # getting package_data files305 # (computed in build_py.data_files by build_py.finalize_options)306 for _pkg, src_dir, _build_dir, filenames in build_py.data_files:307 for filename in filenames:308 self.filelist.append(os.path.join(src_dir, filename))309 310 def _add_defaults_data_files(self):311 # getting distribution.data_files312 if self.distribution.has_data_files():313 for item in self.distribution.data_files:314 if isinstance(item, str):315 # plain file316 item = convert_path(item)317 if os.path.isfile(item):318 self.filelist.append(item)319 else:320 # a (dirname, filenames) tuple321 dirname, filenames = item322 for f in filenames:323 f = convert_path(f)324 if os.path.isfile(f):325 self.filelist.append(f)326 327 def _add_defaults_ext(self):328 if self.distribution.has_ext_modules():329 build_ext = self.get_finalized_command('build_ext')330 self.filelist.extend(build_ext.get_source_files())331 332 def _add_defaults_c_libs(self):333 if self.distribution.has_c_libraries():334 build_clib = self.get_finalized_command('build_clib')335 self.filelist.extend(build_clib.get_source_files())336 337 def _add_defaults_scripts(self):338 if self.distribution.has_scripts():339 build_scripts = self.get_finalized_command('build_scripts')340 self.filelist.extend(build_scripts.get_source_files())341 342 def read_template(self) -> None:343 """Read and parse manifest template file named by self.template.344 345 (usually "MANIFEST.in") The parsing and processing is done by346 'self.filelist', which updates itself accordingly.347 """348 log.info("reading manifest template '%s'", self.template)349 template = TextFile(350 self.template,351 strip_comments=True,352 skip_blanks=True,353 join_lines=True,354 lstrip_ws=True,355 rstrip_ws=True,356 collapse_join=True,357 )358 359 try:360 while True:361 line = template.readline()362 if line is None: # end of file363 break364 365 try:366 self.filelist.process_template_line(line)367 # the call above can raise a DistutilsTemplateError for368 # malformed lines, or a ValueError from the lower-level369 # convert_path function370 except (DistutilsTemplateError, ValueError) as msg:371 self.warn(372 f"{template.filename}, line {int(template.current_line)}: {msg}"373 )374 finally:375 template.close()376 377 def prune_file_list(self) -> None:378 """Prune off branches that might slip into the file list as created379 by 'read_template()', but really don't belong there:380 * the build tree (typically "build")381 * the release tree itself (only an issue if we ran "sdist"382 previously with --keep-temp, or it aborted)383 * any RCS, CVS, .svn, .hg, .git, .bzr, _darcs directories384 """385 build = self.get_finalized_command('build')386 base_dir = self.distribution.get_fullname()387 388 self.filelist.exclude_pattern(None, prefix=os.fspath(build.build_base))389 self.filelist.exclude_pattern(None, prefix=base_dir)390 391 if sys.platform == 'win32':392 seps = r'/|\\'393 else:394 seps = '/'395 396 vcs_dirs = ['RCS', 'CVS', r'\.svn', r'\.hg', r'\.git', r'\.bzr', '_darcs']397 vcs_ptrn = r'(^|{})({})({}).*'.format(seps, '|'.join(vcs_dirs), seps)398 self.filelist.exclude_pattern(vcs_ptrn, is_regex=True)399 400 def write_manifest(self) -> None:401 """Write the file list in 'self.filelist' (presumably as filled in402 by 'add_defaults()' and 'read_template()') to the manifest file403 named by 'self.manifest'.404 """405 if self._manifest_is_not_generated():406 log.info(407 f"not writing to manually maintained manifest file '{self.manifest}'"408 )409 return410 411 content = self.filelist.files[:]412 content.insert(0, '# file GENERATED by distutils, do NOT edit')413 self.execute(414 file_util.write_file,415 (self.manifest, content),416 f"writing manifest file '{self.manifest}'",417 )418 419 def _manifest_is_not_generated(self):420 # check for special comment used in 3.1.3 and higher421 if not os.path.isfile(self.manifest):422 return False423 424 with open(self.manifest, encoding='utf-8') as fp:425 first_line = next(fp)426 return first_line != '# file GENERATED by distutils, do NOT edit\n'427 428 def read_manifest(self) -> None:429 """Read the manifest file (named by 'self.manifest') and use it to430 fill in 'self.filelist', the list of files to include in the source431 distribution.432 """433 log.info("reading manifest file '%s'", self.manifest)434 with open(self.manifest, encoding='utf-8') as lines:435 self.filelist.extend(436 # ignore comments and blank lines437 filter(None, filterfalse(is_comment, map(str.strip, lines)))438 )439 440 def make_release_tree(self, base_dir, files) -> None:441 """Create the directory tree that will become the source442 distribution archive. All directories implied by the filenames in443 'files' are created under 'base_dir', and then we hard link or copy444 (if hard linking is unavailable) those files into place.445 Essentially, this duplicates the developer's source tree, but in a446 directory named after the distribution, containing only the files447 to be distributed.448 """449 # Create all the directories under 'base_dir' necessary to450 # put 'files' there; the 'mkpath()' is just so we don't die451 # if the manifest happens to be empty.452 self.mkpath(base_dir)453 dir_util.create_tree(base_dir, files, dry_run=self.dry_run)454 455 # And walk over the list of files, either making a hard link (if456 # os.link exists) to each one that doesn't already exist in its457 # corresponding location under 'base_dir', or copying each file458 # that's out-of-date in 'base_dir'. (Usually, all files will be459 # out-of-date, because by default we blow away 'base_dir' when460 # we're done making the distribution archives.)461 462 if hasattr(os, 'link'): # can make hard links on this system463 link = 'hard'464 msg = f"making hard links in {base_dir}..."465 else: # nope, have to copy466 link = None467 msg = f"copying files to {base_dir}..."468 469 if not files:470 log.warning("no files to distribute -- empty manifest?")471 else:472 log.info(msg)473 for file in files:474 if not os.path.isfile(file):475 log.warning("'%s' not a regular file -- skipping", file)476 else:477 dest = os.path.join(base_dir, file)478 self.copy_file(file, dest, link=link)479 480 self.distribution.metadata.write_pkg_info(base_dir)481 482 def make_distribution(self) -> None:483 """Create the source distribution(s). First, we create the release484 tree with 'make_release_tree()'; then, we create all required485 archive files (according to 'self.formats') from the release tree.486 Finally, we clean up by blowing away the release tree (unless487 'self.keep_temp' is true). The list of archive files created is488 stored so it can be retrieved later by 'get_archive_files()'.489 """490 # Don't warn about missing meta-data here -- should be (and is!)491 # done elsewhere.492 base_dir = self.distribution.get_fullname()493 base_name = os.path.join(self.dist_dir, base_dir)494 495 self.make_release_tree(base_dir, self.filelist.files)496 archive_files = [] # remember names of files we create497 # tar archive must be created last to avoid overwrite and remove498 if 'tar' in self.formats:499 self.formats.append(self.formats.pop(self.formats.index('tar')))500 501 for fmt in self.formats:502 file = self.make_archive(503 base_name, fmt, base_dir=base_dir, owner=self.owner, group=self.group504 )505 archive_files.append(file)506 self.distribution.dist_files.append(('sdist', '', file))507 508 self.archive_files = archive_files509 510 if not self.keep_temp:511 dir_util.remove_tree(base_dir, dry_run=self.dry_run)512 513 def get_archive_files(self):514 """Return the list of archive files created when the command515 was run, or None if the command hasn't run yet.516 """517 return self.archive_files518 519 520def is_comment(line: str) -> bool:521 return line.startswith('#')522 