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 @@ -13,6 +13,7 @@ Release 0.14.0 (unreleased)
* Add new ``remove`` command to remove projects from manifest and disk (#26)
* Fix "unsafe symlink target" error for archives containing relative ``..`` symlinks (#1122)
* Fix ``dfetch add`` crashing with a ``ValueError`` when the remote URL has a trailing slash (#1137)
* Fix unhelpful error message when a metadata file is malformed (#1145)

Release 0.13.0 (released 2026-03-30)
====================================
Expand Down
4 changes: 2 additions & 2 deletions dfetch/commands/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
from dfetch.log import get_logger
from dfetch.manifest.project import ProjectEntry
from dfetch.project import create_super_project
from dfetch.project.metadata import Metadata
from dfetch.project.metadata import InvalidMetadataError, Metadata
from dfetch.reporting import REPORTERS, ReportTypes
from dfetch.util.license import (
LicenseScanResult,
Expand Down Expand Up @@ -171,6 +171,6 @@ def _determine_version(project: ProjectEntry) -> str:
or project.hash
or ""
)
except FileNotFoundError:
except (FileNotFoundError, InvalidMetadataError):
version = project.tag or project.revision or project.hash or ""
return version
16 changes: 15 additions & 1 deletion dfetch/project/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
"""


class InvalidMetadataError(Exception):
"""Raised when a metadata file exists but cannot be parsed."""


class Dependency(TypedDict):
"""Argument types for dependency class construction."""

Expand Down Expand Up @@ -88,7 +92,17 @@ def from_project_entry(cls, project: ProjectEntry) -> "Metadata":
def from_file(cls, path: str) -> "Metadata":
"""Load metadata file."""
with open(path, encoding="utf-8") as metadata_file:
data: Options = yaml.safe_load(metadata_file)["dfetch"]
try:
data: Options = yaml.safe_load(metadata_file)["dfetch"]
except yaml.YAMLError as exc:
raise InvalidMetadataError(str(exc)) from exc
except (KeyError, TypeError) as exc:
raise InvalidMetadataError(str(exc)) from exc

if not isinstance(data, dict):
raise InvalidMetadataError(
f"Expected a mapping under 'dfetch', got {type(data).__name__}"
)
return cls(data)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def fetched(
Expand Down
6 changes: 3 additions & 3 deletions dfetch/project/subproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from dfetch.manifest.project import ProjectEntry
from dfetch.manifest.version import Version
from dfetch.project.abstract_check_reporter import AbstractCheckReporter
from dfetch.project.metadata import Dependency, Metadata
from dfetch.project.metadata import Dependency, InvalidMetadataError, Metadata
from dfetch.util.util import hash_directory, safe_rm
from dfetch.util.versions import latest_tag_from_list
from dfetch.vcs.patch import Patch
Expand Down Expand Up @@ -348,7 +348,7 @@ def on_disk_version(self) -> Version | None:

try:
return Metadata.from_file(self.__metadata.path).version
except TypeError:
except InvalidMetadataError:
logger.print_warning_line(
self.__project.name,
f"{pathlib.Path(self.__metadata.path).relative_to(os.getcwd()).as_posix()}"
Expand All @@ -367,7 +367,7 @@ def _on_disk_hash(self) -> str | None:

try:
return Metadata.from_file(self.__metadata.path).hash
except TypeError:
except InvalidMetadataError:
logger.print_warning_line(
self.__project.name,
f"{pathlib.Path(self.__metadata.path).relative_to(os.getcwd()).as_posix()}"
Expand Down
4 changes: 2 additions & 2 deletions dfetch/reporting/stdout_reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@

from dfetch.log import get_logger
from dfetch.manifest.project import ProjectEntry
from dfetch.project.metadata import Metadata
from dfetch.project.metadata import InvalidMetadataError, Metadata
from dfetch.reporting.reporter import Reporter
from dfetch.util.license import LicenseScanResult

Expand Down Expand Up @@ -119,7 +119,7 @@ def add_project(
)
logger.info("")

except FileNotFoundError:
except (FileNotFoundError, InvalidMetadataError):
logger.print_info_field(" last fetch", "never")

def dump_to_file(self, outfile: str) -> bool:
Expand Down
51 changes: 50 additions & 1 deletion features/handle-invalid-metadata.feature
Original file line number Diff line number Diff line change
@@ -1,11 +1,60 @@
@update
Feature: Handle invalid metadata files

*Dfetch* will keep metadata about the fetched project locally to prevent re-fetching unchanged projects
or replacing locally changed projects. Sometimes the metadata file will become incorrect and this should not lead
to unpredictable behavior in *DFetch*.
For instance, the metadata may be invalid

@update
Scenario: Metadata with invalid YAML syntax is treated as invalid
Given the manifest 'dfetch.yaml'
"""
manifest:
version: '0.0'

projects:
- name: ext/test-repo-tag
url: https://github.com/dfetch-org/test-repo
tag: v1

"""
And all projects are updated
And the metadata file ".dfetch_data.yaml" of "ext/test-repo-tag" has invalid yaml
When I run "dfetch update"
Then the output shows
"""
Dfetch (0.13.0)
ext/test-repo-tag:
> ext/test-repo-tag/.dfetch_data.yaml is an invalid metadata file, not checking on disk version!
> ext/test-repo-tag/.dfetch_data.yaml is an invalid metadata file, not checking local hash!
> Fetched v1
"""
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@check
Scenario: Metadata with invalid YAML syntax is treated as invalid during check
Given the manifest 'dfetch.yaml'
"""
manifest:
version: '0.0'

projects:
- name: ext/test-repo-tag
url: https://github.com/dfetch-org/test-repo
tag: v1

"""
And all projects are updated
And the metadata file ".dfetch_data.yaml" of "ext/test-repo-tag" has invalid yaml
When I run "dfetch check"
Then the output shows
"""
Dfetch (0.13.0)
ext/test-repo-tag:
> ext/test-repo-tag/.dfetch_data.yaml is an invalid metadata file, not checking on disk version!
> wanted (v1), available (v2.0)
"""

@update
Scenario: Invalid metadata is ignored
Given the manifest 'dfetch.yaml'
"""
Expand Down
8 changes: 8 additions & 0 deletions features/steps/generic_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,14 @@ def step_impl(_, metadata_file, project_path):
)


@given('the metadata file "{metadata_file}" of "{project_path}" has invalid yaml')
def step_impl(_, metadata_file, project_path):
generate_file(
os.path.join(os.getcwd(), project_path, metadata_file),
"key: [unclosed bracket\n",
)


@given('the metadata file "{metadata_file}" of "{project_path}" is changed')
def step_impl(_, metadata_file, project_path):
extend_file(
Expand Down
102 changes: 102 additions & 0 deletions tests/test_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Test metadata loading."""

# mypy: ignore-errors
# flake8: noqa

import textwrap

import pytest

from dfetch.project.metadata import InvalidMetadataError, Metadata

VALID_METADATA = """\
dfetch:
remote_url: https://example.com/repo
branch: main
tag: ''
revision: abc123
last_fetch: 01/01/2000, 00:00:00
hash: deadbeef
patch: ''
"""


def _write_metadata(tmp_path, content):
path = tmp_path / ".dfetch_data.yaml"
path.write_text(textwrap.dedent(content))
return str(path)


def test_from_file_raises_when_dfetch_value_is_a_scalar(tmp_path):
"""A scalar under 'dfetch:' must raise InvalidMetadataError, not AttributeError."""
path = _write_metadata(tmp_path, "dfetch: just-a-string\n")
with pytest.raises(InvalidMetadataError):
Metadata.from_file(path)


def test_from_file_raises_when_dfetch_value_is_a_list(tmp_path):
"""A list under 'dfetch:' must raise InvalidMetadataError, not AttributeError."""
path = _write_metadata(tmp_path, "dfetch:\n - item1\n - item2\n")
with pytest.raises(InvalidMetadataError):
Metadata.from_file(path)


def test_from_file_raises_when_dfetch_value_is_null(tmp_path):
"""A null under 'dfetch:' must raise InvalidMetadataError, not AttributeError."""
path = _write_metadata(tmp_path, "dfetch:\n")
with pytest.raises(InvalidMetadataError):
Metadata.from_file(path)


def test_from_file_raises_on_invalid_yaml_syntax(tmp_path):
"""Broken YAML syntax must raise InvalidMetadataError."""
path = _write_metadata(tmp_path, "dfetch: [unclosed bracket\n")
with pytest.raises(InvalidMetadataError):
Metadata.from_file(path)


def test_from_file_raises_when_dfetch_key_is_absent(tmp_path):
"""A file with no 'dfetch' top-level key must raise InvalidMetadataError."""
path = _write_metadata(tmp_path, "other_key: value\n")
with pytest.raises(InvalidMetadataError):
Metadata.from_file(path)


def test_from_file_loads_remote_url(tmp_path):
"""remote_url is read back correctly from a valid file."""
path = _write_metadata(tmp_path, VALID_METADATA)
meta = Metadata.from_file(path)
assert meta.remote_url == "https://example.com/repo"


def test_from_file_loads_branch(tmp_path):
"""branch is read back correctly from a valid file."""
path = _write_metadata(tmp_path, VALID_METADATA)
meta = Metadata.from_file(path)
assert meta.branch == "main"


def test_from_file_loads_revision(tmp_path):
"""revision is read back correctly from a valid file."""
path = _write_metadata(tmp_path, VALID_METADATA)
meta = Metadata.from_file(path)
assert meta.revision == "abc123"


def test_from_file_loads_hash(tmp_path):
"""hash is read back correctly from a valid file."""
path = _write_metadata(tmp_path, VALID_METADATA)
meta = Metadata.from_file(path)
assert meta.hash == "deadbeef"


def test_from_file_uses_defaults_for_missing_fields(tmp_path):
"""A minimal mapping with only the 'dfetch' key and no fields uses defaults."""
path = _write_metadata(tmp_path, "dfetch: {}\n")
meta = Metadata.from_file(path)
assert meta.remote_url == ""
assert meta.branch == ""
assert meta.revision == ""
assert meta.hash == ""
assert meta.patch == []
assert meta.dependencies == []
Loading