dbt-autofix 0.0.0.post1.dev0__py3-none-any.whl
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_autofix/__init__.py +0 -0
- dbt_autofix/_jinja_environment.py +305 -0
- dbt_autofix/dbt_api.py +343 -0
- dbt_autofix/deprecations.py +20 -0
- dbt_autofix/duplicate_keys.py +133 -0
- dbt_autofix/fields_properties_configs.py +348 -0
- dbt_autofix/hub_packages.py +72 -0
- dbt_autofix/jinja.py +259 -0
- dbt_autofix/main.py +252 -0
- dbt_autofix/package_upgrade.py +584 -0
- dbt_autofix/packages/__init__.py +0 -0
- dbt_autofix/packages/dbt_package_file.py +319 -0
- dbt_autofix/packages/dbt_package_lock_file.py +155 -0
- dbt_autofix/packages/dbt_package_text_file.py +315 -0
- dbt_autofix/packages/installed_packages.py +168 -0
- dbt_autofix/refactor.py +740 -0
- dbt_autofix/refactors/__init__.py +0 -0
- dbt_autofix/refactors/changesets/__init__.py +0 -0
- dbt_autofix/refactors/changesets/dbt_project_yml.py +636 -0
- dbt_autofix/refactors/changesets/dbt_python.py +362 -0
- dbt_autofix/refactors/changesets/dbt_schema_yml.py +1086 -0
- dbt_autofix/refactors/changesets/dbt_schema_yml_semantic_layer.py +1065 -0
- dbt_autofix/refactors/changesets/dbt_sql.py +723 -0
- dbt_autofix/refactors/changesets/dbt_sql_improved.py +142 -0
- dbt_autofix/refactors/constants.py +17 -0
- dbt_autofix/refactors/fancy_quotes_utils.py +22 -0
- dbt_autofix/refactors/iceberg_table_format.py +40 -0
- dbt_autofix/refactors/results.py +386 -0
- dbt_autofix/refactors/static_analysis.py +127 -0
- dbt_autofix/refactors/yml.py +68 -0
- dbt_autofix/retrieve_schemas.py +348 -0
- dbt_autofix/semantic_definitions.py +189 -0
- dbt_autofix-0.0.0.post1.dev0.dist-info/METADATA +200 -0
- dbt_autofix-0.0.0.post1.dev0.dist-info/RECORD +39 -0
- dbt_autofix-0.0.0.post1.dev0.dist-info/WHEEL +4 -0
- dbt_autofix-0.0.0.post1.dev0.dist-info/entry_points.txt +2 -0
- dbt_autofix-0.0.0.post1.dev0.dist-info/licenses/LICENSE +201 -0
- pre_commit_hooks/__init__.py +0 -0
- pre_commit_hooks/check_deprecations.py +163 -0
dbt_autofix/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Vendored Jinja environment setup from dbt-common.
|
|
2
|
+
|
|
3
|
+
This module contains the minimal subset of dbt-common's Jinja machinery needed
|
|
4
|
+
to parse (not render) dbt templates. It exists to avoid pulling in the full
|
|
5
|
+
dbt-common transitive dependency tree (agate, protobuf, mashumaro, etc.) for
|
|
6
|
+
a parse-only use case.
|
|
7
|
+
|
|
8
|
+
Vendored from:
|
|
9
|
+
- dbt_common/utils/jinja.py (name-mangling helpers)
|
|
10
|
+
- dbt_common/clients/jinja.py (parser, environment, extensions, undefined)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import dataclasses
|
|
16
|
+
from typing import Any, Callable, ClassVar, Dict, List, Literal, NoReturn, Optional, Type, Union
|
|
17
|
+
|
|
18
|
+
import jinja2
|
|
19
|
+
import jinja2.ext
|
|
20
|
+
import jinja2.nodes
|
|
21
|
+
import jinja2.parser
|
|
22
|
+
import jinja2.sandbox
|
|
23
|
+
from jinja2 import select_autoescape
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Constants (from dbt_common/utils/jinja.py)
|
|
27
|
+
# https://github.com/dbt-labs/dbt-common/blob/5b331b9c50ca5fee959a9e4fa9ecca964549930c/dbt_common/utils/jinja.py#L6
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
MACRO_PREFIX = "dbt_macro__"
|
|
31
|
+
DOCS_PREFIX = "dbt_docs__"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Name-prefixing helpers (from dbt_common/utils/jinja.py)
|
|
36
|
+
# https://github.com/dbt-labs/dbt-common/blob/5b331b9c50ca5fee959a9e4fa9ecca964549930c/dbt_common/utils/jinja.py#L10
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_dbt_macro_name(name: str) -> str:
|
|
41
|
+
if name is None:
|
|
42
|
+
raise ValueError("Got None for a macro name!")
|
|
43
|
+
return f"{MACRO_PREFIX}{name}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_materialization_macro_name(
|
|
47
|
+
materialization_name: str,
|
|
48
|
+
adapter_type: Optional[str] = None,
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Upstream ``with_prefix`` param dropped; always prefixes (the only usage in jinja client code)."""
|
|
51
|
+
if adapter_type is None:
|
|
52
|
+
adapter_type = "default"
|
|
53
|
+
name = f"materialization_{materialization_name}_{adapter_type}"
|
|
54
|
+
return get_dbt_macro_name(name)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_docs_macro_name(docs_name: str) -> str:
|
|
58
|
+
"""Upstream ``with_prefix`` param dropped; always prefixes (the only usage in jinja client code)."""
|
|
59
|
+
return f"{DOCS_PREFIX}{docs_name}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_test_macro_name(test_name: str) -> str:
|
|
63
|
+
"""Upstream ``with_prefix`` param dropped; always prefixes (the only usage in jinja client code)."""
|
|
64
|
+
name = f"test_{test_name}"
|
|
65
|
+
return get_dbt_macro_name(name)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# MacroType / MacroFuzzParser / MacroFuzzEnvironment
|
|
70
|
+
# (from dbt_common/clients/jinja.py)
|
|
71
|
+
# https://github.com/dbt-labs/dbt-common/blob/5b331b9c50ca5fee959a9e4fa9ecca964549930c/dbt_common/clients/jinja.py#L101
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclasses.dataclass
|
|
76
|
+
class MacroType:
|
|
77
|
+
name: str
|
|
78
|
+
type_params: List[MacroType] = dataclasses.field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_ParseReturn = Union[jinja2.nodes.Node, List[jinja2.nodes.Node]]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class MacroFuzzParser(jinja2.parser.Parser):
|
|
85
|
+
def parse_macro(self) -> jinja2.nodes.Macro:
|
|
86
|
+
node = jinja2.nodes.Macro(lineno=next(self.stream).lineno)
|
|
87
|
+
node.name = get_dbt_macro_name(self.parse_assign_target(name_only=True).name)
|
|
88
|
+
self.parse_signature(node)
|
|
89
|
+
node.body = self.parse_statements(("name:endmacro",), drop_needle=True)
|
|
90
|
+
return node
|
|
91
|
+
|
|
92
|
+
def parse_signature(self, node: Union[jinja2.nodes.Macro, jinja2.nodes.CallBlock]) -> None:
|
|
93
|
+
setattr(node, "arg_types", [])
|
|
94
|
+
setattr(node, "has_type_annotations", False)
|
|
95
|
+
|
|
96
|
+
args = node.args = []
|
|
97
|
+
defaults = node.defaults = []
|
|
98
|
+
|
|
99
|
+
self.stream.expect("lparen")
|
|
100
|
+
while self.stream.current.type != "rparen":
|
|
101
|
+
if args:
|
|
102
|
+
self.stream.expect("comma")
|
|
103
|
+
|
|
104
|
+
arg = self.parse_assign_target(name_only=True)
|
|
105
|
+
arg.set_ctx("param")
|
|
106
|
+
|
|
107
|
+
# TODO: Naming and typing is odd here. Probably better as macro_type: Optional[MacroType]
|
|
108
|
+
type_name: Union[MacroType, Literal[""]]
|
|
109
|
+
|
|
110
|
+
if self.stream.skip_if("colon"):
|
|
111
|
+
node.has_type_annotations = True # ty:ignore[invalid-assignment]
|
|
112
|
+
type_name = self.parse_type_name()
|
|
113
|
+
else:
|
|
114
|
+
type_name = ""
|
|
115
|
+
|
|
116
|
+
node.arg_types.append(type_name) # type: ignore
|
|
117
|
+
|
|
118
|
+
if self.stream.skip_if("assign"):
|
|
119
|
+
defaults.append(self.parse_expression())
|
|
120
|
+
elif defaults:
|
|
121
|
+
self.fail("non-default argument follows default argument")
|
|
122
|
+
|
|
123
|
+
args.append(arg)
|
|
124
|
+
self.stream.expect("rparen")
|
|
125
|
+
|
|
126
|
+
def parse_type_name(self) -> MacroType:
|
|
127
|
+
type_name = self.stream.expect("name").value
|
|
128
|
+
type_ = MacroType(type_name)
|
|
129
|
+
|
|
130
|
+
if self.stream.skip_if("lbracket"):
|
|
131
|
+
while self.stream.current.type != "rbracket":
|
|
132
|
+
if type_.type_params:
|
|
133
|
+
self.stream.expect("comma")
|
|
134
|
+
param_type = self.parse_type_name()
|
|
135
|
+
type_.type_params.append(param_type)
|
|
136
|
+
|
|
137
|
+
self.stream.expect("rbracket")
|
|
138
|
+
|
|
139
|
+
return type_
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class MacroFuzzEnvironment(jinja2.sandbox.SandboxedEnvironment):
|
|
143
|
+
def _parse(self, source: str, name: Optional[str], filename: Optional[str]) -> jinja2.nodes.Template:
|
|
144
|
+
return MacroFuzzParser(self, source, name, filename).parse()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# Extensions (from dbt_common/clients/jinja.py)
|
|
149
|
+
# https://github.com/dbt-labs/dbt-common/blob/5b331b9c50ca5fee959a9e4fa9ecca964549930c/dbt_common/clients/jinja.py#L430
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
SUPPORTED_LANG_ARG = jinja2.nodes.Name("supported_languages", "param")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class MaterializationExtension(jinja2.ext.Extension):
|
|
156
|
+
tags: ClassVar[List[str]] = ["materialization"]
|
|
157
|
+
|
|
158
|
+
def parse(self, parser: jinja2.parser.Parser) -> _ParseReturn:
|
|
159
|
+
node = jinja2.nodes.Macro(lineno=next(parser.stream).lineno)
|
|
160
|
+
materialization_name = parser.parse_assign_target(name_only=True).name
|
|
161
|
+
|
|
162
|
+
adapter_name = "default"
|
|
163
|
+
node.args = []
|
|
164
|
+
node.defaults = []
|
|
165
|
+
|
|
166
|
+
while parser.stream.skip_if("comma"):
|
|
167
|
+
target = parser.parse_assign_target(name_only=True)
|
|
168
|
+
|
|
169
|
+
if target.name == "default":
|
|
170
|
+
pass
|
|
171
|
+
elif target.name == "adapter":
|
|
172
|
+
parser.stream.expect("assign")
|
|
173
|
+
value = parser.parse_expression()
|
|
174
|
+
adapter_name = value.value # ty: ignore[unresolved-attribute]
|
|
175
|
+
elif target.name == "supported_languages":
|
|
176
|
+
target.set_ctx("param")
|
|
177
|
+
node.args.append(target)
|
|
178
|
+
parser.stream.expect("assign")
|
|
179
|
+
languages = parser.parse_expression()
|
|
180
|
+
node.defaults.append(languages)
|
|
181
|
+
else:
|
|
182
|
+
# Upstream raises MaterializationArgError (a dbt-common exception).
|
|
183
|
+
# We use ValueError to avoid the dbt-common dependency.
|
|
184
|
+
raise ValueError(f"Unexpected argument '{target.name}' to materialization '{materialization_name}'")
|
|
185
|
+
|
|
186
|
+
if SUPPORTED_LANG_ARG not in node.args:
|
|
187
|
+
node.args.append(SUPPORTED_LANG_ARG)
|
|
188
|
+
node.defaults.append(jinja2.nodes.List([jinja2.nodes.Const("sql")]))
|
|
189
|
+
|
|
190
|
+
node.name = get_materialization_macro_name(materialization_name, adapter_name)
|
|
191
|
+
node.body = parser.parse_statements(("name:endmaterialization",), drop_needle=True)
|
|
192
|
+
return node
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class DocumentationExtension(jinja2.ext.Extension):
|
|
196
|
+
tags: ClassVar[List[str]] = ["docs"]
|
|
197
|
+
|
|
198
|
+
def parse(self, parser: jinja2.parser.Parser) -> _ParseReturn:
|
|
199
|
+
node = jinja2.nodes.Macro(lineno=next(parser.stream).lineno)
|
|
200
|
+
docs_name = parser.parse_assign_target(name_only=True).name
|
|
201
|
+
|
|
202
|
+
node.args = []
|
|
203
|
+
node.defaults = []
|
|
204
|
+
node.name = get_docs_macro_name(docs_name)
|
|
205
|
+
node.body = parser.parse_statements(("name:enddocs",), drop_needle=True)
|
|
206
|
+
return node
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class TestExtension(jinja2.ext.Extension):
|
|
210
|
+
tags: ClassVar[List[str]] = ["test"]
|
|
211
|
+
|
|
212
|
+
def parse(self, parser: jinja2.parser.Parser) -> _ParseReturn:
|
|
213
|
+
node = jinja2.nodes.Macro(lineno=next(parser.stream).lineno)
|
|
214
|
+
test_name = parser.parse_assign_target(name_only=True).name
|
|
215
|
+
|
|
216
|
+
parser.parse_signature(node)
|
|
217
|
+
node.name = get_test_macro_name(test_name)
|
|
218
|
+
node.body = parser.parse_statements(("name:endtest",), drop_needle=True)
|
|
219
|
+
return node
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# Undefined handling (from dbt_common/clients/jinja.py)
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
def _is_dunder_name(name: str) -> bool:
|
|
226
|
+
return name.startswith("__") and name.endswith("__")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def create_undefined() -> Type[jinja2.Undefined]:
|
|
230
|
+
class Undefined(jinja2.Undefined):
|
|
231
|
+
def __init__(
|
|
232
|
+
self,
|
|
233
|
+
hint: Optional[str] = None,
|
|
234
|
+
obj: Any = None,
|
|
235
|
+
name: Optional[str] = None,
|
|
236
|
+
exc: Any = None,
|
|
237
|
+
) -> None:
|
|
238
|
+
super().__init__(hint=hint, name=name)
|
|
239
|
+
self.name = name
|
|
240
|
+
self.hint = hint
|
|
241
|
+
self.unsafe_callable = False
|
|
242
|
+
self.alters_data = False
|
|
243
|
+
|
|
244
|
+
def __getitem__(self, name: Any) -> Undefined:
|
|
245
|
+
return self
|
|
246
|
+
|
|
247
|
+
def __getattr__(self, name: str) -> Undefined:
|
|
248
|
+
if name == "name" or _is_dunder_name(name):
|
|
249
|
+
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
|
|
250
|
+
self.name = name
|
|
251
|
+
return self.__class__(hint=self.hint, name=self.name)
|
|
252
|
+
|
|
253
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Undefined:
|
|
254
|
+
return self
|
|
255
|
+
|
|
256
|
+
def __reduce__(self) -> NoReturn:
|
|
257
|
+
raise TypeError(f"Compilation Error: undefined variable '{self.name or 'unknown'}'")
|
|
258
|
+
|
|
259
|
+
return Undefined
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# Filters (from dbt_common/clients/jinja.py)
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
def is_list(value: Any) -> bool:
|
|
266
|
+
return isinstance(value, list)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
TEXT_FILTERS: Dict[str, Callable[[Any], Any]] = {
|
|
270
|
+
"as_text": lambda x: x,
|
|
271
|
+
"as_bool": lambda x: x,
|
|
272
|
+
"as_native": lambda x: x,
|
|
273
|
+
"as_number": lambda x: x,
|
|
274
|
+
"is_list": is_list,
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
# Public API
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
def get_jinja_environment() -> jinja2.Environment:
|
|
282
|
+
"""Return a Jinja environment configured for parsing dbt templates.
|
|
283
|
+
|
|
284
|
+
This is equivalent to ``get_environment(None, capture_macros=True)``
|
|
285
|
+
from dbt-common but without the dbt-common dependency.
|
|
286
|
+
"""
|
|
287
|
+
args: Dict[str, Any] = {
|
|
288
|
+
"extensions": [
|
|
289
|
+
"jinja2.ext.do",
|
|
290
|
+
"jinja2.ext.loopcontrols",
|
|
291
|
+
MaterializationExtension,
|
|
292
|
+
DocumentationExtension,
|
|
293
|
+
TestExtension,
|
|
294
|
+
],
|
|
295
|
+
"undefined": create_undefined(),
|
|
296
|
+
# A difference from dbt-common's implementation
|
|
297
|
+
# This disables autoescaping when rendering HTML / XML templates, to improve security posture
|
|
298
|
+
# But it should be irrelevant, as we only parse templates in autofix, and those should always be sql and yaml
|
|
299
|
+
# But let's prefer secure settings for prevention
|
|
300
|
+
"autoescape": select_autoescape(),
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
env = MacroFuzzEnvironment(**args)
|
|
304
|
+
env.filters.update(TEXT_FILTERS)
|
|
305
|
+
return env
|
dbt_autofix/dbt_api.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
HTTP_ERROR_CODE = 400
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def job_dict_to_payload(job_dict: dict) -> dict:
|
|
15
|
+
"""Convert a job dictionary to a payload dictionary."""
|
|
16
|
+
|
|
17
|
+
fields_to_remove = {
|
|
18
|
+
"raw_dbt_version",
|
|
19
|
+
"created_at",
|
|
20
|
+
"updated_at",
|
|
21
|
+
"deactivated",
|
|
22
|
+
"run_failure_count",
|
|
23
|
+
"lifecycle_webhooks",
|
|
24
|
+
"lifecycle_webhooks_url",
|
|
25
|
+
"is_deferrable",
|
|
26
|
+
"generate_sources",
|
|
27
|
+
"cron_humanized",
|
|
28
|
+
"next_run",
|
|
29
|
+
"next_run_humanized",
|
|
30
|
+
"is_system",
|
|
31
|
+
"account",
|
|
32
|
+
"project",
|
|
33
|
+
"environment",
|
|
34
|
+
"most_recent_run",
|
|
35
|
+
"most_recent_completed_run",
|
|
36
|
+
"identifier",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
payload = {}
|
|
40
|
+
for key, value in job_dict.items():
|
|
41
|
+
if key not in fields_to_remove:
|
|
42
|
+
payload[key] = value
|
|
43
|
+
|
|
44
|
+
return payload
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def job_steps_updated(job_dict: dict, behavior_change: bool) -> tuple[bool, List[str]]:
|
|
48
|
+
"""Check if the job steps need to be updated."""
|
|
49
|
+
|
|
50
|
+
exec_steps = job_dict.get("execute_steps", [])
|
|
51
|
+
|
|
52
|
+
# Create a copy of the steps to avoid modifying the original
|
|
53
|
+
updated_steps = exec_steps.copy()
|
|
54
|
+
steps_changed = False
|
|
55
|
+
|
|
56
|
+
if behavior_change:
|
|
57
|
+
update_step_rules = [
|
|
58
|
+
step_remove_source_freshness_output,
|
|
59
|
+
]
|
|
60
|
+
else:
|
|
61
|
+
update_step_rules = [
|
|
62
|
+
step_regex_replace_m_with_s,
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
for i, step in enumerate(updated_steps):
|
|
66
|
+
if isinstance(step, str):
|
|
67
|
+
for update_step_fn in update_step_rules:
|
|
68
|
+
new_step = update_step_fn(step)
|
|
69
|
+
|
|
70
|
+
if new_step != step:
|
|
71
|
+
steps_changed = True
|
|
72
|
+
updated_steps[i] = new_step
|
|
73
|
+
|
|
74
|
+
return steps_changed, updated_steps
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def step_regex_replace_m_with_s(step: str) -> str:
|
|
78
|
+
"""Replace -m with -s and --model/--models with --select."""
|
|
79
|
+
step = re.sub(r"(\s)-m(\s)", r"\1-s\2", step)
|
|
80
|
+
step = re.sub(r"(\s)--model[s]?(\s)", r"\1--select\2", step)
|
|
81
|
+
return step
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def step_remove_source_freshness_output(step: str) -> str:
|
|
85
|
+
"""Remove --output in source freshness commands."""
|
|
86
|
+
if ("dbt source freshness") in step:
|
|
87
|
+
step = re.sub(r"(\s)-o(\s+)\S+", "", step)
|
|
88
|
+
step = re.sub(r"(\s)--output(\s+)\S+", "", step)
|
|
89
|
+
return step
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class DBTClient:
|
|
93
|
+
"""A minimalistic API client for fetching dbt data via the Admin API."""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
account_id: int,
|
|
98
|
+
api_key: Optional[str],
|
|
99
|
+
base_url: str = "https://cloud.getdbt.com",
|
|
100
|
+
disable_ssl_verification: bool = False,
|
|
101
|
+
) -> None:
|
|
102
|
+
self.account_id = account_id
|
|
103
|
+
self._api_key = api_key
|
|
104
|
+
|
|
105
|
+
self.base_url = base_url
|
|
106
|
+
self._headers = {
|
|
107
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
108
|
+
"Content-Type": "application/json",
|
|
109
|
+
"User-Agent": "dbt-autofix",
|
|
110
|
+
}
|
|
111
|
+
self._verify = not disable_ssl_verification
|
|
112
|
+
if not self._verify:
|
|
113
|
+
self._client = httpx.Client(verify=False)
|
|
114
|
+
else:
|
|
115
|
+
self._client = httpx.Client()
|
|
116
|
+
|
|
117
|
+
def update_job(self, job: dict) -> dict:
|
|
118
|
+
"""Update an existing dbt Cloud job using a new JobDefinition"""
|
|
119
|
+
|
|
120
|
+
logging.debug(f"Updating {job['name']}")
|
|
121
|
+
|
|
122
|
+
response = self._client.post(
|
|
123
|
+
url=f"{self.base_url}/api/v2/accounts/{self.account_id}/jobs/{job['id']}/",
|
|
124
|
+
headers=self._headers,
|
|
125
|
+
json=job_dict_to_payload(job),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if response.status_code >= HTTP_ERROR_CODE:
|
|
129
|
+
logging.error(response.json())
|
|
130
|
+
raise Exception(f"Error updating job {job['name']} - {response.json()}")
|
|
131
|
+
else:
|
|
132
|
+
logging.info(f"Job '{job['id']} - {job['name']}' updated successfully.")
|
|
133
|
+
|
|
134
|
+
return response.json()["data"]
|
|
135
|
+
|
|
136
|
+
def get_jobs(
|
|
137
|
+
self,
|
|
138
|
+
project_ids: Optional[List[int]] = None,
|
|
139
|
+
environment_ids: Optional[List[int]] = None,
|
|
140
|
+
) -> List[dict]:
|
|
141
|
+
"""Return a list of Jobs for all the dbt Cloud jobs in an environment."""
|
|
142
|
+
|
|
143
|
+
self._check_for_creds()
|
|
144
|
+
project_ids = project_ids or []
|
|
145
|
+
environment_ids = environment_ids or []
|
|
146
|
+
|
|
147
|
+
jobs: List[dict] = []
|
|
148
|
+
if len(environment_ids) > 1:
|
|
149
|
+
for env_id in environment_ids:
|
|
150
|
+
jobs.extend(self._fetch_jobs(project_ids, env_id))
|
|
151
|
+
elif len(environment_ids) == 1:
|
|
152
|
+
jobs = self._fetch_jobs(project_ids, environment_ids[0])
|
|
153
|
+
else:
|
|
154
|
+
jobs = self._fetch_jobs(project_ids, None)
|
|
155
|
+
|
|
156
|
+
return jobs
|
|
157
|
+
|
|
158
|
+
def _fetch_jobs(self, project_ids: List[int], environment_id: Optional[int]) -> List[dict]:
|
|
159
|
+
offset = 0
|
|
160
|
+
jobs: List[dict] = []
|
|
161
|
+
|
|
162
|
+
while True:
|
|
163
|
+
parameters = self._build_parameters(project_ids, environment_id, offset)
|
|
164
|
+
job_data = self._make_request(parameters)
|
|
165
|
+
|
|
166
|
+
if not job_data:
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
jobs.extend(job_data["data"])
|
|
170
|
+
|
|
171
|
+
if (
|
|
172
|
+
job_data["extra"]["filters"]["limit"] + job_data["extra"]["filters"]["offset"]
|
|
173
|
+
>= job_data["extra"]["pagination"]["total_count"]
|
|
174
|
+
):
|
|
175
|
+
break
|
|
176
|
+
|
|
177
|
+
offset += job_data["extra"]["filters"]["limit"]
|
|
178
|
+
|
|
179
|
+
return jobs
|
|
180
|
+
|
|
181
|
+
def _build_parameters(self, project_ids: List[int], environment_id: Optional[int], offset) -> dict[str, Any]:
|
|
182
|
+
parameters = {"offset": offset}
|
|
183
|
+
|
|
184
|
+
if len(project_ids) == 1:
|
|
185
|
+
parameters["project_id"] = project_ids[0]
|
|
186
|
+
elif len(project_ids) > 1:
|
|
187
|
+
project_id_str = [str(i) for i in project_ids]
|
|
188
|
+
parameters["project_id__in"] = f"[{','.join(project_id_str)}]"
|
|
189
|
+
|
|
190
|
+
if environment_id is not None:
|
|
191
|
+
parameters["environment_id"] = environment_id
|
|
192
|
+
|
|
193
|
+
logging.debug(f"Request parameters {parameters}")
|
|
194
|
+
return parameters
|
|
195
|
+
|
|
196
|
+
def _make_request(self, parameters: dict[str, Any]):
|
|
197
|
+
response = self._client.get(
|
|
198
|
+
url=f"{self.base_url}/api/v2/accounts/{self.account_id}/jobs/",
|
|
199
|
+
params=parameters,
|
|
200
|
+
headers=self._headers,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if response.status_code >= HTTP_ERROR_CODE:
|
|
204
|
+
error_data = response.json()
|
|
205
|
+
logging.error(error_data)
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
return response.json()
|
|
209
|
+
|
|
210
|
+
def _check_for_creds(self):
|
|
211
|
+
"""Confirm the presence of credentials"""
|
|
212
|
+
if not self._api_key:
|
|
213
|
+
raise Exception("An API key is required to get dbt Cloud jobs.")
|
|
214
|
+
|
|
215
|
+
if not self.account_id:
|
|
216
|
+
raise Exception("An account_id is required to get dbt Cloud jobs.")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass
|
|
220
|
+
class DBTCloudRefactor:
|
|
221
|
+
"""Represents a single refactoring operation on a dbt Cloud job."""
|
|
222
|
+
|
|
223
|
+
rule_name: str
|
|
224
|
+
original_value: Any
|
|
225
|
+
new_value: Any
|
|
226
|
+
refactor_logs: List[str]
|
|
227
|
+
|
|
228
|
+
def to_dict(self) -> dict:
|
|
229
|
+
"""Convert the refactor to a dictionary."""
|
|
230
|
+
return {
|
|
231
|
+
"rule_name": self.rule_name,
|
|
232
|
+
"original_value": self.original_value,
|
|
233
|
+
"new_value": self.new_value,
|
|
234
|
+
"refactor_logs": self.refactor_logs,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass
|
|
239
|
+
class DBTCloudChangesetResult:
|
|
240
|
+
"""Represents the result of a dbt Cloud job changeset."""
|
|
241
|
+
|
|
242
|
+
dry_run: bool
|
|
243
|
+
object_type: str = "job"
|
|
244
|
+
object_id: int = 0
|
|
245
|
+
object_name: str = ""
|
|
246
|
+
original_object: Dict[str, Any] = field(default_factory=dict)
|
|
247
|
+
new_object: Dict[str, Any] = field(default_factory=dict)
|
|
248
|
+
url: str = ""
|
|
249
|
+
refactors: List[DBTCloudRefactor] = field(default_factory=list)
|
|
250
|
+
|
|
251
|
+
def to_dict(self) -> dict:
|
|
252
|
+
"""Convert the changeset result to a dictionary."""
|
|
253
|
+
return {
|
|
254
|
+
"dry_run": self.dry_run,
|
|
255
|
+
"object_type": self.object_type,
|
|
256
|
+
"object_id": self.object_id,
|
|
257
|
+
"object_name": self.object_name,
|
|
258
|
+
"original_object": self.original_object,
|
|
259
|
+
"new_object": self.new_object,
|
|
260
|
+
"url": self.url,
|
|
261
|
+
"refactors": [refactor.to_dict() for refactor in self.refactors],
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
def print_to_console(self, json_output: bool = False):
|
|
265
|
+
"""Print the changeset result to the console."""
|
|
266
|
+
if not self.refactors:
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
if json_output:
|
|
270
|
+
print(json.dumps(self.to_dict()))
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
console.print(
|
|
274
|
+
f"\n{'DRY RUN - NOT APPLIED: ' if self.dry_run else ''}{self.object_type.title()} '{self.object_id} - {self.object_name}':",
|
|
275
|
+
style="green",
|
|
276
|
+
)
|
|
277
|
+
for refactor in self.refactors:
|
|
278
|
+
console.print(f" {refactor.rule_name}", style="yellow")
|
|
279
|
+
for log in refactor.refactor_logs:
|
|
280
|
+
console.print(f" {log}")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def update_jobs(
|
|
284
|
+
account_id: int,
|
|
285
|
+
api_key: Optional[str],
|
|
286
|
+
base_url: str = "https://cloud.getdbt.com",
|
|
287
|
+
disable_ssl_verification: bool = False,
|
|
288
|
+
project_ids: Optional[List[int]] = None,
|
|
289
|
+
environment_ids: Optional[List[int]] = None,
|
|
290
|
+
dry_run: bool = False,
|
|
291
|
+
json_output: bool = False,
|
|
292
|
+
behavior_change: bool = False,
|
|
293
|
+
):
|
|
294
|
+
"""Update jobs in dbt Cloud."""
|
|
295
|
+
dbt_cloud = DBTClient(account_id, api_key, base_url, disable_ssl_verification)
|
|
296
|
+
jobs = dbt_cloud.get_jobs(project_ids, environment_ids)
|
|
297
|
+
|
|
298
|
+
changesets: List[DBTCloudChangesetResult] = []
|
|
299
|
+
|
|
300
|
+
for job in jobs:
|
|
301
|
+
modified, updated_steps = job_steps_updated(job, behavior_change)
|
|
302
|
+
execute_steps: List[str] = job.get("execute_steps", [])
|
|
303
|
+
|
|
304
|
+
if modified:
|
|
305
|
+
refactor = DBTCloudRefactor(
|
|
306
|
+
rule_name="m_selector_deprecated",
|
|
307
|
+
original_value=execute_steps,
|
|
308
|
+
new_value=updated_steps,
|
|
309
|
+
refactor_logs=[f"Updated steps from {execute_steps} to {updated_steps}"],
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
new_job = job.copy()
|
|
313
|
+
new_job["execute_steps"] = updated_steps
|
|
314
|
+
|
|
315
|
+
changeset = DBTCloudChangesetResult(
|
|
316
|
+
dry_run=dry_run,
|
|
317
|
+
object_id=job["id"],
|
|
318
|
+
object_name=job["name"],
|
|
319
|
+
original_object=job,
|
|
320
|
+
new_object=new_job,
|
|
321
|
+
url=f"{base_url}/deploy/{account_id}/projects/{job['project_id']}/jobs/{job['id']}/settings",
|
|
322
|
+
refactors=[refactor],
|
|
323
|
+
)
|
|
324
|
+
changesets.append(changeset)
|
|
325
|
+
|
|
326
|
+
if not dry_run:
|
|
327
|
+
dbt_cloud.update_job(new_job)
|
|
328
|
+
else:
|
|
329
|
+
changeset = DBTCloudChangesetResult(
|
|
330
|
+
dry_run=dry_run,
|
|
331
|
+
object_id=job["id"],
|
|
332
|
+
object_name=job["name"],
|
|
333
|
+
original_object=job,
|
|
334
|
+
new_object=job,
|
|
335
|
+
url=f"{base_url}/deploy/{account_id}/projects/{job['project_id']}/jobs/{job['id']}/settings",
|
|
336
|
+
refactors=[],
|
|
337
|
+
)
|
|
338
|
+
changesets.append(changeset)
|
|
339
|
+
logging.debug(f"Job '{job['id']} - {job['name']}' does not need to be updated")
|
|
340
|
+
|
|
341
|
+
# Print results
|
|
342
|
+
for changeset in changesets:
|
|
343
|
+
changeset.print_to_console(json_output)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DeprecationType(str, Enum):
|
|
5
|
+
"""String enum for deprecation types used in DbtDeprecationRefactor."""
|
|
6
|
+
|
|
7
|
+
UNEXPECTED_JINJA_BLOCK_DEPRECATION = "UnexpectedJinjaBlockDeprecation"
|
|
8
|
+
RESOURCE_NAMES_WITH_SPACES_DEPRECATION = "ResourceNamesWithSpacesDeprecation"
|
|
9
|
+
PROPERTY_MOVED_TO_CONFIG_DEPRECATION = "PropertyMovedToConfigDeprecation"
|
|
10
|
+
CUSTOM_KEY_IN_OBJECT_DEPRECATION = "CustomKeyInObjectDeprecation"
|
|
11
|
+
MISSING_GENERIC_TEST_ARGUMENTS_PROPERTY_DEPRECATION = "MissingGenericTestArgumentsPropertyDeprecation"
|
|
12
|
+
DUPLICATE_YAML_KEYS_DEPRECATION = "DuplicateYAMLKeysDeprecation"
|
|
13
|
+
EXPOSURE_NAME_DEPRECATION = "ExposureNameDeprecation"
|
|
14
|
+
CONFIG_LOG_PATH_DEPRECATION = "ConfigLogPathDeprecation"
|
|
15
|
+
CONFIG_TARGET_PATH_DEPRECATION = "ConfigTargetPathDeprecation"
|
|
16
|
+
CONFIG_DATA_PATH_DEPRECATION = "ConfigDataPathDeprecation"
|
|
17
|
+
CONFIG_SOURCE_PATH_DEPRECATION = "ConfigSourcePathDeprecation"
|
|
18
|
+
MISSING_PLUS_PREFIX_DEPRECATION = "MissingPlusPrefixDeprecation"
|
|
19
|
+
CUSTOM_TOP_LEVEL_KEY_DEPRECATION = "CustomTopLevelKeyDeprecation"
|
|
20
|
+
CUSTOM_KEY_IN_CONFIG_DEPRECATION = "CustomKeyInConfigDeprecation"
|