Skip to content

Commit 9a9e27f

Browse files
committed
feat(locations): interpolate environment variables in YAML input (#158)
Expand ${VAR} / ${VAR:-default} / ${VAR:?msg} in requirements.yml, svcs.yml, mvrs.yml, annotations.yml and reqstool_config.yml before parsing, using POSIX shell parameter expansion (the envsubst standard) via the expandvars library. Only the braced form is interpolated; the bare $VAR form is left untouched so that the '# yaml-language-server: $schema=...' directive, regexes and other literal $ characters survive unchanged. An unset variable without an inline default is a hard error, keeping ingestion deterministic and CI-safe. This is the principled, deterministic alternative to 'version: latest' (#138): pin versions to env vars and let Renovate keep them current. Round-trip (typ=rt) loads backing LSP features are intentionally not interpolated, so editor positions are unaffected. Signed-off-by: Jimisola Laursen <jimisola@jimisola.com>
1 parent df73b0d commit 9a9e27f

13 files changed

Lines changed: 225 additions & 6 deletions

File tree

docs/modules/ROOT/pages/usage.adoc

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,44 @@ Each command accepts a location argument that specifies where the reqstool data
2222
reqstool report git -u URL_TO_REPOSITORY.git -t ACCESS_TOKEN -p PATH_TO_DIR -r REF
2323
----
2424

25+
[[env-var-interpolation]]
26+
== Environment variable interpolation
27+
28+
Reqstool expands environment variables in YAML input (`requirements.yml`, `svcs.yml`,
29+
`mvrs.yml`, `annotations.yml`, `reqstool_config.yml`) before parsing, using POSIX shell
30+
parameter expansion (the `envsubst` standard). Only the *braced* form is interpolated:
31+
32+
[cols="1,3"]
33+
|===
34+
| Syntax | Behaviour
35+
36+
| `${VAR}` | Substitute the value of `VAR`.
37+
| `${VAR:-default}` | Use `default` when `VAR` is unset or empty.
38+
| `${VAR:?message}` | Fail with `message` when `VAR` is unset or empty.
39+
| `${VAR:+alt}` | Use `alt` when `VAR` is set.
40+
|===
41+
42+
A bare `${VAR}` whose variable is unset and has no inline default is a *hard error* -- this
43+
keeps ingestion deterministic and surfaces misconfiguration in CI rather than silently
44+
producing empty values.
45+
46+
The bare `$VAR` form (without braces) is intentionally *not* expanded, so the
47+
`# yaml-language-server: $schema=...` directive, regular expressions and other literal `$`
48+
characters are left untouched.
49+
50+
This is the recommended way to keep imported artifact versions current -- pin the version to
51+
an environment variable and let a tool such as Renovate update it:
52+
53+
[source,yaml]
54+
----
55+
imports:
56+
maven:
57+
- url: https://repo.maven.org
58+
group_id: com.example
59+
artifact_id: my-lib
60+
version: ${MY_LIB_VERSION}
61+
----
62+
2563
[[status]]
2664
== Command: status
2765

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ dependencies = [
5353
"packaging==26.2",
5454
"requests==2.34.2",
5555
"beautifulsoup4==4.14.3",
56+
"expandvars==1.1.2",
5657
"pygls>=2.0,<3.0",
5758
"lsprotocol>=2024.0.0",
5859
"mcp>=1.0",

src/reqstool/common/exceptions.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,16 @@ def __init__(self, ref: str, url: str):
4646
self.ref = ref
4747
self.url = url
4848
super().__init__(f"ref '{ref}' not found in {url}")
49+
50+
51+
class EnvVarInterpolationError(Exception):
52+
"""Raised when environment variable interpolation of YAML input fails.
53+
54+
This covers references to unset variables that have no inline default, as
55+
well as malformed ``${...}`` expressions. Failing hard keeps ingestion
56+
deterministic and surfaces configuration mistakes in CI.
57+
"""
58+
59+
def __init__(self, message: str, source: str | None = None):
60+
self.source = source
61+
super().__init__(f"{message} (in {source})" if source else message)

src/reqstool/common/utils.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
import os
5+
import re
56
import tarfile
67
import tempfile
78
from importlib.metadata import version
@@ -10,10 +11,12 @@
1011
from typing import Dict, Iterable, List, Sequence
1112
from zipfile import ZipFile
1213

14+
import expandvars
1315
import requests
1416
from packaging.version import InvalidVersion, Version as PkgVersion
1517
from requests_file import FileAdapter
1618

19+
from reqstool.common.exceptions import EnvVarInterpolationError
1720
from reqstool.common.models.urn_id import UrnId
1821
from reqstool.models.raw_datasets import RawDataset
1922
from reqstool.models.requirements import RequirementData
@@ -24,6 +27,45 @@ class Utils:
2427

2528
is_installed_package: bool = True
2629

30+
# Only the braced ``${...}`` form is interpolated. The bare ``$VAR`` form is
31+
# intentionally left untouched: reqstool YAML files routinely contain a
32+
# ``# yaml-language-server: $schema=...`` directive (and regexes, prices,
33+
# etc.) where a stray ``$`` must not be treated as a variable reference.
34+
_ENV_VAR_PATTERN = re.compile(r"\$\{[^{}]*\}")
35+
36+
@staticmethod
37+
def interpolate_env_vars(text: str, source: str | None = None) -> str:
38+
"""Expand environment variables in raw YAML text before parsing.
39+
40+
Uses POSIX shell parameter expansion (the ``envsubst`` standard),
41+
restricted to the braced form::
42+
43+
${VAR} substitute the value of VAR
44+
${VAR:-default} use ``default`` when VAR is unset or empty
45+
${VAR:?message} fail with ``message`` when VAR is unset or empty
46+
${VAR:+alt} use ``alt`` when VAR is set
47+
48+
A bare ``${VAR}`` whose variable is unset (and has no inline default)
49+
is a hard error: this keeps ingestion deterministic and catches
50+
misconfiguration in CI rather than silently producing empty values.
51+
52+
Args:
53+
text: raw file contents to interpolate.
54+
source: optional file path/URI used in error messages.
55+
56+
Raises:
57+
EnvVarInterpolationError: on an unset variable without a default or
58+
a malformed ``${...}`` expression.
59+
"""
60+
61+
def _expand(match: "re.Match[str]") -> str:
62+
try:
63+
return expandvars.expand(match.group(0), nounset=True)
64+
except expandvars.ExpandvarsException as e:
65+
raise EnvVarInterpolationError(str(e), source=source) from e
66+
67+
return Utils._ENV_VAR_PATTERN.sub(_expand, text)
68+
2769
@staticmethod
2870
def get_version() -> str:
2971
ver: str = f"{version('reqstool')}" if Utils.is_installed_package else "local-dev"

src/reqstool/model_generators/annotations_model_generator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def __generate(self, uri: str) -> AnnotationsData:
2424

2525
yaml = YAML(typ="safe")
2626

27-
data: dict = yaml.load(response.text)
27+
data: dict = yaml.load(Utils.interpolate_env_vars(response.text, source=uri))
2828

2929
if not SyntaxValidator.is_valid_data(json_schema_type=JsonSchemaTypes.ANNOTATIONS, data=data, urn=self.urn):
3030
sys.exit(EXIT_CODE_SYNTAX_VALIDATION_ERROR)

src/reqstool/model_generators/mvrs_model_generator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def __generate(self, uri: str) -> MVRsData:
2626

2727
yaml = YAML(typ="safe")
2828

29-
data: dict = yaml.load(response.text)
29+
data: dict = yaml.load(Utils.interpolate_env_vars(response.text, source=uri))
3030

3131
if not SyntaxValidator.is_valid_data(
3232
json_schema_type=JsonSchemaTypes.MANUAL_VERIFICATION_RESULTS, data=data, urn=self.urn

src/reqstool/model_generators/requirements_model_generator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ def __generate(
8787

8888
yaml = YAML(typ="safe")
8989

90-
data = yaml.load(response.text)
90+
data = yaml.load(Utils.interpolate_env_vars(response.text, source=uri))
9191

9292
urn = self.get_urn_if_available(response.text)
9393

src/reqstool/model_generators/svcs_model_generator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def __generate(self, uri: str) -> SVCsData:
3737

3838
yaml = YAML(typ="safe")
3939

40-
data: dict = yaml.load(response.text)
40+
data: dict = yaml.load(Utils.interpolate_env_vars(response.text, source=uri))
4141

4242
if not SyntaxValidator.is_valid_data(
4343
json_schema_type=JsonSchemaTypes.SOFTWARE_VERIFICATION_CASES, data=data, urn=self.urn

src/reqstool/requirements_indata/requirements_indata.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,12 @@ def model_post_init(self, __context):
4141
def _handle_requirements_config(self):
4242

4343
if os.path.exists(os.path.join(self.dst_path, "reqstool_config.yml")):
44-
response = Utils.open_file_https_file(os.path.join(self.dst_path, "reqstool_config.yml"))
44+
config_path = os.path.join(self.dst_path, "reqstool_config.yml")
45+
response = Utils.open_file_https_file(config_path)
4546

4647
yaml = YAML(typ="safe")
4748

48-
data: dict = yaml.load(response.text)
49+
data: dict = yaml.load(Utils.interpolate_env_vars(response.text, source=config_path))
4950

5051
if not SyntaxValidator.is_valid_data(
5152
json_schema_type=JsonSchemaTypes.REQSTOOL_CONFIG, data=data, urn="unknown"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# yaml-language-server: $schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/requirements.schema.json
2+
3+
metadata:
4+
urn: sys-001
5+
variant: system
6+
title: ${REQSTOOL_TEST_TITLE:-Default Title}
7+
8+
imports:
9+
maven:
10+
- url: https://repo.maven.org
11+
group_id: com.example.one
12+
artifact_id: test-one
13+
version: ${REQSTOOL_TEST_MAVEN_VERSION}
14+
15+
requirements:
16+
- id: REQ_001
17+
title: Title REQ_001
18+
significance: may
19+
description: Description REQ_001
20+
rationale: Rationale REQ_001
21+
categories: ["maintainability"]
22+
revision: 0.0.1

0 commit comments

Comments
 (0)