cacheprovider.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. """
  2. merged implementation of the cache provider
  3. the name cache was not chosen to ensure pluggy automatically
  4. ignores the external pytest-cache
  5. """
  6. from __future__ import absolute_import, division, print_function
  7. from collections import OrderedDict
  8. import py
  9. import six
  10. import attr
  11. import pytest
  12. import json
  13. import shutil
  14. from . import paths
  15. from .compat import _PY2 as PY2, Path
  16. README_CONTENT = u"""\
  17. # pytest cache directory #
  18. This directory contains data from the pytest's cache plugin,
  19. which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
  20. **Do not** commit this to version control.
  21. See [the docs](https://docs.pytest.org/en/latest/cache.html) for more information.
  22. """
  23. @attr.s
  24. class Cache(object):
  25. _cachedir = attr.ib(repr=False)
  26. _warn = attr.ib(repr=False)
  27. @classmethod
  28. def for_config(cls, config):
  29. cachedir = cls.cache_dir_from_config(config)
  30. if config.getoption("cacheclear") and cachedir.exists():
  31. shutil.rmtree(str(cachedir))
  32. cachedir.mkdir()
  33. return cls(cachedir, config.warn)
  34. @staticmethod
  35. def cache_dir_from_config(config):
  36. return paths.resolve_from_str(config.getini("cache_dir"), config.rootdir)
  37. def warn(self, fmt, **args):
  38. self._warn(code="I9", message=fmt.format(**args) if args else fmt)
  39. def makedir(self, name):
  40. """ return a directory path object with the given name. If the
  41. directory does not yet exist, it will be created. You can use it
  42. to manage files likes e. g. store/retrieve database
  43. dumps across test sessions.
  44. :param name: must be a string not containing a ``/`` separator.
  45. Make sure the name contains your plugin or application
  46. identifiers to prevent clashes with other cache users.
  47. """
  48. name = Path(name)
  49. if len(name.parts) > 1:
  50. raise ValueError("name is not allowed to contain path separators")
  51. res = self._cachedir.joinpath("d", name)
  52. res.mkdir(exist_ok=True, parents=True)
  53. return py.path.local(res)
  54. def _getvaluepath(self, key):
  55. return self._cachedir.joinpath("v", Path(key))
  56. def get(self, key, default):
  57. """ return cached value for the given key. If no value
  58. was yet cached or the value cannot be read, the specified
  59. default is returned.
  60. :param key: must be a ``/`` separated value. Usually the first
  61. name is the name of your plugin or your application.
  62. :param default: must be provided in case of a cache-miss or
  63. invalid cache values.
  64. """
  65. path = self._getvaluepath(key)
  66. try:
  67. with path.open("r") as f:
  68. return json.load(f)
  69. except (ValueError, IOError, OSError):
  70. return default
  71. def set(self, key, value):
  72. """ save value for the given key.
  73. :param key: must be a ``/`` separated value. Usually the first
  74. name is the name of your plugin or your application.
  75. :param value: must be of any combination of basic
  76. python types, including nested types
  77. like e. g. lists of dictionaries.
  78. """
  79. path = self._getvaluepath(key)
  80. try:
  81. path.parent.mkdir(exist_ok=True, parents=True)
  82. except (IOError, OSError):
  83. self.warn("could not create cache path {path}", path=path)
  84. return
  85. try:
  86. f = path.open("wb" if PY2 else "w")
  87. except (IOError, OSError):
  88. self.warn("cache could not write path {path}", path=path)
  89. else:
  90. with f:
  91. json.dump(value, f, indent=2, sort_keys=True)
  92. self._ensure_readme()
  93. def _ensure_readme(self):
  94. if self._cachedir.is_dir():
  95. readme_path = self._cachedir / "README.md"
  96. if not readme_path.is_file():
  97. readme_path.write_text(README_CONTENT)
  98. class LFPlugin(object):
  99. """ Plugin which implements the --lf (run last-failing) option """
  100. def __init__(self, config):
  101. self.config = config
  102. active_keys = "lf", "failedfirst"
  103. self.active = any(config.getoption(key) for key in active_keys)
  104. self.lastfailed = config.cache.get("cache/lastfailed", {})
  105. self._previously_failed_count = None
  106. self._no_failures_behavior = self.config.getoption("last_failed_no_failures")
  107. def pytest_report_collectionfinish(self):
  108. if self.active and self.config.getoption("verbose") >= 0:
  109. if not self._previously_failed_count:
  110. mode = "run {} (no recorded failures)".format(
  111. self._no_failures_behavior
  112. )
  113. else:
  114. noun = "failure" if self._previously_failed_count == 1 else "failures"
  115. suffix = " first" if self.config.getoption("failedfirst") else ""
  116. mode = "rerun previous {count} {noun}{suffix}".format(
  117. count=self._previously_failed_count, suffix=suffix, noun=noun
  118. )
  119. return "run-last-failure: %s" % mode
  120. def pytest_runtest_logreport(self, report):
  121. if (report.when == "call" and report.passed) or report.skipped:
  122. self.lastfailed.pop(report.nodeid, None)
  123. elif report.failed:
  124. self.lastfailed[report.nodeid] = True
  125. def pytest_collectreport(self, report):
  126. passed = report.outcome in ("passed", "skipped")
  127. if passed:
  128. if report.nodeid in self.lastfailed:
  129. self.lastfailed.pop(report.nodeid)
  130. self.lastfailed.update((item.nodeid, True) for item in report.result)
  131. else:
  132. self.lastfailed[report.nodeid] = True
  133. def pytest_collection_modifyitems(self, session, config, items):
  134. if self.active:
  135. if self.lastfailed:
  136. previously_failed = []
  137. previously_passed = []
  138. for item in items:
  139. if item.nodeid in self.lastfailed:
  140. previously_failed.append(item)
  141. else:
  142. previously_passed.append(item)
  143. self._previously_failed_count = len(previously_failed)
  144. if not previously_failed:
  145. # running a subset of all tests with recorded failures outside
  146. # of the set of tests currently executing
  147. return
  148. if self.config.getoption("lf"):
  149. items[:] = previously_failed
  150. config.hook.pytest_deselected(items=previously_passed)
  151. else:
  152. items[:] = previously_failed + previously_passed
  153. elif self._no_failures_behavior == "none":
  154. config.hook.pytest_deselected(items=items)
  155. items[:] = []
  156. def pytest_sessionfinish(self, session):
  157. config = self.config
  158. if config.getoption("cacheshow") or hasattr(config, "slaveinput"):
  159. return
  160. saved_lastfailed = config.cache.get("cache/lastfailed", {})
  161. if saved_lastfailed != self.lastfailed:
  162. config.cache.set("cache/lastfailed", self.lastfailed)
  163. class NFPlugin(object):
  164. """ Plugin which implements the --nf (run new-first) option """
  165. def __init__(self, config):
  166. self.config = config
  167. self.active = config.option.newfirst
  168. self.cached_nodeids = config.cache.get("cache/nodeids", [])
  169. def pytest_collection_modifyitems(self, session, config, items):
  170. if self.active:
  171. new_items = OrderedDict()
  172. other_items = OrderedDict()
  173. for item in items:
  174. if item.nodeid not in self.cached_nodeids:
  175. new_items[item.nodeid] = item
  176. else:
  177. other_items[item.nodeid] = item
  178. items[:] = self._get_increasing_order(
  179. six.itervalues(new_items)
  180. ) + self._get_increasing_order(six.itervalues(other_items))
  181. self.cached_nodeids = [x.nodeid for x in items if isinstance(x, pytest.Item)]
  182. def _get_increasing_order(self, items):
  183. return sorted(items, key=lambda item: item.fspath.mtime(), reverse=True)
  184. def pytest_sessionfinish(self, session):
  185. config = self.config
  186. if config.getoption("cacheshow") or hasattr(config, "slaveinput"):
  187. return
  188. config.cache.set("cache/nodeids", self.cached_nodeids)
  189. def pytest_addoption(parser):
  190. group = parser.getgroup("general")
  191. group.addoption(
  192. "--lf",
  193. "--last-failed",
  194. action="store_true",
  195. dest="lf",
  196. help="rerun only the tests that failed "
  197. "at the last run (or all if none failed)",
  198. )
  199. group.addoption(
  200. "--ff",
  201. "--failed-first",
  202. action="store_true",
  203. dest="failedfirst",
  204. help="run all tests but run the last failures first. "
  205. "This may re-order tests and thus lead to "
  206. "repeated fixture setup/teardown",
  207. )
  208. group.addoption(
  209. "--nf",
  210. "--new-first",
  211. action="store_true",
  212. dest="newfirst",
  213. help="run tests from new files first, then the rest of the tests "
  214. "sorted by file mtime",
  215. )
  216. group.addoption(
  217. "--cache-show",
  218. action="store_true",
  219. dest="cacheshow",
  220. help="show cache contents, don't perform collection or tests",
  221. )
  222. group.addoption(
  223. "--cache-clear",
  224. action="store_true",
  225. dest="cacheclear",
  226. help="remove all cache contents at start of test run.",
  227. )
  228. parser.addini("cache_dir", default=".pytest_cache", help="cache directory path.")
  229. group.addoption(
  230. "--lfnf",
  231. "--last-failed-no-failures",
  232. action="store",
  233. dest="last_failed_no_failures",
  234. choices=("all", "none"),
  235. default="all",
  236. help="change the behavior when no test failed in the last run or no "
  237. "information about the last failures was found in the cache",
  238. )
  239. def pytest_cmdline_main(config):
  240. if config.option.cacheshow:
  241. from _pytest.main import wrap_session
  242. return wrap_session(config, cacheshow)
  243. @pytest.hookimpl(tryfirst=True)
  244. def pytest_configure(config):
  245. config.cache = Cache.for_config(config)
  246. config.pluginmanager.register(LFPlugin(config), "lfplugin")
  247. config.pluginmanager.register(NFPlugin(config), "nfplugin")
  248. @pytest.fixture
  249. def cache(request):
  250. """
  251. Return a cache object that can persist state between testing sessions.
  252. cache.get(key, default)
  253. cache.set(key, value)
  254. Keys must be a ``/`` separated value, where the first part is usually the
  255. name of your plugin or application to avoid clashes with other cache users.
  256. Values can be any object handled by the json stdlib module.
  257. """
  258. return request.config.cache
  259. def pytest_report_header(config):
  260. if config.option.verbose:
  261. cachedir = config.cache._cachedir
  262. # TODO: evaluate generating upward relative paths
  263. # starting with .., ../.. if sensible
  264. try:
  265. displaypath = cachedir.relative_to(config.rootdir)
  266. except ValueError:
  267. displaypath = cachedir
  268. return "cachedir: {}".format(displaypath)
  269. def cacheshow(config, session):
  270. from pprint import pformat
  271. tw = py.io.TerminalWriter()
  272. tw.line("cachedir: " + str(config.cache._cachedir))
  273. if not config.cache._cachedir.is_dir():
  274. tw.line("cache is empty")
  275. return 0
  276. dummy = object()
  277. basedir = config.cache._cachedir
  278. vdir = basedir / "v"
  279. tw.sep("-", "cache values")
  280. for valpath in sorted(x for x in vdir.rglob("*") if x.is_file()):
  281. key = valpath.relative_to(vdir)
  282. val = config.cache.get(key, dummy)
  283. if val is dummy:
  284. tw.line("%s contains unreadable content, " "will be ignored" % key)
  285. else:
  286. tw.line("%s contains:" % key)
  287. for line in pformat(val).splitlines():
  288. tw.line(" " + line)
  289. ddir = basedir / "d"
  290. if ddir.is_dir():
  291. contents = sorted(ddir.rglob("*"))
  292. tw.sep("-", "cache directories")
  293. for p in contents:
  294. # if p.check(dir=1):
  295. # print("%s/" % p.relto(basedir))
  296. if p.is_file():
  297. key = p.relative_to(basedir)
  298. tw.line("{} is a file of length {:d}".format(key, p.stat().st_size))
  299. return 0