commit b162ba1b428cacd16556e99a40c1232aeb72ef77 Author: Rónán Carrigan Date: Sun Jan 2 23:15:23 2022 +0000 feat: init commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c69548 --- /dev/null +++ b/.gitignore @@ -0,0 +1,133 @@ +# Generated from GitHub gitignore API for key: Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +Session.vim + diff --git a/lua/neotest-python/base.lua b/lua/neotest-python/base.lua new file mode 100644 index 0000000..bf78ea3 --- /dev/null +++ b/lua/neotest-python/base.lua @@ -0,0 +1,69 @@ +local lib = require("neotest.lib") +local Path = require("plenary.path") + +local M = {} + +function M.is_test_file(file_path) + if not vim.endswith(file_path, ".py") then + return false + end + local elems = vim.split(file_path, Path.path.sep) + local file_name = elems[#elems] + return vim.startswith(file_name, "test_") +end + +---@return string[] +function M.get_python_command(root) + -- Use activated virtualenv. + if vim.env.VIRTUAL_ENV then + return { Path:new(vim.env.VIRTUAL_ENV, "bin", "python").filename } + end + + for _, pattern in ipairs({ "*", ".*" }) do + local match = vim.fn.glob(Path:new(root or vim.fn.getcwd(), pattern, "pyvenv.cfg").filename) + if match ~= "" then + return { (Path:new(match):parent() / "bin" / "python").filename } + end + end + + if lib.files.exists("Pipfile") then + return { "pipenv", "run", "python" } + end + -- Fallback to system Python. + return { vim.fn.exepath("python3") or vim.fn.exepath("python") or "python" } +end + +function M.parse_positions(file_path) + local query = [[ + ((function_definition + name: (identifier) @test.name) + (#match? @test.name "^test_")) + @test.definition + + (class_definition + name: (identifier) @namespace.name) + @namespace.definition + ]] + return lib.treesitter.parse_positions(file_path, query) +end + +function M.get_strategy_config(strategy, python, python_script, args) + local config = { + dap = function() + return { + type = "python", + name = "Neotest Debugger", + request = "launch", + python = python, + program = python_script, + cwd = vim.fn.getcwd(), + args = args, + } + end, + } + if config[strategy] then + return config[strategy]() + end +end + +return M diff --git a/lua/neotest-python/init.lua b/lua/neotest-python/init.lua new file mode 100644 index 0000000..f78d601 --- /dev/null +++ b/lua/neotest-python/init.lua @@ -0,0 +1,131 @@ +local logger = require("neotest.logging") +local Path = require("plenary.path") +local lib = require("neotest.lib") +local base = require("neotest-python.base") + +local function script_path() + local str = debug.getinfo(2, "S").source:sub(2) + return str:match("(.*/)") +end + +local python_script = (Path.new(script_path()):parent():parent() / "neotest.py").filename + +local get_args = function(runner, position) + if runner == "unittest" then + runner = "pyunit" + end + return lib.vim_test.collect_args("python", runner, position) +end + +local get_runner = function() + local vim_test_runner = vim.g["test#python#runner"] + if vim_test_runner == "pyunit" then + return "unittest" + end + if vim_test_runner and lib.func_util.index({ "unittest", "pytest" }, vim_test_runner) then + return vim_test_runner + end + if vim.fn.executable("pytest") == 1 then + return "pytest" + end + return "unittest" +end + +---@type NeotestAdapter +local PythonNeotestAdapter = {name = "neotest-python"} + +function PythonNeotestAdapter.is_test_file(file_path) + return base.is_test_file(file_path) +end + +---@async +---@return Tree | nil +function PythonNeotestAdapter.discover_positions(path) + if path and not lib.files.is_dir(path) then + local query = [[ + ((function_definition + name: (identifier) @test.name) + (#match? @test.name "^test_")) + @test.definition + + (class_definition + name: (identifier) @namespace.name) + @namespace.definition + ]] + return lib.treesitter.parse_positions(path, query, { + require_namespaces = get_runner() == "unittest", + }) + end + local files = lib.func_util.filter_list(base.is_test_file, lib.files.find({ path })) + return lib.files.parse_dir_from_files(path, files) +end + +---@param args NeotestRunArgs +---@return NeotestRunSpec +function PythonNeotestAdapter.build_spec(args) + local results_path = vim.fn.tempname() + local runner = get_runner() + local python = base.get_python_command(vim.fn.getcwd()) + local script_args = vim.tbl_flatten({ + "--results-file", + results_path, + "--runner", + runner, + "--", + get_args(runner, args.position), + }) + if args.position then + local pos = args.position + table.insert(script_args, pos.id) + end + local command = vim.tbl_flatten({ + python, + python_script, + script_args, + }) + return { + command = command, + context = { + results_path = results_path, + }, + strategy = base.get_strategy_config(args.strategy, python, python_script, script_args), + } +end + +---@async +---@param spec NeotestRunSpec +---@param result NeotestStrategyResult +---@return NeotestResult[] +function PythonNeotestAdapter.results(spec, result) + -- TODO: Find out if this JSON option is supported in future + local success, data = pcall(lib.files.read, spec.context.results_path) + if not success then + data = "{}" + end + local results = vim.json.decode(data, { luanil = { object = true } }) + for _, pos_result in pairs(results) do + result.output_path = pos_result.output_path + end + return results +end + +setmetatable(PythonNeotestAdapter, { + __call = function(_, opts) + if type(opts.args) == "function" or (type(opts.args) == "table" and opts.args.__call) then + get_args = opts.args + elseif opts.args then + get_args = function() + return opts.args + end + end + if type(opts.runner) == "function" or (type(opts.runner) == "table" and opts.runner.__call) then + get_runner = opts.runner + elseif opts.runner then + get_runner = function() + return opts.runner + end + end + end, +}) + +return PythonNeotestAdapter diff --git a/neotest.py b/neotest.py new file mode 100644 index 0000000..5f86603 --- /dev/null +++ b/neotest.py @@ -0,0 +1,19 @@ +from contextlib import contextmanager +import sys +from pathlib import Path + + +@contextmanager +def add_to_path(): + old_path = sys.path[:] + sys.path.insert(0, str(Path(__file__).parent)) + try: + yield + finally: + sys.path = old_path + +with add_to_path(): + from neotest_python import main + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/neotest_python/__init__.py b/neotest_python/__init__.py new file mode 100644 index 0000000..1e5e999 --- /dev/null +++ b/neotest_python/__init__.py @@ -0,0 +1,40 @@ +import argparse +import json +from enum import Enum +from typing import List + + +class TestRunner(str, Enum): + PYTEST = "pytest" + UNITTEST = "unittest" + + +def get_adapter(runner: TestRunner): + if runner == TestRunner.PYTEST: + from .pytest import PytestNeotestAdapter + + return PytestNeotestAdapter() + elif runner == TestRunner.UNITTEST: + from .unittest import UnittestNeotestAdapter + + return UnittestNeotestAdapter() + raise NotImplementedError(runner) + + +parser = argparse.ArgumentParser() +parser.add_argument("--runner", required=True) +parser.add_argument( + "--results-file", + dest="results_file", + required=True, + help="File to store result JSON in", +) +parser.add_argument("args", nargs="*") + + +def main(argv: List[str]): + args = parser.parse_args(argv) + adapter = get_adapter(TestRunner(args.runner)) + results = adapter.run(args.args) + with open(args.results_file, "w") as results_file: + json.dump(results, results_file) diff --git a/neotest_python/base.py b/neotest_python/base.py new file mode 100644 index 0000000..067b646 --- /dev/null +++ b/neotest_python/base.py @@ -0,0 +1,42 @@ +from enum import Enum +from typing import TYPE_CHECKING, Dict, List, Optional + + +class NeotestResultStatus(str, Enum): + SKIPPED = "skipped" + PASSED = "passed" + FAILED = "failed" + + def __gt__(self, other) -> bool: + members = list(self.__class__.__members__.values()) + return members.index(self) > members.index(other) + + +if TYPE_CHECKING: + from typing import TypedDict + + class NeotestError(TypedDict): + message: str + line: Optional[int] + + class NeotestResult(TypedDict): + short: Optional[str] + status: NeotestResultStatus + errors: Optional[List[NeotestError]] + +else: + NeotestError = Dict + NeotestResult = Dict + + +class NeotestAdapter: + def update_result( + self, base: Optional[NeotestResult], update: NeotestResult + ) -> NeotestResult: + if not base: + return update + return { + "status": max(base["status"], update["status"]), + "errors": (base.get("errors") or []) + (update.get("errors") or []) or None, + "short": (base.get("short") or "") + (update.get("short") or ""), + } diff --git a/neotest_python/pytest.py b/neotest_python/pytest.py new file mode 100644 index 0000000..174498f --- /dev/null +++ b/neotest_python/pytest.py @@ -0,0 +1,94 @@ +from io import StringIO +from pathlib import Path +from typing import TYPE_CHECKING, Dict, List, Optional, cast + +from .base import NeotestAdapter, NeotestError, NeotestResult, NeotestResultStatus + +if TYPE_CHECKING: + from _pytest._code.code import ExceptionChainRepr + from _pytest.config import Config + from _pytest.reports import TestReport + + +class PytestNeotestAdapter(NeotestAdapter): + def get_short_output(self, config: "Config", report: "TestReport") -> Optional[str]: + from _pytest.terminal import TerminalReporter + + buffer = StringIO() + # Hack to get pytest to write ANSI codes + setattr(buffer, "isatty", lambda: True) + reporter = TerminalReporter(config, buffer) + + # Taked from `_pytest.terminal.TerminalReporter + msg = reporter._getfailureheadline(report) + if report.outcome == NeotestResultStatus.FAILED: + reporter.write_sep("_", msg, red=True, bold=True) + else: + reporter.write_sep("_", msg, green=True, bold=True) + reporter._outrep_summary(report) + reporter.print_teardown_sections(report) + + buffer.seek(0) + return buffer.read() + + def run(self, args: List[str]) -> Dict[str, NeotestResult]: + results: Dict[str, NeotestResult] = {} + pytest_config: "Config" + + class NeotestResultCollector: + @staticmethod + def pytest_cmdline_main(config: "Config"): + nonlocal pytest_config + pytest_config = config + + @staticmethod + def pytest_runtest_logreport(report: "TestReport"): + if report.when != "call" and not ( + report.outcome == "skipped" and report.when == "setup" + ): + return + file_path, *name_path = report.nodeid.split("::") + abs_path = str(Path(pytest_config.rootpath, file_path)) + test_name, *namespaces = reversed(name_path) + valid_test_name, *_ = test_name.split("[") # ] + + errors: List[NeotestError] = [] + short = self.get_short_output(pytest_config, report) + if report.outcome == "failed": + exc_repr = cast("ExceptionChainRepr", report.longrepr) + exc_repr.toterminal + reprtraceback = exc_repr.reprtraceback + error_message = exc_repr.reprcrash.message # type: ignore + error_line = None + for repr in reversed(reprtraceback.reprentries): + if ( + hasattr(repr, "reprfileloc") + and repr.reprfileloc.path == file_path + ): + error_line = repr.reprfileloc.lineno - 1 + errors.append({"message": error_message, "line": error_line}) + pos_id = "::".join([abs_path, *namespaces, valid_test_name]) + results[pos_id] = self.update_result( + results.get(pos_id), + { + "short": short, + "status": NeotestResultStatus(report.outcome), + "errors": errors, + }, + ) + results[abs_path] = self.update_result( + results.get(abs_path), + { + "short": None, + "status": NeotestResultStatus(report.outcome), + "errors": errors, + }, + ) + + import pytest + + pytest.main(args=args, plugins=[NeotestResultCollector]) + return results + + def update_report(self, report: Optional[Dict], update: Dict): + ... diff --git a/neotest_python/unittest.py b/neotest_python/unittest.py new file mode 100644 index 0000000..ad8021e --- /dev/null +++ b/neotest_python/unittest.py @@ -0,0 +1,105 @@ +import inspect +import traceback +import unittest +from pathlib import Path +from types import TracebackType +from typing import Any, Dict, Iterator, List, Tuple +from unittest import TestCase, TestResult, TestSuite +from unittest.runner import TextTestResult, TextTestRunner + +from .base import NeotestAdapter, NeotestResultStatus + + +class UnittestNeotestAdapter(NeotestAdapter): + def iter_suite( + self, suite: "TestSuite | TestCase" + ) -> Iterator["TestCase | TestSuite"]: + if isinstance(suite, TestSuite): + for sub in suite: + for case in self.iter_suite(sub): + yield case + else: + yield suite + + def case_file(self, case) -> str: + return str(Path(inspect.getmodule(case).__file__).absolute()) # type: ignore + + def case_id_elems(self, case) -> List[str]: + file = self.case_file(case) + elems = [file, case.__class__.__name__] + if isinstance(case, TestCase): + elems.append(case._testMethodName) + return elems + + def case_id(self, case: "TestCase | TestSuite") -> str: + return "::".join(self.case_id_elems(case)) + + def run(self, args: List[str]) -> Dict: + results = {} + + errs: Dict[str, Tuple[Exception, Any, TracebackType]] = {} + + class NeotestTextTestResult(TextTestResult): + def addFailure(_, test: TestCase, err) -> None: + errs[self.case_id(test)] = err + return super().addFailure(test, err) + + class NeotestUnittestRunner(TextTestRunner): + def run(_, test: "TestSuite | TestCase") -> "TestResult": # type: ignore + for case in self.iter_suite(test): + results[self.case_id(case)] = { + "status": NeotestResultStatus.PASSED, + "short": None, + } + results[self.case_file(case)] = { + "status": NeotestResultStatus.PASSED, + "short": None, + } + result = super().run(test) + for case, message in result.failures: + case_id = self.case_id(case) + error_line = None + case_file = self.case_file(case) + if case_id in errs: + trace = errs[case_id][2] + summary = traceback.extract_tb(trace) + error_line = next( + frame.lineno - 1 + for frame in reversed(summary) + if frame.filename == case_file + ) + results[case_id] = self.update_result( + results.get(case_id), + { + "status": NeotestResultStatus.FAILED, + "errors": [{"message": message, "line": error_line}], + "short": None, + }, + ) + results[case_file] = self.update_result( + results.get(case_file), + { + "status": NeotestResultStatus.FAILED, + "errors": [{"message": message, "line": error_line}], + "short": None, + }, + ) + for case, message in result.skipped: + results[self.case_id(case)] = self.update_result( + results[self.case_id(case)], + { + "short": None, + "status": NeotestResultStatus.SKIPPED, + "errors": None, + }, + ) + return result + + unittest.main( + module=None, + argv=args, + testRunner=NeotestUnittestRunner(resultclass=NeotestTextTestResult), + exit=False, + ) + + return results diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..780b5ab --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ + +[tools.black] +line-length=120 + +[tool.isort] +profile = "black" +multi_line_output = 3 + +[tool.pytest.ini_options] +filterwarnings = [ + "error", + "ignore::pytest.PytestCollectionWarning", + "ignore:::pynvim[.*]" +] + diff --git a/scripts/style b/scripts/style new file mode 100755 index 0000000..17a9506 --- /dev/null +++ b/scripts/style @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +PYTHON_DIRS=("neotest_python") + +if [[ $1 == "-w" ]]; then + black "${PYTHON_DIRS[@]}" + isort "${PYTHON_DIRS[@]}" + autoflake --remove-unused-variables --remove-all-unused-imports --ignore-init-module-imports --remove-duplicate-keys --recursive -i "${PYTHON_DIRS[@]}" + stylua lua +else + black --check "${PYTHON_DIRS[@]}" + isort --check "${PYTHON_DIRS[@]}" + autoflake --remove-unused-variables --remove-all-unused-imports --ignore-init-module-imports --remove-duplicate-keys --recursive "${PYTHON_DIRS[@]}" +fi diff --git a/scripts/test b/scripts/test new file mode 100755 index 0000000..2f31a29 --- /dev/null +++ b/scripts/test @@ -0,0 +1,10 @@ +#!/bin/sh + +PYTHON_DIR="rplugin/python3/ultest" + +pytest \ + --cov-branch \ + --cov=${PYTHON_DIR} \ + --cov-report xml:coverage/coverage.xml \ + --cov-report term \ + --cov-report html:coverage diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..609d773 --- /dev/null +++ b/stylua.toml @@ -0,0 +1,5 @@ +column_width = 100 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferDouble"