Skip to content

Commit 54708e5

Browse files
spoorccclaudeben-edna
authored
fix runtime error printing (#1129)
* Fix #1096: print runtime errors in context of failing subproject Replace the pattern of collecting RuntimeErrors across all subprojects and printing them at the end with immediate in-context logging via `logger.print_warning_line(project.name, str(exc))` at the point of failure. This ensures errors like "svn not available on system" appear next to the subproject they belong to instead of at the end of the run. The final `raise RuntimeError()` (no message) still produces a non-zero exit code without double-printing. Also bumps version to 0.14.0 and updates CHANGELOG, feature tests. https://claude.ai/code/session_01RTgPqrX37jFkK843dq65Bu Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Reduce cyclomatic complexity below 9 in diff and update-patch commands Extract per-project logic from Diff.__call__ (was CC=11) into _diff_project and from UpdatePatch.__call__ (was CC=10) into _process_project. All methods now have CC <= 8. https://claude.ai/code/session_01RTgPqrX37jFkK843dq65Bu Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Sort commands * Fix #1096: print runtime errors in context of failing subproject Replace the pattern of collecting RuntimeErrors across all subprojects and printing them at the end with immediate in-context logging via `logger.print_warning_line(project.name, str(exc))` at the point of failure. This ensures errors like "svn not available on system" appear next to the subproject they belong to instead of at the end of the run. The final `raise RuntimeError()` (no message) still produces a non-zero exit code without double-printing. Also bumps version to 0.14.0 and updates CHANGELOG, feature tests. https://claude.ai/code/session_01RTgPqrX37jFkK843dq65Bu Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Cleanup commands * Revert version bump * Restrict commits in settings.json * Review comments * Fix glob_within_root root comparison and narrow git fetch permission Resolve root once before the loop in glob_within_root so symlinked or relative root paths compare correctly against resolved match paths. Narrow the Claude Code permission from the wildcard "git fetch:*" to "git fetch origin:*", restricting fetches to the origin remote only. https://claude.ai/code/session_01RTgPqrX37jFkK843dq65Bu * Print errors instead of warnings * Fix fuzz test * Improve logging * Log new version check * Fuzz test again * Fix node reuse across multiple ScenarioAppendixPlaceholders Docutils nodes can only have one parent. Creating a single note (HTML) or a single appendix-nodes list (PDF) and inserting it into multiple placeholders caused earlier placeholders to silently lose their node once it was re-parented. Move node creation inside the placeholder loop so each replacement receives its own fresh node tree. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Ben <b.spoor@edna.eu>
1 parent 7642a9e commit 54708e5

22 files changed

Lines changed: 264 additions & 199 deletions

.claude/settings.json

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,27 @@
22
"permissions": {
33
"allow": [
44
"Bash(codespell)",
5-
"Bash(pre-commit run *)",
6-
"Bash(git stash *)",
7-
"Bash(xenon *)",
8-
"Bash(radon *)",
9-
"Bash(isort --diff dfetch*)",
10-
"Bash(black --check dfetch*)",
11-
"Bash(pylint dfetch*)",
12-
"Bash(ruff check dfetch*)",
13-
"Bash(mypy dfetch*)",
14-
"Bash(python -m mypy dfetch*)",
15-
"Bash(python -m pytest tests/*)",
16-
"Bash(pip show *)",
17-
"Bash(doc8 doc*)",
18-
"Bash(pydocstyle dfetch*)",
19-
"Bash(bandit *)",
5+
"Bash(bandit -r dfetch)",
6+
"Bash(black --check dfetch)",
7+
"Bash(doc8 doc:*)",
8+
"Bash(git fetch origin:*)",
9+
"Bash(git stash:*)",
10+
"Bash(git update-index --refresh)",
11+
"Bash(isort --diff dfetch)",
12+
"Bash(lint-imports)",
13+
"Bash(mypy dfetch:*)",
14+
"Bash(pip show:*)",
15+
"Bash(pre-commit run:*)",
16+
"Bash(pydocstyle dfetch:*)",
17+
"Bash(pylint dfetch:*)",
2018
"Bash(pyroma --directory --min=10 .)",
21-
"Bash(xargs pyupgrade *)",
19+
"Bash(pytest tests/test_sbom_reporter.py -q)",
20+
"Bash(python -m behave *)",
21+
"Bash(python -m pytest tests/*)",
22+
"Bash(radon *)",
23+
"Bash(ruff check:*)",
24+
"Bash(xenon *)",
25+
"Bash(xargs pyupgrade:*)",
2226
"Bash(lint-imports)",
2327
"Bash(pip install *)",
2428
"Bash(pytest *)",

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Release 0.14.0 (unreleased)
1919
* Edit manifest in-place when freezing inside a git or SVN superproject, preserving comments and layout (#1063)
2020
* Add new ``remove`` command to remove projects from manifest and disk (#26)
2121
* Fix "unsafe symlink target" error for archives containing relative ``..`` symlinks (#1122)
22+
* Print runtime errors (e.g. ``svn not available on system``) directly in context of the failing subproject instead of collecting and showing them at the end (#1096)
2223
* Fix ``dfetch add`` crashing with a ``ValueError`` when the remote URL has a trailing slash (#1137)
2324
* Fix unhelpful error message when a metadata file is malformed (#1145)
2425
* Fix arbitrary file write via malicious tar/zip symlink (#1152)

dfetch/commands/check.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
from dfetch.reporting.check.sarif_reporter import SarifReporter
4747
from dfetch.reporting.check.stdout_reporter import CheckStdoutReporter
4848
from dfetch.util.github_version_check import newer_version_available
49-
from dfetch.util.util import catch_runtime_exceptions, in_directory
49+
from dfetch.util.util import in_directory
5050

5151
logger = get_logger(__name__)
5252

@@ -100,30 +100,35 @@ def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None
100100
def __call__(self, args: argparse.Namespace) -> None:
101101
"""Perform the check."""
102102
if not os.environ.get("CI"):
103+
logger.debug("Checking for a newer dfetch version")
103104
newer = newer_version_available()
104105
if newer:
105106
logger.print_newer_version_notice(newer)
106107
superproject = create_super_project()
107108
reporters = self._get_reporters(args, superproject.manifest)
108109

109110
with in_directory(superproject.root_directory):
110-
exceptions: list[str] = []
111+
had_errors: bool = False
111112
for project in superproject.manifest.selected_projects(args.projects):
112-
with catch_runtime_exceptions(exceptions) as exceptions:
113+
try:
113114
dfetch.project.create_sub_project(project).check_for_update(
114115
reporters,
115116
files_to_ignore=superproject.ignored_files(project.destination),
116117
)
117-
118-
if not args.no_recommendations and os.path.isdir(project.destination):
119-
with in_directory(project.destination):
120-
check_sub_manifests(superproject.manifest, project)
118+
if not args.no_recommendations and os.path.isdir(
119+
project.destination
120+
):
121+
with in_directory(project.destination):
122+
check_sub_manifests(superproject.manifest, project)
123+
except RuntimeError as exc:
124+
logger.print_error_line(project.name, str(exc))
125+
had_errors = True
121126

122127
for reporter in reporters:
123128
reporter.dump_to_file()
124129

125-
if exceptions:
126-
raise RuntimeError("\n".join(exceptions))
130+
if had_errors:
131+
raise RuntimeError()
127132

128133
@staticmethod
129134
def _get_reporters(

dfetch/commands/diff.py

Lines changed: 53 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,12 @@
5858
import pathlib
5959

6060
import dfetch.commands.command
61+
import dfetch.manifest.project
6162
from dfetch.log import get_logger
6263
from dfetch.project import create_super_project
6364
from dfetch.project.metadata import Metadata
64-
from dfetch.project.superproject import NoVcsSuperProject, RevisionRange
65-
from dfetch.util.util import catch_runtime_exceptions, in_directory
65+
from dfetch.project.superproject import NoVcsSuperProject, RevisionRange, SuperProject
66+
from dfetch.util.util import in_directory
6667

6768
logger = get_logger(__name__)
6869

@@ -115,51 +116,62 @@ def __call__(self, args: argparse.Namespace) -> None:
115116
)
116117

117118
with in_directory(superproject.root_directory):
118-
exceptions: list[str] = []
119119
projects = superproject.manifest.selected_projects(args.projects)
120120
if not projects:
121121
raise RuntimeError(
122122
f"No (such) project found! {', '.join(args.projects)}"
123123
)
124+
had_errors: bool = False
124125
for project in projects:
125-
with catch_runtime_exceptions(exceptions) as exceptions:
126-
if not os.path.exists(project.destination):
127-
raise RuntimeError(
128-
"You cannot generate a diff of a project that was never fetched"
129-
)
130-
subproject = superproject.get_sub_project(project)
131-
132-
if not subproject:
133-
raise RuntimeError("No subproject!")
134-
135-
old_rev = old_rev or superproject.get_file_revision(
136-
subproject.metadata_path
137-
)
138-
if not old_rev:
139-
raise RuntimeError(
140-
"When not providing any revisions, dfetch starts from"
141-
f" the last revision to {Metadata.FILENAME} in {subproject.local_path}."
142-
" Please either commit this, or specify a revision to start from with --revs"
143-
)
144-
patch = superproject.diff(
145-
project.destination,
146-
revisions=RevisionRange(old_rev, new_rev),
147-
ignore=(Metadata.FILENAME,),
148-
)
149-
150-
msg = self._rev_msg(old_rev, new_rev)
151-
if patch:
152-
patch_path = pathlib.Path(f"{project.name}.patch")
153-
logger.print_info_line(
154-
project.name,
155-
f"Generating patch {patch_path} {msg} in {superproject.root_directory}",
156-
)
157-
patch_path.write_text(patch, encoding="UTF-8")
158-
else:
159-
logger.print_info_line(project.name, f"No diffs found {msg}")
160-
161-
if exceptions:
162-
raise RuntimeError("\n".join(exceptions))
126+
try:
127+
self._diff_project(superproject, project, old_rev, new_rev)
128+
except RuntimeError as exc:
129+
logger.print_error_line(project.name, str(exc))
130+
had_errors = True
131+
132+
if had_errors:
133+
raise RuntimeError()
134+
135+
def _diff_project(
136+
self,
137+
superproject: SuperProject,
138+
project: dfetch.manifest.project.ProjectEntry,
139+
old_rev: str,
140+
new_rev: str,
141+
) -> None:
142+
"""Generate a diff patch for a single project."""
143+
if not os.path.exists(project.destination):
144+
raise RuntimeError(
145+
"You cannot generate a diff of a project that was never fetched"
146+
)
147+
subproject = superproject.get_sub_project(project)
148+
149+
if not subproject:
150+
raise RuntimeError("No subproject!")
151+
152+
old_rev = old_rev or superproject.get_file_revision(subproject.metadata_path)
153+
if not old_rev:
154+
raise RuntimeError(
155+
"When not providing any revisions, dfetch starts from"
156+
f" the last revision to {Metadata.FILENAME} in {subproject.local_path}."
157+
" Please either commit this, or specify a revision to start from with --revs"
158+
)
159+
patch = superproject.diff(
160+
project.destination,
161+
revisions=RevisionRange(old_rev, new_rev),
162+
ignore=(Metadata.FILENAME,),
163+
)
164+
165+
msg = self._rev_msg(old_rev, new_rev)
166+
if patch:
167+
patch_path = pathlib.Path(f"{project.name}.patch")
168+
logger.print_info_line(
169+
project.name,
170+
f"Generating patch {patch_path} {msg} in {superproject.root_directory}",
171+
)
172+
patch_path.write_text(patch, encoding="UTF-8")
173+
else:
174+
logger.print_info_line(project.name, f"No diffs found {msg}")
163175

164176
@staticmethod
165177
def _parse_revs(revs_arg: str) -> tuple[str, str]:

dfetch/commands/environment.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None
3737
def __call__(self, _: argparse.Namespace) -> None:
3838
"""Perform listing the environment."""
3939
logger.print_report_line("dfetch", __version__)
40+
logger.debug("Checking for a newer dfetch version")
4041
newer = newer_version_available()
4142
if newer:
4243
logger.print_newer_version_notice(newer)

dfetch/commands/format_patch.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@
3838
from dfetch.project.subproject import SubProject
3939
from dfetch.project.svnsubproject import SvnSubProject
4040
from dfetch.util.util import (
41-
catch_runtime_exceptions,
4241
check_no_path_traversal,
4342
in_directory,
4443
)
@@ -84,7 +83,7 @@ def __call__(self, args: argparse.Namespace) -> None:
8483
"""Perform the format patch."""
8584
superproject = create_super_project()
8685

87-
exceptions: list[str] = []
86+
had_errors: bool = False
8887

8988
output_dir_path = pathlib.Path(args.output_directory).resolve()
9089

@@ -94,7 +93,7 @@ def __call__(self, args: argparse.Namespace) -> None:
9493

9594
with in_directory(superproject.root_directory):
9695
for project in superproject.manifest.selected_projects(args.projects):
97-
with catch_runtime_exceptions(exceptions) as exceptions:
96+
try:
9897
subproject = dfetch.project.create_sub_project(project)
9998

10099
# Check if the project has a patch, maybe suggest creating one?
@@ -139,9 +138,12 @@ def __call__(self, args: argparse.Namespace) -> None:
139138
project.name,
140139
f"formatted patch written to {output_patch_file.relative_to(os.getcwd())}",
141140
)
141+
except RuntimeError as exc:
142+
logger.print_error_line(project.name, str(exc))
143+
had_errors = True
142144

143-
if exceptions:
144-
raise RuntimeError("\n".join(exceptions))
145+
if had_errors:
146+
raise RuntimeError()
145147

146148

147149
def _determine_target_patch_type(subproject: SubProject) -> PatchType:

dfetch/commands/freeze.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
from dfetch.log import get_logger
6969
from dfetch.project import create_super_project
7070
from dfetch.project.superproject import NoVcsSuperProject
71-
from dfetch.util.util import catch_runtime_exceptions, in_directory
71+
from dfetch.util.util import in_directory
7272

7373
logger = get_logger(__name__)
7474

@@ -99,8 +99,8 @@ def __call__(self, args: argparse.Namespace) -> None:
9999
superproject = create_super_project()
100100
make_backup = isinstance(superproject, NoVcsSuperProject)
101101

102-
exceptions: list[str] = []
103102
manifest_updated = False
103+
had_errors = False
104104

105105
with in_directory(superproject.root_directory):
106106
manifest_path = superproject.manifest.path
@@ -110,7 +110,7 @@ def __call__(self, args: argparse.Namespace) -> None:
110110
shutil.copyfile(manifest_path, manifest_path + ".backup")
111111

112112
for project in projects_to_freeze:
113-
with catch_runtime_exceptions(exceptions) as exceptions:
113+
try:
114114
sub_project = dfetch.project.create_sub_project(project)
115115
on_disk_version = sub_project.on_disk_version()
116116

@@ -133,7 +133,13 @@ def __call__(self, args: argparse.Namespace) -> None:
133133
)
134134
superproject.manifest.update_project_version(project)
135135
manifest_updated = True
136+
except RuntimeError as exc:
137+
logger.print_error_line(project.name, str(exc))
138+
had_errors = True
136139

137140
if manifest_updated:
138141
superproject.manifest.dump()
139142
logger.info(f"Updated manifest ({manifest_path}) in {os.getcwd()}")
143+
144+
if had_errors:
145+
raise RuntimeError()

dfetch/commands/update.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
from dfetch.log import get_logger
2929
from dfetch.project import create_super_project
3030
from dfetch.util.util import (
31-
catch_runtime_exceptions,
3231
check_no_path_traversal,
3332
in_directory,
3433
)
@@ -78,20 +77,25 @@ def __call__(self, args: argparse.Namespace) -> None:
7877
"""Perform the update."""
7978
superproject = create_super_project()
8079

81-
exceptions: list[str] = []
80+
had_errors: bool = False
8281
destinations: list[str] = [
8382
os.path.realpath(project.destination)
8483
for project in superproject.manifest.projects
8584
]
8685
with in_directory(superproject.root_directory):
8786
for project in superproject.manifest.selected_projects(args.projects):
88-
with catch_runtime_exceptions(exceptions) as exceptions:
87+
try:
8988
self._check_destination(project, destinations)
90-
destination = project.destination
89+
except RuntimeError:
90+
had_errors = True
91+
continue
9192

92-
def _ignored(dst: str = destination) -> list[str]:
93-
return list(superproject.ignored_files(dst))
93+
destination = project.destination
9494

95+
def _ignored(dst: str = destination) -> list[str]:
96+
return list(superproject.ignored_files(dst))
97+
98+
try:
9599
dfetch.project.create_sub_project(project).update(
96100
force=args.force,
97101
ignored_files_callback=_ignored,
@@ -103,9 +107,12 @@ def _ignored(dst: str = destination) -> list[str]:
103107
):
104108
with in_directory(project.destination):
105109
check_sub_manifests(superproject.manifest, project)
110+
except RuntimeError as exc:
111+
logger.print_error_line(project.name, str(exc))
112+
had_errors = True
106113

107-
if exceptions:
108-
raise RuntimeError("\n".join(exceptions))
114+
if had_errors:
115+
raise RuntimeError()
109116

110117
@staticmethod
111118
def _check_destination(

0 commit comments

Comments
 (0)