Skip to content

Commit ed44443

Browse files
committed
Allow empty manifest, fixes #1197
1 parent 2011364 commit ed44443

11 files changed

Lines changed: 229 additions & 18 deletions

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Release 0.14.0 (unreleased)
1616
* Fix unhelpful error message when a metadata file is malformed (#1145)
1717
* Fix arbitrary file write via malicious tar/zip symlink (#1152)
1818
* Prevent SSH command injection (#1152)
19+
* Allow manifests with no ``projects`` key so ``dfetch add`` can bootstrap empty manifest (#1197)
1920

2021
Release 0.13.0 (released 2026-03-30)
2122
====================================

dfetch/manifest/manifest.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929

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

151151
version: int | str
152152
remotes: NotRequired[Sequence[RemoteDict | Remote]]
153-
projects: Sequence[
154-
ProjectEntryDict | ProjectEntry | dict[str, str | list[str] | dict[str, str]]
153+
projects: NotRequired[
154+
Sequence[
155+
ProjectEntryDict
156+
| ProjectEntry
157+
| dict[str, str | list[str] | dict[str, str]]
158+
]
155159
]
156160

157161

@@ -179,7 +183,7 @@ def __init__(
179183
"""Create the manifest."""
180184
manifest_data = self._initialize_basic_attributes(doc, path)
181185
remotes_raw = manifest_data.get("remotes", [])
182-
projects_raw = manifest_data["projects"]
186+
projects_raw = manifest_data.get("projects", [])
183187
self._validate_manifest_data(remotes_raw, projects_raw)
184188
self._setup_default_remote(remotes_raw)
185189
# Re-apply quoting to scalars whose style was stripped by strictyaml.
@@ -355,8 +359,10 @@ def version(self) -> str:
355359
@property
356360
def projects(self) -> Sequence[ProjectEntry]:
357361
"""Get a list of Projects from the manifest."""
358-
projects_mu = self._doc["manifest"]["projects"].as_marked_up()
359-
return list(self._build_projects(projects_mu).values())
362+
manifest_mu = self._doc["manifest"].as_marked_up()
363+
if "projects" not in manifest_mu:
364+
return []
365+
return list(self._build_projects(manifest_mu["projects"]).values())
360366

361367
@staticmethod
362368
def _filter_projects(
@@ -385,14 +391,18 @@ def selected_projects(self, names: Sequence[str]) -> Sequence[ProjectEntry]:
385391

386392
def _find_doc_project(self, name: str) -> Any | None:
387393
"""Return the raw YAML mapping for the project with *name*, or None."""
388-
for project in self._doc["manifest"]["projects"].as_marked_up():
394+
manifest_mu = self._doc["manifest"].as_marked_up()
395+
for project in manifest_mu.get("projects", []):
389396
if project["name"] == name:
390397
return project
391398
return None
392399

393400
def remove(self, project_name: str) -> None:
394401
"""Remove a project from the manifest."""
395-
doc_projects = self._doc["manifest"]["projects"].as_marked_up()
402+
manifest_mu = self._doc["manifest"].as_marked_up()
403+
doc_projects = manifest_mu.get("projects")
404+
if doc_projects is None:
405+
raise RequestedProjectNotFoundError([project_name], [])
396406
names = [p["name"] for p in doc_projects]
397407
try:
398408
del doc_projects[names.index(project_name)]
@@ -469,7 +479,10 @@ def append_project_entry(self, project_entry: "ProjectEntry") -> None:
469479
document (2-space indent under ``projects:``). Call
470480
:meth:`dump` afterwards to persist the change to disk.
471481
"""
472-
projects_mu = self._doc["manifest"]["projects"].as_marked_up()
482+
manifest_mu = self._doc["manifest"].as_marked_up()
483+
if "projects" not in manifest_mu:
484+
manifest_mu["projects"] = CommentedSeq()
485+
projects_mu = manifest_mu["projects"]
473486
projects_mu.append(CommentedMap(project_entry.as_yaml()))
474487
idx = len(projects_mu) - 1
475488
projects_mu.ca.items[idx] = [
@@ -483,12 +496,10 @@ def update_project_version(self, project: ProjectEntry) -> None:
483496
"""Update a project's version in the manifest in-place, preserving layout, comments, and line endings."""
484497
p = self._find_doc_project(project.name)
485498
if p is None:
499+
manifest_mu = self._doc["manifest"].as_marked_up()
486500
raise RequestedProjectNotFoundError(
487501
[project.name],
488-
[
489-
proj["name"]
490-
for proj in self._doc["manifest"]["projects"].as_marked_up()
491-
],
502+
[proj["name"] for proj in manifest_mu.get("projects", [])],
492503
)
493504
mu = p
494505
insert_pos = 1 # right after 'name:' for any newly added key

dfetch/manifest/schema.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
{
5858
"version": VERSION,
5959
Optional("remotes"): Seq(REMOTE_SCHEMA),
60-
"projects": Seq(PROJECT_SCHEMA),
60+
Optional("projects"): Seq(PROJECT_SCHEMA),
6161
}
6262
)
6363
}

doc/howto/adding-a-project.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ There are three ways to add a new dependency to your manifest — edit it
88
directly, use the ``dfetch add`` command, or use the interactive wizard
99
``dfetch add -i``.
1010

11+
If you are starting from scratch, create a minimal manifest with just a
12+
version and no projects yet:
13+
14+
.. code-block:: yaml
15+
16+
manifest:
17+
version: '0.0'
18+
19+
You can then use ``dfetch add`` or ``dfetch add -i`` to populate it.
20+
1121
- :ref:`adding-manifest` — write the entry by hand for full control
1222
- :ref:`adding-add` — one command, no prompts
1323
- :ref:`adding-interactive` — guided wizard with branch/tag browsing

features/check-git-repo.feature

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,3 +251,15 @@ Feature: Checking dependencies from a git repository
251251
Dfetch (0.13.0)
252252
>>>git ls-remote --heads --tags git@github.com:dfetch-org/test-repo-private.git<<< returned 128:
253253
"""
254+
255+
Scenario: Check with empty manifest does nothing
256+
Given the manifest 'dfetch.yaml'
257+
"""
258+
manifest:
259+
version: '0.0'
260+
"""
261+
When I run "dfetch check"
262+
Then the output shows
263+
"""
264+
Dfetch (0.13.0)
265+
"""

features/fetch-git-repo.feature

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,15 @@ Feature: Fetching dependencies from a git repository
8989
ext/test-repo-tag:
9090
> Fetched v1
9191
"""
92+
93+
Scenario: Update with empty manifest does nothing
94+
Given the manifest 'dfetch.yaml'
95+
"""
96+
manifest:
97+
version: '0.0'
98+
"""
99+
When I run "dfetch update"
100+
Then the output shows
101+
"""
102+
Dfetch (0.13.0)
103+
"""

features/freeze-projects.feature

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,21 @@ Feature: Freeze dependencies
6262
url: svn://svn.code.sf.net/p/cunit/code
6363
6464
"""
65+
66+
Scenario: Freeze with empty manifest does nothing
67+
Given the manifest 'dfetch.yaml'
68+
"""
69+
manifest:
70+
version: '0.0'
71+
"""
72+
When I run "dfetch freeze"
73+
Then the output shows
74+
"""
75+
Dfetch (0.13.0)
76+
"""
77+
And the manifest 'dfetch.yaml' is replaced with
78+
"""
79+
manifest:
80+
version: '0.0'
81+
82+
"""

features/interactive-add.feature

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,33 @@ Feature: Add a project interactively via the CLI
195195
"""
196196
And the manifest 'dfetch.yaml' does not contain 'src:'
197197

198+
Scenario: Interactive add works on an empty manifest
199+
Given the manifest 'dfetch.yaml'
200+
"""
201+
manifest:
202+
version: '0.0'
203+
"""
204+
When I run "dfetch add -i some-remote-server/MyLib.git" with inputs
205+
| Question | Answer |
206+
| Project name | my-lib |
207+
| Destination path | my-lib |
208+
| Version | master |
209+
| Source path | |
210+
| Ignore paths | |
211+
| Add project to manifest? | y |
212+
| Run update | n |
213+
Then the manifest 'dfetch.yaml' is replaced with
214+
"""
215+
manifest:
216+
version: '0.0'
217+
projects:
218+
219+
- name: my-lib
220+
url: some-remote-server/MyLib.git
221+
branch: master
222+
223+
"""
224+
198225
Scenario: Interactive add with pre-filled fields skips those prompts
199226
Given the manifest 'dfetch.yaml'
200227
"""

features/report-sbom.feature

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,3 +267,23 @@ Feature: Create an CycloneDX sbom
267267
]
268268
}
269269
"""
270+
271+
Scenario: SBOM report on empty manifest produces a valid file with no components
272+
Given the manifest 'dfetch.yaml'
273+
"""
274+
manifest:
275+
version: '0.0'
276+
"""
277+
When I run "dfetch report -t sbom"
278+
Then the output shows
279+
"""
280+
Dfetch (0.13.0)
281+
Generated SBoM report: report.cdx.json
282+
"""
283+
And the 'report.cdx.json' json file includes
284+
"""
285+
{
286+
"bomFormat": "CycloneDX",
287+
"specVersion": "1.6"
288+
}
289+
"""

features/validate-manifest.feature

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,19 @@ Feature: Validate a manifest
2525
dfetch.yaml : valid
2626
"""
2727

28+
Scenario: An empty manifest with no projects is valid
29+
Given the manifest 'dfetch.yaml'
30+
"""
31+
manifest:
32+
version: '0.0'
33+
"""
34+
When I run "dfetch validate"
35+
Then the output shows
36+
"""
37+
Dfetch (0.13.0)
38+
dfetch.yaml : valid
39+
"""
40+
2841
Scenario: An invalid manifest is provided
2942
Given the manifest 'dfetch.yaml'
3043
"""

0 commit comments

Comments
 (0)