Skip to content

Commit 5b75b84

Browse files
committed
WIP
1 parent 58eb46a commit 5b75b84

1 file changed

Lines changed: 300 additions & 1 deletion

File tree

src/dbt_core_interface/project.py

Lines changed: 300 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env python
2-
# pyright: reportDeprecated=false,reportPrivateImportUsage=false,reportAny=false,reportUnknownMemberType=false,reportUnknownVariableType=false
2+
# pyright: reportDeprecated=false,reportPrivateImportUsage=false,reportAny=false,reportUnknownMemberType=false,reportUnknownVariableType=false,reportUnnecessaryComparison=false
33
"""Minimal dbt-core interface for in-memory manifest management and SQL execution."""
44

55
from __future__ import annotations
@@ -476,6 +476,305 @@ def get_node_by_path(self, path: Path | str) -> ManifestNode | None:
476476
for node in self.manifest.nodes.values():
477477
if self.project_root / node.original_file_path == path:
478478
return node
479+
return None
480+
481+
def update_node_from_content( # noqa: C901
482+
self, path_: Path | str, /, content: str | None = None, force_update: bool = False
483+
) -> bool:
484+
"""Update a specific node in the manifest from file content or disk."""
485+
path = Path(path_)
486+
if not path.is_absolute():
487+
path = self.project_root / path
488+
path = path.resolve()
489+
490+
if not self._is_dbt_file(path):
491+
return False
492+
493+
if content is None:
494+
try:
495+
content = path.read_text(encoding="utf-8")
496+
except (OSError, UnicodeDecodeError) as e:
497+
logger.warning(f"Failed to read file {path}: {e}")
498+
return False
499+
500+
existing_node = self.get_node_by_path(path)
501+
502+
if not force_update and existing_node and existing_node.raw_code == content:
503+
return True
504+
505+
try:
506+
with self._manifest_lock:
507+
if existing_node:
508+
unique_id = existing_node.unique_id
509+
510+
_ = self.manifest.nodes.pop(unique_id, None)
511+
_ = self.manifest.macros.pop(unique_id, None)
512+
513+
if self._is_macro_file(path):
514+
processed_nodes = self._parse_macro_content(path, content)
515+
else:
516+
processed_nodes = self._parse_model_content(path, content)
517+
518+
for node in processed_nodes:
519+
if node.resource_type == NodeType.Macro:
520+
self.manifest.macros[node.unique_id] = node
521+
else:
522+
self.manifest.nodes[node.unique_id] = node
523+
524+
if hasattr(node, "depends_on"):
525+
process_node(self.runtime_config, self.manifest, node)
526+
527+
if existing_node and existing_node.raw_code in self.__compilation_cache:
528+
del self.__compilation_cache[existing_node.raw_code]
529+
530+
logger.debug(f"Successfully updated node(s) from {path}")
531+
return True
532+
533+
except Exception as e:
534+
logger.error(f"Failed to update node from {path}: {e}")
535+
if existing_node:
536+
if existing_node.resource_type == NodeType.Macro:
537+
self.manifest.macros[existing_node.unique_id] = existing_node
538+
else:
539+
self.manifest.nodes[existing_node.unique_id] = existing_node
540+
return False
541+
542+
def update_nodes_by_paths(self, *paths: Path | str) -> dict[str, bool]:
543+
"""Update multiple nodes from their disk files."""
544+
results: dict[str, bool] = {}
545+
for path in paths:
546+
results[str(path)] = self.update_node_from_content(path)
547+
return results
548+
549+
def detect_new_files(self) -> list[Path]:
550+
"""Detect new dbt files that aren't in the manifest."""
551+
known_files = set()
552+
553+
for node in self.manifest.nodes.values():
554+
known_files.add(self.project_root / node.original_file_path)
555+
for macro in self.manifest.macros.values():
556+
known_files.add(self.project_root / macro.original_file_path)
557+
558+
discovered = []
559+
for pattern in ["**/*.sql", "**/*.py"]:
560+
for path in self.project_root.glob(pattern):
561+
if path.is_file() and self._is_dbt_file(path):
562+
if path not in known_files:
563+
discovered.append(path)
564+
565+
return discovered
566+
567+
def add_new_files(self, *paths: Path | str) -> dict[str, bool]:
568+
"""Add new files to the manifest."""
569+
if not paths:
570+
paths = tuple(self.detect_new_files())
571+
572+
results = {}
573+
for path in paths:
574+
try:
575+
success = self.update_node_from_content(path, force_update=True)
576+
results[str(path)] = success
577+
if success:
578+
logger.info(f"Added new file to manifest: {path}")
579+
except Exception as e:
580+
logger.error(f"Failed to add new file {path}: {e}")
581+
results[str(path)] = False
582+
583+
return results
584+
585+
def create_project_watcher(self, check_interval: float = 2.0) -> DbtProjectWatcher:
586+
"""Create a project watcher for automatic incremental updates."""
587+
return DbtProjectWatcher(self, check_interval)
588+
589+
def _is_dbt_file(self, path: Path) -> bool: # noqa: C901
590+
"""Check if a file is a valid dbt file using runtime config paths."""
591+
if not path.is_file():
592+
return False
593+
594+
parts = path.parts
595+
if any(skip in parts for skip in ["target", "logs", "dbt_packages", ".git", "__pycache__"]):
596+
return False
597+
598+
if path.suffix.lower() not in [".sql", ".py"]:
599+
return False
600+
601+
try:
602+
relative_path = path.relative_to(self.project_root)
603+
path_str = str(relative_path)
604+
605+
for model_path in self.runtime_config.model_paths:
606+
if path_str.startswith(model_path):
607+
return True
608+
609+
for macro_path in self.runtime_config.macro_paths:
610+
if path_str.startswith(macro_path):
611+
return True
612+
613+
for snapshot_path in self.runtime_config.snapshot_paths:
614+
if path_str.startswith(snapshot_path):
615+
return True
616+
617+
except ValueError:
618+
return False
619+
620+
return False
621+
622+
def _is_macro_file(self, path: Path) -> bool:
623+
"""Check if a file is a macro file using runtime config."""
624+
try:
625+
relative_path = str(path.relative_to(self.project_root))
626+
return any(
627+
relative_path.startswith(macro_path)
628+
for macro_path in self.runtime_config.macro_paths
629+
)
630+
except ValueError:
631+
return False
632+
633+
def _parse_macro_content(self, path: Path, content: str) -> list[ManifestNode]:
634+
"""Parse macro content and return list of macro nodes."""
635+
parser = self.macro_parser
636+
nodes = []
637+
638+
try:
639+
parsed_macros = parser.parse_remote(content)
640+
for macro in parsed_macros:
641+
nodes.append(macro)
642+
except Exception as e:
643+
logger.error(f"Failed to parse macro content from {path}: {e}")
644+
raise
645+
646+
return nodes
647+
648+
def _parse_model_content(self, path: Path, content: str) -> list[ManifestNode]:
649+
"""Parse model content and return list of model nodes."""
650+
relative_path = path.relative_to(self.project_root)
651+
652+
parser = self.sql_parser
653+
nodes = []
654+
655+
try:
656+
node = parser.parse_remote(content, str(relative_path))
657+
nodes.append(node)
658+
except Exception as e:
659+
logger.error(f"Failed to parse model content from {path}: {e}")
660+
raise
661+
662+
return nodes
663+
664+
665+
@t.final
666+
class DbtProjectWatcher:
667+
"""Watch dbt files for changes and automatically update the manifest."""
668+
669+
def __init__(self, project: DbtProject, check_interval: float = 2.0):
670+
self.project = project
671+
self.check_interval = check_interval
672+
self._mtimes: dict[Path, float] = {}
673+
self._running = False
674+
self._thread: threading.Thread | None = None
675+
self._stop_event = threading.Event()
676+
677+
def start(self) -> None:
678+
"""Start monitoring files for changes."""
679+
if self._running:
680+
return
681+
682+
self._running = True
683+
self._stop_event.clear()
684+
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
685+
self._thread.start()
686+
logger.info("Project watcher started")
687+
688+
def stop(self) -> None:
689+
"""Stop monitoring files."""
690+
if not self._running:
691+
return
692+
693+
self._running = False
694+
self._stop_event.set()
695+
if self._thread:
696+
self._thread.join(timeout=5.0)
697+
logger.info("Project watcher stopped")
698+
699+
def _monitor_loop(self) -> None:
700+
"""Run the main monitoring loop."""
701+
self._initialize_file_mtimes()
702+
703+
while self._running and not self._stop_event.is_set():
704+
try:
705+
self._check_for_changes()
706+
self._check_for_new_files()
707+
except Exception as e:
708+
logger.error(f"Error in project watcher loop: {e}")
709+
710+
_ = self._stop_event.wait(self.check_interval)
711+
712+
def _initialize_file_mtimes(self) -> None:
713+
"""Initialize the file modification time tracking."""
714+
try:
715+
for node in self.project.manifest.nodes.values():
716+
path = self.project.project_root / node.original_file_path
717+
if path.exists():
718+
self._mtimes[path] = path.stat().st_mtime
719+
720+
for macro in self.project.manifest.macros.values():
721+
path = self.project.project_root / macro.original_file_path
722+
if path.exists():
723+
self._mtimes[path] = path.stat().st_mtime
724+
725+
logger.debug(f"Initialized tracking for {len(self._mtimes)} files")
726+
except Exception as e:
727+
logger.error(f"Failed to initialize file mtimes: {e}")
728+
729+
def _check_for_changes(self) -> None:
730+
"""Check for changes in tracked files."""
731+
edits: list[Path] = []
732+
733+
for path, stamped_mtime in list(self._mtimes.items()):
734+
try:
735+
if not path.exists():
736+
_ = self._mtimes.pop(path, None)
737+
logger.info(f"File deleted: {path}")
738+
continue
739+
740+
current_mtime = path.stat().st_mtime
741+
if current_mtime > stamped_mtime:
742+
edits.append(path)
743+
self._mtimes[path] = current_mtime
744+
745+
except OSError as e:
746+
logger.warning(f"Error checking file {path}: {e}")
747+
continue
748+
749+
if edits:
750+
logger.info(f"Detected changes in {len(edits)} files")
751+
results = self.project.update_nodes_by_paths(*edits)
752+
753+
for path, success in results.items():
754+
if success:
755+
logger.info(f"Successfully updated: {path}")
756+
else:
757+
logger.error(f"Failed to update: {path}")
758+
759+
def _check_for_new_files(self) -> None:
760+
"""Check for new dbt files."""
761+
try:
762+
added_files = self.project.detect_new_files()
763+
if added_files:
764+
logger.info(f"Detected {len(added_files)} new files")
765+
results = self.project.add_new_files(*added_files)
766+
767+
for path, success in results.items():
768+
if success:
769+
path = Path(path)
770+
if path.exists():
771+
self._mtimes[path] = path.stat().st_mtime
772+
logger.info(f"Successfully added new file: {path}")
773+
else:
774+
logger.error(f"Failed to add new file: {path}")
775+
776+
except Exception as e:
777+
logger.error(f"Error checking for new files: {e}")
479778

480779

481780
# Import protection for optional dependencies

0 commit comments

Comments
 (0)