diff --git a/setup.cfg b/setup.cfg index ff0f327..94ed931 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,9 +1,9 @@ [metadata] name = pytest-testmon -version = 1.3.7 +version = 1.4.0b1 license = AGPL author_email = tibor.arpas@infinit.sk -author = Tibor Arpas, Tomas Matlovic, Daniel Hahler, Martin Racak +author = Tibor Arpas, Tomas Matlovic description = selects tests affected by changed files and methods long_description = file: README.md long_description_content_type = text/markdown @@ -36,7 +36,7 @@ classifiers = python_requires = >=3.7, <3.12 install_requires = pytest>=5,<8 - coverage>=5,<7 + coverage>=6,<7 packages = testmon diff --git a/testmon/__init__.py b/testmon/__init__.py index e69de29..b6e968b 100644 --- a/testmon/__init__.py +++ b/testmon/__init__.py @@ -0,0 +1 @@ +"""PYTEST_DONT_REWRITE""" diff --git a/testmon/configure.py b/testmon/configure.py index c5d8ceb..fcdb290 100644 --- a/testmon/configure.py +++ b/testmon/configure.py @@ -1,18 +1,26 @@ import sys import re -from coverage.tracer import CTracer +try: + from coverage.tracer import CTracer as Tracer +except ImportError: + from coverage.pytracer import PyTracer as Tracer + + +def _is_dogfooding(coverage_stack): + return coverage_stack def _is_debugger(): - return sys.gettrace() and not isinstance(sys.gettrace(), CTracer) + return sys.gettrace() and not isinstance(sys.gettrace(), Tracer) def _is_coverage(): - return isinstance(sys.gettrace(), CTracer) + return False def _deactivate_on_xdist(options): + return False return ( options.get("numprocesses", False) or options.get("distload", False) @@ -54,6 +62,9 @@ def _get_nocollect_reasons( if options["testmon_nocollect"]: return [None] + if cov_plugin: + return [] + if coverage and not dogfooding: return ["coverage.py was detected and simultaneous collection is not supported"] @@ -141,6 +152,7 @@ def header_collect_select(config, coverage_stack, cov_plugin=None): options, debugger=_is_debugger(), coverage=_is_coverage(), + dogfooding=_is_dogfooding(coverage_stack), xdist=_deactivate_on_xdist(options), cov_plugin=cov_plugin, ) diff --git a/testmon/db.py b/testmon/db.py index 8020cba..d2b54df 100644 --- a/testmon/db.py +++ b/testmon/db.py @@ -3,13 +3,9 @@ import sqlite3 from collections import namedtuple +from functools import lru_cache -from testmon.process_code import ( - blob_to_checksums, - checksums_to_blob, - Fingerprint, - Fingerprints, -) +from testmon.process_code import blob_to_checksums, checksums_to_blob DATA_VERSION = 0 @@ -18,12 +14,26 @@ ) +class CachedProperty: + def __init__(self, func): + self.__doc__ = getattr(func, "__doc__") + self.func = func + + def __get__(self, obj, cls): + if obj is None: + return self + value = obj.__dict__[self.func.__name__] = self.func(obj) + return value + + class TestmonDbException(Exception): pass -def connect(datafile): - connection = sqlite3.connect(datafile) +def connect(datafile, readonly=False): + connection = sqlite3.connect( + f"file:{datafile}{'?mode=ro' if readonly else''}", uri=True + ) connection.execute("PRAGMA synchronous = OFF") connection.execute("PRAGMA foreign_keys = TRUE ") @@ -69,7 +79,7 @@ def update_mtimes(self, new_mtimes): "UPDATE fingerprint SET mtime=?, checksum=? WHERE id = ?", new_mtimes ) - def remove_unused_fingerprints(self): + def remove_unused_file_fps(self): with self.con as con: con.execute( """ @@ -80,7 +90,7 @@ def remove_unused_fingerprints(self): """ ) - def fetch_or_create_fingerprint(self, filename, mtime, checksum, method_checksums): + def fetch_or_create_file_fp(self, filename, mtime, checksum, method_checksums): cursor = self.con.cursor() try: cursor.execute( @@ -112,39 +122,41 @@ def fetch_or_create_fingerprint(self, filename, mtime, checksum, method_checksum self.update_mtimes([(mtime, checksum, fingerprint_id)]) return fingerprint_id - def insert_node_fingerprints( - self, nodeid, fingerprints, failed=False, duration=None - ): + def insert_node_file_fps(self, nodes_fingerprints, fa_durs=None): + if fa_durs is None: + fa_durs = {} with self.con as con: cursor = con.cursor() - cursor.execute( - """ - INSERT OR REPLACE INTO node - (environment, name, duration, failed) - VALUES (?, ?, ?, ?) - """, - ( - self.env, - nodeid, - duration, - 1 if failed else 0, - ), - ) - node_id = cursor.lastrowid - - # record: Fingerprint - for record in fingerprints: - fingerprint_id = self.fetch_or_create_fingerprint( - record["filename"], - record["mtime"], - record["checksum"], - checksums_to_blob(record["method_checksums"]), - ) - + for nodeid in nodes_fingerprints: + fingerprints = nodes_fingerprints[nodeid] + failed, duration = fa_durs.get(nodeid, (0, None)) cursor.execute( - "INSERT INTO node_fingerprint VALUES (?, ?)", - (node_id, fingerprint_id), + """ + INSERT OR REPLACE INTO node + (environment, name, duration, failed) + VALUES (?, ?, ?, ?) + """, + ( + self.env, + nodeid, + duration, + 1 if failed else 0, + ), ) + node_id = cursor.lastrowid + + for record in fingerprints: + fingerprint_id = self.fetch_or_create_file_fp( + record["filename"], + record["mtime"], + record["checksum"], + checksums_to_blob(record["method_checksums"]), + ) + + cursor.execute( + "INSERT INTO node_fingerprint VALUES (?, ?)", + (node_id, fingerprint_id), + ) def _fetch_data_version(self): con = self.con @@ -172,10 +184,16 @@ def _fetch_attribute(self, attribute, default=None, environment=None): def init_tables(self): connection = self.con - connection.execute("CREATE TABLE metadata (dataid TEXT PRIMARY KEY, data TEXT)") - - connection.execute( + connection.executescript( """ + CREATE TABLE metadata (dataid TEXT PRIMARY KEY, data TEXT); + + CREATE TABLE environment ( + id INTEGER PRIMARY KEY ASC, + name TEXT, + libraries TEXT + ); + CREATE TABLE node ( id INTEGER PRIMARY KEY ASC, environment TEXT, @@ -183,23 +201,15 @@ def init_tables(self): duration FLOAT, failed BIT, UNIQUE (environment, name) - ) - """ - ) + ); - connection.execute( - """ CREATE TABLE node_fingerprint ( node_id INTEGER, fingerprint_id INTEGER, FOREIGN KEY(node_id) REFERENCES node(id) ON DELETE CASCADE, FOREIGN KEY(fingerprint_id) REFERENCES fingerprint(id) - ) - """ - ) + ); - connection.execute( - """ CREATE table fingerprint ( id INTEGER PRIMARY KEY, @@ -208,7 +218,7 @@ def init_tables(self): mtime FLOAT, checksum TEXT, UNIQUE (filename, method_checksums) - ) + ); """ ) @@ -278,6 +288,7 @@ def all_nodes(self): ) } + @lru_cache(128) def filenames_fingerprints(self): cursor = self.con.execute( """ diff --git a/testmon/process_code.py b/testmon/process_code.py index b581e65..6efbb0a 100644 --- a/testmon/process_code.py +++ b/testmon/process_code.py @@ -14,22 +14,6 @@ from coverage.misc import NoSource -try: - from typing import TypedDict, List - - class Fingerprint(TypedDict): - # filename: str - mtime = None - checksum = None - method_checksums = None - fingerprint_id = None - -except ImportError: - Fingerprint = dict - -Fingerprints = [Fingerprint] - - CHECKUMS_ARRAY_TYPE = "i" @@ -135,7 +119,7 @@ def dump_and_block(self, node, end, name="unknown", into_block=False): fields = [] for field_name, field_value in ast.iter_fields(node): transform_into_block = ( - class_name in ("FunctionDef", "Module") + class_name in ("AsyncFunctionDef", "FunctionDef", "Module") ) and field_name == "body" fields.append( ( diff --git a/testmon/pytest_testmon.py b/testmon/pytest_testmon.py index f3d32df..cf63187 100644 --- a/testmon/pytest_testmon.py +++ b/testmon/pytest_testmon.py @@ -1,4 +1,5 @@ import os +import xmlrpc from collections import defaultdict from datetime import date, timedelta @@ -15,6 +16,7 @@ TestmonException, get_node_class_name, get_node_module_name, + nofili2fingerprints, LIBRARIES_KEY, ) from testmon import configure @@ -93,6 +95,7 @@ def pytest_addoption(parser): ) parser.addini("environment_expression", "environment expression", default="") + parser.addini("testmon_port", "testmon port", default="") def testmon_options(config): @@ -111,15 +114,36 @@ def init_testmon_data(config, read_source=True): environment = config.getoption("environment_expression") or eval_environment( config.getini("environment_expression") ) + remote_port = config.getini("testmon_port") + + rpc_client = None + if remote_port: + url = f"http://localhost:{remote_port}" + print(f"using remote server at {url}") + rpc_client = xmlrpc.client.ServerProxy(url, allow_none=True) + libraries = ", ".join(sorted(str(p) for p in pkg_resources.working_set)) testmon_data = TestmonData( - config.rootdir.strpath, environment=environment, libraries=libraries + config.rootdir.strpath, + environment=environment, + libraries=libraries, + rpc=rpc_client, ) if read_source: testmon_data.determine_stable() config.testmon_data = testmon_data +def parallelism_status(config): + if hasattr(config, "workerinput"): + return "worker" + + if getattr(config.option, "dist", "no") == "no": + return "single" + + return "controller" + + def register_plugins(config, should_select, should_collect, cov_plugin): if should_select or should_collect: config.pluginmanager.register( @@ -135,6 +159,7 @@ def register_plugins(config, should_select, should_collect, cov_plugin): cov_plugin=cov_plugin, ), config.testmon_data, + host=parallelism_status(config), ), "TestmonCollect", ) @@ -142,8 +167,15 @@ def register_plugins(config, should_select, should_collect, cov_plugin): def pytest_configure(config): coverage_stack = None + try: + from tmnet.testmon_core import Testmon as UberTestmon + + coverage_stack = UberTestmon.coverage_stack + except ImportError: + pass cov_plugin = None + cov_plugin = config.pluginmanager.get_plugin("_cov") message, should_collect, should_select = configure.header_collect_select( config, coverage_stack, cov_plugin=cov_plugin @@ -196,7 +228,10 @@ def pytest_report_header(config): ) if show_survey_notification: - message += "\nWe'd like to hear from testmon users! Please go to https://testmon.org/survey to leave feedback." + message += ( + "\nWe'd like to hear from testmon users! " + "🙏🙏 go to https://testmon.org/survey to leave feedback ✅❌" + ) return message @@ -239,13 +274,14 @@ def process_result(result): class TestmonCollect: - def __init__(self, testmon, testmon_data, is_worker=None): + def __init__(self, testmon, testmon_data, host="single", cov_plugin=None): self.testmon_data = testmon_data self.testmon = testmon - self._is_worker = is_worker + self._host = host self.reports = defaultdict(lambda: {}) self.raw_nodeids = [] + self.cov_plugin = cov_plugin @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_pycollect_makeitem(self, collector, name, obj): @@ -261,15 +297,17 @@ def pytest_pycollect_makeitem(self, collector, name, obj): @pytest.hookimpl(tryfirst=True) def pytest_collection_modifyitems(self, session, config, items): should_sync = not session.testsfailed + if getattr(config, "workerinput", {}).get("workerid", "gw0") != "gw0": + should_sync = False if should_sync: config.testmon_data.sync_db_fs_nodes(retain=set(self.raw_nodeids)) @pytest.hookimpl(hookwrapper=True) def pytest_runtest_protocol(self, item, nextitem): - self.testmon.start() + self.testmon.start_testmon(item.nodeid, nextitem.nodeid if nextitem else None) result = yield if result.excinfo and issubclass(result.excinfo[0], BaseException): - self.testmon.stop() + self.testmon.discard_current() @pytest.hookimpl(hookwrapper=True) def pytest_runtest_makereport(self, item, call): @@ -277,27 +315,43 @@ def pytest_runtest_makereport(self, item, call): if call.when == "teardown": report = result.get_result() - report.node_fingerprints = self.testmon.stop_and_process( - self.testmon_data, item.nodeid - ) + report.nodes_files_lines = self.testmon.get_batch_coverage_data() result.force_result(report) @pytest.hookimpl def pytest_runtest_logreport(self, report): + if self._host == "worker": + return self.reports[report.nodeid][report.when] = report - if report.when == "teardown" and hasattr(report, "node_fingerprints"): - self.testmon.save_fingerprints( - self.testmon_data, - report.nodeid, - report.node_fingerprints, - *process_result(self.reports[report.nodeid]), + if report.when == "teardown" and hasattr(report, "nodes_files_lines"): + nodes_fingerprints = nofili2fingerprints( + report.nodes_files_lines, self.testmon_data + ) + fa_durs = { + nodeid: process_result(self.reports[nodeid]) + for nodeid in nodes_fingerprints + } + self.testmon_data.db.insert_node_file_fps(nodes_fingerprints, fa_durs) + + def pytest_keyboard_interrupt(self, excinfo): + if self._host == "single": + + nodes_files_lines = self.testmon.get_batch_coverage_data() + + nodes_fingerprints = nofili2fingerprints( + nodes_files_lines, self.testmon_data ) - del self.reports[report.nodeid] + fa_durs = { + nodeid: process_result(self.reports[nodeid]) + for nodeid in nodes_fingerprints + } + self.testmon_data.db.insert_node_file_fps(nodes_fingerprints, fa_durs) + self.testmon.close() def pytest_sessionfinish(self, session): - if not self._is_worker: - self.testmon_data.db.remove_unused_fingerprints() + if self._host in ("single", "controller"): + self.testmon_data.db.remove_unused_file_fps() self.testmon.close() @@ -339,6 +393,7 @@ def pytest_ignore_collect(self, path, config): strpath = os.path.relpath(path.strpath, config.rootdir.strpath) if strpath in self.deselected_files and self.config.testmon_config[2]: return True + return None @pytest.mark.trylast def pytest_collection_modifyitems(self, session, config, items): diff --git a/testmon/testmon_core.py b/testmon/testmon_core.py index 7d417b0..45e6c79 100644 --- a/testmon/testmon_core.py +++ b/testmon/testmon_core.py @@ -3,17 +3,13 @@ import random import sys import textwrap - -from typing import TypeVar +from functools import lru_cache from collections import defaultdict - -import coverage import pkg_resources +import pytest +from coverage import Coverage, CoverageData -from packaging import version - -from coverage import Coverage - +from testmon.db import CachedProperty from testmon import db from testmon.process_code import ( read_file_with_checksum, @@ -21,11 +17,11 @@ create_fingerprint, encode_lines, string_checksum, - Fingerprints, ) from testmon.process_code import Module -T = TypeVar("T") +TEST_BATCH_SIZE = 100 + LIBRARIES_KEY = "/libraries_checksum_testmon_name" @@ -37,18 +33,6 @@ def get_data_file_path(rootdir): return os.environ.get("TESTMON_DATAFILE", os.path.join(rootdir, DB_FILENAME)) -class CachedProperty: - def __init__(self, func): - self.__doc__ = getattr(func, "__doc__") - self.func = func - - def __get__(self, obj, cls): - if obj is None: - return self - value = obj.__dict__[self.func.__name__] = self.func(obj) - return value - - def _get_python_lib_paths(): res = [sys.prefix] for attr in ["exec_prefix", "real_prefix", "base_prefix"]: @@ -127,24 +111,19 @@ def split_filter(disk, function, records): return first, second -def get_measured_relfiles(rootdir, cov, test_file): +def get_measured_relfiles(rootdir, test_file, lines_data=None): files = {test_file: set([1])} - conf = cov.config - cov_data = cov.get_data() - for filename in cov_data.measured_files(): + for filename, lines in lines_data.items(): if not is_python_file(filename): continue - relfilename = os.path.relpath(filename, rootdir) - files[relfilename] = cov_data.lines(filename) - assert files[relfilename] is not None, ( - f"{filename} is in measured_files but wasn't measured! cov.config: " - f"{conf.config_files}, {conf._omit}, {conf._include}, {conf.source}" - ) + relfilename = cached_relpath(filename, rootdir) + if lines: + files[relfilename] = lines return files class TestmonData: - def __init__(self, rootdir="", environment=None, libraries=None): + def __init__(self, rootdir="", environment=None, libraries=None, rpc=None): self.environment = environment if environment else "default" self.rootdir = rootdir @@ -159,7 +138,10 @@ def __init__(self, rootdir="", environment=None, libraries=None): self.connection = None self.datafile = get_data_file_path(self.rootdir) - self.db = db.DB(self.datafile, self.environment) + if rpc: + self.db = rpc + else: + self.db = db.DB(self.datafile, self.environment) self.libraries_miss = set() self.unstable_nodeids = set() @@ -171,13 +153,9 @@ def close_connection(self): if self.connection: self.connection.close() - @CachedProperty - def filenames_fingerprints(self): - return self.db.filenames_fingerprints() - @property def all_files(self): - return {row["filename"] for row in self.filenames_fingerprints} + return {row["filename"] for row in self.db.filenames_fingerprints()} @CachedProperty def all_nodes(self): @@ -207,16 +185,17 @@ def sync_db_fs_nodes(self, retain): for nodeid in add: if is_python_file(home_file(nodeid)): - database.insert_node_fingerprints( - nodeid, - ( - { - "filename": home_file(nodeid), - "method_checksums": encode_lines(["0match"]), - "mtime": None, - "checksum": None, - }, - ), + database.insert_node_file_fps( + { + nodeid: ( + { + "filename": home_file(nodeid), + "method_checksums": encode_lines(["0match"]), + "mtime": None, + "checksum": None, + }, + ) + }, ) database.delete_nodes(list(set(self.all_nodes) - collected)) @@ -257,7 +236,7 @@ def run_filters(self, filenames_fingerprints): def determine_stable(self): - filenames_fingerprints = self.filenames_fingerprints + filenames_fingerprints = self.db.filenames_fingerprints() ( fingerprint_hits, @@ -304,6 +283,27 @@ def avg_durations(self): return durations +def nofili2fingerprints(nodes_files_lines, testmon_data): + + nodes_fingerprints = {} + for context in nodes_files_lines: + node_fingerprints = testmon_data.get_nodes_fingerprints( + nodes_files_lines[context] + ) + + node_fingerprints.append( + { + "filename": LIBRARIES_KEY, + "checksum": testmon_data.libraries, + "mtime": None, + "method_checksums": encode_lines([testmon_data.libraries]), + } + ) + + nodes_fingerprints[context] = node_fingerprints + return nodes_fingerprints + + def get_new_mtimes(filesystem, hits): try: @@ -330,73 +330,198 @@ def get_node_module_name(node_id): return node_id.split("::")[0] +@lru_cache(1000) +def cached_relpath(path, basepath): + return os.path.relpath(path, basepath) + + class Testmon: coverage_stack = [] def __init__(self, rootdir="", testmon_labels=None, cov_plugin=None): + try: + from testmon.testmon_core import Testmon as UberTestmon + + Testmon.coverage_stack = UberTestmon.coverage_stack + except ImportError: + pass if testmon_labels is None: testmon_labels = {"singleprocess"} self.rootdir = rootdir self.testmon_labels = testmon_labels self.cov = None self.sub_cov_file = None - self.setup_coverage(not ("singleprocess" in testmon_labels), cov_plugin) + self.cov_plugin = cov_plugin + self._nodeid = None + self._next_nodeid = None + self.batched_nodeids = set() + self.check_stack = [] self.is_started = False + self._interrupted_at = None + + def start_cov(self): + if not self.cov._started: + Testmon.coverage_stack.append(self.cov) + self.cov.start() + + def stop_cov(self): + if self.cov is None: + return + assert self.cov in Testmon.coverage_stack + if Testmon.coverage_stack: + while Testmon.coverage_stack[-1] != self.cov: + cov = Testmon.coverage_stack.pop() + cov.stop() + if self.cov._started: + self.cov.stop() + Testmon.coverage_stack.pop() + if Testmon.coverage_stack: + Testmon.coverage_stack[-1].start() - def setup_coverage(self, subprocess, cov_plugin=None): + def setup_coverage(self, subprocess=False): params = { "include": [os.path.join(self.rootdir, "*")], "omit": _get_python_lib_paths(), } + if self.cov_plugin and self.cov_plugin._started: + cov = self.cov_plugin.cov_controller.cov + Testmon.coverage_stack.append(cov) + if cov.config.source: + params["include"] = list( + set( + [os.path.join(self.rootdir, "*")] + + [ + os.path.join(os.path.abspath(source), "*") + for source in cov.config.source + ] + ) + ) + elif cov.config.run_include: + params["include"] = list( + set(cov.config.run_include + params["include"]) + ) + if cov.config.branch: + raise TestmonException( + "testmon doesn't support simultaneous run with pytest-cov when " + "branch coverage is on. Please disable branch coverage." + ) + self.cov = Coverage(data_file=self.sub_cov_file, config_file=False, **params) self.cov._warn_no_data = False + if Testmon.coverage_stack: + Testmon.coverage_stack[-1].stop() - def start(self): - if self.is_started: - return - self.is_started = True + self.start_cov() - Testmon.coverage_stack.append(self.cov) - self.cov.erase() - self.cov.start() + class DummyFrame: + f_globals = None - def stop(self): - self.is_started = False - self.cov.stop() - if Testmon.coverage_stack: - Testmon.coverage_stack.pop() + @lru_cache(1000) + def filter_parent(self, parent_cov, filename): + check_include_omit_etc = parent_cov._inorout.check_include_omit_etc + return check_include_omit_etc(filename, self.DummyFrame) - def stop_and_process(self, testmon_data, nodeid): - self.stop() - if self.sub_cov_file: - self.cov.combine() - measured_files = get_measured_relfiles( - self.rootdir, self.cov, home_file(nodeid) - ) + def start_testmon(self, nodeid, next_nodeid=None): + self._next_nodeid = next_nodeid - node_fingerprints = testmon_data.get_nodes_fingerprints(measured_files) + self.batched_nodeids.add(nodeid) + if self.cov is None: + self.setup_coverage() - node_fingerprints.append( - { - "filename": LIBRARIES_KEY, - "checksum": testmon_data.libraries, - "mtime": None, - "method_checksums": encode_lines([testmon_data.libraries]), - } - ) - return node_fingerprints + self.start_cov() + self._nodeid = nodeid + self.cov.switch_context(nodeid) + self.check_stack = Testmon.coverage_stack.copy() + + def discard_current(self): + self._interrupted_at = self._nodeid + + def get_batch_coverage_data(self): + + if self.check_stack != Testmon.coverage_stack: + pytest.exit( + f"Exiting pytest!!!! This test corrupts Testmon.coverage_stack: " + f"{self._nodeid} {self.check_stack}, {Testmon.coverage_stack}", + returncode=3, + ) + + nodes_files_lines = {} + + if ( + len(self.batched_nodeids) >= TEST_BATCH_SIZE + or self._next_nodeid is None + or self._interrupted_at + ): + self.cov.stop() + nodes_files_lines, lines_data = self.get_nodes_files_lines( + dont_include=self._interrupted_at + ) + + if ( + len(Testmon.coverage_stack) > 1 + and Testmon.coverage_stack[-1] == self.cov + ): + filtered_lines_data = { + file: data + for file, data in lines_data.items() + if not self.filter_parent(Testmon.coverage_stack[-2], file) + } + Testmon.coverage_stack[-2].get_data().add_lines(filtered_lines_data) + + self.cov.erase() + self.cov.start() + self.batched_nodeids = set() + return nodes_files_lines + + def get_nodes_files_lines(self, dont_include): + cov_data = self.cov.get_data() + files = cov_data.measured_files() + nodes_files_lines = {} + files_lines = {} + for file in files: + + relfilename = cached_relpath(file, self.rootdir) + + contexts_by_lineno = cov_data.contexts_by_lineno(file) + + for lineno, contexts in contexts_by_lineno.items(): + for context in contexts: + nodes_files_lines.setdefault(context, {}).setdefault( + relfilename, set() + ).add(lineno) + files_lines.setdefault(file, set()).add(lineno) + nodes_files_lines.pop(dont_include, None) + self.batched_nodeids.discard(dont_include) + nodes_files_lines.pop("", None) + for nodeid in self.batched_nodeids: + if home_file(nodeid) not in nodes_files_lines.setdefault(nodeid, {}): + nodes_files_lines[nodeid].setdefault(home_file(nodeid), {1}) + return nodes_files_lines, files_lines @staticmethod def save_fingerprints(testmon_data, nodeid, node_fingerprints, failed, duration): - testmon_data.db.insert_node_fingerprints( + testmon_data.db.insert_node_file_fps( nodeid, node_fingerprints, failed, duration ) def close(self): + if self.cov is None: + return + assert self.cov in Testmon.coverage_stack + if Testmon.coverage_stack: + while Testmon.coverage_stack[-1] != self.cov: + cov = Testmon.coverage_stack.pop() + cov.stop() + if self.cov._started: + self.cov.stop() + Testmon.coverage_stack.pop() if self.sub_cov_file: os.remove(self.sub_cov_file + "_rc") os.environ.pop("COVERAGE_PROCESS_START", None) + self.cov = None + if Testmon.coverage_stack: + Testmon.coverage_stack[-1].start() def eval_environment(environment, **kwargs):