diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..bb80d3b6 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,79 @@ +# Python CircleCI 2.0 configuration file +# +# Check https://circleci.com/docs/2.0/language-python/ for more details +# +version: 2 + +common: &common + working_directory: ~/repo + steps: + - checkout + - restore_cache: + keys: + - v1-deps-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.py" }} + - v1-deps- + - run: pip install --user tox + - run: ~/.local/bin/tox + - run: + name: upload coverage results for non-checkqa builds + command: | + if [[ "$TOXENV" != checkqa ]]; then + PATH=$HOME/.local/bin:$PATH + # XXX: use bash script?! + pip install --user codecov + coverage xml + coverage report -m + codecov --required -X search gcov pycov -f coverage.xml --flags $CIRCLE_JOB + + if [[ "$CIRCLE_JOB" == py36 ]]; then + pip install --user coveralls + coveralls + fi + set +x + fi + - save_cache: + paths: + - .tox + - ~/.cache/pip + key: v1-deps-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.py" }} +jobs: + py36: + <<: *common + docker: + - image: circleci/python:3.6 + environment: + TOXENV=py36-coverage + py35: + <<: *common + docker: + - image: circleci/python:3.5 + environment: + TOXENV=py35-coverage + py34: + <<: *common + docker: + - image: circleci/python:3.4 + environment: + TOXENV=py34-coverage + py27: + <<: *common + docker: + - image: circleci/python:2.7 + environment: + TOXENV=py27-coverage + checkqa: + <<: *common + docker: + - image: circleci/python:3.6 + environment: + TOXENV=checkqa + +workflows: + version: 2 + test: + jobs: + - py36 + - py35 + - py34 + - py27 + - checkqa diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..0496fc03 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,7 @@ +[run] +source = . +include = covimerage/*,tests/* +branch = true + +[report] +show_missing = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..567609b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..8e95691f --- /dev/null +++ b/Makefile @@ -0,0 +1,100 @@ +test: + tox -e py36 + +VIM:=$(shell command -v nvim || echo vim) + +test_integration: + tox -e integration + +# Fixture generation. +PROFILES_TO_MERGE_COND:=tests/fixtures/merged_conditionals-0.profile \ + tests/fixtures/merged_conditionals-1.profile \ + tests/fixtures/merged_conditionals-2.profile +fixtures: tests/fixtures/test_plugin.vim.profile +fixtures: tests/fixtures/test_plugin.nvim.profile +fixtures: tests/fixtures/dict_function.profile +fixtures: tests/fixtures/dict_function_with_same_source.profile +fixtures: tests/fixtures/dict_function_with_continued_lines.profile +fixtures: tests/fixtures/dict_function_used_twice.profile +fixtures: tests/fixtures/continued_lines.profile +fixtures: tests/fixtures/conditional_function.profile +fixtures: $(PROFILES_TO_MERGE_COND) + +# TODO: cleanup. Should be handled by the generic rule at the bottom. +tests/fixtures/dict_function.profile: tests/test_plugin/dict_function.vim + $(VIM) --noplugin -Nu tests/t.vim --cmd 'let g:prof_fname = "$@"' -c 'source $<' -c q + sed -i 's:^SCRIPT .*/test_plugin:SCRIPT /test_plugin:' $@ + +tests/fixtures/dict_function_with_same_source.profile: test_plugin/dict_function_with_same_source.vim + $(VIM) --noplugin -Nu tests/t.vim --cmd 'let g:prof_fname = "$@"' -c 'source $<' -c q + sed -i 's:^SCRIPT .*/test_plugin:SCRIPT /test_plugin:' $@ + +tests/fixtures/test_plugin.vim.profile: test_plugin/autoload/test_plugin.vim + vim --noplugin -Nu tests/t.vim --cmd 'let g:prof_fname = "$@"' -c q + +tests/fixtures/test_plugin.nvim.profile: test_plugin/autoload/test_plugin.vim + nvim --noplugin -Nu tests/t.vim --cmd 'let g:prof_fname = "$@"' -c q + +PROFILES_TO_MERGE:=tests/fixtures/merge-1.profile tests/fixtures/merge-2.profile +$(PROFILES_TO_MERGE): test_plugin/merged_profiles.vim test_plugin/merged_profiles-init.vim Makefile + $(VIM) -Nu test_plugin/merged_profiles-init.vim -c q + sed -i 's:^SCRIPT .*/test_plugin:SCRIPT /test_plugin:' $(PROFILES_TO_MERGE) + +PROFILES_TO_MERGE_COND:=tests/fixtures/merged_conditionals-0.profile \ + tests/fixtures/merged_conditionals-1.profile \ + tests/fixtures/merged_conditionals-2.profile +$(PROFILES_TO_MERGE_COND): tests/test_plugin/merged_conditionals.vim Makefile + for cond in 0 1 2; do \ + $(VIM) --noplugin -Nu tests/t.vim \ + --cmd "let g:prof_fname = 'tests/fixtures/merged_conditionals-$$cond.profile'" \ + --cmd "let test_conditional = $$cond" \ + -c "source $<" -c q; \ + done + sed -i 's:^SCRIPT .*/test_plugin:SCRIPT tests/test_plugin:' $(PROFILES_TO_MERGE_COND) + +tests/fixtures/%.profile: tests/test_plugin/%.vim Makefile + $(VIM) --noplugin -Nu tests/t.vim --cmd 'let g:prof_fname = "$@"' -c 'source $<' -c q + sed -i 's:^SCRIPT .*/test_plugin:SCRIPT tests/test_plugin:' $@ + + +# Helpers to generate (combined) coverage and show a diff {{{ +# +# Use `make coverage-diff` to diff coverage diff to the old state +# (recorded via `make coverage-save`). + +MAIN_COVERAGE:=build/coverage + +coverage: $(MAIN_COVERAGE) + COVERAGE_FILE=$< coverage report -m + +coverage-save: | build + cp -a $(MAIN_COVERAGE) build/coverage.old + +coverage-diff: build/covreport.old +coverage-diff: build/covreport.new +coverage-diff: + @diff --color=always -u $^ | /usr/share/git/diff-highlight/diff-highlight | sed 1,3d + @#git --no-pager diff --no-index --color-words build/covreport.old build/covreport.new | sed 1,5d + @# git --no-pager diff --color --no-index build/covreport.old build/covreport.new | sed 1,5d | diff-so-fancy + +.PHONY: coverage coverage-save coverage-diff + +$(MAIN_COVERAGE): $(shell find covimerage tests -name '*.py') | build + COVERAGE_FILE=$@ tox -e coverage.pytest + +build/coverage.old: + $(MAKE) coverage-save + +build/covreport.old: build/coverage.old | build + COVERAGE_FILE=$< coverage report -m > $@ || { ret=$$?; cat $@; exit $$ret; } + +build/covreport.new: $(MAIN_COVERAGE) | build + COVERAGE_FILE=$< coverage report -m > $@ || { ret=$$?; cat $@; exit $$ret; } +# }}} + +tags: + rg --files-with-matches . | ctags --links=no -L- +.PHONY: tags + +build: + mkdir -p $@ diff --git a/README.md b/README.md new file mode 100644 index 00000000..8df2438e --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# covimerage + +Generates code coverage information for Vim scripts. + +It parses the output from Vim's `:profile` command, and generates data +compatible with [Coverage.py](http://coverage.readthedocs.io/). + +**NOTE:** this `develop` branch will be squash-merged into master after some +stabilization (1-2 weeks). + +[![CircleCI](https://circleci.com/gh/Vimjas/covimerage.svg?style=svg)](https://circleci.com/gh/Vimjas/covimerage) +[![codecov](https://codecov.io/gh/Vimjas/covimerage/branch/develop/graph/badge.svg)](https://codecov.io/gh/Vimjas/covimerage/branch/develop) + +## Installation + +You can install covimerage using pip: + +```sh +pip install covimerage +``` + +## Simple usage + +You can use `covimerage run` to wrap the call to Neovim/Vim with necessary +boilerplate: + +```sh +covimerage run vim -Nu test/vimrc -c 'Vader! test/**' +``` + +This will write the file `.coverage.covimerage` by default (use `--data-file` +to configure it), which is compatible to Coverage.py. +A report is automatically generated (on stdout). + +You can then call `covimerage xml` to create a `coverage.xml` file +(Cobertura-compatible), which tools like [Codecov](https://codecov.io/)'s +`codecov` tool can consume, e.g. via `codecov -f coverage.xml`. + +## Manual/advanced usage + +### 1. Generate profile information for your Vim script(s) + +You have to basically add the following to your tests vimrc: + +```vim +profile start /tmp/vim-profile.txt +profile! file ./* +``` + +This makes Neovim/Vim then write a file with profiling information. + +### 2. Call covimerage on the output file(s) + +```sh +covimerage write_coverage /tmp/vim-profile.txt +``` + +This will create a file `.coverage.covimerage` (the default for `--data-file`), +with entries marked for processing by a +[Coverage.py](http://coverage.readthedocs.io/) plugin (provided by +covimerage)). + +### 3. Include the covimerage plugin in .coveragerc + +When using `coverage` on the generated output (data file), you need to add +the `covimerage` plugin to the `.coveragerc` file (which Coverage.py uses). +This is basically all the `.coveragerc` you will need, but you could use +other settings here (for Coverage.py), e.g. to omit some files: + +``` +[run] +plugins = covimerage +data_file = .coverage.covimerage +``` + +### 4. Create the report(s) + +You can now call e.g. `coverage report -m`, and you should be able to use +coverage reporting platforms like or +, which are basically using `coverage xml`. + +## Reference implementation + +- [Neomake](https://github.com/neomake/neomake) is the first adopter of this. + It has an advanced test setup (including Docker based builds), and looking at + tis setup could be helpful when setting up covimerage for your + plugin/project. + + - [Neomake's coverage report on codecov.io](https://codecov.io/gh/neomake/neomake/tree/master) + - [PR/change to integrate it in + Neomake](https://github.com/neomake/neomake/pull/1600) (Neomake's test + setup is rather advanced, so do not let that scare you!) + +## Links + +- Discussion in Coverage.py's issue tracker: + [coverage issue 607](https://bitbucket.org/ned/coveragepy/issues/607/) + +## TODO + +- Line hit counts: known to covimerage, but not supported by Coverage.py + (). diff --git a/covimerage/__init__.py b/covimerage/__init__.py new file mode 100755 index 00000000..bec099e8 --- /dev/null +++ b/covimerage/__init__.py @@ -0,0 +1,500 @@ +"""The main covimerage module.""" +import copy +import itertools +import os +import re + +import attr +from click.utils import string_types + +from .coveragepy import CoverageData +from .logger import LOGGER +from .utils import ( + find_executable_files, get_fname_and_fobj_and_str, is_executable_line, +) + +DEFAULT_COVERAGE_DATA_FILE = '.coverage.covimerage' +RE_FUNC_PREFIX = re.compile( + r'^\s*fu(?:n(?:(?:c(?:t(?:i(?:o(?:n)?)?)?)?)?)?)?!?\s+') +RE_CONTINUING_LINE = r'\s*\\' + + +@attr.s +class Line(object): + """A source code line.""" + line = attr.ib() + count = attr.ib(default=None) + total_time = attr.ib(default=None) + self_time = attr.ib(default=None) + _parsed_line = None + + +@attr.s(hash=True) +class Script(object): + path = attr.ib() + lines = attr.ib(default=attr.Factory(dict), repr=False, hash=False) + # List of line numbers for dict functions. + dict_functions = attr.ib(default=attr.Factory(set), repr=False, hash=False) + # List of line numbers for dict functions that have been mapped already. + mapped_dict_functions = attr.ib(default=attr.Factory(set), repr=False, + hash=False) + func_to_lnums = attr.ib(default=attr.Factory(dict), repr=False, hash=False) + sourced_count = attr.ib(default=None) + + def parse_script_line(self, lnum, line): + m = re.match(RE_FUNC_PREFIX, line) + if m: + f = line[m.end():].split('(', 1)[0] + if '.' in f: + self.dict_functions.add(lnum) + + if f.startswith(''): + f = 's:' + f[5:] + elif f.startswith('g:'): + f = f[2:] + self.func_to_lnums.setdefault(f, []).append(lnum) + + +@attr.s +class Function(object): + name = attr.ib() + calls = attr.ib(default=None) + total_time = attr.ib(default=None) + self_time = attr.ib(default=None) + lines = attr.ib(default=attr.Factory(dict), repr=False) + + +@attr.s +class MergedProfiles(object): + profiles = attr.ib(default=attr.Factory(list)) + source = attr.ib(default=attr.Factory(list)) + append_to = attr.ib(default=None) + + _coveragepy_data = None + + def __setattr__(self, name, value): + """Invalidate cache if profiles get changed.""" + if name == 'profiles': + self._coveragepy_data = None + super(MergedProfiles, self).__setattr__(name, value) + + def add_profile_files(self, *profile_files): + for f in profile_files: + p = Profile(f) + p.parse() + self.profiles.append(p) + + @property + def scripts(self): + return itertools.chain.from_iterable(p.scripts for p in self.profiles) + + @property + def scriptfiles(self): + return {s.path for s in self.scripts} + + @property + def lines(self): + def merge_lines(line1, line2): + assert line1.line == line2.line + new = Line(line1.line) + if line1.count is None: + new.count = line2.count + elif line2.count is None: + new.count = line1.count + else: + new.count = line1.count + line2.count + return new + + lines = {} + for p in self.profiles: + for s, s_lines in p.lines.items(): + if s.path in lines: + new = lines[s.path] + for lnum, line in s_lines.items(): + if lnum in new: + new[lnum] = merge_lines(new[lnum], line) + else: + new[lnum] = copy.copy(line) + else: + lines[s.path] = copy.copy(s_lines) + + if s.sourced_count and s_lines: + # Fix line count for first line. + # https://github.com/vim/vim/issues/2103. + line1 = lines[s.path][1] + if not line1.count and is_executable_line(line1.line): + line1.count = 1 + return lines + + def _get_coveragepy_data(self): + if self.append_to: + fname, fobj, fstr = get_fname_and_fobj_and_str(self.append_to) + if fobj or (fname and os.path.exists(fname)): + data = CoverageData(data_file=self.append_to) + else: + data = CoverageData() + else: + data = CoverageData() + + cov_dict = {} + cov_file_tracers = {} + + source_files = [] + for source in self.source: + source = os.path.abspath(source) + if os.path.isfile(source): + source_files.append(source) + else: + source_files.extend(find_executable_files(source)) + LOGGER.debug('source_files: %r', source_files) + + for fname, lines in self.lines.items(): + fname = os.path.abspath(fname) + if self.source and fname not in source_files: + LOGGER.info('Ignoring non-source: %s', fname) + continue + + cov_dict[fname] = { + # lnum: line.count for lnum, line in lines.items() + # XXX: coveragepy does not support hit counts?! + lnum: None for lnum, line in lines.items() if line.count + } + # Add our plugin as file tracer, so that it gets used with e.g. + # `coverage annotate`. + cov_file_tracers[fname] = 'covimerage.CoveragePlugin' + measured_files = cov_dict.keys() + non_measured_files = set(source_files) - set(measured_files) + for fname in non_measured_files: + LOGGER.debug('Non-measured file: %s', fname) + cov_dict[fname] = {} + cov_file_tracers[fname] = 'covimerage.CoveragePlugin' + + data.add_lines(cov_dict) + data.cov_data.add_file_tracers(cov_file_tracers) + return data.cov_data + + def get_coveragepy_data(self): + if self._coveragepy_data is not None: + return self._coveragepy_data + else: + self._coveragepy_data = self._get_coveragepy_data() + return self._coveragepy_data + + # TODO: move to CoverageWrapper + def write_coveragepy_data(self, data_file='.coverage'): + cov_data = self.get_coveragepy_data() + if not cov_data.line_counts(): + LOGGER.warning('Not writing coverage file: no data to report!') + return False + + if isinstance(data_file, string_types): + LOGGER.info('Writing coverage file %s.', data_file) + cov_data.write_file(data_file) + else: + try: + filename = data_file.name + except AttributeError: + filename = str(data_file) + LOGGER.info('Writing coverage file %s.', filename) + cov_data.write_fileobj(data_file) + return True + + +@attr.s +class Profile(object): + fname = attr.ib() + scripts = attr.ib(default=attr.Factory(list)) + anonymous_functions = attr.ib(default=attr.Factory(dict)) + + def __attrs_post_init__(self): + self.fname, self.fobj, self.fstr = get_fname_and_fobj_and_str( + self.fname) + + @property + def scriptfiles(self): + return {s.path for s in self.scripts} + + @property + def lines(self): + return {s: s.lines for s in self.scripts} + + def _get_anon_func_script_line(self, func): + len_func_lines = len(func.lines) + found = [] + for s in self.scripts: + for lnum in s.dict_functions: + script_lnum = lnum + 1 + len_script_lines = len(s.lines) + + func_lnum = 0 + script_is_func = True + script_line = s.lines[script_lnum].line + while (script_lnum <= len_script_lines and + func_lnum < len_func_lines): + script_lnum += 1 + next_line = s.lines[script_lnum].line + m = re.match(RE_CONTINUING_LINE, next_line) + if m: + script_line += next_line[m.end():] + continue + func_lnum += 1 + if script_line != func.lines[func_lnum].line: + script_is_func = False + break + script_line = s.lines[script_lnum].line + + if script_is_func: + found.append((s, lnum)) + + if found: + if len(found) > 1: + LOGGER.warning( + 'Found multiple sources for anonymous function %s (%s).', + func.name, (', '.join('%s:%d' % (f[0].path, f[1]) + for f in found))) + + for s, lnum in found: + if lnum in s.mapped_dict_functions: + # More likely to happen with merged profiles. + LOGGER.debug( + 'Found already mapped dict function again (%s:%d).', + s.path, lnum) + continue + s.mapped_dict_functions.add(lnum) + return (s, lnum) + return found[0] + + def get_anon_func_script_line(self, func): + funcname = func.name + try: + return self.anonymous_functions[funcname] + except KeyError: + f_info = self._get_anon_func_script_line(func) + if f_info is not None: + (script, lnum) = f_info + self.anonymous_functions[func.name] = (script, lnum) + return self.anonymous_functions[funcname] + + def find_func_in_source(self, func): + funcname = func.name + if funcname.isdigit(): + # This is an anonymous function, which we need to lookup based on + # its source contents. + return self.get_anon_func_script_line(func) + + m = re.match(r'^\d+_', funcname) + if m: + funcname = 's:' + funcname[m.end():] + + found = [] + for script in self.scripts: + try: + lnums = script.func_to_lnums[funcname] + except KeyError: + continue + + for script_lnum in lnums: + if self.source_contains_func(script, script_lnum, func): + found.append((script, script_lnum)) + if found: + if len(found) > 1: + LOGGER.warning('Found multiple sources for function %s (%s).', + func, (', '.join('%s:%d' % (f[0].path, f[1]) + for f in found))) + return found[0] + return None + + @staticmethod + def source_contains_func(script, script_lnum, func): + for [f_lnum, f_line] in func.lines.items(): + s_line = script.lines[script_lnum + f_lnum] + + # XXX: might not be the same, since function lines + # are joined, while script lines might be spread + # across several lines (prefixed with \). + script_source = s_line.line + if script_source != f_line.line: + while True: + # try: + peek = script.lines[script_lnum + + f_lnum + 1] + # except KeyError: + # pass + if True: + m = re.match(RE_CONTINUING_LINE, peek.line) + if m: + script_source += peek.line[m.end():] + script_lnum += 1 + # script_lines.append(peek) + continue + if script_source == f_line.line: + break + + return False + return True + + def parse(self): + LOGGER.debug('Parsing file: %s', self.fstr) + if self.fobj: + return self._parse(self.fobj) + with open(self.fname, 'r') as file_object: + return self._parse(file_object) + + def _parse(self, file_object): + in_script = False + in_function = False + plnum = lnum = 0 + + def skip_to_count_header(): + skipped = 0 + while True: + next_line = next(file_object) + skipped += 1 + if next_line.startswith('count'): + break + return skipped + + for line in file_object: + plnum += 1 + line = line.rstrip('\r\n') + if line == '': + if in_function: + func_name = in_function.name + script_line = self.find_func_in_source(in_function) + if not script_line: + LOGGER.error('Could not find source for function: %s', + func_name) + in_function = False + continue + + # Assign counts from function to script. + script, script_lnum = script_line + for [f_lnum, f_line] in in_function.lines.items(): + s_line = script.lines[script_lnum + f_lnum] + + # XXX: might not be the same, since function lines + # are joined, while script lines might be spread + # across several lines (prefixed with \). + script_source = s_line.line + if script_source != f_line.line: + while True: + try: + peek = script.lines[script_lnum + + f_lnum + 1] + except KeyError: + pass + else: + m = re.match(RE_CONTINUING_LINE, peek.line) + if m: + script_source += peek.line[m.end():] + script_lnum += 1 + # script_lines.append(peek) + continue + if script_source == f_line.line: + break + + assert 0, 'Script line matches function line.' + + if f_line.count is not None: + if s_line.count: + s_line.count += f_line.count + else: + s_line.count = f_line.count + if f_line.self_time: + if s_line.self_time: + s_line.self_time += f_line.self_time + else: + s_line.self_time = f_line.self_time + if f_line.total_time: + if s_line.total_time: + s_line.total_time += f_line.total_time + else: + s_line.total_time = f_line.total_time + + in_script = False + in_function = False + continue + + if in_script or in_function: + lnum += 1 + try: + count, total_time, self_time = parse_count_and_times(line) + except Exception as exc: + LOGGER.warning( + 'Could not parse count/times from line: %s (%s:%d).', + line, self.fstr, plnum) + continue + source_line = line[28:] + + if in_script: + in_script.lines[lnum] = Line( + line=source_line, count=count, + total_time=total_time, self_time=self_time) + if count or lnum == 1: + # Parse line 1 always, as a workaround for + # https://github.com/vim/vim/issues/2103. + in_script.parse_script_line(lnum, source_line) + else: + if count is None: + # Functions do not have continued lines, assume 0. + count = 0 + line = Line(line=source_line, count=count, + total_time=total_time, self_time=self_time) + in_function.lines[lnum] = line + + elif line.startswith('SCRIPT '): + fname = line[8:] + in_script = Script(fname) + LOGGER.debug('Parsing script %s', in_script) + self.scripts.append(in_script) + + next_line = next(file_object) + m = re.match('Sourced (\d+) time', next_line) + in_script.sourced_count = int(m.group(1)) + + plnum += skip_to_count_header() + 1 + lnum = 0 + + elif line.startswith('FUNCTION '): + func_name = line[10:-2] + in_function = Function(name=func_name) + LOGGER.debug('Parsing function %s', in_function) + plnum += skip_to_count_header() + lnum = 0 + + +def parse_count_and_times(line): + count = line[0:5] + if count == '': + return 0, None, None + if count == ' ': + count = None + else: + count = int(count) + total_time = line[8:16] + if total_time == ' ': + total_time = None + else: + total_time = float(total_time) + self_time = line[19:27] + if self_time == ' ': + self_time = None + else: + self_time = float(self_time) + + return count, total_time, self_time + + +def coverage_init(reg, options): + """ + Called from coverage.py when used as plugin in .coveragerc. + + This is not really necessary, but let's keep it so that e.g. + `coverage annotate` can be used. + + [run] + plugins = covimerage + """ + from .coveragepy import CoveragePlugin + + reg.add_file_tracer(CoveragePlugin()) diff --git a/covimerage/__main__.py b/covimerage/__main__.py new file mode 100644 index 00000000..5da29c46 --- /dev/null +++ b/covimerage/__main__.py @@ -0,0 +1,3 @@ +if __name__ == '__main__': + from .cli import main + main() diff --git a/covimerage/__version__.py b/covimerage/__version__.py new file mode 100644 index 00000000..838f52e6 --- /dev/null +++ b/covimerage/__version__.py @@ -0,0 +1 @@ +__version__ = '0.0.1.dev3' diff --git a/covimerage/_compat.py b/covimerage/_compat.py new file mode 100644 index 00000000..c6691997 --- /dev/null +++ b/covimerage/_compat.py @@ -0,0 +1,28 @@ +try: + FileNotFoundError = FileNotFoundError +except NameError: + FileNotFoundError = IOError + +try: + from StringIO import StringIO +except ImportError: + from io import StringIO # noqa: F401 + +try: + from shlex import quote as shell_quote +except ImportError: + import re + + # Copy'n'paste from Python 3.6.2. + _find_unsafe = re.compile(r'[^a-zA-Z0-9_@%+=:,./-]').search + + def shell_quote(s): + """Return a shell-escaped version of the string *s*.""" + if not s: + return "''" + if _find_unsafe(s) is None: + return s + + # use single quotes, and put single quotes into double quotes + # the string $'b is then quoted as '$'"'"'b' + return "'" + s.replace("'", "'\"'\"'") + "'" diff --git a/covimerage/cli.py b/covimerage/cli.py new file mode 100644 index 00000000..65ab12b0 --- /dev/null +++ b/covimerage/cli.py @@ -0,0 +1,229 @@ +import os + +import click + +from . import DEFAULT_COVERAGE_DATA_FILE, MergedProfiles, Profile +from .__version__ import __version__ +from .coveragepy import CoverageWrapper +from .logger import LOGGER +from .utils import build_vim_profile_args, join_argv + + +@click.group(context_settings={'help_option_names': ['-h', '--help']}) +@click.version_option(__version__, '-V', '--version', prog_name='covimerage') +@click.option('-v', '--verbose', count=True, help='Increase verbosity.') +@click.option('-q', '--quiet', count=True, help='Decrease verbosity.') +def main(verbose, quiet): + if verbose - quiet: + LOGGER.setLevel(max(10, LOGGER.level - (verbose - quiet) * 10)) + + +@main.command() +@click.argument('profile_file', type=click.File('r'), required=False, nargs=-1) +@click.option('--data-file', required=False, show_default=True, + default=DEFAULT_COVERAGE_DATA_FILE, type=click.File(mode='w')) +@click.option('--source', type=click.types.Path(exists=True), help=( + 'Source files/dirs to include. This is necessary to include completely ' + 'uncovered files.'), show_default=True, multiple=True) +def write_coverage(profile_file, data_file, source): + """ + Parse PROFILE_FILE (output from Vim's :profile) and write it into DATA_FILE + (Coverage.py compatible). + """ + m = MergedProfiles(source=source) + m.add_profile_files(*profile_file) + if not m.write_coveragepy_data(data_file=data_file): + raise click.ClickException('No data to report.') + + +@main.command(context_settings=dict( + # ignore_unknown_options=True, + allow_interspersed_args=False, +)) +@click.argument('args', nargs=-1, type=click.UNPROCESSED) +@click.option('--wrap-profile/--no-wrap-profile', required=False, + default=True, show_default=True, + help='Wrap VIM cmd with options to create a PROFILE_FILE.') +@click.option('--profile-file', required=False, metavar='PROFILE_FILE', + type=click.Path(dir_okay=False), + help='File name for the PROFILE_FILE file. By default a temporary file is used.') # noqa: E501 +@click.option('--data-file', required=False, type=click.File('w'), + help='DATA_FILE to write into.', show_default=True) +@click.option('--append', is_flag=True, default=False, + help='Read existing DATA_FILE for appending.', show_default=True) +@click.option('--write-data/--no-write-data', is_flag=True, + default=True, show_default=True, + help='Write Coverage.py compatible DATA_FILE.') +@click.option('--report/--no-report', is_flag=True, default=True, + help='Automatically report. This avoids having to write an intermediate data file.') # noqa: E501 +@click.option('--report-file', type=click.File('w'), + help='Report output file. Defaults to stdout.') +# TODO: rather handle this via real options, and pass them through?! +@click.option('--report-options', required=False, + help='Options to be passed on to `covimerage report`.') +@click.option('--source', type=click.types.Path(exists=True), default=['.'], + multiple=True) +@click.pass_context +def run(ctx, args, wrap_profile, profile_file, write_data, data_file, + report, report_file, report_options, source, append): + """ + Run VIM wrapped with :profile instructions. + + """ + import subprocess + import tempfile + + if report: + report_cmd = main.get_command(ctx, 'report') + if report_options: + report_args = click.parser.split_arg_string(report_options) + parser = report_cmd.make_parser(ctx) + try: + report_opts, _, _ = parser.parse_args(args=report_args) + except click.exceptions.UsageError as exc: + raise click.exceptions.UsageError( + 'Failed to parse --report-options: %s' % exc.message, + ctx=ctx) + else: + report_opts = {} + + args = list(args) + if wrap_profile: + if not profile_file: + # TODO: remove it automatically in the end? + profile_file = tempfile.mktemp(prefix='covimerage.profile.') + args += build_vim_profile_args(profile_file, source) + cmd = args + LOGGER.info('Running cmd: %s (in %s)', join_argv(cmd), os.getcwd()) + + try: + exit_code = subprocess.call(cmd) + except Exception as exc: + raise click.exceptions.ClickException( + 'Failed to run %s: %s' % (cmd, exc)) + + if profile_file: + if not os.path.exists(profile_file): + if not exit_code: + exit = click.exceptions.ClickException( + 'The profile file (%s) has not been created.' % ( + profile_file)) + exit.exit_code = 1 + raise exit + + elif write_data or report: + LOGGER.info('Parsing profile file %s.', profile_file) + p = Profile(profile_file) + p.parse() + + if (write_data or append) and not data_file: + data_file = DEFAULT_COVERAGE_DATA_FILE + + if append: + m = MergedProfiles([p], source=source, append_to=data_file) + else: + m = MergedProfiles([p], source=source) + + if write_data: + m.write_coveragepy_data(data_file) + + if report: + cov_data = m.get_coveragepy_data() + if not cov_data: + raise click.ClickException('No data to report.') + report_opts['data'] = cov_data + ctx.invoke(report_cmd, report_file=report_file, + **report_opts) + + if exit_code != 0: + exit = click.exceptions.ClickException( + 'Command exited non-zero: %d.' % exit_code) + exit.exit_code = exit_code + raise exit + + +def report_data_file_cb(ctx, param, value): + """Use click.File for data_file only if it is used, to prevent an error + if it does not exist (click tries to open it always).""" + if ctx.params.get('profile_file', ()): + return value + return click.File('r').convert(value, param, ctx) + + +def report_source_cb(ctx, param, value): + if value and not ctx.params.get('profile_file', ()): + raise click.exceptions.UsageError( + '--source can only be used with PROFILE_FILE.') + return value + + +@main.command() +@click.argument('profile_file', type=click.File('r'), required=False, nargs=-1, + is_eager=True) +@click.option('--data-file', required=False, show_default=True, help=( + 'DATA_FILE to use in case PROFILE_FILE is not provided.'), + callback=report_data_file_cb, + default=DEFAULT_COVERAGE_DATA_FILE) +@click.option('--show-missing', '-m', is_flag=True, default=False, help=( + 'Show line numbers of statements in each file that was not executed.')) +@click.option('--include', required=False, multiple=True, help=( + 'Include only files whose paths match one of these patterns. ' + 'Accepts shell-style wildcards, which must be quoted.')) +@click.option('--omit', required=False, multiple=True, help=( + 'Omit files whose paths match one of these patterns. ' + 'Accepts shell-style wildcards, which must be quoted.')) +@click.option('--skip-covered', is_flag=True, default=False, + help='Skip files with 100% coverage.') +@click.option('--source', type=click.types.Path(exists=True), help=( + 'Source dirs/files to include (only when PROFILE_FILE is used - otherwise ' + 'it is expected to be in the data already).'), + callback=report_source_cb, + show_default=True, default=None, multiple=True) +@click.option('--report-file', type=click.File('w'), + help='Report output file. Defaults to stdout.') +def report(profile_file, data_file, show_missing, include, omit, skip_covered, + source, report_file, data=None): + """ + A wrapper around `coverage report`. + + This will automatically add covimerage as a plugin, and then just forwards + most options. + + If PROFILE_FILE is provided this gets parsed, otherwise DATA_FILE is + being used. + """ + if data: + data_file = None + elif profile_file: + data_file = None + m = MergedProfiles(source=source) + m.add_profile_files(*profile_file) + data = m.get_coveragepy_data() + CoverageWrapper(data=data, data_file=data_file).report( + report_file=report_file, + show_missing=show_missing, include=include, omit=omit, + skip_covered=skip_covered) + + +# TODO: support / pass through --rcfile?! +@main.command() +@click.option('--data-file', required=False, type=click.File('r'), + default=DEFAULT_COVERAGE_DATA_FILE, show_default=True) +@click.option('--include', required=False, multiple=True, help=( + 'Include only files whose paths match one of these patterns. ' + 'Accepts shell-style wildcards, which must be quoted.')) +@click.option('--omit', required=False, multiple=True, help=( + 'Omit files whose paths match one of these patterns. ' + 'Accepts shell-style wildcards, which must be quoted.')) +@click.option('--ignore-errors', is_flag=True, default=False, + show_default=True, required=False, + help='Ignore errors while reading source files.') +def xml(data_file, include, omit, ignore_errors): + """ + A wrapper around `coverage xml`. + + This will automatically add covimerage as a plugin, and then just forwards + most options. + """ + CoverageWrapper(data_file=data_file).reportxml( + include=include, omit=omit, ignore_errors=ignore_errors) diff --git a/covimerage/coveragepy.py b/covimerage/coveragepy.py new file mode 100644 index 00000000..b6d48a90 --- /dev/null +++ b/covimerage/coveragepy.py @@ -0,0 +1,175 @@ +import re + +import attr +import click +import coverage + +from ._compat import FileNotFoundError +from .logger import LOGGER +from .utils import get_fname_and_fobj_and_str, is_executable_line + +RE_EXCLUDED = re.compile( + r'"\s*(pragma|PRAGMA)[:\s]?\s*(no|NO)\s*(cover|COVER)') + + +class CoverageWrapperException(click.ClickException): + """Inherit from ClickException for automatic handling.""" + def __init__(self, message, orig_exc=None): + self.orig_exc = orig_exc + super(CoverageWrapperException, self).__init__(message) + + def format_message(self): + """Append information about original exception if any.""" + msg = super(CoverageWrapperException, self).format_message() + if self.orig_exc: + return '%s (%r)' % (msg, self.orig_exc) + return msg + + def __str__(self): + return self.format_message() + + def __repr__(self): + return 'CoverageWrapperException(message=%r, orig_exc=%r)' % ( + self.message, self.orig_exc) + + +@attr.s(frozen=True) +class CoverageData(object): + cov_data = attr.ib(default=None) + data_file = attr.ib(default=None) + + def __attrs_post_init__(self): + if self.cov_data and self.data_file: + raise TypeError('data and data_file are mutually exclusive.') + if self.cov_data: + if not isinstance(self.cov_data, coverage.data.CoverageData): + raise TypeError( + 'data needs to be of type coverage.data.CoverageData') + return + cov_data = coverage.data.CoverageData() + if self.data_file: + fname, fobj, fstr = get_fname_and_fobj_and_str(self.data_file) + try: + if fobj: + cov_data.read_fileobj(fobj) + else: + cov_data.read_file(fname) + except coverage.CoverageException as exc: + raise CoverageWrapperException( + 'Coverage could not read data_file: %s' % fstr, + orig_exc=exc) + object.__setattr__(self, 'cov_data', cov_data) + + @property + def lines(self): + data = self.cov_data + return {f: sorted(data.lines(f)) for f in data.measured_files()} + + def add_lines(self, lines): + self.cov_data.add_lines(lines) + + +@attr.s(frozen=True) +class CoverageWrapper(object): + """Wrap Coveragepy related functionality.""" + data = attr.ib(default=None) + data_file = attr.ib(default=None) + + _cached_cov_obj = None + + def __attrs_post_init__(self): + if not isinstance(self.data, CoverageData): + data = CoverageData(cov_data=self.data, data_file=self.data_file) + object.__setattr__(self, 'data', data) + elif self.data_file: + raise TypeError('data and data_file are mutually exclusive.') + + @property + def lines(self): + return self.data.lines + + @property + def _cov_obj(self): + if not self._cached_cov_obj: + object.__setattr__(self, '_cached_cov_obj', self._get_cov_obj()) + return self._cached_cov_obj + + def _get_cov_obj(self): + class CoverageW(coverage.Coverage): + """Wrap/shortcut _get_file_reporter to return ours.""" + def _get_file_reporter(self, morf): + return FileReporter(morf) + + cov_coverage = CoverageW(config_file=False) + cov_coverage._init() + cov_coverage.data = self.data.cov_data + return cov_coverage + + def report(self, report_file=None, show_missing=None, + include=None, omit=None, skip_covered=None): + self._cov_obj.report( + file=report_file, show_missing=show_missing, include=include, + omit=omit, skip_covered=skip_covered) + + def reportxml(self, report_file=None, include=None, omit=None, + ignore_errors=None): + self._cov_obj.xml_report( + outfile=report_file, include=include, omit=omit, + ignore_errors=ignore_errors) + + +class FileReporter(coverage.FileReporter): + _split_lines = None + + def __repr__(self): + return ''.format(self.filename) + + def source(self): + try: + with open(self.filename, 'rb') as f: + source = f.read() + try: + return source.decode('utf8') + except UnicodeDecodeError: + LOGGER.debug('UnicodeDecodeError in %s for utf8. ' + 'Trying iso-8859-15.', self.filename) + return source.decode('iso-8859-15') + except FileNotFoundError as exc: + LOGGER.warning('%s', exc) + raise coverage.misc.NoSource(str(exc)) + except Exception as exc: + raise CoverageWrapperException( + 'Could not read source for %s.' % self.filename, orig_exc=exc) + + @property + def split_lines(self): + if self._split_lines is None: + self._split_lines = self.source().splitlines() + return self._split_lines + + # NOTE: should be done before already, to end up in .coverage already. + # def translate_lines(self, lines): + # if (1 not in lines and + # lines and re.match(RE_FUNC_PREFIX, self.split_lines[0])): + # lines.append(1) + # return set(lines) + + def lines(self): + lines = [] + for lnum, l in enumerate(self.split_lines, start=1): + if is_executable_line(l): + lines.append(lnum) + return set(lines) + + def excluded_lines(self): + lines = [] + for lnum, l in enumerate(self.split_lines, start=1): + # TODO: exclude until end of block (by using vimlparser?!) + if RE_EXCLUDED.search(l): + lines.append(lnum) + return set(lines) + + +class CoveragePlugin(coverage.CoveragePlugin): + def file_reporter(self, filename): + return FileReporter(filename) diff --git a/covimerage/logger.py b/covimerage/logger.py new file mode 100644 index 00000000..8c74ea01 --- /dev/null +++ b/covimerage/logger.py @@ -0,0 +1,21 @@ +import logging +import sys + +LOGGER = logging.getLogger('covimerage') +LOGGER.setLevel(logging.INFO) + + +class AlwaysStderrHandler(logging.StreamHandler): + def __init__(self, level=logging.NOTSET): + logging.Handler.__init__(self, level) + + @property + def stream(self): + return sys.stderr + + def handleError(self, record): + super(AlwaysStderrHandler, self).handleError(record) + raise Exception('Internal logging error') + + +LOGGER.addHandler(AlwaysStderrHandler()) diff --git a/covimerage/utils.py b/covimerage/utils.py new file mode 100644 index 00000000..1818d185 --- /dev/null +++ b/covimerage/utils.py @@ -0,0 +1,58 @@ +import os +import re + +from click.utils import string_types + +from ._compat import shell_quote + +# Empty (whitespace only), comments, continued, or `end` statements. +RE_NON_EXECED = re.compile(r'^\s*("|\\|end|$)') + + +def get_fname_and_fobj_and_str(fname_or_fobj): + if isinstance(fname_or_fobj, string_types): + return fname_or_fobj, None, fname_or_fobj + try: + fname = fname_or_fobj.name + except AttributeError: + return None, fname_or_fobj, str(fname_or_fobj) + return fname, fname_or_fobj, fname + + +def build_vim_profile_args(profile_fname, sources): + args = ['--cmd', 'profile start %s' % profile_fname] + for source in sources: + if os.path.isdir(source): + pattern = '%s/*' + else: + pattern = '%s' + args += ['--cmd', 'profile! file %s' % (pattern % source)] + return args + + +def is_executable_filename(filename): + # We're only interested in files that look like reasonable Vim + # files: + # Must end with .vim or start with vim, and must not have + # certain funny characters that probably mean they are editor + # junk. + if re.match(r'[^.][^#~!$@%^&*()+=,]*\.n?vim$', filename): + return True + if re.match(r'[^#~!$@%^&*()+=,]*vimrc[^#~!$@%^&*()+=,]*$', filename): + return True + return False + + +def find_executable_files(src_dir): + for i, (dirpath, dirnames, filenames) in enumerate(os.walk(src_dir)): + for filename in filenames: + if is_executable_filename(filename): + yield os.path.join(dirpath, filename) + + +def is_executable_line(l): + return not RE_NON_EXECED.match(l) + + +def join_argv(argv): + return ' '.join(shell_quote(s) for s in argv) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..74193818 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,14 @@ +[tool:pytest] +testpaths = tests +addopts = -ra + +[isort] +combine_as_imports = 1 +default_section = THIRDPARTY +force_sort_within_sections = 1 +include_trailing_comma = 1 +known_first_party = covimerage +lines = 79 +multi_line_output = 5 +order_by_type = 1 +use_parentheses = 1 diff --git a/setup.py b/setup.py new file mode 100755 index 00000000..3486f983 --- /dev/null +++ b/setup.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Based on https://github.com/kennethreitz/setup.py. + +# Note: To use the 'upload' functionality of this file, you must: +# $ pip install twine + +# import io +import os +from shutil import rmtree +import sys + +from setuptools import Command, setup + +# Package meta-data. +NAME = 'covimerage' +DESCRIPTION = 'Generate coverage information for Vim scripts.' +URL = 'https://github.com/Vimjas/covimerage' +# EMAIL = 'me@example.com' +AUTHOR = 'Daniel Hahler' + +# What packages are required for this module to be executed? +REQUIRED = ['attrs', 'click', 'coverage'] + +here = os.path.abspath(os.path.dirname(__file__)) + +# Import the README and use it as the long-description. +# Note: this will only work if 'README.rst' is present in your MANIFEST.in +# file! +# with io.open(os.path.join(here, 'README.rst'), encoding='utf-8') as f: +# long_description = '\n' + f.read() + +# Load the package's __version__.py module as a dictionary. +about = {} +with open(os.path.join(here, NAME, '__version__.py')) as f: + exec(f.read(), about) + + +class PublishCommand(Command): + """Support setup.py publish.""" + + description = 'Build and publish the package.' + user_options = [] + + @staticmethod + def status(s): + """Prints things in bold.""" + print('\033[1m{0}\033[0m'.format(s)) + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def run(self): + try: + self.status('Removing previous builds…') + rmtree(os.path.join(here, 'dist')) + except OSError: + pass + + self.status('Building Source and Wheel (universal) distribution…') + os.system('{0} setup.py sdist bdist_wheel --universal'.format( + sys.executable)) + + self.status('Uploading the package to PyPi via Twine…') + os.system('twine upload dist/*') + + sys.exit() + + +DEPS_QA = [ + 'flake8', + 'flake8-isort', + 'flake8-quotes', +] +DEPS_TESTING = [ + 'pytest', + 'pytest-catchlog', + 'pytest-cov', + 'pytest-mock', +] + +# Where the magic happens: +setup( + name=NAME, + version=about['__version__'], + description=DESCRIPTION, + # long_description=long_description, + author=AUTHOR, + # author_email=EMAIL, + url=URL, + packages=['covimerage'], + entry_points={ + 'console_scripts': ['covimerage=covimerage.cli:main'], + }, + install_requires=REQUIRED, + extras_require={ + 'testing': DEPS_TESTING, + 'dev': DEPS_TESTING + DEPS_QA + [ + 'pdbpp', + 'pytest-pdb', + ], + 'qa': DEPS_QA, + }, + include_package_data=True, + license='MIT', + classifiers=[ + # Trove classifiers + # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy' + ], + # $ setup.py publish support. + cmdclass={ + 'publish': PublishCommand, + }, +) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..5fcd6767 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +from glob import glob +import os + +from click.testing import CliRunner +import pytest + + +@pytest.fixture(autouse=True) +def ensure_no_coverage_data_changed_in_cwd(): + fnames = glob('.coverage') + old_coverage = { + fname: os.stat(fname) if os.path.exists(fname) else None + for fname in fnames} + yield + new_fnames = glob('.coverage') + for new_fname in new_fnames: # pragma: no cover + old_stat = old_coverage.get(new_fname) + if old_stat is None: + pytest.fail('Test created a data file: %s.' % new_fname) + elif old_stat != os.stat(new_fname): + pytest.fail('Test changed an existing data file: %s.' % ( + new_fname)) + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def devnull(): + with open(os.devnull) as f: + yield f + + +@pytest.fixture +def covdata_empty(): + return "!coverage.py: This is a private format, don't read it directly!{}" diff --git a/tests/fixtures/conditional_function.profile b/tests/fixtures/conditional_function.profile new file mode 100644 index 00000000..36bb27da --- /dev/null +++ b/tests/fixtures/conditional_function.profile @@ -0,0 +1,56 @@ +SCRIPT tests/test_plugin/conditional_function.vim +Sourced 1 time +Total time: 0.000094 + Self time: 0.000074 + +count total (s) self (s) + " Test for detection of conditional functions. + + 1 0.000007 if 0 + function Foo() + return 1 + endfunction + else + 1 0.000002 function Foo() + return 1 + endfunction + 1 0.000001 endif + + 1 0.000013 0.000008 if Foo() + 1 0.000002 function Bar() + echom 1 + endfunction + 1 0.000001 else + function Bar() + echom 1 + endfunction + endif + + 1 0.000021 0.000007 call Bar() + +FUNCTION Foo() +Called 1 time +Total time: 0.000005 + Self time: 0.000005 + +count total (s) self (s) + 1 0.000003 return 1 + +FUNCTION Bar() +Called 1 time +Total time: 0.000015 + Self time: 0.000015 + +count total (s) self (s) + 1 0.000014 echom 1 + +FUNCTIONS SORTED ON TOTAL TIME +count total (s) self (s) function + 1 0.000015 Bar() + 1 0.000005 Foo() + +FUNCTIONS SORTED ON SELF TIME +count total (s) self (s) function + 1 0.000015 Bar() + 1 0.000005 Foo() + diff --git a/tests/fixtures/continued_lines.profile b/tests/fixtures/continued_lines.profile new file mode 100644 index 00000000..c178d664 --- /dev/null +++ b/tests/fixtures/continued_lines.profile @@ -0,0 +1,11 @@ +SCRIPT /test_plugin/continued_lines.vim +Sourced 1 time +Total time: 0.000071 + Self time: 0.000071 + +count total (s) self (s) + echom 1 + 1 0.000028 echom 2 + \ 3 + 1 0.000013 echom 4 + diff --git a/tests/fixtures/dict_function.profile b/tests/fixtures/dict_function.profile new file mode 100644 index 00000000..655cb852 --- /dev/null +++ b/tests/fixtures/dict_function.profile @@ -0,0 +1,40 @@ +SCRIPT /test_plugin/dict_function.vim +Sourced 1 time +Total time: 0.000159 + Self time: 0.000066 + +count total (s) self (s) + " Test parsing of dict function. + 1 0.000012 let obj = {} + 1 0.000006 function! obj.dict_function(arg) abort + if a:arg + echom a:arg + else + echom a:arg + endif + endfunction + + 1 0.000049 0.000012 call obj.dict_function(0) + 1 0.000042 0.000010 call obj.dict_function(1) + 1 0.000034 0.000010 call obj.dict_function(2) + +FUNCTION 4() +Called 3 times +Total time: 0.000093 + Self time: 0.000093 + +count total (s) self (s) + 3 0.000014 if a:arg + 2 0.000032 echom a:arg + 2 0.000004 else + 1 0.000023 echom a:arg + 1 0.000002 endif + +FUNCTIONS SORTED ON TOTAL TIME +count total (s) self (s) function + 3 0.000093 4() + +FUNCTIONS SORTED ON SELF TIME +count total (s) self (s) function + 3 0.000093 4() + diff --git a/tests/fixtures/dict_function_used_twice.profile b/tests/fixtures/dict_function_used_twice.profile new file mode 100644 index 00000000..31bd2c8e --- /dev/null +++ b/tests/fixtures/dict_function_used_twice.profile @@ -0,0 +1,71 @@ +SCRIPT /test_plugin/dict_function_used_twice.vim +Sourced 1 time +Total time: 0.000174 + Self time: 0.000088 + +count total (s) self (s) + " Test parsing of dict function that gets used twice. + 1 0.000015 let base = {} + 1 0.000006 function! base.base_function() abort dict + if 1 + echom 1 + else + echom 2 + endif + endfunction + + 1 0.000009 let obj1 = deepcopy(base) + 1 0.000003 function! obj1.func() abort + call self.base_function() + endfunction + + 1 0.000004 let obj2 = deepcopy(base) + 1 0.000001 function! obj2.func() abort + call self.base_function() + endfunction + + 1 0.000034 0.000007 call obj1.func() + 1 0.000039 0.000006 call obj2.func() + 1 0.000019 0.000006 call obj1.base_function() + 1 0.000019 0.000007 call obj2.base_function() + +FUNCTION 1() +Called 4 times +Total time: 0.000070 + Self time: 0.000070 + +count total (s) self (s) + 4 0.000007 if 1 + 4 0.000042 echom 1 + 4 0.000006 else + echom 2 + endif + +FUNCTION 2() +Called 1 time +Total time: 0.000028 + Self time: 0.000009 + +count total (s) self (s) + 1 0.000026 0.000008 call self.base_function() + +FUNCTION 3() +Called 1 time +Total time: 0.000033 + Self time: 0.000007 + +count total (s) self (s) + 1 0.000032 0.000007 call self.base_function() + +FUNCTIONS SORTED ON TOTAL TIME +count total (s) self (s) function + 4 0.000070 1() + 1 0.000033 0.000007 3() + 1 0.000028 0.000009 2() + +FUNCTIONS SORTED ON SELF TIME +count total (s) self (s) function + 4 0.000070 1() + 1 0.000028 0.000009 2() + 1 0.000033 0.000007 3() + diff --git a/tests/fixtures/dict_function_with_continued_lines.profile b/tests/fixtures/dict_function_with_continued_lines.profile new file mode 100644 index 00000000..e35e1e75 --- /dev/null +++ b/tests/fixtures/dict_function_with_continued_lines.profile @@ -0,0 +1,41 @@ +SCRIPT /test_plugin/dict_function_with_continued_lines.vim +Sourced 1 time +Total time: 0.000158 + Self time: 0.000078 + +count total (s) self (s) + " Test parsing of dict function with continued lines. + 1 0.000018 let obj = {} + 1 0.000007 function! obj.dict_function(arg) abort + if a:arg + echom + \ a:arg + else + echom a:arg + endif + endfunction + + 1 0.000044 0.000013 call obj.dict_function(0) + 1 0.000040 0.000008 call obj.dict_function(1) + 1 0.000025 0.000008 call obj.dict_function(1) + +FUNCTION 1() +Called 3 times +Total time: 0.000079 + Self time: 0.000079 + +count total (s) self (s) + 3 0.000010 if a:arg + 2 0.000031 echom a:arg + 2 0.000004 else + 1 0.000017 echom a:arg + 1 0.000002 endif + +FUNCTIONS SORTED ON TOTAL TIME +count total (s) self (s) function + 3 0.000079 1() + +FUNCTIONS SORTED ON SELF TIME +count total (s) self (s) function + 3 0.000079 1() + diff --git a/tests/fixtures/dict_function_with_same_source.profile b/tests/fixtures/dict_function_with_same_source.profile new file mode 100644 index 00000000..e85f5680 --- /dev/null +++ b/tests/fixtures/dict_function_with_same_source.profile @@ -0,0 +1,64 @@ +SCRIPT /test_plugin/dict_function_with_same_source.vim +Sourced 1 time +Total time: 0.000153 + Self time: 0.000080 + +count total (s) self (s) + " Test parsing of dict function (with same source). + 1 0.000015 let obj1 = {} + 1 0.000007 function! obj1.dict_function(arg) abort + if a:arg + echom a:arg + else + echom a:arg + endif + endfunction + + 1 0.000002 let obj2 = {} + 1 0.000002 function! obj2.dict_function(arg) abort + if a:arg + echom a:arg + else + echom a:arg + endif + endfunction + + 1 0.000032 0.000009 call obj2.dict_function(0) + 1 0.000027 0.000005 call obj2.dict_function(1) + 1 0.000021 0.000007 call obj2.dict_function(2) + 1 0.000023 0.000008 call obj1.dict_function(3) + +FUNCTION 1() +Called 1 time +Total time: 0.000014 + Self time: 0.000014 + +count total (s) self (s) + 1 0.000002 if a:arg + 1 0.000008 echom a:arg + 1 0.000001 else + echom a:arg + endif + +FUNCTION 2() +Called 3 times +Total time: 0.000058 + Self time: 0.000058 + +count total (s) self (s) + 3 0.000006 if a:arg + 2 0.000021 echom a:arg + 2 0.000003 else + 1 0.000014 echom a:arg + 1 0.000001 endif + +FUNCTIONS SORTED ON TOTAL TIME +count total (s) self (s) function + 3 0.000058 2() + 1 0.000014 1() + +FUNCTIONS SORTED ON SELF TIME +count total (s) self (s) function + 3 0.000058 2() + 1 0.000014 1() + diff --git a/tests/fixtures/merge-1.profile b/tests/fixtures/merge-1.profile new file mode 100644 index 00000000..a2743055 --- /dev/null +++ b/tests/fixtures/merge-1.profile @@ -0,0 +1,33 @@ +SCRIPT /test_plugin/merged_profiles.vim +Sourced 1 time +Total time: 0.000000 + Self time: 0.000000 + +count total (s) self (s) + " Generate profile output for merged profiles. + " Used merged_profiles-init.vim + 1 0.000005 if !exists('s:conditional') + 1 0.000002 function! F() + echom 1 + endfunction + 1 0.000003 let s:conditional = 1 + 1 0.000020 echom 1 + 1 0.000020 0.000006 call F() + call NeomakeTestsProfileRestart() + +FUNCTION F() +Called 1 time +Total time: 0.000014 + Self time: 0.000014 + +count total (s) self (s) + 1 0.000013 echom 1 + +FUNCTIONS SORTED ON TOTAL TIME +count total (s) self (s) function + 1 0.000014 F() + +FUNCTIONS SORTED ON SELF TIME +count total (s) self (s) function + 1 0.000014 F() + diff --git a/tests/fixtures/merge-2.profile b/tests/fixtures/merge-2.profile new file mode 100644 index 00000000..87580505 --- /dev/null +++ b/tests/fixtures/merge-2.profile @@ -0,0 +1,42 @@ +SCRIPT /test_plugin/merged_profiles.vim +Sourced 1 time +Total time: 76887.125215 + Self time: 76887.125183 + +count total (s) self (s) + " Generate profile output for merged profiles. + " Used merged_profiles-init.vim + 1 0.000009 if !exists('s:conditional') + function! F() + echom 1 + endfunction + let s:conditional = 1 + echom 1 + call F() + call NeomakeTestsProfileRestart() + exe 'source ' . expand('') + 1 0.000001 else + 1 0.000002 function! F() + echom 2 + endfunction + 1 0.000024 echom 2 + 1 0.000030 0.000014 call F() + 1 0.000002 endif + + +FUNCTION F() +Called 1 time +Total time: 0.000016 + Self time: 0.000016 + +count total (s) self (s) + 1 0.000011 echom 2 + +FUNCTIONS SORTED ON TOTAL TIME +count total (s) self (s) function + 1 0.000016 F() + +FUNCTIONS SORTED ON SELF TIME +count total (s) self (s) function + 1 0.000016 F() + diff --git a/tests/fixtures/merged_conditionals-0.profile b/tests/fixtures/merged_conditionals-0.profile new file mode 100644 index 00000000..dd2e08b1 --- /dev/null +++ b/tests/fixtures/merged_conditionals-0.profile @@ -0,0 +1,57 @@ +SCRIPT tests/test_plugin/merged_conditionals.vim +Sourced 1 time +Total time: 0.000084 + Self time: 0.000070 + +count total (s) self (s) + " Generate profile output for merged profiles. + 1 0.000025 let cond = get(g:, 'test_conditional', 0) + + 1 0.000004 if cond == 1 + let foo = 1 + elseif cond == 2 + let foo = 2 + elseif cond == 3 + let foo = 3 + else + 1 0.000003 let foo = 'else' + 1 0.000001 endif + + 1 0.000003 function F(...) + if a:1 == 1 + let foo = 1 + elseif a:1 == 2 + let foo = 2 + elseif a:1 == 3 + let foo = 3 + else + let foo = 'else' + endif + endfunction + + 1 0.000024 0.000010 call F(cond) + +FUNCTION F() +Called 1 time +Total time: 0.000014 + Self time: 0.000014 + +count total (s) self (s) + 1 0.000002 if a:1 == 1 + let foo = 1 + elseif a:1 == 2 + let foo = 2 + elseif a:1 == 3 + let foo = 3 + else + 1 0.000002 let foo = 'else' + 1 0.000001 endif + +FUNCTIONS SORTED ON TOTAL TIME +count total (s) self (s) function + 1 0.000014 F() + +FUNCTIONS SORTED ON SELF TIME +count total (s) self (s) function + 1 0.000014 F() + diff --git a/tests/fixtures/merged_conditionals-1.profile b/tests/fixtures/merged_conditionals-1.profile new file mode 100644 index 00000000..79820f4e --- /dev/null +++ b/tests/fixtures/merged_conditionals-1.profile @@ -0,0 +1,57 @@ +SCRIPT tests/test_plugin/merged_conditionals.vim +Sourced 1 time +Total time: 0.000113 + Self time: 0.000091 + +count total (s) self (s) + " Generate profile output for merged profiles. + 1 0.000029 let cond = get(g:, 'test_conditional', 0) + + 1 0.000006 if cond == 1 + 1 0.000004 let foo = 1 + 1 0.000002 elseif cond == 2 + let foo = 2 + elseif cond == 3 + let foo = 3 + else + let foo = 'else' + endif + + 1 0.000005 function F(...) + if a:1 == 1 + let foo = 1 + elseif a:1 == 2 + let foo = 2 + elseif a:1 == 3 + let foo = 3 + else + let foo = 'else' + endif + endfunction + + 1 0.000035 0.000013 call F(cond) + +FUNCTION F() +Called 1 time +Total time: 0.000022 + Self time: 0.000022 + +count total (s) self (s) + 1 0.000005 if a:1 == 1 + 1 0.000004 let foo = 1 + 1 0.000002 elseif a:1 == 2 + let foo = 2 + elseif a:1 == 3 + let foo = 3 + else + let foo = 'else' + endif + +FUNCTIONS SORTED ON TOTAL TIME +count total (s) self (s) function + 1 0.000022 F() + +FUNCTIONS SORTED ON SELF TIME +count total (s) self (s) function + 1 0.000022 F() + diff --git a/tests/fixtures/merged_conditionals-2.profile b/tests/fixtures/merged_conditionals-2.profile new file mode 100644 index 00000000..0e1b9b0d --- /dev/null +++ b/tests/fixtures/merged_conditionals-2.profile @@ -0,0 +1,57 @@ +SCRIPT tests/test_plugin/merged_conditionals.vim +Sourced 1 time +Total time: 0.000098 + Self time: 0.000078 + +count total (s) self (s) + " Generate profile output for merged profiles. + 1 0.000017 let cond = get(g:, 'test_conditional', 0) + + 1 0.000003 if cond == 1 + let foo = 1 + elseif cond == 2 + 1 0.000002 let foo = 2 + 1 0.000001 elseif cond == 3 + let foo = 3 + else + let foo = 'else' + endif + + 1 0.000007 function F(...) + if a:1 == 1 + let foo = 1 + elseif a:1 == 2 + let foo = 2 + elseif a:1 == 3 + let foo = 3 + else + let foo = 'else' + endif + endfunction + + 1 0.000031 0.000011 call F(cond) + +FUNCTION F() +Called 1 time +Total time: 0.000020 + Self time: 0.000020 + +count total (s) self (s) + 1 0.000003 if a:1 == 1 + let foo = 1 + elseif a:1 == 2 + 1 0.000003 let foo = 2 + 1 0.000002 elseif a:1 == 3 + let foo = 3 + else + let foo = 'else' + endif + +FUNCTIONS SORTED ON TOTAL TIME +count total (s) self (s) function + 1 0.000020 F() + +FUNCTIONS SORTED ON SELF TIME +count total (s) self (s) function + 1 0.000020 F() + diff --git a/tests/fixtures/test_plugin.profile b/tests/fixtures/test_plugin.profile new file mode 100644 index 00000000..b3908556 --- /dev/null +++ b/tests/fixtures/test_plugin.profile @@ -0,0 +1,41 @@ +SCRIPT /test_plugin/autoload/test_plugin.vim +Sourced 1 time +Total time: 0.000033 + Self time: 0.000033 + +count total (s) self (s) + function! test_plugin#func1(a) abort + echom 'func1' + endfunction + + 1 0.000005 function! test_plugin#func2(a) abort + echom 'func2' + endfunction + + +FUNCTION test_plugin#func2() +Called 0 times +Total time: 0.000000 + Self time: 0.000000 + +count total (s) self (s) + echom 'func2' + +FUNCTION test_plugin#func1() +Called 1 time +Total time: 0.000037 + Self time: 0.000037 + +count total (s) self (s) + 1 0.000035 echom 'func1' + +FUNCTIONS SORTED ON TOTAL TIME +count total (s) self (s) function + 1 0.000037 test_plugin#func1() + test_plugin#func2() + +FUNCTIONS SORTED ON SELF TIME +count total (s) self (s) function + 1 0.000037 test_plugin#func1() + test_plugin#func2() + diff --git a/tests/fixtures/test_plugin.vim.profile b/tests/fixtures/test_plugin.vim.profile new file mode 100644 index 00000000..1ca5bd2a --- /dev/null +++ b/tests/fixtures/test_plugin.vim.profile @@ -0,0 +1,40 @@ +SCRIPT /home/daniel/src/covimerage/test_plugin/autoload/test_plugin.vim +Sourced 2 times +Total time: 0.000128 + Self time: 0.000050 + +count total (s) self (s) + " Test parsing of dict function. + 2 0.000007 let obj = {} + 2 0.000004 function! obj.dict_function(arg) abort + if a:arg + echom a:arg + else + echom a:arg + endif + endfunction + + 2 0.000049 0.000013 call obj.dict_function(0) + 2 0.000026 0.000004 call obj.dict_function(1) + 2 0.000026 0.000006 call obj.dict_function(2) + +FUNCTION 2() +Called 3 times +Total time: 0.000027 + Self time: 0.000027 + +count total (s) self (s) + 3 0.000002 if a:arg + 2 0.000008 echom a:arg + 2 0.000001 else + 1 0.000008 echom a:arg + 1 0.000001 endif + +FUNCTIONS SORTED ON TOTAL TIME +count total (s) self (s) function + 3 0.000027 2() + +FUNCTIONS SORTED ON SELF TIME +count total (s) self (s) function + 3 0.000027 2() + diff --git a/tests/t.vim b/tests/t.vim new file mode 100644 index 00000000..ad3ba426 --- /dev/null +++ b/tests/t.vim @@ -0,0 +1,17 @@ +let prof_fname = get(g:, 'prof_fname') +exe 'profile start '.prof_fname +profile! file tests/test_plugin/** + +set runtimepath+=$PWD/test_plugin + +" call test_plugin#func1(1) + +" autocmd SourceCmd * echom 'SOURCE: '.expand('') +" function! s:dump_anonymous_functions() +" redir => output +" +" redir END +" endfunction +" autocmd VimLeave * +" +" augroup END diff --git a/tests/test__compat.py b/tests/test__compat.py new file mode 100644 index 00000000..e9d01e31 --- /dev/null +++ b/tests/test__compat.py @@ -0,0 +1,6 @@ +def test_shell_quote(): + from covimerage._compat import shell_quote + + assert shell_quote('') == "''" + assert shell_quote('word') == 'word' + assert shell_quote('two words') == "'two words'" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..efa1dce6 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,605 @@ +try: + from StringIO import StringIO +except ImportError: + from io import StringIO +import os +from subprocess import call +import sys + +import pytest + +from covimerage import DEFAULT_COVERAGE_DATA_FILE, cli +from covimerage.__version__ import __version__ + + +def test_dunder_main_run(capfd): + assert call([sys.executable, '-m', 'covimerage']) == 0 + out, err = capfd.readouterr() + assert out.startswith('Usage: __main__') + + +def test_dunder_main_run_help(capfd): + assert call([sys.executable, '-m', 'covimerage', '--version']) == 0 + out, err = capfd.readouterr() + assert out == 'covimerage, version %s\n' % __version__ + + +def test_cli(runner, tmpdir): + with tmpdir.as_cwd() as old_dir: + with pytest.raises(SystemExit) as excinfo: + cli.write_coverage([os.path.join( + str(old_dir), 'tests/fixtures/conditional_function.profile')]) + assert excinfo.value.code == 0 + assert os.path.exists(DEFAULT_COVERAGE_DATA_FILE) + + result = runner.invoke(cli.main, ['write_coverage', '/does/not/exist']) + assert result.output.splitlines()[-1].startswith( + 'Error: Invalid value for "profile_file": Could not open file:') + assert result.exit_code == 2 + + +@pytest.mark.parametrize('arg', ('-V', '--version')) +def test_cli_version(arg, runner): + result = runner.invoke(cli.main, [arg]) + assert result.output == 'covimerage, version %s\n' % __version__ + assert result.exit_code == 0 + + +@pytest.mark.parametrize('arg', ('-h', '--help')) +def test_cli_help(arg, runner): + result = runner.invoke(cli.main, [arg]) + assert result.output.startswith('Usage:') + assert result.exit_code == 0 + + result = runner.invoke(cli.main, ['write_coverage', arg]) + assert result.output.startswith('Usage:') + assert result.exit_code == 0 + + +def test_cli_run_with_args_fd(capfd): + ret = call(['covimerage', 'run', '--profile-file', '/doesnotexist', + 'echo', '--', '--no-profile', '%sMARKER']) + out, err = capfd.readouterr() + lines = err.splitlines() + assert lines == [ + "Running cmd: echo -- --no-profile %sMARKER --cmd 'profile start /doesnotexist' --cmd 'profile! file ./*' (in {})".format(os.getcwd()), # noqa: E501 + 'Error: The profile file (/doesnotexist) has not been created.'] + assert ret == 1 + + +def test_cli_run_subprocess_exception(runner, mocker): + result = runner.invoke(cli.run, [os.devnull]) + out = result.output.splitlines() + assert out[-1].startswith("Error: Failed to run ['/dev/null', '--cmd',") + assert out[-1].endswith("']: [Errno 13] Permission denied") + assert result.exit_code == 1 + + +def test_cli_run_args(runner, mocker, devnull, tmpdir): + m = mocker.patch('subprocess.call') + m.return_value = 1 + result = runner.invoke( + cli.run, ['--no-wrap-profile', 'printf', '--headless']) + assert m.call_args[0] == (['printf', '--headless'],) + assert result.output.splitlines() == [ + 'Running cmd: printf --headless (in %s)' % os.getcwd(), + 'Error: Command exited non-zero: 1.'] + assert result.exit_code == 1 + + m.return_value = 2 + result = runner.invoke( + cli.run, ['--no-wrap-profile', '--', 'printf', '--headless']) + assert m.call_args[0] == (['printf', '--headless'],) + assert result.output.splitlines() == [ + 'Running cmd: printf --headless (in %s)' % os.getcwd(), + 'Error: Command exited non-zero: 2.'] + assert result.exit_code == 2 + + m.return_value = 3 + result = runner.invoke( + cli.run, ['--no-wrap-profile', 'printf', '--', '--headless']) + assert m.call_args[0] == (['printf', '--', '--headless'],) + assert result.output.splitlines() == [ + 'Running cmd: printf -- --headless (in %s)' % os.getcwd(), + 'Error: Command exited non-zero: 3.'] + assert result.exit_code == 3 + + result = runner.invoke(cli.run, [ + '--no-wrap-profile', '--no-report', '--profile-file', devnull.name, + '--no-write-data', 'printf', '--', '--headless']) + assert m.call_args[0] == (['printf', '--', '--headless'],) + assert result.output.splitlines() == [ + 'Running cmd: printf -- --headless (in %s)' % os.getcwd(), + 'Error: Command exited non-zero: 3.'] + + result = runner.invoke(cli.run, [ + '--no-wrap-profile', '--no-report', '--profile-file', devnull.name, + '--source', devnull.name, + '--write-data', 'printf', '--', '--headless']) + assert m.call_args[0] == (['printf', '--', '--headless'],) + assert result.output.splitlines() == [ + 'Running cmd: printf -- --headless (in %s)' % os.getcwd(), + 'Parsing profile file /dev/null.', + 'Not writing coverage file: no data to report!', + 'Error: Command exited non-zero: 3.'] + + # Write data with non-sources only. + f = StringIO() + m.return_value = 0 + with tmpdir.as_cwd() as old_dir: + profile_file = str(old_dir.join( + 'tests/fixtures/conditional_function.profile')) + result = runner.invoke(cli.run, [ + '--no-wrap-profile', '--no-report', + '--profile-file', profile_file, + '--write-data', '--data-file', f, + 'printf', '--', '--headless']) + assert m.call_args[0] == (['printf', '--', '--headless'],) + out = result.output.splitlines() + assert out == [ + 'Running cmd: printf -- --headless (in %s)' % str(tmpdir), + 'Parsing profile file %s.' % profile_file, + 'Ignoring non-source: %s' % str(tmpdir.join( + 'tests/test_plugin/conditional_function.vim')), + 'Not writing coverage file: no data to report!'] + f.seek(0) + assert f.read() == '' + + profiled_file = 'tests/test_plugin/conditional_function.vim' + profiled_file_content = open(profiled_file, 'r').read() + with tmpdir.as_cwd() as old_dir: + profile_file = str(old_dir.join( + 'tests/fixtures/conditional_function.profile')) + tmpdir.join(profiled_file).write(profiled_file_content, ensure=True) + tmpdir.join('not-profiled.vim').write('') + tmpdir.join('not-a-vim-file').write('') + result = runner.invoke(cli.run, [ + '--no-wrap-profile', '--no-report', + '--profile-file', profile_file, + '--write-data', '--data-file', f, + 'printf', '--', '--headless']) + assert m.call_args[0] == (['printf', '--', '--headless'],) + assert result.output.splitlines() == [ + 'Running cmd: printf -- --headless (in %s)' % str(tmpdir), + 'Parsing profile file %s.' % profile_file, + 'Writing coverage file %r.' % f] + f.seek(0) + + m.exit_code == 0 + from covimerage.coveragepy import CoverageWrapper + cov = CoverageWrapper(data_file=f) + expected = { + str(tmpdir.join('not-profiled.vim')): [], + str(tmpdir.join('tests/test_plugin/conditional_function.vim')): [ + 3, 8, 9, 11, 13, 14, 15, 17, 23]} + assert cov.lines == expected + + +@pytest.mark.parametrize('with_append', (True, False)) +def test_cli_run_can_skip_writing_data(with_append, runner, tmpdir): + profiled_file = 'tests/test_plugin/conditional_function.vim' + profiled_file_content = open(profiled_file, 'r').read() + with tmpdir.as_cwd() as old_dir: + profile_file = str(old_dir.join( + 'tests/fixtures/conditional_function.profile')) + tmpdir.join(profiled_file).write(profiled_file_content, ensure=True) + args = ['--no-wrap-profile', '--profile-file', profile_file, + '--no-write-data', 'printf', '--', '--headless'] + if with_append: + args.insert(0, '--append') + result = runner.invoke(cli.run, args) + assert result.output.splitlines() == [ + 'Running cmd: printf -- --headless (in %s)' % str(tmpdir), + 'Parsing profile file %s.' % profile_file, + 'Name Stmts Miss Cover', + '----------------------------------------------------------------', + 'tests/test_plugin/conditional_function.vim 13 5 62%'] + assert not tmpdir.join(DEFAULT_COVERAGE_DATA_FILE).exists() + + +def test_cli_run_report_fd(capfd, tmpdir): + profile_fname = 'tests/fixtures/conditional_function.profile' + with open(profile_fname, 'r') as f: + profile_lines = f.readlines() + profile_lines[0] = 'SCRIPT tests/test_plugin/conditional_function.vim\n' + + tmp_profile_fname = str(tmpdir.join('tmp.profile')) + with open(tmp_profile_fname, 'w') as f: + f.writelines(profile_lines) + data_file = str(tmpdir.join('datafile')) + args = ['--no-wrap-profile', '--profile-file', tmp_profile_fname, + '--source', 'tests/test_plugin/conditional_function.vim', + '--data-file', data_file, 'true'] + exit_code = call(['covimerage', 'run'] + args) + out, err = capfd.readouterr() + assert exit_code == 0, err + assert out.splitlines() == [ + 'Name Stmts Miss Cover', + '----------------------------------------------------------------', + 'tests/test_plugin/conditional_function.vim 13 5 62%'] + assert err.splitlines() == [ + 'Running cmd: true (in %s)' % str(os.getcwd()), + 'Parsing profile file %s.' % tmp_profile_fname, + 'Writing coverage file %s.' % data_file] + + # Same, but to some file. + ofname = str(tmpdir.join('ofname')) + exit_code = call(['covimerage', 'run', '--report-file', ofname] + args) + out, err = capfd.readouterr() + assert exit_code == 0, err + assert out == '' + assert err.splitlines() == [ + 'Running cmd: true (in %s)' % str(os.getcwd()), + 'Parsing profile file %s.' % tmp_profile_fname, + 'Writing coverage file %s.' % data_file] + assert open(ofname).read().splitlines() == [ + 'Name Stmts Miss Cover', + '----------------------------------------------------------------', + 'tests/test_plugin/conditional_function.vim 13 5 62%'] + + +def test_cli_call(capfd): + assert call(['covimerage', '--version']) == 0 + out, err = capfd.readouterr() + assert out == 'covimerage, version %s\n' % __version__ + + assert call(['covimerage', '--help']) == 0 + out, err = capfd.readouterr() + assert out.startswith('Usage:') + + assert call(['covimerage', 'file not found']) == 2 + out, err = capfd.readouterr() + err_lines = err.splitlines() + assert err_lines[0] == 'Usage: covimerage [OPTIONS] COMMAND [ARGS]...' + # click after 6.7 (9cfea14) includes: 'Try "covimerage --help" for help.' + assert err_lines[-2:] == [ + '', + 'Error: No such command "file not found".'] + assert out == '' + + assert call(['covimerage', 'write_coverage', 'file not found']) == 2 + out, err = capfd.readouterr() + err_lines = err.splitlines() + assert err_lines[-1] == ( + 'Error: Invalid value for "profile_file": Could not open file: file not found: No such file or directory') # noqa: E501 + assert out == '' + + +def test_cli_call_verbosity_fd(capfd): + assert call(['covimerage', 'write_coverage', os.devnull]) == 1 + out, err = capfd.readouterr() + assert out == '' + assert err.splitlines() == [ + 'Not writing coverage file: no data to report!', + 'Error: No data to report.'] + + assert call(['covimerage', '-v', 'write_coverage', os.devnull]) == 1 + out, err = capfd.readouterr() + assert out == '' + assert err.splitlines() == [ + 'Parsing file: /dev/null', + 'source_files: []', + 'Not writing coverage file: no data to report!', + 'Error: No data to report.'] + + assert call(['covimerage', '-vvvv', 'write_coverage', os.devnull]) == 1 + out, err = capfd.readouterr() + assert out == '' + assert err.splitlines() == [ + 'Parsing file: /dev/null', + 'source_files: []', + 'Not writing coverage file: no data to report!', + 'Error: No data to report.'] + + assert call(['covimerage', '-vq', 'write_coverage', os.devnull]) == 1 + out, err = capfd.readouterr() + assert out == '' + assert err.splitlines() == [ + 'Not writing coverage file: no data to report!', + 'Error: No data to report.'] + + assert call(['covimerage', '-qq', 'write_coverage', os.devnull]) == 1 + out, err = capfd.readouterr() + assert out == '' + assert err == 'Error: No data to report.\n' + + +def test_cli_writecoverage_without_data(runner): + result = runner.invoke(cli.main, ['write_coverage', os.devnull]) + assert result.output == '\n'.join([ + 'Not writing coverage file: no data to report!', + 'Error: No data to report.', + '']) + assert result.exit_code == 1 + + +def test_cli_writecoverage_datafile(runner): + from covimerage.coveragepy import CoverageWrapper + + f = StringIO() + result = runner.invoke(cli.main, ['write_coverage', '--data-file', f, + 'tests/fixtures/conditional_function.profile']) + assert result.output == '\n'.join([ + 'Writing coverage file %s.' % f, + '']) + assert result.exit_code == 0 + + f.seek(0) + cov = CoverageWrapper(data_file=f) + assert cov.lines == { + os.path.abspath('tests/test_plugin/conditional_function.vim'): [ + 3, 8, 9, 11, 13, 14, 15, 17, 23]} + + +def test_cli_writecoverage_source(runner): + from covimerage.coveragepy import CoverageWrapper + + f = StringIO() + result = runner.invoke(cli.main, [ + 'write_coverage', '--data-file', f, '--source', '.', + 'tests/fixtures/conditional_function.profile']) + assert result.output == '\n'.join([ + 'Writing coverage file %s.' % f, + '']) + assert result.exit_code == 0 + + f.seek(0) + cov = CoverageWrapper(data_file=f) + assert cov.lines[ + os.path.abspath('tests/test_plugin/conditional_function.vim')] == [ + 3, 8, 9, 11, 13, 14, 15, 17, 23] + assert cov.lines[ + os.path.abspath('tests/test_plugin/autoload/test_plugin.vim')] == [] + + +def test_coverage_plugin_for_annotate_merged_conditionals(runner, capfd, + tmpdir): + tmpfile = str(tmpdir.join('.coverage')) + result = runner.invoke(cli.main, [ + 'write_coverage', '--data-file', tmpfile, + 'tests/fixtures/merged_conditionals-0.profile', + 'tests/fixtures/merged_conditionals-1.profile', + 'tests/fixtures/merged_conditionals-2.profile']) + assert result.output == '\n'.join([ + 'Writing coverage file %s.' % tmpfile, + '']) + assert result.exit_code == 0 + + coveragerc = str(tmpdir.join('.coveragerc')) + with open(coveragerc, 'w') as f: + f.write('[run]\nplugins = covimerage') + + exit_code = call(['env', 'COVERAGE_FILE=%s' % tmpfile, + 'coverage', 'annotate', '--rcfile', coveragerc, + '--directory', str(tmpdir)]) + out, err = capfd.readouterr() + assert exit_code == 0, (err, out) + ann_fname = 'tests_test_plugin_merged_conditionals_vim,cover' + annotated_lines = tmpdir.join(ann_fname).read().splitlines() + assert annotated_lines == [ + '> " Generate profile output for merged profiles.', + "> let cond = get(g:, 'test_conditional', 0)", + ' ', + '> if cond == 1', + '> let foo = 1', + '> elseif cond == 2', + '> let foo = 2', + '> elseif cond == 3', + '! let foo = 3', + '! else', + "> let foo = 'else'", + '> endif', + ' ', + '> function F(...)', + '> if a:1 == 1', + '> let foo = 1', + '> elseif a:1 == 2', + '> let foo = 2', + '> elseif a:1 == 3', + '! let foo = 3', + '! else', + "> let foo = 'else'", + '> endif', + '> endfunction', + ' ', + '> call F(cond)', + ] + + +def test_report_profile_or_data_file(runner, tmpdir): + from covimerage.cli import DEFAULT_COVERAGE_DATA_FILE + + result = runner.invoke(cli.main, [ + 'report', '--data-file', '/does/not/exist']) + assert result.output.splitlines()[-1] == \ + 'Error: Invalid value for "--data-file": Could not open file: /does/not/exist: No such file or directory' # noqa: E501 + assert result.exit_code == 2 + + result = runner.invoke(cli.main, [ + 'report', '--data-file', os.devnull]) + cov_exc = 'CoverageException("Doesn\'t seem to be a coverage.py data file",)' # noqa: E501 + assert result.output.splitlines()[-1] == \ + 'Error: Coverage could not read data_file: /dev/null (%s)' % cov_exc + assert result.exit_code == 1 + + with tmpdir.as_cwd(): + result = runner.invoke(cli.main, ['report']) + assert result.output.splitlines()[-1] == \ + 'Error: Invalid value for "--data-file": Could not open file: %s: No such file or directory' % DEFAULT_COVERAGE_DATA_FILE # noqa: E501 + assert result.exit_code == 2 + + result = runner.invoke(cli.main, ['report', '/does/not/exist']) + assert result.output.splitlines()[-1] == \ + 'Error: Invalid value for "profile_file": Could not open file: /does/not/exist: No such file or directory' # noqa: E501 + assert result.exit_code == 2 + + result = runner.invoke(cli.main, [ + 'report', 'tests/fixtures/merged_conditionals-0.profile']) + assert result.output.splitlines() == [ + 'Name Stmts Miss Cover', + '---------------------------------------------------------------', + 'tests/test_plugin/merged_conditionals.vim 19 12 37%'] + assert result.exit_code == 0 + + +def test_report_source(runner, tmpdir, devnull): + result = runner.invoke(cli.main, [ + 'report', '--source', '.', '/does/not/exist']) + assert result.output.splitlines()[-1] == \ + 'Error: Invalid value for "profile_file": Could not open file: /does/not/exist: No such file or directory' # noqa: E501 + assert result.exit_code == 2 + + result = runner.invoke(cli.main, [ + 'report', '--source', '.', devnull.name]) + out = result.output.splitlines() + assert any(l.startswith('tests/test_plugin/vimrc') # pragma: no branch + for l in out) + testplugin_fname = 'tests/test_plugin/autoload/test_plugin.vim' + assert any(testplugin_fname in l for l in out) # pragma: no branch + assert out[-1].startswith('TOTAL') + assert out[-1].endswith(' 0%') + assert result.exit_code == 0 + + result = runner.invoke(cli.main, [ + 'report', devnull.name, '--source', '.']) + out = result.output.splitlines() + assert any(testplugin_fname in l for l in out) # pragma: no branch + assert out[-1].startswith('TOTAL') + assert out[-1].endswith(' 0%') + assert result.exit_code == 0 + + result = runner.invoke(cli.main, [ + 'report', '--source', '.']) + out = result.output.splitlines() + assert out[-1] == 'Error: --source can only be used with PROFILE_FILE.' + assert result.exit_code == 2 + + result = runner.invoke(cli.main, [ + 'report', '--source', 'tests/test_plugin/merged_conditionals.vim', + 'tests/fixtures/merged_conditionals-0.profile']) + assert result.output.splitlines() == [ + 'Name Stmts Miss Cover', + '---------------------------------------------------------------', + 'tests/test_plugin/merged_conditionals.vim 19 12 37%'] + assert result.exit_code == 0 + + +def test_cli_xml(runner, tmpdir): + """Smoke test for the xml command.""" + result = runner.invoke(cli.main, [ + 'write_coverage', '--data-file', str(tmpdir.join('.coverage')), + 'tests/fixtures/merged_conditionals-0.profile']) + with tmpdir.as_cwd(): + result = runner.invoke(cli.main, ['xml', '--data-file', '.coverage']) + assert result.exit_code == 0 + + with open('coverage.xml') as f: + xml = f.read() + assert 'filename="%s/tests/test_plugin/merged_conditionals.vim' % ( + os.getcwd()) in xml + + +def test_run_handles_exit_code_from_python_fd(capfd): + ret = call(['covimerage', 'run', + 'python', '-c', 'print("output"); import sys; sys.exit(42)']) + out, err = capfd.readouterr() + assert 'Error: Command exited non-zero: 42.' in err.splitlines() + assert out == 'output\n' + assert ret == 42 + + +def test_run_handles_exit_code_from_python_pty_fd(capfd): + ret = call(['covimerage', 'run', '--profile-file', '/not/used', + 'python', '-c', + "import pty; pty.spawn(['/bin/sh', '-c', " + "'printf output; exit 42'])"]) + out, err = capfd.readouterr() + assert ('Error: The profile file (/not/used) has not been created.' in + err.splitlines()) + assert out == 'output' + assert ret == 1 + + +def test_run_append_with_empty_data(runner, tmpdir): + with tmpdir.as_cwd() as old_dir: + profile_file = str(old_dir.join( + 'tests/fixtures/conditional_function.profile')) + data_file = StringIO() + result = runner.invoke(cli.run, [ + '--append', '--no-wrap-profile', '--profile-file', profile_file, + '--data-file', data_file, 'printf', '--', '--headless']) + assert result.output.splitlines() == [ + 'Running cmd: printf -- --headless (in %s)' % str(tmpdir), + 'Parsing profile file %s.' % profile_file, + 'Error: Coverage could not read data_file: %r (CoverageException("Doesn\'t seem to be a coverage.py data file",))' % data_file, # noqa: E501 + ] + assert result.exit_code == 1 + + +def test_run_append_with_data(runner, tmpdir, covdata_empty): + profiled_file = 'tests/test_plugin/conditional_function.vim' + profiled_file_content = open(profiled_file, 'r').read() + tmpdir.join(profiled_file).write(profiled_file_content, ensure=True) + with tmpdir.as_cwd() as old_dir: + profile_file = str(old_dir.join( + 'tests/fixtures/conditional_function.profile')) + result = runner.invoke(cli.run, [ + '--append', '--no-wrap-profile', '--profile-file', profile_file, + 'printf', '--', '--headless']) + assert result.output.splitlines() == [ + 'Running cmd: printf -- --headless (in %s)' % str(tmpdir), + 'Parsing profile file %s.' % profile_file, + 'Writing coverage file .coverage.covimerage.', + 'Name Stmts Miss Cover', + '----------------------------------------------------------------', + 'tests/test_plugin/conditional_function.vim 13 5 62%'] + assert result.exit_code == 0 + + # The same again. + result = runner.invoke(cli.run, [ + '--append', '--no-wrap-profile', '--profile-file', profile_file, + 'printf', '--', '--headless']) + assert result.output.splitlines() == [ + 'Running cmd: printf -- --headless (in %s)' % str(tmpdir), + 'Parsing profile file %s.' % profile_file, + 'Writing coverage file .coverage.covimerage.', + 'Name Stmts Miss Cover', + '----------------------------------------------------------------', + 'tests/test_plugin/conditional_function.vim 13 5 62%'] + assert result.exit_code == 0 + + # Append another profile. + another_profiled_file = 'tests/test_plugin/merged_conditionals.vim' + tmpdir.join(another_profiled_file).write( + old_dir.join(another_profiled_file).read(), ensure=True) + tmpdir.join(profiled_file).write(profiled_file_content, ensure=True) + profile_file = str(old_dir.join( + 'tests/fixtures/merged_conditionals-0.profile')) + tmpdir.join(profiled_file).write(profiled_file_content, ensure=True) + result = runner.invoke(cli.run, [ + '--append', '--no-wrap-profile', '--profile-file', profile_file, + 'printf', '--', '--headless']) + assert result.output.splitlines() == [ + 'Running cmd: printf -- --headless (in %s)' % str(tmpdir), + 'Parsing profile file %s.' % profile_file, + 'Writing coverage file .coverage.covimerage.', + 'Name Stmts Miss Cover', + '----------------------------------------------------------------', + 'tests/test_plugin/conditional_function.vim 13 5 62%', + 'tests/test_plugin/merged_conditionals.vim 19 12 37%', + '----------------------------------------------------------------', + 'TOTAL 32 17 47%'] + assert result.exit_code == 0 + + +def test_run_report_without_data(tmpdir, runner, devnull): + with tmpdir.as_cwd(): + result = runner.invoke(cli.run, [ + '--no-write-data', '--no-wrap-profile', + '--profile-file', devnull.name, + 'printf', '--', '--headless']) + assert result.output.splitlines() == [ + 'Running cmd: printf -- --headless (in %s)' % str(tmpdir), + 'Parsing profile file %s.' % devnull.name, + 'Error: No data to report.'] + assert result.exit_code == 1 diff --git a/tests/test_coveragepy.py b/tests/test_coveragepy.py new file mode 100644 index 00000000..71d05b8b --- /dev/null +++ b/tests/test_coveragepy.py @@ -0,0 +1,181 @@ +try: + from StringIO import StringIO +except ImportError: + from io import StringIO +import sys + +import attr +import coverage +import pytest + + +def test_filereporter(): + from covimerage.coveragepy import FileReporter + + f = FileReporter('/doesnotexist') + assert repr(f) == "" + + with pytest.raises(coverage.misc.NoSource) as excinfo: + f.lines() + assert excinfo.value.args == ( + "[Errno 2] No such file or directory: '/doesnotexist'",) + + +def test_filereporter_source_handles_latin1(tmpdir): + from covimerage.coveragepy import FileReporter + + with tmpdir.as_cwd(): + with open('iso.txt', 'wb') as f: + f.write(b'Hellstr\xf6m') + with open('utf8.txt', 'wb') as f: + f.write(b'Hellstr\xc3\xb6m') + + assert FileReporter('iso.txt').source().encode( + 'utf-8') == b'Hellstr\xc3\xb6m' + assert FileReporter('iso.txt').source().encode( + 'utf-8') == b'Hellstr\xc3\xb6m' + + +@pytest.mark.skipif(sys.version_info[0] == 3 and sys.version_info[1] < 5, + reason='Failed to patch open with py33/py34.') +def test_filereporter_source_exception(mocker, devnull): + from covimerage.coveragepy import CoverageWrapperException, FileReporter + + class CustomException(Exception): + pass + + m = mocker.mock_open() + m.return_value.read.side_effect = CustomException + mocker.patch('covimerage.coveragepy.open', m) + + f = FileReporter(devnull.name) + with pytest.raises(CoverageWrapperException) as excinfo: + f.source() + + assert isinstance(excinfo.value.orig_exc, CustomException) + + +@pytest.fixture +def coverage_fileobj(): + return StringIO('\n'.join(['!coverage.py: This is a private format, don\'t read it directly!{"lines":{"/test_plugin/conditional_function.vim":[17,3,23,8,9,11,13,14,15]},"file_tracers":{"/test_plugin/conditional_function.vim":"covimerage.CoveragePlugin"}}'])) # noqa: E501 + + +def test_coveragedata(coverage_fileobj): + import coverage + from covimerage.coveragepy import CoverageData, CoverageWrapperException + + with pytest.raises(TypeError) as excinfo: + CoverageData(data_file='foo', cov_data='bar') + assert excinfo.value.args == ( + 'data and data_file are mutually exclusive.',) + + data = CoverageData() + assert isinstance(data.cov_data, coverage.data.CoverageData) + + with pytest.raises(TypeError) as excinfo: + CoverageData(cov_data='foo') + assert excinfo.value.args == ( + 'data needs to be of type coverage.data.CoverageData',) + + with pytest.raises(CoverageWrapperException) as excinfo: + CoverageData(data_file='/does/not/exist') + assert excinfo.value.args == ( + 'Coverage could not read data_file: /does/not/exist',) + assert isinstance(excinfo.value.orig_exc, coverage.misc.CoverageException) + + f = StringIO() + with pytest.raises(CoverageWrapperException) as excinfo: + CoverageData(data_file=f) + e = excinfo.value + assert isinstance(e.orig_exc, coverage.misc.CoverageException) + assert e.message == 'Coverage could not read data_file: %s' % f + assert e.format_message() == '%s (%r)' % (e.message, e.orig_exc) + assert str(e) == e.format_message() + assert repr(e) == 'CoverageWrapperException(message=%r, orig_exc=%r)' % ( + e.message, e.orig_exc) + + cov_data = CoverageData(data_file=coverage_fileobj) + with pytest.raises(attr.exceptions.FrozenInstanceError): + cov_data.data = 'foo' + + assert cov_data.lines == { + '/test_plugin/conditional_function.vim': [ + 3, 8, 9, 11, 13, 14, 15, 17, 23]} + + +def test_coveragedata_empty(covdata_empty): + from covimerage.coveragepy import CoverageData + + f = StringIO() + data = CoverageData() + data.cov_data.write_fileobj(f) + f.seek(0) + assert f.read() == covdata_empty + + +def test_coveragewrapper(coverage_fileobj, devnull): + import coverage + from covimerage.coveragepy import ( + CoverageData, CoverageWrapper, CoverageWrapperException) + + cov_data = CoverageWrapper() + assert cov_data.lines == {} + assert isinstance(cov_data.data, CoverageData) + + cov_data = CoverageWrapper(data=coverage.data.CoverageData()) + assert cov_data.lines == {} + assert isinstance(cov_data.data, CoverageData) + + with pytest.raises(TypeError): + CoverageWrapper(data_file='foo', data='bar') + + with pytest.raises(TypeError): + CoverageWrapper(data_file='foo', data=CoverageData()) + + cov = CoverageWrapper(data_file=coverage_fileobj) + with pytest.raises(attr.exceptions.FrozenInstanceError): + cov.data = 'foo' + + assert cov.lines == { + '/test_plugin/conditional_function.vim': [ + 3, 8, 9, 11, 13, 14, 15, 17, 23]} + + assert isinstance(cov._cov_obj, coverage.control.Coverage) + assert cov._cov_obj.data is cov.data.cov_data + + with pytest.raises(CoverageWrapperException) as excinfo: + CoverageWrapper(data_file=devnull.name) + assert excinfo.value.args == ( + 'Coverage could not read data_file: /dev/null',) + + f = StringIO() + with pytest.raises(CoverageWrapperException) as excinfo: + CoverageWrapper(data_file=f) + e = excinfo.value + assert isinstance(e.orig_exc, coverage.misc.CoverageException) + assert e.message == 'Coverage could not read data_file: %s' % f + assert e.format_message() == '%s (%r)' % (e.message, e.orig_exc) + assert str(e) == e.format_message() + assert repr(e) == 'CoverageWrapperException(message=%r, orig_exc=%r)' % ( + e.message, e.orig_exc) + + +def test_coveragewrapper_accepts_data(): + from covimerage.coveragepy import CoverageData, CoverageWrapper + + data = CoverageData() + cov = CoverageWrapper(data=data) + assert cov.data is data + + +def test_coveragewrapperexception(): + from covimerage.coveragepy import CoverageWrapperException + + assert CoverageWrapperException('foo').format_message() == 'foo' + + with pytest.raises(CoverageWrapperException) as excinfo: + try: + raise Exception('orig') + except Exception as orig_exc: + raise CoverageWrapperException('bar', orig_exc=orig_exc) + assert excinfo.value.format_message() == "bar (Exception('orig',))" diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 00000000..06535638 --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,19 @@ +import pytest + + +def test_logging_error_causes_exception(capfd): + from covimerage import LOGGER + + with pytest.raises(Exception) as excinfo: + LOGGER.info('Wrong:', 'no %s') + assert excinfo.value.args[0] == 'Internal logging error' + out, err = capfd.readouterr() + + lines = err.splitlines() + assert any((l.startswith('Traceback') for l in lines)) # pragma: no branch + + if not lines[-1].startswith('Logged from file test_logging.py, line '): + assert lines[-2:] == [ + "Message: 'Wrong:'", + "Arguments: ('no %s',)"] + assert 'TypeError: not all arguments converted during string formatting' in lines # noqa: E501 diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 00000000..9bb92dd2 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,424 @@ +import logging + +import coverage +import pytest + +from covimerage._compat import FileNotFoundError, StringIO + + +def test_main_import(): + from covimerage import __main__ # noqa: F401 + + +def test_profile_repr_lines(): + from covimerage import Line, Profile, Script + + with pytest.raises(TypeError): + Profile() + + p = Profile('profile-path') + s = Script('script-path') + p.scripts.append(s) + assert repr(p.lines) == '{%r: {}}' % s + assert repr(s) == "Script(path='script-path', sourced_count=None)" + + l = Line('line1') + s.lines[1] = l + assert repr(p.lines) == ('{%r: {1: %r}}' % (s, l)) + + +def test_profile_fname_or_fobj(caplog, devnull): + from covimerage import Profile + + with pytest.raises(FileNotFoundError) as excinfo: + Profile('/does/not/exist').parse() + assert str(excinfo.value) == \ + "[Errno 2] No such file or directory: '/does/not/exist'" + + with caplog.at_level(logging.NOTSET, logger='covimerage'): + Profile(devnull).parse() + msgs = [(r.levelname, r.message) for r in caplog.records] + assert msgs == [('DEBUG', 'Parsing file: /dev/null')] + + fileobj = StringIO('') + with caplog.at_level(logging.NOTSET, logger='covimerage'): + Profile(fileobj).parse() + msgs = [(r.levelname, r.message) for r in caplog.records] + assert msgs[-1] == ('DEBUG', 'Parsing file: %s' % fileobj) + assert len(msgs) == 2 + + +def test_parse_count_and_times(): + from covimerage import parse_count_and_times + + f = parse_count_and_times + assert f(' 1 0.000035 Foo') == (1, None, 0.000035) + assert f(' 1 0.000037 Foo') == (1, 0.000037, None) + assert f('') == (0, None, None) + + +def test_line(): + from covimerage import Line + + l = Line(' 1 0.000005 Foo') + assert repr(l) == "Line(line=' 1 0.000005 Foo', count=None, total_time=None, self_time=None)" # noqa + + +def test_profile_parse(): + from covimerage import Line, Profile + + fname = 'tests/fixtures/test_plugin.profile' + p = Profile(fname) + p.parse() + + assert len(p.scripts) == 1 + + script_fname = '/test_plugin/autoload/test_plugin.vim' + # assert script_fname in p.scripts + s = p.scripts[0] + assert s.path == script_fname + + assert len(s.lines) == 8 + assert s.lines == { + 1: Line(line='function! test_plugin#func1(a) abort', count=None, + total_time=None, self_time=None), + 2: Line(line=" echom 'func1'", count=1, total_time=None, + self_time=3.5e-05), + 3: Line(line='endfunction', count=None, total_time=None, + self_time=None), + 4: Line(line='', count=None, total_time=None, self_time=None), + 5: Line(line='function! test_plugin#func2(a) abort', count=1, + total_time=None, self_time=5e-06), + 6: Line(line=" echom 'func2'", count=0, total_time=None, + self_time=None), + 7: Line(line='endfunction', count=None, total_time=None, + self_time=None), + 8: Line(line='', count=None, total_time=None, self_time=None)} + + +def test_profile_parse_handles_cannot_open_file(caplog): + from covimerage import Profile + + file_object = StringIO('\n'.join([ + 'SCRIPT /tmp/nvimDG3tAV/801', + 'Sourced 1 time', + 'Total time: 0.046577', + ' Self time: 0.000136', + '', + 'count total (s) self (s)', + 'Cannot open file!', + ])) + p = Profile('fake') + p._parse(file_object) + + assert len(p.scripts) == 1 + s = p.scripts[0] + assert not s.lines + assert len(s.lines) == 0 + assert s.sourced_count == 1 + msgs = [r.message for r in caplog.records] + assert msgs == [ + 'Could not parse count/times from line: Cannot open file! (fake:7).'] + + +def test_find_func_in_source(): + from covimerage import Function, Profile, Script + + s = Script('fake') + s.parse_script_line(1, 'fun! Python_jump(mode, motion, flags) range') + s.parse_script_line(2, 'fu g:Foo()') + s.parse_script_line(3, 'fun\tsome#autoload()') + p = Profile('fake', scripts=[s]) + + f = p.find_func_in_source + + assert f(Function(name='247_Python_jump')) == (s, 1) + assert f(Function(name='Foo')) == (s, 2) + assert f(Function(name='some#autoload')) == (s, 3) + + +def test_profile_parse_dict_function(): + from covimerage import Profile + + fname = 'tests/fixtures/dict_function.profile' + p = Profile(fname) + p.parse() + + assert len(p.scripts) == 1 + + script_fname = '/test_plugin/dict_function.vim' + # assert script_fname in p.scripts + s = p.scripts[0] + assert s.path == script_fname + + assert s.dict_functions == {3} + assert s.mapped_dict_functions == {3} + + assert len(s.lines) == 13 + assert [l.line for l in s.lines.values()] == [ + '" Test parsing of dict function.', + 'let obj = {}', + 'function! obj.dict_function(arg) abort', + ' if a:arg', + ' echom a:arg', + ' else', + ' echom a:arg', + ' endif', + 'endfunction', + '', + 'call obj.dict_function(0)', + 'call obj.dict_function(1)', + 'call obj.dict_function(2)', + ] + + assert s.lines[4].count == 3 + assert s.lines[5].count == 2 + + +def test_profile_parse_dict_function_with_same_source(caplog): + from covimerage import Profile + + fname = 'tests/fixtures/dict_function_with_same_source.profile' + with caplog.at_level(logging.NOTSET, logger='covimerage'): + p = Profile(fname) + p.parse() + + assert len(p.scripts) == 1 + + script_fname = '/test_plugin/dict_function_with_same_source.vim' + s = p.scripts[0] + assert s.path == script_fname + + assert s.dict_functions == {3, 12} + assert s.mapped_dict_functions == {3, 12} + + N = None + assert [(l.count, l.line) for l in s.lines.values()] == [ + (N, '" Test parsing of dict function (with same source).'), + (1, 'let obj1 = {}'), + (1, 'function! obj1.dict_function(arg) abort'), + (1, ' if a:arg'), + (1, ' echom a:arg'), + (1, ' else'), + (0, ' echom a:arg'), + (0, ' endif'), + (N, 'endfunction'), + (N, ''), + (1, 'let obj2 = {}'), + (1, 'function! obj2.dict_function(arg) abort'), + (3, ' if a:arg'), + (2, ' echom a:arg'), + (2, ' else'), + (1, ' echom a:arg'), + (1, ' endif'), + (N, 'endfunction'), + (N, ''), + (1, 'call obj2.dict_function(0)'), + (1, 'call obj2.dict_function(1)'), + (1, 'call obj2.dict_function(2)'), + (1, 'call obj1.dict_function(3)')] + + msgs = [r.message for r in caplog.records] + assert "Found multiple sources for anonymous function 1 (/test_plugin/dict_function_with_same_source.vim:3, /test_plugin/dict_function_with_same_source.vim:12)." in msgs # noqa + assert "Found multiple sources for anonymous function 2 (/test_plugin/dict_function_with_same_source.vim:3, /test_plugin/dict_function_with_same_source.vim:12)." in msgs # noqa + assert "Found already mapped dict function again (/test_plugin/dict_function_with_same_source.vim:3)." in msgs # noqa + + +def test_profile_parse_dict_function_with_continued_lines(): + from covimerage import Profile + + fname = 'tests/fixtures/dict_function_with_continued_lines.profile' + p = Profile(fname) + p.parse() + + assert len(p.scripts) == 1 + s = p.scripts[0] + + assert s.dict_functions == {3} + assert s.mapped_dict_functions == {3} + + N = None + assert [(l.count, l.line) for l in s.lines.values()] == [ + (N, '" Test parsing of dict function with continued lines.'), + (1, 'let obj = {}'), + (1, 'function! obj.dict_function(arg) abort'), + (3, ' if a:arg'), + (2, ' echom'), + (N, ' \\ a:arg'), + (2, ' else'), + (1, ' echom a:arg'), + (1, ' endif'), + (N, 'endfunction'), + (N, ''), + (1, 'call obj.dict_function(0)'), + (1, 'call obj.dict_function(1)'), + (1, 'call obj.dict_function(1)')] + + +def test_profile_continued_lines(): + from covimerage import Profile + + fname = 'tests/fixtures/continued_lines.profile' + p = Profile(fname) + p.parse() + + assert len(p.scripts) == 1 + s = p.scripts[0] + + N = None + assert [(l.count, l.line) for l in s.lines.values()] == [ + (N, 'echom 1'), + (1, 'echom 2'), + (N, ' \\ 3'), + (1, 'echom 4')] + + +def test_conditional_functions(): + from covimerage import Profile + + fname = 'tests/fixtures/conditional_function.profile' + p = Profile(fname) + p.parse() + + assert len(p.scripts) == 1 + s = p.scripts[0] + + N = None + assert [(l.count, l.line) for l in s.lines.values()] == [ + (N, '" Test for detection of conditional functions.'), + (N, ''), + (1, 'if 0'), + (N, ' function Foo()'), + (N, ' return 1'), + (N, ' endfunction'), + (N, 'else'), + (1, ' function Foo()'), + (1, ' return 1'), + (N, ' endfunction'), + (1, 'endif'), + (N, ''), + (1, 'if Foo()'), + (1, ' function Bar()'), + (1, ' echom 1'), + (N, ' endfunction'), + (1, 'else'), + (N, ' function Bar()'), + (N, ' echom 1'), + (N, ' endfunction'), + (N, 'endif'), + (N, ''), + (1, 'call Bar()')] + + +def test_merged_profiles(): + from covimerage import MergedProfiles, Profile + + p1 = Profile('tests/fixtures/merge-1.profile') + p1.parse() + p2 = Profile('tests/fixtures/merge-2.profile') + p2.parse() + assert p1.scriptfiles == p2.scriptfiles + + m = MergedProfiles([p1, p2]) + + assert list(m.scripts) == p1.scripts + p2.scripts + assert m.scriptfiles == p1.scriptfiles + + N = None + s_fname = '/test_plugin/merged_profiles.vim' + assert [(l.count, l.line) for lnum, l in m.lines[s_fname].items()] == [ + (N, '" Generate profile output for merged profiles.'), + (N, '" Used merged_profiles-init.vim'), + (2, "if !exists('s:conditional')"), + (1, ' function! F()'), + (1, ' echom 1'), + (N, ' endfunction'), + (1, ' let s:conditional = 1'), + (1, ' echom 1'), + (1, ' call F()'), + (N, ' call NeomakeTestsProfileRestart()'), + (N, " exe 'source ' . expand('')"), + (1, 'else'), + (1, ' function! F()'), + (1, ' echom 2'), + (N, ' endfunction'), + (1, ' echom 2'), + (1, ' call F()'), + (1, 'endif'), + (N, '')] + + +def test_mergedprofiles_fixes_line_count(): + from covimerage import MergedProfiles, Profile + + fname = 'tests/fixtures/continued_lines.profile' + p = Profile(fname) + p.parse() + + script = p.scripts[0] + + N = None + assert [(l.count, l.line) for l in script.lines.values()] == [ + (N, 'echom 1'), + (1, 'echom 2'), + (N, ' \\ 3'), + (1, 'echom 4')] + + m = MergedProfiles([p]) + assert [(l.count, l.line) for l in m.lines[script.path].values()] == [ + (1, 'echom 1'), + (1, 'echom 2'), + (N, ' \\ 3'), + (1, 'echom 4')] + + +def test_merged_profiles_get_coveragepy_data(): + from covimerage import MergedProfiles + + m = MergedProfiles([]) + cov_data = m.get_coveragepy_data() + assert isinstance(cov_data, coverage.CoverageData) + assert repr(cov_data) == '' # noqa: E501 + + +def test_merged_profiles_write_coveragepy_data_handles_fname_and_fobj( + mocker, caplog): + from covimerage import MergedProfiles + + m = MergedProfiles([]) + mocked_data = mocker.Mock() + mocker.patch.object(m, 'get_coveragepy_data', return_value=mocked_data) + + m.write_coveragepy_data() + assert mocked_data.write_file.call_args_list == [mocker.call('.coverage')] + assert mocked_data.write_fileobj.call_args_list == [] + + mocked_data.reset_mock() + fileobj = StringIO() + m.write_coveragepy_data(data_file=fileobj) + assert mocked_data.write_file.call_args_list == [] + assert mocked_data.write_fileobj.call_args_list == [mocker.call(fileobj)] + msgs = [r.message for r in caplog.records] + assert msgs == [ + 'Writing coverage file .coverage.', + 'Writing coverage file %s.' % fileobj] + + +def test_mergedprofiles_caches_coveragepy_data(mocker): + from covimerage import MergedProfiles, Profile + + m = MergedProfiles([]) + spy = mocker.spy(m, '_get_coveragepy_data') + + m.get_coveragepy_data() + assert spy.call_count == 1 + m.get_coveragepy_data() + assert spy.call_count == 1 + + m.profiles += [Profile('foo')] + m.get_coveragepy_data() + assert spy.call_count == 2 + + m.profiles = [Profile('bar')] + m.get_coveragepy_data() + assert spy.call_count == 3 diff --git a/tests/test_plugin/autoload/test_plugin.vim b/tests/test_plugin/autoload/test_plugin.vim new file mode 100644 index 00000000..aabd1329 --- /dev/null +++ b/tests/test_plugin/autoload/test_plugin.vim @@ -0,0 +1,19 @@ +" Comment +" Another comment +let foo = 1 +let bar = foo + +function! test_plugin#func1(a) abort + echom 'func1' +endfunction + +function! test_plugin#func2(a) abort + echom 'func2.1' + + echom 'func2.2' +endfunction + +if foo == 2 + echom 'not covered' +endif + diff --git a/tests/test_plugin/conditional_function.vim b/tests/test_plugin/conditional_function.vim new file mode 100644 index 00000000..12c5ff9e --- /dev/null +++ b/tests/test_plugin/conditional_function.vim @@ -0,0 +1,23 @@ +" Test for detection of conditional functions. + +if 0 + function Foo() + return 1 + endfunction +else + function Foo() + return 1 + endfunction +endif + +if Foo() + function Bar() + echom 1 + endfunction +else + function Bar() + echom 1 + endfunction +endif + +call Bar() diff --git a/tests/test_plugin/continued_lines.vim b/tests/test_plugin/continued_lines.vim new file mode 100644 index 00000000..e4d50825 --- /dev/null +++ b/tests/test_plugin/continued_lines.vim @@ -0,0 +1,5 @@ +echom 1 +echom 2 + \ 3 +echom 4 + \ 5 diff --git a/tests/test_plugin/dict_function.vim b/tests/test_plugin/dict_function.vim new file mode 100644 index 00000000..3ebb730e --- /dev/null +++ b/tests/test_plugin/dict_function.vim @@ -0,0 +1,13 @@ +" Test parsing of dict function. +let obj = {} +function! obj.dict_function(arg) abort + if a:arg + echom a:arg + else + echom a:arg + endif +endfunction + +call obj.dict_function(0) +call obj.dict_function(1) +call obj.dict_function(2) diff --git a/tests/test_plugin/dict_function_used_twice.vim b/tests/test_plugin/dict_function_used_twice.vim new file mode 100644 index 00000000..d5529de2 --- /dev/null +++ b/tests/test_plugin/dict_function_used_twice.vim @@ -0,0 +1,24 @@ +" Test parsing of dict function that gets used twice. +let base = {} +function! base.base_function() abort dict + if 1 + echom 1 + else + echom 2 + endif +endfunction + +let obj1 = deepcopy(base) +function! obj1.func() abort + call self.base_function() +endfunction + +let obj2 = deepcopy(base) +function! obj2.func() abort + call self.base_function() +endfunction + +call obj1.func() +call obj2.func() +call obj1.base_function() +call obj2.base_function() diff --git a/tests/test_plugin/dict_function_with_continued_lines.vim b/tests/test_plugin/dict_function_with_continued_lines.vim new file mode 100644 index 00000000..e2a5a082 --- /dev/null +++ b/tests/test_plugin/dict_function_with_continued_lines.vim @@ -0,0 +1,14 @@ +" Test parsing of dict function with continued lines. +let obj = {} +function! obj.dict_function(arg) abort + if a:arg + echom + \ a:arg + else + echom a:arg + endif +endfunction + +call obj.dict_function(0) +call obj.dict_function(1) +call obj.dict_function(1) diff --git a/tests/test_plugin/dict_function_with_same_source.vim b/tests/test_plugin/dict_function_with_same_source.vim new file mode 100644 index 00000000..0721311f --- /dev/null +++ b/tests/test_plugin/dict_function_with_same_source.vim @@ -0,0 +1,23 @@ +" Test parsing of dict function (with same source). +let obj1 = {} +function! obj1.dict_function(arg) abort + if a:arg + echom a:arg + else + echom a:arg + endif +endfunction + +let obj2 = {} +function! obj2.dict_function(arg) abort + if a:arg + echom a:arg + else + echom a:arg + endif +endfunction + +call obj2.dict_function(0) +call obj2.dict_function(1) +call obj2.dict_function(2) +call obj1.dict_function(3) diff --git a/tests/test_plugin/merged_conditionals.vim b/tests/test_plugin/merged_conditionals.vim new file mode 100644 index 00000000..c276a678 --- /dev/null +++ b/tests/test_plugin/merged_conditionals.vim @@ -0,0 +1,26 @@ +" Generate profile output for merged profiles. +let cond = get(g:, 'test_conditional', 0) + +if cond == 1 + let foo = 1 +elseif cond == 2 + let foo = 2 +elseif cond == 3 + let foo = 3 +else + let foo = 'else' +endif + +function F(...) + if a:1 == 1 + let foo = 1 + elseif a:1 == 2 + let foo = 2 + elseif a:1 == 3 + let foo = 3 + else + let foo = 'else' + endif +endfunction + +call F(cond) diff --git a/tests/test_plugin/merged_profiles-init.vim b/tests/test_plugin/merged_profiles-init.vim new file mode 100644 index 00000000..0d668563 --- /dev/null +++ b/tests/test_plugin/merged_profiles-init.vim @@ -0,0 +1,15 @@ +let s:profile_count = 0 +function! NeomakeTestsProfileRestart() + let s:profile_count += 1 + if s:profile_count > 1 + echom 'stop' + profile stop + endif + let profile_file = printf('tests/fixtures/merge-%d.profile', s:profile_count) + exec 'profile start '.profile_file + exe 'profile! file ./*.vim' +endfunction + +call NeomakeTestsProfileRestart() + +source test_plugin/merged_profiles.vim diff --git a/tests/test_plugin/merged_profiles.vim b/tests/test_plugin/merged_profiles.vim new file mode 100644 index 00000000..f9882bf6 --- /dev/null +++ b/tests/test_plugin/merged_profiles.vim @@ -0,0 +1,19 @@ +" Generate profile output for merged profiles. +" Used merged_profiles-init.vim +if !exists('s:conditional') + function! F() + echom 1 + endfunction + let s:conditional = 1 + echom 1 + call F() + call NeomakeTestsProfileRestart() + exe 'source ' . expand('') +else + function! F() + echom 2 + endfunction + echom 2 + call F() +endif + diff --git a/tests/test_plugin/vimrc b/tests/test_plugin/vimrc new file mode 100644 index 00000000..4f0adefd --- /dev/null +++ b/tests/test_plugin/vimrc @@ -0,0 +1,12 @@ +" vimrc to run covimerage against the test plugin. + +let prof_fname = get(g:, 'prof_fname') +exe 'profile start '.prof_fname +profile! file ./** + +exe 'set runtimepath+='.expand(':h') + +set lazyredraw +set noloadplugins +set nomore +set noswapfile diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..117d5673 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,49 @@ +from covimerage._compat import StringIO + + +def test_build_vim_profile_args(devnull, tmpdir): + from covimerage.utils import build_vim_profile_args + + F = build_vim_profile_args + + fname = devnull.name + dname = str(tmpdir) + + assert F(fname, [fname]) == [ + '--cmd', 'profile start %s' % fname, + '--cmd', 'profile! file %s' % fname] + assert F(fname, [dname]) == [ + '--cmd', 'profile start %s' % fname, + '--cmd', 'profile! file %s/*' % dname] + assert F(fname, [fname, dname]) == [ + '--cmd', 'profile start %s' % fname, + '--cmd', 'profile! file %s' % fname, + '--cmd', 'profile! file %s/*' % dname] + + +def test_get_fname_and_fobj_and_str(devnull): + from covimerage.utils import get_fname_and_fobj_and_str + + F = get_fname_and_fobj_and_str + assert F('foo') == ('foo', None, 'foo') + assert F(None) == (None, None, 'None') + assert F(devnull) == ('/dev/null', devnull, '/dev/null') + s = StringIO('') + assert F(s) == (None, s, str(s)) + + +def test_is_executable_filename(): + from covimerage.utils import is_executable_filename + + assert is_executable_filename('foo.vim') + assert is_executable_filename('foo.nvim') + assert is_executable_filename('vimrc') + assert is_executable_filename('.vimrc') + assert is_executable_filename('minimal.vimrc') + assert is_executable_filename('another-minimal.vimrc') + assert is_executable_filename('another.minimal.vimrc') + assert is_executable_filename('foo.bar.vim') + + assert not is_executable_filename('.hidden.vim') + assert not is_executable_filename('foo.txt') + assert not is_executable_filename('vim') diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..e73fc992 --- /dev/null +++ b/tox.ini @@ -0,0 +1,28 @@ +[tox] +envlist = py{27,33,34,35,36}{,-dev}{,-coverage},checkqa + +[testenv] +extras = {env:TOX_EXTRAS:testing} +usedevelop = True +passenv = PYTEST_ADDOPTS +commands = {env:TOX_CMD:{env:COVERAGE_RUN:pytest}} {posargs} +setenv = + coverage: COVERAGE_RUN=pytest --cov + dev: TOX_EXTRAS=testing,dev +changedir = + integration: {envtmpdir} + +[testenv:checkqa] +extras = qa +commands = + flake8 --version + flake8 --show-source --statistics {posargs:covimerage tests} + +[testenv:coverage.pytest] +passenv = COVERAGE_FILE +commands = coverage run -m pytest {posargs} + +[testenv:coverage] +commands = + {[testenv:coverage.pytest]commands} + coverage report -m