import argparse
import copy
import logging
import sys
from dataclasses import dataclass
from datetime import datetime, timedelta
from slack_sdk import WebClient
from typing import Dict, Optional, List

import pytz

from hunter import config
from hunter.attributes import get_back_links
from hunter.config import ConfigError, Config
from hunter.data_selector import DataSelector

from hunter.grafana import GrafanaError, Grafana, Annotation
from hunter.graphite import GraphiteError
from hunter.importer import DataImportError, Importers
from hunter.report import Report
from hunter.series import (
    AnalysisOptions,
    ChangePointGroup,
    SeriesComparison,
    compare,
    AnalyzedSeries,
)
from hunter.slack import SlackNotifier, NotificationError
from hunter.test_config import TestConfigError, TestConfig, GraphiteTestConfig
from hunter.util import parse_datetime, DateFormatError, interpolate


@dataclass
class HunterError(Exception):
    message: str


class Hunter:
    __conf: Config
    __importers: Importers
    __grafana: Optional[Grafana]
    __slack: Optional[SlackNotifier]

    def __init__(self, conf: Config):
        self.__conf = conf
        self.__importers = Importers(conf)
        self.__grafana = None
        self.__slack = self.__maybe_create_slack_notifier()

    def list_tests(self, group_names: Optional[List[str]]):
        if group_names is not None:
            test_names = []
            for group_name in group_names:
                group = self.__conf.test_groups.get(group_name)
                if group is None:
                    raise HunterError(f"Test group not found: {group_name}")
                test_names += (t.name for t in group)
        else:
            test_names = self.__conf.tests

        for test_name in sorted(test_names):
            print(test_name)

    def list_test_groups(self):
        for group_name in sorted(self.__conf.test_groups):
            print(group_name)

    def get_test(self, test_name: str) -> TestConfig:
        test = self.__conf.tests.get(test_name)
        if test is None:
            raise HunterError(f"Test not found {test_name}")
        return test

    def get_tests(self, *names: str) -> List[TestConfig]:
        tests = []
        for name in names:
            group = self.__conf.test_groups.get(name)
            if group is not None:
                tests += group
            else:
                test = self.__conf.tests.get(name)
                if test is not None:
                    tests.append(test)
                else:
                    raise HunterError(f"Test or group not found: {name}")
        return tests

    def list_metrics(self, test: TestConfig):
        importer = self.__importers.get(test)
        for metric_name in importer.fetch_all_metric_names(test):
            print(metric_name)

    def analyze(
        self, test: TestConfig, selector: DataSelector, options: AnalysisOptions
    ) -> AnalyzedSeries:
        importer = self.__importers.get(test)
        series = importer.fetch_data(test, selector)
        analyzed_series = series.analyze(options)
        change_points = analyzed_series.change_points_by_time
        report = Report(series, change_points)
        print(test.name + ":")
        print(report.format_log_annotated())
        return analyzed_series

    def __get_grafana(self) -> Grafana:
        if self.__grafana is None:
            self.__grafana = Grafana(self.__conf.grafana)
        return self.__grafana

    def update_grafana_annotations(self, test: GraphiteTestConfig, series: AnalyzedSeries):
        grafana = self.__get_grafana()
        begin = datetime.fromtimestamp(series.time()[0], tz=pytz.UTC)
        end = datetime.fromtimestamp(series.time()[len(series.time()) - 1], tz=pytz.UTC)

        logging.info(f"Fetching Grafana annotations for test {test.name}...")
        tags_to_query = ["hunter", "change-point", "test:" + test.name]
        old_annotations_for_test = grafana.fetch_annotations(begin, end, list(tags_to_query))
        logging.info(f"Found {len(old_annotations_for_test)} annotations")

        created_count = 0
        for metric_name, change_points in series.change_points.items():
            path = test.get_path(series.branch_name(), metric_name)
            metric_tag = f"metric:{metric_name}"
            tags_to_create = (
                tags_to_query
                + [metric_tag]
                + test.tags
                + test.annotate
                + test.metrics[metric_name].annotate
            )

            substitutions = {
                "TEST_NAME": test.name,
                "METRIC_NAME": metric_name,
                "GRAPHITE_PATH": [path],
                "GRAPHITE_PATH_COMPONENTS": path.split("."),
                "GRAPHITE_PREFIX": [test.prefix],
                "GRAPHITE_PREFIX_COMPONENTS": test.prefix.split("."),
            }

            tmp_tags_to_create = []
            for t in tags_to_create:
                tmp_tags_to_create += interpolate(t, substitutions)
            tags_to_create = tmp_tags_to_create

            old_annotations = [a for a in old_annotations_for_test if metric_tag in a.tags]
            old_annotation_times = set((a.time for a in old_annotations if a.tags))

            target_annotations = []
            for cp in change_points:
                attributes = series.attributes_at(cp.index)
                annotation_text = get_back_links(attributes)
                target_annotations.append(
                    Annotation(
                        id=None,
                        time=datetime.fromtimestamp(cp.time, tz=pytz.UTC),
                        text=annotation_text,
                        tags=tags_to_create,
                    )
                )
            target_annotation_times = set((a.time for a in target_annotations))

            to_delete = [a for a in old_annotations if a.time not in target_annotation_times]
            if to_delete:
                logging.info(
                    f"Removing {len(to_delete)} annotations "
                    f"for test {test.name} and metric {metric_name}..."
                )
                grafana.delete_annotations(*(a.id for a in to_delete))

            to_create = [a for a in target_annotations if a.time not in old_annotation_times]
            if to_create:
                logging.info(
                    f"Creating {len(to_create)} annotations "
                    f"for test {test.name} and metric {metric_name}..."
                )
                grafana.create_annotations(*to_create)
                created_count += len(to_create)

        if created_count == 0:
            logging.info("All annotations up-to-date. No new annotations needed.")
        else:
            logging.info(f"Created {created_count} annotations.")

    def remove_grafana_annotations(self, test: Optional[TestConfig], force: bool):
        """Removes all Hunter annotations (optionally for a given test) in Grafana"""
        grafana = self.__get_grafana()
        if test:
            logging.info(f"Fetching Grafana annotations for test {test.name}...")
        else:
            logging.info(f"Fetching Grafana annotations...")
        tags_to_query = {"hunter", "change-point"}
        if test:
            tags_to_query.add("test:" + test.name)
        annotations = grafana.fetch_annotations(None, None, list(tags_to_query))
        if not annotations:
            logging.info("No annotations found.")
            return
        if not force:
            print(
                f"Are you sure to remove {len(annotations)} annotations from {grafana.url}? [y/N]"
            )
            decision = input().strip()
            if decision.lower() != "y" and decision.lower() != "yes":
                return
        logging.info(f"Removing {len(annotations)} annotations...")
        grafana.delete_annotations(*(a.id for a in annotations))

    def regressions(
        self, test: TestConfig, selector: DataSelector, options: AnalysisOptions
    ) -> bool:
        importer = self.__importers.get(test)

        # Even if user is interested only in performance difference since some point X,
        # we really need to fetch some earlier points than X.
        # Otherwise, if performance went down very early after X, e.g. at X + 1, we'd have
        # insufficient number of data points to compute the baseline performance.
        # Instead of using `since-` selector, we're fetching everything from the
        # beginning and then we find the baseline performance around the time pointed by
        # the original selector.
        since_version = selector.since_version
        since_commit = selector.since_commit
        since_time = selector.since_time
        baseline_selector = copy.deepcopy(selector)
        baseline_selector.last_n_points = sys.maxsize
        baseline_selector.branch = None
        baseline_selector.since_version = None
        baseline_selector.since_commit = None
        baseline_selector.since_time = since_time - timedelta(days=30)
        baseline_series = importer.fetch_data(test, baseline_selector)

        if since_version:
            baseline_index = baseline_series.find_by_attribute("version", since_version)
            if not baseline_index:
                raise HunterError(f"No runs of test {test.name} with version {since_version}")
            baseline_index = max(baseline_index)
        elif since_commit:
            baseline_index = baseline_series.find_by_attribute("commit", since_commit)
            if not baseline_index:
                raise HunterError(f"No runs of test {test.name} with commit {since_commit}")
            baseline_index = max(baseline_index)
        else:
            baseline_index = baseline_series.find_first_not_earlier_than(since_time)

        baseline_series = baseline_series.analyze()

        if selector.branch:
            target_series = importer.fetch_data(test, selector).analyze()
        else:
            target_series = baseline_series

        cmp = compare(baseline_series, baseline_index, target_series, target_series.len())
        regressions = []
        for metric_name, stats in cmp.stats.items():
            direction = baseline_series.metric(metric_name).direction
            m1 = stats.mean_1
            m2 = stats.mean_2
            change_percent = stats.forward_rel_change() * 100.0
            if m2 * direction < m1 * direction and stats.pvalue < options.max_pvalue:
                regressions.append(
                    "    {:16}: {:#8.3g} --> {:#8.3g} ({:+6.1f}%)".format(
                        metric_name, m1, m2, change_percent
                    )
                )

        if regressions:
            print(f"{test.name}:")
            for r in regressions:
                print(r)
        else:
            print(f"{test.name}: OK")
        return len(regressions) > 0

    def __maybe_create_slack_notifier(self):
        if not self.__conf.slack:
            return None
        return SlackNotifier(WebClient(token=self.__conf.slack.bot_token))

    def notify_slack(
        self,
        test_change_points: Dict[str, AnalyzedSeries],
        selector: DataSelector,
        channels: List[str],
        since: datetime,
    ):
        if not self.__slack:
            logging.error(
                "Slack definition is missing from the configuration, cannot send notification"
            )
            return
        self.__slack.notify(test_change_points, selector=selector, channels=channels, since=since)

    def validate(self):
        valid = True
        unique_metrics = set()
        for name, test in self.__conf.tests.items():
            logging.info("Checking {}".format(name))
            test_metrics = test.fully_qualified_metric_names()
            for test_metric in test_metrics:
                if test_metric not in unique_metrics:
                    unique_metrics.add(test_metric)
                else:
                    valid = False
                    logging.error(f"Found duplicated metric: {test_metric}")
            try:
                importer = self.__importers.get(test)
                series = importer.fetch_data(test)
                for metric, metric_data in series.data.items():
                    if not metric_data:
                        logging.warning(f"Test's metric does not have data: {name} {metric}")
            except Exception as err:
                logging.error(f"Invalid test definition: {name}\n{repr(err)}\n")
                valid = False
        logging.info(f"Validation finished: {'VALID' if valid else 'INVALID'}")
        if not valid:
            exit(1)


def setup_data_selector_parser(parser: argparse.ArgumentParser):
    parser.add_argument(
        "--branch", metavar="STRING", dest="branch", help="name of the branch", nargs="?"
    )
    parser.add_argument(
        "--metrics",
        metavar="LIST",
        dest="metrics",
        help="a comma-separated list of metrics to analyze",
    )
    parser.add_argument(
        "--attrs",
        metavar="LIST",
        dest="attributes",
        help="a comma-separated list of attribute names associated with the runs "
        "(e.g. commit, branch, version); "
        "if not specified, it will be automatically filled based on available information",
    )
    since_group = parser.add_mutually_exclusive_group()
    since_group.add_argument(
        "--since-commit",
        metavar="STRING",
        dest="since_commit",
        help="the commit at the start of the time span to analyze",
    )
    since_group.add_argument(
        "--since-version",
        metavar="STRING",
        dest="since_version",
        help="the version at the start of the time span to analyze",
    )
    since_group.add_argument(
        "--since",
        metavar="DATE",
        dest="since_time",
        help="the start of the time span to analyze; "
        "accepts ISO, and human-readable dates like '10 weeks ago'",
    )
    until_group = parser.add_mutually_exclusive_group()
    until_group.add_argument(
        "--until-commit",
        metavar="STRING",
        dest="until_commit",
        help="the commit at the end of the time span to analyze",
    )
    until_group.add_argument(
        "--until-version",
        metavar="STRING",
        dest="until_version",
        help="the version at the end of the time span to analyze",
    )
    until_group.add_argument(
        "--until",
        metavar="DATE",
        dest="until_time",
        help="the end of the time span to analyze; same syntax as --since",
    )
    parser.add_argument(
        "--last",
        type=int,
        metavar="COUNT",
        dest="last_n_points",
        help="the number of data points to take from the end of the series"
    )


def data_selector_from_args(args: argparse.Namespace) -> DataSelector:
    data_selector = DataSelector()
    if args.branch:
        data_selector.branch = args.branch
    if args.metrics is not None:
        data_selector.metrics = list(args.metrics.split(","))
    if args.attributes is not None:
        data_selector.attributes = list(args.attributes.split(","))
    if args.since_commit is not None:
        data_selector.since_commit = args.since_commit
    if args.since_version is not None:
        data_selector.since_version = args.since_version
    if args.since_time is not None:
        data_selector.since_time = parse_datetime(args.since_time)
    if args.until_commit is not None:
        data_selector.until_commit = args.until_commit
    if args.until_version is not None:
        data_selector.until_version = args.until_version
    if args.until_time is not None:
        data_selector.until_time = parse_datetime(args.until_time)
    if args.last_n_points is not None:
        data_selector.last_n_points = args.last_n_points
    return data_selector


def setup_analysis_options_parser(parser: argparse.ArgumentParser):
    parser.add_argument(
        "-P, --p-value",
        dest="pvalue",
        type=float,
        default=0.001,
        help="maximum accepted P-value of a change-point; "
        "P denotes the probability that the change-point has "
        "been found by a random coincidence, rather than a real "
        "difference between the data distributions",
    )
    parser.add_argument(
        "-M",
        "--magnitude",
        dest="magnitude",
        type=float,
        default=0.0,
        help="minimum accepted magnitude of a change-point "
        "computed as abs(new_mean / old_mean - 1.0); use it "
        "to filter out stupidly small changes like < 0.01",
    )
    parser.add_argument(
        "--window",
        default=50,
        type=int,
        dest="window",
        help="the number of data points analyzed at once; "
        "the window size affects the discriminative "
        "power of the change point detection algorithm; "
        "large windows are less susceptible to noise; "
        "however, a very large window may cause dismissing short regressions "
        "as noise so it is best to keep it short enough to include not more "
        "than a few change points (optimally at most 1)",
    )


def analysis_options_from_args(args: argparse.Namespace) -> AnalysisOptions:
    conf = AnalysisOptions()
    if args.pvalue is not None:
        conf.max_pvalue = args.pvalue
    if args.magnitude is not None:
        conf.min_magnitude = args.magnitude
    if args.window is not None:
        conf.window_len = args.window
    return conf


def main():
    logging.basicConfig(format="%(levelname)s: %(message)s", level=logging.INFO)

    parser = argparse.ArgumentParser(description="Hunts performance regressions in Fallout results")

    subparsers = parser.add_subparsers(dest="command")
    list_tests_parser = subparsers.add_parser("list-tests", help="list available tests")
    list_tests_parser.add_argument("group", help="name of the group of the tests", nargs="*")

    list_metrics_parser = subparsers.add_parser(
        "list-metrics", help="list available metrics for a test"
    )
    list_metrics_parser.add_argument("test", help="name of the test")

    subparsers.add_parser("list-groups", help="list available groups of tests")

    analyze_parser = subparsers.add_parser(
        "analyze",
        help="analyze performance test results",
        formatter_class=argparse.RawTextHelpFormatter,
    )
    analyze_parser.add_argument("tests", help="name of the test or group of the tests", nargs="+")
    analyze_parser.add_argument(
        "--update-grafana",
        help="Update Grafana dashboards with appropriate annotations of change points",
        action="store_true",
    )
    analyze_parser.add_argument(
        "--notify-slack",
        help="Send notification containing a summary of change points to given Slack channels",
        nargs="+",
    )
    analyze_parser.add_argument(
        "--cph-report-since",
        help="Sets a limit on the date range of the Change Point History reported to Slack. Same syntax as --since.",
        metavar="DATE",
        dest="cph_report_since",
    )
    setup_data_selector_parser(analyze_parser)
    setup_analysis_options_parser(analyze_parser)

    regressions_parser = subparsers.add_parser("regressions", help="find performance regressions")
    regressions_parser.add_argument(
        "tests", help="name of the test or group of the tests", nargs="+"
    )
    setup_data_selector_parser(regressions_parser)
    setup_analysis_options_parser(regressions_parser)

    remove_annotations_parser = subparsers.add_parser("remove-annotations")
    remove_annotations_parser.add_argument(
        "tests", help="name of the test or test group", nargs="*"
    )
    remove_annotations_parser.add_argument(
        "--force", help="don't ask questions, just do it", dest="force", action="store_true"
    )

    validate_parser = subparsers.add_parser("validate",
                                            help="validates the tests and metrics defined in the configuration")

    try:
        args = parser.parse_args()
        conf = config.load_config()
        hunter = Hunter(conf)

        if args.command == "list-groups":
            hunter.list_test_groups()

        if args.command == "list-tests":
            group_names = args.group if args.group else None
            hunter.list_tests(group_names)

        if args.command == "list-metrics":
            test = hunter.get_test(args.test)
            hunter.list_metrics(test)

        if args.command == "analyze":
            update_grafana_flag = args.update_grafana
            slack_notification_channels = args.notify_slack
            slack_cph_since = parse_datetime(args.cph_report_since)
            data_selector = data_selector_from_args(args)
            options = analysis_options_from_args(args)
            tests = hunter.get_tests(*args.tests)
            tests_analyzed_series = {test.name: None for test in tests}
            for test in tests:
                try:
                    analyzed_series = hunter.analyze(test, selector=data_selector, options=options)
                    if update_grafana_flag:
                        if not isinstance(test, GraphiteTestConfig):
                            raise GrafanaError(f"Not a Graphite test")
                        hunter.update_grafana_annotations(test, analyzed_series)
                    if slack_notification_channels:
                        tests_analyzed_series[test.name] = analyzed_series
                except DataImportError as err:
                    logging.error(err.message)
                except GrafanaError as err:
                    logging.error(
                        f"Failed to update grafana dashboards for {test.name}: {err.message}"
                    )
            if slack_notification_channels:
                hunter.notify_slack(
                    tests_analyzed_series,
                    selector=data_selector,
                    channels=slack_notification_channels,
                    since=slack_cph_since,
                )

        if args.command == "regressions":
            data_selector = data_selector_from_args(args)
            options = analysis_options_from_args(args)
            tests = hunter.get_tests(*args.tests)
            regressing_test_count = 0
            errors = 0
            for test in tests:
                try:
                    regressions = hunter.regressions(
                        test, selector=data_selector, options=options
                    )
                    if regressions:
                        regressing_test_count += 1
                except HunterError as err:
                    logging.error(err.message)
                    errors += 1
                except DataImportError as err:
                    logging.error(err.message)
                    errors += 1
            if regressing_test_count == 0:
                print("No regressions found!")
            elif regressing_test_count == 1:
                print("Regressions in 1 test found")
            else:
                print(f"Regressions in {regressing_test_count} tests found")
            if errors > 0:
                print(f"Some tests were skipped due to import / analyze errors. Consult error log.")

        if args.command == "remove-annotations":
            if args.tests:
                tests = hunter.get_tests(*args.tests)
                for test in tests:
                    hunter.remove_grafana_annotations(test, args.force)
            else:
                hunter.remove_grafana_annotations(None, args.force)

        if args.command == "validate":
            hunter.validate()

        if args.command is None:
            parser.print_usage()

    except ConfigError as err:
        logging.error(err.message)
        exit(1)
    except TestConfigError as err:
        logging.error(err.message)
        exit(1)
    except GraphiteError as err:
        logging.error(err.message)
        exit(1)
    except GrafanaError as err:
        logging.error(err.message)
        exit(1)
    except DataImportError as err:
        logging.error(err.message)
        exit(1)
    except HunterError as err:
        logging.error(err.message)
        exit(1)
    except DateFormatError as err:
        logging.error(err.message)
        exit(1)
    except NotificationError as err:
        logging.error(err.message)
        exit(1)


if __name__ == "__main__":
    main()

[((14875, 14889), 'hunter.data_selector.DataSelector', 'DataSelector', ([], {}), '()\n', (14887, 14889), False, 'from hunter.data_selector import DataSelector\n'), ((17254, 17271), 'hunter.series.AnalysisOptions', 'AnalysisOptions', ([], {}), '()\n', (17269, 17271), False, 'from hunter.series import AnalysisOptions, ChangePointGroup, SeriesComparison, compare, AnalyzedSeries\n'), ((17525, 17601), 'logging.basicConfig', 'logging.basicConfig', ([], {'format': '"""%(levelname)s: %(message)s"""', 'level': 'logging.INFO'}), "(format='%(levelname)s: %(message)s', level=logging.INFO)\n", (17544, 17601), False, 'import logging\n'), ((17616, 17708), 'argparse.ArgumentParser', 'argparse.ArgumentParser', ([], {'description': '"""Hunts performance regressions in Fallout results"""'}), "(description=\n    'Hunts performance regressions in Fallout results')\n", (17639, 17708), False, 'import argparse\n'), ((1189, 1204), 'hunter.importer.Importers', 'Importers', (['conf'], {}), '(conf)\n', (1198, 1204), False, 'from hunter.importer import DataImportError, Importers\n'), ((3183, 3212), 'hunter.report.Report', 'Report', (['series', 'change_points'], {}), '(series, change_points)\n', (3189, 3212), False, 'from hunter.report import Report\n'), ((3784, 3853), 'logging.info', 'logging.info', (['f"""Fetching Grafana annotations for test {test.name}..."""'], {}), "(f'Fetching Grafana annotations for test {test.name}...')\n", (3796, 3853), False, 'import logging\n'), ((8763, 8786), 'copy.deepcopy', 'copy.deepcopy', (['selector'], {}), '(selector)\n', (8776, 8786), False, 'import copy\n'), ((12536, 12607), 'logging.info', 'logging.info', (['f"""Validation finished: {\'VALID\' if valid else \'INVALID\'}"""'], {}), '(f"Validation finished: {\'VALID\' if valid else \'INVALID\'}")\n', (12548, 12607), False, 'import logging\n'), ((15412, 15443), 'hunter.util.parse_datetime', 'parse_datetime', (['args.since_time'], {}), '(args.since_time)\n', (15426, 15443), False, 'from hunter.util import parse_datetime, DateFormatError, interpolate\n'), ((15704, 15735), 'hunter.util.parse_datetime', 'parse_datetime', (['args.until_time'], {}), '(args.until_time)\n', (15718, 15735), False, 'from hunter.util import parse_datetime, DateFormatError, interpolate\n'), ((20081, 20101), 'hunter.config.load_config', 'config.load_config', ([], {}), '()\n', (20099, 20101), False, 'from hunter import config\n'), ((3425, 3453), 'hunter.grafana.Grafana', 'Grafana', (['self.__conf.grafana'], {}), '(self.__conf.grafana)\n', (3432, 3453), False, 'from hunter.grafana import GrafanaError, Grafana, Annotation\n'), ((6661, 6731), 'logging.info', 'logging.info', (['"""All annotations up-to-date. No new annotations needed."""'], {}), "('All annotations up-to-date. No new annotations needed.')\n", (6673, 6731), False, 'import logging\n'), ((6758, 6811), 'logging.info', 'logging.info', (['f"""Created {created_count} annotations."""'], {}), "(f'Created {created_count} annotations.')\n", (6770, 6811), False, 'import logging\n'), ((7050, 7119), 'logging.info', 'logging.info', (['f"""Fetching Grafana annotations for test {test.name}..."""'], {}), "(f'Fetching Grafana annotations for test {test.name}...')\n", (7062, 7119), False, 'import logging\n'), ((7146, 7194), 'logging.info', 'logging.info', (['f"""Fetching Grafana annotations..."""'], {}), "(f'Fetching Grafana annotations...')\n", (7158, 7194), False, 'import logging\n'), ((7435, 7472), 'logging.info', 'logging.info', (['"""No annotations found."""'], {}), "('No annotations found.')\n", (7447, 7472), False, 'import logging\n'), ((9026, 9044), 'datetime.timedelta', 'timedelta', ([], {'days': '(30)'}), '(days=30)\n', (9035, 9044), False, 'from datetime import datetime, timedelta\n'), ((11016, 11060), 'slack_sdk.WebClient', 'WebClient', ([], {'token': 'self.__conf.slack.bot_token'}), '(token=self.__conf.slack.bot_token)\n', (11025, 11060), False, 'from slack_sdk import WebClient\n'), ((11288, 11391), 'logging.error', 'logging.error', (['"""Slack definition is missing from the configuration, cannot send notification"""'], {}), "(\n    'Slack definition is missing from the configuration, cannot send notification'\n    )\n", (11301, 11391), False, 'import logging\n'), ((20670, 20707), 'hunter.util.parse_datetime', 'parse_datetime', (['args.cph_report_since'], {}), '(args.cph_report_since)\n', (20684, 20707), False, 'from hunter.util import parse_datetime, DateFormatError, interpolate\n'), ((23775, 23801), 'logging.error', 'logging.error', (['err.message'], {}), '(err.message)\n', (23788, 23801), False, 'import logging\n'), ((23861, 23887), 'logging.error', 'logging.error', (['err.message'], {}), '(err.message)\n', (23874, 23887), False, 'import logging\n'), ((23945, 23971), 'logging.error', 'logging.error', (['err.message'], {}), '(err.message)\n', (23958, 23971), False, 'import logging\n'), ((24028, 24054), 'logging.error', 'logging.error', (['err.message'], {}), '(err.message)\n', (24041, 24054), False, 'import logging\n'), ((24114, 24140), 'logging.error', 'logging.error', (['err.message'], {}), '(err.message)\n', (24127, 24140), False, 'import logging\n'), ((24196, 24222), 'logging.error', 'logging.error', (['err.message'], {}), '(err.message)\n', (24209, 24222), False, 'import logging\n'), ((24282, 24308), 'logging.error', 'logging.error', (['err.message'], {}), '(err.message)\n', (24295, 24308), False, 'import logging\n'), ((24370, 24396), 'logging.error', 'logging.error', (['err.message'], {}), '(err.message)\n', (24383, 24396), False, 'import logging\n'), ((4993, 5022), 'hunter.util.interpolate', 'interpolate', (['t', 'substitutions'], {}), '(t, substitutions)\n', (5004, 5022), False, 'from hunter.util import parse_datetime, DateFormatError, interpolate\n'), ((5416, 5442), 'hunter.attributes.get_back_links', 'get_back_links', (['attributes'], {}), '(attributes)\n', (5430, 5442), False, 'from hunter.attributes import get_back_links\n'), ((12003, 12059), 'logging.error', 'logging.error', (['f"""Found duplicated metric: {test_metric}"""'], {}), "(f'Found duplicated metric: {test_metric}')\n", (12016, 12059), False, 'import logging\n'), ((12310, 12379), 'logging.warning', 'logging.warning', (['f"""Test\'s metric does not have data: {name} {metric}"""'], {}), '(f"Test\'s metric does not have data: {name} {metric}")\n', (12325, 12379), False, 'import logging\n'), ((21554, 21580), 'logging.error', 'logging.error', (['err.message'], {}), '(err.message)\n', (21567, 21580), False, 'import logging\n'), ((21645, 21734), 'logging.error', 'logging.error', (['f"""Failed to update grafana dashboards for {test.name}: {err.message}"""'], {}), "(\n    f'Failed to update grafana dashboards for {test.name}: {err.message}')\n", (21658, 21734), False, 'import logging\n'), ((22678, 22704), 'logging.error', 'logging.error', (['err.message'], {}), '(err.message)\n', (22691, 22704), False, 'import logging\n'), ((22804, 22830), 'logging.error', 'logging.error', (['err.message'], {}), '(err.message)\n', (22817, 22830), False, 'import logging\n'), ((5580, 5624), 'datetime.datetime.fromtimestamp', 'datetime.fromtimestamp', (['cp.time'], {'tz': 'pytz.UTC'}), '(cp.time, tz=pytz.UTC)\n', (5602, 5624), False, 'from datetime import datetime, timedelta\n'), ((21242, 21278), 'hunter.grafana.GrafanaError', 'GrafanaError', (['f"""Not a Graphite test"""'], {}), "(f'Not a Graphite test')\n", (21254, 21278), False, 'from hunter.grafana import GrafanaError, Grafana, Annotation\n')]