dbt-tui 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (50) hide show
  1. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/PKG-INFO +1 -1
  2. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/pyproject.toml +1 -1
  3. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/backend/__init__.py +1 -0
  4. dbt_tui-0.2.0/src/dbtui/backend/metrics.py +75 -0
  5. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/backend/project.py +44 -8
  6. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/backend/property_discovery.py +289 -0
  7. dbt_tui-0.2.0/src/dbtui/frontend/common/timing.py +66 -0
  8. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/main.py +34 -6
  9. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_tree/model_tree.py +33 -8
  10. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/README.md +0 -0
  11. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/__init__.py +0 -0
  12. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/__main__.py +0 -0
  13. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/backend/fetch.py +0 -0
  14. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/backend/model.py +0 -0
  15. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/backend/property_claim.py +0 -0
  16. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/common/__init__.py +0 -0
  17. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/common/cache.py +0 -0
  18. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/common/exceptions.py +0 -0
  19. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/common/model.py +0 -0
  20. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/common/project.py +0 -0
  21. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/__init__.py +0 -0
  22. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/common/__init__.py +0 -0
  23. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/common/dbtui_screen.py +0 -0
  24. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/common/isolated.py +0 -0
  25. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/common/model_list.py +0 -0
  26. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/common/model_list_item.py +0 -0
  27. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/lorem.txt +0 -0
  28. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_search/__init__.py +0 -0
  29. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_search/model_search.py +0 -0
  30. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_search/model_search_input.py +0 -0
  31. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_search/model_search_list.py +0 -0
  32. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_tree/__init__.py +0 -0
  33. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_tree/children_list.py +0 -0
  34. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_tree/constants.py +0 -0
  35. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_tree/model_relatives_list.py +0 -0
  36. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_tree/parents_list.py +0 -0
  37. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_view/__init__.py +0 -0
  38. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_view/model_tree.py +0 -0
  39. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_view/properties_panel.py +0 -0
  40. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/new_model/__init__.py +0 -0
  41. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/new_model/new_model.py +0 -0
  42. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/new_model/select_filepath.py +0 -0
  43. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/options/__init__.py +0 -0
  44. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/options/options.py +0 -0
  45. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/project_search/__init__.py +0 -0
  46. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/project_search/project_search.py +0 -0
  47. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/pseudo/__init__.py +0 -0
  48. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/pseudo/dbt_model.py +0 -0
  49. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/pseudo/dbt_project.py +0 -0
  50. {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/pseudo/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbt-tui
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Terminal UI for exploring and managing dbt projects
5
5
  License: MIT
6
6
  Keywords: dbt,tui,terminal,data,analytics,sql
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dbt-tui"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Terminal UI for exploring and managing dbt projects"
5
5
  authors = [
6
6
  {name = "Mike", email = "sortia@protonmail.com"}
@@ -1,2 +1,3 @@
1
1
  from .project import DbtProject, DbtModelNotFoundException
2
2
  from .model import DbtModel
3
+ from .metrics import LoadMetrics
@@ -0,0 +1,75 @@
1
+ """
2
+ Performance metrics for dbtui.
3
+
4
+ Provides timing instrumentation for project loading and other operations.
5
+ Metrics can be accessed after project load to diagnose performance issues.
6
+ """
7
+
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from typing import Callable, TypeVar, ParamSpec
11
+
12
+ P = ParamSpec('P')
13
+ T = TypeVar('T')
14
+
15
+
16
+ @dataclass
17
+ class LoadMetrics:
18
+ """Metrics collected during project loading."""
19
+ model_count: int = 0
20
+ parse_dbt_project_yml_ms: float = 0.0
21
+ load_models_ms: float = 0.0
22
+ populate_graph_ms: float = 0.0
23
+ collect_property_claims_ms: float = 0.0
24
+ total_load_ms: float = 0.0
25
+
26
+ def __str__(self) -> str:
27
+ return (
28
+ f"Load metrics ({self.model_count} models):\n"
29
+ f" parse_dbt_project.yml: {self.parse_dbt_project_yml_ms:.1f}ms\n"
30
+ f" load_models: {self.load_models_ms:.1f}ms\n"
31
+ f" populate_graph: {self.populate_graph_ms:.1f}ms\n"
32
+ f" collect_property_claims: {self.collect_property_claims_ms:.1f}ms\n"
33
+ f" total: {self.total_load_ms:.1f}ms"
34
+ )
35
+
36
+ def as_dict(self) -> dict:
37
+ """Return metrics as a dictionary for serialization."""
38
+ return {
39
+ 'model_count': self.model_count,
40
+ 'parse_dbt_project_yml_ms': self.parse_dbt_project_yml_ms,
41
+ 'load_models_ms': self.load_models_ms,
42
+ 'populate_graph_ms': self.populate_graph_ms,
43
+ 'collect_property_claims_ms': self.collect_property_claims_ms,
44
+ 'total_load_ms': self.total_load_ms,
45
+ }
46
+
47
+
48
+ class Timer:
49
+ """Context manager for timing code blocks."""
50
+
51
+ def __init__(self):
52
+ self.start_time: float = 0.0
53
+ self.elapsed_ms: float = 0.0
54
+
55
+ def __enter__(self) -> 'Timer':
56
+ self.start_time = time.perf_counter()
57
+ return self
58
+
59
+ def __exit__(self, *args) -> None:
60
+ self.elapsed_ms = (time.perf_counter() - self.start_time) * 1000
61
+
62
+
63
+ def timed(func: Callable[P, T]) -> Callable[P, tuple[T, float]]:
64
+ """
65
+ Decorator that returns both result and elapsed time in ms.
66
+
67
+ Usage:
68
+ result, elapsed_ms = timed(my_function)(arg1, arg2)
69
+ """
70
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> tuple[T, float]:
71
+ start = time.perf_counter()
72
+ result = func(*args, **kwargs)
73
+ elapsed_ms = (time.perf_counter() - start) * 1000
74
+ return result, elapsed_ms
75
+ return wrapper
@@ -16,11 +16,12 @@ InvalidProjectPathException
16
16
 
17
17
  from .model import DbtModel
18
18
  from .property_claim import PropertyClaimAggregate
19
- from .property_discovery import collect_model_claims
19
+ from .property_discovery import collect_model_claims_cached, PropertyDiscoveryCache
20
+ from .metrics import LoadMetrics, Timer
20
21
 
21
22
 
22
23
  class DbtProject(DbtProjectAbstract):
23
- root_folder: Path
24
+ root_folder: Path
24
25
  dbt_project_yml: Generator
25
26
  model_folder: str
26
27
  full_models_paths: list[Path]
@@ -32,6 +33,9 @@ class DbtProject(DbtProjectAbstract):
32
33
  models_by_name: dict[str, DbtModel]
33
34
  models_by_file_name: dict[str, DbtModel]
34
35
 
36
+ # performance metrics from last load
37
+ load_metrics: LoadMetrics | None
38
+
35
39
  def populate_graph(self):
36
40
  self.graph = DiGraph()
37
41
  for model in self.models:
@@ -60,10 +64,17 @@ class DbtProject(DbtProjectAbstract):
60
64
  This method creates a PropertyClaimAggregate for each model and populates
61
65
  it with claims from all sources (dbt_project.yml, schema.yml, model SQL).
62
66
  The aggregates handle precedence resolution lazily when accessed.
67
+
68
+ Uses PropertyDiscoveryCache to read and parse YAML files only once,
69
+ dramatically improving performance for projects with many models.
63
70
  """
71
+ # Initialize cache once for all models
72
+ cache = PropertyDiscoveryCache()
73
+ cache.initialize(self)
74
+
64
75
  for model in self.models:
65
76
  aggregate = PropertyClaimAggregate(model)
66
- claims = collect_model_claims(model)
77
+ claims = collect_model_claims_cached(model, cache)
67
78
  aggregate.add_all(claims)
68
79
  model.property_claims = aggregate
69
80
 
@@ -90,12 +101,36 @@ class DbtProject(DbtProjectAbstract):
90
101
  def refresh(self):
91
102
  if not os.path.exists(self.root_folder):
92
103
  raise FileNotFoundError("Folder not found: %s" % self.root_folder)
104
+
105
+ total_timer = Timer()
106
+ self.load_metrics = LoadMetrics()
107
+
93
108
  try:
94
- with open(self.root_folder / 'dbt_project.yml', 'r', encoding='utf-8') as f:
95
- self.parse_dbt_project(f.read())
96
- self.load_models()
97
- self.populate_graph()
98
- self.collect_property_claims()
109
+ with total_timer:
110
+ # Parse dbt_project.yml
111
+ with Timer() as t:
112
+ with open(self.root_folder / 'dbt_project.yml', 'r', encoding='utf-8') as f:
113
+ self.parse_dbt_project(f.read())
114
+ self.load_metrics.parse_dbt_project_yml_ms = t.elapsed_ms
115
+
116
+ # Load models
117
+ with Timer() as t:
118
+ self.load_models()
119
+ self.load_metrics.load_models_ms = t.elapsed_ms
120
+
121
+ # Build graph
122
+ with Timer() as t:
123
+ self.populate_graph()
124
+ self.load_metrics.populate_graph_ms = t.elapsed_ms
125
+
126
+ # Collect property claims
127
+ with Timer() as t:
128
+ self.collect_property_claims()
129
+ self.load_metrics.collect_property_claims_ms = t.elapsed_ms
130
+
131
+ self.load_metrics.total_load_ms = total_timer.elapsed_ms
132
+ self.load_metrics.model_count = len(self.models)
133
+
99
134
  except FileNotFoundError:
100
135
  raise FileNotFoundError("dbt folder is present, but dbt_project.yml is not found: %s" % self.root_folder)
101
136
 
@@ -197,6 +232,7 @@ class DbtProject(DbtProjectAbstract):
197
232
  )
198
233
  self.fall_back_to_filename = fall_back_to_filename
199
234
  self.root_folder = project_path
235
+ self.load_metrics = None
200
236
  self.refresh()
201
237
 
202
238
 
@@ -5,6 +5,9 @@ This module provides functions to discover and collect property claims from:
5
5
  - dbt_project.yml (project-level configurations)
6
6
  - schema.yml files (model-specific properties and configs)
7
7
  - model SQL files (inline config() calls)
8
+
9
+ Performance note: Use PropertyDiscoveryCache when collecting claims for multiple models
10
+ to avoid re-reading and re-parsing YAML files.
8
11
  """
9
12
 
10
13
  from pathlib import Path
@@ -12,11 +15,124 @@ from typing import TYPE_CHECKING
12
15
  import yaml
13
16
  import re
14
17
  import logging
18
+ from dataclasses import dataclass, field
15
19
 
16
20
  from .property_claim import PropertyClaim
17
21
 
18
22
  if TYPE_CHECKING:
19
23
  from .model import DbtModel
24
+ from .project import DbtProject
25
+
26
+
27
+ @dataclass
28
+ class PropertyDiscoveryCache:
29
+ """
30
+ Cache for parsed YAML files to avoid re-reading during property collection.
31
+
32
+ This dramatically improves performance when collecting properties for many models,
33
+ as each file is only read and parsed once.
34
+ """
35
+ project_path: Path = None
36
+ dbt_project_data: dict = field(default_factory=dict)
37
+ schema_files_by_dir: dict = field(default_factory=dict)
38
+ schema_data_by_path: dict = field(default_factory=dict)
39
+ models_by_name_in_schemas: dict = field(default_factory=dict)
40
+ _initialized: bool = False
41
+
42
+ def initialize(self, project: 'DbtProject') -> None:
43
+ """
44
+ Initialize the cache by reading all relevant files once.
45
+
46
+ Args:
47
+ project: The DbtProject to cache data for
48
+ """
49
+ if self._initialized:
50
+ return
51
+
52
+ self.project_path = project.root_folder
53
+
54
+ # 1. Parse dbt_project.yml once
55
+ project_file = self.project_path / "dbt_project.yml"
56
+ if project_file.exists():
57
+ try:
58
+ self.dbt_project_data = yaml.safe_load(project_file.read_text()) or {}
59
+ except Exception as e:
60
+ logging.warning(f"Failed to parse {project_file}: {e}")
61
+ self.dbt_project_data = {}
62
+
63
+ # 2. Find and parse all schema files in the project
64
+ self._discover_schema_files(project)
65
+
66
+ # 3. Build index of models in schema files
67
+ self._index_models_in_schemas()
68
+
69
+ self._initialized = True
70
+
71
+ def _discover_schema_files(self, project: 'DbtProject') -> None:
72
+ """Find all schema.yml files in the project and parse them."""
73
+ seen_paths: set[Path] = set()
74
+
75
+ for models_path in project.full_models_paths:
76
+ if not models_path.exists():
77
+ continue
78
+ for root, _, files in models_path.walk():
79
+ root_path = Path(root)
80
+ for ext in ("yml", "yaml"):
81
+ for schema_file in root_path.glob(f"*.{ext}"):
82
+ stem = schema_file.stem.lower()
83
+ if stem in ("schema", "models", "_schema", "_models"):
84
+ abs_path = schema_file.resolve()
85
+ if abs_path in seen_paths:
86
+ continue
87
+ seen_paths.add(abs_path)
88
+
89
+ # Track by directory for path-based lookup
90
+ if root_path not in self.schema_files_by_dir:
91
+ self.schema_files_by_dir[root_path] = []
92
+ self.schema_files_by_dir[root_path].append(schema_file)
93
+
94
+ # Parse and cache the content
95
+ try:
96
+ data = yaml.safe_load(schema_file.read_text()) or {}
97
+ self.schema_data_by_path[schema_file] = data
98
+ except Exception as e:
99
+ logging.debug(f"Failed to parse {schema_file}: {e}")
100
+ self.schema_data_by_path[schema_file] = {}
101
+
102
+ def _index_models_in_schemas(self) -> None:
103
+ """Build an index of model names to their schema file definitions."""
104
+ for schema_path, data in self.schema_data_by_path.items():
105
+ models_list = data.get("models", [])
106
+ if not isinstance(models_list, list):
107
+ continue
108
+ for model_def in models_list:
109
+ if not isinstance(model_def, dict):
110
+ continue
111
+ model_name = model_def.get("name")
112
+ if model_name:
113
+ if model_name not in self.models_by_name_in_schemas:
114
+ self.models_by_name_in_schemas[model_name] = []
115
+ self.models_by_name_in_schemas[model_name].append((schema_path, model_def))
116
+
117
+ def get_schema_files_for_model(self, model: 'DbtModel') -> list[Path]:
118
+ """Get schema files relevant to a model (in its directory and ancestors)."""
119
+ result: list[Path] = []
120
+ model_dir = model.file_path_full.parent.resolve()
121
+ project_dir = self.project_path.resolve()
122
+
123
+ current = model_dir
124
+ while current >= project_dir:
125
+ if current in self.schema_files_by_dir:
126
+ result.extend(self.schema_files_by_dir[current])
127
+ if current == project_dir:
128
+ break
129
+ current = current.parent
130
+
131
+ return result
132
+
133
+ def get_model_definitions(self, model_name: str) -> list[tuple[Path, dict]]:
134
+ """Get all schema definitions for a model by name."""
135
+ return self.models_by_name_in_schemas.get(model_name, [])
20
136
 
21
137
 
22
138
  def get_model_path_parts(
@@ -459,3 +575,176 @@ def get_effective_properties(
459
575
  claims = collect_model_claims(model)
460
576
  resolved = resolve_property_precedence(claims)
461
577
  return {name: claim.value for name, claim in resolved.items()}
578
+
579
+
580
+ # ============================================================================
581
+ # Cached versions for performance when processing multiple models
582
+ # ============================================================================
583
+
584
+
585
+ def collect_project_configs_cached(
586
+ model: 'DbtModel',
587
+ cache: PropertyDiscoveryCache,
588
+ ) -> list[PropertyClaim]:
589
+ """
590
+ Collect property claims from dbt_project.yml using cached data.
591
+
592
+ Args:
593
+ model: The DbtModel instance to collect configs for
594
+ cache: PropertyDiscoveryCache with pre-parsed dbt_project.yml
595
+
596
+ Returns:
597
+ List of PropertyClaim objects from dbt_project.yml
598
+ """
599
+ claims: list[PropertyClaim] = []
600
+ project_path = model.project.root_folder
601
+ model_path = model.file_path_full
602
+ project_file = project_path / "dbt_project.yml"
603
+
604
+ data = cache.dbt_project_data
605
+ if not data:
606
+ return claims
607
+
608
+ models = data.get("models", {})
609
+ if not models:
610
+ return claims
611
+
612
+ model_parts = get_model_path_parts(model_path, project_path)
613
+ package_name = data.get("name", "")
614
+
615
+ def walk(node, path_index: int, yaml_path: str, parts_matched: list[str]):
616
+ if not isinstance(node, dict):
617
+ return
618
+
619
+ for k, v in node.items():
620
+ if isinstance(k, str) and k.startswith("+"):
621
+ prop_name = k[1:]
622
+ effective = parts_matched == model_parts[:len(parts_matched)]
623
+ claims.append(
624
+ PropertyClaim(
625
+ source_type="dbt_project.yml",
626
+ source_path=project_file,
627
+ model=model,
628
+ name=prop_name,
629
+ value=v,
630
+ yaml_path=yaml_path,
631
+ effective=effective,
632
+ kind="config",
633
+ )
634
+ )
635
+
636
+ if path_index < len(model_parts):
637
+ next_key = model_parts[path_index]
638
+ if next_key in node:
639
+ walk(
640
+ node[next_key],
641
+ path_index + 1,
642
+ f"{yaml_path}.{next_key}",
643
+ parts_matched + [next_key]
644
+ )
645
+
646
+ if package_name in models:
647
+ walk(models[package_name], 0, f"models.{package_name}", [])
648
+
649
+ for k, v in models.items():
650
+ if isinstance(k, str) and k.startswith("+"):
651
+ prop_name = k[1:]
652
+ claims.append(
653
+ PropertyClaim(
654
+ source_type="dbt_project.yml",
655
+ source_path=project_file,
656
+ model=model,
657
+ name=prop_name,
658
+ value=v,
659
+ yaml_path="models",
660
+ effective=True,
661
+ kind="config",
662
+ )
663
+ )
664
+
665
+ return claims
666
+
667
+
668
+ def collect_schema_properties_cached(
669
+ model: 'DbtModel',
670
+ cache: PropertyDiscoveryCache,
671
+ ) -> list[PropertyClaim]:
672
+ """
673
+ Collect property claims from schema.yml files using cached data.
674
+
675
+ Uses the pre-built model name index for fast lookup instead of
676
+ walking filesystem and parsing YAML files.
677
+
678
+ Args:
679
+ model: The DbtModel instance to collect properties for
680
+ cache: PropertyDiscoveryCache with pre-parsed schema files
681
+
682
+ Returns:
683
+ List of PropertyClaim objects from schema files
684
+ """
685
+ claims: list[PropertyClaim] = []
686
+
687
+ # Use the model name index for fast lookup
688
+ model_defs = cache.get_model_definitions(model.name)
689
+
690
+ for schema_path, model_def in model_defs:
691
+ for key, value in model_def.items():
692
+ if key == "name":
693
+ continue
694
+ elif key == "config":
695
+ if isinstance(value, dict):
696
+ for ck, cv in value.items():
697
+ claims.append(
698
+ PropertyClaim(
699
+ source_type="schema.yml",
700
+ source_path=schema_path,
701
+ model=model,
702
+ name=ck,
703
+ value=cv,
704
+ kind="config",
705
+ )
706
+ )
707
+ else:
708
+ claims.append(
709
+ PropertyClaim(
710
+ source_type="schema.yml",
711
+ source_path=schema_path,
712
+ model=model,
713
+ name=key,
714
+ value=value,
715
+ kind="property",
716
+ )
717
+ )
718
+
719
+ return claims
720
+
721
+
722
+ def collect_model_claims_cached(
723
+ model: 'DbtModel',
724
+ cache: PropertyDiscoveryCache,
725
+ ) -> list[PropertyClaim]:
726
+ """
727
+ Collect all property claims for a model using cached YAML data.
728
+
729
+ This is the cached version of collect_model_claims that uses pre-parsed
730
+ YAML files from PropertyDiscoveryCache for dramatically better performance.
731
+
732
+ Args:
733
+ model: The DbtModel instance to collect all properties for
734
+ cache: PropertyDiscoveryCache with pre-parsed YAML files
735
+
736
+ Returns:
737
+ List of all PropertyClaim objects for the model
738
+ """
739
+ claims: list[PropertyClaim] = []
740
+
741
+ # 1. Collect from dbt_project.yml (using cached data)
742
+ claims.extend(collect_project_configs_cached(model, cache))
743
+
744
+ # 2. Collect from schema.yml files (using cached data)
745
+ claims.extend(collect_schema_properties_cached(model, cache))
746
+
747
+ # 3. Collect from model SQL file (no caching needed - already parsed in model)
748
+ claims.extend(collect_sql_configs(model))
749
+
750
+ return claims
@@ -0,0 +1,66 @@
1
+ """
2
+ Timing utilities for frontend performance debugging.
3
+
4
+ Set DBTUI_TIMING=1 environment variable to enable timing output.
5
+ """
6
+
7
+ import os
8
+ import time
9
+ import logging
10
+ from contextlib import contextmanager
11
+ from functools import wraps
12
+
13
+ # Check if timing is enabled via environment variable
14
+ TIMING_ENABLED = os.environ.get('DBTUI_TIMING', '').lower() in ('1', 'true', 'yes')
15
+
16
+ # Set up a dedicated logger for timing
17
+ timing_logger = logging.getLogger('dbtui.timing')
18
+ if TIMING_ENABLED:
19
+ logging.basicConfig(level=logging.INFO, format='%(message)s')
20
+ timing_logger.setLevel(logging.INFO)
21
+
22
+
23
+ @contextmanager
24
+ def timed_block(name: str):
25
+ """Context manager to time a block of code and log the result."""
26
+ start = time.perf_counter()
27
+ yield
28
+ elapsed_ms = (time.perf_counter() - start) * 1000
29
+ timing_logger.info(f"{name}: {elapsed_ms:.1f}ms")
30
+
31
+
32
+ def timed(func):
33
+ """Decorator to time a function and log the result."""
34
+ @wraps(func)
35
+ def wrapper(*args, **kwargs):
36
+ start = time.perf_counter()
37
+ result = func(*args, **kwargs)
38
+ elapsed_ms = (time.perf_counter() - start) * 1000
39
+ timing_logger.info(f"{func.__qualname__}: {elapsed_ms:.1f}ms")
40
+ return result
41
+ return wrapper
42
+
43
+
44
+ class TimingContext:
45
+ """Accumulates timing for multiple operations."""
46
+
47
+ def __init__(self, name: str):
48
+ self.name = name
49
+ self.timings: dict[str, float] = {}
50
+
51
+ def record(self, step: str, elapsed_ms: float):
52
+ self.timings[step] = elapsed_ms
53
+
54
+ @contextmanager
55
+ def step(self, step_name: str):
56
+ start = time.perf_counter()
57
+ yield
58
+ self.timings[step_name] = (time.perf_counter() - start) * 1000
59
+
60
+ def log(self):
61
+ if not TIMING_ENABLED:
62
+ return
63
+ total = sum(self.timings.values())
64
+ timing_logger.info(f"{self.name} total: {total:.1f}ms")
65
+ for step, ms in self.timings.items():
66
+ timing_logger.info(f" - {step}: {ms:.1f}ms")
@@ -14,6 +14,7 @@ from .new_model import NewModel
14
14
 
15
15
  from ..common import dbtuiCache, load_cache, save_cache, NonePathException
16
16
  from .common import DbtuiScreen, DbtProject, DbtModel
17
+ from .common.timing import TimingContext
17
18
  import logging
18
19
 
19
20
  @dataclass
@@ -44,8 +45,12 @@ class dbtuiFrontend(App):
44
45
  screen_stack: list[DbtuiScreen]
45
46
  external_editor_command: reactive[str] = reactive('vi')
46
47
 
48
+ # For debounced save
49
+ _save_timer = None
50
+ _SAVE_DEBOUNCE_SECONDS = 0.5 # Save at most every 500ms
51
+
47
52
  def watch_external_editor_command(self, old_value: str, new_value: str):
48
- self.save_context()
53
+ self.save_context_debounced()
49
54
 
50
55
  project: reactive[DbtProject|None] = reactive(None, always_update=True, init=True)
51
56
 
@@ -55,7 +60,7 @@ class dbtuiFrontend(App):
55
60
  return project
56
61
 
57
62
  def on_project_change(self, project: DbtProject|None):
58
- self.save_context()
63
+ self.save_context_debounced()
59
64
  for screen in self.screen_stack:
60
65
  if not screen.id == '_default':
61
66
  screen.on_project_change(project)
@@ -72,12 +77,20 @@ class dbtuiFrontend(App):
72
77
  return model
73
78
 
74
79
  def on_model_change(self, model: DbtModel|None):
75
- self.save_context()
80
+ timing = TimingContext("on_model_change")
81
+
82
+ with timing.step("save_context_debounced"):
83
+ self.save_context_debounced()
84
+
76
85
  for screen in self.screen_stack:
77
86
  if not screen.id == '_default':
78
- screen.on_model_change(model)
79
- self.push_screen('model_view')
80
- pass
87
+ with timing.step(f"screen.{screen.__class__.__name__}.on_model_change"):
88
+ screen.on_model_change(model)
89
+
90
+ with timing.step("push_screen"):
91
+ self.push_screen('model_view')
92
+
93
+ timing.log()
81
94
 
82
95
  def watch_model(self, old_model: DbtModel|None, new_model: DbtModel|None):
83
96
 
@@ -116,11 +129,26 @@ class dbtuiFrontend(App):
116
129
  self.external_editor_command = cache.external_editor_command
117
130
 
118
131
  def save_context(self):
132
+ """Save context immediately (blocking)."""
119
133
  save_cache(
120
134
  project_path=self.project.root_folder if isinstance(self.project, DbtProject) else None,
121
135
  model_name=self.model.name if isinstance(self.model, DbtModel) else None,
122
136
  external_editor_command=self.external_editor_command,
123
137
  )
138
+
139
+ def save_context_debounced(self):
140
+ """Schedule a debounced save - coalesces rapid changes into one save."""
141
+ if self._save_timer is not None:
142
+ self._save_timer.stop()
143
+ self._save_timer = self.set_timer(
144
+ self._SAVE_DEBOUNCE_SECONDS,
145
+ self._do_debounced_save
146
+ )
147
+
148
+ def _do_debounced_save(self):
149
+ """Callback for debounced save timer."""
150
+ self._save_timer = None
151
+ self.save_context()
124
152
 
125
153
 
126
154
  def on_mount(self):
@@ -6,6 +6,7 @@ from textual.binding import Binding
6
6
  from textual.events import Key
7
7
 
8
8
  from ..common import DbtuiScreen, DbtModel, DbtProject
9
+ from ..common.timing import TimingContext
9
10
  if TYPE_CHECKING:
10
11
  from ..main import dbtuiFrontend
11
12
 
@@ -73,20 +74,44 @@ class ModelTree(DbtuiScreen):
73
74
  if not model:
74
75
  self.app.push_screen('model_search')
75
76
  return
77
+
78
+ timing = TimingContext("ModelTree.on_model_change")
79
+
76
80
  # model_content
77
- model_content = self.get_widget_by_id('model_content')
81
+ with timing.step("get_widget(model_content)"):
82
+ model_content = self.get_widget_by_id('model_content')
78
83
  assert isinstance(model_content, TextArea)
79
- model_content.clear()
80
- model_content.load_text(model.text)
84
+
85
+ with timing.step("model_content.clear"):
86
+ model_content.clear()
87
+
88
+ with timing.step("model.text (file read)"):
89
+ text = model.text
90
+
91
+ with timing.step("model_content.load_text"):
92
+ model_content.load_text(text)
93
+
81
94
  # parents
82
- parents = self.get_widget_by_id('parents')
95
+ with timing.step("get_widget(parents)"):
96
+ parents = self.get_widget_by_id('parents')
83
97
  assert isinstance(parents, ParentsList)
84
- parents.on_model_change(model)
98
+
99
+ with timing.step("parents.on_model_change"):
100
+ parents.on_model_change(model)
101
+
85
102
  # children
86
- children = self.get_widget_by_id('children')
103
+ with timing.step("get_widget(children)"):
104
+ children = self.get_widget_by_id('children')
87
105
  assert isinstance(children, ChildrenList)
88
- children.on_model_change(model)
89
- self.recompose()
106
+
107
+ with timing.step("children.on_model_change"):
108
+ children.on_model_change(model)
109
+
110
+ # Note: recompose() removed - unnecessary since:
111
+ # - TextArea is updated via load_text()
112
+ # - ListViews are updated via clear() and append()
113
+
114
+ timing.log()
90
115
 
91
116
  def on_project_change(self, project: DbtProject | None):
92
117
  # When project changes, redirect to model search
File without changes
File without changes
File without changes