Skip to content

Commit 0053c3a

Browse files
jawwad-aliclaude
andauthored
fix(workflows): keep non-ASCII text readable in written overlay files (#4148)
Both overlay writers in `overlays/_commands.py` called `yaml.safe_dump(data, sort_keys=False)` without `allow_unicode=True`, so every non-ASCII character was rewritten as a `\uXXXX` / `\xNN` escape inside a double-quoted scalar. Every other YAML writer in the repo already passes `allow_unicode=True` (agents.py, bundler/lib/yamlio.py, extensions, integrations/base.py, ...). Overlay files are explicitly hand-authored and hand-edited -- the format is documented in docs/reference/workflows.md and users are told to write these files. `overlay add`, `enable`, `disable` and `set-priority` all round-trip the file through `safe_dump`, so merely toggling an overlay mangled a UTF-8 file the user wrote by hand: message: "Revisar el plan — \xBFaprobar? 日本語" The value still parses back identically, so this is not corruption -- it is the loss of a documented, hand-edited file's legibility. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 15dffed commit 0053c3a

2 files changed

Lines changed: 127 additions & 2 deletions

File tree

src/specify_cli/workflows/overlays/_commands.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,15 @@ def workflow_overlay_add(
214214
existed_before = target_path.exists()
215215
staged = _stage_workflow_file(target_path.parent)
216216
try:
217-
staged.write_bytes(yaml.safe_dump(data, sort_keys=False).encode("utf-8"))
217+
# ``allow_unicode=True`` matches every other YAML writer in the
218+
# repo. Without it every non-ASCII character in a hand-authored
219+
# overlay is rewritten as a ``\uXXXX`` escape, so merely toggling
220+
# an overlay makes the user's own file unreadable.
221+
staged.write_bytes(
222+
yaml.safe_dump(data, sort_keys=False, allow_unicode=True).encode(
223+
"utf-8"
224+
)
225+
)
218226
backup = _commit_workflow_file(staged, target_path, existed_before)
219227
except BaseException:
220228
_safe_discard_staged_workflow_file(
@@ -267,7 +275,15 @@ def _update_overlay_field(
267275
existed_before = path.exists()
268276
staged = _stage_workflow_file(path.parent)
269277
try:
270-
staged.write_bytes(yaml.safe_dump(data, sort_keys=False).encode("utf-8"))
278+
# ``allow_unicode=True`` matches every other YAML writer in the
279+
# repo. Without it every non-ASCII character in a hand-authored
280+
# overlay is rewritten as a ``\uXXXX`` escape, so merely toggling
281+
# an overlay makes the user's own file unreadable.
282+
staged.write_bytes(
283+
yaml.safe_dump(data, sort_keys=False, allow_unicode=True).encode(
284+
"utf-8"
285+
)
286+
)
271287
backup = _commit_workflow_file(staged, path, existed_before)
272288
except BaseException:
273289
_safe_discard_staged_workflow_file(staged, path.parent, existed_before)

tests/workflows/test_overlay_commands.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,115 @@ def test_overlay_add_rejects_non_positive_priority(self, project_dir, monkeypatc
220220
assert result.exit_code == 1
221221
assert "must be >= 1" in result.output
222222

223+
def test_overlay_add_keeps_non_ascii_text_readable(
224+
self, project_dir, monkeypatch
225+
):
226+
"""``overlay add`` must not escape non-ASCII text in the written file.
227+
228+
Overlay files are documented as hand-authored, so writing them back
229+
with ``\\uXXXX`` escapes makes the user's own file unreadable.
230+
"""
231+
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
232+
_write_workflow(
233+
project_dir,
234+
"wf",
235+
{
236+
"schema_version": "1.0",
237+
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
238+
"steps": [{"id": "a", "type": "command", "command": "echo"}],
239+
},
240+
)
241+
message = "Revisar el plan — ¿aprobar? 日本語"
242+
overlay_file = project_dir / "overlay.yml"
243+
overlay_file.write_text(
244+
yaml.safe_dump(
245+
{
246+
"id": "ov1",
247+
"extends": "wf",
248+
"priority": 10,
249+
"edits": [
250+
{
251+
"operation": "replace",
252+
"anchor": "a",
253+
"step": {
254+
"id": "a",
255+
"type": "gate",
256+
"message": message,
257+
"options": ["approve"],
258+
},
259+
}
260+
],
261+
},
262+
allow_unicode=True,
263+
),
264+
encoding="utf-8",
265+
)
266+
267+
result = runner.invoke(app, ["workflow", "overlay", "add", str(overlay_file)])
268+
assert result.exit_code == 0, result.output
269+
270+
installed = (
271+
project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml"
272+
)
273+
text = installed.read_text(encoding="utf-8")
274+
assert message in text, text
275+
assert "\\u" not in text and "\\x" not in text, text
276+
# The value must still round-trip identically.
277+
data = yaml.safe_load(text)
278+
assert data["edits"][0]["step"]["message"] == message
279+
280+
def test_overlay_set_priority_keeps_non_ascii_text_readable(
281+
self, project_dir, monkeypatch
282+
):
283+
"""Toggling an overlay must not mangle non-ASCII text already in it."""
284+
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
285+
_write_workflow(
286+
project_dir,
287+
"wf",
288+
{
289+
"schema_version": "1.0",
290+
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
291+
"steps": [{"id": "a", "type": "command", "command": "echo"}],
292+
},
293+
)
294+
message = "Revisar el plan — ¿aprobar? 日本語"
295+
_write_overlay(
296+
project_dir,
297+
"wf",
298+
"ov1",
299+
{
300+
"id": "ov1",
301+
"extends": "wf",
302+
"priority": 10,
303+
"edits": [
304+
{
305+
"operation": "replace",
306+
"anchor": "a",
307+
"step": {
308+
"id": "a",
309+
"type": "gate",
310+
"message": message,
311+
"options": ["approve"],
312+
},
313+
}
314+
],
315+
},
316+
)
317+
318+
result = runner.invoke(
319+
app, ["workflow", "overlay", "set-priority", "wf", "ov1", "20"]
320+
)
321+
assert result.exit_code == 0, result.output
322+
323+
text = (
324+
project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml"
325+
).read_text(encoding="utf-8")
326+
assert message in text, text
327+
assert "\\u" not in text and "\\x" not in text, text
328+
data = yaml.safe_load(text)
329+
assert data["priority"] == 20
330+
assert data["edits"][0]["step"]["message"] == message
331+
223332
def test_overlay_set_priority(self, project_dir, monkeypatch):
224333
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
225334
_write_workflow(

0 commit comments

Comments
 (0)