Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions src/apm_cli/commands/_apm_yml_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ def set_skill_subset_for_entry(
"""
data = load_yaml(manifest_path) or {}
deps_section = data.get("dependencies", {})
# Normalise flat list format to structured dict so .get("apm")
# does not raise AttributeError on a list object.
if isinstance(deps_section, list):
deps_section = {"apm": deps_section}
apm_deps = deps_section.get("apm", [])
if not apm_deps:
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
return False
Expand Down
43 changes: 39 additions & 4 deletions src/apm_cli/models/apm_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
compatibility.
"""

import logging
import warnings
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Union # noqa: F401, UP035
Expand All @@ -30,6 +32,8 @@
validate_apm_package,
)

_logger = logging.getLogger(__name__)

# Re-export all moved symbols so `from apm_cli.models.apm_package import X` keeps working
__all__ = [ # noqa: RUF022
# Backward-compatible re-exports from .dependency
Expand Down Expand Up @@ -202,13 +206,44 @@ def from_apm_yml(

# Parse dependencies
dependencies = None
if "dependencies" in data and isinstance(data["dependencies"], dict):
dependencies = cls._parse_dependency_dict(data["dependencies"], label="")
raw_deps = data.get("dependencies")
if raw_deps is not None:
if isinstance(raw_deps, list):
# Flat list format: normalise to {"apm": [...]} so
# transitive resolution works. The structured form
# (dependencies.apm:) is canonical; warn authors.
warnings.warn(
f"Flat dependency list in {apm_yml_path} is deprecated. "
"Use 'dependencies:\\n apm:\\n - ...' instead.",
DeprecationWarning,
stacklevel=2,
)
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
Outdated
_logger.debug(
"Normalising flat dependency list in %s to structured format",
apm_yml_path,
)
raw_deps = {"apm": raw_deps}
if isinstance(raw_deps, dict):
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
Outdated
dependencies = cls._parse_dependency_dict(raw_deps, label="")

# Parse devDependencies (same structure as dependencies)
dev_dependencies = None
if "devDependencies" in data and isinstance(data["devDependencies"], dict):
dev_dependencies = cls._parse_dependency_dict(data["devDependencies"], label="dev ")
raw_dev_deps = data.get("devDependencies")
if raw_dev_deps is not None:
if isinstance(raw_dev_deps, list):
warnings.warn(
f"Flat devDependencies list in {apm_yml_path} is deprecated. "
"Use 'devDependencies:\\n apm:\\n - ...' instead.",
DeprecationWarning,
stacklevel=2,
)
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
Outdated
_logger.debug(
"Normalising flat devDependencies list in %s to structured format",
apm_yml_path,
)
raw_dev_deps = {"apm": raw_dev_deps}
if isinstance(raw_dev_deps, dict):
dev_dependencies = cls._parse_dependency_dict(raw_dev_deps, label="dev ")

# Parse package content type
pkg_type = None
Expand Down
151 changes: 151 additions & 0 deletions tests/unit/test_apm_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,3 +441,154 @@ def test_has_apm_dependencies_false_for_include_only_manifest(self, tmp_path):
)
pkg = APMPackage.from_apm_yml(yml)
assert pkg.has_apm_dependencies() is False


class TestFlatListDependencies:
"""Tests for flat list dependency format normalisation.

The flat format (``dependencies: [owner/repo#sha, ...]``) is
deprecated but must be normalised to the structured form
(``dependencies: {apm: [...]}``), otherwise transitive resolution
silently drops them.
"""

def test_flat_list_dependencies_parsed_as_apm(self, tmp_path):
"""Flat list dependencies are normalised to apm deps."""
yml = _write_apm_yml(
tmp_path,
{
"name": "test-pkg",
"version": "1.0.0",
"dependencies": [
"owner/repo1#abc123",
"owner/repo2#def456",
],
},
)

with pytest.warns(DeprecationWarning, match="Flat dependency list"):
pkg = APMPackage.from_apm_yml(yml)

deps = pkg.get_apm_dependencies()
assert len(deps) == 2
assert all(isinstance(d, DependencyReference) for d in deps)
urls = {d.repo_url for d in deps}
assert "owner/repo1" in urls
assert "owner/repo2" in urls

def test_flat_list_dependencies_have_references(self, tmp_path):
"""Flat list entries with #ref preserve the reference."""
yml = _write_apm_yml(
tmp_path,
{
"name": "test-pkg",
"version": "1.0.0",
"dependencies": ["owner/repo#main"],
},
)

with pytest.warns(DeprecationWarning):
pkg = APMPackage.from_apm_yml(yml)

deps = pkg.get_apm_dependencies()
assert len(deps) == 1
assert deps[0].repo_url == "owner/repo"
assert deps[0].reference == "main"

def test_flat_list_empty_dependencies(self, tmp_path):
"""Empty flat list produces no deps and no crash."""
yml = _write_apm_yml(
tmp_path,
{
"name": "test-pkg",
"version": "1.0.0",
"dependencies": [],
},
)

with pytest.warns(DeprecationWarning):
pkg = APMPackage.from_apm_yml(yml)

assert pkg.get_apm_dependencies() == []
assert pkg.has_apm_dependencies() is False

def test_flat_list_dev_dependencies_parsed_as_apm(self, tmp_path):
"""Flat list devDependencies are normalised to dev apm deps."""
yml = _write_apm_yml(
tmp_path,
{
"name": "test-pkg",
"version": "1.0.0",
"devDependencies": [
"owner/dev-tool#v1",
],
},
)

with pytest.warns(DeprecationWarning, match="Flat devDependencies list"):
pkg = APMPackage.from_apm_yml(yml)

dev_deps = pkg.get_dev_apm_dependencies()
assert len(dev_deps) == 1
assert dev_deps[0].repo_url == "owner/dev-tool"

def test_flat_list_subpath_dependencies(self, tmp_path):
"""Flat list with sub-path packages (owner/repo/sub/path) works."""
yml = _write_apm_yml(
tmp_path,
{
"name": "test-pkg",
"version": "1.0.0",
"dependencies": [
"org/monorepo/sub/path#abc123",
],
},
)

with pytest.warns(DeprecationWarning):
pkg = APMPackage.from_apm_yml(yml)

deps = pkg.get_apm_dependencies()
assert len(deps) == 1
assert deps[0].repo_url == "org/monorepo"

def test_structured_format_still_works(self, tmp_path):
"""Regression guard: structured format must not break."""
yml = _write_apm_yml(
tmp_path,
{
"name": "test-pkg",
"version": "1.0.0",
"dependencies": {
"apm": ["owner/repo1"],
"mcp": [],
},
},
)

# Must not emit a DeprecationWarning
import warnings

with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
pkg = APMPackage.from_apm_yml(yml)

deps = pkg.get_apm_dependencies()
assert len(deps) == 1
assert deps[0].repo_url == "owner/repo1"

def test_flat_list_mcp_not_in_deps(self, tmp_path):
"""Flat list does not produce MCP deps -- only APM."""
yml = _write_apm_yml(
tmp_path,
{
"name": "test-pkg",
"version": "1.0.0",
"dependencies": ["owner/repo1"],
},
)

with pytest.warns(DeprecationWarning):
pkg = APMPackage.from_apm_yml(yml)

assert pkg.get_mcp_dependencies() == []
21 changes: 21 additions & 0 deletions tests/unit/test_skill_subset_persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,27 @@ def test_empty_deps_returns_false(self, tmp_path):
result = set_skill_subset_for_entry(manifest, "owner/repo", ["alpha"])
assert result is False

def test_flat_list_deps_normalised(self, tmp_path):
"""Flat list dependencies don't crash the writer."""
from apm_cli.commands._apm_yml_writer import set_skill_subset_for_entry
from apm_cli.utils.yaml_io import load_yaml

manifest = self._write_manifest(
tmp_path,
"""\
dependencies:
- owner/repo#main
""",
)
result = set_skill_subset_for_entry(manifest, "owner/repo", ["alpha"])
assert result is True
data = load_yaml(manifest)
# Writer should have normalised the flat list to structured form
assert isinstance(data["dependencies"], dict)
entry = data["dependencies"]["apm"][0]
assert isinstance(entry, dict)
assert entry["skills"] == ["alpha"]

def test_update_existing_dict_entry(self, tmp_path):
"""Dict entry with existing skills: gets updated."""
from apm_cli.commands._apm_yml_writer import set_skill_subset_for_entry
Expand Down
Loading