feat: init commit

This commit is contained in:
Rónán Carrigan
2022-01-02 23:15:23 +00:00
commit b162ba1b42
12 changed files with 679 additions and 0 deletions

133
.gitignore vendored Normal file
View File

@@ -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

View File

@@ -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

131
lua/neotest-python/init.lua Normal file
View File

@@ -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

19
neotest.py Normal file
View File

@@ -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:])

View File

@@ -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)

42
neotest_python/base.py Normal file
View File

@@ -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 ""),
}

94
neotest_python/pytest.py Normal file
View File

@@ -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):
...

105
neotest_python/unittest.py Normal file
View File

@@ -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

15
pyproject.toml Normal file
View File

@@ -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[.*]"
]

16
scripts/style Executable file
View File

@@ -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

10
scripts/test Executable file
View File

@@ -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

5
stylua.toml Normal file
View File

@@ -0,0 +1,5 @@
column_width = 100
line_endings = "Unix"
indent_type = "Spaces"
indent_width = 2
quote_style = "AutoPreferDouble"