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
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Release 0.14.0 (unreleased)
* Fix unhelpful error message when a metadata file is malformed (#1145)
* Fix arbitrary file write via malicious tar/zip symlink (#1152)
* Prevent SSH command injection (#1152)
* Allow manifests with no ``projects`` key so ``dfetch add`` can bootstrap empty manifest (#1197)

Release 0.13.0 (released 2026-03-30)
====================================
Expand Down
37 changes: 24 additions & 13 deletions dfetch/manifest/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

import yaml
from strictyaml import YAML, StrictYAMLError, YAMLValidationError, load
from strictyaml.ruamel.comments import CommentedMap
from strictyaml.ruamel.comments import CommentedMap, CommentedSeq
from strictyaml.ruamel.error import CommentMark
from strictyaml.ruamel.scalarstring import SingleQuotedScalarString
from strictyaml.ruamel.tokens import CommentToken
Expand Down Expand Up @@ -150,8 +150,12 @@ class ManifestDict(TypedDict, total=True): # pylint: disable=too-many-ancestors

version: int | str
remotes: NotRequired[Sequence[RemoteDict | Remote]]
projects: Sequence[
ProjectEntryDict | ProjectEntry | dict[str, str | list[str] | dict[str, str]]
projects: NotRequired[
Sequence[
ProjectEntryDict
| ProjectEntry
| dict[str, str | list[str] | dict[str, str]]
]
]


Expand Down Expand Up @@ -179,7 +183,7 @@ def __init__(
"""Create the manifest."""
manifest_data = self._initialize_basic_attributes(doc, path)
remotes_raw = manifest_data.get("remotes", [])
projects_raw = manifest_data["projects"]
projects_raw = manifest_data.get("projects", [])
self._validate_manifest_data(remotes_raw, projects_raw)
self._setup_default_remote(remotes_raw)
# Re-apply quoting to scalars whose style was stripped by strictyaml.
Expand Down Expand Up @@ -355,8 +359,10 @@ def version(self) -> str:
@property
def projects(self) -> Sequence[ProjectEntry]:
"""Get a list of Projects from the manifest."""
projects_mu = self._doc["manifest"]["projects"].as_marked_up()
return list(self._build_projects(projects_mu).values())
manifest_mu = self._doc["manifest"].as_marked_up()
if "projects" not in manifest_mu:
return []
return list(self._build_projects(manifest_mu["projects"]).values())

@staticmethod
def _filter_projects(
Expand Down Expand Up @@ -385,14 +391,18 @@ def selected_projects(self, names: Sequence[str]) -> Sequence[ProjectEntry]:

def _find_doc_project(self, name: str) -> Any | None:
"""Return the raw YAML mapping for the project with *name*, or None."""
for project in self._doc["manifest"]["projects"].as_marked_up():
manifest_mu = self._doc["manifest"].as_marked_up()
for project in manifest_mu.get("projects", []):
if project["name"] == name:
return project
return None

def remove(self, project_name: str) -> None:
"""Remove a project from the manifest."""
doc_projects = self._doc["manifest"]["projects"].as_marked_up()
manifest_mu = self._doc["manifest"].as_marked_up()
doc_projects = manifest_mu.get("projects")
if doc_projects is None:
raise RequestedProjectNotFoundError([project_name], [])
names = [p["name"] for p in doc_projects]
try:
del doc_projects[names.index(project_name)]
Expand Down Expand Up @@ -469,7 +479,10 @@ def append_project_entry(self, project_entry: "ProjectEntry") -> None:
document (2-space indent under ``projects:``). Call
:meth:`dump` afterwards to persist the change to disk.
"""
projects_mu = self._doc["manifest"]["projects"].as_marked_up()
manifest_mu = self._doc["manifest"].as_marked_up()
if "projects" not in manifest_mu:
manifest_mu["projects"] = CommentedSeq()
projects_mu = manifest_mu["projects"]
projects_mu.append(CommentedMap(project_entry.as_yaml()))
idx = len(projects_mu) - 1
projects_mu.ca.items[idx] = [
Expand All @@ -483,12 +496,10 @@ def update_project_version(self, project: ProjectEntry) -> None:
"""Update a project's version in the manifest in-place, preserving layout, comments, and line endings."""
p = self._find_doc_project(project.name)
if p is None:
manifest_mu = self._doc["manifest"].as_marked_up()
raise RequestedProjectNotFoundError(
[project.name],
[
proj["name"]
for proj in self._doc["manifest"]["projects"].as_marked_up()
],
[proj["name"] for proj in manifest_mu.get("projects", [])],
)
mu = p
insert_pos = 1 # right after 'name:' for any newly added key
Expand Down
2 changes: 1 addition & 1 deletion dfetch/manifest/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
{
"version": VERSION,
Optional("remotes"): Seq(REMOTE_SCHEMA),
"projects": Seq(PROJECT_SCHEMA),
Optional("projects"): Seq(PROJECT_SCHEMA),
}
)
}
Expand Down
10 changes: 10 additions & 0 deletions doc/howto/adding-a-project.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ There are three ways to add a new dependency to your manifest — edit it
directly, use the ``dfetch add`` command, or use the interactive wizard
``dfetch add -i``.

If you are starting from scratch, create a minimal manifest with just a
version and no projects yet:

.. code-block:: yaml

manifest:
version: '0.0'

You can then use ``dfetch add`` or ``dfetch add -i`` to populate it.

- :ref:`adding-manifest` — write the entry by hand for full control
- :ref:`adding-add` — one command, no prompts
- :ref:`adding-interactive` — guided wizard with branch/tag browsing
Expand Down
12 changes: 12 additions & 0 deletions features/check-git-repo.feature
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,15 @@ Feature: Checking dependencies from a git repository
Dfetch (0.13.0)
>>>git ls-remote --heads --tags git@github.com:dfetch-org/test-repo-private.git<<< returned 128:
"""

Scenario: Check with empty manifest does nothing
Given the manifest 'dfetch.yaml'
"""
manifest:
version: '0.0'
"""
When I run "dfetch check"
Then the output shows
"""
Dfetch (0.13.0)
"""
12 changes: 12 additions & 0 deletions features/fetch-git-repo.feature
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,15 @@ Feature: Fetching dependencies from a git repository
ext/test-repo-tag:
> Fetched v1
"""

Scenario: Update with empty manifest does nothing
Given the manifest 'dfetch.yaml'
"""
manifest:
version: '0.0'
"""
When I run "dfetch update"
Then the output shows
"""
Dfetch (0.13.0)
"""
18 changes: 18 additions & 0 deletions features/freeze-projects.feature
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,21 @@ Feature: Freeze dependencies
url: svn://svn.code.sf.net/p/cunit/code

"""

Scenario: Freeze with empty manifest does nothing
Given the manifest 'dfetch.yaml'
"""
manifest:
version: '0.0'
"""
When I run "dfetch freeze"
Then the output shows
"""
Dfetch (0.13.0)
"""
And the manifest 'dfetch.yaml' is replaced with
"""
manifest:
version: '0.0'

"""
27 changes: 27 additions & 0 deletions features/interactive-add.feature
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,33 @@ Feature: Add a project interactively via the CLI
"""
And the manifest 'dfetch.yaml' does not contain 'src:'

Scenario: Interactive add works on an empty manifest
Given the manifest 'dfetch.yaml'
"""
manifest:
version: '0.0'
"""
When I run "dfetch add -i some-remote-server/MyLib.git" with inputs
| Question | Answer |
| Project name | my-lib |
| Destination path | my-lib |
| Version | master |
| Source path | |
| Ignore paths | |
| Add project to manifest? | y |
| Run update | n |
Then the manifest 'dfetch.yaml' is replaced with
"""
manifest:
version: '0.0'
projects:

- name: my-lib
url: some-remote-server/MyLib.git
branch: master

"""

Scenario: Interactive add with pre-filled fields skips those prompts
Given the manifest 'dfetch.yaml'
"""
Expand Down
20 changes: 20 additions & 0 deletions features/report-sbom.feature
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,23 @@ Feature: Create an CycloneDX sbom
]
}
"""

Scenario: SBOM report on empty manifest produces a valid file with no components
Given the manifest 'dfetch.yaml'
"""
manifest:
version: '0.0'
"""
When I run "dfetch report -t sbom"
Then the output shows
"""
Dfetch (0.13.0)
Generated SBoM report: report.cdx.json
"""
And the 'report.cdx.json' json file includes
"""
{
"bomFormat": "CycloneDX",
"specVersion": "1.6"
}
"""
13 changes: 13 additions & 0 deletions features/validate-manifest.feature
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ Feature: Validate a manifest
dfetch.yaml : valid
"""

Scenario: An empty manifest with no projects is valid
Given the manifest 'dfetch.yaml'
"""
manifest:
version: '0.0'
"""
When I run "dfetch validate"
Then the output shows
"""
Dfetch (0.13.0)
dfetch.yaml : valid
"""

Scenario: An invalid manifest is provided
Given the manifest 'dfetch.yaml'
"""
Expand Down
95 changes: 91 additions & 4 deletions tests/test_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,9 @@ def test_can_read_version() -> None:


def test_no_projects() -> None:
"""Test that manifest without projects cannot be read."""

with pytest.raises(RuntimeError):
given_manifest_from_text(MANIFEST_NO_PROJECTS)
"""Test that manifest without projects key is valid and yields an empty list."""
manifest = given_manifest_from_text(MANIFEST_NO_PROJECTS)
assert list(manifest.projects) == []


def test_no_remotes() -> None:
Expand Down Expand Up @@ -407,6 +406,94 @@ def test_remove_last_project_updates_manifest_with_empty_list() -> None:
assert not manifest.projects


# ---------------------------------------------------------------------------
# Empty manifest (no projects key)
# ---------------------------------------------------------------------------

_EMPTY_MANIFEST = "manifest:\n version: '0.0'\n"
_EMPTY_MANIFEST_WITH_REMOTE = (
"manifest:\n"
" version: '0.0'\n"
" remotes:\n"
" - name: my-remote\n"
" url-base: http://www.myremote.com/\n"
)


def test_append_to_empty_manifest_creates_projects_section() -> None:
"""append_project_entry must create the projects key when it is absent."""
manifest = Manifest.from_yaml(_EMPTY_MANIFEST)
new_project = _make_project("mylib", url="https://example.com/mylib")

manifest.append_project_entry(new_project)

names = [p.name for p in manifest.projects]
assert names == ["mylib"]


def test_append_to_empty_manifest_dump_contains_projects() -> None:
"""dump() after append on an empty manifest must include the projects section."""
manifest = Manifest.from_yaml(_EMPTY_MANIFEST)
new_project = _make_project("mylib", url="https://example.com/mylib")
manifest.append_project_entry(new_project)

m = mock_open()
with patch("builtins.open", m):
manifest.dump("/tmp/test.yaml")
written = "".join(call.args[0] for call in m().write.call_args_list)

assert "projects:" in written
assert "name: mylib" in written
assert "url: https://example.com/mylib" in written


def test_remove_from_empty_manifest_raises() -> None:
"""remove() on a manifest with no projects key must raise RequestedProjectNotFoundError."""
manifest = Manifest.from_yaml(_EMPTY_MANIFEST)
with pytest.raises(RequestedProjectNotFoundError):
manifest.remove("anything")


def test_selected_projects_empty_names_on_empty_manifest() -> None:
"""selected_projects([]) on an empty manifest must return an empty list."""
manifest = Manifest.from_yaml(_EMPTY_MANIFEST)
assert list(manifest.selected_projects([])) == []


def test_selected_projects_with_name_on_empty_manifest_raises() -> None:
"""selected_projects with a name on an empty manifest must raise RequestedProjectNotFoundError."""
manifest = Manifest.from_yaml(_EMPTY_MANIFEST)
with pytest.raises(RequestedProjectNotFoundError):
manifest.selected_projects(["foo"])


def test_check_name_uniqueness_on_empty_manifest_does_not_raise() -> None:
"""check_name_uniqueness must not raise when the manifest has no projects at all."""
manifest = Manifest.from_yaml(_EMPTY_MANIFEST)
manifest.check_name_uniqueness("anything") # must not raise


def test_guess_destination_on_empty_manifest_returns_empty() -> None:
"""guess_destination must return an empty string when there are no existing projects."""
manifest = Manifest.from_yaml(_EMPTY_MANIFEST)
assert manifest.guess_destination("newlib") == ""


def test_update_project_version_on_empty_manifest_raises() -> None:
"""update_project_version must raise cleanly (not KeyError) on an empty manifest."""
manifest = Manifest.from_yaml(_EMPTY_MANIFEST)
project = _make_project("ghost", branch="main")
with pytest.raises(RequestedProjectNotFoundError):
manifest.update_project_version(project)


def test_empty_manifest_with_remote_has_no_projects() -> None:
"""A manifest with remotes but no projects key is valid and returns empty project list."""
manifest = Manifest.from_yaml(_EMPTY_MANIFEST_WITH_REMOTE)
assert list(manifest.projects) == []
assert len(manifest.remotes) == 1


# ---------------------------------------------------------------------------
# Version field: always serialised as a quoted string
# ---------------------------------------------------------------------------
Expand Down
Loading