├── .github └── workflows │ ├── default.yml │ └── dependabot-to-jira.yml ├── .gitignore ├── MANIFEST.in ├── README.md ├── build-macos.sh ├── dev-requirements.txt ├── go.mod ├── go.sum ├── hcl_to_json.go ├── pre-build-command.sh ├── pygohcl.go ├── pygohcl ├── __init__.py └── build_cffi.py ├── pyproject.toml ├── setup.py └── tests ├── test_attributes.py ├── test_comments.py ├── test_pygohcl.py └── test_validation.py /.github/workflows/default.yml: -------------------------------------------------------------------------------- 1 | name: pygohcl python package 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | tests: 13 | name: tests 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | cache: pip 26 | cache-dependency-path: dev-requirements.txt 27 | check-latest: true 28 | 29 | - name: Set up Go 30 | uses: actions/setup-go@v3 31 | with: 32 | go-version: "1.23.9" 33 | 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | pip install -r dev-requirements.txt 38 | 39 | - name: Install package 40 | run: | 41 | pip install -e . 42 | 43 | - name: Test with pytest for Python ${{ matrix.python-version }} 44 | run: | 45 | pytest --doctest-modules -o junit_family=xunit2 --junitxml=junit/test-results-${{ matrix.python-version }}.xml 46 | 47 | - name: Upload pytest test results 48 | uses: actions/upload-artifact@v4 49 | with: 50 | name: pytest-results-${{ matrix.python-version }} 51 | path: junit/test-results-${{ matrix.python-version }}.xml 52 | if: ${{ always() }} 53 | 54 | build-wheels: 55 | name: Build wheel for ${{ matrix.python }}-${{ matrix.buildplat[1] }}-${{ matrix.buildplat[2] }} 56 | needs: [tests] 57 | runs-on: ${{ matrix.buildplat[0] }} 58 | strategy: 59 | fail-fast: false 60 | matrix: 61 | buildplat: 62 | - [ubuntu-latest, manylinux, "x86_64 aarch64"] 63 | - [macos-13, macosx, x86_64] 64 | - [macos-14, macosx, arm64] 65 | python: ["cp38", "cp39", "cp310", "cp311", "cp312", "cp313"] 66 | 67 | steps: 68 | - name: Checkout 69 | uses: actions/checkout@v3 70 | with: 71 | fetch-depth: 0 72 | 73 | - name: Set up QEMU 74 | if: runner.os == 'Linux' 75 | uses: docker/setup-qemu-action@v1 76 | with: 77 | platforms: all 78 | 79 | - name: Build wheels on linux 80 | if: ${{ matrix.buildplat[1] == 'manylinux' }} 81 | uses: pypa/cibuildwheel@v2.21.3 82 | env: 83 | CIBW_ENVIRONMENT: PATH=$(pwd)/go/bin:$PATH 84 | CIBW_BEFORE_BUILD: sh pre-build-command.sh 85 | CIBW_BUILD: ${{ matrix.python }}-${{ matrix.buildplat[1] }}* 86 | CIBW_SKIP: "pp* *-musllinux*" 87 | CIBW_ARCHS_LINUX: ${{ matrix.buildplat[2] }} 88 | 89 | - name: Build wheels on macos 90 | if: ${{ matrix.buildplat[1] == 'macosx' }} 91 | uses: pypa/cibuildwheel@v2.21.3 92 | env: 93 | CIBW_ENVIRONMENT: PATH=$(pwd)/go/bin:$PATH 94 | CIBW_BEFORE_BUILD: sh pre-build-command.sh 95 | CIBW_BUILD: ${{ matrix.python }}-${{ matrix.buildplat[1] }}* 96 | CIBW_SKIP: "pp* *-musllinux*" 97 | CIBW_ARCHS_MACOS: ${{ matrix.buildplat[2] }} 98 | 99 | - uses: actions/upload-artifact@v4 100 | with: 101 | name: wheels-${{ matrix.python }}-${{ matrix.buildplat[1] }}-${{ matrix.buildplat[2] }} 102 | path: ./wheelhouse/*.whl 103 | 104 | upload: 105 | name: upload 106 | if: startsWith(github.ref, 'refs/tags/') 107 | needs: [build-wheels] 108 | runs-on: ubuntu-latest 109 | 110 | steps: 111 | - uses: actions/download-artifact@v4 112 | with: 113 | pattern: wheels-* 114 | merge-multiple: true 115 | path: dist 116 | 117 | - name: Display structure of downloaded files 118 | run: ls -lh dist 119 | 120 | - uses: pypa/gh-action-pypi-publish@release/v1 121 | if: startsWith(github.ref, 'refs/tags/') 122 | with: 123 | skip-existing: true 124 | user: ${{ secrets.PYPI_USERNAME }} 125 | password: ${{ secrets.PYPI_PASSWORD }} 126 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-to-jira.yml: -------------------------------------------------------------------------------- 1 | name: Create Jira Tickets from Dependabot Security Alerts 2 | permissions: 3 | contents: read 4 | security-events: write 5 | on: 6 | schedule: 7 | - cron: '0 */6 * * *' #Runs every 6 hours 8 | workflow_dispatch: 9 | jobs: 10 | create_ticket: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Generate GitHub Token 14 | id: generate_token 15 | uses: tibdex/github-app-token@v1 16 | with: 17 | app_id: ${{vars.SUDO_GHA_APP_ID}} 18 | installation_id: ${{vars.SUDO_GHA_APP_INSTALLATION_ID}} 19 | private_key: ${{secrets.SUDO_GHA_APP_PRIVATE_KEY}} 20 | 21 | - name: Run Dependabot Alerts Checker 22 | uses: Scalr/actions/dependabot-jira-ticket@master 23 | with: 24 | github_repository: ${{ github.repository }} 25 | github_token: ${{ steps.generate_token.outputs.token }} 26 | jira_token: ${{ secrets.JIRA_TOKEN }} 27 | jira_user: ${{ vars.JIRA_USER }} 28 | jira_host: ${{ vars.JIRA_HOST }} 29 | jira_project: ${{ vars.JIRA_PROJECT }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.so 2 | venv 3 | /.python-version 4 | .eggs 5 | _pygohcl.py 6 | build 7 | dist 8 | __pycache__ 9 | *.egg-info 10 | .drone-secrets 11 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include go.mod 2 | include go.sum 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![pygohcl python package](https://github.com/Scalr/pygohcl/actions/workflows/default.yml/badge.svg)](https://github.com/Scalr/pygohcl/actions/workflows/default.yml) 2 | 3 | # pygohcl 4 | 5 | Python wrapper for [hashicorp/hcl](https://github.com/hashicorp/hcl) (v2). 6 | 7 | ## Requirements 8 | 9 | The following versions are supported - 3.8, 3.9, 3.10, 3.11, 3.12, 3.13. 10 | 11 | ## Setup 12 | 13 | ```sh 14 | pip install pygohcl 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```py 20 | >>> import pygohcl 21 | >>> pygohcl.loads("""variable "docker_ports" { 22 | ... type = list(object({ 23 | ... internal = number 24 | ... external = number 25 | ... protocol = string 26 | ... })) 27 | ... default = [ 28 | ... { 29 | ... internal = 8300 30 | ... external = 8300 31 | ... protocol = "tcp" 32 | ... } 33 | ... ] 34 | ... }""") 35 | {'variable': {'docker_ports': {'default': [{'external': 8300, 'internal': 8300, 'protocol': 'tcp'}], 'type': 'list(object({internal=numberexternal=numberprotocol=string}))'}}} 36 | ``` 37 | 38 | ## Building locally 39 | 40 | You can use the following commands to build a wheel for your platform: 41 | 42 | ```sh 43 | pip install wheel 44 | python setup.py bdist_wheel 45 | ``` 46 | 47 | The wheel will be available in `./dist/`. 48 | -------------------------------------------------------------------------------- /build-macos.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | # Run this script in the environment with the pre-installed go language. 6 | # Before using this script you need to set the target version explicitly in the setup.py as below: 7 | #diff --git a/setup.py b/setup.py 8 | #index 93a7c76..2a80a32 100644 9 | #--- a/setup.py 10 | #+++ b/setup.py 11 | #@@ -11,7 +11,8 @@ os.chdir(os.path.dirname(sys.argv[0]) or ".") 12 | # 13 | # setup( 14 | # name="pygohcl", 15 | #- use_scm_version=True, 16 | #+ # use_scm_version=True, 17 | #+ version="1.0.8", 18 | # description="Python bindings for Hashicorp HCL2 Go library", 19 | 20 | mkdir -p ./macos-dist 21 | rm -fr ./macos-dist/*.whl 22 | for VERSION in 3.8.10 3.9.16 3.10.10 3.11.3 3.12.0, 3.13.0 23 | do 24 | # Assumes that all python version are pre-installed. 25 | # pyenv install -v ${VERSION} 26 | 27 | # Create a python virtual environment and install all dev requirements 28 | pyenv virtualenv ${VERSION} tmp 29 | pyenv local tmp 30 | python -m pip install --upgrade pip 31 | pip install -r dev-requirements.txt 32 | pip install wheel 33 | 34 | # Build a wheel 35 | pip wheel --no-deps --use-pep517 -w dist . 36 | 37 | # Copy the wheel to the artifacts directory 38 | cp dist/*.whl ./macos-dist 39 | 40 | # cleanup a python virtual environment 41 | rm -fr build .eggs pygohcl.egg-info dist 42 | pip freeze | awk '{ print $1 }' | xargs pip uninstall -y 43 | pyenv virtualenv-delete -f tmp 44 | 45 | # pyenv uninstall -f ${VERSION} 46 | done 47 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | attrs==23.2.0 2 | auditwheel==5.4.0 3 | cffi==1.17.1 4 | importlib-metadata==7.0.1 5 | more-itertools==10.2.0 6 | packaging==23.2 7 | pluggy==1.3.0 8 | py==1.11.0 9 | pycparser==2.21 10 | pyelftools==0.30 11 | pyparsing==3.1.1 12 | pytest==7.4.4 13 | setuptools==69.0.3 14 | six==1.16.0 15 | wcwidth==0.2.13 16 | zipp==3.17.0 17 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Scalr/pygohcl 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/hashicorp/hcl/v2 v2.22.0 7 | github.com/zclconf/go-cty v1.13.0 8 | ) 9 | 10 | require ( 11 | github.com/agext/levenshtein v1.2.1 // indirect 12 | github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect 13 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect 14 | github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect 15 | golang.org/x/mod v0.8.0 // indirect 16 | golang.org/x/sys v0.5.0 // indirect 17 | golang.org/x/text v0.11.0 // indirect 18 | golang.org/x/tools v0.6.0 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= 2 | github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= 3 | github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= 4 | github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= 5 | github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= 6 | github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= 10 | github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= 11 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 12 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 13 | github.com/hashicorp/hcl/v2 v2.22.0 h1:hkZ3nCtqeJsDhPRFz5EA9iwcG1hNWGePOTw6oyul12M= 14 | github.com/hashicorp/hcl/v2 v2.22.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= 15 | github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= 16 | github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= 17 | github.com/zclconf/go-cty v1.13.0 h1:It5dfKTTZHe9aeppbNOda3mN7Ag7sg6QkBNm6TkyFa0= 18 | github.com/zclconf/go-cty v1.13.0/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0= 19 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= 20 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= 21 | golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= 22 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 23 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 24 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 25 | golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= 26 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 27 | golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= 28 | golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 29 | golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= 30 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 31 | -------------------------------------------------------------------------------- /hcl_to_json.go: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/tmccombs/hcl2json 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/hashicorp/hcl/v2" 9 | "github.com/hashicorp/hcl/v2/hclsyntax" 10 | "github.com/zclconf/go-cty/cty" 11 | ctyconvert "github.com/zclconf/go-cty/cty/convert" 12 | ctyjson "github.com/zclconf/go-cty/cty/json" 13 | ) 14 | 15 | type jsonObj map[string]interface{} 16 | 17 | // Convert an hcl File to a json serializable object 18 | // This assumes that the body is a hclsyntax.Body 19 | func convertFile(file *hcl.File, keepInterp bool) (jsonObj, error) { 20 | c := converter{bytes: file.Bytes, keepInterp: keepInterp} 21 | body := file.Body.(*hclsyntax.Body) 22 | return c.convertBody(body) 23 | } 24 | 25 | type converter struct { 26 | bytes []byte 27 | keepInterp bool 28 | } 29 | 30 | func (c *converter) rangeSource(r hcl.Range) string { 31 | data := string(c.bytes[r.Start.Byte:r.End.Byte]) 32 | data = stripComments(data) 33 | data = strings.ReplaceAll(data, "\n", " ") 34 | data = strings.Join(strings.Fields(data), " ") 35 | return data 36 | } 37 | 38 | func (c *converter) convertBody(body *hclsyntax.Body) (jsonObj, error) { 39 | var err error 40 | out := make(jsonObj) 41 | for key, value := range body.Attributes { 42 | out[key], err = c.convertExpression(value.Expr) 43 | if err != nil { 44 | return nil, err 45 | } 46 | } 47 | 48 | for _, block := range body.Blocks { 49 | err = c.convertBlock(block, out) 50 | if err != nil { 51 | return nil, err 52 | } 53 | } 54 | 55 | return out, nil 56 | } 57 | 58 | func (c *converter) convertBlock(block *hclsyntax.Block, out jsonObj) error { 59 | var key string = block.Type 60 | 61 | value, err := c.convertBody(block.Body) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | for _, label := range block.Labels { 67 | if inner, exists := out[key]; exists { 68 | var ok bool 69 | out, ok = inner.(jsonObj) 70 | if !ok { 71 | // TODO: better diagnostics 72 | return fmt.Errorf("unable to convert Block to JSON: %v.%v", block.Type, strings.Join(block.Labels, ".")) 73 | } 74 | } else { 75 | obj := make(jsonObj) 76 | out[key] = obj 77 | out = obj 78 | } 79 | key = label 80 | } 81 | 82 | if current, exists := out[key]; exists { 83 | if list, ok := current.([]interface{}); ok { 84 | out[key] = append(list, value) 85 | } else { 86 | out[key] = []interface{}{current, value} 87 | } 88 | } else { 89 | out[key] = value 90 | } 91 | 92 | return nil 93 | } 94 | 95 | func (c *converter) convertExpression(expr hclsyntax.Expression) (interface{}, error) { 96 | // assume it is hcl syntax (because, um, it is) 97 | switch value := expr.(type) { 98 | case *hclsyntax.LiteralValueExpr: 99 | return ctyjson.SimpleJSONValue{Value: value.Val}, nil 100 | case *hclsyntax.UnaryOpExpr: 101 | return c.convertUnary(value) 102 | case *hclsyntax.TemplateExpr: 103 | return c.convertTemplate(value) 104 | case *hclsyntax.TemplateWrapExpr: 105 | return c.convertExpression(value.Wrapped) 106 | case *hclsyntax.TupleConsExpr: 107 | list := make([]interface{}, 0) 108 | for _, ex := range value.Exprs { 109 | elem, err := c.convertExpression(ex) 110 | if err != nil { 111 | return nil, err 112 | } 113 | list = append(list, elem) 114 | } 115 | return list, nil 116 | case *hclsyntax.ObjectConsExpr: 117 | m := make(jsonObj) 118 | for _, item := range value.Items { 119 | key, err := c.convertKey(item.KeyExpr) 120 | if err != nil { 121 | return nil, err 122 | } 123 | m[key], err = c.convertExpression(item.ValueExpr) 124 | if err != nil { 125 | return nil, err 126 | } 127 | } 128 | return m, nil 129 | default: 130 | return c.wrapExpr(expr), nil 131 | } 132 | } 133 | 134 | func (c *converter) convertTemplate(t *hclsyntax.TemplateExpr) (string, error) { 135 | if t.IsStringLiteral() { 136 | // safe because the value is just the string 137 | v, err := t.Value(nil) 138 | if err != nil { 139 | return "", err 140 | } 141 | return v.AsString(), nil 142 | } 143 | var builder strings.Builder 144 | for _, part := range t.Parts { 145 | s, err := c.convertStringPart(part) 146 | if err != nil { 147 | return "", err 148 | } 149 | builder.WriteString(s) 150 | } 151 | return builder.String(), nil 152 | } 153 | 154 | func (c *converter) convertStringPart(expr hclsyntax.Expression) (string, error) { 155 | switch v := expr.(type) { 156 | case *hclsyntax.LiteralValueExpr: 157 | s, err := ctyconvert.Convert(v.Val, cty.String) 158 | if err != nil { 159 | return "", err 160 | } 161 | return s.AsString(), nil 162 | case *hclsyntax.TemplateExpr: 163 | return c.convertTemplate(v) 164 | case *hclsyntax.TemplateWrapExpr: 165 | return c.convertStringPart(v.Wrapped) 166 | case *hclsyntax.ConditionalExpr: 167 | return c.convertTemplateConditional(v) 168 | case *hclsyntax.TemplateJoinExpr: 169 | return c.convertTemplateFor(v.Tuple.(*hclsyntax.ForExpr)) 170 | case *hclsyntax.ScopeTraversalExpr: 171 | return c.wrapTraversal(expr), nil 172 | 173 | default: 174 | // treating as an embedded expression 175 | return c.wrapExpr(expr), nil 176 | } 177 | } 178 | 179 | func (c *converter) convertKey(keyExpr hclsyntax.Expression) (string, error) { 180 | // a key should never have dynamic input 181 | if k, isKeyExpr := keyExpr.(*hclsyntax.ObjectConsKeyExpr); isKeyExpr { 182 | keyExpr = k.Wrapped 183 | if _, isTraversal := keyExpr.(*hclsyntax.ScopeTraversalExpr); isTraversal { 184 | return c.rangeSource(keyExpr.Range()), nil 185 | } 186 | } 187 | return c.convertStringPart(keyExpr) 188 | } 189 | 190 | func (c *converter) convertTemplateConditional(expr *hclsyntax.ConditionalExpr) (string, error) { 191 | var builder strings.Builder 192 | builder.WriteString("%{if ") 193 | builder.WriteString(c.rangeSource(expr.Condition.Range())) 194 | builder.WriteString("}") 195 | trueResult, err := c.convertStringPart(expr.TrueResult) 196 | if err != nil { 197 | return "", nil 198 | } 199 | builder.WriteString(trueResult) 200 | falseResult, _ := c.convertStringPart(expr.FalseResult) 201 | if len(falseResult) > 0 { 202 | builder.WriteString("%{else}") 203 | builder.WriteString(falseResult) 204 | } 205 | builder.WriteString("%{endif}") 206 | 207 | return builder.String(), nil 208 | } 209 | 210 | func (c *converter) convertTemplateFor(expr *hclsyntax.ForExpr) (string, error) { 211 | var builder strings.Builder 212 | builder.WriteString("%{for ") 213 | if len(expr.KeyVar) > 0 { 214 | builder.WriteString(expr.KeyVar) 215 | builder.WriteString(", ") 216 | } 217 | builder.WriteString(expr.ValVar) 218 | builder.WriteString(" in ") 219 | builder.WriteString(c.rangeSource(expr.CollExpr.Range())) 220 | builder.WriteString("}") 221 | templ, err := c.convertStringPart(expr.ValExpr) 222 | if err != nil { 223 | return "", err 224 | } 225 | builder.WriteString(templ) 226 | builder.WriteString("%{endfor}") 227 | 228 | return builder.String(), nil 229 | } 230 | 231 | func (c *converter) wrapExpr(expr hclsyntax.Expression) string { 232 | return c.rangeSource(expr.Range()) 233 | } 234 | 235 | func (c *converter) wrapTraversal(expr hclsyntax.Expression) string { 236 | res := c.wrapExpr(expr) 237 | if c.keepInterp { 238 | res = "${" + res + "}" 239 | } 240 | return res 241 | } 242 | 243 | func (c *converter) convertUnary(v *hclsyntax.UnaryOpExpr) (interface{}, error) { 244 | _, isLiteral := v.Val.(*hclsyntax.LiteralValueExpr) 245 | if !isLiteral { 246 | return c.wrapExpr(v), nil 247 | } 248 | val, err := v.Value(nil) 249 | if err != nil { 250 | return nil, err 251 | } 252 | return ctyjson.SimpleJSONValue{Value: val}, nil 253 | } 254 | 255 | func stripComments(text string) string { 256 | var result strings.Builder 257 | // Track if we're inside a block comment 258 | inBlockComment := false 259 | 260 | for _, line := range strings.Split(text, "\n") { 261 | // Track if we're inside a string literal 262 | inString := false 263 | inStringChar := byte(0) 264 | // Track if we're inside an inline comment 265 | inInlineComment := false 266 | var lineBuilder strings.Builder 267 | 268 | for i := 0; i < len(line); i++ { 269 | char := line[i] 270 | 271 | // Handle string literals 272 | if char == '"' || char == '\'' { 273 | if !inString { 274 | inString = true 275 | inStringChar = char 276 | } else if inStringChar == char { 277 | inString = false 278 | } 279 | } 280 | 281 | // Only process comments if we're not inside a string literal 282 | if !inString { 283 | // Check for inline comment start 284 | if !inInlineComment && !inBlockComment { 285 | if char == '#' { 286 | inInlineComment = true 287 | } else if char == '/' && i+1 < len(line) && line[i+1] == '/' { 288 | inInlineComment = true 289 | i++ // Skip the second '/' 290 | } 291 | } 292 | 293 | // Check for block comment start/end if not in inline comment 294 | if !inInlineComment { 295 | if !inBlockComment && char == '/' && i+1 < len(line) && line[i+1] == '*' { 296 | // Found the start of a block comment 297 | inBlockComment = true 298 | i++ // Skip the '*' 299 | continue 300 | } 301 | 302 | if inBlockComment && char == '*' && i+1 < len(line) && line[i+1] == '/' { 303 | // Found the end of a block comment 304 | inBlockComment = false 305 | i++ // Skip the '/' 306 | continue 307 | } 308 | } 309 | } 310 | 311 | // Only write characters that are not in any type of comment 312 | if !inBlockComment && !inInlineComment { 313 | lineBuilder.WriteByte(char) 314 | } 315 | } 316 | 317 | // Add the processed line to the result 318 | result.WriteString(lineBuilder.String()) 319 | result.WriteByte('\n') 320 | } 321 | 322 | return result.String() 323 | } 324 | -------------------------------------------------------------------------------- /pre-build-command.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -e 4 | 5 | OS_NAME=$(uname) 6 | case $OS_NAME in 7 | Darwin*) OS="darwin" ;; 8 | Linux*) OS="linux" ;; 9 | *) echo "Unexpected OS: $OS_NAME" 10 | exit 1 11 | ;; 12 | esac 13 | 14 | # It's defined only on macos runner and ends with an architecture 15 | if [ -z "$ARCHFLAGS" ]; then 16 | ARCH=$(uname -m) 17 | else 18 | ARCH=$ARCHFLAGS 19 | fi 20 | 21 | case $ARCH in 22 | *amd64) ARCH="amd64" ;; 23 | *x86_64) ARCH="amd64" ;; 24 | *arm64) ARCH="arm64" ;; 25 | *aarch64) ARCH="arm64" ;; 26 | esac 27 | curl "https://storage.googleapis.com/golang/go1.23.9.${OS}-${ARCH}.tar.gz" --silent --location | tar -xz 28 | export PATH="$(pwd)/go/bin:$PATH" 29 | 30 | echo "OS: $(uname -a)" 31 | echo "GO=go1.23.9.${OS}-${ARCH}.tar.gz" 32 | echo "ARCH=$ARCH" 33 | echo "PATH=$PATH" 34 | -------------------------------------------------------------------------------- /pygohcl.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // typedef struct { 4 | // char *json; 5 | // char *err; 6 | // } parseResponse; 7 | import "C" 8 | import ( 9 | "encoding/json" 10 | "fmt" 11 | "strings" 12 | 13 | "github.com/hashicorp/hcl/v2" 14 | "github.com/hashicorp/hcl/v2/ext/tryfunc" 15 | "github.com/hashicorp/hcl/v2/hclparse" 16 | "github.com/hashicorp/hcl/v2/hclsyntax" 17 | "github.com/zclconf/go-cty/cty" 18 | "github.com/zclconf/go-cty/cty/convert" 19 | "github.com/zclconf/go-cty/cty/function" 20 | "github.com/zclconf/go-cty/cty/function/stdlib" 21 | ) 22 | 23 | //export Parse 24 | func Parse(a *C.char, keepInterpFlag C.int) (resp C.parseResponse) { 25 | defer func() { 26 | if err := recover(); err != nil { 27 | retValue := fmt.Sprintf("panic HCL: %v", err) 28 | resp = C.parseResponse{nil, C.CString(retValue)} 29 | } 30 | }() 31 | 32 | input := C.GoString(a) 33 | keepInterp := keepInterpFlag == 1 34 | hclFile, diags := hclparse.NewParser().ParseHCL([]byte(input), "tmp.hcl") 35 | if diags.HasErrors() { 36 | return C.parseResponse{nil, C.CString(diagErrorsToString(diags, "invalid HCL: %s"))} 37 | } 38 | hclMap, err := convertFile(hclFile, keepInterp) 39 | if err != nil { 40 | return C.parseResponse{nil, C.CString(fmt.Sprintf("cannot convert HCL to Go map representation: %s", err))} 41 | } 42 | hclInJson, err := json.Marshal(hclMap) 43 | if err != nil { 44 | return C.parseResponse{nil, C.CString(fmt.Sprintf("cannot convert Go map representation to JSON: %s", err))} 45 | } 46 | resp = C.parseResponse{C.CString(string(hclInJson)), nil} 47 | 48 | return 49 | } 50 | 51 | //export ParseAttributes 52 | func ParseAttributes(a *C.char) (resp C.parseResponse) { 53 | defer func() { 54 | if err := recover(); err != nil { 55 | retValue := fmt.Sprintf("panic HCL: %v", err) 56 | resp = C.parseResponse{nil, C.CString(retValue)} 57 | } 58 | }() 59 | 60 | input := C.GoString(a) 61 | hclFile, parseDiags := hclsyntax.ParseConfig([]byte(input), "tmp.hcl", hcl.InitialPos) 62 | if parseDiags.HasErrors() { 63 | return C.parseResponse{nil, C.CString(diagErrorsToString(parseDiags, "invalid HCL: %s"))} 64 | } 65 | 66 | var diags hcl.Diagnostics 67 | hclMap := make(jsonObj) 68 | c := converter{[]byte(input), false} 69 | 70 | attrs, attrsDiags := hclFile.Body.JustAttributes() 71 | diags = diags.Extend(attrsDiags) 72 | 73 | for _, attr := range attrs { 74 | _, valueDiags := attr.Expr.Value(nil) 75 | diags = diags.Extend(valueDiags) 76 | if valueDiags.HasErrors() { 77 | continue 78 | } 79 | 80 | value, err := c.convertExpression(attr.Expr.(hclsyntax.Expression)) 81 | if err != nil { 82 | diags.Append(&hcl.Diagnostic{ 83 | Severity: hcl.DiagError, 84 | Summary: "Error processing variable value", 85 | Detail: fmt.Sprintf("Cannot convert HCL to Go map representation: %s.", err), 86 | Subject: attr.NameRange.Ptr(), 87 | }) 88 | continue 89 | } 90 | 91 | hclMap[attr.Name] = value 92 | } 93 | 94 | hclInJson, err := json.Marshal(hclMap) 95 | if err != nil { 96 | diags.Append(&hcl.Diagnostic{ 97 | Severity: hcl.DiagError, 98 | Summary: "Error preparing JSON result", 99 | Detail: fmt.Sprintf("Cannot convert Go map representation to JSON: %s.", err), 100 | }) 101 | return C.parseResponse{nil, C.CString(diagErrorsToString(diags, ""))} 102 | } 103 | if diags.HasErrors() { 104 | resp = C.parseResponse{C.CString(string(hclInJson)), C.CString(diagErrorsToString(diags, ""))} 105 | } else { 106 | resp = C.parseResponse{C.CString(string(hclInJson)), nil} 107 | } 108 | 109 | return 110 | } 111 | 112 | //export EvalValidationRule 113 | func EvalValidationRule(c *C.char, e *C.char, n *C.char, v *C.char) (resp *C.char) { 114 | defer func() { 115 | if err := recover(); err != nil { 116 | retValue := fmt.Sprintf("panic HCL: %v", err) 117 | resp = C.CString(retValue) 118 | } 119 | }() 120 | 121 | condition := C.GoString(c) 122 | errorMsg := C.GoString(e) 123 | varName := C.GoString(n) 124 | varValue := C.GoString(v) 125 | 126 | // First evaluate variable value to get its cty representation 127 | 128 | varValueCty, diags := expressionValue(varValue, nil) 129 | if diags.HasErrors() { 130 | if containsError(diags, "Variables not allowed") { 131 | // Try again to handle the case when a string value was provided without enclosing quotes 132 | varValueCty, diags = expressionValue(fmt.Sprintf("%q", varValue), nil) 133 | } 134 | } 135 | if diags.HasErrors() { 136 | return C.CString(diagErrorsToString(diags, "cannot process variable value: %s")) 137 | } 138 | 139 | // Now evaluate the condition 140 | 141 | hclCtx := &hcl.EvalContext{ 142 | Variables: map[string]cty.Value{ 143 | "var": cty.ObjectVal(map[string]cty.Value{ 144 | varName: varValueCty, 145 | }), 146 | }, 147 | Functions: knownFunctions, 148 | } 149 | conditionCty, diags := expressionValue(condition, hclCtx) 150 | if diags.HasErrors() { 151 | return C.CString(diagErrorsToString(diags, "cannot process condition expression: %s")) 152 | } 153 | 154 | if conditionCty.IsNull() { 155 | return C.CString("condition expression result is null") 156 | } 157 | 158 | conditionCty, err := convert.Convert(conditionCty, cty.Bool) 159 | if err != nil { 160 | return C.CString("condition expression result must be bool") 161 | } 162 | 163 | if conditionCty.True() { 164 | return nil 165 | } 166 | 167 | // Finally evaluate the error message expression 168 | 169 | var errorMsgValue = "cannot process error message expression" 170 | errorMsgCty, diags := expressionValue(errorMsg, hclCtx) 171 | if diags.HasErrors() { 172 | errorMsgCty, diags = expressionValue(fmt.Sprintf("%q", errorMsg), hclCtx) 173 | } 174 | if !diags.HasErrors() && !errorMsgCty.IsNull() { 175 | errorMsgCty, err = convert.Convert(errorMsgCty, cty.String) 176 | if err == nil { 177 | errorMsgValue = errorMsgCty.AsString() 178 | } 179 | } 180 | return C.CString(errorMsgValue) 181 | } 182 | 183 | func diagErrorsToString(diags hcl.Diagnostics, format string) string { 184 | diagErrs := diags.Errs() 185 | errors := make([]string, 0, len(diagErrs)) 186 | for _, err := range diagErrs { 187 | errors = append(errors, err.Error()) 188 | } 189 | if format == "" { 190 | return strings.Join(errors, ", ") 191 | } 192 | return fmt.Sprintf(format, strings.Join(errors, ", ")) 193 | } 194 | 195 | func containsError(diags hcl.Diagnostics, e string) bool { 196 | for _, err := range diags.Errs() { 197 | if strings.Contains(err.Error(), e) { 198 | return true 199 | } 200 | } 201 | return false 202 | } 203 | 204 | func expressionValue(in string, ctx *hcl.EvalContext) (cty.Value, hcl.Diagnostics) { 205 | var diags hcl.Diagnostics 206 | 207 | expr, diags := hclsyntax.ParseExpression([]byte(in), "tmp.hcl", hcl.InitialPos) 208 | if diags.HasErrors() { 209 | return cty.NilVal, diags 210 | } 211 | 212 | val, diags := expr.Value(ctx) 213 | if diags.HasErrors() { 214 | return cty.NilVal, diags 215 | } 216 | 217 | return val, diags 218 | } 219 | 220 | var knownFunctions = map[string]function.Function{ 221 | "abs": stdlib.AbsoluteFunc, 222 | "can": tryfunc.CanFunc, 223 | "ceil": stdlib.CeilFunc, 224 | "chomp": stdlib.ChompFunc, 225 | "coalescelist": stdlib.CoalesceListFunc, 226 | "compact": stdlib.CompactFunc, 227 | "concat": stdlib.ConcatFunc, 228 | "contains": stdlib.ContainsFunc, 229 | "csvdecode": stdlib.CSVDecodeFunc, 230 | "distinct": stdlib.DistinctFunc, 231 | "element": stdlib.ElementFunc, 232 | "chunklist": stdlib.ChunklistFunc, 233 | "flatten": stdlib.FlattenFunc, 234 | "floor": stdlib.FloorFunc, 235 | "format": stdlib.FormatFunc, 236 | "formatdate": stdlib.FormatDateFunc, 237 | "formatlist": stdlib.FormatListFunc, 238 | "indent": stdlib.IndentFunc, 239 | "join": stdlib.JoinFunc, 240 | "jsondecode": stdlib.JSONDecodeFunc, 241 | "jsonencode": stdlib.JSONEncodeFunc, 242 | "keys": stdlib.KeysFunc, 243 | "log": stdlib.LogFunc, 244 | "lower": stdlib.LowerFunc, 245 | "max": stdlib.MaxFunc, 246 | "merge": stdlib.MergeFunc, 247 | "min": stdlib.MinFunc, 248 | "parseint": stdlib.ParseIntFunc, 249 | "pow": stdlib.PowFunc, 250 | "range": stdlib.RangeFunc, 251 | "regex": stdlib.RegexFunc, 252 | "regexall": stdlib.RegexAllFunc, 253 | "reverse": stdlib.ReverseListFunc, 254 | "setintersection": stdlib.SetIntersectionFunc, 255 | "setproduct": stdlib.SetProductFunc, 256 | "setsubtract": stdlib.SetSubtractFunc, 257 | "setunion": stdlib.SetUnionFunc, 258 | "signum": stdlib.SignumFunc, 259 | "slice": stdlib.SliceFunc, 260 | "sort": stdlib.SortFunc, 261 | "split": stdlib.SplitFunc, 262 | "strrev": stdlib.ReverseFunc, 263 | "substr": stdlib.SubstrFunc, 264 | "timeadd": stdlib.TimeAddFunc, 265 | "title": stdlib.TitleFunc, 266 | "trim": stdlib.TrimFunc, 267 | "trimprefix": stdlib.TrimPrefixFunc, 268 | "trimspace": stdlib.TrimSpaceFunc, 269 | "trimsuffix": stdlib.TrimSuffixFunc, 270 | "try": tryfunc.TryFunc, 271 | "upper": stdlib.UpperFunc, 272 | "values": stdlib.ValuesFunc, 273 | "zipmap": stdlib.ZipmapFunc, 274 | } 275 | 276 | func main() {} 277 | -------------------------------------------------------------------------------- /pygohcl/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sysconfig 3 | import typing as tp 4 | from pathlib import Path 5 | 6 | from pygohcl._pygohcl import ffi 7 | 8 | 9 | def load_lib(): 10 | suffix = sysconfig.get_config_var("EXT_SUFFIX") 11 | 12 | libpath = Path(__file__).parent.parent / f"pygohcl{suffix}" 13 | return ffi.dlopen(str(libpath)) 14 | 15 | 16 | lib = load_lib() 17 | 18 | 19 | class HCLParseError(Exception): 20 | pass 21 | 22 | 23 | class HCLInternalError(Exception): 24 | pass 25 | 26 | 27 | class ValidationError(Exception): 28 | pass 29 | 30 | 31 | class UnknownFunctionError(ValidationError): 32 | pass 33 | 34 | 35 | def loadb(data: bytes, keep_interpolations: bool = False) -> tp.Dict: 36 | """ 37 | Parse and load HCL input into Python dictionary. 38 | :param data: HCL to parse. 39 | :param keep_interpolations: Set to True 40 | to preserve template interpolation sequences (${ ... }) in strings. Defaults to False. 41 | """ 42 | s = ffi.new("char[]", data) 43 | ret = lib.Parse(s, int(keep_interpolations)) 44 | if ret.err != ffi.NULL: 45 | err: bytes = ffi.string(ret.err) 46 | ffi.gc(ret.err, lib.free) 47 | err = err.decode("utf8") 48 | if "invalid HCL:" in err: 49 | raise HCLParseError(err) 50 | raise HCLInternalError(err) 51 | ret_json = ffi.string(ret.json) 52 | ffi.gc(ret.json, lib.free) 53 | return json.loads(ret_json) 54 | 55 | 56 | def loads(data: str, keep_interpolations: bool = False) -> tp.Dict: 57 | return loadb(data.encode("utf8"), keep_interpolations) 58 | 59 | 60 | def load(stream: tp.IO, keep_interpolations: bool = False) -> tp.Dict: 61 | data = stream.read() 62 | return loadb(data, keep_interpolations) 63 | 64 | 65 | def attributes_loadb(data: bytes) -> tp.Dict: 66 | """ 67 | Like :func:`pygohcl.loadb`, 68 | but expects from the input to contain only top-level attributes. 69 | 70 | Example: 71 | >>> hcl = ''' 72 | ... key1 = "value" 73 | ... key2 = false 74 | ... key3 = [1, 2, 3] 75 | ... ''' 76 | >>> import pygohcl 77 | >>> print(pygohcl.attributes_loads(hcl)) 78 | {'key1': 'value', 'key2': False, 'key3': [1, 2, 3]} 79 | 80 | :raises HCLParseError: when the provided input cannot be parsed as valid HCL, 81 | or it contains other blocks, not only attributes. 82 | """ 83 | s = ffi.new("char[]", data) 84 | ret = lib.ParseAttributes(s) 85 | if ret.err != ffi.NULL: 86 | err: bytes = ffi.string(ret.err) 87 | ffi.gc(ret.err, lib.free) 88 | err = err.decode("utf8") 89 | raise HCLParseError(err) 90 | ret_json = ffi.string(ret.json) 91 | ffi.gc(ret.json, lib.free) 92 | return json.loads(ret_json) 93 | 94 | 95 | def attributes_loads(data: str) -> tp.Dict: 96 | return attributes_loadb(data.encode("utf8")) 97 | 98 | 99 | def attributes_load(stream: tp.IO) -> tp.Dict: 100 | data = stream.read() 101 | return attributes_loadb(data) 102 | 103 | 104 | def eval_var_condition( 105 | condition: str, error_message: str, variable_name: str, variable_value: str 106 | ) -> None: 107 | """ 108 | This is specific to Terraform/OpenTofu configuration language 109 | and is meant to evaluate results of the `validation` block of a variable definition. 110 | 111 | This comes with a limited selection of supported functions. 112 | Terraform/OpenTofu expand this list with their own set 113 | of useful functions, which will not pass this validation. 114 | For that reason a separate `UnknownFunctionError` is raised then, 115 | so the consumer can decide how to treat this case. 116 | 117 | Example: 118 | >>> import pygohcl 119 | >>> pygohcl.eval_var_condition( 120 | ... condition="var.count < 3", 121 | ... error_message="count must be less than 3, but ${var.count} was given", 122 | ... variable_name="count", 123 | ... variable_value="5", 124 | ... ) 125 | Traceback (most recent call last): 126 | ... 127 | pygohcl.ValidationError: count must be less than 3, but 5 was given 128 | 129 | :raises ValidationError: when the condition expression has not evaluated to `True` 130 | :raises UnknownFunctionError: when the condition expression refers to a function 131 | that is not known to the library 132 | """ 133 | c = ffi.new("char[]", condition.encode("utf8")) 134 | e = ffi.new("char[]", error_message.encode("utf8")) 135 | n = ffi.new("char[]", variable_name.encode("utf8")) 136 | v = ffi.new("char[]", variable_value.encode("utf8")) 137 | ret = lib.EvalValidationRule(c, e, n, v) 138 | if ret != ffi.NULL: 139 | err: bytes = ffi.string(ret) 140 | ffi.gc(ret, lib.free) 141 | err = err.decode("utf8") 142 | if "Call to unknown function" in err: 143 | raise UnknownFunctionError(err) 144 | raise ValidationError(err) 145 | -------------------------------------------------------------------------------- /pygohcl/build_cffi.py: -------------------------------------------------------------------------------- 1 | from cffi import FFI 2 | 3 | ffi = FFI() 4 | 5 | ffi.set_source( 6 | "pygohcl._pygohcl", 7 | None, 8 | include_dirs=[], 9 | extra_compile_args=["-march=native"], 10 | libraries=[], 11 | ) 12 | 13 | ffi.cdef( 14 | """ 15 | typedef struct { 16 | char *json; 17 | char *err; 18 | } parseResponse; 19 | 20 | parseResponse Parse(char* a, int keepInterpFlag); 21 | parseResponse ParseAttributes(char* a); 22 | char* EvalValidationRule(char* c, char* e, char* n, char* v); 23 | void free(void *ptr); 24 | """ 25 | ) 26 | ffi.compile() 27 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pytest.ini_options] 2 | addopts = "--color=yes" 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | from setuptools import setup, find_packages 7 | from setuptools import Extension 8 | 9 | 10 | os.chdir(os.path.dirname(sys.argv[0]) or ".") 11 | 12 | setup( 13 | name="pygohcl", 14 | use_scm_version=True, 15 | description="Python bindings for Hashicorp HCL2 Go library", 16 | long_description=open("README.md", "rt").read(), 17 | long_description_content_type="text/markdown", 18 | url="https://github.com/Scalr/pygohcl", 19 | author="Lee Archer", 20 | author_email="l.archer@scalr.com", 21 | classifiers=[ 22 | "Development Status :: 5 - Production/Stable", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Programming Language :: Python :: 3.13", 29 | "License :: OSI Approved :: MIT License", 30 | ], 31 | packages=find_packages(), 32 | install_requires=["cffi>=1.17.0"], 33 | setup_requires=["cffi>=1.17.0", "setuptools-golang", "setuptools_scm"], 34 | build_golang={"root": "github.com/Scalr/pygohcl"}, 35 | ext_modules=[Extension("pygohcl", ["pygohcl.go"])], 36 | cffi_modules=["pygohcl/build_cffi.py:ffi",], 37 | ) 38 | -------------------------------------------------------------------------------- /tests/test_attributes.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import pygohcl 3 | 4 | 5 | def test_basic(): 6 | s = """ 7 | var1 = "value" 8 | var2 = 2 9 | var3 = true 10 | """ 11 | assert pygohcl.attributes_loads(s) == {"var1": "value", "var2": 2, "var3": True} 12 | 13 | 14 | def test_list(): 15 | s = """ 16 | var1 = ["value1", "value2", "value3"] 17 | var2 = [1, 2, 3] 18 | var3 = [true, false] 19 | """ 20 | assert pygohcl.attributes_loads(s) == { 21 | "var1": ["value1", "value2", "value3"], 22 | "var2": [1, 2, 3], 23 | "var3": [True, False], 24 | } 25 | 26 | 27 | def test_empty_list(): 28 | s = """ 29 | var = [] 30 | """ 31 | assert pygohcl.attributes_loads(s) == {"var": []} 32 | 33 | 34 | def test_non_hcl(): 35 | s = """ 36 | 37 | """ 38 | with pytest.raises(pygohcl.HCLParseError) as err: 39 | pygohcl.attributes_loads(s) 40 | assert "invalid HCL" in str(err.value) 41 | 42 | 43 | def test_non_attributes(): 44 | """ 45 | When the content is mixed with not expected but valid HCL. 46 | """ 47 | s = """ 48 | var = "value" 49 | variable "test" {} 50 | """ 51 | with pytest.raises(pygohcl.HCLParseError) as err: 52 | pygohcl.attributes_loads(s) 53 | assert "Blocks are not allowed" in str(err.value) 54 | 55 | 56 | def test_variable_in_value(): 57 | s = """ 58 | var1 = "value" 59 | var2 = value 60 | """ 61 | with pytest.raises(pygohcl.HCLParseError) as err: 62 | pygohcl.attributes_loads(s) 63 | assert "Variables not allowed" in str(err.value) 64 | 65 | 66 | def test_multiple_errors(): 67 | """ 68 | Make sure the processing doesn't stop at first error and all found issues are reported. 69 | """ 70 | s = """ 71 | var = value 72 | variable "test" {} 73 | """ 74 | with pytest.raises(pygohcl.HCLParseError) as err: 75 | pygohcl.attributes_loads(s) 76 | assert "Variables not allowed" in str(err.value) 77 | assert "Blocks are not allowed" in str(err.value) 78 | 79 | 80 | def test_heredoc(): 81 | s = """ 82 | var = <