Skip to content

Commit ba230f9

Browse files
committed
feat(_internal[config_style]) Add central config output formatting
why: All commands that write config entries (add, discover, import, fmt) were independently hardcoding {"repo": url}. A central module enables consistent output and bidirectional style conversion. what: - Add format_repo_entry() for creating new entries in any style - Add restyle_repo_entry() for converting existing entries between styles - Add apply_config_style() for bulk-transforming full config dicts - Add helpers: _infer_vcs_from_url, _strip_vcs_prefix, _read_git_remotes, _extract_url, _has_extra_keys - Lossy verbose→concise conversions warn and preserve original entry - Add 38 parametrized tests with syrupy snapshots
1 parent 9e179ef commit ba230f9

5 files changed

Lines changed: 859 additions & 0 deletions

File tree

Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
"""Central config output formatting for vcspull.
2+
3+
All commands that *write* configuration entries (``add``, ``discover``,
4+
``import``, ``fmt``) delegate to the functions here so that the output
5+
style (concise / standard / verbose) is controlled in one place.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import logging
11+
import pathlib
12+
import subprocess
13+
import typing as t
14+
15+
from vcspull.types import ConfigStyle, RawRepoEntry
16+
17+
log = logging.getLogger(__name__)
18+
19+
# ---------------------------------------------------------------------------
20+
# Internal helpers
21+
# ---------------------------------------------------------------------------
22+
23+
24+
def _infer_vcs_from_url(url: str) -> str | None:
25+
"""Extract the VCS type from a prefixed URL.
26+
27+
Parameters
28+
----------
29+
url : str
30+
Repository URL, optionally prefixed (e.g. ``"git+https://..."``).
31+
32+
Returns
33+
-------
34+
str | None
35+
``"git"``, ``"hg"``, ``"svn"``, or ``None`` when no prefix is found.
36+
37+
Examples
38+
--------
39+
>>> _infer_vcs_from_url("git+https://github.com/user/repo.git")
40+
'git'
41+
>>> _infer_vcs_from_url("hg+https://hg.example.com/repo")
42+
'hg'
43+
>>> _infer_vcs_from_url("https://github.com/user/repo.git") is None
44+
True
45+
"""
46+
for prefix in ("git+", "hg+", "svn+"):
47+
if url.startswith(prefix):
48+
return prefix[:-1]
49+
return None
50+
51+
52+
def _strip_vcs_prefix(url: str) -> str:
53+
"""Remove a VCS prefix from a URL.
54+
55+
Parameters
56+
----------
57+
url : str
58+
Repository URL, optionally prefixed (e.g. ``"git+https://..."``).
59+
60+
Returns
61+
-------
62+
str
63+
URL without VCS prefix.
64+
65+
Examples
66+
--------
67+
>>> _strip_vcs_prefix("git+https://github.com/user/repo.git")
68+
'https://github.com/user/repo.git'
69+
>>> _strip_vcs_prefix("https://github.com/user/repo.git")
70+
'https://github.com/user/repo.git'
71+
"""
72+
for prefix in ("git+", "hg+", "svn+"):
73+
if url.startswith(prefix):
74+
return url[len(prefix) :]
75+
return url
76+
77+
78+
def _read_git_remotes(repo_path: pathlib.Path) -> dict[str, str] | None:
79+
"""Read git remote names and URLs from a local repository.
80+
81+
Parameters
82+
----------
83+
repo_path : pathlib.Path
84+
Path to a git working tree.
85+
86+
Returns
87+
-------
88+
dict[str, str] | None
89+
Mapping of remote name to fetch URL, or ``None`` on failure.
90+
91+
Examples
92+
--------
93+
>>> import pathlib
94+
>>> _read_git_remotes(pathlib.Path("/nonexistent")) is None
95+
True
96+
"""
97+
try:
98+
result = subprocess.run(
99+
["git", "-C", str(repo_path), "remote", "-v"],
100+
check=True,
101+
capture_output=True,
102+
text=True,
103+
)
104+
except (FileNotFoundError, subprocess.CalledProcessError):
105+
return None
106+
107+
remotes: dict[str, str] = {}
108+
for line in result.stdout.splitlines():
109+
parts = line.split()
110+
if len(parts) >= 2 and line.endswith("(fetch)"):
111+
remotes[parts[0]] = parts[1]
112+
return remotes or None
113+
114+
115+
def _extract_url(repo_data: RawRepoEntry) -> str:
116+
"""Get the URL from any entry form.
117+
118+
Parameters
119+
----------
120+
repo_data : str | dict
121+
Repository entry in any style.
122+
123+
Returns
124+
-------
125+
str
126+
The repository URL.
127+
128+
Examples
129+
--------
130+
>>> _extract_url("git+https://github.com/user/repo.git")
131+
'git+https://github.com/user/repo.git'
132+
>>> _extract_url({"repo": "git+https://github.com/user/repo.git"})
133+
'git+https://github.com/user/repo.git'
134+
"""
135+
if isinstance(repo_data, str):
136+
return repo_data
137+
return str(repo_data.get("repo") or repo_data.get("url", ""))
138+
139+
140+
def _has_extra_keys(repo_data: RawRepoEntry) -> bool:
141+
"""Check whether a dict entry has keys beyond ``repo`` / ``url``.
142+
143+
Parameters
144+
----------
145+
repo_data : str | dict
146+
Repository entry.
147+
148+
Returns
149+
-------
150+
bool
151+
``True`` when the entry carries additional metadata.
152+
153+
Examples
154+
--------
155+
>>> _has_extra_keys("git+https://github.com/user/repo.git")
156+
False
157+
>>> _has_extra_keys({"repo": "url"})
158+
False
159+
>>> _has_extra_keys({"repo": "url", "shell_command_after": "make"})
160+
True
161+
"""
162+
if isinstance(repo_data, str):
163+
return False
164+
return bool(set(repo_data.keys()) - {"repo", "url"})
165+
166+
167+
# ---------------------------------------------------------------------------
168+
# Public API
169+
# ---------------------------------------------------------------------------
170+
171+
172+
def format_repo_entry(
173+
url: str,
174+
*,
175+
style: ConfigStyle,
176+
existing_remotes: dict[str, str] | None = None,
177+
repo_path: pathlib.Path | None = None,
178+
) -> RawRepoEntry:
179+
"""Create a single repository config entry in the requested style.
180+
181+
Parameters
182+
----------
183+
url : str
184+
The full repository URL (e.g. ``"git+https://..."``).
185+
style : ConfigStyle
186+
Desired output style.
187+
existing_remotes : dict[str, str] | None
188+
Pre-fetched remote mapping; skips ``git remote -v`` when provided.
189+
repo_path : pathlib.Path | None
190+
Local clone path, used to read remotes for :attr:`ConfigStyle.VERBOSE`.
191+
192+
Returns
193+
-------
194+
str | dict
195+
``str`` for concise, ``dict`` for standard / verbose.
196+
197+
Examples
198+
--------
199+
>>> from vcspull.types import ConfigStyle
200+
>>> format_repo_entry("git+https://github.com/u/r.git", style=ConfigStyle.CONCISE)
201+
'git+https://github.com/u/r.git'
202+
>>> entry = format_repo_entry(
203+
... "git+https://github.com/u/r.git", style=ConfigStyle.STANDARD
204+
... )
205+
>>> entry == {"repo": "git+https://github.com/u/r.git"}
206+
True
207+
"""
208+
if style is ConfigStyle.CONCISE:
209+
return url
210+
211+
if style is ConfigStyle.STANDARD:
212+
return {"repo": url}
213+
214+
# VERBOSE
215+
entry: dict[str, t.Any] = {"repo": url}
216+
217+
vcs = _infer_vcs_from_url(url)
218+
if vcs is not None:
219+
entry["vcs"] = vcs
220+
221+
remotes = existing_remotes
222+
if remotes is None and repo_path is not None:
223+
remotes = _read_git_remotes(repo_path)
224+
if remotes is None:
225+
bare_url = _strip_vcs_prefix(url)
226+
remotes = {"origin": bare_url}
227+
228+
entry["remotes"] = remotes
229+
return entry
230+
231+
232+
def restyle_repo_entry(
233+
repo_name: str,
234+
repo_data: RawRepoEntry,
235+
*,
236+
style: ConfigStyle,
237+
repo_path: pathlib.Path | None = None,
238+
) -> tuple[RawRepoEntry, list[str]]:
239+
"""Convert an existing entry to a different style.
240+
241+
Parameters
242+
----------
243+
repo_name : str
244+
Name of the repository (for warning messages).
245+
repo_data : str | dict
246+
Current entry value.
247+
style : ConfigStyle
248+
Target output style.
249+
repo_path : pathlib.Path | None
250+
Local clone path, used for verbose remote reading.
251+
252+
Returns
253+
-------
254+
tuple[str | dict, list[str]]
255+
The restyled entry and a list of warning messages (may be empty).
256+
257+
Examples
258+
--------
259+
>>> from vcspull.types import ConfigStyle
260+
>>> entry, warns = restyle_repo_entry(
261+
... "myrepo", {"repo": "git+https://github.com/u/r.git"},
262+
... style=ConfigStyle.CONCISE,
263+
... )
264+
>>> entry
265+
'git+https://github.com/u/r.git'
266+
>>> warns
267+
[]
268+
"""
269+
warnings: list[str] = []
270+
url = _extract_url(repo_data)
271+
272+
if style is ConfigStyle.CONCISE:
273+
if _has_extra_keys(repo_data):
274+
warnings.append(
275+
f"'{repo_name}' has extra keys that would be lost in concise "
276+
f"style; keeping original entry"
277+
)
278+
return repo_data, warnings
279+
return url, warnings
280+
281+
if style is ConfigStyle.STANDARD:
282+
if isinstance(repo_data, dict) and _has_extra_keys(repo_data):
283+
normalized = dict(repo_data)
284+
if "url" in normalized and "repo" not in normalized:
285+
normalized["repo"] = normalized.pop("url")
286+
return normalized, warnings
287+
return {"repo": url}, warnings
288+
289+
# VERBOSE
290+
existing_remotes: dict[str, str] | None = None
291+
if isinstance(repo_data, dict) and "remotes" in repo_data:
292+
raw_remotes = repo_data["remotes"]
293+
if isinstance(raw_remotes, dict):
294+
existing_remotes = {
295+
k: v for k, v in raw_remotes.items() if isinstance(v, str)
296+
}
297+
298+
entry = format_repo_entry(
299+
url,
300+
style=ConfigStyle.VERBOSE,
301+
existing_remotes=existing_remotes,
302+
repo_path=repo_path,
303+
)
304+
305+
if isinstance(repo_data, dict) and isinstance(entry, dict):
306+
for key, value in repo_data.items():
307+
if key not in entry:
308+
entry[key] = value
309+
310+
return entry, warnings
311+
312+
313+
def apply_config_style(
314+
config_data: dict[str, t.Any],
315+
*,
316+
style: ConfigStyle,
317+
base_dirs: dict[str, pathlib.Path] | None = None,
318+
) -> tuple[dict[str, t.Any], int, list[str]]:
319+
"""Restyle all entries in a full config dict.
320+
321+
Parameters
322+
----------
323+
config_data : dict
324+
Full vcspull configuration mapping.
325+
style : ConfigStyle
326+
Target output style.
327+
base_dirs : dict[str, pathlib.Path] | None
328+
Optional mapping of workspace labels to resolved filesystem paths,
329+
used to locate repo clones for verbose remote reading.
330+
331+
Returns
332+
-------
333+
tuple[dict, int, list[str]]
334+
The restyled configuration, count of changed entries, and warnings.
335+
336+
Examples
337+
--------
338+
>>> from vcspull.types import ConfigStyle
339+
>>> cfg = {"~/code/": {"flask": "git+https://github.com/pallets/flask.git"}}
340+
>>> styled, count, warns = apply_config_style(cfg, style=ConfigStyle.STANDARD)
341+
>>> styled["~/code/"]["flask"]
342+
{'repo': 'git+https://github.com/pallets/flask.git'}
343+
>>> count
344+
1
345+
"""
346+
result: dict[str, t.Any] = {}
347+
change_count = 0
348+
all_warnings: list[str] = []
349+
350+
for workspace_label, repos in config_data.items():
351+
if not isinstance(repos, dict):
352+
result[workspace_label] = repos
353+
continue
354+
355+
result_section: dict[str, t.Any] = {}
356+
for repo_name, repo_data in repos.items():
357+
repo_path: pathlib.Path | None = None
358+
if base_dirs and workspace_label in base_dirs:
359+
repo_path = base_dirs[workspace_label] / repo_name
360+
361+
new_entry, warnings = restyle_repo_entry(
362+
repo_name,
363+
repo_data,
364+
style=style,
365+
repo_path=repo_path,
366+
)
367+
all_warnings.extend(warnings)
368+
369+
if new_entry != repo_data:
370+
change_count += 1
371+
372+
result_section[repo_name] = new_entry
373+
374+
result[workspace_label] = result_section
375+
376+
return result, change_count, all_warnings
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"~/code/": {
3+
"django": "git+https://github.com/django/django.git",
4+
"flask": "git+https://github.com/pallets/flask.git"
5+
}
6+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"~/code/": {
3+
"django": {
4+
"repo": "git+https://github.com/django/django.git"
5+
},
6+
"flask": {
7+
"repo": "git+https://github.com/pallets/flask.git"
8+
}
9+
}
10+
}

0 commit comments

Comments
 (0)