sqlmesh 0.193.1.dev3__py3-none-any.whl → 0.193.1.dev5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sqlmesh/_version.py CHANGED
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '0.193.1.dev3'
21
- __version_tuple__ = version_tuple = (0, 193, 1, 'dev3')
20
+ __version__ = version = '0.193.1.dev5'
21
+ __version_tuple__ = version_tuple = (0, 193, 1, 'dev5')
sqlmesh/core/console.py CHANGED
@@ -7,6 +7,7 @@ import unittest
7
7
  import uuid
8
8
  import logging
9
9
  import textwrap
10
+ from itertools import zip_longest
10
11
  from pathlib import Path
11
12
  from hyperscript import h
12
13
  from rich.console import Console as RichConsole
@@ -26,6 +27,7 @@ from rich.table import Table
26
27
  from rich.tree import Tree
27
28
  from sqlglot import exp
28
29
 
30
+ from sqlmesh.core.test.result import ModelTextTestResult
29
31
  from sqlmesh.core.environment import EnvironmentNamingInfo, EnvironmentSummary
30
32
  from sqlmesh.core.linter.rule import RuleViolation
31
33
  from sqlmesh.core.model import Model
@@ -46,6 +48,7 @@ from sqlmesh.utils.errors import (
46
48
  NodeAuditsErrors,
47
49
  format_destructive_change_msg,
48
50
  )
51
+ from sqlmesh.utils.rich import strip_ansi_codes
49
52
 
50
53
  if t.TYPE_CHECKING:
51
54
  import ipywidgets as widgets
@@ -316,6 +319,17 @@ class PlanBuilderConsole(BaseConsole, abc.ABC):
316
319
  """Display a destructive change error or warning to the user."""
317
320
 
318
321
 
322
+ class UnitTestConsole(abc.ABC):
323
+ @abc.abstractmethod
324
+ def log_test_results(self, result: ModelTextTestResult, target_dialect: str) -> None:
325
+ """Display the test result and output.
326
+
327
+ Args:
328
+ result: The unittest test result that contains metrics like num success, fails, ect.
329
+ target_dialect: The dialect that tests were run against. Assumes all tests run against the same dialect.
330
+ """
331
+
332
+
319
333
  class Console(
320
334
  PlanBuilderConsole,
321
335
  LinterConsole,
@@ -327,6 +341,7 @@ class Console(
327
341
  DifferenceConsole,
328
342
  TableDiffConsole,
329
343
  BaseConsole,
344
+ UnitTestConsole,
330
345
  abc.ABC,
331
346
  ):
332
347
  """Abstract base class for defining classes used for displaying information to the user and also interact
@@ -460,18 +475,6 @@ class Console(
460
475
  fail. Default: False
461
476
  """
462
477
 
463
- @abc.abstractmethod
464
- def log_test_results(
465
- self, result: unittest.result.TestResult, output: t.Optional[str], target_dialect: str
466
- ) -> None:
467
- """Display the test result and output.
468
-
469
- Args:
470
- result: The unittest test result that contains metrics like num success, fails, ect.
471
- output: The generated output from the unittest.
472
- target_dialect: The dialect that tests were run against. Assumes all tests run against the same dialect.
473
- """
474
-
475
478
  @abc.abstractmethod
476
479
  def show_sql(self, sql: str) -> None:
477
480
  """Display to the user SQL."""
@@ -668,9 +671,7 @@ class NoopConsole(Console):
668
671
  if auto_apply:
669
672
  plan_builder.apply()
670
673
 
671
- def log_test_results(
672
- self, result: unittest.result.TestResult, output: t.Optional[str], target_dialect: str
673
- ) -> None:
674
+ def log_test_results(self, result: ModelTextTestResult, target_dialect: str) -> None:
674
675
  pass
675
676
 
676
677
  def show_sql(self, sql: str) -> None:
@@ -1952,10 +1953,12 @@ class TerminalConsole(Console):
1952
1953
  ):
1953
1954
  plan_builder.apply()
1954
1955
 
1955
- def log_test_results(
1956
- self, result: unittest.result.TestResult, output: t.Optional[str], target_dialect: str
1957
- ) -> None:
1956
+ def log_test_results(self, result: ModelTextTestResult, target_dialect: str) -> None:
1958
1957
  divider_length = 70
1958
+
1959
+ self._log_test_details(result)
1960
+ self._print("\n")
1961
+
1959
1962
  if result.wasSuccessful():
1960
1963
  self._print("=" * divider_length)
1961
1964
  self._print(
@@ -1972,9 +1975,13 @@ class TerminalConsole(Console):
1972
1975
  )
1973
1976
  for test, _ in result.failures + result.errors:
1974
1977
  if isinstance(test, ModelTest):
1975
- self._print(f"Failure Test: {test.model.name} {test.test_name}")
1978
+ self._print(f"Failure Test: {test.path}::{test.test_name}")
1976
1979
  self._print("=" * divider_length)
1977
- self._print(output)
1980
+
1981
+ def _captured_unit_test_results(self, result: ModelTextTestResult) -> str:
1982
+ with self.console.capture() as capture:
1983
+ self._log_test_details(result)
1984
+ return strip_ansi_codes(capture.get())
1978
1985
 
1979
1986
  def show_sql(self, sql: str) -> None:
1980
1987
  self._print(Syntax(sql, "sql", word_wrap=True), crop=False)
@@ -2492,6 +2499,63 @@ class TerminalConsole(Console):
2492
2499
  else:
2493
2500
  self.log_warning(msg)
2494
2501
 
2502
+ def _log_test_details(self, result: ModelTextTestResult) -> None:
2503
+ """
2504
+ This is a helper method that encapsulates the logic for logging the relevant unittest for the result.
2505
+ The top level method (`log_test_results`) reuses `_log_test_details` differently based on the console.
2506
+
2507
+ Args:
2508
+ result: The unittest test result that contains metrics like num success, fails, ect.
2509
+ """
2510
+ tests_run = result.testsRun
2511
+ errors = result.errors
2512
+ failures = result.failures
2513
+ skipped = result.skipped
2514
+ is_success = not (errors or failures)
2515
+
2516
+ infos = []
2517
+ if failures:
2518
+ infos.append(f"failures={len(failures)}")
2519
+ if errors:
2520
+ infos.append(f"errors={len(errors)}")
2521
+ if skipped:
2522
+ infos.append(f"skipped={skipped}")
2523
+
2524
+ self._print("\n", end="")
2525
+
2526
+ for (test_case, failure), test_failure_tables in zip_longest( # type: ignore
2527
+ failures, result.failure_tables
2528
+ ):
2529
+ self._print(unittest.TextTestResult.separator1)
2530
+ self._print(f"FAIL: {test_case}")
2531
+
2532
+ if test_description := test_case.shortDescription():
2533
+ self._print(test_description)
2534
+ self._print(f"{unittest.TextTestResult.separator2}")
2535
+
2536
+ if not test_failure_tables:
2537
+ self._print(failure)
2538
+ else:
2539
+ for failure_table in test_failure_tables:
2540
+ self._print(failure_table)
2541
+ self._print("\n", end="")
2542
+
2543
+ for test_case, error in errors:
2544
+ self._print(unittest.TextTestResult.separator1)
2545
+ self._print(f"ERROR: {test_case}")
2546
+ self._print(f"{unittest.TextTestResult.separator2}")
2547
+ self._print(error)
2548
+
2549
+ # Output final report
2550
+ self._print(unittest.TextTestResult.separator2)
2551
+ test_duration_msg = f" in {result.duration:.3f}s" if result.duration else ""
2552
+ self._print(
2553
+ f"\nRan {tests_run} {'tests' if tests_run > 1 else 'test'}{test_duration_msg} \n"
2554
+ )
2555
+ self._print(
2556
+ f"{'OK' if is_success else 'FAILED'}{' (' + ', '.join(infos) + ')' if infos else ''}"
2557
+ )
2558
+
2495
2559
 
2496
2560
  def _cells_match(x: t.Any, y: t.Any) -> bool:
2497
2561
  """Helper function to compare two cells and returns true if they're equal, handling array objects."""
@@ -2763,9 +2827,7 @@ class NotebookMagicConsole(TerminalConsole):
2763
2827
  )
2764
2828
  self.display(radio)
2765
2829
 
2766
- def log_test_results(
2767
- self, result: unittest.result.TestResult, output: t.Optional[str], target_dialect: str
2768
- ) -> None:
2830
+ def log_test_results(self, result: ModelTextTestResult, target_dialect: str) -> None:
2769
2831
  import ipywidgets as widgets
2770
2832
 
2771
2833
  divider_length = 70
@@ -2781,12 +2843,14 @@ class NotebookMagicConsole(TerminalConsole):
2781
2843
  h(
2782
2844
  "span",
2783
2845
  {"style": {**shared_style, **success_color}},
2784
- f"Successfully Ran {str(result.testsRun)} Tests Against {target_dialect}",
2846
+ f"Successfully Ran {str(result.testsRun)} tests against {target_dialect}",
2785
2847
  )
2786
2848
  )
2787
2849
  footer = str(h("span", {"style": shared_style}, "=" * divider_length))
2788
2850
  self.display(widgets.HTML("<br>".join([header, message, footer])))
2789
2851
  else:
2852
+ output = self._captured_unit_test_results(result)
2853
+
2790
2854
  fail_color = {"color": "#db3737"}
2791
2855
  fail_shared_style = {**shared_style, **fail_color}
2792
2856
  header = str(h("span", {"style": fail_shared_style}, "-" * divider_length))
@@ -3137,21 +3201,22 @@ class MarkdownConsole(CaptureTerminalConsole):
3137
3201
  def log_success(self, message: str) -> None:
3138
3202
  self._print(message)
3139
3203
 
3140
- def log_test_results(
3141
- self, result: unittest.result.TestResult, output: t.Optional[str], target_dialect: str
3142
- ) -> None:
3204
+ def log_test_results(self, result: ModelTextTestResult, target_dialect: str) -> None:
3143
3205
  if result.wasSuccessful():
3144
3206
  self._print(
3145
3207
  f"**Successfully Ran `{str(result.testsRun)}` Tests Against `{target_dialect}`**\n\n"
3146
3208
  )
3147
3209
  else:
3210
+ self._print("```")
3211
+ self._log_test_details(result)
3212
+ self._print("```\n\n")
3213
+
3148
3214
  self._print(
3149
3215
  f"**Num Successful Tests: {result.testsRun - len(result.failures) - len(result.errors)}**\n\n"
3150
3216
  )
3151
3217
  for test, _ in result.failures + result.errors:
3152
3218
  if isinstance(test, ModelTest):
3153
3219
  self._print(f"* Failure Test: `{test.model.name}` - `{test.test_name}`\n\n")
3154
- self._print(f"```{output}```\n\n")
3155
3220
 
3156
3221
  def log_skipped_models(self, snapshot_names: t.Set[str]) -> None:
3157
3222
  if snapshot_names:
@@ -3530,9 +3595,7 @@ class DebuggerTerminalConsole(TerminalConsole):
3530
3595
  for modified in context_diff.modified_snapshots:
3531
3596
  self._write(f" Modified: {modified}")
3532
3597
 
3533
- def log_test_results(
3534
- self, result: unittest.result.TestResult, output: t.Optional[str], target_dialect: str
3535
- ) -> None:
3598
+ def log_test_results(self, result: ModelTextTestResult, target_dialect: str) -> None:
3536
3599
  self._write("Test Results:", result)
3537
3600
 
3538
3601
  def show_sql(self, sql: str) -> None:
sqlmesh/core/context.py CHANGED
@@ -40,7 +40,6 @@ import sys
40
40
  import time
41
41
  import traceback
42
42
  import typing as t
43
- import unittest.result
44
43
  from functools import cached_property
45
44
  from io import StringIO
46
45
  from itertools import chain
@@ -2061,7 +2060,7 @@ class GenericContext(BaseContext, t.Generic[C]):
2061
2060
 
2062
2061
  test_meta = self.load_model_tests(tests=tests, patterns=match_patterns)
2063
2062
 
2064
- return run_tests(
2063
+ result = run_tests(
2065
2064
  model_test_metadata=test_meta,
2066
2065
  models=self._models,
2067
2066
  config=self.config,
@@ -2074,6 +2073,13 @@ class GenericContext(BaseContext, t.Generic[C]):
2074
2073
  default_catalog_dialect=self.config.dialect or "",
2075
2074
  )
2076
2075
 
2076
+ self.console.log_test_results(
2077
+ result,
2078
+ self.test_connection_config._engine_adapter.DIALECT,
2079
+ )
2080
+
2081
+ return result
2082
+
2077
2083
  @python_api_analytics
2078
2084
  def audit(
2079
2085
  self,
@@ -2496,28 +2502,20 @@ class GenericContext(BaseContext, t.Generic[C]):
2496
2502
 
2497
2503
  def _run_tests(
2498
2504
  self, verbosity: Verbosity = Verbosity.DEFAULT
2499
- ) -> t.Tuple[unittest.result.TestResult, str]:
2505
+ ) -> t.Tuple[ModelTextTestResult, str]:
2500
2506
  test_output_io = StringIO()
2501
2507
  result = self.test(stream=test_output_io, verbosity=verbosity)
2502
2508
  return result, test_output_io.getvalue()
2503
2509
 
2504
- def _run_plan_tests(
2505
- self, skip_tests: bool = False
2506
- ) -> t.Tuple[t.Optional[unittest.result.TestResult], t.Optional[str]]:
2510
+ def _run_plan_tests(self, skip_tests: bool = False) -> t.Optional[ModelTextTestResult]:
2507
2511
  if not skip_tests:
2508
- result, test_output = self._run_tests()
2509
- if result.testsRun > 0:
2510
- self.console.log_test_results(
2511
- result,
2512
- test_output,
2513
- self.test_connection_config._engine_adapter.DIALECT,
2514
- )
2512
+ result = self.test()
2515
2513
  if not result.wasSuccessful():
2516
2514
  raise PlanError(
2517
2515
  "Cannot generate plan due to failing test(s). Fix test(s) and run again."
2518
2516
  )
2519
- return result, test_output
2520
- return None, None
2517
+ return result
2518
+ return None
2521
2519
 
2522
2520
  @property
2523
2521
  def _model_tables(self) -> t.Dict[str, str]:
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import sys
4
+
3
5
  import datetime
4
6
  import threading
5
7
  import typing as t
@@ -10,6 +12,7 @@ from itertools import chain
10
12
  from pathlib import Path
11
13
  from unittest.mock import patch
12
14
 
15
+
13
16
  from io import StringIO
14
17
  from sqlglot import Dialect, exp
15
18
  from sqlglot.optimizer.annotate_types import annotate_types
@@ -24,6 +27,8 @@ from sqlmesh.utils import UniqueKeyDict, random_id, type_is_known, yaml
24
27
  from sqlmesh.utils.date import date_dict, pandas_timestamp_to_pydatetime, to_datetime
25
28
  from sqlmesh.utils.errors import ConfigError, TestError
26
29
  from sqlmesh.utils.yaml import load as yaml_load
30
+ from sqlmesh.utils import Verbosity
31
+ from sqlmesh.utils.rich import df_to_table
27
32
 
28
33
  if t.TYPE_CHECKING:
29
34
  import pandas as pd
@@ -60,6 +65,7 @@ class ModelTest(unittest.TestCase):
60
65
  preserve_fixtures: bool = False,
61
66
  default_catalog: str | None = None,
62
67
  concurrency: bool = False,
68
+ verbosity: Verbosity = Verbosity.DEFAULT,
63
69
  ) -> None:
64
70
  """ModelTest encapsulates a unit test for a model.
65
71
 
@@ -83,6 +89,7 @@ class ModelTest(unittest.TestCase):
83
89
  self.default_catalog = default_catalog
84
90
  self.dialect = dialect
85
91
  self.concurrency = concurrency
92
+ self.verbosity = verbosity
86
93
 
87
94
  self._fixture_table_cache: t.Dict[str, exp.Table] = {}
88
95
  self._normalized_column_name_cache: t.Dict[str, str] = {}
@@ -134,6 +141,11 @@ class ModelTest(unittest.TestCase):
134
141
 
135
142
  super().__init__()
136
143
 
144
+ def defaultTestResult(self) -> unittest.TestResult:
145
+ from sqlmesh.core.test.result import ModelTextTestResult
146
+
147
+ return ModelTextTestResult(stream=sys.stdout, descriptions=True, verbosity=self.verbosity)
148
+
137
149
  def shortDescription(self) -> t.Optional[str]:
138
150
  return self.body.get("description")
139
151
 
@@ -290,23 +302,53 @@ class ModelTest(unittest.TestCase):
290
302
  check_like=True, # Ignore column order
291
303
  )
292
304
  except AssertionError as e:
305
+ # There are 2 concepts at play here:
306
+ # 1. The Exception args will contain the error message plus the diff dataframe table stringified
307
+ # (backwards compatibility with existing tests, possible to serialize/send over network etc)
308
+ # 2. Each test will also transform these diff dataframes into Rich tables, which will be the ones that'll
309
+ # be surfaced to the user through Console for better UX (versus stringified dataframes)
310
+ #
311
+ # This is a bit of a hack, but it's a way to get the best of both worlds.
312
+ args: t.List[t.Any] = []
293
313
  if expected.shape != actual.shape:
294
314
  _raise_if_unexpected_columns(expected.columns, actual.columns)
295
315
 
296
- error_msg = "Data mismatch (rows are different)"
316
+ args.append("Data mismatch (rows are different)")
297
317
 
298
318
  missing_rows = _row_difference(expected, actual)
299
319
  if not missing_rows.empty:
300
- error_msg += f"\n\nMissing rows:\n\n{missing_rows}"
320
+ args[0] += f"\n\nMissing rows:\n\n{missing_rows}"
321
+ args.append(df_to_table("Missing rows", missing_rows))
301
322
 
302
323
  unexpected_rows = _row_difference(actual, expected)
324
+
303
325
  if not unexpected_rows.empty:
304
- error_msg += f"\n\nUnexpected rows:\n\n{unexpected_rows}"
326
+ args[0] += f"\n\nUnexpected rows:\n\n{unexpected_rows}"
327
+ args.append(df_to_table("Unexpected rows", unexpected_rows))
305
328
 
306
- e.args = (error_msg,)
307
329
  else:
308
330
  diff = expected.compare(actual).rename(columns={"self": "exp", "other": "act"})
309
- e.args = (f"Data mismatch (exp: expected, act: actual)\n\n{diff}",)
331
+
332
+ args.append(f"Data mismatch (exp: expected, act: actual)\n\n{diff}")
333
+
334
+ diff.rename(columns={"exp": "Expected", "act": "Actual"}, inplace=True)
335
+ if self.verbosity == Verbosity.DEFAULT:
336
+ args.extend(
337
+ df_to_table("Data mismatch", df) for df in _split_df_by_column_pairs(diff)
338
+ )
339
+ else:
340
+ from pandas import MultiIndex
341
+
342
+ levels = t.cast(MultiIndex, diff.columns).levels[0]
343
+ for col in levels:
344
+ col_diff = diff[col]
345
+ if not col_diff.empty:
346
+ table = df_to_table(
347
+ f"[bold red]Column '{col}' mismatch[/bold red]", col_diff
348
+ )
349
+ args.append(table)
350
+
351
+ e.args = (*args,)
310
352
 
311
353
  raise e
312
354
 
@@ -328,6 +370,7 @@ class ModelTest(unittest.TestCase):
328
370
  preserve_fixtures: bool = False,
329
371
  default_catalog: str | None = None,
330
372
  concurrency: bool = False,
373
+ verbosity: Verbosity = Verbosity.DEFAULT,
331
374
  ) -> t.Optional[ModelTest]:
332
375
  """Create a SqlModelTest or a PythonModelTest.
333
376
 
@@ -373,6 +416,7 @@ class ModelTest(unittest.TestCase):
373
416
  preserve_fixtures,
374
417
  default_catalog,
375
418
  concurrency,
419
+ verbosity,
376
420
  )
377
421
  except Exception as e:
378
422
  raise TestError(f"Failed to create test {test_name} ({path})\n{str(e)}")
@@ -692,6 +736,7 @@ class PythonModelTest(ModelTest):
692
736
  preserve_fixtures: bool = False,
693
737
  default_catalog: str | None = None,
694
738
  concurrency: bool = False,
739
+ verbosity: Verbosity = Verbosity.DEFAULT,
695
740
  ) -> None:
696
741
  """PythonModelTest encapsulates a unit test for a Python model.
697
742
 
@@ -718,6 +763,7 @@ class PythonModelTest(ModelTest):
718
763
  preserve_fixtures,
719
764
  default_catalog,
720
765
  concurrency,
766
+ verbosity,
721
767
  )
722
768
 
723
769
  self.context = TestExecutionContext(
@@ -951,3 +997,43 @@ def _normalize_df_value(value: t.Any) -> t.Any:
951
997
  return {k: _normalize_df_value(v) for k, v in zip(value["key"], value["value"])}
952
998
  return {k: _normalize_df_value(v) for k, v in value.items()}
953
999
  return value
1000
+
1001
+
1002
+ def _split_df_by_column_pairs(df: pd.DataFrame, pairs_per_chunk: int = 4) -> t.List[pd.DataFrame]:
1003
+ """Split a dataframe into chunks of column pairs.
1004
+
1005
+ Args:
1006
+ df: The dataframe to split
1007
+ pairs_per_chunk: Number of column pairs per chunk (default: 4)
1008
+
1009
+ Returns:
1010
+ List of dataframes, each containing an even number of columns
1011
+ """
1012
+ total_columns = len(df.columns)
1013
+
1014
+ # If we have fewer columns than pairs_per_chunk * 2, return the original df
1015
+ if total_columns <= pairs_per_chunk * 2:
1016
+ return [df]
1017
+
1018
+ # Calculate number of chunks needed to split columns evenly
1019
+ num_chunks = (total_columns + (pairs_per_chunk * 2 - 1)) // (pairs_per_chunk * 2)
1020
+
1021
+ # Calculate columns per chunk to ensure equal distribution
1022
+ # We round down to nearest even number to ensure each chunk has even columns
1023
+ columns_per_chunk = (total_columns // num_chunks) & ~1 # Round down to nearest even number
1024
+ remainder = total_columns - (columns_per_chunk * num_chunks)
1025
+
1026
+ chunks = []
1027
+ start_idx = 0
1028
+
1029
+ # Distribute columns evenly across chunks
1030
+ for i in range(num_chunks):
1031
+ # Add 2 columns to early chunks if there's a remainder
1032
+ # This ensures we always add pairs of columns
1033
+ extra = 2 if i < remainder // 2 else 0
1034
+ end_idx = start_idx + columns_per_chunk + extra
1035
+ chunk = df.iloc[:, start_idx:end_idx]
1036
+ chunks.append(chunk)
1037
+ start_idx = end_idx
1038
+
1039
+ return chunks
@@ -15,10 +15,13 @@ class ModelTextTestResult(unittest.TextTestResult):
15
15
  successes: t.List[unittest.TestCase]
16
16
 
17
17
  def __init__(self, *args: t.Any, **kwargs: t.Any):
18
+ self.console = kwargs.pop("console", None)
18
19
  super().__init__(*args, **kwargs)
19
20
  self.successes = []
20
21
  self.original_failures: t.List[t.Tuple[unittest.TestCase, ErrorType]] = []
22
+ self.failure_tables: t.List[t.Tuple[t.Any, ...]] = []
21
23
  self.original_errors: t.List[t.Tuple[unittest.TestCase, ErrorType]] = []
24
+ self.duration: t.Optional[float] = None
22
25
 
23
26
  def addSubTest(
24
27
  self,
@@ -41,6 +44,12 @@ class ModelTextTestResult(unittest.TextTestResult):
41
44
 
42
45
  super().addSubTest(test, subtest, err)
43
46
 
47
+ def _print_char(self, char: str) -> None:
48
+ from sqlmesh.core.console import TerminalConsole
49
+
50
+ if isinstance(self.console, TerminalConsole):
51
+ self.console._print(char, end="")
52
+
44
53
  def addFailure(self, test: unittest.TestCase, err: ErrorType) -> None:
45
54
  """Called when the test case test signals a failure.
46
55
 
@@ -51,7 +60,18 @@ class ModelTextTestResult(unittest.TextTestResult):
51
60
  err: A tuple of the form returned by sys.exc_info(), i.e., (type, value, traceback).
52
61
  """
53
62
  exctype, value, _ = err
63
+
64
+ if value and value.args:
65
+ exception_msg, rich_tables = value.args[:1], value.args[1:]
66
+ value.args = exception_msg
67
+
68
+ if rich_tables:
69
+ self.failure_tables.append(rich_tables)
70
+
71
+ self._print_char("F")
72
+
54
73
  self.original_failures.append((test, err))
74
+
55
75
  # Intentionally ignore the traceback to hide it from the user
56
76
  return super().addFailure(test, (exctype, value, None)) # type: ignore
57
77
 
@@ -64,6 +84,9 @@ class ModelTextTestResult(unittest.TextTestResult):
64
84
  """
65
85
  exctype, value, _ = err
66
86
  self.original_errors.append((test, err))
87
+
88
+ self._print_char("E")
89
+
67
90
  # Intentionally ignore the traceback to hide it from the user
68
91
  return super().addError(test, (exctype, value, None)) # type: ignore
69
92
 
@@ -74,52 +97,24 @@ class ModelTextTestResult(unittest.TextTestResult):
74
97
  test: The test case
75
98
  """
76
99
  super().addSuccess(test)
77
- self.successes.append(test)
78
100
 
79
- def log_test_report(self, test_duration: float) -> None:
80
- """
81
- Log the test report following unittest's conventions.
101
+ self._print_char(".")
82
102
 
83
- Args:
84
- test_duration: The duration of the tests.
85
- """
86
- tests_run = self.testsRun
87
- errors = self.errors
88
- failures = self.failures
89
- skipped = self.skipped
90
-
91
- is_success = not (errors or failures)
92
-
93
- infos = []
94
- if failures:
95
- infos.append(f"failures={len(failures)}")
96
- if errors:
97
- infos.append(f"errors={len(errors)}")
98
- if skipped:
99
- infos.append(f"skipped={skipped}")
100
-
101
- stream = self.stream
102
-
103
- stream.write("\n")
104
-
105
- for test_case, failure in failures:
106
- stream.writeln(unittest.TextTestResult.separator1)
107
- stream.writeln(f"FAIL: {test_case}")
108
- if test_description := test_case.shortDescription():
109
- stream.writeln(test_description)
110
- stream.writeln(unittest.TextTestResult.separator2)
111
- stream.writeln(failure)
112
-
113
- for test_case, error in errors:
114
- stream.writeln(unittest.TextTestResult.separator1)
115
- stream.writeln(f"ERROR: {test_case}")
116
- stream.writeln(error)
117
-
118
- # Output final report
119
- stream.writeln(unittest.TextTestResult.separator2)
120
- stream.writeln(
121
- f"Ran {tests_run} {'tests' if tests_run > 1 else 'test'} in {test_duration:.3f}s \n"
122
- )
123
- stream.writeln(
124
- f"{'OK' if is_success else 'FAILED'}{' (' + ', '.join(infos) + ')' if infos else ''}"
125
- )
103
+ self.successes.append(test)
104
+
105
+ def merge(self, other: ModelTextTestResult) -> None:
106
+ if other.successes:
107
+ self.addSuccess(other.successes[0])
108
+ elif other.errors:
109
+ for error_test, error in other.original_errors:
110
+ self.addError(error_test, error)
111
+ elif other.failures:
112
+ for failure_test, failure in other.original_failures:
113
+ self.addFailure(failure_test, failure)
114
+
115
+ self.failure_tables.extend(other.failure_tables)
116
+ elif other.skipped:
117
+ skipped_args = other.skipped[0]
118
+ self.addSkip(skipped_args[0], skipped_args[1])
119
+
120
+ self.testsRun += 1
@@ -1,10 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- import sys
4
3
  import time
5
4
  import threading
6
5
  import typing as t
7
6
  import unittest
7
+ from io import StringIO
8
8
 
9
9
  import concurrent
10
10
  from concurrent.futures import ThreadPoolExecutor
@@ -16,7 +16,6 @@ from sqlmesh.core.test.discovery import (
16
16
  ModelTestMetadata as ModelTestMetadata,
17
17
  )
18
18
  from sqlmesh.core.config.connection import BaseDuckDBConnectionConfig
19
-
20
19
  from sqlmesh.core.test.result import ModelTextTestResult as ModelTextTestResult
21
20
  from sqlmesh.utils import UniqueKeyDict, Verbosity
22
21
 
@@ -106,10 +105,13 @@ def run_tests(
106
105
 
107
106
  lock = threading.Lock()
108
107
 
108
+ from sqlmesh.core.console import get_console
109
+
109
110
  combined_results = ModelTextTestResult(
110
- stream=unittest.runner._WritelnDecorator(stream or sys.stderr), # type: ignore
111
+ stream=unittest.runner._WritelnDecorator(stream or StringIO()), # type: ignore
111
112
  verbosity=2 if verbosity >= Verbosity.VERBOSE else 1,
112
113
  descriptions=True,
114
+ console=get_console(),
113
115
  )
114
116
 
115
117
  metadata_to_adapter = create_testing_engine_adapters(
@@ -136,6 +138,7 @@ def run_tests(
136
138
  default_catalog=default_catalog,
137
139
  preserve_fixtures=preserve_fixtures,
138
140
  concurrency=num_workers > 1,
141
+ verbosity=verbosity,
139
142
  )
140
143
 
141
144
  if not test:
@@ -147,19 +150,7 @@ def run_tests(
147
150
  )
148
151
 
149
152
  with lock:
150
- if result.successes:
151
- combined_results.addSuccess(result.successes[0])
152
- elif result.errors:
153
- for error_test, error in result.original_errors:
154
- combined_results.addError(error_test, error)
155
- elif result.failures:
156
- for failure_test, failure in result.original_failures:
157
- combined_results.addFailure(failure_test, failure)
158
- elif result.skipped:
159
- skipped_args = result.skipped[0]
160
- combined_results.addSkip(skipped_args[0], skipped_args[1])
161
-
162
- combined_results.testsRun += 1
153
+ combined_results.merge(result)
163
154
 
164
155
  return result
165
156
 
@@ -183,6 +174,6 @@ def run_tests(
183
174
 
184
175
  end_time = time.perf_counter()
185
176
 
186
- combined_results.log_test_report(test_duration=end_time - start_time)
177
+ combined_results.duration = end_time - start_time
187
178
 
188
179
  return combined_results
@@ -63,20 +63,19 @@ def check_required_approvers(ctx: click.Context) -> None:
63
63
  def _run_tests(controller: GithubController) -> bool:
64
64
  controller.update_test_check(status=GithubCheckStatus.IN_PROGRESS)
65
65
  try:
66
- result, output = controller.run_tests()
66
+ result, _ = controller.run_tests()
67
67
  controller.update_test_check(
68
68
  status=GithubCheckStatus.COMPLETED,
69
69
  # Conclusion will be updated with final status based on test results
70
70
  conclusion=GithubCheckConclusion.NEUTRAL,
71
71
  result=result,
72
- output=output,
73
72
  )
74
73
  return result.wasSuccessful()
75
74
  except Exception:
76
75
  controller.update_test_check(
77
76
  status=GithubCheckStatus.COMPLETED,
78
77
  conclusion=GithubCheckConclusion.FAILURE,
79
- output=traceback.format_exc(),
78
+ traceback=traceback.format_exc(),
80
79
  )
81
80
  return False
82
81
 
@@ -8,7 +8,6 @@ import pathlib
8
8
  import re
9
9
  import traceback
10
10
  import typing as t
11
- import unittest
12
11
  from enum import Enum
13
12
  from typing import List
14
13
  from pathlib import Path
@@ -20,6 +19,7 @@ from sqlglot.helper import seq_get
20
19
  from sqlmesh.core import constants as c
21
20
  from sqlmesh.core.console import SNAPSHOT_CHANGE_CATEGORY_STR, get_console, MarkdownConsole
22
21
  from sqlmesh.core.context import Context
22
+ from sqlmesh.core.test.result import ModelTextTestResult
23
23
  from sqlmesh.core.environment import Environment
24
24
  from sqlmesh.core.plan import Plan, PlanBuilder
25
25
  from sqlmesh.core.snapshot.definition import (
@@ -494,7 +494,7 @@ class GithubController:
494
494
  except PlanError as e:
495
495
  return f"Plan failed to generate. Check for pending or unresolved changes. Error: {e}"
496
496
 
497
- def run_tests(self) -> t.Tuple[unittest.result.TestResult, str]:
497
+ def run_tests(self) -> t.Tuple[ModelTextTestResult, str]:
498
498
  """
499
499
  Run tests for the PR
500
500
  """
@@ -734,8 +734,8 @@ class GithubController:
734
734
  self,
735
735
  status: GithubCheckStatus,
736
736
  conclusion: t.Optional[GithubCheckConclusion] = None,
737
- result: t.Optional[unittest.result.TestResult] = None,
738
- output: t.Optional[str] = None,
737
+ result: t.Optional[ModelTextTestResult] = None,
738
+ traceback: t.Optional[str] = None,
739
739
  ) -> None:
740
740
  """
741
741
  Updates the status of tests for code in the PR
@@ -743,15 +743,13 @@ class GithubController:
743
743
 
744
744
  def conclusion_handler(
745
745
  conclusion: GithubCheckConclusion,
746
- result: t.Optional[unittest.result.TestResult],
747
- output: t.Optional[str],
746
+ result: t.Optional[ModelTextTestResult],
748
747
  ) -> t.Tuple[GithubCheckConclusion, str, t.Optional[str]]:
749
748
  if result:
750
749
  # Clear out console
751
750
  self._console.consume_captured_output()
752
751
  self._console.log_test_results(
753
752
  result,
754
- output,
755
753
  self._context.test_connection_config._engine_adapter.DIALECT,
756
754
  )
757
755
  test_summary = self._console.consume_captured_output()
@@ -762,8 +760,11 @@ class GithubController:
762
760
  else GithubCheckConclusion.FAILURE
763
761
  )
764
762
  return test_conclusion, test_title, test_summary
763
+ if traceback:
764
+ self._console._print(traceback)
765
+
765
766
  test_title = "Skipped Tests" if conclusion.is_skipped else "Tests Failed"
766
- return conclusion, test_title, output
767
+ return conclusion, test_title, traceback
767
768
 
768
769
  self._update_check_handler(
769
770
  check_name="SQLMesh - Run Unit Tests",
@@ -776,7 +777,7 @@ class GithubController:
776
777
  }[status],
777
778
  None,
778
779
  ),
779
- conclusion_handler=functools.partial(conclusion_handler, result=result, output=output),
780
+ conclusion_handler=functools.partial(conclusion_handler, result=result),
780
781
  )
781
782
 
782
783
  def update_required_approval_check(
sqlmesh/magics.py CHANGED
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from io import StringIO
4
+
3
5
  import functools
4
6
  import logging
5
7
  import typing as t
@@ -1032,6 +1034,7 @@ class SQLMeshMagics(Magics):
1032
1034
  tests=args.tests,
1033
1035
  verbosity=Verbosity(args.verbose),
1034
1036
  preserve_fixtures=args.preserve_fixtures,
1037
+ stream=StringIO(), # consume the output instead of redirecting to stdout
1035
1038
  )
1036
1039
 
1037
1040
  @magic_arguments()
sqlmesh/utils/rich.py CHANGED
@@ -1,8 +1,17 @@
1
+ from __future__ import annotations
2
+
1
3
  import typing as t
2
4
 
5
+ import re
6
+
3
7
  from rich.console import Console
4
8
  from rich.progress import Column, ProgressColumn, Task, Text
5
9
  from rich.theme import Theme
10
+ from rich.table import Table
11
+ from rich.align import Align
12
+
13
+ if t.TYPE_CHECKING:
14
+ import pandas as pd
6
15
 
7
16
  theme = Theme(
8
17
  {
@@ -46,3 +55,50 @@ class BatchColumn(ProgressColumn):
46
55
  f"{completed:{total_width}d}{self.separator}{total}",
47
56
  style="progress.download",
48
57
  )
58
+
59
+
60
+ def strip_ansi_codes(text: str) -> str:
61
+ """Strip ANSI color codes and styling from text."""
62
+ ansi_escape = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
63
+ return ansi_escape.sub("", text).strip()
64
+
65
+
66
+ def df_to_table(
67
+ header: str,
68
+ df: pd.DataFrame,
69
+ show_index: bool = True,
70
+ index_name: str = "Row",
71
+ ) -> Table:
72
+ """Convert a pandas.DataFrame obj into a rich.Table obj.
73
+ Args:
74
+ df (DataFrame): A Pandas DataFrame to be converted to a rich Table.
75
+ rich_table (Table): A rich Table that should be populated by the DataFrame values.
76
+ show_index (bool): Add a column with a row count to the table. Defaults to True.
77
+ index_name (str, optional): The column name to give to the index column. Defaults to None, showing no value.
78
+ Returns:
79
+ Table: The rich Table instance passed, populated with the DataFrame values."""
80
+
81
+ rich_table = Table(title=f"[bold red]{header}[/bold red]", show_lines=True, min_width=60)
82
+ if show_index:
83
+ index_name = str(index_name) if index_name else ""
84
+ rich_table.add_column(Align.center(index_name))
85
+
86
+ for column in df.columns:
87
+ column_name = column if isinstance(column, str) else ": ".join(str(col) for col in column)
88
+
89
+ # Color coding unit test columns (expected/actual), can be removed or refactored if df_to_table is used elswhere too
90
+ lower = column_name.lower()
91
+ if "expected" in lower:
92
+ column_name = f"[green]{column_name}[/green]"
93
+ elif "actual" in lower:
94
+ column_name = f"[red]{column_name}[/red]"
95
+
96
+ rich_table.add_column(Align.center(column_name))
97
+
98
+ for index, value_list in enumerate(df.values.tolist()):
99
+ row = [str(index)] if show_index else []
100
+ row += [str(x) for x in value_list]
101
+ center = [Align.center(x) for x in row]
102
+ rich_table.add_row(*center)
103
+
104
+ return rich_table
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlmesh
3
- Version: 0.193.1.dev3
3
+ Version: 0.193.1.dev5
4
4
  Summary: Next-generation data transformation framework
5
5
  Author-email: "TobikoData Inc." <engineering@tobikodata.com>
6
6
  License: Apache License
@@ -1,6 +1,6 @@
1
1
  sqlmesh/__init__.py,sha256=QjwaamJIdW7voXoS880yAx7tHm-Im4_mqGA-XJgafos,7631
2
- sqlmesh/_version.py,sha256=A-0fd39FVh3bu3l1Lc2d2q9sXWSPcjbiYwszIxgWC5M,528
3
- sqlmesh/magics.py,sha256=gjzoxREb1XT8Yho_PQMlMqhhkLq_sEfBM8S3PTgxvWE,40337
2
+ sqlmesh/_version.py,sha256=-vQeNkqxpJKSmc2IWN3JZZ9rI-Wo6DrJsVeyc_gNCP4,528
3
+ sqlmesh/magics.py,sha256=ysPC5ohXpgQ8IHQPwtQtwGoVypmVq_YBlm1noUueDIw,40448
4
4
  sqlmesh/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
5
  sqlmesh/cicd/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  sqlmesh/cicd/bot.py,sha256=2zlbn-DXkqQzr3lA0__IGU4XaIfXBXBKLWXNI2DRJX8,759
@@ -11,9 +11,9 @@ sqlmesh/cli/main.py,sha256=CM97CwI_DkrcE6TfJOwpiAaheYDr8WE7Tl-8asgV-Yw,33845
11
11
  sqlmesh/cli/options.py,sha256=aC_L5WnMScKzMU55BD1Y-8R-QzqCkPSYrlCI-payHRA,1333
12
12
  sqlmesh/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
13
  sqlmesh/core/_typing.py,sha256=e1f7xO2aZCy38M_3VoDy-XI3shyd99-IFctgIFSgXWM,497
14
- sqlmesh/core/console.py,sha256=x7qWETjHgu8ZpMtfW8V3bGKByT4nZXDjgtO6xmvAb-I,151143
14
+ sqlmesh/core/console.py,sha256=DO8Ixq6tR4H6EIhpZRvObMclt5qZN9_IMt7OKkWsOZA,153591
15
15
  sqlmesh/core/constants.py,sha256=pnQXs5cS61zqjS8szbA1IRYTZ_G5jIt8Qrw-gneQoqE,2594
16
- sqlmesh/core/context.py,sha256=kcs8dT3juyRmJZNG1gCa-4yrmApBfwuAD9JcwPeOQbU,118289
16
+ sqlmesh/core/context.py,sha256=kbFRzNM-5HxrgmMrr_dgOhhCryWbVan4ASkS3QAF0hw,118099
17
17
  sqlmesh/core/context_diff.py,sha256=TCzM8egBE35DskpouOB-GQM2Qf5iUHWbRgiB5yZ6tzg,21552
18
18
  sqlmesh/core/dialect.py,sha256=93cXYVuNoZ9CvJNTVn2Yz7WDOdHINwVXflbn0eZ-p14,49764
19
19
  sqlmesh/core/environment.py,sha256=bNj-JCG_l13dwrp--YPIuGNsuWLTMLcFA5sY8IazVK8,12917
@@ -119,10 +119,10 @@ sqlmesh/core/state_sync/db/utils.py,sha256=204OpT3ISFPCwWP9Aqq8nr6ETl24sbG5uOyo8
119
119
  sqlmesh/core/state_sync/db/version.py,sha256=fl5g1tNMYu8O6WTN4J3bciwjGq0c5nZlRDlxxRKGU78,2210
120
120
  sqlmesh/core/test/__init__.py,sha256=e83TJPwPRR_rAG29Y0OVbZb-5oWVBzz-_wrcd22Qk10,418
121
121
  sqlmesh/core/test/context.py,sha256=Shmft5gRWCsk8Hropfmd4otWkLmw43r_uzrr6Azu5Ns,2220
122
- sqlmesh/core/test/definition.py,sha256=_JLs5JkoVfor_LuDz4adJ4xetH8yTc8a8-ZYS-lYjZ0,37729
122
+ sqlmesh/core/test/definition.py,sha256=1V6EoaEpAWwiMpJIV630h-HcSTTQQuTZVhua7VWL4So,41301
123
123
  sqlmesh/core/test/discovery.py,sha256=WXIwPidLoacAGHoT_3TVFFEKamG89nHNtaMsZ01uGjs,1188
124
- sqlmesh/core/test/result.py,sha256=CR8c_tdBK7p8jx-dMibbj4zCtxVAmE_Xah9GQ5oPBTE,4218
125
- sqlmesh/core/test/runner.py,sha256=yno0GDUW9angCy2ZeLIZE9lKrqBiJOnFibu6RLuct6I,6647
124
+ sqlmesh/core/test/result.py,sha256=IIyR4uMDDFJ9Hf57iwrHgZ_W_tLwnr0KSWKeBJ0Pc6k,3961
125
+ sqlmesh/core/test/runner.py,sha256=etwyGICHdPunx5-dUkC8oAA6khSQK5VpAztM4Yz9UgA,6159
126
126
  sqlmesh/dbt/__init__.py,sha256=KUv-lW5sG9D2ceXAIzA4MLcjyhzq3E-7qJP4P_PH2EU,144
127
127
  sqlmesh/dbt/adapter.py,sha256=pV8b8m_wqK3YJo_6D5b6cZBwgewFAPvAlvLO51UCBl4,16948
128
128
  sqlmesh/dbt/basemodel.py,sha256=XuiK9-r5JWzvZGEk98Jt4ukR2exfMDsotGZqYx5xluc,12561
@@ -162,9 +162,9 @@ sqlmesh/integrations/llm.py,sha256=URgp3JKr06HYv6hGlJ7DQ9m3QCc41xRWK23HzmGpbDI,1
162
162
  sqlmesh/integrations/slack.py,sha256=nxLxu5WztGbZH3JdqnzyPqmJUMfRdJ_49LQ7zR-u39Q,6801
163
163
  sqlmesh/integrations/github/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
164
164
  sqlmesh/integrations/github/cicd/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
165
- sqlmesh/integrations/github/cicd/command.py,sha256=rNWb-aMOA9R23YumD5vPwR0PgPGGj8KwG6KG1ftdqV0,11586
165
+ sqlmesh/integrations/github/cicd/command.py,sha256=bQCLLzWS3pZQpATWADPQE_TpDZTHOt0qYwxcNm3St0Q,11557
166
166
  sqlmesh/integrations/github/cicd/config.py,sha256=tRhMZaLf1p6AWXvi67n85Q-lptJNGt1Wk_Jr61ytpiM,2104
167
- sqlmesh/integrations/github/cicd/controller.py,sha256=IgUWK9orYSMiElO3GaL6Ja-osFgA7je3RDcLChWUi5s,43769
167
+ sqlmesh/integrations/github/cicd/controller.py,sha256=CvyAjyOTSt9H8ojDLfz_4GDdpHe1hK4DUT7RL_yiybc,43790
168
168
  sqlmesh/lsp/api.py,sha256=_lA_buSPZFIoLJuINbS53LbNOOaHp3orb0uFTE3aCqI,2328
169
169
  sqlmesh/lsp/completions.py,sha256=Nt1XpFL_-QAIRM8BBBC6Ia6YG0hMZriiNJra7nGfD4Q,6676
170
170
  sqlmesh/lsp/context.py,sha256=QGbNL0sifxzVJ7T1U5oWcJ5FXrOrLrVKt1jtH1qM_50,6099
@@ -279,10 +279,10 @@ sqlmesh/utils/migration.py,sha256=R7Ck6FX-LfcujGy7sgS0kDWlK-xAEBODr3gqIQ8gC08,83
279
279
  sqlmesh/utils/pandas.py,sha256=FytTSLcRNtO0-YMvpoDBkkut3yoQJR9mBSKpif8lxug,2497
280
280
  sqlmesh/utils/process.py,sha256=vpXcpJ1eIbivs7gZdwTKSJI7OS6kKBebnaBzz75ykvs,2428
281
281
  sqlmesh/utils/pydantic.py,sha256=I898jAFqa3qU6lG2uPpRebVFncOUo8ZrLWM_R3K3DHU,11685
282
- sqlmesh/utils/rich.py,sha256=xNZyPCqEc4TAlJlsEthg-5eYUWpaizz0QjBzO7a1Yv4,1509
282
+ sqlmesh/utils/rich.py,sha256=BpB47G0-JcU41LkGkeGKroexj4NOV5fox4-GOIXPgIc,3545
283
283
  sqlmesh/utils/windows.py,sha256=r7Yin4WEwbvH7sKA3hOJtRsclmscNuBeCklCfVPvtQs,611
284
284
  sqlmesh/utils/yaml.py,sha256=np-WVcwmwsKghCgAj1OBBMsK1SXSRn4wR-H37cGiFr8,2282
285
- sqlmesh-0.193.1.dev3.dist-info/licenses/LICENSE,sha256=OlMefUjgWJdULtf84BLW0AZZcY8DwdgQqb_1j2862j8,11346
285
+ sqlmesh-0.193.1.dev5.dist-info/licenses/LICENSE,sha256=OlMefUjgWJdULtf84BLW0AZZcY8DwdgQqb_1j2862j8,11346
286
286
  web/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
287
287
  web/client/dist/index.html,sha256=6S_GSRtPwXyr17YSqAWyZ2Prb2puyUa-UdLiZpFtwe4,5472
288
288
  web/client/dist/assets/Audits-Dym5PKHr.js,sha256=UZFlxjPaLxv7b4C494dCxXDNY7918lWEkn2FghwDd4E,1041
@@ -379,7 +379,7 @@ web/client/dist/fonts/Sohne/sohne-mono-kraftig.woff2,sha256=DGsWMljiFHztzyb0Gg_T
379
379
  web/client/src/workers/sqlglot/sqlglot.py,sha256=XAMrvqcRBa7vy8bHVayYbZNyWsf1lNSjVk_odtif-YY,1566
380
380
  web/server/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
381
381
  web/server/app.py,sha256=b6qEJ4btjaSm2kWwQUGV--S3VE8QW0Um7f4rBdaoB_M,59
382
- web/server/console.py,sha256=K2fJpiZbibWaQa2OvN7YbeI9sxkXtkXwA8lJKsXfq-k,14529
382
+ web/server/console.py,sha256=CJlhoNBVUj_U5h_88gQgDJNzLOe2FAQ1DcFeMdO3H18,14583
383
383
  web/server/exceptions.py,sha256=I2PPpChwhDWawQENr5kHg6DjyRGeiJ6OWZsCzEZhVzs,1533
384
384
  web/server/main.py,sha256=9PaDa8sigo1z0AxtI3sQ3fHmDqs6uXwP-_IvoMwkznw,3199
385
385
  web/server/models.py,sha256=NUI2xqDcav20uuC2BZ9xDL2x0v16-gpVT1Sb-WHfAcg,15518
@@ -389,7 +389,7 @@ web/server/utils.py,sha256=bb6AOM3Nga-zihPjZvJeDL6sHqTPNC_SYjklsy8tAQA,4013
389
389
  web/server/watcher.py,sha256=5biJ8gbHmGNUrooMFWRmw00XDVg0bMx6Hub571cs6TI,5246
390
390
  web/server/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
391
391
  web/server/api/endpoints/__init__.py,sha256=FcXl56-kPqbKgCZKOx0kjE50hn37q1m-nwOCxI9tRcc,932
392
- web/server/api/endpoints/commands.py,sha256=-4Ed0YLpjQEShHSASf0nFkyD3lFrU_CfUlaYD0klDvg,8371
392
+ web/server/api/endpoints/commands.py,sha256=Fj7FUxOuageasij_7-dVvOlJMsBnRmFBWYk_-t91UQI,8242
393
393
  web/server/api/endpoints/directories.py,sha256=Aa8U4pTs3Saj5DnJ9TDnX9mZois-EXgpcBm5GpNNM_s,1822
394
394
  web/server/api/endpoints/environments.py,sha256=g3YC6j4LLlG_wjJqn-Hrz78BWmgS7vhUhzaycsiez00,2124
395
395
  web/server/api/endpoints/events.py,sha256=tjhOhGe9jt7L1mOLLapl5qTvicDRnP5lPZHSq9OnSVI,932
@@ -400,8 +400,8 @@ web/server/api/endpoints/models.py,sha256=ud-lRwriyI_OYg9tZchrBgeiXELiRTLNEodraf
400
400
  web/server/api/endpoints/modules.py,sha256=8hqqgonGay_mJmpCw0IdbjsPhWlQH2VLdKAqha-myac,468
401
401
  web/server/api/endpoints/plan.py,sha256=OYuQ_4dgbhG9ibTcX1a8mvYgH3ANxw9Fhbb4wg6QWqc,9222
402
402
  web/server/api/endpoints/table_diff.py,sha256=SCy3-pr5x6bEEiOaZGCDbiHQhpgxwmnz3Acqa2Ie6jI,2090
403
- sqlmesh-0.193.1.dev3.dist-info/METADATA,sha256=RurSN3vjgivLx8UsHpSqesGx0bqGEcg3UV_jqmGFc2Y,26690
404
- sqlmesh-0.193.1.dev3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
405
- sqlmesh-0.193.1.dev3.dist-info/entry_points.txt,sha256=pIYKtg-u-uO7BK_Y0qrdmfyj0bYrcMc3Xiqr5WE3VFk,121
406
- sqlmesh-0.193.1.dev3.dist-info/top_level.txt,sha256=bspjo5vjELQkuN0qKuTFIbsrUt3Zeo8D2mlKeHG1LdE,12
407
- sqlmesh-0.193.1.dev3.dist-info/RECORD,,
403
+ sqlmesh-0.193.1.dev5.dist-info/METADATA,sha256=QdC-XqtGabVA8cl1VBNe5pd7iDZzRjS99x-1rVSHZys,26690
404
+ sqlmesh-0.193.1.dev5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
405
+ sqlmesh-0.193.1.dev5.dist-info/entry_points.txt,sha256=pIYKtg-u-uO7BK_Y0qrdmfyj0bYrcMc3Xiqr5WE3VFk,121
406
+ sqlmesh-0.193.1.dev5.dist-info/top_level.txt,sha256=bspjo5vjELQkuN0qKuTFIbsrUt3Zeo8D2mlKeHG1LdE,12
407
+ sqlmesh-0.193.1.dev5.dist-info/RECORD,,
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
- import io
5
4
  import typing as t
6
5
 
7
6
  from fastapi import APIRouter, Body, Depends, Request, Response
@@ -142,12 +141,10 @@ async def test(
142
141
  context: Context = Depends(get_loaded_context),
143
142
  ) -> models.TestResult:
144
143
  """Run one or all model tests"""
145
- test_output = io.StringIO()
146
144
  try:
147
145
  result = context.test(
148
146
  tests=[str(context.path / Path(test))] if test else None,
149
147
  verbosity=verbosity,
150
- stream=test_output,
151
148
  )
152
149
  except Exception:
153
150
  import traceback
@@ -157,11 +154,7 @@ async def test(
157
154
  message="Unable to run tests",
158
155
  origin="API -> commands -> test",
159
156
  )
160
- context.console.log_test_results(
161
- result,
162
- test_output.getvalue(),
163
- context.test_connection_config._engine_adapter.DIALECT,
164
- )
157
+ context.console.log_test_results(result, context.test_connection_config._engine_adapter.DIALECT)
165
158
 
166
159
  def _test_path(test: ModelTest) -> t.Optional[str]:
167
160
  if path := test.path_relative_to(context.path):
web/server/console.py CHANGED
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import json
5
5
  import typing as t
6
- import unittest
7
6
  from fastapi.encoders import jsonable_encoder
8
7
  from sse_starlette.sse import ServerSentEvent
9
8
  from sqlmesh.core.snapshot.definition import Interval, Intervals
@@ -12,6 +11,7 @@ from sqlmesh.core.environment import EnvironmentNamingInfo
12
11
  from sqlmesh.core.plan.definition import EvaluatablePlan
13
12
  from sqlmesh.core.snapshot import Snapshot, SnapshotInfoLike, SnapshotTableInfo
14
13
  from sqlmesh.core.test import ModelTest
14
+ from sqlmesh.core.test.result import ModelTextTestResult
15
15
  from sqlmesh.utils.date import now_timestamp
16
16
  from web.server import models
17
17
  from web.server.exceptions import ApiException
@@ -258,9 +258,7 @@ class ApiConsole(TerminalConsole):
258
258
  )
259
259
  )
260
260
 
261
- def log_test_results(
262
- self, result: unittest.result.TestResult, output: t.Optional[str], target_dialect: str
263
- ) -> None:
261
+ def log_test_results(self, result: ModelTextTestResult, target_dialect: str) -> None:
264
262
  if result.wasSuccessful():
265
263
  self.log_event(
266
264
  event=models.EventName.TESTS,
@@ -279,6 +277,8 @@ class ApiConsole(TerminalConsole):
279
277
  details=details,
280
278
  )
281
279
  )
280
+
281
+ output = self._captured_unit_test_results(result)
282
282
  self.log_event(
283
283
  event=models.EventName.TESTS,
284
284
  data=models.ReportTestsFailure(