Skip to content

Commit 6745888

Browse files
authored
Merge pull request #135 from dbt-labs/add-json-output
Add `--json` parameter to `plan` and `sync`
2 parents c49e994 + c377c08 commit 6745888

10 files changed

Lines changed: 593 additions & 58 deletions

File tree

docs/advanced_config/jobs_importing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ dbt-jobs-as-code import-jobs --account-id 1234 --project-id 3213 --environment-i
7373
It would then be possible to automate the creation of PRs whenever the `jobs.yml` file is updated, meaning that some jobs would have been updated in the dbt Cloud UI. The GitHub action [Create Pull Request](https://github.com/marketplace/actions/create-pull-request) could be used to implement this flow.
7474

7575

76-
Then, the `jobs.yml` file can be used to import the jobs in a different environment with the following command, like described in [YAML templating](templating.md) and in the [typical flows](../typical_flows.md) page :
76+
Then, the `jobs.yml` file can be used to import the jobs in a different environment with the following command, like described in [YAML templating](templating.md) and in the [typical flows](../typical_flows.md#advanced-flows) page :
7777

7878
```bash
7979
dbt-jobs-as-code plan jobs.yml -v qa_vars.yml --limit-projects-envs-to-yml

docs/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11

22
To see the details of all changes, head to the GitHub repo
33

4+
### 1.5
5+
6+
- Add `--json` to `plan` and `sync` to output the `stdout` changes in JSON format. This can be useful for automating some processes and consuming the changes from scripts. We are still printing logs to `stderr` though, so to remove those logs you can redirect `stderr` to `/dev/null` or redirect `stdout` to a file and then read from the file.
7+
48
### 1.4
59

610
- Add `--templated-fields` to `import-jobs` to add Jinja variables to the generated YAML file. This can be useful to allow users to maintain jobs in the dbt Cloud UI and set a process to automatically promote those to other environments.

src/dbt_jobs_as_code/cloud_yaml_mapping/change_set.py

Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import glob
2+
import json
23
import os
34
import string
45
from collections import Counter
6+
from typing import Dict, Optional
57

68
from beartype import BeartypeConf, BeartypeStrategy, beartype
79
from beartype.typing import Callable, List
810
from loguru import logger
911
from pydantic import BaseModel
12+
from rich.console import Console
1013
from rich.table import Table
1114

1215
from dbt_jobs_as_code.client import DBTCloud, DBTCloudException
@@ -18,6 +21,12 @@
1821
nobeartype = beartype(conf=BeartypeConf(strategy=BeartypeStrategy.O0))
1922

2023

24+
def json_serializer_type(obj):
25+
if isinstance(obj, type):
26+
return obj.__name__
27+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
28+
29+
2130
class Change(BaseModel):
2231
"""Describes what a given change is and how to apply it."""
2332

@@ -28,6 +37,7 @@ class Change(BaseModel):
2837
env_id: int
2938
sync_function: Callable
3039
parameters: dict
40+
differences: Optional[Dict] = {}
3141

3242
def __str__(self):
3343
return f"{self.action.upper()} {string.capwords(self.type)} {self.identifier}"
@@ -74,6 +84,32 @@ def to_table(self) -> Table:
7484

7585
return table
7686

87+
def to_json(self) -> dict:
88+
"""Return a structured JSON representation of the changeset."""
89+
job_changes = []
90+
env_var_changes = []
91+
92+
for change in self.root:
93+
# Create the base change dictionary for overall changes
94+
overall_change_dict = {
95+
"action": change.action.upper(),
96+
"type": string.capwords(change.type),
97+
"identifier": change.identifier,
98+
"project_id": change.proj_id,
99+
"environment_id": change.env_id,
100+
"differences": change.differences,
101+
}
102+
103+
if change.type == "job":
104+
job_changes.append(overall_change_dict)
105+
elif change.type == "env var overwrite":
106+
env_var_changes.append(overall_change_dict)
107+
108+
return {
109+
"job_changes": job_changes,
110+
"env_var_overwrite_changes": env_var_changes,
111+
}
112+
77113
def __len__(self):
78114
return len(self.root)
79115

@@ -144,6 +180,7 @@ def build_change_set(
144180
project_ids: List[int],
145181
environment_ids: List[int],
146182
limit_projects_envs_to_yml: bool = False,
183+
output_json: bool = False,
147184
):
148185
"""Compares the config of YML files versus dbt Cloud.
149186
Depending on the value of no_update, it will either update the dbt Cloud config or not.
@@ -203,12 +240,15 @@ def build_change_set(
203240
deleted_jobs = set(tracked_jobs.keys()) - set(defined_jobs.keys())
204241

205242
# Update changed jobs
206-
logger.info("Detected {count} existing jobs.", count=len(shared_jobs))
243+
if not output_json:
244+
logger.info("Detected {count} existing jobs.", count=len(shared_jobs))
207245
for identifier in shared_jobs:
208-
logger.info("Checking for differences in {identifier}", identifier=identifier)
209-
if not check_job_mapping_same(
246+
if not output_json:
247+
logger.info("Checking for differences in {identifier}", identifier=identifier)
248+
is_same, diff_data = check_job_mapping_same(
210249
source_job=defined_jobs[identifier], dest_job=tracked_jobs[identifier]
211-
):
250+
)
251+
if not is_same:
212252
dbt_cloud_change = Change(
213253
identifier=identifier,
214254
type="job",
@@ -217,12 +257,21 @@ def build_change_set(
217257
env_id=defined_jobs[identifier].environment_id,
218258
sync_function=dbt_cloud.update_job,
219259
parameters={"job": defined_jobs[identifier]},
260+
differences=diff_data.get("differences", {}) if diff_data else {},
220261
)
221262
dbt_cloud_change_set.append(dbt_cloud_change)
222263
defined_jobs[identifier].id = tracked_jobs[identifier].id
264+
if not output_json:
265+
console = Console()
266+
console.print(
267+
f"❌ Job {identifier} is different - Diff:\n{json.dumps(diff_data, indent=2, default=json_serializer_type)}"
268+
)
269+
elif not output_json:
270+
logger.success(f"✅ Job {identifier} is identical")
223271

224272
# Create new jobs
225-
logger.info("Detected {count} new jobs.", count=len(created_jobs))
273+
if not output_json:
274+
logger.info("Detected {count} new jobs.", count=len(created_jobs))
226275
for identifier in created_jobs:
227276
dbt_cloud_change = Change(
228277
identifier=identifier,
@@ -236,7 +285,8 @@ def build_change_set(
236285
dbt_cloud_change_set.append(dbt_cloud_change)
237286

238287
# Remove Deleted Jobs
239-
logger.info("Detected {count} deleted jobs.", count=len(deleted_jobs))
288+
if not output_json:
289+
logger.info("Detected {count} deleted jobs.", count=len(deleted_jobs))
240290
for identifier in deleted_jobs:
241291
dbt_cloud_change = Change(
242292
identifier=identifier,
@@ -252,7 +302,8 @@ def build_change_set(
252302
# -- ENV VARS --
253303
# Now that we have replicated all jobs we can get their IDs for further API calls
254304
mapping_job_identifier_job_id = dbt_cloud.build_mapping_job_identifier_job_id(cloud_jobs)
255-
logger.debug(f"Mapping of job identifier to id: {mapping_job_identifier_job_id}")
305+
if not output_json:
306+
logger.debug(f"Mapping of job identifier to id: {mapping_job_identifier_job_id}")
256307

257308
# Replicate the env vars from the YML to dbt Cloud
258309
for job in defined_jobs.values():
@@ -261,14 +312,21 @@ def build_change_set(
261312
all_env_vars_for_job = dbt_cloud.get_env_vars(project_id=job.project_id, job_id=job_id)
262313
for env_var_yml in job.custom_environment_variables:
263314
env_var_yml.job_definition_id = job_id
264-
same_env_var, env_var_id = check_env_var_same(
315+
same_env_var, env_var_id, diff_data = check_env_var_same(
265316
source_env_var=env_var_yml, dest_env_vars=all_env_vars_for_job
266317
)
267-
if not same_env_var:
318+
if not same_env_var and diff_data:
319+
action = (
320+
"CREATE"
321+
if diff_data.get("old_value") is None
322+
else "DELETE"
323+
if diff_data.get("new_value") is None
324+
else "UPDATE"
325+
)
268326
dbt_cloud_change = Change(
269327
identifier=f"{job.identifier}:{env_var_yml.name}",
270328
type="env var overwrite",
271-
action="update",
329+
action=action,
272330
proj_id=job.project_id,
273331
env_id=job.environment_id,
274332
sync_function=dbt_cloud.update_env_var,
@@ -278,6 +336,7 @@ def build_change_set(
278336
"custom_env_var": env_var_yml,
279337
"env_var_id": env_var_id,
280338
},
339+
differences=diff_data,
281340
)
282341
dbt_cloud_change_set.append(dbt_cloud_change)
283342

@@ -315,7 +374,8 @@ def build_change_set(
315374
for env_var, env_var_val in env_var_dbt_cloud.items():
316375
# If the env var is not in the YML but is defined at the "job" level in dbt Cloud, we delete it
317376
if env_var not in env_vars_for_job and env_var_val.id:
318-
logger.info(f"{env_var} not in the YML file but in the dbt Cloud job")
377+
if not output_json:
378+
logger.info(f"{env_var} not in the YML file but in the dbt Cloud job")
319379
dbt_cloud_change = Change(
320380
identifier=f"{job.identifier}:{env_var}",
321381
type="env var overwrite",
@@ -332,6 +392,5 @@ def build_change_set(
332392

333393
# Filtering out the change set, if project_id(s), environment_id(s) are passed as arguments to function
334394
# TODO: Confirm if this is the desired functionality, remove otherwise
335-
logger.debug(f"dbt cloud change set: {dbt_cloud_change_set}")
336395

337396
return dbt_cloud_change_set

src/dbt_jobs_as_code/loader/load.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ def _load_yaml_no_template(config_files: List[str]) -> dict:
7373
return combined_config
7474

7575

76+
def _replace_none_with_null(obj):
77+
if isinstance(obj, dict):
78+
return {k: _replace_none_with_null(v) for k, v in obj.items()}
79+
elif isinstance(obj, list):
80+
return [_replace_none_with_null(item) for item in obj]
81+
return "null" if obj is None else obj
82+
83+
7684
def _load_vars_files(vars_file: List[str]) -> dict:
7785
"""Load and merge multiple vars files into a single dictionary.
7886
@@ -97,7 +105,7 @@ def _load_vars_files(vars_file: List[str]) -> dict:
97105
f"Variable '{key}' is defined multiple times in vars files"
98106
)
99107
template_vars_values.update(vars_data)
100-
return template_vars_values
108+
return _replace_none_with_null(template_vars_values) # type: ignore
101109

102110

103111
def _load_yaml_with_template(config_files: List[str], vars_file: List[str]) -> dict:

src/dbt_jobs_as_code/main.py

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import os
23
import sys
34
from importlib.metadata import version
@@ -10,7 +11,7 @@
1011
from ruamel.yaml import YAML
1112

1213
from dbt_jobs_as_code.client import DBTCloud
13-
from dbt_jobs_as_code.cloud_yaml_mapping.change_set import build_change_set
14+
from dbt_jobs_as_code.cloud_yaml_mapping.change_set import build_change_set, json_serializer_type
1415
from dbt_jobs_as_code.cloud_yaml_mapping.validate_link import can_be_linked
1516
from dbt_jobs_as_code.exporter.export import export_jobs_yml
1617
from dbt_jobs_as_code.importer import check_job_fields, fetch_jobs, get_account_id
@@ -58,6 +59,13 @@
5859
help="The path to your vars_yml YML file (or pattern for those files) when using a templated job YML file.",
5960
)
6061

62+
option_json_output = click.option(
63+
"--json",
64+
"output_json",
65+
is_flag=True,
66+
help="Output results in JSON format instead of human-readable text.",
67+
)
68+
6169

6270
@click.group(
6371
help=f"dbt-jobs-as-code {VERSION}\n\nA CLI to allow defining dbt Cloud jobs as code",
@@ -75,13 +83,15 @@ def cli() -> None:
7583
@option_project_ids
7684
@option_environment_ids
7785
@option_limit_projects_envs_to_yml
86+
@option_json_output
7887
def sync(
7988
config: str,
8089
vars_yml,
8190
project_id,
8291
environment_id,
8392
limit_projects_envs_to_yml,
8493
disable_ssl_verification,
94+
output_json: bool,
8595
):
8696
"""Synchronize a dbt Cloud job config file against dbt Cloud.
8797
This command will update dbt Cloud with the changes in the local YML file. It is recommended to run a `plan` first to see what will be changed.
@@ -111,13 +121,20 @@ def sync(
111121
cloud_project_ids,
112122
cloud_environment_ids,
113123
limit_projects_envs_to_yml,
124+
output_json=output_json,
114125
)
115126
if len(change_set) == 0:
116-
logger.success("-- SYNC -- No changes detected.")
127+
if output_json:
128+
print(json.dumps({"job_changes": [], "env_var_overwrite_changes": []}))
129+
else:
130+
logger.success("-- SYNC -- No changes detected.")
117131
else:
118-
logger.info("-- SYNC -- {count} changes detected.", count=len(change_set))
119-
console = Console()
120-
console.log(change_set.to_table())
132+
if output_json:
133+
print(json.dumps(change_set.to_json()))
134+
else:
135+
logger.info("-- SYNC -- {count} changes detected.", count=len(change_set))
136+
console = Console()
137+
console.log(change_set.to_table())
121138
change_set.apply()
122139

123140
if not change_set.apply_success:
@@ -132,13 +149,15 @@ def sync(
132149
@option_project_ids
133150
@option_environment_ids
134151
@option_limit_projects_envs_to_yml
152+
@option_json_output
135153
def plan(
136154
config: str,
137155
vars_yml: str,
138156
project_id: List[int],
139157
environment_id: List[int],
140158
limit_projects_envs_to_yml: bool,
141159
disable_ssl_verification: bool,
160+
output_json: bool,
142161
):
143162
"""Check the difference between a local file and dbt Cloud without updating dbt Cloud.
144163
This command will not update dbt Cloud.
@@ -167,13 +186,20 @@ def plan(
167186
cloud_project_ids,
168187
cloud_environment_ids,
169188
limit_projects_envs_to_yml,
189+
output_json=output_json,
170190
)
171191
if len(change_set) == 0:
172-
logger.success("-- PLAN -- No changes detected.")
192+
if output_json:
193+
print(json.dumps({"job_changes": [], "env_var_overwrite_changes": []}))
194+
else:
195+
logger.success("-- PLAN -- No changes detected.")
173196
else:
174-
logger.info("-- PLAN -- {count} changes detected.", count=len(change_set))
175-
console = Console()
176-
console.log(change_set.to_table())
197+
if output_json:
198+
print(json.dumps(change_set.to_json(), default=json_serializer_type))
199+
else:
200+
logger.info("-- PLAN -- {count} changes detected.", count=len(change_set))
201+
console = Console()
202+
console.log(change_set.to_table())
177203

178204

179205
@cli.command()

0 commit comments

Comments
 (0)