|
1 | 1 | #!/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 |
3 | 3 | """Minimal dbt-core interface for in-memory manifest management and SQL execution.""" |
4 | 4 |
|
5 | 5 | from __future__ import annotations |
@@ -476,6 +476,305 @@ def get_node_by_path(self, path: Path | str) -> ManifestNode | None: |
476 | 476 | for node in self.manifest.nodes.values(): |
477 | 477 | if self.project_root / node.original_file_path == path: |
478 | 478 | 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}") |
479 | 778 |
|
480 | 779 |
|
481 | 780 | # Import protection for optional dependencies |
|
0 commit comments