diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 474b0b612..8d34db77a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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) ==================================== diff --git a/dfetch/manifest/manifest.py b/dfetch/manifest/manifest.py index 96ae69721..e98bde027 100644 --- a/dfetch/manifest/manifest.py +++ b/dfetch/manifest/manifest.py @@ -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 @@ -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]] + ] ] @@ -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. @@ -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( @@ -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)] @@ -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] = [ @@ -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 diff --git a/dfetch/manifest/schema.py b/dfetch/manifest/schema.py index 2967988cc..5af4d3fb7 100644 --- a/dfetch/manifest/schema.py +++ b/dfetch/manifest/schema.py @@ -57,7 +57,7 @@ { "version": VERSION, Optional("remotes"): Seq(REMOTE_SCHEMA), - "projects": Seq(PROJECT_SCHEMA), + Optional("projects"): Seq(PROJECT_SCHEMA), } ) } diff --git a/doc/howto/adding-a-project.rst b/doc/howto/adding-a-project.rst index 15503a1d7..09432c57a 100644 --- a/doc/howto/adding-a-project.rst +++ b/doc/howto/adding-a-project.rst @@ -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 diff --git a/features/check-git-repo.feature b/features/check-git-repo.feature index eb55deaef..e74086839 100644 --- a/features/check-git-repo.feature +++ b/features/check-git-repo.feature @@ -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) + """ diff --git a/features/fetch-git-repo.feature b/features/fetch-git-repo.feature index 0cb99d3b6..2d3846fb0 100644 --- a/features/fetch-git-repo.feature +++ b/features/fetch-git-repo.feature @@ -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) + """ diff --git a/features/freeze-projects.feature b/features/freeze-projects.feature index 7ce5844c0..9d1477a05 100644 --- a/features/freeze-projects.feature +++ b/features/freeze-projects.feature @@ -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' + + """ diff --git a/features/interactive-add.feature b/features/interactive-add.feature index afd93b865..6fe92e538 100644 --- a/features/interactive-add.feature +++ b/features/interactive-add.feature @@ -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' """ diff --git a/features/report-sbom.feature b/features/report-sbom.feature index 4839c2f40..9a1a4ce88 100644 --- a/features/report-sbom.feature +++ b/features/report-sbom.feature @@ -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" + } + """ diff --git a/features/validate-manifest.feature b/features/validate-manifest.feature index 85de37ab2..4c36d1bf2 100644 --- a/features/validate-manifest.feature +++ b/features/validate-manifest.feature @@ -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' """ diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 2e6642039..42d321259 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -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: @@ -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 # ---------------------------------------------------------------------------