From 0aa93cbe2478d95134bd9201f8dafaa23a6dffcf Mon Sep 17 00:00:00 2001 From: Eason09053360 <185830721+Eason09053360@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:27:53 +0800 Subject: [PATCH 1/2] 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. --- .../airflow/cli/commands/variable_command.py | 7 +++- .../cli/commands/test_variable_command.py | 34 +++++++++++++++++++ 2 files changed, 40 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 194b02b529a97..f9fbf360dd134 100644 --- a/airflow-core/src/airflow/cli/commands/variable_command.py +++ b/airflow-core/src/airflow/cli/commands/variable_command.py @@ -186,11 +186,16 @@ def variables_export(args): data = json.JSONDecoder() for var in qry: + # Emit a form variables_import turns back into var.val: it stores strings verbatim, + # JSON-encodes everything else, and unwraps any dict carrying a "value" key. try: val = data.decode(var.val) except Exception: val = var.val - if var.description: + else: + 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 a02c95aa31722..ac8eb11a3aeaa 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 @@ def test_variables_export(self): """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" From bf34d1f3349b0614780455913a801f60537b72ad Mon Sep 17 00:00:00 2001 From: Eason09053360 <185830721+Eason09053360@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:15:33 +0800 Subject: [PATCH 2/2] apply suggestion --- airflow-core/src/airflow/cli/commands/variable_command.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/airflow-core/src/airflow/cli/commands/variable_command.py b/airflow-core/src/airflow/cli/commands/variable_command.py index f9fbf360dd134..8cdf5490d3a2f 100644 --- a/airflow-core/src/airflow/cli/commands/variable_command.py +++ b/airflow-core/src/airflow/cli/commands/variable_command.py @@ -186,15 +186,13 @@ def variables_export(args): data = json.JSONDecoder() for var in qry: - # Emit a form variables_import turns back into var.val: it stores strings verbatim, - # JSON-encodes everything else, and unwraps any dict carrying a "value" key. + # Mirror variables_import's reconstruction so export/import round-trips. try: val = data.decode(var.val) except Exception: val = var.val - else: - if isinstance(val, str): - val = var.val + if isinstance(val, str): + val = var.val if var.description or (isinstance(val, dict) and "value" in val): var_dict[var.key] = { "value": val,