diff --git a/coconut/api.py b/coconut/api.py index e5bc8eb7..03fc719c 100644 --- a/coconut/api.py +++ b/coconut/api.py @@ -309,6 +309,21 @@ def auto_compilation(on=True, args=None, use_cache_dir=None): auto_compilation() +def install_pytest_plugin(): + """Install Coconut's pytest plugin. Call this at the top of your conftest.py. + + Injects pytest hook functions directly into the calling module's namespace, + which works in any conftest.py regardless of its location. This is the + recommended fallback when Coconut is not installed via pip (which would + register the plugin automatically via the pytest11 entry point). + """ + import inspect + from coconut import pytest_plugin as _plugin + frame = inspect.currentframe().f_back + for name in ("pytest_configure", "pytest_collect_file", "pytest_ignore_collect"): + frame.f_globals[name] = getattr(_plugin, name) + + # ----------------------------------------------------------------------------------------------------------------------- # ENCODING: # ----------------------------------------------------------------------------------------------------------------------- diff --git a/coconut/pytest_plugin.py b/coconut/pytest_plugin.py new file mode 100644 index 00000000..37371c92 --- /dev/null +++ b/coconut/pytest_plugin.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# ----------------------------------------------------------------------------------------------------------------------- +# INFO: +# ----------------------------------------------------------------------------------------------------------------------- + +""" +Author: Adam Forest +License: Apache 2.0 +Description: Pytest plugin for automatic Coconut compilation. +""" + +# ----------------------------------------------------------------------------------------------------------------------- +# IMPORTS: +# ----------------------------------------------------------------------------------------------------------------------- + +from __future__ import print_function, absolute_import, unicode_literals, division + +from coconut.root import * # NOQA + +import sys +import io + +from coconut.constants import code_exts, coconut_cache_dir + +# ----------------------------------------------------------------------------------------------------------------------- +# HELPERS: +# ----------------------------------------------------------------------------------------------------------------------- + + +class _TtyStdin(io.StringIO): + """Fake stdin that reports itself as a TTY. + + Under pytest, stdin is captured and raises OSError on read(). + Coconut's stdin_readable() checks isatty() to decide whether to read; + returning True here makes it skip the read entirely. + """ + def isatty(self): + return True + + +_pytest_importer = None + + +def _get_pytest_importer(): + """Get or create the stdin-safe CoconutImporter for use under pytest.""" + global _pytest_importer + if _pytest_importer is None: + from coconut.api import CoconutImporter + + class _PytestCoconutImporter(CoconutImporter): + """CoconutImporter for use under pytest. + + Compiles in-place (--no-cache) so that the path returned by + pytest_collect_file matches what Python's import system loads, + avoiding __file__ mismatch errors. + + Overrides find_spec to return a concrete spec pointing to the + in-place .py file, preventing the global coconut_importer (which + uses a cache dir) from intercepting imports and redirecting to a + different path. + + Guards sys.stdin during every compile() call so pytest's stdin + capture does not cause OSError. + """ + + def compile(self, path, package): + old_stdin = sys.stdin + sys.stdin = _TtyStdin() + try: + return super(_PytestCoconutImporter, self).compile(path, package) + finally: + sys.stdin = old_stdin + + def find_spec(self, fullname, path=None, target=None): + destpath = self.find_coconut(fullname, path) + if destpath is None: + return None + from importlib.machinery import SourceFileLoader + from importlib.util import spec_from_loader + return spec_from_loader(fullname, SourceFileLoader(fullname, destpath)) + + # --no-cache: compile in-place next to the .coco file so the + # collected .py path and module.__file__ always agree. + _pytest_importer = _PytestCoconutImporter("--no-cache") + return _pytest_importer + + +# ----------------------------------------------------------------------------------------------------------------------- +# HOOKS: +# ----------------------------------------------------------------------------------------------------------------------- + + +def pytest_configure(config): + """Register the pytest-safe Coconut importer for the test session.""" + importer = _get_pytest_importer() + if importer not in sys.meta_path: + sys.meta_path.insert(0, importer) + + +def pytest_collect_file(file_path, parent): + """Compile .coco test files and collect the resulting in-place .py file.""" + if file_path.suffix in code_exts and file_path.stem.startswith("test_"): + from pathlib import Path + import pytest + importer = _get_pytest_importer() + py_path = Path(importer.compile(str(file_path), package=False)) + return pytest.Module.from_parent(parent, path=py_path) + + +def pytest_ignore_collect(collection_path, config): + """Skip cache directories and .py files that have a .coco source alongside them.""" + # Ignore the entire __coconut_cache__ directory tree + if coconut_cache_dir in collection_path.parts: + return True + # Ignore .py files whose .coco source will be compiled and collected separately + if collection_path.suffix == ".py" and collection_path.stem.startswith("test_"): + for ext in code_exts: + coco_sibling = collection_path.with_suffix(ext) + if coco_sibling.exists(): + return True diff --git a/setup.py b/setup.py index 185e0375..13b20bad 100644 --- a/setup.py +++ b/setup.py @@ -85,6 +85,9 @@ "xonsh.xontribs": [ "coconut = coconut.integrations", ], + "pytest11": [ + "coconut = coconut.pytest_plugin", + ], }, classifiers=list(classifiers), keywords=list(search_terms),