This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new f549b34c682 Stop variables export/import from silently corrupting
values (#70944)
f549b34c682 is described below
commit f549b34c682bfead7b611b1b20c5ae734714ed3d
Author: Y-C <[email protected]>
AuthorDate: Wed Aug 19 02:43:39 2026 +0800
Stop variables export/import from silently corrupting values (#70944)
* Stop variables export/import from silently corrupting values
`airflow variables export` writes a bare value for any variable without a
description, while `airflow variables import` treats every dict carrying a
"value" key as a {"value": ..., "description": ...} envelope. A variable
whose
own value happens to have that shape is therefore unwrapped on the way back
in:
its value is replaced by the inner "value" and a description is invented
from
the inner "description". Nothing warns the operator, so a round-trip
through a
file - the documented way to migrate variables between environments -
quietly
rewrites their data.
Export also decodes the stored JSON before writing it out, which erases the
difference between a variable holding raw text and one holding a
JSON-encoded
string. Re-importing flattens the latter, and any Dag reading it with
deserialize_json=True starts failing on a value that is no longer valid
JSON.
Import's reconstruction is deterministic - strings are stored verbatim,
everything else is JSON-encoded, and one envelope layer is unwrapped - so
export
alone can be made lossless. Fixing it there leaves import untouched and
keeps
hand-written import files working exactly as before.
* apply suggestion
---------
Co-authored-by: Eason09053360
<[email protected]>
---
.../src/airflow/cli/commands/variable_command.py | 5 +++-
.../unit/cli/commands/test_variable_command.py | 34 ++++++++++++++++++++++
2 files changed, 38 insertions(+), 1 deletion(-)
diff --git a/airflow-core/src/airflow/cli/commands/variable_command.py
b/airflow-core/src/airflow/cli/commands/variable_command.py
index 194b02b529a..8cdf5490d3a 100644
--- a/airflow-core/src/airflow/cli/commands/variable_command.py
+++ b/airflow-core/src/airflow/cli/commands/variable_command.py
@@ -186,11 +186,14 @@ def variables_export(args):
data = json.JSONDecoder()
for var in qry:
+ # Mirror variables_import's reconstruction so export/import
round-trips.
try:
val = data.decode(var.val)
except Exception:
val = var.val
- if var.description:
+ if isinstance(val, str):
+ val = var.val
+ if var.description or (isinstance(val, dict) and "value" in val):
var_dict[var.key] = {
"value": val,
"description": var.description,
diff --git a/airflow-core/tests/unit/cli/commands/test_variable_command.py
b/airflow-core/tests/unit/cli/commands/test_variable_command.py
index a02c95aa317..ac8eb11a3ae 100644
--- a/airflow-core/tests/unit/cli/commands/test_variable_command.py
+++ b/airflow-core/tests/unit/cli/commands/test_variable_command.py
@@ -360,6 +360,40 @@ class TestCliVariables:
"""Test variables_export command"""
variable_command.variables_export(self.parser.parse_args(["variables",
"export", os.devnull]))
+ @pytest.mark.parametrize(
+ ("stored_value", "expected_export"),
+ [
+ pytest.param(
+ '{"value": "a", "description": "b"}',
+ {"value": {"value": "a", "description": "b"}, "description":
None},
+ id="envelope_lookalike",
+ ),
+ pytest.param(
+ '{"value": 1, "other": 2}',
+ {"value": {"value": 1, "other": 2}, "description": None},
+ id="envelope_lookalike_with_extra_keys",
+ ),
+ pytest.param('"hello"', '"hello"', id="json_string"),
+ ],
+ )
+ def test_variables_export_survives_reimport(self, tmp_path, stored_value,
expected_export):
+ """Values that collide with the export format must round-trip through
export/import."""
+ path = tmp_path / "variables.json"
+ variable_command.variables_set(self.parser.parse_args(["variables",
"set", "k", stored_value]))
+ variable_command.variables_export(self.parser.parse_args(["variables",
"export", os.fspath(path)]))
+
+ assert json.loads(path.read_text()) == {"k": expected_export}
+
+ variable_command.variables_delete(self.parser.parse_args(["variables",
"delete", "k"]))
+ with create_session() as session:
+ variable_command.variables_import(
+ self.parser.parse_args(["variables", "import",
os.fspath(path)]), session=session
+ )
+
+ assert Variable.get("k", deserialize_json=True) ==
json.loads(stored_value)
+ with create_session() as session:
+ assert
session.scalar(select(Variable.description).where(Variable.key == "k")) is None
+
def test_variables_isolation(self, tmp_path):
"""Test isolation of variables"""
path1 = tmp_path / "testfile1.json"