Skip to content

Commit 6f67bf0

Browse files
spoorccben-edna
authored andcommitted
Allow multiple patches in manifest
(Fixes #897)
1 parent 9fce550 commit 6f67bf0

14 files changed

Lines changed: 154 additions & 45 deletions

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Release 0.12.0 (unreleased)
66
* Show line number when manifest validation fails (#36)
77
* Add Fuzzing (#819)
88
* Don't allow NULL or control characters in manifest (#114)
9+
* Allow multiple patches in manifest (#897)
910

1011
Release 0.11.0 (released 2026-01-03)
1112
====================================

dfetch/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def run(argv: Sequence[str]) -> None:
6565

6666
try:
6767
args.func(args)
68-
except RuntimeError as exc:
68+
except (RuntimeError, TypeError) as exc:
6969
for msg in exc.args:
7070
logger.error(msg, stack_info=False)
7171
raise DfetchFatalException from exc

dfetch/manifest/manifest.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,9 @@ class ManifestDict(TypedDict, total=True): # pylint: disable=too-many-ancestors
9797

9898
version: Union[int, str]
9999
remotes: NotRequired[Sequence[Union[RemoteDict, Remote]]]
100-
projects: Sequence[Union[ProjectEntryDict, ProjectEntry, dict[str, str]]]
100+
projects: Sequence[
101+
Union[ProjectEntryDict, ProjectEntry, dict[str, Union[str, list[str]]]]
102+
]
101103

102104

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

140142
def _init_projects(
141-
self, projects: Sequence[Union[ProjectEntryDict, ProjectEntry, dict[str, str]]]
143+
self,
144+
projects: Sequence[
145+
Union[ProjectEntryDict, ProjectEntry, dict[str, Union[str, list[str]]]]
146+
],
142147
) -> dict[str, ProjectEntry]:
143148
"""Iterate over projects from manifest and initialize ProjectEntries from it.
144149
145150
Args:
146-
projects (Sequence[Union[ProjectEntryDict, ProjectEntry, Dict[str, str]]]): Iterable with projects
151+
projects (Sequence[
152+
Union[ProjectEntryDict, ProjectEntry, Dict[str, Union[str, list[str]]]]
153+
]): Iterable with projects
147154
148155
Raises:
149156
RuntimeError: Project unknown
@@ -157,6 +164,10 @@ def _init_projects(
157164
if isinstance(project, dict):
158165
if "name" not in project:
159166
raise KeyError("Missing name!")
167+
if not isinstance(project["name"], str):
168+
raise TypeError(
169+
f"Project name must be a string, got {type(project['name']).__name__}"
170+
)
160171
last_project = _projects[project["name"]] = ProjectEntry.from_yaml(
161172
project, self._default_remote_name
162173
)
@@ -295,9 +306,9 @@ def _as_dict(self) -> dict[str, ManifestDict]:
295306
if len(remotes) == 1:
296307
remotes[0].pop("default", None)
297308

298-
projects: list[dict[str, str]] = []
309+
projects: list[dict[str, Union[str, list[str]]]] = []
299310
for project in self.projects:
300-
project_yaml: dict[str, str] = project.as_yaml()
311+
project_yaml: dict[str, Union[str, list[str]]] = project.as_yaml()
301312
if len(remotes) == 1:
302313
project_yaml.pop("remote", None)
303314
projects.append(project_yaml)

dfetch/manifest/project.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@
278278

279279
from dfetch.manifest.remote import Remote
280280
from dfetch.manifest.version import Version
281+
from dfetch.util.util import always_str_list
281282

282283
ProjectEntryDict = TypedDict(
283284
"ProjectEntryDict",
@@ -288,7 +289,7 @@
288289
"src": str,
289290
"dst": str,
290291
"url": str,
291-
"patch": str,
292+
"patch": Union[str, list[str]],
292293
"repo": str,
293294
"branch": str,
294295
"tag": str,
@@ -316,7 +317,7 @@ def __init__(self, kwargs: ProjectEntryDict) -> None:
316317
self._src: str = kwargs.get("src", "") # noqa
317318
self._dst: str = kwargs.get("dst", self._name)
318319
self._url: str = kwargs.get("url", "")
319-
self._patch: str = kwargs.get("patch", "") # noqa
320+
self._patch: list[str] = always_str_list(kwargs.get("patch", []))
320321
self._repo_path: str = kwargs.get("repo-path", "")
321322
self._branch: str = kwargs.get("branch", "")
322323
self._tag: str = kwargs.get("tag", "")
@@ -329,7 +330,7 @@ def __init__(self, kwargs: ProjectEntryDict) -> None:
329330
@classmethod
330331
def from_yaml(
331332
cls,
332-
yamldata: Union[dict[str, str], ProjectEntryDict],
333+
yamldata: Union[dict[str, Union[str, list[str]]], ProjectEntryDict],
333334
default_remote: str = "",
334335
) -> "ProjectEntry":
335336
"""Create a Project Entry from yaml data.
@@ -409,8 +410,8 @@ def destination(self) -> str:
409410
return self._dst
410411

411412
@property
412-
def patch(self) -> str:
413-
"""Get the patch that should be applied."""
413+
def patch(self) -> list[str]:
414+
"""Get the patches that should be applied."""
414415
return self._patch
415416

416417
@property
@@ -451,14 +452,14 @@ def as_recommendation(self) -> "ProjectEntry":
451452
"""Get a copy that can be used as recommendation."""
452453
recommendation = self.copy(self)
453454
recommendation._dst = "" # pylint: disable=protected-access
454-
recommendation._patch = "" # pylint: disable=protected-access
455+
recommendation._patch = [] # pylint: disable=protected-access
455456
recommendation._url = self.remote_url # pylint: disable=protected-access
456457
recommendation._remote = "" # pylint: disable=protected-access
457458
recommendation._remote_obj = None # pylint: disable=protected-access
458459
recommendation._repo_path = "" # pylint: disable=protected-access
459460
return recommendation
460461

461-
def as_yaml(self) -> dict[str, str]:
462+
def as_yaml(self) -> dict[str, Union[str, list[str]]]:
462463
"""Get this project as yaml dictionary."""
463464
yamldata = {
464465
"name": self._name,

dfetch/manifest/schema.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
Optional("url"): SAFE_STR,
2626
Optional("repo-path"): SAFE_STR,
2727
Optional("remote"): SAFE_STR,
28-
Optional("patch"): SAFE_STR,
28+
Optional("patch"): SAFE_STR | Seq(SAFE_STR),
2929
Optional("vcs"): Enum(["git", "svn"]),
3030
Optional("src"): SAFE_STR,
3131
Optional("ignore"): Seq(SAFE_STR),

dfetch/project/metadata.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
import datetime
44
import os
5+
from typing import Optional, Union
56

67
import yaml
78
from typing_extensions import TypedDict
89

910
from dfetch.manifest.project import ProjectEntry
1011
from dfetch.manifest.version import Version
12+
from dfetch.util.util import always_str_list, str_if_possible
1113

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

3032

3133
class Metadata:
@@ -49,7 +51,9 @@ def __init__(self, kwargs: Options) -> None:
4951
self._remote_url: str = str(kwargs.get("remote_url", ""))
5052
self._destination: str = str(kwargs.get("destination", ""))
5153
self._hash: str = str(kwargs.get("hash", ""))
52-
self._patch: str = str(kwargs.get("patch", ""))
54+
55+
# Historically only a single patch was allowed
56+
self._patch: list[str] = always_str_list(kwargs.get("patch", []))
5357

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

76-
def fetched(self, version: Version, hash_: str = "", patch_: str = "") -> None:
80+
def fetched(
81+
self, version: Version, hash_: str = "", patch_: Optional[list[str]] = None
82+
) -> None:
7783
"""Update metadata."""
7884
self._last_fetch = datetime.datetime.now()
7985
self._version = version
8086
self._hash = hash_
81-
self._patch = patch_
87+
self._patch = patch_ or []
8288

8389
@property
8490
def version(self) -> Version:
@@ -120,7 +126,7 @@ def hash(self) -> str:
120126
return self._hash
121127

122128
@property
123-
def patch(self) -> str:
129+
def patch(self) -> list[str]:
124130
"""The applied patch as stored in the metadata."""
125131
return self._patch
126132

@@ -160,7 +166,7 @@ def dump(self) -> None:
160166
"last_fetch": self.last_fetch_string(),
161167
"tag": self._version.tag,
162168
"hash": self.hash,
163-
"patch": self.patch,
169+
"patch": str_if_possible(self.patch),
164170
}
165171
}
166172

dfetch/project/subproject.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -128,33 +128,33 @@ def update(
128128
actually_fetched = self._fetch_impl(to_fetch)
129129
self._log_project(f"Fetched {actually_fetched}")
130130

131-
applied_patch = ""
132-
if self.__project.patch:
133-
if os.path.exists(self.__project.patch):
134-
self.apply_patch()
135-
applied_patch = self.__project.patch
131+
applied_patches = []
132+
for patch in self.__project.patch:
133+
if os.path.exists(patch):
134+
self.apply_patch(patch)
135+
applied_patches += [patch]
136136
else:
137-
logger.warning(f"Skipping non-existent patch {self.__project.patch}")
137+
logger.warning(f"Skipping non-existent patch {patch}")
138138

139139
self.__metadata.fetched(
140140
actually_fetched,
141141
hash_=hash_directory(self.local_path, skiplist=[self.__metadata.FILENAME]),
142-
patch_=applied_patch,
142+
patch_=applied_patches,
143143
)
144144

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

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

152152
if not patch_set:
153-
raise RuntimeError(f'Invalid patch file: "{self.__project.patch}"')
153+
raise RuntimeError(f'Invalid patch file: "{patch}"')
154154
if patch_set.apply(0, root=self.local_path, fuzz=True):
155-
self._log_project(f'Applied patch "{self.__project.patch}"')
155+
self._log_project(f'Applied patch "{patch}"')
156156
else:
157-
raise RuntimeError(f'Applying patch "{self.__project.patch}" failed')
157+
raise RuntimeError(f'Applying patch "{patch}" failed')
158158

159159
def check_for_update(
160160
self, reporters: Sequence[AbstractCheckReporter], files_to_ignore: Sequence[str]

dfetch/reporting/stdout_reporter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def add_project(
3535
logger.print_info_field(" tag", metadata.tag)
3636
logger.print_info_field(" last fetch", str(metadata.last_fetch))
3737
logger.print_info_field(" revision", metadata.revision)
38-
logger.print_info_field(" patch", metadata.patch)
38+
logger.print_info_field(" patch", ", ".join(metadata.patch))
3939
logger.print_info_field(
4040
" licenses", ",".join(license.name for license in licenses)
4141
)

dfetch/util/util.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,28 @@ def hash_file(file_path: str, digest: HASH) -> HASH:
134134
buf = f_obj.read(1024 * 1024)
135135

136136
return digest
137+
138+
139+
def always_str_list(data: Union[str, list[str]]) -> list[str]:
140+
"""Convert a string or list of strings into a list of strings.
141+
142+
Args:
143+
data: A string or list of strings.
144+
145+
Returns:
146+
A list of strings. Empty strings are converted to empty lists.
147+
"""
148+
return data if not isinstance(data, str) else [data] if data else []
149+
150+
151+
def str_if_possible(data: list[str]) -> Union[str, list[str]]:
152+
"""Convert a single-element list to a string, otherwise keep as list.
153+
154+
Args:
155+
data: A list of strings.
156+
157+
Returns:
158+
A single string if the list has exactly one element, an empty string
159+
if the list is empty, otherwise the original list.
160+
"""
161+
return "" if not data else data[0] if len(data) == 1 else data

features/list-projects.feature

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ Feature: List dependencies
7979
licenses : <none>
8080
"""
8181

82-
Scenario: Git repo with applied patch
83-
Given MyProject with applied patch 'diff.patch'
82+
Scenario: Git repo with applied patches
83+
Given MyProject with applied patches "001-diff.patch, 002-diff.patch"
8484
When I run "dfetch report"
8585
Then the output shows
8686
"""
@@ -92,6 +92,6 @@ Feature: List dependencies
9292
tag : v2.0
9393
last fetch : 02/07/2021, 20:25:56
9494
revision : <none>
95-
patch : diff.patch
95+
patch : 001-diff.patch, 002-diff.patch
9696
licenses : MIT License
9797
"""

0 commit comments

Comments
 (0)