1
+ import datetime as dt
1
2
import json
2
3
import logging
3
4
import os
6
7
import sys
7
8
import tempfile
8
9
import typing as t
9
- from dataclasses import dataclass
10
+ from dataclasses import dataclass , field
10
11
11
12
import duckdb
12
13
import polars
@@ -41,8 +42,32 @@ def sample_project_root() -> t.Iterator[str]:
41
42
"""Creates a temporary project directory containing both SQLMesh and Dagster projects"""
42
43
with tempfile .TemporaryDirectory () as tmp_dir :
43
44
project_dir = shutil .copytree ("sample" , tmp_dir , dirs_exist_ok = True )
45
+
44
46
yield project_dir
45
47
48
+ # Create debug directory with timestamp AFTER test run
49
+ debug_dir = os .path .join (
50
+ os .path .dirname (os .path .dirname (__file__ )), "debug_runs"
51
+ )
52
+ os .makedirs (debug_dir , exist_ok = True )
53
+ timestamp = dt .datetime .now ().strftime ("%Y%m%d_%H%M%S" )
54
+ run_debug_dir = os .path .join (debug_dir , f"run_{ timestamp } " )
55
+
56
+ # Copy contents to debug directory
57
+ try :
58
+ shutil .copytree (tmp_dir , run_debug_dir , dirs_exist_ok = True )
59
+ logger .info (
60
+ f"Copied final test project contents to { run_debug_dir } for debugging"
61
+ )
62
+ except FileNotFoundError :
63
+ logger .warning (
64
+ f"Temporary directory { tmp_dir } not found during cleanup copy."
65
+ )
66
+ except Exception as e :
67
+ logger .error (
68
+ f"Error copying temporary directory { tmp_dir } to { run_debug_dir } : { e } "
69
+ )
70
+
46
71
47
72
@pytest .fixture
48
73
def sample_sqlmesh_project (sample_project_root : str ) -> t .Iterator [str ]:
@@ -75,6 +100,9 @@ class SQLMeshTestContext:
75
100
context_config : SQLMeshContextConfig
76
101
project_path : str
77
102
103
+ # Internal state for backup/restore
104
+ _backed_up_files : set [str ] = field (default_factory = set , init = False )
105
+
78
106
def create_controller (
79
107
self , enable_debug_console : bool = False
80
108
) -> DagsterSQLMeshController :
@@ -160,6 +188,39 @@ def cleanup_modified_files(self) -> None:
160
188
self .restore_model_file (model_name )
161
189
self ._backed_up_files .clear ()
162
190
191
+ def save_sqlmesh_debug_state (self , name_suffix : str = "manual_save" ) -> str :
192
+ """Saves the current state of the SQLMesh project to the debug directory.
193
+
194
+ Copies the contents of the SQLMesh project directory (self.project_path)
195
+ to a timestamped sub-directory within the 'debug_runs' folder.
196
+
197
+ Args:
198
+ name_suffix: An optional suffix to append to the debug directory name
199
+ to distinguish this save point (e.g., 'before_change',
200
+ 'after_plan'). Defaults to 'manual_save'.
201
+
202
+ Returns:
203
+ The path to the created debug state directory.
204
+ """
205
+ debug_dir_base = os .path .join (
206
+ os .path .dirname (self .project_path ), ".." , "debug_runs"
207
+ )
208
+ os .makedirs (debug_dir_base , exist_ok = True )
209
+ timestamp = dt .datetime .now ().strftime ("%Y%m%d_%H%M%S" )
210
+ run_debug_dir = os .path .join (
211
+ debug_dir_base , f"sqlmesh_state_{ timestamp } _{ name_suffix } "
212
+ )
213
+
214
+ try :
215
+ shutil .copytree (self .project_path , run_debug_dir , dirs_exist_ok = True )
216
+ logger .info (f"Saved SQLMesh project debug state to { run_debug_dir } " )
217
+ return run_debug_dir
218
+ except Exception as e :
219
+ logger .error (
220
+ f"Error saving SQLMesh project debug state to { run_debug_dir } : { e } "
221
+ )
222
+ raise
223
+
163
224
def query (self , * args : t .Any , return_df : bool = False , ** kwargs : t .Any ) -> t .Any :
164
225
"""Execute a query against the test database.
165
226
@@ -477,7 +538,8 @@ def model_change_test_context(
477
538
class DagsterTestContext :
478
539
"""A test context for running Dagster"""
479
540
480
- project_path : str
541
+ dagster_project_path : str
542
+ sqlmesh_project_path : str
481
543
482
544
def _run_command (self , cmd : list [str ]) -> None :
483
545
"""Execute a command and stream its output in real-time.
@@ -488,76 +550,93 @@ def _run_command(self, cmd: list[str]) -> None:
488
550
Raises:
489
551
subprocess.CalledProcessError: If the command returns non-zero exit code
490
552
"""
553
+ import io
491
554
import queue
492
555
import threading
493
556
import typing as t
494
557
495
558
def stream_output (
496
- pipe : t .IO [str ], output_queue : queue .Queue [str | None ]
559
+ pipe : t .IO [str ], output_queue : queue .Queue [tuple [ str , str | None ] ]
497
560
) -> None :
498
- """Stream output from a pipe to a queue."""
561
+ """Stream output from a pipe to a queue.
562
+
563
+ Args:
564
+ pipe: The pipe to read from (stdout or stderr)
565
+ output_queue: Queue to write output to, as (stream_type, line) tuples
566
+ """
567
+ # Use a StringIO buffer to accumulate characters into lines
568
+ buffer = io .StringIO ()
569
+ stream_type = "stdout" if pipe is process .stdout else "stderr"
570
+
499
571
try :
500
572
while True :
501
573
char = pipe .read (1 )
502
574
if not char :
575
+ # Flush any remaining content in buffer
576
+ remaining = buffer .getvalue ()
577
+ if remaining :
578
+ output_queue .put ((stream_type , remaining ))
503
579
break
504
- output_queue .put (char )
580
+
581
+ buffer .write (char )
582
+
583
+ # If we hit a newline, flush the buffer
584
+ if char == "\n " :
585
+ output_queue .put ((stream_type , buffer .getvalue ()))
586
+ buffer = io .StringIO ()
505
587
finally :
506
- output_queue .put (None ) # Signal EOF
588
+ buffer .close ()
589
+ output_queue .put ((stream_type , None )) # Signal EOF
507
590
508
591
print (f"Running command: { ' ' .join (cmd )} " )
592
+ print (f"Current working directory: { os .getcwd ()} " )
593
+ print (f"Changing to directory: { self .dagster_project_path } " )
594
+
595
+ # Change to the dagster project directory before running the command
596
+ os .chdir (self .dagster_project_path )
597
+
509
598
process = subprocess .Popen (
510
599
cmd ,
511
600
stdout = subprocess .PIPE ,
512
601
stderr = subprocess .PIPE ,
513
602
text = True ,
514
603
universal_newlines = True ,
604
+ encoding = "utf-8" ,
605
+ errors = "replace" ,
515
606
)
516
607
517
608
if not process .stdout or not process .stderr :
518
609
raise RuntimeError ("Failed to open subprocess pipes" )
519
610
520
- # Create queues for stdout and stderr
521
- stdout_queue : queue .Queue [str | None ] = queue .Queue ()
522
- stderr_queue : queue .Queue [str | None ] = queue .Queue ()
611
+ # Create a single queue for all output
612
+ output_queue : queue .Queue [tuple [str , str | None ]] = queue .Queue ()
523
613
524
614
# Start threads to read from pipes
525
615
stdout_thread = threading .Thread (
526
- target = stream_output , args = (process .stdout , stdout_queue )
616
+ target = stream_output , args = (process .stdout , output_queue )
527
617
)
528
618
stderr_thread = threading .Thread (
529
- target = stream_output , args = (process .stderr , stderr_queue )
619
+ target = stream_output , args = (process .stderr , output_queue )
530
620
)
531
621
532
622
stdout_thread .daemon = True
533
623
stderr_thread .daemon = True
534
624
stdout_thread .start ()
535
625
stderr_thread .start ()
536
626
537
- # Read from queues and print output
538
- stdout_done = False
539
- stderr_done = False
627
+ # Track which streams are still active
628
+ active_streams = {"stdout" , "stderr" }
540
629
541
- while not ( stdout_done and stderr_done ):
542
- # Handle stdout
630
+ # Read from queue and print output
631
+ while active_streams :
543
632
try :
544
- char = stdout_queue . get_nowait ( )
545
- if char is None :
546
- stdout_done = True
633
+ stream_type , content = output_queue . get ( timeout = 0.1 )
634
+ if content is None :
635
+ active_streams . remove ( stream_type )
547
636
else :
548
- print (char , end = "" , flush = True )
637
+ print (content , end = "" , flush = True )
549
638
except queue .Empty :
550
- pass
551
-
552
- # Handle stderr
553
- try :
554
- char = stderr_queue .get_nowait ()
555
- if char is None :
556
- stderr_done = True
557
- else :
558
- print (char , end = "" , flush = True )
559
- except queue .Empty :
560
- pass
639
+ continue
561
640
562
641
stdout_thread .join ()
563
642
stderr_thread .join ()
@@ -583,7 +662,10 @@ def asset_materialisation(
583
662
"resources" : {
584
663
"sqlmesh" : {
585
664
"config" : {
586
- "config" : {"gateway" : "local" , "path" : self .project_path }
665
+ "config" : {
666
+ "gateway" : "local" ,
667
+ "path" : self .sqlmesh_project_path ,
668
+ }
587
669
}
588
670
}
589
671
}
@@ -610,7 +692,7 @@ def asset_materialisation(
610
692
"asset" ,
611
693
"materialize" ,
612
694
"-f" ,
613
- os .path .join (self .project_path , "definitions.py" ),
695
+ os .path .join (self .dagster_project_path , "definitions.py" ),
614
696
"--select" ,
615
697
"," .join (assets ),
616
698
"--config-json" ,
@@ -633,7 +715,10 @@ def sample_dagster_test_context(
633
715
sample_dagster_project : str ,
634
716
) -> t .Iterator [DagsterTestContext ]:
635
717
test_context = DagsterTestContext (
636
- project_path = os .path .join (sample_dagster_project ),
718
+ dagster_project_path = os .path .join (sample_dagster_project ),
719
+ sqlmesh_project_path = os .path .join (
720
+ sample_dagster_project .replace ("dagster_project" , "sqlmesh_project" )
721
+ ),
637
722
)
638
723
yield test_context
639
724
0 commit comments