A Python dataclass is useful when a class mainly stores and shares data. Declared fields provide its common initialization behavior, while ordinary methods can add operations that belong with that data. For a Terraform or OpenTofu command, one result object can carry the command, standard output, standard error, and return code. section 11.1.2
Listing 11.3 Command results
import json #1
from dataclasses import dataclass #1
from typing import Any, Generator #1
@dataclass #2
class CommandResults:
command: str #3
stdout: str #4
stderr: str #5
returncode: int #6
def json(self) -> Dict[str, Any]: #7
return json.loads(self.stdout)
def jsonl(self) -> Generator[Dict[str, Any], None, None]: #8
for line in self.stdout.splitlines(): #9
yield json.loads(line) #10
def raise_error(self) -> None: #11
try:
for log in self.jsonl(): #12
if log.get("@level") == "error" and (
diagnostics := log.get("diagnostic")
):
raise RuntimeError(
f"Terraform command '{self.command}' failed:
{diagnostics.get('summary')}\n{diagnostics.get('detail')}",
diagnostics,
)
except Exception: #13
raise RuntimeError(
f"Terraform command '{self.command}' failed: {self.stdout}."
)
#1 We need to add a few new imports to use below.
#2 By turning this into a dataclass we allow Python to create methods like __init__ for us.
#3 We want to store the command to make it easier to generate useful error messages.
#4 The stdout is the most import piece, as it contains the output of the command.
#5 Most of the time we won’t need to use this, as Terraform logs to stdout in automation mode.
#6 For most commands, this records the success or failure of the command, but for Plan it has more meaning.
#7 This method will parse the stdout as JSON and return it.
#8 This method will parse the stdout as JSONL and return it.
#9 We split the stdout into lines, since JSONL is line delimited.
#10 We parse each line as JSON and yield it for use.
#11 This helper command allows us to raise an error with the command output.
#12 Some errors come in the form of JSONL, so we parse it to improve our messaging.
#13 If we can’t parse the logs as JSONL, we raise a generic error.
The CommandResults listing declares four result fields and adds methods to parse JSON or line-delimited JSON from standard output. The class keeps command data and its parsing operations together. section 11.1.2
Dataclasses can also turn command JSON into structured results. Validation uses a Validate object containing Diagnostic objects. Passing the full JSON data to each class keeps parsing near the fields it fills; the parent converts nested diagnostics into their own objects. section 11.1.5
Listing 11.11 Validate and diagnostic structures
from dataclasses import field
@dataclass
class Diagnostic:
# Format: https://developer.hashicorp.com/terraform/cli/commands/
validate#json #1
data: Dict[str, Any] = field(repr=False) #2
severity: str = field(init=False) #3
summary: str = field(init=False) #3
detail: str = field(init=False) #3
def __post_init__(self): #4
self.severity = self.data.get("severity") #5
self.summary = self.data.get("summary") #6
self.detail = self.data.get("detail")
@dataclass
class Validate:
# Format: https://developer.hashicorp.com/terraform/cli/commands/
validate#json #7
data: Dict[str, Any] = field(repr=False) #8
valid: bool = field(init=False) #9
diagnostics: List[Diagnostic] = field(init=False) #10
error_count: int = field(init=False) #11
warning_count: int = field(init=False) #12
def __post_init__(self):
self.valid = bool(self.data.get("valid")) #13
self.diagnostics = [ #14
Diagnostic(diagnostic) for diagnostic in self.data.get(
"diagnostics", []) #15
]
self.error_count = int(self.data.get("error_count")) #16
self.warning_count = int(self.data.get("warning_count")) #16
#1 It’s useful to store a link to the documentation for the data structure you’re using.
#2 The data field is the raw data. We don’t want it to show up if the whole object is printed.
#3 We set the init value of the field to false because we’re going to generate these values from data.
#4 This will be run immediately after the __init__ method. This is where we convert the raw data into the fields we want.
#5 The data property contains the raw data from Terraform after it has been parsed.
#6 Most of the work we’ll be doing going forward is simply mapping data to our fields.
#7 All of our classes will link to the source of their data structures.
#8 We will also always keep the raw data available in case it is needed.
#9 This lets us know if the validate passed or not.
#10 This will be a list of diagnostics that came from the validate command.
#11 The validate command returns an error count.
#12 It also returns the number of warnings.
#13 We pull in the field and convert it to the appropriate type.
#14 For nested fields like this we pass the raw data to the class we want to use.
#15 Here we are creating a list of diagnostics.
#16 We convert these fields into integers.
The Diagnostic listing accepts a data dictionary, excludes it from the representation with field(repr=False), and marks derived fields init=False. Its __post_init__ method fills those fields from the dictionary. section 11.1.5
Keep command status distinct from the result being modeled: an invalid configuration can make validation return code 1 without preventing a useful Validate object. Tests should check both successful and failed validation, including the Diagnostic objects produced for errors. section 11.1.5






