Skip to content

Commit 05073c5

Browse files
committed
fix: write UTF-8 characters in apm.yml instead of escaped \xNN sequences
PyYAML defaults to `allow_unicode=False`, which escapes non-ASCII characters (e.g. "López" → "L\xF3pez") regardless of file encoding. Add `allow_unicode=True` to all yaml.dump/safe_dump calls that write apm.yml files.
1 parent 8c677c6 commit 05073c5

File tree

7 files changed

+96
-18
lines changed

7 files changed

+96
-18
lines changed

src/apm_cli/commands/_helpers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -457,5 +457,5 @@ def _create_minimal_apm_yml(config, plugin=False):
457457
apm_yml_data["scripts"] = {}
458458

459459
# Write apm.yml
460-
with open(APM_YML_FILENAME, "w") as f:
461-
yaml.safe_dump(apm_yml_data, f, default_flow_style=False, sort_keys=False)
460+
with open(APM_YML_FILENAME, "w", encoding="utf-8") as f:
461+
yaml.safe_dump(apm_yml_data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)

src/apm_cli/commands/install.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,8 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
200200

201201
# Write back to apm.yml
202202
try:
203-
with open(apm_yml_path, "w") as f:
204-
yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
203+
with open(apm_yml_path, "w", encoding="utf-8") as f:
204+
yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
205205
if logger:
206206
logger.success(f"Updated {APM_YML_FILENAME} with {len(validated_packages)} new package(s)")
207207
except Exception as e:
@@ -2102,7 +2102,3 @@ def _collect_descendants(node, visited=None):
21022102

21032103
except Exception as e:
21042104
raise RuntimeError(f"Failed to resolve APM dependencies: {e}")
2105-
2106-
2107-
2108-

src/apm_cli/commands/uninstall/cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ def uninstall(ctx, packages, dry_run, verbose):
8888
logger.progress(f"Removed {package} from apm.yml")
8989
data["dependencies"]["apm"] = current_deps
9090
try:
91-
with open(apm_yml_path, "w") as f:
92-
yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
91+
with open(apm_yml_path, "w", encoding="utf-8") as f:
92+
yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
9393
logger.success(f"Updated {APM_YML_FILENAME} (removed {len(packages_to_remove)} package(s))")
9494
except Exception as e:
9595
logger.error(f"Failed to write {APM_YML_FILENAME}: {e}")

src/apm_cli/core/script_runner.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -822,8 +822,8 @@ def _add_dependency_to_config(self, package_ref: str) -> None:
822822
config["dependencies"]["apm"].append(package_ref)
823823

824824
# Write back to file
825-
with open(config_path, "w") as f:
826-
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
825+
with open(config_path, "w", encoding="utf-8") as f:
826+
yaml.dump(config, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
827827

828828
print(f" [i] Added {package_ref} to apm.yml dependencies")
829829

@@ -838,8 +838,8 @@ def _create_minimal_config(self) -> None:
838838
"description": "Auto-generated for zero-config virtual package execution",
839839
}
840840

841-
with open("apm.yml", "w") as f:
842-
yaml.dump(minimal_config, f, default_flow_style=False, sort_keys=False)
841+
with open("apm.yml", "w", encoding="utf-8") as f:
842+
yaml.dump(minimal_config, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
843843

844844
print(f" [i] Created minimal apm.yml for zero-config execution")
845845

src/apm_cli/deps/github_downloader.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1238,7 +1238,7 @@ def download_virtual_file_package(self, dep_ref: DependencyReference, target_pat
12381238

12391239
# Create target directory structure
12401240
target_path.mkdir(parents=True, exist_ok=True)
1241-
1241+
12421242
# Determine the subdirectory based on file extension
12431243
subdirs = {
12441244
'.prompt.md': 'prompts',
@@ -1683,7 +1683,7 @@ def download_subdirectory_package(self, dep_ref: DependencyReference, target_pat
16831683
_data = _yaml.safe_load(_f) or {}
16841684
_data["version"] = short_sha
16851685
with open(apm_yml_path, "w", encoding="utf-8") as _f:
1686-
_yaml.dump(_data, _f, default_flow_style=False, sort_keys=False)
1686+
_yaml.dump(_data, _f, default_flow_style=False, sort_keys=False, allow_unicode=True)
16871687

16881688
# Update progress - complete
16891689
if progress_obj and progress_task_id is not None:
@@ -1991,7 +1991,7 @@ def download_package(
19911991
_data = _yaml.safe_load(_f) or {}
19921992
_data["version"] = short_sha
19931993
with open(apm_yml_path, "w", encoding="utf-8") as _f:
1994-
_yaml.dump(_data, _f, default_flow_style=False, sort_keys=False)
1994+
_yaml.dump(_data, _f, default_flow_style=False, sort_keys=False, allow_unicode=True)
19951995

19961996
# Create and return PackageInfo
19971997
return PackageInfo(
@@ -2017,4 +2017,4 @@ def progress_callback(op_code, cur_count, max_count=None, message=''):
20172017
else:
20182018
print(f"\r Cloning: {message} ({cur_count})", end='', flush=True)
20192019

2020-
return progress_callback
2020+
return progress_callback

tests/unit/test_init_command.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,38 @@ def test_init_auto_detection(self):
282282
finally:
283283
os.chdir(self.original_dir) # restore CWD before TemporaryDirectory cleanup
284284

285+
286+
def test_init_unicode_author(self):
287+
"""Test that non-ASCII author names are written as UTF-8, not escaped."""
288+
with tempfile.TemporaryDirectory() as tmp_dir:
289+
os.chdir(tmp_dir)
290+
try:
291+
292+
import subprocess
293+
294+
subprocess.run(["git", "init"], capture_output=True, check=True)
295+
subprocess.run(
296+
["git", "config", "user.name", "Pepe Rodríguez"],
297+
capture_output=True,
298+
check=True,
299+
)
300+
301+
result = self.runner.invoke(cli, ["init", "--yes"])
302+
303+
assert result.exit_code == 0
304+
305+
# Verify parsed value
306+
with open("apm.yml", encoding="utf-8") as f:
307+
config = yaml.safe_load(f)
308+
assert config["author"] == "Pepe Rodríguez"
309+
310+
# Verify raw file contains actual UTF-8, not escaped sequences
311+
raw = Path("apm.yml").read_text(encoding="utf-8")
312+
assert "Rodríguez" in raw
313+
assert "\\x" not in raw
314+
finally:
315+
os.chdir(self.original_dir) # restore CWD before TemporaryDirectory cleanup
316+
285317
def test_init_does_not_create_skill_md(self):
286318
"""Test that init does not create SKILL.md (only apm.yml)."""
287319
with tempfile.TemporaryDirectory() as tmp_dir:

tests/unit/test_install_command.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,3 +489,53 @@ def tracking_callback(dep_ref, mods_dir, parent_chain=""):
489489
assert ">" in chain, (
490490
f"Expected '>' separator in chain, got: '{chain}'"
491491
)
492+
493+
@patch("apm_cli.commands.install._validate_package_exists")
494+
@patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True)
495+
@patch("apm_cli.commands.install.APMPackage")
496+
@patch("apm_cli.commands.install._install_apm_dependencies")
497+
def test_install_preserves_unicode_author_on_rewrite(
498+
self, mock_install_apm, mock_apm_package, mock_validate
499+
):
500+
"""Test that install round-trip preserves non-ASCII author as UTF-8."""
501+
with self._chdir_tmp():
502+
# Create apm.yml with non-ASCII author
503+
initial_config = {
504+
"name": "test-project",
505+
"version": "0.1.0",
506+
"author": "Alejandro López Sánchez",
507+
"dependencies": {"apm": []},
508+
}
509+
with open("apm.yml", "w", encoding="utf-8") as f:
510+
yaml.safe_dump(
511+
initial_config, f, default_flow_style=False,
512+
sort_keys=False, allow_unicode=True,
513+
)
514+
515+
mock_validate.return_value = True
516+
517+
mock_pkg_instance = MagicMock()
518+
mock_pkg_instance.get_apm_dependencies.return_value = [
519+
MagicMock(repo_url="test/package", reference="main")
520+
]
521+
mock_pkg_instance.get_mcp_dependencies.return_value = []
522+
mock_apm_package.from_apm_yml.return_value = mock_pkg_instance
523+
524+
mock_install_apm.return_value = InstallResult(
525+
diagnostics=MagicMock(
526+
has_diagnostics=False, has_critical_security=False
527+
)
528+
)
529+
530+
result = self.runner.invoke(cli, ["install", "test/package"])
531+
assert result.exit_code == 0
532+
533+
# Verify parsed value preserves Unicode
534+
with open("apm.yml", encoding="utf-8") as f:
535+
config = yaml.safe_load(f)
536+
assert config["author"] == "Alejandro López Sánchez"
537+
538+
# Verify raw file contains actual UTF-8, not escaped sequences
539+
raw = Path("apm.yml").read_text(encoding="utf-8")
540+
assert "López" in raw
541+
assert "\\x" not in raw

0 commit comments

Comments
 (0)