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 @@ -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