Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ jobs:
- name: Install dependencies
run: uv sync --extra dev --extra build

- name: Check YAML encoding safety
run: |
# Ensure YAML file I/O goes through yaml_io helpers.
# Catches yaml.dump/safe_dump writing to a file handle outside yaml_io.py.
VIOLATIONS=$(grep -rn --include='*.py' -P \
'yaml\.(safe_)?dump\(.+,\s*[a-zA-Z_]\w*\b' src/apm_cli/ \
| grep -v 'utils/yaml_io.py' \
| grep -v '# yaml-io-exempt' \
|| true)
if [ -n "$VIOLATIONS" ]; then
echo "::error::Direct yaml.dump() to file handle detected. Use yaml_io.dump_yaml() instead:"
echo "$VIOLATIONS"
exit 1
fi

- name: Run tests
run: uv run pytest tests/unit tests/test_console.py -n auto --dist worksteal

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Cross-platform YAML encoding: all `apm.yml` read/write operations now use explicit UTF-8 encoding via centralized `yaml_io` helpers, preventing silent mojibake on Windows cp1252 terminals; non-ASCII characters (e.g. accented author names) are preserved as real UTF-8 instead of `\xNN` escape sequences (#387, #433) -- based on #388 by @alopezsanchez
- SSL certificate verification failures in PyInstaller binary on systems without python.org Python installed; bundled `certifi` CA bundle is now auto-configured via runtime hook (#429)
- Virtual package types (files, collections, subdirectories) now respect `ARTIFACTORY_ONLY=1`, matching the primary zip-archive proxy-only behavior (#418)
- `apm pack --target claude` no longer produces an empty bundle when skills/agents are installed under `.github/` -- cross-target path mapping remaps `skills/` and `agents/` to the pack target prefix (#420)
Expand Down
14 changes: 5 additions & 9 deletions src/apm_cli/bundle/lockfile_enrichment.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,9 @@ def enrich_lockfile_for_pack(
break
pack_meta["mapped_from"] = sorted(used_src_prefixes)

pack_section = yaml.dump(
{"pack": pack_meta},
default_flow_style=False,
sort_keys=False,
)

lockfile_yaml = yaml.dump(
data, default_flow_style=False, sort_keys=False, allow_unicode=True
)
from ..utils.yaml_io import yaml_to_str

pack_section = yaml_to_str({"pack": pack_meta})

lockfile_yaml = yaml_to_str(data)
return pack_section + lockfile_yaml
3 changes: 2 additions & 1 deletion src/apm_cli/bundle/plugin_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,8 @@ def _get_dev_dependency_urls(apm_yml_path: Path) -> Set[Tuple[str, str]]:
``github/awesome-copilot``).
"""
try:
data = yaml.safe_load(apm_yml_path.read_text(encoding="utf-8"))
from ..utils.yaml_io import load_yaml
data = load_yaml(apm_yml_path)
except (yaml.YAMLError, OSError, ValueError):
return set()
if not isinstance(data, dict):
Expand Down
11 changes: 4 additions & 7 deletions src/apm_cli/commands/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,9 +331,8 @@ def _update_gitignore_for_apm_modules(logger=None):
def _load_apm_config():
"""Load configuration from apm.yml."""
if Path(APM_YML_FILENAME).exists():
with open(APM_YML_FILENAME, "r") as f:
yaml = _lazy_yaml()
return yaml.safe_load(f)
from ..utils.yaml_io import load_yaml
return load_yaml(APM_YML_FILENAME)
return None


Expand Down Expand Up @@ -440,8 +439,6 @@ def _create_minimal_apm_yml(config, plugin=False):
config: dict with name, version, description, author keys.
plugin: if True, include a devDependencies section.
"""
yaml = _lazy_yaml()

# Create minimal apm.yml structure
apm_yml_data = {
"name": config["name"],
Expand All @@ -457,5 +454,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)
from ..utils.yaml_io import dump_yaml
dump_yaml(apm_yml_data, APM_YML_FILENAME)
Comment thread
danielmeppiel marked this conversation as resolved.
10 changes: 4 additions & 6 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,12 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
import tempfile
from pathlib import Path

import yaml

apm_yml_path = Path(APM_YML_FILENAME)

# Read current apm.yml
try:
with open(apm_yml_path, "r") as f:
data = yaml.safe_load(f) or {}
from ..utils.yaml_io import load_yaml
data = load_yaml(apm_yml_path) or {}
Comment thread
danielmeppiel marked this conversation as resolved.
except Exception as e:
if logger:
logger.error(f"Failed to read {APM_YML_FILENAME}: {e}")
Expand Down Expand Up @@ -200,8 +198,8 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo

# 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)
from ..utils.yaml_io import dump_yaml
dump_yaml(data, apm_yml_path)
if logger:
logger.success(f"Updated {APM_YML_FILENAME} with {len(validated_packages)} new package(s)")
except Exception as e:
Expand Down
8 changes: 3 additions & 5 deletions src/apm_cli/commands/uninstall/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,11 @@ def uninstall(ctx, packages, dry_run, verbose):
logger.start(f"Uninstalling {len(packages)} package(s)...")

# Read current apm.yml
import yaml
from ...utils.yaml_io import load_yaml, dump_yaml

apm_yml_path = Path(APM_YML_FILENAME)
try:
with open(apm_yml_path, "r") as f:
data = yaml.safe_load(f) or {}
data = load_yaml(apm_yml_path) or {}
except Exception as e:
logger.error(f"Failed to read {APM_YML_FILENAME}: {e}")
sys.exit(1)
Expand Down Expand Up @@ -88,8 +87,7 @@ def uninstall(ctx, packages, dry_run, verbose):
logger.progress(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)
dump_yaml(data, apm_yml_path)
logger.success(f"Updated {APM_YML_FILENAME} (removed {len(packages_to_remove)} package(s))")
except Exception as e:
logger.error(f"Failed to write {APM_YML_FILENAME}: {e}")
Expand Down
5 changes: 2 additions & 3 deletions src/apm_cli/commands/uninstall/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,6 @@ def _remove_packages_from_disk(packages_to_remove, apm_modules_dir, logger):

def _cleanup_transitive_orphans(lockfile, packages_to_remove, apm_modules_dir, apm_yml_path, logger):
"""Remove orphaned transitive deps and return (removed_count, actual_orphan_keys)."""
import yaml

if not lockfile or not apm_modules_dir.exists():
return 0, builtins.set()
Expand Down Expand Up @@ -179,8 +178,8 @@ def _cleanup_transitive_orphans(lockfile, packages_to_remove, apm_modules_dir, a
# Determine remaining deps to avoid removing still-needed packages
remaining_deps = builtins.set()
try:
with open(apm_yml_path, "r") as f:
updated_data = yaml.safe_load(f) or {}
from ...utils.yaml_io import load_yaml
updated_data = load_yaml(apm_yml_path) or {}
Comment thread
danielmeppiel marked this conversation as resolved.
for dep_str in updated_data.get("dependencies", {}).get("apm", []) or []:
try:
ref = _parse_dependency_entry(dep_str)
Expand Down
5 changes: 2 additions & 3 deletions src/apm_cli/compilation/agents_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,10 @@ def from_apm_yml(cls, **overrides) -> 'CompilationConfig':
# Try to load from apm.yml
try:
from pathlib import Path
import yaml

if Path('apm.yml').exists():
with open('apm.yml', 'r') as f:
apm_config = yaml.safe_load(f) or {}
from ..utils.yaml_io import load_yaml
apm_config = load_yaml('apm.yml') or {}

# Look for compilation section
compilation_config = apm_config.get('compilation', {})
Expand Down
15 changes: 7 additions & 8 deletions src/apm_cli/core/script_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,8 @@ def _load_config(self) -> Optional[Dict]:
if not config_path.exists():
return None

with open(config_path, "r") as f:
return yaml.safe_load(f)
from ..utils.yaml_io import load_yaml
return load_yaml(config_path)

def _auto_compile_prompts(
self, command: str, params: Dict[str, str]
Expand Down Expand Up @@ -808,8 +808,8 @@ def _add_dependency_to_config(self, package_ref: str) -> None:
return

# Load current config
with open(config_path, "r") as f:
config = yaml.safe_load(f) or {}
from ..utils.yaml_io import load_yaml, dump_yaml
config = load_yaml(config_path) or {}

# Ensure dependencies.apm section exists
if "dependencies" not in config:
Expand All @@ -822,8 +822,7 @@ 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)
dump_yaml(config, config_path)

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

Expand All @@ -838,8 +837,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)
from ..utils.yaml_io import dump_yaml
dump_yaml(minimal_config, "apm.yml")

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

Expand Down
4 changes: 2 additions & 2 deletions src/apm_cli/deps/aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ def sync_workflow_dependencies(output_file="apm.yml"):

try:
# Create the file
with open(output_file, 'w', encoding='utf-8') as f:
yaml.dump(apm_config, f, default_flow_style=False)
from ..utils.yaml_io import dump_yaml
dump_yaml(apm_config, output_file)
return True, apm_config['servers']
except Exception as e:
print(f"Error writing to {output_file}: {e}")
Expand Down
16 changes: 6 additions & 10 deletions src/apm_cli/deps/github_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -1678,12 +1678,10 @@ def download_subdirectory_package(self, dep_ref: DependencyReference, target_pat
package.version = short_sha
apm_yml_path = target_path / "apm.yml"
if apm_yml_path.exists():
import yaml as _yaml
with open(apm_yml_path, "r", encoding="utf-8") as _f:
_data = _yaml.safe_load(_f) or {}
from ..utils.yaml_io import load_yaml, dump_yaml
_data = load_yaml(apm_yml_path) 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)
dump_yaml(_data, apm_yml_path)

# Update progress - complete
if progress_obj and progress_task_id is not None:
Expand Down Expand Up @@ -1986,12 +1984,10 @@ def download_package(
# Keep the synthesized apm.yml in sync
apm_yml_path = target_path / "apm.yml"
if apm_yml_path.exists():
import yaml as _yaml
with open(apm_yml_path, "r", encoding="utf-8") as _f:
_data = _yaml.safe_load(_f) or {}
from ..utils.yaml_io import load_yaml, dump_yaml
_data = load_yaml(apm_yml_path) 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)
dump_yaml(_data, apm_yml_path)

# Create and return PackageInfo
return PackageInfo(
Expand Down
5 changes: 2 additions & 3 deletions src/apm_cli/deps/lockfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,8 @@ def to_yaml(self) -> str:
data["mcp_servers"] = sorted(self.mcp_servers)
if self.mcp_configs:
data["mcp_configs"] = dict(sorted(self.mcp_configs.items()))
return yaml.dump(
data, default_flow_style=False, sort_keys=False, allow_unicode=True
)
from ..utils.yaml_io import yaml_to_str
return yaml_to_str(data)

@classmethod
def from_yaml(cls, yaml_str: str) -> "LockFile":
Expand Down
6 changes: 4 additions & 2 deletions src/apm_cli/deps/plugin_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,8 @@ def _generate_apm_yml(manifest: Dict[str, Any]) -> str:
# field. Default to hybrid so the standard pipeline handles all components.
apm_package['type'] = 'hybrid'

return yaml.dump(apm_package, default_flow_style=False, sort_keys=False)
from ..utils.yaml_io import yaml_to_str
return yaml_to_str(apm_package)


def synthesize_plugin_json_from_apm_yml(apm_yml_path: Path) -> dict:
Expand All @@ -521,7 +522,8 @@ def synthesize_plugin_json_from_apm_yml(apm_yml_path: Path) -> dict:
raise FileNotFoundError(f"apm.yml not found: {apm_yml_path}")

try:
data = yaml.safe_load(apm_yml_path.read_text(encoding="utf-8"))
from ..utils.yaml_io import load_yaml
data = load_yaml(apm_yml_path)
except yaml.YAMLError as exc:
raise ValueError(f"Invalid YAML in {apm_yml_path}: {exc}") from exc

Expand Down
4 changes: 2 additions & 2 deletions src/apm_cli/deps/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ def load_apm_config(config_file="apm.yml"):
print(f"Configuration file {config_file} not found.")
return None

with open(config_path, 'r', encoding='utf-8') as f:
config = yaml.safe_load(f)
from ..utils.yaml_io import load_yaml
config = load_yaml(config_path)

return config
except Exception as e:
Expand Down
6 changes: 2 additions & 4 deletions src/apm_cli/integration/mcp_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -891,12 +891,10 @@ def install(
if apm_config is None:
# Lazy load -- only when the caller doesn't provide it
try:
import yaml

apm_yml = Path("apm.yml")
if apm_yml.exists():
with open(apm_yml, "r", encoding="utf-8") as f:
apm_config = yaml.safe_load(f)
from apm_cli.utils.yaml_io import load_yaml
apm_config = load_yaml(apm_yml)
except Exception:
apm_config = None

Expand Down
4 changes: 2 additions & 2 deletions src/apm_cli/models/apm_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ def from_apm_yml(cls, apm_yml_path: Path) -> "APMPackage":
return cached

try:
with open(apm_yml_path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
from ..utils.yaml_io import load_yaml
data = load_yaml(apm_yml_path)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML format in {apm_yml_path}: {e}")

Expand Down
55 changes: 55 additions & 0 deletions src/apm_cli/utils/yaml_io.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Cross-platform YAML I/O with guaranteed UTF-8 encoding.

All YAML file operations in apm_cli should use these helpers to ensure
consistent encoding (UTF-8) and formatting (unicode, block style, key
order preserved). This prevents silent mojibake on Windows where the
default file encoding is cp1252, not UTF-8.

Public API::

load_yaml(path) -- read a .yml/.yaml file -> dict | None
dump_yaml(data, path) -- write dict -> .yml/.yaml file
yaml_to_str(data) -- serialize dict -> YAML string
"""

from pathlib import Path
from typing import Any, Dict, Optional, Union

import yaml

# Shared defaults matching existing codebase convention.
_DUMP_DEFAULTS: Dict[str, Any] = dict(
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
)


def load_yaml(path: Union[str, Path]) -> Optional[Dict[str, Any]]:
"""Load a YAML file with explicit UTF-8 encoding.

Returns parsed data or ``None`` for empty files.
Raises ``FileNotFoundError`` or ``yaml.YAMLError`` on failure.
"""
with open(path, "r", encoding="utf-8") as fh:
return yaml.safe_load(fh)
Comment thread
danielmeppiel marked this conversation as resolved.


def dump_yaml(
data: Any,
path: Union[str, Path],
*,
sort_keys: bool = False,
) -> None:
"""Write data to a YAML file with UTF-8 encoding and unicode support."""
with open(path, "w", encoding="utf-8") as fh:
yaml.safe_dump(data, fh, **{**_DUMP_DEFAULTS, "sort_keys": sort_keys})


def yaml_to_str(data: Any, *, sort_keys: bool = False) -> str:
"""Serialize data to a YAML string with unicode support.

Use instead of bare ``yaml.dump()`` when building YAML content
for later file writes or string returns.
"""
return yaml.safe_dump(data, **{**_DUMP_DEFAULTS, "sort_keys": sort_keys})
4 changes: 2 additions & 2 deletions tests/unit/test_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ def test_scan_workflows_for_dependencies(
self.assertEqual(mock_frontmatter_load.call_count, 2)

@patch("apm_cli.deps.aggregator.scan_workflows_for_dependencies")
@patch("builtins.open", new_callable=mock_open)
@patch("yaml.dump")
@patch("apm_cli.utils.yaml_io.open", new_callable=mock_open)
@patch("apm_cli.utils.yaml_io.yaml.safe_dump")
def test_sync_workflow_dependencies(self, mock_yaml_dump, mock_file, mock_scan):
"""Test syncing workflow dependencies to apm.yml."""
# Mock scan_workflows_for_dependencies to return a set of servers
Expand Down
Loading
Loading