From 9e28bfefad2b69dd026ccd3d544b719caa39cbda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20L=C3=B3pez=20S=C3=A1nchez?= Date: Fri, 20 Mar 2026 13:58:22 +0100 Subject: [PATCH] fix: write UTF-8 characters in apm.yml instead of escaped `\xNN` sequences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/apm_cli/commands/_helpers.py | 4 +-- src/apm_cli/commands/install.py | 4 +-- src/apm_cli/commands/uninstall/cli.py | 4 +-- src/apm_cli/core/script_runner.py | 8 ++--- src/apm_cli/deps/github_downloader.py | 8 ++--- tests/unit/test_init_command.py | 32 +++++++++++++++++ tests/unit/test_install_command.py | 50 +++++++++++++++++++++++++++ 7 files changed, 96 insertions(+), 14 deletions(-) diff --git a/src/apm_cli/commands/_helpers.py b/src/apm_cli/commands/_helpers.py index bc0c886a..5d0d91b2 100644 --- a/src/apm_cli/commands/_helpers.py +++ b/src/apm_cli/commands/_helpers.py @@ -448,5 +448,5 @@ def _create_minimal_apm_yml(config, plugin=False): apm_yml_data["scripts"] = {} # Write apm.yml - with open(APM_YML_FILENAME, "w") as f: - yaml.safe_dump(apm_yml_data, f, default_flow_style=False, sort_keys=False) + with open(APM_YML_FILENAME, "w", encoding="utf-8") as f: + yaml.safe_dump(apm_yml_data, f, default_flow_style=False, sort_keys=False, allow_unicode=True) diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index cde1ff48..e8440ff2 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -167,8 +167,8 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False): # Write back to apm.yml try: - with open(apm_yml_path, "w") as f: - yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False) + with open(apm_yml_path, "w", encoding="utf-8") as f: + yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True) _rich_success(f"Updated {APM_YML_FILENAME} with {len(validated_packages)} new package(s)") except Exception as e: _rich_error(f"Failed to write {APM_YML_FILENAME}: {e}") diff --git a/src/apm_cli/commands/uninstall/cli.py b/src/apm_cli/commands/uninstall/cli.py index 15ab4482..32cfbc4d 100644 --- a/src/apm_cli/commands/uninstall/cli.py +++ b/src/apm_cli/commands/uninstall/cli.py @@ -88,8 +88,8 @@ def uninstall(ctx, packages, dry_run): _rich_info(f"Removed {package} from apm.yml") data["dependencies"]["apm"] = current_deps try: - with open(apm_yml_path, "w") as f: - yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False) + with open(apm_yml_path, "w", encoding="utf-8") as f: + yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True) _rich_success(f"Updated {APM_YML_FILENAME} (removed {len(packages_to_remove)} package(s))") except Exception as e: _rich_error(f"Failed to write {APM_YML_FILENAME}: {e}") diff --git a/src/apm_cli/core/script_runner.py b/src/apm_cli/core/script_runner.py index da8c05e2..4832e97b 100644 --- a/src/apm_cli/core/script_runner.py +++ b/src/apm_cli/core/script_runner.py @@ -822,8 +822,8 @@ def _add_dependency_to_config(self, package_ref: str) -> None: config["dependencies"]["apm"].append(package_ref) # Write back to file - with open(config_path, "w") as f: - yaml.dump(config, f, default_flow_style=False, sort_keys=False) + with open(config_path, "w", encoding="utf-8") as f: + yaml.dump(config, f, default_flow_style=False, sort_keys=False, allow_unicode=True) print(f" [i] Added {package_ref} to apm.yml dependencies") @@ -838,8 +838,8 @@ def _create_minimal_config(self) -> None: "description": "Auto-generated for zero-config virtual package execution", } - with open("apm.yml", "w") as f: - yaml.dump(minimal_config, f, default_flow_style=False, sort_keys=False) + with open("apm.yml", "w", encoding="utf-8") as f: + yaml.dump(minimal_config, f, default_flow_style=False, sort_keys=False, allow_unicode=True) print(f" [i] Created minimal apm.yml for zero-config execution") diff --git a/src/apm_cli/deps/github_downloader.py b/src/apm_cli/deps/github_downloader.py index 32cb3d1b..1043387b 100644 --- a/src/apm_cli/deps/github_downloader.py +++ b/src/apm_cli/deps/github_downloader.py @@ -1204,7 +1204,7 @@ def download_virtual_file_package(self, dep_ref: DependencyReference, target_pat # Create target directory structure target_path.mkdir(parents=True, exist_ok=True) - + # Determine the subdirectory based on file extension subdirs = { '.prompt.md': 'prompts', @@ -1649,7 +1649,7 @@ def download_subdirectory_package(self, dep_ref: DependencyReference, target_pat _data = _yaml.safe_load(_f) or {} _data["version"] = short_sha with open(apm_yml_path, "w", encoding="utf-8") as _f: - _yaml.dump(_data, _f, default_flow_style=False, sort_keys=False) + _yaml.dump(_data, _f, default_flow_style=False, sort_keys=False, allow_unicode=True) # Update progress - complete if progress_obj and progress_task_id is not None: @@ -1950,7 +1950,7 @@ def download_package( _data = _yaml.safe_load(_f) or {} _data["version"] = short_sha with open(apm_yml_path, "w", encoding="utf-8") as _f: - _yaml.dump(_data, _f, default_flow_style=False, sort_keys=False) + _yaml.dump(_data, _f, default_flow_style=False, sort_keys=False, allow_unicode=True) # Create and return PackageInfo return PackageInfo( @@ -1976,4 +1976,4 @@ def progress_callback(op_code, cur_count, max_count=None, message=''): else: print(f"\r Cloning: {message} ({cur_count})", end='', flush=True) - return progress_callback \ No newline at end of file + return progress_callback diff --git a/tests/unit/test_init_command.py b/tests/unit/test_init_command.py index af21b9e7..8bb55052 100644 --- a/tests/unit/test_init_command.py +++ b/tests/unit/test_init_command.py @@ -282,6 +282,38 @@ def test_init_auto_detection(self): finally: os.chdir(self.original_dir) # restore CWD before TemporaryDirectory cleanup + + def test_init_unicode_author(self): + """Test that non-ASCII author names are written as UTF-8, not escaped.""" + with tempfile.TemporaryDirectory() as tmp_dir: + os.chdir(tmp_dir) + try: + + import subprocess + + subprocess.run(["git", "init"], capture_output=True, check=True) + subprocess.run( + ["git", "config", "user.name", "Pepe Rodríguez"], + capture_output=True, + check=True, + ) + + result = self.runner.invoke(cli, ["init", "--yes"]) + + assert result.exit_code == 0 + + # Verify parsed value + with open("apm.yml", encoding="utf-8") as f: + config = yaml.safe_load(f) + assert config["author"] == "Pepe Rodríguez" + + # Verify raw file contains actual UTF-8, not escaped sequences + raw = Path("apm.yml").read_text(encoding="utf-8") + assert "Rodríguez" in raw + assert "\\x" not in raw + finally: + os.chdir(self.original_dir) # restore CWD before TemporaryDirectory cleanup + def test_init_does_not_create_skill_md(self): """Test that init does not create SKILL.md (only apm.yml).""" with tempfile.TemporaryDirectory() as tmp_dir: diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py index 5696e204..6467199f 100644 --- a/tests/unit/test_install_command.py +++ b/tests/unit/test_install_command.py @@ -243,3 +243,53 @@ def test_install_dry_run_with_no_apm_yml_shows_what_would_be_created( assert "Would add" in result.output or "Dry run" in result.output # apm.yml should still be created (for dry-run to work) assert Path("apm.yml").exists() + + @patch("apm_cli.commands.install._validate_package_exists") + @patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True) + @patch("apm_cli.commands.install.APMPackage") + @patch("apm_cli.commands.install._install_apm_dependencies") + def test_install_preserves_unicode_author_on_rewrite( + self, mock_install_apm, mock_apm_package, mock_validate + ): + """Test that install round-trip preserves non-ASCII author as UTF-8.""" + with self._chdir_tmp(): + # Create apm.yml with non-ASCII author + initial_config = { + "name": "test-project", + "version": "0.1.0", + "author": "Alejandro López Sánchez", + "dependencies": {"apm": []}, + } + with open("apm.yml", "w", encoding="utf-8") as f: + yaml.safe_dump( + initial_config, f, default_flow_style=False, + sort_keys=False, allow_unicode=True, + ) + + mock_validate.return_value = True + + mock_pkg_instance = MagicMock() + mock_pkg_instance.get_apm_dependencies.return_value = [ + MagicMock(repo_url="test/package", reference="main") + ] + mock_pkg_instance.get_mcp_dependencies.return_value = [] + mock_apm_package.from_apm_yml.return_value = mock_pkg_instance + + mock_install_apm.return_value = InstallResult( + diagnostics=MagicMock( + has_diagnostics=False, has_critical_security=False + ) + ) + + result = self.runner.invoke(cli, ["install", "test/package"]) + assert result.exit_code == 0 + + # Verify parsed value preserves Unicode + with open("apm.yml", encoding="utf-8") as f: + config = yaml.safe_load(f) + assert config["author"] == "Alejandro López Sánchez" + + # Verify raw file contains actual UTF-8, not escaped sequences + raw = Path("apm.yml").read_text(encoding="utf-8") + assert "López" in raw + assert "\\x" not in raw