test_release.py 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250
  1. # Copyright (c) Twisted Matrix Laboratories.
  2. # See LICENSE for details.
  3. """
  4. Tests for L{twisted.python.release} and L{twisted.python._release}.
  5. All of these tests are skipped on platforms other than Linux, as the release is
  6. only ever performed on Linux.
  7. """
  8. from __future__ import print_function
  9. import glob
  10. import functools
  11. import operator
  12. import os
  13. import sys
  14. import textwrap
  15. import tempfile
  16. import shutil
  17. from io import BytesIO as StringIO
  18. from twisted.trial.unittest import TestCase, FailTest, SkipTest
  19. from twisted.python.procutils import which
  20. from twisted.python import release
  21. from twisted.python.filepath import FilePath
  22. from incremental import Version
  23. from subprocess import CalledProcessError
  24. from twisted.python._release import (
  25. findTwistedProjects, replaceInFile, Project, filePathDelta,
  26. APIBuilder, BuildAPIDocsScript, CheckTopfileScript,
  27. runCommand, NotWorkingDirectory, SphinxBuilder,
  28. GitCommand, getRepositoryCommand, IVCSCommand)
  29. if os.name != 'posix':
  30. skip = "Release toolchain only supported on POSIX."
  31. else:
  32. skip = None
  33. testingSphinxConf = "master_doc = 'index'\n"
  34. try:
  35. import pydoctor.driver
  36. # it might not be installed, or it might use syntax not available in
  37. # this version of Python.
  38. except (ImportError, SyntaxError):
  39. pydoctorSkip = "Pydoctor is not present."
  40. else:
  41. if getattr(pydoctor, "version_info", (0,)) < (0, 1):
  42. pydoctorSkip = "Pydoctor is too old."
  43. else:
  44. pydoctorSkip = skip
  45. if not skip and which("sphinx-build"):
  46. sphinxSkip = None
  47. else:
  48. sphinxSkip = "Sphinx not available."
  49. if not skip and which("git"):
  50. gitVersion = runCommand(["git", "--version"]).split(" ")[2].split(".")
  51. # We want git 2.0 or above.
  52. if int(gitVersion[0]) >= 2:
  53. gitSkip = skip
  54. else:
  55. gitSkip = "old git is present"
  56. else:
  57. gitSkip = "git is not present."
  58. class ExternalTempdirTestCase(TestCase):
  59. """
  60. A test case which has mkdir make directories outside of the usual spot, so
  61. that Git commands don't interfere with the Twisted checkout.
  62. """
  63. def mktemp(self):
  64. """
  65. Make our own directory.
  66. """
  67. newDir = tempfile.mkdtemp(dir="/tmp/")
  68. self.addCleanup(shutil.rmtree, newDir)
  69. return newDir
  70. def _gitConfig(path):
  71. """
  72. Set some config in the repo that Git requires to make commits. This isn't
  73. needed in real usage, just for tests.
  74. @param path: The path to the Git repository.
  75. @type path: L{FilePath}
  76. """
  77. runCommand(["git", "config",
  78. "--file", path.child(".git").child("config").path,
  79. "user.name", '"someone"'])
  80. runCommand(["git", "config",
  81. "--file", path.child(".git").child("config").path,
  82. "user.email", '"someone@someplace.com"'])
  83. def _gitInit(path):
  84. """
  85. Run a git init, and set some config that git requires. This isn't needed in
  86. real usage.
  87. @param path: The path to where the Git repo will be created.
  88. @type path: L{FilePath}
  89. """
  90. runCommand(["git", "init", path.path])
  91. _gitConfig(path)
  92. def genVersion(*args, **kwargs):
  93. """
  94. A convenience for generating _version.py data.
  95. @param args: Arguments to pass to L{Version}.
  96. @param kwargs: Keyword arguments to pass to L{Version}.
  97. """
  98. return ("from incremental import Version\n__version__=%r" % (
  99. Version(*args, **kwargs))).encode('ascii')
  100. class StructureAssertingMixin(object):
  101. """
  102. A mixin for L{TestCase} subclasses which provides some methods for
  103. asserting the structure and contents of directories and files on the
  104. filesystem.
  105. """
  106. def createStructure(self, root, dirDict):
  107. """
  108. Create a set of directories and files given a dict defining their
  109. structure.
  110. @param root: The directory in which to create the structure. It must
  111. already exist.
  112. @type root: L{FilePath}
  113. @param dirDict: The dict defining the structure. Keys should be strings
  114. naming files, values should be strings describing file contents OR
  115. dicts describing subdirectories. All files are written in binary
  116. mode. Any string values are assumed to describe text files and
  117. will have their newlines replaced with the platform-native newline
  118. convention. For example::
  119. {"foofile": "foocontents",
  120. "bardir": {"barfile": "bar\ncontents"}}
  121. @type dirDict: C{dict}
  122. """
  123. for x in dirDict:
  124. child = root.child(x)
  125. if isinstance(dirDict[x], dict):
  126. child.createDirectory()
  127. self.createStructure(child, dirDict[x])
  128. else:
  129. child.setContent(dirDict[x].replace('\n', os.linesep))
  130. def assertStructure(self, root, dirDict):
  131. """
  132. Assert that a directory is equivalent to one described by a dict.
  133. @param root: The filesystem directory to compare.
  134. @type root: L{FilePath}
  135. @param dirDict: The dict that should describe the contents of the
  136. directory. It should be the same structure as the C{dirDict}
  137. parameter to L{createStructure}.
  138. @type dirDict: C{dict}
  139. """
  140. children = [each.basename() for each in root.children()]
  141. for pathSegment, expectation in dirDict.items():
  142. child = root.child(pathSegment)
  143. if callable(expectation):
  144. self.assertTrue(expectation(child))
  145. elif isinstance(expectation, dict):
  146. self.assertTrue(child.isdir(), "%s is not a dir!"
  147. % (child.path,))
  148. self.assertStructure(child, expectation)
  149. else:
  150. actual = child.getContent().replace(os.linesep, '\n')
  151. self.assertEqual(actual, expectation)
  152. children.remove(pathSegment)
  153. if children:
  154. self.fail("There were extra children in %s: %s"
  155. % (root.path, children))
  156. class ProjectTests(ExternalTempdirTestCase):
  157. """
  158. There is a first-class representation of a project.
  159. """
  160. def assertProjectsEqual(self, observedProjects, expectedProjects):
  161. """
  162. Assert that two lists of L{Project}s are equal.
  163. """
  164. self.assertEqual(len(observedProjects), len(expectedProjects))
  165. observedProjects = sorted(observedProjects,
  166. key=operator.attrgetter('directory'))
  167. expectedProjects = sorted(expectedProjects,
  168. key=operator.attrgetter('directory'))
  169. for observed, expected in zip(observedProjects, expectedProjects):
  170. self.assertEqual(observed.directory, expected.directory)
  171. def makeProject(self, version, baseDirectory=None):
  172. """
  173. Make a Twisted-style project in the given base directory.
  174. @param baseDirectory: The directory to create files in
  175. (as a L{FilePath).
  176. @param version: The version information for the project.
  177. @return: L{Project} pointing to the created project.
  178. """
  179. if baseDirectory is None:
  180. baseDirectory = FilePath(self.mktemp())
  181. segments = version[0].split('.')
  182. directory = baseDirectory
  183. for segment in segments:
  184. directory = directory.child(segment)
  185. if not directory.exists():
  186. directory.createDirectory()
  187. directory.child('__init__.py').setContent('')
  188. directory.child('topfiles').createDirectory()
  189. directory.child('_version.py').setContent(genVersion(*version))
  190. return Project(directory)
  191. def makeProjects(self, *versions):
  192. """
  193. Create a series of projects underneath a temporary base directory.
  194. @return: A L{FilePath} for the base directory.
  195. """
  196. baseDirectory = FilePath(self.mktemp())
  197. for version in versions:
  198. self.makeProject(version, baseDirectory)
  199. return baseDirectory
  200. def test_getVersion(self):
  201. """
  202. Project objects know their version.
  203. """
  204. version = ('twisted', 2, 1, 0)
  205. project = self.makeProject(version)
  206. self.assertEqual(project.getVersion(), Version(*version))
  207. def test_repr(self):
  208. """
  209. The representation of a Project is Project(directory).
  210. """
  211. foo = Project(FilePath('bar'))
  212. self.assertEqual(
  213. repr(foo), 'Project(%r)' % (foo.directory))
  214. def test_findTwistedStyleProjects(self):
  215. """
  216. findTwistedStyleProjects finds all projects underneath a particular
  217. directory. A 'project' is defined by the existence of a 'topfiles'
  218. directory and is returned as a Project object.
  219. """
  220. baseDirectory = self.makeProjects(
  221. ('foo', 2, 3, 0), ('foo.bar', 0, 7, 4))
  222. projects = findTwistedProjects(baseDirectory)
  223. self.assertProjectsEqual(
  224. projects,
  225. [Project(baseDirectory.child('foo')),
  226. Project(baseDirectory.child('foo').child('bar'))])
  227. class UtilityTests(ExternalTempdirTestCase):
  228. """
  229. Tests for various utility functions for releasing.
  230. """
  231. def test_chdir(self):
  232. """
  233. Test that the runChdirSafe is actually safe, i.e., it still
  234. changes back to the original directory even if an error is
  235. raised.
  236. """
  237. cwd = os.getcwd()
  238. def chAndBreak():
  239. os.mkdir('releaseCh')
  240. os.chdir('releaseCh')
  241. 1 // 0
  242. self.assertRaises(ZeroDivisionError,
  243. release.runChdirSafe, chAndBreak)
  244. self.assertEqual(cwd, os.getcwd())
  245. def test_replaceInFile(self):
  246. """
  247. L{replaceInFile} replaces data in a file based on a dict. A key from
  248. the dict that is found in the file is replaced with the corresponding
  249. value.
  250. """
  251. content = 'foo\nhey hey $VER\nbar\n'
  252. with open('release.replace', 'w') as outf:
  253. outf.write(content)
  254. expected = content.replace('$VER', '2.0.0')
  255. replaceInFile('release.replace', {'$VER': '2.0.0'})
  256. with open('release.replace') as f:
  257. self.assertEqual(f.read(), expected)
  258. expected = expected.replace('2.0.0', '3.0.0')
  259. replaceInFile('release.replace', {'2.0.0': '3.0.0'})
  260. with open('release.replace') as f:
  261. self.assertEqual(f.read(), expected)
  262. def doNotFailOnNetworkError(func):
  263. """
  264. A decorator which makes APIBuilder tests not fail because of intermittent
  265. network failures -- mamely, APIBuilder being unable to get the "object
  266. inventory" of other projects.
  267. @param func: The function to decorate.
  268. @return: A decorated function which won't fail if the object inventory
  269. fetching fails.
  270. """
  271. @functools.wraps(func)
  272. def wrapper(*a, **kw):
  273. try:
  274. func(*a, **kw)
  275. except FailTest as e:
  276. if e.args[0].startswith("'Failed to get object inventory from "):
  277. raise SkipTest(
  278. ("This test is prone to intermittent network errors. "
  279. "See ticket 8753. Exception was: {!r}").format(e))
  280. raise
  281. return wrapper
  282. class DoNotFailTests(TestCase):
  283. """
  284. Tests for L{doNotFailOnNetworkError}.
  285. """
  286. def test_skipsOnAssertionError(self):
  287. """
  288. When the test raises L{FailTest} and the assertion failure starts with
  289. "'Failed to get object inventory from ", the test will be skipped
  290. instead.
  291. """
  292. @doNotFailOnNetworkError
  293. def inner():
  294. self.assertEqual("Failed to get object inventory from blah", "")
  295. try:
  296. inner()
  297. except Exception as e:
  298. self.assertIsInstance(e, SkipTest)
  299. def test_doesNotSkipOnDifferentError(self):
  300. """
  301. If there is a L{FailTest} that is not the intersphinx fetching error,
  302. it will be passed through.
  303. """
  304. @doNotFailOnNetworkError
  305. def inner():
  306. self.assertEqual("Error!!!!", "")
  307. try:
  308. inner()
  309. except Exception as e:
  310. self.assertIsInstance(e, FailTest)
  311. class APIBuilderTests(ExternalTempdirTestCase):
  312. """
  313. Tests for L{APIBuilder}.
  314. """
  315. skip = pydoctorSkip
  316. @doNotFailOnNetworkError
  317. def test_build(self):
  318. """
  319. L{APIBuilder.build} writes an index file which includes the name of the
  320. project specified.
  321. """
  322. stdout = StringIO()
  323. self.patch(sys, 'stdout', stdout)
  324. projectName = "Foobar"
  325. packageName = "quux"
  326. projectURL = "scheme:project"
  327. sourceURL = "scheme:source"
  328. docstring = "text in docstring"
  329. privateDocstring = "should also appear in output"
  330. inputPath = FilePath(self.mktemp()).child(packageName)
  331. inputPath.makedirs()
  332. inputPath.child("__init__.py").setContent(
  333. "def foo():\n"
  334. " '%s'\n"
  335. "def _bar():\n"
  336. " '%s'" % (docstring, privateDocstring))
  337. outputPath = FilePath(self.mktemp())
  338. builder = APIBuilder()
  339. builder.build(projectName, projectURL, sourceURL, inputPath,
  340. outputPath)
  341. indexPath = outputPath.child("index.html")
  342. self.assertTrue(
  343. indexPath.exists(),
  344. "API index %r did not exist." % (outputPath.path,))
  345. self.assertIn(
  346. '<a href="%s">%s</a>' % (projectURL, projectName),
  347. indexPath.getContent(),
  348. "Project name/location not in file contents.")
  349. quuxPath = outputPath.child("quux.html")
  350. self.assertTrue(
  351. quuxPath.exists(),
  352. "Package documentation file %r did not exist." % (quuxPath.path,))
  353. self.assertIn(
  354. docstring, quuxPath.getContent(),
  355. "Docstring not in package documentation file.")
  356. self.assertIn(
  357. '<a href="%s/%s">View Source</a>' % (sourceURL, packageName),
  358. quuxPath.getContent())
  359. self.assertIn(
  360. '<a class="functionSourceLink" href="%s/%s/__init__.py#L1">' % (
  361. sourceURL, packageName),
  362. quuxPath.getContent())
  363. self.assertIn(privateDocstring, quuxPath.getContent())
  364. # There should also be a page for the foo function in quux.
  365. self.assertTrue(quuxPath.sibling('quux.foo.html').exists())
  366. self.assertEqual(stdout.getvalue(), '')
  367. @doNotFailOnNetworkError
  368. def test_buildWithPolicy(self):
  369. """
  370. L{BuildAPIDocsScript.buildAPIDocs} builds the API docs with values
  371. appropriate for the Twisted project.
  372. """
  373. stdout = StringIO()
  374. self.patch(sys, 'stdout', stdout)
  375. docstring = "text in docstring"
  376. projectRoot = FilePath(self.mktemp())
  377. packagePath = projectRoot.child("twisted")
  378. packagePath.makedirs()
  379. packagePath.child("__init__.py").setContent(
  380. "def foo():\n"
  381. " '%s'\n" % (docstring,))
  382. packagePath.child("_version.py").setContent(
  383. genVersion("twisted", 1, 0, 0))
  384. outputPath = FilePath(self.mktemp())
  385. script = BuildAPIDocsScript()
  386. script.buildAPIDocs(projectRoot, outputPath)
  387. indexPath = outputPath.child("index.html")
  388. self.assertTrue(
  389. indexPath.exists(),
  390. "API index %r did not exist." % (outputPath.path,))
  391. self.assertIn(
  392. '<a href="http://twistedmatrix.com/">Twisted</a>',
  393. indexPath.getContent(),
  394. "Project name/location not in file contents.")
  395. twistedPath = outputPath.child("twisted.html")
  396. self.assertTrue(
  397. twistedPath.exists(),
  398. "Package documentation file %r did not exist."
  399. % (twistedPath.path,))
  400. self.assertIn(
  401. docstring, twistedPath.getContent(),
  402. "Docstring not in package documentation file.")
  403. #Here we check that it figured out the correct version based on the
  404. #source code.
  405. self.assertIn(
  406. '<a href="https://github.com/twisted/twisted/tree/'
  407. 'twisted-1.0.0/src/twisted">View Source</a>',
  408. twistedPath.getContent())
  409. self.assertEqual(stdout.getvalue(), '')
  410. @doNotFailOnNetworkError
  411. def test_buildWithDeprecated(self):
  412. """
  413. The templates and System for Twisted includes adding deprecations.
  414. """
  415. stdout = StringIO()
  416. self.patch(sys, 'stdout', stdout)
  417. projectName = "Foobar"
  418. packageName = "quux"
  419. projectURL = "scheme:project"
  420. sourceURL = "scheme:source"
  421. docstring = "text in docstring"
  422. privateDocstring = "should also appear in output"
  423. inputPath = FilePath(self.mktemp()).child(packageName)
  424. inputPath.makedirs()
  425. inputPath.child("__init__.py").setContent(
  426. "from twisted.python.deprecate import deprecated\n"
  427. "from incremental import Version\n"
  428. "@deprecated(Version('Twisted', 15, 0, 0), "
  429. "'Baz')\n"
  430. "def foo():\n"
  431. " '%s'\n"
  432. "from twisted.python import deprecate\n"
  433. "import incremental\n"
  434. "@deprecate.deprecated(incremental.Version('Twisted', 16, 0, 0))\n"
  435. "def _bar():\n"
  436. " '%s'\n"
  437. "@deprecated(Version('Twisted', 14, 2, 3), replacement='stuff')\n"
  438. "class Baz(object):\n"
  439. " pass"
  440. "" % (docstring, privateDocstring))
  441. outputPath = FilePath(self.mktemp())
  442. builder = APIBuilder()
  443. builder.build(projectName, projectURL, sourceURL, inputPath,
  444. outputPath)
  445. quuxPath = outputPath.child("quux.html")
  446. self.assertTrue(
  447. quuxPath.exists(),
  448. "Package documentation file %r did not exist." % (quuxPath.path,))
  449. self.assertIn(
  450. docstring, quuxPath.getContent(),
  451. "Docstring not in package documentation file.")
  452. self.assertIn(
  453. 'foo was deprecated in Twisted 15.0.0; please use Baz instead.',
  454. quuxPath.getContent())
  455. self.assertIn(
  456. '_bar was deprecated in Twisted 16.0.0.',
  457. quuxPath.getContent())
  458. self.assertIn(privateDocstring, quuxPath.getContent())
  459. # There should also be a page for the foo function in quux.
  460. self.assertTrue(quuxPath.sibling('quux.foo.html').exists())
  461. self.assertIn(
  462. 'foo was deprecated in Twisted 15.0.0; please use Baz instead.',
  463. quuxPath.sibling('quux.foo.html').getContent())
  464. self.assertIn(
  465. 'Baz was deprecated in Twisted 14.2.3; please use stuff instead.',
  466. quuxPath.sibling('quux.Baz.html').getContent())
  467. self.assertEqual(stdout.getvalue(), '')
  468. def test_apiBuilderScriptMainRequiresTwoArguments(self):
  469. """
  470. SystemExit is raised when the incorrect number of command line
  471. arguments are passed to the API building script.
  472. """
  473. script = BuildAPIDocsScript()
  474. self.assertRaises(SystemExit, script.main, [])
  475. self.assertRaises(SystemExit, script.main, ["foo"])
  476. self.assertRaises(SystemExit, script.main, ["foo", "bar", "baz"])
  477. def test_apiBuilderScriptMain(self):
  478. """
  479. The API building script invokes the same code that
  480. L{test_buildWithPolicy} tests.
  481. """
  482. script = BuildAPIDocsScript()
  483. calls = []
  484. script.buildAPIDocs = lambda a, b: calls.append((a, b))
  485. script.main(["hello", "there"])
  486. self.assertEqual(calls, [(FilePath("hello"), FilePath("there"))])
  487. class FilePathDeltaTests(TestCase):
  488. """
  489. Tests for L{filePathDelta}.
  490. """
  491. def test_filePathDeltaSubdir(self):
  492. """
  493. L{filePathDelta} can create a simple relative path to a child path.
  494. """
  495. self.assertEqual(filePathDelta(FilePath("/foo/bar"),
  496. FilePath("/foo/bar/baz")),
  497. ["baz"])
  498. def test_filePathDeltaSiblingDir(self):
  499. """
  500. L{filePathDelta} can traverse upwards to create relative paths to
  501. siblings.
  502. """
  503. self.assertEqual(filePathDelta(FilePath("/foo/bar"),
  504. FilePath("/foo/baz")),
  505. ["..", "baz"])
  506. def test_filePathNoCommonElements(self):
  507. """
  508. L{filePathDelta} can create relative paths to totally unrelated paths
  509. for maximum portability.
  510. """
  511. self.assertEqual(filePathDelta(FilePath("/foo/bar"),
  512. FilePath("/baz/quux")),
  513. ["..", "..", "baz", "quux"])
  514. def test_filePathDeltaSimilarEndElements(self):
  515. """
  516. L{filePathDelta} doesn't take into account final elements when
  517. comparing 2 paths, but stops at the first difference.
  518. """
  519. self.assertEqual(filePathDelta(FilePath("/foo/bar/bar/spam"),
  520. FilePath("/foo/bar/baz/spam")),
  521. ["..", "..", "baz", "spam"])
  522. class SphinxBuilderTests(TestCase):
  523. """
  524. Tests for L{SphinxBuilder}.
  525. @note: This test case depends on twisted.web, which violates the standard
  526. Twisted practice of not having anything in twisted.python depend on
  527. other Twisted packages and opens up the possibility of creating
  528. circular dependencies. Do not use this as an example of how to
  529. structure your dependencies.
  530. @ivar builder: A plain L{SphinxBuilder}.
  531. @ivar sphinxDir: A L{FilePath} representing a directory to be used for
  532. containing a Sphinx project.
  533. @ivar sourceDir: A L{FilePath} representing a directory to be used for
  534. containing the source files for a Sphinx project.
  535. """
  536. skip = sphinxSkip
  537. confContent = """\
  538. source_suffix = '.rst'
  539. master_doc = 'index'
  540. """
  541. confContent = textwrap.dedent(confContent)
  542. indexContent = """\
  543. ==============
  544. This is a Test
  545. ==============
  546. This is only a test
  547. -------------------
  548. In case you hadn't figured it out yet, this is a test.
  549. """
  550. indexContent = textwrap.dedent(indexContent)
  551. def setUp(self):
  552. """
  553. Set up a few instance variables that will be useful.
  554. """
  555. self.builder = SphinxBuilder()
  556. # set up a place for a fake sphinx project
  557. self.twistedRootDir = FilePath(self.mktemp())
  558. self.sphinxDir = self.twistedRootDir.child("docs")
  559. self.sphinxDir.makedirs()
  560. self.sourceDir = self.sphinxDir
  561. def createFakeSphinxProject(self):
  562. """
  563. Create a fake Sphinx project for test purposes.
  564. Creates a fake Sphinx project with the absolute minimum of source
  565. files. This includes a single source file ('index.rst') and the
  566. smallest 'conf.py' file possible in order to find that source file.
  567. """
  568. self.sourceDir.child("conf.py").setContent(self.confContent)
  569. self.sourceDir.child("index.rst").setContent(self.indexContent)
  570. def verifyFileExists(self, fileDir, fileName):
  571. """
  572. Helper which verifies that C{fileName} exists in C{fileDir} and it has
  573. some content.
  574. @param fileDir: A path to a directory.
  575. @type fileDir: L{FilePath}
  576. @param fileName: The last path segment of a file which may exist within
  577. C{fileDir}.
  578. @type fileName: L{str}
  579. @raise: L{FailTest <twisted.trial.unittest.FailTest>} if
  580. C{fileDir.child(fileName)}:
  581. 1. Does not exist.
  582. 2. Is empty.
  583. 3. In the case where it's a path to a C{.html} file, the
  584. content looks like an HTML file.
  585. @return: L{None}
  586. """
  587. # check that file exists
  588. fpath = fileDir.child(fileName)
  589. self.assertTrue(fpath.exists())
  590. # check that the output files have some content
  591. fcontents = fpath.getContent()
  592. self.assertTrue(len(fcontents) > 0)
  593. # check that the html files are at least html-ish
  594. # this is not a terribly rigorous check
  595. if fpath.path.endswith('.html'):
  596. self.assertIn("<body", fcontents)
  597. def test_build(self):
  598. """
  599. Creates and builds a fake Sphinx project using a L{SphinxBuilder}.
  600. """
  601. self.createFakeSphinxProject()
  602. self.builder.build(self.sphinxDir)
  603. self.verifyBuilt()
  604. def test_main(self):
  605. """
  606. Creates and builds a fake Sphinx project as if via the command line.
  607. """
  608. self.createFakeSphinxProject()
  609. self.builder.main([self.sphinxDir.parent().path])
  610. self.verifyBuilt()
  611. def test_warningsAreErrors(self):
  612. """
  613. Creates and builds a fake Sphinx project as if via the command line,
  614. failing if there are any warnings.
  615. """
  616. output = StringIO()
  617. self.patch(sys, "stdout", output)
  618. self.createFakeSphinxProject()
  619. with self.sphinxDir.child("index.rst").open("a") as f:
  620. f.write("\n.. _malformed-link-target\n")
  621. exception = self.assertRaises(
  622. SystemExit,
  623. self.builder.main, [self.sphinxDir.parent().path]
  624. )
  625. self.assertEqual(exception.code, 1)
  626. self.assertIn("malformed hyperlink target", output.getvalue())
  627. self.verifyBuilt()
  628. def verifyBuilt(self):
  629. """
  630. Verify that a sphinx project has been built.
  631. """
  632. htmlDir = self.sphinxDir.sibling('doc')
  633. self.assertTrue(htmlDir.isdir())
  634. doctreeDir = htmlDir.child("doctrees")
  635. self.assertFalse(doctreeDir.exists())
  636. self.verifyFileExists(htmlDir, 'index.html')
  637. self.verifyFileExists(htmlDir, 'genindex.html')
  638. self.verifyFileExists(htmlDir, 'objects.inv')
  639. self.verifyFileExists(htmlDir, 'search.html')
  640. self.verifyFileExists(htmlDir, 'searchindex.js')
  641. def test_failToBuild(self):
  642. """
  643. Check that SphinxBuilder.build fails when run against a non-sphinx
  644. directory.
  645. """
  646. # note no fake sphinx project is created
  647. self.assertRaises(CalledProcessError,
  648. self.builder.build,
  649. self.sphinxDir)
  650. class CommandsTestMixin(StructureAssertingMixin):
  651. """
  652. Test mixin for the VCS commands used by the release scripts.
  653. """
  654. def setUp(self):
  655. self.tmpDir = FilePath(self.mktemp())
  656. def test_ensureIsWorkingDirectoryWithWorkingDirectory(self):
  657. """
  658. Calling the C{ensureIsWorkingDirectory} VCS command's method on a valid
  659. working directory doesn't produce any error.
  660. """
  661. reposDir = self.makeRepository(self.tmpDir)
  662. self.assertIsNone(
  663. self.createCommand.ensureIsWorkingDirectory(reposDir))
  664. def test_ensureIsWorkingDirectoryWithNonWorkingDirectory(self):
  665. """
  666. Calling the C{ensureIsWorkingDirectory} VCS command's method on an
  667. invalid working directory raises a L{NotWorkingDirectory} exception.
  668. """
  669. self.assertRaises(NotWorkingDirectory,
  670. self.createCommand.ensureIsWorkingDirectory,
  671. self.tmpDir)
  672. def test_statusClean(self):
  673. """
  674. Calling the C{isStatusClean} VCS command's method on a repository with
  675. no pending modifications returns C{True}.
  676. """
  677. reposDir = self.makeRepository(self.tmpDir)
  678. self.assertTrue(self.createCommand.isStatusClean(reposDir))
  679. def test_statusNotClean(self):
  680. """
  681. Calling the C{isStatusClean} VCS command's method on a repository with
  682. no pending modifications returns C{False}.
  683. """
  684. reposDir = self.makeRepository(self.tmpDir)
  685. reposDir.child('some-file').setContent("something")
  686. self.assertFalse(self.createCommand.isStatusClean(reposDir))
  687. def test_remove(self):
  688. """
  689. Calling the C{remove} VCS command's method remove the specified path
  690. from the directory.
  691. """
  692. reposDir = self.makeRepository(self.tmpDir)
  693. testFile = reposDir.child('some-file')
  694. testFile.setContent("something")
  695. self.commitRepository(reposDir)
  696. self.assertTrue(testFile.exists())
  697. self.createCommand.remove(testFile)
  698. testFile.restat(False) # Refresh the file information
  699. self.assertFalse(testFile.exists(), "File still exists")
  700. def test_export(self):
  701. """
  702. The C{exportTo} VCS command's method export the content of the
  703. repository as identical in a specified directory.
  704. """
  705. structure = {
  706. "README.rst": "Hi this is 1.0.0.",
  707. "twisted": {
  708. "topfiles": {
  709. "README": "Hi this is 1.0.0"},
  710. "_version.py": genVersion("twisted", 1, 0, 0),
  711. "web": {
  712. "topfiles": {
  713. "README": "Hi this is 1.0.0"},
  714. "_version.py": genVersion("twisted.web", 1, 0, 0)}}}
  715. reposDir = self.makeRepository(self.tmpDir)
  716. self.createStructure(reposDir, structure)
  717. self.commitRepository(reposDir)
  718. exportDir = FilePath(self.mktemp()).child("export")
  719. self.createCommand.exportTo(reposDir, exportDir)
  720. self.assertStructure(exportDir, structure)
  721. class GitCommandTest(CommandsTestMixin, ExternalTempdirTestCase):
  722. """
  723. Specific L{CommandsTestMixin} related to Git repositories through
  724. L{GitCommand}.
  725. """
  726. createCommand = GitCommand
  727. skip = gitSkip
  728. def makeRepository(self, root):
  729. """
  730. Create a Git repository in the specified path.
  731. @type root: L{FilePath}
  732. @params root: The directory to create the Git repository into.
  733. @return: The path to the repository just created.
  734. @rtype: L{FilePath}
  735. """
  736. _gitInit(root)
  737. return root
  738. def commitRepository(self, repository):
  739. """
  740. Add and commit all the files from the Git repository specified.
  741. @type repository: L{FilePath}
  742. @params repository: The Git repository to commit into.
  743. """
  744. runCommand(["git", "-C", repository.path, "add"] +
  745. glob.glob(repository.path + "/*"))
  746. runCommand(["git", "-C", repository.path, "commit", "-m", "hop"])
  747. class RepositoryCommandDetectionTest(ExternalTempdirTestCase):
  748. """
  749. Test the L{getRepositoryCommand} to access the right set of VCS commands
  750. depending on the repository manipulated.
  751. """
  752. skip = gitSkip
  753. def setUp(self):
  754. self.repos = FilePath(self.mktemp())
  755. def test_git(self):
  756. """
  757. L{getRepositoryCommand} from a Git repository returns L{GitCommand}.
  758. """
  759. _gitInit(self.repos)
  760. cmd = getRepositoryCommand(self.repos)
  761. self.assertIs(cmd, GitCommand)
  762. def test_unknownRepository(self):
  763. """
  764. L{getRepositoryCommand} from a directory which doesn't look like a Git
  765. repository produces a L{NotWorkingDirectory} exception.
  766. """
  767. self.assertRaises(NotWorkingDirectory, getRepositoryCommand,
  768. self.repos)
  769. class VCSCommandInterfaceTests(TestCase):
  770. """
  771. Test that the VCS command classes implement their interface.
  772. """
  773. def test_git(self):
  774. """
  775. L{GitCommand} implements L{IVCSCommand}.
  776. """
  777. self.assertTrue(IVCSCommand.implementedBy(GitCommand))
  778. class CheckTopfileScriptTests(ExternalTempdirTestCase):
  779. """
  780. Tests for L{CheckTopfileScript}.
  781. """
  782. skip = gitSkip
  783. def setUp(self):
  784. self.origin = FilePath(self.mktemp())
  785. _gitInit(self.origin)
  786. runCommand(["git", "checkout", "-b", "trunk"],
  787. cwd=self.origin.path)
  788. self.origin.child("test").setContent(b"test!")
  789. runCommand(["git", "add", self.origin.child("test").path],
  790. cwd=self.origin.path)
  791. runCommand(["git", "commit", "-m", "initial"],
  792. cwd=self.origin.path)
  793. self.repo = FilePath(self.mktemp())
  794. runCommand(["git", "clone", self.origin.path, self.repo.path])
  795. _gitConfig(self.repo)
  796. def test_noArgs(self):
  797. """
  798. Too few arguments returns a failure.
  799. """
  800. logs = []
  801. with self.assertRaises(SystemExit) as e:
  802. CheckTopfileScript(logs.append).main([])
  803. self.assertEqual(e.exception.args,
  804. ("Must specify one argument: the Twisted checkout",))
  805. def test_diffFromTrunkNoTopfiles(self):
  806. """
  807. If there are changes from trunk, then there should also be a topfile.
  808. """
  809. runCommand(["git", "checkout", "-b", "mypatch"],
  810. cwd=self.repo.path)
  811. somefile = self.repo.child("somefile")
  812. somefile.setContent(b"change")
  813. runCommand(["git", "add", somefile.path, somefile.path],
  814. cwd=self.repo.path)
  815. runCommand(["git", "commit", "-m", "some file"],
  816. cwd=self.repo.path)
  817. logs = []
  818. with self.assertRaises(SystemExit) as e:
  819. CheckTopfileScript(logs.append).main([self.repo.path])
  820. self.assertEqual(e.exception.args, (1,))
  821. self.assertEqual(logs[-1],
  822. "No newsfragment found. Have you committed it?")
  823. def test_noChangeFromTrunk(self):
  824. """
  825. If there are no changes from trunk, then no need to check the topfiles
  826. """
  827. runCommand(["git", "checkout", "-b", "mypatch"],
  828. cwd=self.repo.path)
  829. logs = []
  830. with self.assertRaises(SystemExit) as e:
  831. CheckTopfileScript(logs.append).main([self.repo.path])
  832. self.assertEqual(e.exception.args, (0,))
  833. self.assertEqual(
  834. logs[-1],
  835. "On trunk or no diffs from trunk; no need to look at this.")
  836. def test_trunk(self):
  837. """
  838. Running it on trunk always gives green.
  839. """
  840. logs = []
  841. with self.assertRaises(SystemExit) as e:
  842. CheckTopfileScript(logs.append).main([self.repo.path])
  843. self.assertEqual(e.exception.args, (0,))
  844. self.assertEqual(
  845. logs[-1],
  846. "On trunk or no diffs from trunk; no need to look at this.")
  847. def test_release(self):
  848. """
  849. Running it on a release branch returns green if there is no topfiles
  850. even if there are changes.
  851. """
  852. runCommand(["git", "checkout", "-b", "release-16.11111-9001"],
  853. cwd=self.repo.path)
  854. somefile = self.repo.child("somefile")
  855. somefile.setContent(b"change")
  856. runCommand(["git", "add", somefile.path, somefile.path],
  857. cwd=self.repo.path)
  858. runCommand(["git", "commit", "-m", "some file"],
  859. cwd=self.repo.path)
  860. logs = []
  861. with self.assertRaises(SystemExit) as e:
  862. CheckTopfileScript(logs.append).main([self.repo.path])
  863. self.assertEqual(e.exception.args, (0,))
  864. self.assertEqual(logs[-1],
  865. "Release branch with no newsfragments, all good.")
  866. def test_releaseWithTopfiles(self):
  867. """
  868. Running it on a release branch returns red if there are new topfiles.
  869. """
  870. runCommand(["git", "checkout", "-b", "release-16.11111-9001"],
  871. cwd=self.repo.path)
  872. topfiles = self.repo.child("twisted").child("newsfragments")
  873. topfiles.makedirs()
  874. fragment = topfiles.child("1234.misc")
  875. fragment.setContent(b"")
  876. unrelated = self.repo.child("somefile")
  877. unrelated.setContent(b"Boo")
  878. runCommand(["git", "add", fragment.path, unrelated.path],
  879. cwd=self.repo.path)
  880. runCommand(["git", "commit", "-m", "fragment"],
  881. cwd=self.repo.path)
  882. logs = []
  883. with self.assertRaises(SystemExit) as e:
  884. CheckTopfileScript(logs.append).main([self.repo.path])
  885. self.assertEqual(e.exception.args, (1,))
  886. self.assertEqual(logs[-1],
  887. "No newsfragments should be on the release branch.")
  888. def test_onlyQuotes(self):
  889. """
  890. Running it on a branch with only a quotefile change gives green.
  891. """
  892. runCommand(["git", "checkout", "-b", "quotefile"],
  893. cwd=self.repo.path)
  894. fun = self.repo.child("docs").child("fun")
  895. fun.makedirs()
  896. quotes = fun.child("Twisted.Quotes")
  897. quotes.setContent(b"Beep boop")
  898. runCommand(["git", "add", quotes.path],
  899. cwd=self.repo.path)
  900. runCommand(["git", "commit", "-m", "quotes"],
  901. cwd=self.repo.path)
  902. logs = []
  903. with self.assertRaises(SystemExit) as e:
  904. CheckTopfileScript(logs.append).main([self.repo.path])
  905. self.assertEqual(e.exception.args, (0,))
  906. self.assertEqual(logs[-1],
  907. "Quotes change only; no newsfragment needed.")
  908. def test_topfileAdded(self):
  909. """
  910. Running it on a branch with a fragment in the topfiles dir added
  911. returns green.
  912. """
  913. runCommand(["git", "checkout", "-b", "quotefile"],
  914. cwd=self.repo.path)
  915. topfiles = self.repo.child("twisted").child("newsfragments")
  916. topfiles.makedirs()
  917. fragment = topfiles.child("1234.misc")
  918. fragment.setContent(b"")
  919. unrelated = self.repo.child("somefile")
  920. unrelated.setContent(b"Boo")
  921. runCommand(["git", "add", fragment.path, unrelated.path],
  922. cwd=self.repo.path)
  923. runCommand(["git", "commit", "-m", "topfile"],
  924. cwd=self.repo.path)
  925. logs = []
  926. with self.assertRaises(SystemExit) as e:
  927. CheckTopfileScript(logs.append).main([self.repo.path])
  928. self.assertEqual(e.exception.args, (0,))
  929. self.assertEqual(logs[-1], "Found twisted/newsfragments/1234.misc")
  930. def test_topfileButNotFragmentAdded(self):
  931. """
  932. Running it on a branch with a non-fragment in the topfiles dir does not
  933. return green.
  934. """
  935. runCommand(["git", "checkout", "-b", "quotefile"],
  936. cwd=self.repo.path)
  937. topfiles = self.repo.child("twisted").child("newsfragments")
  938. topfiles.makedirs()
  939. notFragment = topfiles.child("1234.txt")
  940. notFragment.setContent(b"")
  941. unrelated = self.repo.child("somefile")
  942. unrelated.setContent(b"Boo")
  943. runCommand(["git", "add", notFragment.path, unrelated.path],
  944. cwd=self.repo.path)
  945. runCommand(["git", "commit", "-m", "not topfile"],
  946. cwd=self.repo.path)
  947. logs = []
  948. with self.assertRaises(SystemExit) as e:
  949. CheckTopfileScript(logs.append).main([self.repo.path])
  950. self.assertEqual(e.exception.args, (1,))
  951. self.assertEqual(logs[-1],
  952. "No newsfragment found. Have you committed it?")
  953. def test_topfileAddedButWithOtherTopfiles(self):
  954. """
  955. Running it on a branch with a fragment in the topfiles dir added
  956. returns green, even if there are other files in the topfiles dir.
  957. """
  958. runCommand(["git", "checkout", "-b", "quotefile"],
  959. cwd=self.repo.path)
  960. topfiles = self.repo.child("twisted").child("newsfragments")
  961. topfiles.makedirs()
  962. fragment = topfiles.child("1234.misc")
  963. fragment.setContent(b"")
  964. unrelated = topfiles.child("somefile")
  965. unrelated.setContent(b"Boo")
  966. runCommand(["git", "add", fragment.path, unrelated.path],
  967. cwd=self.repo.path)
  968. runCommand(["git", "commit", "-m", "topfile"],
  969. cwd=self.repo.path)
  970. logs = []
  971. with self.assertRaises(SystemExit) as e:
  972. CheckTopfileScript(logs.append).main([self.repo.path])
  973. self.assertEqual(e.exception.args, (0,))
  974. self.assertEqual(logs[-1], "Found twisted/newsfragments/1234.misc")