# Copyright (c) Twisted Matrix Laboratories. # See LICENSE for details. """ Tests for L{twisted.python.release} and L{twisted.python._release}. All of these tests are skipped on platforms other than Linux, as the release is only ever performed on Linux. """ import functools import glob import operator import os import shutil import sys import tempfile import textwrap from io import BytesIO, StringIO from subprocess import CalledProcessError from unittest import skipIf from incremental import Version from twisted.python import release from twisted.python._release import ( APIBuilder, BuildAPIDocsScript, CheckNewsfragmentScript, GitCommand, IVCSCommand, NotWorkingDirectory, Project, SphinxBuilder, filePathDelta, findTwistedProjects, getRepositoryCommand, replaceInFile, runCommand, ) from twisted.python.filepath import FilePath from twisted.python.procutils import which from twisted.python.reflect import requireModule from twisted.trial.unittest import FailTest, SkipTest, TestCase if sys.platform != "win32": skip = None else: skip = "Release toolchain only supported on POSIX." # This should match the GitHub Actions environment used by pre-commit.ci to push changes to the auto-updated branches. PRECOMMIT_CI_ENVIRON = { "GITHUB_HEAD_REF": "pre-commit-ci-update-config", "PATH": os.environ["PATH"], } # This should match the GHA environment for non pre-commit.ci PRs. GENERIC_CI_ENVIRON = { "GITHUB_HEAD_REF": "1234-some-branch-name", "PATH": os.environ["PATH"], } class ExternalTempdirTestCase(TestCase): """ A test case which has mkdir make directories outside of the usual spot, so that Git commands don't interfere with the Twisted checkout. """ def mktemp(self): """ Make our own directory. """ newDir = tempfile.mkdtemp(dir=tempfile.gettempdir()) self.addCleanup(shutil.rmtree, newDir) return newDir def _gitConfig(path): """ Set some config in the repo that Git requires to make commits. This isn't needed in real usage, just for tests. @param path: The path to the Git repository. @type path: L{FilePath} """ runCommand( [ "git", "config", "--file", path.child(".git").child("config").path, "user.name", '"someone"', ] ) runCommand( [ "git", "config", "--file", path.child(".git").child("config").path, "user.email", '"someone@someplace.com"', ] ) def _gitInit(path): """ Run a git init, and set some config that git requires. This isn't needed in real usage. @param path: The path to where the Git repo will be created. @type path: L{FilePath} """ runCommand(["git", "init", path.path]) _gitConfig(path) def genVersion(*args, **kwargs): """ A convenience for generating _version.py data. @param args: Arguments to pass to L{Version}. @param kwargs: Keyword arguments to pass to L{Version}. """ return "from incremental import Version\n__version__={!r}".format( Version(*args, **kwargs) ) class StructureAssertingMixin: """ A mixin for L{TestCase} subclasses which provides some methods for asserting the structure and contents of directories and files on the filesystem. """ def createStructure(self, root, dirDict): """ Create a set of directories and files given a dict defining their structure. @param root: The directory in which to create the structure. It must already exist. @type root: L{FilePath} @param dirDict: The dict defining the structure. Keys should be strings naming files, values should be strings describing file contents OR dicts describing subdirectories. All files are written in binary mode. Any string values are assumed to describe text files and will have their newlines replaced with the platform-native newline convention. For example:: {"foofile": "foocontents", "bardir": {"barfile": "bar\ncontents"}} @type dirDict: C{dict} """ for x in dirDict: child = root.child(x) if isinstance(dirDict[x], dict): child.createDirectory() self.createStructure(child, dirDict[x]) else: child.setContent(dirDict[x].replace("\n", os.linesep).encode()) def assertStructure(self, root, dirDict): """ Assert that a directory is equivalent to one described by a dict. @param root: The filesystem directory to compare. @type root: L{FilePath} @param dirDict: The dict that should describe the contents of the directory. It should be the same structure as the C{dirDict} parameter to L{createStructure}. @type dirDict: C{dict} """ children = [each.basename() for each in root.children()] for pathSegment, expectation in dirDict.items(): child = root.child(pathSegment) if callable(expectation): self.assertTrue(expectation(child)) elif isinstance(expectation, dict): self.assertTrue(child.isdir(), f"{child.path} is not a dir!") self.assertStructure(child, expectation) else: actual = child.getContent().decode().replace(os.linesep, "\n") self.assertEqual(actual, expectation) children.remove(pathSegment) if children: self.fail(f"There were extra children in {root.path}: {children}") class ProjectTests(ExternalTempdirTestCase): """ There is a first-class representation of a project. """ def assertProjectsEqual(self, observedProjects, expectedProjects): """ Assert that two lists of L{Project}s are equal. """ self.assertEqual(len(observedProjects), len(expectedProjects)) observedProjects = sorted( observedProjects, key=operator.attrgetter("directory") ) expectedProjects = sorted( expectedProjects, key=operator.attrgetter("directory") ) for observed, expected in zip(observedProjects, expectedProjects): self.assertEqual(observed.directory, expected.directory) def makeProject(self, version, baseDirectory=None): """ Make a Twisted-style project in the given base directory. @param baseDirectory: The directory to create files in (as a L{FilePath). @param version: The version information for the project. @return: L{Project} pointing to the created project. """ if baseDirectory is None: baseDirectory = FilePath(self.mktemp()) segments = version[0].split(".") directory = baseDirectory for segment in segments: directory = directory.child(segment) if not directory.exists(): directory.createDirectory() directory.child("__init__.py").setContent(b"") directory.child("newsfragments").createDirectory() directory.child("_version.py").setContent(genVersion(*version).encode()) return Project(directory) def makeProjects(self, *versions): """ Create a series of projects underneath a temporary base directory. @return: A L{FilePath} for the base directory. """ baseDirectory = FilePath(self.mktemp()) for version in versions: self.makeProject(version, baseDirectory) return baseDirectory def test_getVersion(self): """ Project objects know their version. """ version = ("twisted", 2, 1, 0) project = self.makeProject(version) self.assertEqual(project.getVersion(), Version(*version)) def test_repr(self): """ The representation of a Project is Project(directory). """ foo = Project(FilePath("bar")) self.assertEqual(repr(foo), "Project(%r)" % (foo.directory)) def test_findTwistedStyleProjects(self): """ findTwistedStyleProjects finds all projects underneath a particular directory. A 'project' is defined by the existence of a 'newsfragments' directory and is returned as a Project object. """ baseDirectory = self.makeProjects(("foo", 2, 3, 0), ("foo.bar", 0, 7, 4)) projects = findTwistedProjects(baseDirectory) self.assertProjectsEqual( projects, [ Project(baseDirectory.child("foo")), Project(baseDirectory.child("foo").child("bar")), ], ) class UtilityTests(ExternalTempdirTestCase): """ Tests for various utility functions for releasing. """ def test_chdir(self): """ Test that the runChdirSafe is actually safe, i.e., it still changes back to the original directory even if an error is raised. """ cwd = os.getcwd() def chAndBreak(): os.mkdir("releaseCh") os.chdir("releaseCh") 1 // 0 self.assertRaises(ZeroDivisionError, release.runChdirSafe, chAndBreak) self.assertEqual(cwd, os.getcwd()) def test_replaceInFile(self): """ L{replaceInFile} replaces data in a file based on a dict. A key from the dict that is found in the file is replaced with the corresponding value. """ content = "foo\nhey hey $VER\nbar\n" with open("release.replace", "w") as outf: outf.write(content) expected = content.replace("$VER", "2.0.0") replaceInFile("release.replace", {"$VER": "2.0.0"}) with open("release.replace") as f: self.assertEqual(f.read(), expected) expected = expected.replace("2.0.0", "3.0.0") replaceInFile("release.replace", {"2.0.0": "3.0.0"}) with open("release.replace") as f: self.assertEqual(f.read(), expected) def doNotFailOnNetworkError(func): """ A decorator which makes APIBuilder tests not fail because of intermittent network failures -- mamely, APIBuilder being unable to get the "object inventory" of other projects. @param func: The function to decorate. @return: A decorated function which won't fail if the object inventory fetching fails. """ @functools.wraps(func) def wrapper(*a, **kw): try: func(*a, **kw) except FailTest as e: if e.args[0].startswith("'Failed to get object inventory from "): raise SkipTest( ( "This test is prone to intermittent network errors. " "See ticket 8753. Exception was: {!r}" ).format(e) ) raise return wrapper class DoNotFailTests(TestCase): """ Tests for L{doNotFailOnNetworkError}. """ def test_skipsOnAssertionError(self): """ When the test raises L{FailTest} and the assertion failure starts with "'Failed to get object inventory from ", the test will be skipped instead. """ @doNotFailOnNetworkError def inner(): self.assertEqual("Failed to get object inventory from blah", "") try: inner() except Exception as e: self.assertIsInstance(e, SkipTest) def test_doesNotSkipOnDifferentError(self): """ If there is a L{FailTest} that is not the intersphinx fetching error, it will be passed through. """ @doNotFailOnNetworkError def inner(): self.assertEqual("Error!!!!", "") try: inner() except Exception as e: self.assertIsInstance(e, FailTest) pydoctor = requireModule("pydoctor") @skipIf(pydoctor is None, "Pydoctor is not present.") class APIBuilderTests(ExternalTempdirTestCase): """ Tests for L{APIBuilder}. """ @doNotFailOnNetworkError def test_build(self): """ L{APIBuilder.build} writes an index file which includes the name of the project specified. """ stdout = BytesIO() self.patch(sys, "stdout", stdout) projectName = "Foobar" packageName = "quux" projectURL = "scheme:project" sourceURL = "scheme:source" docstring = "text in docstring" privateDocstring = "should also appear in output" inputPath = FilePath(self.mktemp()).child(packageName) inputPath.makedirs() inputPath.child("__init__.py").setContent( "def foo():\n" " '{}'\n" "def _bar():\n" " '{}'".format(docstring, privateDocstring).encode() ) outputPath = FilePath(self.mktemp()) builder = APIBuilder() builder.build(projectName, projectURL, sourceURL, inputPath, outputPath) indexPath = outputPath.child("index.html") self.assertTrue( indexPath.exists(), f"API index {outputPath.path!r} did not exist." ) self.assertIn( f'{projectName}', indexPath.getContent().decode(), "Project name/location not in file contents.", ) quuxPath = outputPath.child("quux.html") self.assertTrue( quuxPath.exists(), f"Package documentation file {quuxPath.path!r} did not exist.", ) self.assertIn( docstring, quuxPath.getContent().decode(), "Docstring not in package documentation file.", ) self.assertIn( f'' f"(source)", quuxPath.getContent().decode(), ) self.assertIn( '' % (sourceURL, packageName), quuxPath.getContent().decode(), ) self.assertIn(privateDocstring, quuxPath.getContent().decode()) self.assertEqual(stdout.getvalue(), b"") @doNotFailOnNetworkError def test_buildWithPolicy(self): """ L{BuildAPIDocsScript.buildAPIDocs} builds the API docs with values appropriate for the Twisted project. """ stdout = BytesIO() self.patch(sys, "stdout", stdout) docstring = "text in docstring" projectRoot = FilePath(self.mktemp()) packagePath = projectRoot.child("twisted") packagePath.makedirs() packagePath.child("__init__.py").setContent( "def foo():\n" " '{}'\n".format(docstring).encode() ) packagePath.child("_version.py").setContent( genVersion("twisted", 1, 0, 0).encode() ) outputPath = FilePath(self.mktemp()) script = BuildAPIDocsScript() script.buildAPIDocs(projectRoot, outputPath) indexPath = outputPath.child("index.html") self.assertTrue( indexPath.exists(), f"API index {outputPath.path} did not exist." ) self.assertIn( 'Twisted', indexPath.getContent().decode(), "Project name/location not in file contents.", ) twistedPath = outputPath.child("twisted.html") self.assertTrue( twistedPath.exists(), f"Package documentation file {twistedPath.path!r} did not exist.", ) self.assertIn( docstring, twistedPath.getContent().decode(), "Docstring not in package documentation file.", ) # Here we check that it figured out the correct version based on the # source code. self.assertIn( '(source)', twistedPath.getContent().decode(), ) self.assertEqual(stdout.getvalue(), b"") @doNotFailOnNetworkError def test_buildWithDeprecated(self): """ The templates and System for Twisted includes adding deprecations. """ stdout = BytesIO() self.patch(sys, "stdout", stdout) projectName = "Foobar" packageName = "quux" projectURL = "scheme:project" sourceURL = "scheme:source" docstring = "text in docstring" privateDocstring = "should also appear in output" inputPath = FilePath(self.mktemp()).child(packageName) inputPath.makedirs() inputPath.child("__init__.py").setContent( "from twisted.python.deprecate import deprecated\n" "from incremental import Version\n" "@deprecated(Version('Twisted', 15, 0, 0), " "'Baz')\n" "def foo():\n" " '{}'\n" "from twisted.python import deprecate\n" "import incremental\n" "@deprecate.deprecated(incremental.Version('Twisted', 16, 0, 0))\n" "def _bar():\n" " '{}'\n" "@deprecated(Version('Twisted', 14, 2, 3), replacement='stuff')\n" "class Baz:\n" " pass" "".format(docstring, privateDocstring).encode() ) outputPath = FilePath(self.mktemp()) builder = APIBuilder() builder.build(projectName, projectURL, sourceURL, inputPath, outputPath) quuxPath = outputPath.child("quux.html") self.assertTrue( quuxPath.exists(), f"Package documentation file {quuxPath.path!r} did not exist.", ) self.assertIn( docstring, quuxPath.getContent().decode(), "Docstring not in package documentation file.", ) self.assertIn( "foo was deprecated in Twisted 15.0.0; please use Baz instead.", quuxPath.getContent().decode(), ) self.assertIn( "_bar was deprecated in Twisted 16.0.0.", quuxPath.getContent().decode() ) self.assertIn(privateDocstring, quuxPath.getContent().decode()) self.assertIn( "Baz was deprecated in Twisted 14.2.3; please use stuff instead.", quuxPath.sibling("quux.Baz.html").getContent().decode(), ) self.assertEqual(stdout.getvalue(), b"") def test_apiBuilderScriptMainRequiresTwoArguments(self): """ SystemExit is raised when the incorrect number of command line arguments are passed to the API building script. """ script = BuildAPIDocsScript() self.assertRaises(SystemExit, script.main, []) self.assertRaises(SystemExit, script.main, ["foo"]) self.assertRaises(SystemExit, script.main, ["foo", "bar", "baz"]) def test_apiBuilderScriptMain(self): """ The API building script invokes the same code that L{test_buildWithPolicy} tests. """ script = BuildAPIDocsScript() calls = [] script.buildAPIDocs = lambda a, b: calls.append((a, b)) script.main(["hello", "there"]) self.assertEqual(calls, [(FilePath("hello"), FilePath("there"))]) class FilePathDeltaTests(TestCase): """ Tests for L{filePathDelta}. """ def test_filePathDeltaSubdir(self): """ L{filePathDelta} can create a simple relative path to a child path. """ self.assertEqual( filePathDelta(FilePath("/foo/bar"), FilePath("/foo/bar/baz")), ["baz"] ) def test_filePathDeltaSiblingDir(self): """ L{filePathDelta} can traverse upwards to create relative paths to siblings. """ self.assertEqual( filePathDelta(FilePath("/foo/bar"), FilePath("/foo/baz")), ["..", "baz"] ) def test_filePathNoCommonElements(self): """ L{filePathDelta} can create relative paths to totally unrelated paths for maximum portability. """ self.assertEqual( filePathDelta(FilePath("/foo/bar"), FilePath("/baz/quux")), ["..", "..", "baz", "quux"], ) def test_filePathDeltaSimilarEndElements(self): """ L{filePathDelta} doesn't take into account final elements when comparing 2 paths, but stops at the first difference. """ self.assertEqual( filePathDelta(FilePath("/foo/bar/bar/spam"), FilePath("/foo/bar/baz/spam")), ["..", "..", "baz", "spam"], ) @skipIf(not which("sphinx-build"), "Sphinx not available.") class SphinxBuilderTests(TestCase): """ Tests for L{SphinxBuilder}. @note: This test case depends on twisted.web, which violates the standard Twisted practice of not having anything in twisted.python depend on other Twisted packages and opens up the possibility of creating circular dependencies. Do not use this as an example of how to structure your dependencies. @ivar builder: A plain L{SphinxBuilder}. @ivar sphinxDir: A L{FilePath} representing a directory to be used for containing a Sphinx project. @ivar sourceDir: A L{FilePath} representing a directory to be used for containing the source files for a Sphinx project. """ confContent = """\ source_suffix = '.rst' master_doc = 'index' """ confContent = textwrap.dedent(confContent) indexContent = """\ ============== This is a Test ============== This is only a test ------------------- In case you hadn't figured it out yet, this is a test. """ indexContent = textwrap.dedent(indexContent) def setUp(self): """ Set up a few instance variables that will be useful. """ self.builder = SphinxBuilder() # set up a place for a fake sphinx project self.twistedRootDir = FilePath(self.mktemp()) self.sphinxDir = self.twistedRootDir.child("docs") self.sphinxDir.makedirs() self.sourceDir = self.sphinxDir def createFakeSphinxProject(self): """ Create a fake Sphinx project for test purposes. Creates a fake Sphinx project with the absolute minimum of source files. This includes a single source file ('index.rst') and the smallest 'conf.py' file possible in order to find that source file. """ self.sourceDir.child("conf.py").setContent(self.confContent.encode()) self.sourceDir.child("index.rst").setContent(self.indexContent.encode()) def verifyFileExists(self, fileDir, fileName): """ Helper which verifies that C{fileName} exists in C{fileDir} and it has some content. @param fileDir: A path to a directory. @type fileDir: L{FilePath} @param fileName: The last path segment of a file which may exist within C{fileDir}. @type fileName: L{str} @raise FailTest: If C{fileDir.child(fileName)}: 1. Does not exist. 2. Is empty. 3. In the case where it's a path to a C{.html} file, the content looks like an HTML file. @return: L{None} """ # check that file exists fpath = fileDir.child(fileName) self.assertTrue(fpath.exists()) # check that the output files have some content fcontents = fpath.getContent() self.assertTrue(len(fcontents) > 0) # check that the html files are at least html-ish # this is not a terribly rigorous check if fpath.path.endswith(".html"): self.assertIn(b"