Skip to content

Commit 2ecaa44

Browse files
committed
dftech add command
Fixes #25
1 parent 33f2c54 commit 2ecaa44

9 files changed

Lines changed: 305 additions & 0 deletions

File tree

CHANGELOG.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
Release 0.13.0 (unreleased)
2+
====================================
3+
4+
* Introduce new ``add`` command (#25)
5+
16
Release 0.12.0 (released 2026-02-21)
27
====================================
38

dfetch/__main__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from rich.console import Console
1111

12+
import dfetch.commands.add
1213
import dfetch.commands.check
1314
import dfetch.commands.diff
1415
import dfetch.commands.environment
@@ -43,6 +44,7 @@ def create_parser() -> argparse.ArgumentParser:
4344
parser.set_defaults(func=_help)
4445
subparsers = parser.add_subparsers(help="commands")
4546

47+
dfetch.commands.add.Add.create_menu(subparsers)
4648
dfetch.commands.check.Check.create_menu(subparsers)
4749
dfetch.commands.diff.Diff.create_menu(subparsers)
4850
dfetch.commands.environment.Environment.create_menu(subparsers)

dfetch/commands/add.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""*Dfetch* can add projects through the cli to the manifest.
2+
3+
Sometimes you want to add a project to your manifest, but you don't want to
4+
edit the manifest by hand. With ``dfetch add`` you can add a project to your manifest
5+
through the command line. This will add the project to your manifest and fetch it
6+
to your disk. You can also specify a version to add, or it will be added with the
7+
latest version available.
8+
9+
"""
10+
11+
import argparse
12+
import os
13+
from collections.abc import Sequence
14+
from pathlib import Path
15+
16+
from rich.prompt import Prompt
17+
18+
import dfetch.commands.command
19+
import dfetch.manifest.project
20+
import dfetch.project
21+
from dfetch.log import get_logger
22+
from dfetch.manifest.manifest import append_entry_manifest_file
23+
from dfetch.manifest.project import ProjectEntry, ProjectEntryDict
24+
from dfetch.manifest.remote import Remote
25+
from dfetch.project import create_sub_project, create_super_project
26+
from dfetch.util.purl import remote_url_to_purl
27+
28+
logger = get_logger(__name__)
29+
30+
31+
class Add(dfetch.commands.command.Command):
32+
"""Add a new project to the manifest.
33+
34+
Add a new project to the manifest.
35+
"""
36+
37+
@staticmethod
38+
def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None:
39+
"""Add the parser menu for this action."""
40+
parser = dfetch.commands.command.Command.parser(subparsers, Add)
41+
42+
parser.add_argument(
43+
"remote_url",
44+
metavar="<remote_url>",
45+
type=str,
46+
nargs=1,
47+
help="Remote URL of project to add",
48+
)
49+
50+
parser.add_argument(
51+
"-f",
52+
"--force",
53+
action="store_true",
54+
help="Always perform addition.",
55+
)
56+
57+
def __call__(self, args: argparse.Namespace) -> None:
58+
"""Perform the add."""
59+
superproject = create_super_project()
60+
61+
purl = remote_url_to_purl(args.remote_url[0])
62+
project_entry = ProjectEntry(
63+
ProjectEntryDict(name=purl.name, url=args.remote_url[0])
64+
)
65+
66+
# Determines VCS type tries to reach remote
67+
subproject = create_sub_project(project_entry)
68+
69+
if project_entry.name in [
70+
project.name for project in superproject.manifest.projects
71+
]:
72+
raise RuntimeError(
73+
f"Project with name {project_entry.name} already exists in manifest!"
74+
)
75+
76+
destination = _guess_destination(
77+
project_entry.name, superproject.manifest.projects
78+
)
79+
80+
if remote_to_use := _determine_remote(
81+
superproject.manifest.remotes, project_entry.remote_url
82+
):
83+
logger.debug(
84+
f"Remote URL {project_entry.remote_url} matches remote {remote_to_use.name}"
85+
)
86+
87+
project_entry = ProjectEntry(
88+
ProjectEntryDict(
89+
name=project_entry.name,
90+
url=(project_entry.remote_url),
91+
branch=subproject.get_default_branch(),
92+
dst=destination,
93+
),
94+
)
95+
if remote_to_use:
96+
project_entry.set_remote(remote_to_use)
97+
98+
logger.print_overview(
99+
project_entry.name,
100+
"Will add following entry to manifest:",
101+
project_entry.as_yaml(),
102+
)
103+
104+
if not args.force and not confirm():
105+
logger.print_warning_line(project_entry.name, "Aborting add of project")
106+
return
107+
108+
append_entry_manifest_file(
109+
(superproject.root_directory / superproject.manifest.path).absolute(),
110+
project_entry,
111+
)
112+
113+
logger.print_info_line(project_entry.name, "Added project to manifest")
114+
115+
116+
def confirm() -> bool:
117+
"""Show a confirmation prompt to the user before adding the project."""
118+
return (
119+
Prompt.ask("Add project to manifest?", choices=["y", "n"], default="y") == "y"
120+
)
121+
122+
123+
def _check_name_uniqueness(
124+
project_name: str, manifest_projects: Sequence[ProjectEntry]
125+
) -> None:
126+
"""Validate that the project name is not already used in the manifest."""
127+
if project_name in [project.name for project in manifest_projects]:
128+
raise RuntimeError(
129+
f"Project with name {project_name} already exists in manifest!"
130+
)
131+
132+
133+
def _guess_destination(
134+
project_name: str, manifest_projects: Sequence[ProjectEntry]
135+
) -> str:
136+
"""Guess the destination of the project based on the remote URL and existing projects."""
137+
if len(manifest_projects) <= 1:
138+
return ""
139+
140+
common_path = os.path.commonpath(
141+
[project.destination for project in manifest_projects]
142+
)
143+
144+
if common_path and common_path != os.path.sep:
145+
return (Path(common_path) / project_name).as_posix()
146+
return ""
147+
148+
149+
def _determine_remote(remotes: Sequence[Remote], remote_url: str) -> Remote | None:
150+
"""Determine if the remote URL matches any of the remotes in the manifest."""
151+
for remote in remotes:
152+
if remote_url.startswith(remote.url):
153+
return remote
154+
return None

dfetch/log.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,13 @@ def print_warning_line(self, name: str, info: str) -> None:
7474
line = info.replace("\n", "\n ")
7575
self.info(f" [bold bright_yellow]> {line}[/bold bright_yellow]")
7676

77+
def print_overview(self, name: str, title: str, info: dict[str, Any]) -> None:
78+
"""Print an overview of fields."""
79+
self.print_info_line(name, title)
80+
for key, value in info.items():
81+
key += ":"
82+
self.info(f" [blue]{key:20s}[/blue][white] {value}[/white]")
83+
7784
def print_title(self) -> None:
7885
"""Print the DFetch tool title and version."""
7986
self.info(f"[bold blue]Dfetch ({__version__})[/bold blue]")

dfetch/manifest/manifest.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import re
2525
from collections.abc import Sequence
2626
from dataclasses import dataclass
27+
from pathlib import Path
2728
from typing import IO, Any
2829

2930
import yaml
@@ -385,3 +386,21 @@ def write_line_break(self, data: Any = None) -> None:
385386
super().write_line_break() # type: ignore[unused-ignore, no-untyped-call]
386387

387388
self._last_additional_break = len(self.indents)
389+
390+
391+
def append_entry_manifest_file(
392+
manifest_path: str | Path,
393+
project_entry: ProjectEntry,
394+
) -> None:
395+
"""Add the project entry to the manifest file."""
396+
with Path(manifest_path).open("a", encoding="utf-8") as manifest_file:
397+
398+
new_entry = yaml.dump(
399+
[project_entry.as_yaml()],
400+
sort_keys=False,
401+
line_break=os.linesep,
402+
indent=2,
403+
)
404+
manifest_file.write("\n")
405+
for line in new_entry.splitlines():
406+
manifest_file.write(f" {line}\n")

doc/asciicasts/add.cast

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
{"version": 2, "width": 175, "height": 16, "timestamp": 1771797309, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}}
2+
[0.494902, "o", "\u001b[H\u001b[2J\u001b[3J"]
3+
[0.497658, "o", "$ "]
4+
[1.500235, "o", "\u001b["]
5+
[1.680644, "o", "1m"]
6+
[1.770828, "o", "ca"]
7+
[1.860948, "o", "t "]
8+
[1.951108, "o", "dfe"]
9+
[2.041269, "o", "tc"]
10+
[2.131404, "o", "h."]
11+
[2.221555, "o", "ya"]
12+
[2.311663, "o", "ml"]
13+
[2.401896, "o", "\u001b[0"]
14+
[2.582125, "o", "m"]
15+
[3.583662, "o", "\r\n"]
16+
[3.585601, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"]
17+
[3.589043, "o", "$ "]
18+
[4.591589, "o", "\u001b"]
19+
[4.771858, "o", "[1"]
20+
[4.861983, "o", "md"]
21+
[4.966335, "o", "fe"]
22+
[5.043363, "o", "tc"]
23+
[5.133493, "o", "h "]
24+
[5.223631, "o", "ad"]
25+
[5.31377, "o", "d "]
26+
[5.403904, "o", "-f"]
27+
[5.494126, "o", " h"]
28+
[5.674549, "o", "t"]
29+
[5.764721, "o", "tp"]
30+
[5.854808, "o", "s:"]
31+
[5.94494, "o", "//"]
32+
[6.035324, "o", "gi"]
33+
[6.125438, "o", "th"]
34+
[6.215568, "o", "ub"]
35+
[6.305716, "o", ".c"]
36+
[6.39586, "o", "om"]
37+
[6.576041, "o", "/d"]
38+
[6.666201, "o", "f"]
39+
[6.756456, "o", "et"]
40+
[6.846865, "o", "ch"]
41+
[6.936987, "o", "-o"]
42+
[7.02721, "o", "rg"]
43+
[7.11736, "o", "/d"]
44+
[7.20751, "o", "fe"]
45+
[7.297833, "o", "tc"]
46+
[7.47816, "o", "h."]
47+
[7.56839, "o", "gi"]
48+
[7.658512, "o", "t"]
49+
[7.748652, "o", "\u001b["]
50+
[7.839, "o", "0m"]
51+
[8.840408, "o", "\r\n"]
52+
[9.321142, "o", "\u001b[1;34mDfetch (0.12.0)\u001b[0m \r\n"]
53+
[9.632322, "o", " \u001b[1;92mdfetch:\u001b[0m \r\n"]
54+
[9.632906, "o", " \u001b[1;34m> Will add following entry to manifest:\u001b[0m \r\n"]
55+
[9.633466, "o", " \u001b[34mname: \u001b[0m\u001b[37m dfetch\u001b[0m \r\n"]
56+
[9.633962, "o", " \u001b[34mremote: \u001b[0m\u001b[37m github\u001b[0m \r\n"]
57+
[9.63445, "o", " \u001b[34mbranch: \u001b[0m\u001b[37m main\u001b[0m \r\n"]
58+
[9.634929, "o", " \u001b[34mrepo-path: \u001b[0m\u001b[37m dfetch-org/dfetch.git\u001b[0m \r\n"]
59+
[9.63603, "o", " \u001b[1;34m> Added project to manifest\u001b[0m \r\n"]
60+
[9.698037, "o", "$ "]
61+
[10.700827, "o", "\u001b["]
62+
[10.881118, "o", "1m"]
63+
[10.971276, "o", "ca"]
64+
[11.061415, "o", "t "]
65+
[11.151604, "o", "df"]
66+
[11.241877, "o", "et"]
67+
[11.332175, "o", "ch"]
68+
[11.422206, "o", ".y"]
69+
[11.512344, "o", "am"]
70+
[11.60248, "o", "l\u001b"]
71+
[11.782771, "o", "[0"]
72+
[11.872931, "o", "m"]
73+
[12.874505, "o", "\r\n"]
74+
[12.87646, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n\r\n - name: dfetch\r\n remote: github\r\n branch: main\r\n repo-path: dfetch-org/dfetch.git\r\n"]
75+
[15.8913, "o", "$ "]
76+
[15.891782, "o", "\u001b["]
77+
[16.071982, "o", "1m"]
78+
[16.162128, "o", "\u001b["]
79+
[16.252276, "o", "0m"]
80+
[16.252759, "o", "\r\n"]
81+
[16.254621, "o", "/workspaces/dfetch/doc/generate-casts\r\n"]

doc/generate-casts/add-demo.sh

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/usr/bin/env bash
2+
3+
source ./demo-magic/demo-magic.sh
4+
5+
PROMPT_TIMEOUT=1
6+
7+
# Copy example manifest
8+
mkdir add
9+
pushd add
10+
dfetch init
11+
clear
12+
13+
# Run the command
14+
pe "cat dfetch.yaml"
15+
pe "dfetch add -f https://github.com/dfetch-org/dfetch.git"
16+
pe "cat dfetch.yaml"
17+
18+
PROMPT_TIMEOUT=3
19+
wait
20+
21+
pei ""
22+
23+
popd
24+
rm -rf add

doc/generate-casts/generate-casts.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ rm -rf ../asciicasts/*
77

88
asciinema rec --overwrite -c "./basic-demo.sh" ../asciicasts/basic.cast
99
asciinema rec --overwrite -c "./init-demo.sh" ../asciicasts/init.cast
10+
asciinema rec --overwrite -c "./add-demo.sh" ../asciicasts/add.cast
1011
asciinema rec --overwrite -c "./environment-demo.sh" ../asciicasts/environment.cast
1112
asciinema rec --overwrite -c "./validate-demo.sh" ../asciicasts/validate.cast
1213
asciinema rec --overwrite -c "./check-demo.sh" ../asciicasts/check.cast

doc/manual.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,18 @@ Validate
173173

174174
.. automodule:: dfetch.commands.validate
175175

176+
Add
177+
---
178+
.. argparse::
179+
:module: dfetch.__main__
180+
:func: create_parser
181+
:prog: dfetch
182+
:path: add
183+
184+
.. asciinema:: asciicasts/add.cast
185+
186+
.. automodule:: dfetch.commands.add
187+
176188

177189
CLI Cheatsheet
178190
--------------

0 commit comments

Comments
 (0)