diff --git a/src/specify_cli/workflows/overlays/_commands.py b/src/specify_cli/workflows/overlays/_commands.py index 549f1ea151..06c4dca835 100644 --- a/src/specify_cli/workflows/overlays/_commands.py +++ b/src/specify_cli/workflows/overlays/_commands.py @@ -214,7 +214,15 @@ def workflow_overlay_add( existed_before = target_path.exists() staged = _stage_workflow_file(target_path.parent) try: - staged.write_bytes(yaml.safe_dump(data, sort_keys=False).encode("utf-8")) + # ``allow_unicode=True`` matches every other YAML writer in the + # repo. Without it every non-ASCII character in a hand-authored + # overlay is rewritten as a ``\uXXXX`` escape, so merely toggling + # an overlay makes the user's own file unreadable. + staged.write_bytes( + yaml.safe_dump(data, sort_keys=False, allow_unicode=True).encode( + "utf-8" + ) + ) backup = _commit_workflow_file(staged, target_path, existed_before) except BaseException: _safe_discard_staged_workflow_file( @@ -267,7 +275,15 @@ def _update_overlay_field( existed_before = path.exists() staged = _stage_workflow_file(path.parent) try: - staged.write_bytes(yaml.safe_dump(data, sort_keys=False).encode("utf-8")) + # ``allow_unicode=True`` matches every other YAML writer in the + # repo. Without it every non-ASCII character in a hand-authored + # overlay is rewritten as a ``\uXXXX`` escape, so merely toggling + # an overlay makes the user's own file unreadable. + staged.write_bytes( + yaml.safe_dump(data, sort_keys=False, allow_unicode=True).encode( + "utf-8" + ) + ) backup = _commit_workflow_file(staged, path, existed_before) except BaseException: _safe_discard_staged_workflow_file(staged, path.parent, existed_before) diff --git a/tests/workflows/test_overlay_commands.py b/tests/workflows/test_overlay_commands.py index 8a344cacdf..c28f53b050 100644 --- a/tests/workflows/test_overlay_commands.py +++ b/tests/workflows/test_overlay_commands.py @@ -220,6 +220,115 @@ def test_overlay_add_rejects_non_positive_priority(self, project_dir, monkeypatc assert result.exit_code == 1 assert "must be >= 1" in result.output + def test_overlay_add_keeps_non_ascii_text_readable( + self, project_dir, monkeypatch + ): + """``overlay add`` must not escape non-ASCII text in the written file. + + Overlay files are documented as hand-authored, so writing them back + with ``\\uXXXX`` escapes makes the user's own file unreadable. + """ + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + message = "Revisar el plan — ¿aprobar? 日本語" + overlay_file = project_dir / "overlay.yml" + overlay_file.write_text( + yaml.safe_dump( + { + "id": "ov1", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "replace", + "anchor": "a", + "step": { + "id": "a", + "type": "gate", + "message": message, + "options": ["approve"], + }, + } + ], + }, + allow_unicode=True, + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["workflow", "overlay", "add", str(overlay_file)]) + assert result.exit_code == 0, result.output + + installed = ( + project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml" + ) + text = installed.read_text(encoding="utf-8") + assert message in text, text + assert "\\u" not in text and "\\x" not in text, text + # The value must still round-trip identically. + data = yaml.safe_load(text) + assert data["edits"][0]["step"]["message"] == message + + def test_overlay_set_priority_keeps_non_ascii_text_readable( + self, project_dir, monkeypatch + ): + """Toggling an overlay must not mangle non-ASCII text already in it.""" + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + _write_workflow( + project_dir, + "wf", + { + "schema_version": "1.0", + "workflow": {"id": "wf", "name": "WF", "version": "1.0.0"}, + "steps": [{"id": "a", "type": "command", "command": "echo"}], + }, + ) + message = "Revisar el plan — ¿aprobar? 日本語" + _write_overlay( + project_dir, + "wf", + "ov1", + { + "id": "ov1", + "extends": "wf", + "priority": 10, + "edits": [ + { + "operation": "replace", + "anchor": "a", + "step": { + "id": "a", + "type": "gate", + "message": message, + "options": ["approve"], + }, + } + ], + }, + ) + + result = runner.invoke( + app, ["workflow", "overlay", "set-priority", "wf", "ov1", "20"] + ) + assert result.exit_code == 0, result.output + + text = ( + project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml" + ).read_text(encoding="utf-8") + assert message in text, text + assert "\\u" not in text and "\\x" not in text, text + data = yaml.safe_load(text) + assert data["priority"] == 20 + assert data["edits"][0]["step"]["message"] == message + def test_overlay_set_priority(self, project_dir, monkeypatch): monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) _write_workflow(