"""Module containing the logic for ``twine check``.""" # Copyright 2018 Dustin Ingram # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import argparse import cgi import io import logging import re from typing import List, Optional, Tuple, cast import readme_renderer.rst from rich import print from twine import commands from twine import package as package_file logger = logging.getLogger(__name__) _RENDERERS = { None: readme_renderer.rst, # Default if description_content_type is None "text/plain": None, # Rendering cannot fail "text/x-rst": readme_renderer.rst, "text/markdown": None, # Rendering cannot fail } # Regular expression used to capture and reformat docutils warnings into # something that a human can understand. This is loosely borrowed from # Sphinx: https://github.com/sphinx-doc/sphinx/blob # /c35eb6fade7a3b4a6de4183d1dd4196f04a5edaf/sphinx/util/docutils.py#L199 _REPORT_RE = re.compile( r"^:(?P(?:\d+)?): " r"\((?PDEBUG|INFO|WARNING|ERROR|SEVERE)/(\d+)?\) " r"(?P.*)", re.DOTALL | re.MULTILINE, ) class _WarningStream(io.StringIO): def write(self, text: str) -> int: matched = _REPORT_RE.search(text) if matched: line = matched.group("line") level_text = matched.group("level").capitalize() message = matched.group("message").rstrip("\r\n") text = f"line {line}: {level_text}: {message}\n" return super().write(text) def __str__(self) -> str: return self.getvalue().strip() def _check_file( filename: str, render_warning_stream: _WarningStream ) -> Tuple[List[str], bool]: """Check given distribution.""" warnings = [] is_ok = True package = package_file.PackageFile.from_filename(filename, comment=None) metadata = package.metadata_dictionary() description = cast(Optional[str], metadata["description"]) description_content_type = cast(Optional[str], metadata["description_content_type"]) if description_content_type is None: warnings.append( "`long_description_content_type` missing. defaulting to `text/x-rst`." ) description_content_type = "text/x-rst" content_type, params = cgi.parse_header(description_content_type) renderer = _RENDERERS.get(content_type, _RENDERERS[None]) if description is None or description.rstrip() == "UNKNOWN": warnings.append("`long_description` missing.") elif renderer: rendering_result = renderer.render( description, stream=render_warning_stream, **params ) if rendering_result is None: is_ok = False return warnings, is_ok def check( dists: List[str], strict: bool = False, ) -> bool: """Check that a distribution will render correctly on PyPI and display the results. This is currently only validates ``long_description``, but more checks could be added; see https://github.com/pypa/twine/projects/2. :param dists: The distribution files to check. :param output_stream: The destination of the resulting output. :param strict: If ``True``, treat warnings as errors. :return: ``True`` if there are rendering errors, otherwise ``False``. """ uploads = [i for i in commands._find_dists(dists) if not i.endswith(".asc")] if not uploads: # Return early, if there are no files to check. logger.error("No files to check.") return False failure = False for filename in uploads: print(f"Checking {filename}: ", end="") render_warning_stream = _WarningStream() warnings, is_ok = _check_file(filename, render_warning_stream) # Print the status and/or error if not is_ok: failure = True print("[red]FAILED[/red]") logger.error( "`long_description` has syntax errors in markup" " and would not be rendered on PyPI." f"\n{render_warning_stream}" ) elif warnings: if strict: failure = True print("[red]FAILED due to warnings[/red]") else: print("[yellow]PASSED with warnings[/yellow]") else: print("[green]PASSED[/green]") # Print warnings after the status and/or error for message in warnings: logger.warning(message) return failure def main(args: List[str]) -> bool: """Execute the ``check`` command. :param args: The command-line arguments. :return: The exit status of the ``check`` command. """ parser = argparse.ArgumentParser(prog="twine check") parser.add_argument( "dists", nargs="+", metavar="dist", help="The distribution files to check, usually dist/*", ) parser.add_argument( "--strict", action="store_true", default=False, required=False, help="Fail on warnings", ) parsed_args = parser.parse_args(args) # Call the check function with the arguments from the command line return check(parsed_args.dists, strict=parsed_args.strict)