Skip to content
Open
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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ dfetch.log ← logging (lowest layer)
- **`dfetch/__main__.py`** — CLI entry point; builds argparse subcommands and dispatches
- **`dfetch/commands/`** — One file per CLI command (e.g., `update.py`, `check.py`); all inherit from `command.py`'s abstract `Command` base
- **`dfetch/manifest/`** — YAML manifest loading/writing with `strictyaml` schema validation; `manifest.py` is the main handler
- **`dfetch/project/`** — Abstract `Subproject`/`Superproject` classes with concrete Git, SVN, and Archive implementations; factory functions `create_sub_project()` and `create_super_project()` are the main entry points
- **`dfetch/project/`** — Concrete `SubProject` domain aggregate that composes with a `Fetcher`; `GitFetcher`, `SvnFetcher`, and `ArchiveFetcher` implement the `Fetcher`/`VcsFetcher` protocols defined in `fetcher.py`; factory functions `create_sub_project()` and `create_super_project()` are the main entry points
- **`dfetch/vcs/`** — Low-level VCS operations: `git.py`, `svn.py`, `archive.py` (with hash verification in `integrity_hash.py`), and `patch.py`
- **`dfetch/reporting/`** — Output formatters; check results can be emitted as stdout, Jenkins JSON, SARIF, or Code Climate format; SBOM output uses CycloneDX format
- **`dfetch/terminal/`** — Terminal UI components (interactive prompts, tree browser, ANSI colors)
Expand All @@ -84,7 +84,7 @@ dfetch.log ← logging (lowest layer)

### Adding a new VCS backend

Implement the abstract interfaces in `dfetch/project/subproject.py` and `dfetch/vcs/` and register via the factory in `dfetch/project/`.
Implement the `Fetcher` protocol (or `VcsFetcher` if you need branch/tag/revision semantics) defined in `dfetch/project/fetcher.py`, add a concrete class in `dfetch/project/`, add low-level VCS operations in `dfetch/vcs/` if needed, and register via the factory in `dfetch/project/__init__.py`.

## Testing conventions

Expand Down
85 changes: 48 additions & 37 deletions dfetch/commands/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,8 @@
from dfetch.manifest.remote import Remote
from dfetch.manifest.version import Version
from dfetch.project import create_sub_project, create_super_project
from dfetch.project.gitsubproject import GitSubProject
from dfetch.project.subproject import SubProject
from dfetch.project.superproject import SuperProject
from dfetch.project.svnsubproject import SvnSubProject
from dfetch.terminal import Entry, LsFunction
from dfetch.terminal.tree_browser import (
BrowserConfig,
Expand Down Expand Up @@ -86,9 +84,9 @@ def browse_tree(subproject: SubProject, version: str = "") -> Generator[LsFuncti
Adds '.' as the first entry to allow selecting the repo root (which is
treated as empty src).
"""
if isinstance(subproject, (GitSubProject, SvnSubProject)):
remote = subproject.remote_repo
with remote.browse_tree(version) as vcs_ls:
vcs = subproject.as_vcs()
if vcs is not None:
with vcs.browse_tree(version) as vcs_ls:

def ls(path: str = "") -> list[Entry]:
entries = [
Expand Down Expand Up @@ -274,44 +272,54 @@ def _finalize_add(
Update()(update_args)


def _resolve_entry_version(ctx: _AddContext, raw_version: str) -> Version:
"""Resolve a raw version string to a ``Version`` using remote branches and tags.

For archive-backed subprojects ``raw_version`` is preserved as a revision
identifier (URL or hash) because archives have no branch/tag semantics.
"""
if ctx.subproject.as_vcs() is None:
return Version(revision=raw_version)
branches = ctx.subproject.list_of_branches()
tags = ctx.subproject.list_of_tags()
choices: list[Version] = [
*[Version(branch=b) for b in prioritise_default(branches, ctx.default_branch)],
*[Version(tag=t) for t in sort_tags_newest_first(tags)],
]
return _resolve_raw_version(raw_version, choices) or Version(
branch=ctx.default_branch
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def _non_interactive_entry(ctx: _AddContext, overrides: _Overrides) -> ProjectEntry:
"""Build a ``ProjectEntry`` using inferred defaults (no user interaction)."""
if overrides.version:
branches = ctx.subproject.list_of_branches()
tags = ctx.subproject.list_of_tags()
choices: list[Version] = [
*[
Version(branch=b)
for b in prioritise_default(branches, ctx.default_branch)
],
*[Version(tag=t) for t in sort_tags_newest_first(tags)],
]
version = _resolve_raw_version(overrides.version, choices) or Version(
branch=ctx.default_branch
)
else:
version = Version(branch=ctx.default_branch)
version = (
_resolve_entry_version(ctx, overrides.version)
if overrides.version
else Version(branch=ctx.default_branch)
)
existing_names = {p.name for p in ctx.manifest.projects}
return _build_entry(
entry = _build_entry(
name=overrides.name or _unique_name(ctx.default_name, existing_names),
remote_url=ctx.url,
dst=overrides.dst or ctx.default_dst,
version=version,
src=overrides.src or "",
ignore=overrides.ignore or [],
remote_to_use=ctx.remote_to_use,
)
if ctx.remote_to_use:
entry.set_remote(ctx.remote_to_use)
return entry


def _build_entry( # pylint: disable=too-many-arguments
def _build_entry(
*,
name: str,
remote_url: str,
dst: str,
version: Version,
src: str,
ignore: list[str],
remote_to_use: Remote | None,
) -> ProjectEntry:
"""Assemble a ``ProjectEntry`` from the fields collected by the wizard."""
kind, value = version.field
Expand All @@ -325,10 +333,7 @@ def _build_entry( # pylint: disable=too-many-arguments
entry_dict["src"] = src
if ignore:
entry_dict["ignore"] = ignore
entry = ProjectEntry(entry_dict)
if remote_to_use:
entry.set_remote(remote_to_use)
return entry
return ProjectEntry(entry_dict)


# ---------------------------------------------------------------------------
Expand All @@ -340,15 +345,17 @@ def _show_url_fields(
name: str, remote_url: str, default_branch: str, remote_to_use: Remote | None
) -> None:
"""Print the fields determined solely by the URL (name, remote, url, repo-path)."""
seed = _build_entry(
entry = _build_entry(
name=name,
remote_url=remote_url,
dst=name,
version=Version(branch=default_branch),
src="",
ignore=[],
remote_to_use=remote_to_use,
).as_yaml()
)
if remote_to_use:
entry.set_remote(remote_to_use)
seed = entry.as_yaml()
logger.print_yaml(
{k: seed[k] for k in ("name", "remote", "url", "repo-path") if k in seed}
)
Expand Down Expand Up @@ -414,15 +421,17 @@ def _interactive_flow(ctx: _AddContext, overrides: _Overrides) -> ProjectEntry:

src, ignore = _pick_src_and_ignore(ctx.subproject, version_value, overrides)

return _build_entry(
entry = _build_entry(
name=name,
remote_url=ctx.url,
dst=dst,
version=version,
src=src,
ignore=ignore,
remote_to_use=ctx.remote_to_use,
)
if ctx.remote_to_use:
entry.set_remote(ctx.remote_to_use)
return entry


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -507,10 +516,12 @@ def _ask_src(ls_function: LsFunction) -> str:
src = tree_single_pick(ls_function, "Source path", dirs_selectable=True)
return "" if src == "." else src

return Prompt.ask(
_PROMPT_FORMAT.format(label="Source path")
+ " (sub-path/glob, or Enter to fetch whole repo)",
default="",
return str(
Prompt.ask(
_PROMPT_FORMAT.format(label="Source path")
+ " (sub-path/glob, or Enter to fetch whole repo)",
default="",
)
).strip()


Expand Down
6 changes: 3 additions & 3 deletions dfetch/commands/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import dfetch.commands.command
from dfetch import __version__
from dfetch.log import get_logger
from dfetch.project import SUPPORTED_SUBPROJECT_TYPES
from dfetch.project import SUPPORTED_FETCHERS
from dfetch.util.github_version_check import newer_version_available

logger = get_logger(__name__)
Expand All @@ -43,5 +43,5 @@ def __call__(self, _: argparse.Namespace) -> None:
logger.print_report_line(
"platform", f"{platform.system()} {platform.release()}"
)
for project_type in SUPPORTED_SUBPROJECT_TYPES:
project_type.list_tool_info()
for fetcher_type in SUPPORTED_FETCHERS:
fetcher_type.list_tool_info()
14 changes: 3 additions & 11 deletions dfetch/commands/format_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,7 @@
import dfetch.project
from dfetch.log import get_logger
from dfetch.project import create_super_project
from dfetch.project.gitsubproject import GitSubProject
from dfetch.project.subproject import SubProject
from dfetch.project.svnsubproject import SvnSubProject
from dfetch.util.util import (
catch_runtime_exceptions,
check_no_path_traversal,
Expand Down Expand Up @@ -145,12 +143,6 @@ def __call__(self, args: argparse.Namespace) -> None:


def _determine_target_patch_type(subproject: SubProject) -> PatchType:
"""Determine the subproject type for the patch."""
if isinstance(subproject, GitSubProject):
required_type = PatchType.GIT
elif isinstance(subproject, SvnSubProject):
required_type = PatchType.SVN
else:
required_type = PatchType.PLAIN

return required_type
"""Determine the patch format for *subproject*."""
vcs = subproject.as_vcs()
return vcs.patch_type() if vcs is not None else PatchType.PLAIN
59 changes: 28 additions & 31 deletions dfetch/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,47 +7,33 @@
import sys
import types
from contextlib import nullcontext
from logging import LogRecord
from typing import TYPE_CHECKING, Any, cast
from typing import Any, cast

from rich.console import Console, ConsoleRenderable
from rich._log_render import LogRender # type: ignore[import-untyped]
from rich.console import Console
from rich.highlighter import NullHighlighter
from rich.logging import RichHandler
from rich.markup import escape as markup_escape
from rich.status import Status
from rich.table import Table

from dfetch import __version__

if TYPE_CHECKING:
from rich.traceback import Traceback

def _make_non_expanding_log_render(**kwargs: Any) -> Any:
"""Return a LogRender callable that disables table expansion.

class _NoExpandRichHandler(RichHandler):
"""RichHandler that disables table expansion to prevent blank lines in asciicasts.

Rich's LogRender uses expand=True on its Table.grid, which pads every
log message with trailing spaces to fill the full terminal width. When
asciinema records the output the padded line fills the terminal exactly,
causing the subsequent newline to produce a blank line in the cast
player. Overriding render to set expand=False removes the trailing
spaces and avoids the spurious blank lines.
Used when recording with asciinema to prevent Rich's ``expand=True`` from
padding log lines to the full terminal width, which produces spurious blank
lines in the cast player.
"""
renderer = LogRender(**kwargs)

def render(
self,
*,
record: LogRecord,
traceback: Traceback | None,
message_renderable: ConsoleRenderable,
) -> ConsoleRenderable:
"""Render log entry without expanding the table to the full terminal width."""
renderable = super().render(
record=record, traceback=traceback, message_renderable=message_renderable
)
if isinstance(renderable, Table):
renderable.expand = False
return renderable
def _render(*args: Any, **kw: Any) -> Any:
table = renderer(*args, **kw)
table.expand = False
return table

return _render


def make_console(no_color: bool = False) -> Console:
Expand All @@ -63,8 +49,7 @@ def configure_root_logger(console: Console | None = None) -> None:
"""Configure the root logger with RichHandler using the provided Console."""
console = console or make_console()

handler_class = _NoExpandRichHandler if os.getenv("ASCIINEMA_REC") else RichHandler
handler = handler_class(
handler = RichHandler(
console=console,
show_time=False,
show_path=False,
Expand All @@ -74,6 +59,18 @@ def configure_root_logger(console: Console | None = None) -> None:
highlighter=NullHighlighter(),
)

if os.getenv("ASCIINEMA_REC"):
# Rich's LogRender uses expand=True on its Table.grid, which pads every
# log message with trailing spaces to fill the full terminal width. When
# asciinema records the output the padded line fills the terminal exactly,
# causing the subsequent newline to produce a blank line in the cast
# player. Wrapping _log_render so it returns a non-expanding table
# removes the trailing spaces and avoids the spurious blank lines.
no_expand = _make_non_expanding_log_render(
show_time=False, show_level=False, show_path=False
)
handler._log_render = no_expand # pylint: disable=protected-access

Comment on lines +69 to +73

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For rich==15.0.0, is there a public RichHandler extension point to disable log table expansion (without setting _log_render directly)?

💡 Result:

In Rich 15.0.0, there is no public extension point or configuration parameter for the RichHandler class designed to disable log table expansion or the associated column-based wrapping [1][2][3][4]. The RichHandler is explicitly architected to render logs using a table-based layout to maintain aligned, readable output in terminal environments [3][5][4]. Consequently, the internal rendering logic—including the use of a table that enforces fixed-width columns—is considered a core part of its design rather than an optional behavior [3][5]. For users who require control over this rendering (e.g., to disable hard wrapping or column expansion), the library maintainers recommend one of the following approaches: 1. Subclassing and overriding: You can create a custom logging handler by inheriting from logging.Handler or by subclassing RichHandler and overriding the emit method to implement your own rendering logic [3][5]. 2. Custom LogRender: While RichHandler uses an internal LogRender class, it is not intended to be exposed as a public API [3][5]. However, as documented in community discussions, some users have achieved specific layout requirements by defining a custom renderer class and assigning it to the _log_render attribute, though this relies on internal implementation details that may change [3][5]. If you are using the handler for output that is not intended for human consumption in a terminal (e.g., piping logs to a file or another process), it is generally recommended to use a standard logging.StreamHandler or a custom handler that does not perform terminal-specific layout formatting [6][5].

Citations:


🏁 Script executed:

# First, let's check if dfetch/log.py exists and get its basic structure
fd -t f "log.py" dfetch/

Repository: dfetch-org/dfetch

Length of output: 75


🏁 Script executed:

# Read the relevant sections of dfetch/log.py
cat -n dfetch/log.py | head -80

Repository: dfetch-org/dfetch

Length of output: 3123


🏁 Script executed:

# Also check if there's an import statement or usage of _AsciinemaRichHandler later in the file
rg "_AsciinemaRichHandler|_log_render|ASCIINEMA_REC" dfetch/log.py -n

Repository: dfetch-org/dfetch

Length of output: 447


🏁 Script executed:

# Check if assigning to self._log_render in __init__ would trigger protected-access warnings
# Also verify no other inline suppressions exist in the file
rg "#\s*(noqa|type:\s*ignore|pylint:\s*disable|pyright:\s*ignore)" dfetch/log.py -n

Repository: dfetch-org/dfetch

Length of output: 296


🏁 Script executed:

# Verify the exact line count of the file to ensure line references are accurate
wc -l dfetch/log.py

Repository: dfetch-org/dfetch

Length of output: 79


Remove inline suppression by moving handler customization to a dedicated subclass.

Line 72 contains an inline # pylint: disable=protected-access suppression, which violates the coding guideline for dfetch/**/*.py (no inline lint suppressions except module-level headers in lines 1–5). The direct assignment to handler._log_render couples this code to Rich's private API at the call site.

Create a dedicated RichHandler subclass to encapsulate the customization and eliminate the inline suppression:

Suggested refactor
+class _AsciinemaRichHandler(RichHandler):
+    """RichHandler variant with non-expanding log table rendering."""
+
+    def __init__(self, **kwargs: Any) -> None:
+        super().__init__(**kwargs)
+        self._log_render = _make_non_expanding_log_render(
+            show_time=False, show_level=False, show_path=False
+        )
+
 def configure_root_logger(console: Console | None = None) -> None:
     """Configure the root logger with RichHandler using the provided Console."""
     console = console or make_console()
 
+    handler_cls: type[RichHandler] = (
+        _AsciinemaRichHandler if os.getenv("ASCIINEMA_REC") else RichHandler
+    )
-    handler = RichHandler(
+    handler = handler_cls(
         console=console,
         show_time=False,
         show_path=False,
         show_level=False,
         markup=True,
         rich_tracebacks=True,
         highlighter=NullHighlighter(),
     )
-
-    if os.getenv("ASCIINEMA_REC"):
-        # Rich's LogRender uses expand=True on its Table.grid, which pads every
-        # log message with trailing spaces to fill the full terminal width.  When
-        # asciinema records the output the padded line fills the terminal exactly,
-        # causing the subsequent newline to produce a blank line in the cast
-        # player.  Wrapping _log_render so it returns a non-expanding table
-        # removes the trailing spaces and avoids the spurious blank lines.
-        no_expand = _make_non_expanding_log_render(
-            show_time=False, show_level=False, show_path=False
-        )
-        handler._log_render = no_expand  # pylint: disable=protected-access

Rich 15.0.0 provides no public API to disable log table expansion; the library maintainers recommend subclassing RichHandler for customization. This refactor aligns with that guidance while removing the guideline violation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dfetch/log.py` around lines 69 - 73, The inline pylint suppression on the
assignment to handler._log_render violates the coding guideline against inline
lint suppressions. Create a dedicated RichHandler subclass that internally
handles setting the _log_render attribute with the result of
_make_non_expanding_log_render, then instantiate and use this custom subclass
instead of directly assigning to the handler's private attribute at the call
site. This encapsulates the private API access within the subclass definition,
eliminating the need for the inline pylint suppression while maintaining the
functionality.

Source: Coding guidelines

logging.basicConfig(
level=logging.INFO,
format="%(message)s",
Expand Down
31 changes: 16 additions & 15 deletions dfetch/project/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,37 +7,38 @@
from dfetch.log import get_logger
from dfetch.manifest.manifest import Manifest
from dfetch.manifest.parse import find_manifest
from dfetch.project.archivesubproject import ArchiveSubProject
from dfetch.project.gitsubproject import GitSubProject
from dfetch.project.archivesubproject import ArchiveFetcher
from dfetch.project.gitsubproject import GitFetcher
from dfetch.project.gitsuperproject import GitSuperProject
from dfetch.project.subproject import SubProject
from dfetch.project.superproject import NoVcsSuperProject, SuperProject
from dfetch.project.svnsubproject import SvnSubProject
from dfetch.project.svnsubproject import SvnFetcher
from dfetch.project.svnsuperproject import SvnSuperProject
from dfetch.util.util import resolve_absolute_path

SUPPORTED_SUBPROJECT_TYPES: list[
type[ArchiveSubProject] | type[GitSubProject] | type[SvnSubProject]
] = [ArchiveSubProject, GitSubProject, SvnSubProject]
_AnyFetcherType = type[ArchiveFetcher] | type[GitFetcher] | type[SvnFetcher]
SUPPORTED_FETCHERS: list[_AnyFetcherType] = [ArchiveFetcher, GitFetcher, SvnFetcher]
SUPPORTED_SUPERPROJECT_TYPES = [GitSuperProject, SvnSuperProject]

# Backward-compatible alias used by environment.py and any external callers.
SUPPORTED_SUBPROJECT_TYPES = SUPPORTED_FETCHERS

logger = get_logger(__name__)


def create_sub_project(
project_entry: dfetch.manifest.project.ProjectEntry,
) -> SubProject:
"""Create a new SubProject based on a project from the manifest."""
for project_type in SUPPORTED_SUBPROJECT_TYPES:
if project_type.NAME == project_entry.vcs:
return project_type(project_entry)
"""Create a SubProject by selecting the appropriate fetcher for *project_entry*."""
for fetcher_type in SUPPORTED_FETCHERS:
if fetcher_type.NAME == project_entry.vcs:
return SubProject(project_entry, fetcher_type(project_entry.remote_url))

for project_type in SUPPORTED_SUBPROJECT_TYPES:
project = project_type(project_entry)
for fetcher_type in SUPPORTED_FETCHERS:
if fetcher_type.handles(project_entry.remote_url):
return SubProject(project_entry, fetcher_type(project_entry.remote_url))

if project.check():
return project
raise RuntimeError("vcs type unsupported")
raise RuntimeError(f"vcs type unsupported for {project_entry.remote_url}")


def create_super_project() -> SuperProject:
Expand Down
Loading
Loading