Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 @@ -6,6 +6,7 @@ Release 0.12.0 (unreleased)
* Show line number when manifest validation fails (#36)
* Add Fuzzing (#819)
* Don't allow NULL or control characters in manifest (#114)
* Allow multiple patches in manifest (#897)

Release 0.11.0 (released 2026-01-03)
====================================
Expand Down
4 changes: 3 additions & 1 deletion dfetch.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,6 @@ manifest:
repo-path: divi255/sphinxcontrib.asciinema.git
dst: doc/_ext/sphinxcontrib_asciinema
src: sphinxcontrib/asciinema
patch: doc/_ext/sphinxcontrib_asciinema.patch
patch:
- doc/_ext/patches/001-autoformat-sphinxcontrib.asciinema.patch
- doc/_ext/patches/002-fix-options-sphinxcontrib.asciinema.patch
2 changes: 1 addition & 1 deletion dfetch/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def run(argv: Sequence[str]) -> None:

try:
args.func(args)
except RuntimeError as exc:
except (RuntimeError, TypeError) as exc:
for msg in exc.args:
logger.error(msg, stack_info=False)
raise DfetchFatalException from exc
Expand Down
21 changes: 16 additions & 5 deletions dfetch/manifest/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ class ManifestDict(TypedDict, total=True): # pylint: disable=too-many-ancestors

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


class Manifest:
Expand Down Expand Up @@ -138,12 +140,17 @@ def __init__(
self._projects = self._init_projects(manifest["projects"])

def _init_projects(
self, projects: Sequence[Union[ProjectEntryDict, ProjectEntry, dict[str, str]]]
self,
projects: Sequence[
Union[ProjectEntryDict, ProjectEntry, dict[str, Union[str, list[str]]]]
],
) -> dict[str, ProjectEntry]:
"""Iterate over projects from manifest and initialize ProjectEntries from it.

Args:
projects (Sequence[Union[ProjectEntryDict, ProjectEntry, Dict[str, str]]]): Iterable with projects
projects (Sequence[
Union[ProjectEntryDict, ProjectEntry, Dict[str, Union[str, list[str]]]]
]): Iterable with projects

Raises:
RuntimeError: Project unknown
Expand All @@ -157,6 +164,10 @@ def _init_projects(
if isinstance(project, dict):
if "name" not in project:
raise KeyError("Missing name!")
if not isinstance(project["name"], str):
raise TypeError(
f"Project name must be a string, got {type(project['name']).__name__}"
)
last_project = _projects[project["name"]] = ProjectEntry.from_yaml(
project, self._default_remote_name
)
Expand Down Expand Up @@ -295,9 +306,9 @@ def _as_dict(self) -> dict[str, ManifestDict]:
if len(remotes) == 1:
remotes[0].pop("default", None)

projects: list[dict[str, str]] = []
projects: list[dict[str, Union[str, list[str]]]] = []
for project in self.projects:
project_yaml: dict[str, str] = project.as_yaml()
project_yaml: dict[str, Union[str, list[str]]] = project.as_yaml()
if len(remotes) == 1:
project_yaml.pop("remote", None)
projects.append(project_yaml)
Expand Down
17 changes: 9 additions & 8 deletions dfetch/manifest/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@

from dfetch.manifest.remote import Remote
from dfetch.manifest.version import Version
from dfetch.util.util import always_str_list, str_if_possible

ProjectEntryDict = TypedDict(
"ProjectEntryDict",
Expand All @@ -288,7 +289,7 @@
"src": str,
"dst": str,
"url": str,
"patch": str,
"patch": Union[str, list[str]],
"repo": str,
"branch": str,
"tag": str,
Expand Down Expand Up @@ -316,7 +317,7 @@ def __init__(self, kwargs: ProjectEntryDict) -> None:
self._src: str = kwargs.get("src", "") # noqa
self._dst: str = kwargs.get("dst", self._name)
self._url: str = kwargs.get("url", "")
self._patch: str = kwargs.get("patch", "") # noqa
self._patch: list[str] = always_str_list(kwargs.get("patch", []))
self._repo_path: str = kwargs.get("repo-path", "")
self._branch: str = kwargs.get("branch", "")
self._tag: str = kwargs.get("tag", "")
Expand All @@ -329,7 +330,7 @@ def __init__(self, kwargs: ProjectEntryDict) -> None:
@classmethod
def from_yaml(
cls,
yamldata: Union[dict[str, str], ProjectEntryDict],
yamldata: Union[dict[str, Union[str, list[str]]], ProjectEntryDict],
default_remote: str = "",
) -> "ProjectEntry":
"""Create a Project Entry from yaml data.
Expand Down Expand Up @@ -409,8 +410,8 @@ def destination(self) -> str:
return self._dst

@property
def patch(self) -> str:
"""Get the patch that should be applied."""
def patch(self) -> list[str]:
"""Get the patches that should be applied."""
return self._patch

@property
Expand Down Expand Up @@ -451,14 +452,14 @@ def as_recommendation(self) -> "ProjectEntry":
"""Get a copy that can be used as recommendation."""
recommendation = self.copy(self)
recommendation._dst = "" # pylint: disable=protected-access
recommendation._patch = "" # pylint: disable=protected-access
recommendation._patch = [] # pylint: disable=protected-access
recommendation._url = self.remote_url # pylint: disable=protected-access
recommendation._remote = "" # pylint: disable=protected-access
recommendation._remote_obj = None # pylint: disable=protected-access
recommendation._repo_path = "" # pylint: disable=protected-access
return recommendation

def as_yaml(self) -> dict[str, str]:
def as_yaml(self) -> dict[str, Union[str, list[str]]]:
"""Get this project as yaml dictionary."""
yamldata = {
"name": self._name,
Expand All @@ -467,7 +468,7 @@ def as_yaml(self) -> dict[str, str]:
"src": self._src,
"dst": self._dst if self._dst != self._name else None,
"url": self._url,
"patch": self._patch,
"patch": str_if_possible(self._patch),
"branch": self._branch,
"tag": self._tag,
"repo-path": self._repo_path,
Expand Down
2 changes: 1 addition & 1 deletion dfetch/manifest/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
Optional("url"): SAFE_STR,
Optional("repo-path"): SAFE_STR,
Optional("remote"): SAFE_STR,
Optional("patch"): SAFE_STR,
Optional("patch"): SAFE_STR | Seq(SAFE_STR),
Optional("vcs"): Enum(["git", "svn"]),
Optional("src"): SAFE_STR,
Optional("ignore"): Seq(SAFE_STR),
Expand Down
18 changes: 12 additions & 6 deletions dfetch/project/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

import datetime
import os
from typing import Optional, Union

import yaml
from typing_extensions import TypedDict

from dfetch.manifest.project import ProjectEntry
from dfetch.manifest.version import Version
from dfetch.util.util import always_str_list, str_if_possible

DONT_EDIT_WARNING = """\
# This is a generated file by dfetch. Don't edit this, but edit the manifest.
Expand All @@ -25,7 +27,7 @@ class Options(TypedDict): # pylint: disable=too-many-ancestors
remote_url: str
destination: str
hash: str
patch: str
patch: Union[str, list[str]]


class Metadata:
Expand All @@ -49,7 +51,9 @@ def __init__(self, kwargs: Options) -> None:
self._remote_url: str = str(kwargs.get("remote_url", ""))
self._destination: str = str(kwargs.get("destination", ""))
self._hash: str = str(kwargs.get("hash", ""))
self._patch: str = str(kwargs.get("patch", ""))

# Historically only a single patch was allowed
self._patch: list[str] = always_str_list(kwargs.get("patch", []))

@classmethod
def from_project_entry(cls, project: ProjectEntry) -> "Metadata":
Expand All @@ -73,12 +77,14 @@ def from_file(cls, path: str) -> "Metadata":
data: Options = yaml.safe_load(metadata_file)["dfetch"]
return cls(data)

def fetched(self, version: Version, hash_: str = "", patch_: str = "") -> None:
def fetched(
self, version: Version, hash_: str = "", patch_: Optional[list[str]] = None
) -> None:
"""Update metadata."""
self._last_fetch = datetime.datetime.now()
self._version = version
self._hash = hash_
self._patch = patch_
self._patch = patch_ or []

@property
def version(self) -> Version:
Expand Down Expand Up @@ -120,7 +126,7 @@ def hash(self) -> str:
return self._hash

@property
def patch(self) -> str:
def patch(self) -> list[str]:
"""The applied patch as stored in the metadata."""
return self._patch

Expand Down Expand Up @@ -160,7 +166,7 @@ def dump(self) -> None:
"last_fetch": self.last_fetch_string(),
"tag": self._version.tag,
"hash": self.hash,
"patch": self.patch,
"patch": str_if_possible(self.patch),
}
}

Expand Down
24 changes: 12 additions & 12 deletions dfetch/project/subproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,33 +128,33 @@ def update(
actually_fetched = self._fetch_impl(to_fetch)
self._log_project(f"Fetched {actually_fetched}")

applied_patch = ""
if self.__project.patch:
if os.path.exists(self.__project.patch):
self.apply_patch()
applied_patch = self.__project.patch
applied_patches = []
for patch in self.__project.patch:
if os.path.exists(patch):
self.apply_patch(patch)
applied_patches.append(patch)
else:
logger.warning(f"Skipping non-existent patch {self.__project.patch}")
logger.warning(f"Skipping non-existent patch {patch}")

self.__metadata.fetched(
actually_fetched,
hash_=hash_directory(self.local_path, skiplist=[self.__metadata.FILENAME]),
patch_=applied_patch,
patch_=applied_patches,
)

logger.debug(f"Writing repo metadata to: {self.__metadata.path}")
self.__metadata.dump()

def apply_patch(self) -> None:
def apply_patch(self, patch: str) -> None:
"""Apply the specified patch to the destination."""
patch_set = fromfile(self.__project.patch)
patch_set = fromfile(patch)

if not patch_set:
raise RuntimeError(f'Invalid patch file: "{self.__project.patch}"')
raise RuntimeError(f'Invalid patch file: "{patch}"')
if patch_set.apply(0, root=self.local_path, fuzz=True):
self._log_project(f'Applied patch "{self.__project.patch}"')
self._log_project(f'Applied patch "{patch}"')
else:
raise RuntimeError(f'Applying patch "{self.__project.patch}" failed')
raise RuntimeError(f'Applying patch "{patch}" failed')

def check_for_update(
self, reporters: Sequence[AbstractCheckReporter], files_to_ignore: Sequence[str]
Expand Down
2 changes: 1 addition & 1 deletion dfetch/reporting/stdout_reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def add_project(
logger.print_info_field(" tag", metadata.tag)
logger.print_info_field(" last fetch", str(metadata.last_fetch))
logger.print_info_field(" revision", metadata.revision)
logger.print_info_field(" patch", metadata.patch)
logger.print_info_field(" patch", ", ".join(metadata.patch))
logger.print_info_field(
" licenses", ",".join(license.name for license in licenses)
)
Expand Down
25 changes: 25 additions & 0 deletions dfetch/util/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,28 @@ def hash_file(file_path: str, digest: HASH) -> HASH:
buf = f_obj.read(1024 * 1024)

return digest


def always_str_list(data: Union[str, list[str]]) -> list[str]:
"""Convert a string or list of strings into a list of strings.

Args:
data: A string or list of strings.

Returns:
A list of strings. Empty strings are converted to empty lists.
"""
return data if not isinstance(data, str) else [data] if data else []


def str_if_possible(data: list[str]) -> Union[str, list[str]]:
"""Convert a single-element list to a string, otherwise keep as list.

Args:
data: A list of strings.

Returns:
A single string if the list has exactly one element, an empty string
if the list is empty, otherwise the original list.
"""
return "" if not data else data[0] if len(data) == 1 else data
Loading
Loading