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.
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/PKG-INFO +1 -1
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/pyproject.toml +1 -1
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/backend/__init__.py +1 -0
- dbt_tui-0.2.0/src/dbtui/backend/metrics.py +75 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/backend/project.py +44 -8
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/backend/property_discovery.py +289 -0
- dbt_tui-0.2.0/src/dbtui/frontend/common/timing.py +66 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/main.py +34 -6
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_tree/model_tree.py +33 -8
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/README.md +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/__init__.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/__main__.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/backend/fetch.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/backend/model.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/backend/property_claim.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/common/__init__.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/common/cache.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/common/exceptions.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/common/model.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/common/project.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/__init__.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/common/__init__.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/common/dbtui_screen.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/common/isolated.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/common/model_list.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/common/model_list_item.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/lorem.txt +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_search/__init__.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_search/model_search.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_search/model_search_input.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_search/model_search_list.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_tree/__init__.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_tree/children_list.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_tree/constants.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_tree/model_relatives_list.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_tree/parents_list.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_view/__init__.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_view/model_tree.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/model_view/properties_panel.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/new_model/__init__.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/new_model/new_model.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/new_model/select_filepath.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/options/__init__.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/options/options.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/project_search/__init__.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/project_search/project_search.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/pseudo/__init__.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/pseudo/dbt_model.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/pseudo/dbt_project.py +0 -0
- {dbt_tui-0.1.0 → dbt_tui-0.2.0}/src/dbtui/frontend/pseudo/utils.py +0 -0
|
@@ -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
|
|
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 =
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
95
|
+
with timing.step("get_widget(parents)"):
|
|
96
|
+
parents = self.get_widget_by_id('parents')
|
|
83
97
|
assert isinstance(parents, ParentsList)
|
|
84
|
-
|
|
98
|
+
|
|
99
|
+
with timing.step("parents.on_model_change"):
|
|
100
|
+
parents.on_model_change(model)
|
|
101
|
+
|
|
85
102
|
# children
|
|
86
|
-
|
|
103
|
+
with timing.step("get_widget(children)"):
|
|
104
|
+
children = self.get_widget_by_id('children')
|
|
87
105
|
assert isinstance(children, ChildrenList)
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|