Skip to content

Add support for preserving scientific notation for floats #225

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions hcl2/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ def loads(text: str, with_meta=False) -> dict:
tree = parser().parse(text + "\n")
return DictTransformer(with_meta=with_meta).transform(tree)

def loads_preserving_format(text: str, with_meta=False) -> dict:
"""Load HCL2 from a string, preserving the format of values like scientific notation."""
tree = parser().parse(text + "\n")
return DictTransformer(with_meta=with_meta, preserve_format=True).transform(tree)

def parse(file: TextIO) -> Tree:
"""Load HCL2 syntax tree from a file.
Expand Down
14 changes: 14 additions & 0 deletions hcl2/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,17 @@ def _add_nested_blocks(
block[key] = []
block[key].extend(value)
return block

def sci_float(self, value, original_format=None):
"""
Creates a special float representation that preserves scientific notation
format information.
"""
if original_format is None:
original_format = str(value)

return {
"__sci_float__": True,
"value": value,
"format": original_format
}
89 changes: 89 additions & 0 deletions hcl2/reconstructor.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,16 +253,26 @@ def _should_add_space(self, rule, current_terminal):
r"^__(tuple|arguments)_(star|plus)_.*", self._last_rule
):

if self._last_terminal == Terminal("COMMA"):
return True

# string literals, decimals, and identifiers should always be
# preceded by a space if they're following a comma in a tuple or
# function arg
if current_terminal in [
Terminal("STRING_LIT"),
Terminal("DECIMAL"),
Terminal("NAME"),
Terminal("NEGATIVE_DECIMAL")
]:
return True

if self._last_terminal == Terminal("COMMA") and (
current_terminal == Terminal("NEGATIVE_DECIMAL") or
current_terminal == Terminal("DECIMAL")
):
return True

# the catch-all case, we're not sure, so don't add a space
return False

Expand Down Expand Up @@ -548,6 +558,49 @@ def _transform_value_to_expr_term(self, value, level) -> Union[Token, Tree]:
[Tree(Token("RULE", "identifier"), [Token("NAME", "null")])],
)

# Special handling for scientific notation metadata
if isinstance(value, dict) and value.get("__sci_float__") is True:
# Extract the format string from metadata
format_str = value.get("format", str(value.get("value")))

# Check if it's scientific notation
if 'e' in format_str.lower():
# Parse the scientific notation format
base_part, exp_part = format_str.lower().split('e')

# Handle the base part
int_part, dec_part = base_part.split('.') if '.' in base_part else (base_part, "")

# Create tokens
tokens = []

# Handle negative sign if present
is_negative = int_part.startswith('-')
if is_negative:
int_part = int_part[1:]
tokens.append(Token("NEGATIVE_DECIMAL", "-" + int_part[0]))
for digit in int_part[1:]:
tokens.append(Token("DECIMAL", digit))
else:
for digit in int_part:
tokens.append(Token("DECIMAL", digit))

# Add decimal part if exists
if dec_part:
tokens.append(Token("DOT", "."))
for digit in dec_part:
tokens.append(Token("DECIMAL", digit))

# Use the sign from the original format
exp_sign = "+" if "+" in exp_part else "-" if "-" in exp_part else ""
exp_digits = exp_part.lstrip("+-")
tokens.append(Token("EXP_MARK", f"e{exp_sign}{exp_digits}"))

return Tree(
Token("RULE", "expr_term"),
[Tree(Token("RULE", "float_lit"), tokens)]
)

# for dicts, recursively turn the child k/v pairs into object elements
# and store within an object
if isinstance(value, dict):
Expand Down Expand Up @@ -599,6 +652,42 @@ def _transform_value_to_expr_term(self, value, level) -> Union[Token, Tree]:
],
)

if isinstance(value, float):
str_value = str(value)

# For regular floats (no scientific notation)
# Split into integer and decimal parts
if '.' in str_value:
int_part, dec_part = str_value.split('.')
else:
int_part, dec_part = str_value, ""

# Check if negative
is_negative = int_part.startswith('-')
if is_negative:
int_part = int_part[1:] # Remove negative sign for processing

tokens = []

# Handle integer part based on negative flag
if is_negative:
tokens.append(Token("NEGATIVE_DECIMAL", "-" + int_part[0]))
for digit in int_part[1:]:
tokens.append(Token("DECIMAL", digit))
else:
for digit in int_part:
tokens.append(Token("DECIMAL", digit))

# Add decimal part
tokens.append(Token("DOT", "."))
for digit in dec_part:
tokens.append(Token("DECIMAL", digit))

return Tree(
Token("RULE", "expr_term"),
[Tree(Token("RULE", "float_lit"), tokens)]
)

# store integers as literals, digit by digit
if isinstance(value, int):
return Tree(
Expand Down
21 changes: 17 additions & 4 deletions hcl2/transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import re
import sys
from collections import namedtuple
from typing import List, Dict, Any
from typing import List, Dict, Any, Union

from lark import Token
from lark.tree import Meta
Expand Down Expand Up @@ -35,16 +35,29 @@ class DictTransformer(Transformer):
def is_type_keyword(value: str) -> bool:
return value in {"bool", "number", "string"}

def __init__(self, with_meta: bool = False):
def __init__(self, with_meta: bool = False, preserve_format: bool = False):
"""
:param with_meta: If set to true then adds `__start_line__` and `__end_line__`
parameters to the output dict. Default to false.
:param preserve_format: If set to true, preserves formatting of special values
like scientific notation. Default to false.
"""
self.with_meta = with_meta
self.preserve_format = preserve_format
super().__init__()

def float_lit(self, args: List) -> float:
return float("".join([self.to_tf_inline(arg) for arg in args]))
def float_lit(self, args: List) -> Union[float, Dict]:
original_string = "".join([self.to_tf_inline(arg) for arg in args])
float_value = float(original_string)

# Check if it's in scientific notation
if 'e' in original_string.lower() and self.preserve_format:
return {
"__sci_float__": True,
"value": float_value,
"format": original_string
}
return float_value

def int_lit(self, args: List) -> int:
return int("".join([self.to_tf_inline(arg) for arg in args]))
Expand Down
27 changes: 27 additions & 0 deletions test/helpers/terraform-config/test_floats.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
resource "test_resource" "float_examples" {
simple_float = 123.456
small_float = 0.123
large_float = 9876543.21
negative_float = -42.5
negative_small = -0.001
scientific_positive = 1.23e5
scientific_negative = 9.87e-3
scientific_large = 6.022e+23
integer_as_float = 100.0
float_calculation = 10.5 * 3.0 / 2.1
float_comparison = 5.6 > 2.3 ? 1.0 : 0.0
float_list = [1.1, 2.2, 3.3, -4.4, 5.5e2]
float_object = {
pi = 3.14159
euler = 2.71828
sqrt2 = 1.41421
}
}

variable "float_variable" {
default = 3.14159
}

output "float_output" {
value = var.float_variable * 2.0
}
73 changes: 73 additions & 0 deletions test/unit/test_complex_floats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Test building HCL files with complex float values"""

from pathlib import Path
from unittest import TestCase

import hcl2
import hcl2.builder


HELPERS_DIR = Path(__file__).absolute().parent.parent / "helpers"
HCL2_DIR = HELPERS_DIR / "terraform-config"
JSON_DIR = HELPERS_DIR / "terraform-config-json"
HCL2_FILES = [str(file.relative_to(HCL2_DIR)) for file in HCL2_DIR.iterdir()]


class TestComplexFloats(TestCase):
"""Test building hcl files with various float representations"""

# print any differences fully to the console
maxDiff = None

def test_builder_with_complex_floats(self):
builder = hcl2.Builder()

builder.block(
"resource",
["test_resource", "float_examples"],
simple_float = 123.456,
small_float = 0.123,
large_float = 9876543.21,
negative_float = -42.5,
negative_small = -0.001,
scientific_positive = builder.sci_float(1.23e5, "1.23e5"),
scientific_negative = builder.sci_float(9.87e-3, "9.87e-3"),
scientific_large = builder.sci_float(6.022e+23, "6.022e+23"),
integer_as_float= 100.0,
float_calculation = "${10.5 * 3.0 / 2.1}",
float_comparison = "${5.6 > 2.3 ? 1.0 : 0.0}",
float_list = [1.1, 2.2, 3.3, -4.4, builder.sci_float(5.5e2, "5.5e2")],
float_object = {
"pi": 3.14159,
"euler": 2.71828,
"sqrt2": 1.41421
}
)

builder.block(
"variable",
["float_variable"],
default=3.14159,
)

builder.block(
"output",
["float_output"],
value="${var.float_variable * 2.0}",
)

self.compare_filenames(builder, "test_floats.tf")

def compare_filenames(self, builder: hcl2.Builder, filename: str):
hcl_dict = builder.build()
hcl_ast = hcl2.reverse_transform(hcl_dict)
hcl_content_built = hcl2.writes(hcl_ast)

hcl_path = (HCL2_DIR / filename).absolute()
with hcl_path.open("r") as hcl_file:
hcl_file_content = hcl_file.read()
self.assertMultiLineEqual(
hcl_content_built,
hcl_file_content,
f"file {filename} does not match its programmatically built version.",
)