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.
Files changed (39) hide show
  1. dbt_autofix/__init__.py +0 -0
  2. dbt_autofix/_jinja_environment.py +305 -0
  3. dbt_autofix/dbt_api.py +343 -0
  4. dbt_autofix/deprecations.py +20 -0
  5. dbt_autofix/duplicate_keys.py +133 -0
  6. dbt_autofix/fields_properties_configs.py +348 -0
  7. dbt_autofix/hub_packages.py +72 -0
  8. dbt_autofix/jinja.py +259 -0
  9. dbt_autofix/main.py +252 -0
  10. dbt_autofix/package_upgrade.py +584 -0
  11. dbt_autofix/packages/__init__.py +0 -0
  12. dbt_autofix/packages/dbt_package_file.py +319 -0
  13. dbt_autofix/packages/dbt_package_lock_file.py +155 -0
  14. dbt_autofix/packages/dbt_package_text_file.py +315 -0
  15. dbt_autofix/packages/installed_packages.py +168 -0
  16. dbt_autofix/refactor.py +740 -0
  17. dbt_autofix/refactors/__init__.py +0 -0
  18. dbt_autofix/refactors/changesets/__init__.py +0 -0
  19. dbt_autofix/refactors/changesets/dbt_project_yml.py +636 -0
  20. dbt_autofix/refactors/changesets/dbt_python.py +362 -0
  21. dbt_autofix/refactors/changesets/dbt_schema_yml.py +1086 -0
  22. dbt_autofix/refactors/changesets/dbt_schema_yml_semantic_layer.py +1065 -0
  23. dbt_autofix/refactors/changesets/dbt_sql.py +723 -0
  24. dbt_autofix/refactors/changesets/dbt_sql_improved.py +142 -0
  25. dbt_autofix/refactors/constants.py +17 -0
  26. dbt_autofix/refactors/fancy_quotes_utils.py +22 -0
  27. dbt_autofix/refactors/iceberg_table_format.py +40 -0
  28. dbt_autofix/refactors/results.py +386 -0
  29. dbt_autofix/refactors/static_analysis.py +127 -0
  30. dbt_autofix/refactors/yml.py +68 -0
  31. dbt_autofix/retrieve_schemas.py +348 -0
  32. dbt_autofix/semantic_definitions.py +189 -0
  33. dbt_autofix-0.0.0.post1.dev0.dist-info/METADATA +200 -0
  34. dbt_autofix-0.0.0.post1.dev0.dist-info/RECORD +39 -0
  35. dbt_autofix-0.0.0.post1.dev0.dist-info/WHEEL +4 -0
  36. dbt_autofix-0.0.0.post1.dev0.dist-info/entry_points.txt +2 -0
  37. dbt_autofix-0.0.0.post1.dev0.dist-info/licenses/LICENSE +201 -0
  38. pre_commit_hooks/__init__.py +0 -0
  39. pre_commit_hooks/check_deprecations.py +163 -0
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"