Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes15kdownloads
pytest_plugin.py359 linesDownload Raw Back to langsmith
1"""LangSmith Pytest hooks."""2 3import importlib.util4import json5import logging6import os7import time8from collections import defaultdict9from threading import Lock10from typing import Any11 12import pytest13 14from langsmith import utils as ls_utils15from langsmith.testing._internal import test as ls_test16 17logger = logging.getLogger(__name__)18 19 20def pytest_addoption(parser):21    """Set a boolean flag for LangSmith output.22 23    Skip if --langsmith-output is already defined.24    """25    try:26        # Try to add the option, will raise if it already exists27        group = parser.getgroup("langsmith", "LangSmith")28        group.addoption(29            "--langsmith-output",30            action="store_true",31            default=False,32            help="Use LangSmith output (requires 'rich').",33        )34    except ValueError:35        # Option already exists36        logger.warning(37            "LangSmith output flag cannot be added because it's already defined."38        )39 40 41def _handle_output_args(args):42    """Handle output arguments."""43    if any(opt in args for opt in ["--langsmith-output"]):44        # Only add --quiet if it's not already there45        if not any(a in args for a in ["-qq"]):46            args.insert(0, "-qq")47        # Disable built-in output capturing48        if not any(a in args for a in ["-s", "--capture=no"]):49            args.insert(0, "-s")50 51 52if pytest.__version__.startswith("7."):53 54    def pytest_cmdline_preparse(config, args):55        """Call immediately after command line options are parsed (pytest v7)."""56        _handle_output_args(args)57 58else:59 60    def pytest_load_initial_conftests(args):61        """Handle args in pytest v8+."""62        _handle_output_args(args)63 64 65@pytest.hookimpl(hookwrapper=True)66def pytest_runtest_call(item):67    """Apply LangSmith tracking to tests marked with @pytest.mark.langsmith."""68    marker = item.get_closest_marker("langsmith")69    if marker:70        # Get marker kwargs if any (e.g.,71        # @pytest.mark.langsmith(output_keys=["expected"]))72        kwargs = marker.kwargs if marker else {}73        # Inject experiment_metadata from session-scoped fixture if available74        # and not already explicitly set in the marker kwargs75        request_obj = getattr(item, "_request", None)76        if "experiment_metadata" not in kwargs and request_obj is not None:77            try:78                experiment_metadata = request_obj.getfixturevalue(79                    "langsmith_experiment_metadata"80                )81                if experiment_metadata is not None:82                    kwargs = {**kwargs, "experiment_metadata": experiment_metadata}83            except pytest.FixtureLookupError:84                pass85        # Wrap the test function with our test decorator86        original_func = item.obj87        item.obj = ls_test(**kwargs)(original_func)88        if request_obj is not None and "request" not in item.funcargs:89            item.funcargs["request"] = request_obj90        if request_obj is not None and "request" not in item._fixtureinfo.argnames:91            # Create a new FuncFixtureInfo instance with updated argnames92            item._fixtureinfo = type(item._fixtureinfo)(93                argnames=item._fixtureinfo.argnames + ("request",),94                initialnames=item._fixtureinfo.initialnames,95                names_closure=item._fixtureinfo.names_closure,96                name2fixturedefs=item._fixtureinfo.name2fixturedefs,97            )98    yield99 100 101@pytest.hookimpl102def pytest_report_teststatus(report, config):103    """Remove the short test-status character outputs ("./F")."""104    # The hook normally returns a 3-tuple: (short_letter, verbose_word, color)105    # By returning empty strings, the progress characters won't show.106    if config.getoption("--langsmith-output"):107        return "", "", ""108 109 110class LangSmithPlugin:111    """Plugin for rendering LangSmith results."""112 113    def __init__(self):114        """Initialize."""115        from rich.console import Console  # type: ignore[import-not-found]116        from rich.live import Live  # type: ignore[import-not-found]117 118        self.test_suites = defaultdict(list)119        self.test_suite_urls = {}120 121        self.process_status = {}  # Track process status122        self.status_lock = Lock()  # Thread-safe updates123        self.console = Console()124 125        self.live = Live(126            self.generate_tables(), console=self.console, refresh_per_second=10127        )128        self.live.start()129        self.live.console.print("Collecting tests...")130 131    def pytest_collection_finish(self, session):132        """Call after collection phase is completed and session.items is populated."""133        self.collected_nodeids = set()134        for item in session.items:135            self.collected_nodeids.add(item.nodeid)136 137    def add_process_to_test_suite(self, test_suite, process_id):138        """Group a test case with its test suite."""139        self.test_suites[test_suite].append(process_id)140 141    def update_process_status(self, process_id, status):142        """Update test results."""143        # First update144        if not self.process_status:145            self.live.console.print("Running tests...")146 147        with self.status_lock:148            current_status = self.process_status.get(process_id, {})149            self.process_status[process_id] = _merge_statuses(150                status,151                current_status,152                unpack=["feedback", "inputs", "reference_outputs", "outputs"],153            )154        self.live.update(self.generate_tables())155 156    def pytest_runtest_logstart(self, nodeid):157        """Initialize live display when first test starts."""158        self.update_process_status(nodeid, {"status": "running"})159 160    def generate_tables(self):161        """Generate a collection of tables—one per suite.162 163        Returns a 'Group' object so it can be rendered simultaneously by Rich Live.164        """165        from rich.console import Group166 167        tables = []168        for suite_name in self.test_suites:169            table = self._generate_table(suite_name)170            tables.append(table)171        group = Group(*tables)172        return group173 174    def _generate_table(self, suite_name: str):175        """Generate results table."""176        from rich.table import Table  # type: ignore[import-not-found]177 178        process_ids = self.test_suites[suite_name]179 180        title = f"""Test Suite: [bold]{suite_name}[/bold]181LangSmith URL: [bright_cyan]{self.test_suite_urls[suite_name]}[/bright_cyan]"""  # noqa: E501182        table = Table(title=title, title_justify="left")183        table.add_column("Test")184        table.add_column("Inputs")185        table.add_column("Ref outputs")186        table.add_column("Outputs")187        table.add_column("Status")188        table.add_column("Feedback")189        table.add_column("Duration")190 191        # Test, inputs, ref outputs, outputs col width192        max_status = len("status")193        max_duration = len("duration")194        now = time.time()195        durations = []196        numeric_feedbacks = defaultdict(list)197        # Gather data only for this suite198        suite_statuses = {pid: self.process_status[pid] for pid in process_ids}199        for pid, status in suite_statuses.items():200            duration = status.get("end_time", now) - status.get("start_time", now)201            durations.append(duration)202            for k, v in status.get("feedback", {}).items():203                if isinstance(v, (float, int, bool)):204                    numeric_feedbacks[k].append(v)205            max_duration = max(len(f"{duration:.2f}s"), max_duration)206            max_status = max(len(status.get("status", "queued")), max_status)207 208        passed_count = sum(s.get("status") == "passed" for s in suite_statuses.values())209        failed_count = sum(s.get("status") == "failed" for s in suite_statuses.values())210 211        # You could arrange a row to show the aggregated data—here, in the last column:212        if passed_count + failed_count:213            rate = passed_count / (passed_count + failed_count)214            color = "green" if rate == 1 else "red"215            aggregate_status = f"[{color}]{rate:.0%}[/{color}]"216        else:217            aggregate_status = "Passed: --"218        if durations:219            aggregate_duration = f"{sum(durations) / len(durations):.2f}s"220        else:221            aggregate_duration = "--s"222        if numeric_feedbacks:223            aggregate_feedback = "\n".join(224                f"{k}: {sum(v) / len(v)}" for k, v in numeric_feedbacks.items()225            )226        else:227            aggregate_feedback = "--"228 229        max_duration = max(max_duration, len(aggregate_duration))230        max_dynamic_col_width = (self.console.width - (max_status + max_duration)) // 5231        max_dynamic_col_width = max(max_dynamic_col_width, 8)232 233        for pid, status in suite_statuses.items():234            status_color = {235                "running": "yellow",236                "passed": "green",237                "failed": "red",238                "skipped": "cyan",239            }.get(status.get("status", "queued"), "white")240 241            duration = status.get("end_time", now) - status.get("start_time", now)242            feedback = "\n".join(243                f"{_abbreviate(k, max_len=max_dynamic_col_width)}: {int(v) if isinstance(v, bool) else v}"  # noqa: E501244                for k, v in status.get("feedback", {}).items()245            )246            inputs = _dumps_with_fallback(status.get("inputs", {}))247            reference_outputs = _dumps_with_fallback(248                status.get("reference_outputs", {})249            )250            outputs = _dumps_with_fallback(status.get("outputs", {}))251            table.add_row(252                _abbreviate_test_name(str(pid), max_len=max_dynamic_col_width),253                _abbreviate(inputs, max_len=max_dynamic_col_width),254                _abbreviate(reference_outputs, max_len=max_dynamic_col_width),255                _abbreviate(outputs, max_len=max_dynamic_col_width)[256                    -max_dynamic_col_width:257                ],258                f"[{status_color}]{status.get('status', 'queued')}[/{status_color}]",259                feedback,260                f"{duration:.2f}s",261            )262 263        # Add a blank row or a section separator if you like:264        table.add_row("", "", "", "", "", "", "")265        # Finally, our “footer” row:266        table.add_row(267            "[bold]Averages[/bold]",268            "",269            "",270            "",271            aggregate_status,272            aggregate_feedback,273            aggregate_duration,274        )275 276        return table277 278    def pytest_configure(self, config):279        """Disable warning reporting and show no warnings in output."""280        # Disable general warning reporting281        config.option.showwarnings = False282 283        # Disable warning summary284        reporter = config.pluginmanager.get_plugin("warnings-plugin")285        if reporter:286            reporter.warning_summary = lambda *args, **kwargs: None287 288    def pytest_sessionfinish(self, session):289        """Stop Rich Live rendering at the end of the session."""290        self.live.stop()291        self.live.console.print("\nFinishing up...")292 293 294def pytest_configure(config):295    """Register the 'langsmith' marker."""296    config.addinivalue_line(297        "markers", "langsmith: mark test to be tracked in LangSmith"298    )299    if config.getoption("--langsmith-output"):300        if not importlib.util.find_spec("rich"):301            msg = (302                "Must have 'rich' installed to use --langsmith-output. "303                "Please install with: `pip install -U 'langsmith[pytest]'`"304            )305            raise ValueError(msg)306        if os.environ.get("PYTEST_XDIST_TESTRUNUID"):307            msg = (308                "--langsmith-output not supported with pytest-xdist. "309                "Please remove the '--langsmith-output' option or '-n' option."310            )311            raise ValueError(msg)312        if ls_utils.test_tracking_is_disabled():313            msg = (314                "--langsmith-output not supported when env var"315                "LANGSMITH_TEST_TRACKING='false'. Please remove the"316                "'--langsmith-output' option "317                "or enable test tracking."318            )319            raise ValueError(msg)320        config.pluginmanager.register(LangSmithPlugin(), "langsmith_output_plugin")321        # Suppress warnings summary322        config.option.showwarnings = False323 324 325def _abbreviate(x: str, max_len: int) -> str:326    if len(x) > max_len:327        return x[: max_len - 3] + "..."328    else:329        return x330 331 332def _abbreviate_test_name(test_name: str, max_len: int) -> str:333    if len(test_name) > max_len:334        file, test = test_name.split("::")335        if len(".py::" + test) > max_len:336            return "..." + test[-(max_len - 3) :]337        file_len = max_len - len("...::" + test)338        return "..." + file[-file_len:] + "::" + test339    else:340        return test_name341 342 343def _merge_statuses(update: dict, current: dict, *, unpack: list[str]) -> dict:344    for path in unpack:345        if path_update := update.pop(path, None):346            path_current = current.get(path, {})347            if isinstance(path_update, dict) and isinstance(path_current, dict):348                current[path] = {**path_current, **path_update}349            else:350                current[path] = path_update351    return {**current, **update}352 353 354def _dumps_with_fallback(obj: Any) -> str:355    try:356        return json.dumps(obj)357    except Exception:358        return "unserializable"359 
codekingpro/portable-devtools · Team Ai