projscan 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- projscan/__init__.py +26 -0
- projscan/cli.py +198 -0
- projscan/git.py +221 -0
- projscan/render.py +129 -0
- projscan/uv.py +339 -0
- projscan-0.1.0.dist-info/METADATA +176 -0
- projscan-0.1.0.dist-info/RECORD +9 -0
- projscan-0.1.0.dist-info/WHEEL +4 -0
- projscan-0.1.0.dist-info/entry_points.txt +2 -0
projscan/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""projscan — scanner de dépôts git locaux et de tools uv installés.
|
|
2
|
+
|
|
3
|
+
API publique importable depuis une app Textual ou tout autre code async :
|
|
4
|
+
|
|
5
|
+
from projscan import scan_paths, scan_tools, RepoInfo, ToolInfo
|
|
6
|
+
|
|
7
|
+
repos = await scan_paths([Path("~/code")], fetch=True)
|
|
8
|
+
tools = await scan_tools(check_remote=True)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from projscan.git import RepoInfo, scan_paths, scan_repo, find_repos
|
|
14
|
+
from projscan.uv import ToolInfo, scan_tools, scan_tool, uv_tool_dir
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"RepoInfo",
|
|
18
|
+
"scan_paths",
|
|
19
|
+
"scan_repo",
|
|
20
|
+
"find_repos",
|
|
21
|
+
"ToolInfo",
|
|
22
|
+
"scan_tools",
|
|
23
|
+
"scan_tool",
|
|
24
|
+
"uv_tool_dir",
|
|
25
|
+
]
|
|
26
|
+
__version__ = "0.1.0"
|
projscan/cli.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""projscan.cli — command-line interface for projscan.
|
|
2
|
+
|
|
3
|
+
Two commands:
|
|
4
|
+
projscan repos [PATHS]... — scan folders for git repositories
|
|
5
|
+
projscan tools — scan uv tools installed from git
|
|
6
|
+
|
|
7
|
+
Exit codes:
|
|
8
|
+
0 everything up to date
|
|
9
|
+
1 at least one item needs updating (or has an error)
|
|
10
|
+
2 fatal execution error
|
|
11
|
+
130 keyboard interrupt (Ctrl-C)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
import shutil
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import click
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
25
|
+
|
|
26
|
+
from projscan.git import scan_paths
|
|
27
|
+
from projscan.render import repos_table, tools_table
|
|
28
|
+
from projscan.uv import scan_tools
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
err_console = Console(stderr=True)
|
|
32
|
+
|
|
33
|
+
# Statuses that indicate an item needs attention
|
|
34
|
+
_ACTIONABLE = frozenset(["behind", "update available", "diverged", "error"])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _require_git() -> None:
|
|
38
|
+
"""Exit with a clear message if git is not found in PATH."""
|
|
39
|
+
if shutil.which("git") is None:
|
|
40
|
+
err_console.print("[bold red]Error:[/bold red] git is not installed or not found in PATH.")
|
|
41
|
+
err_console.print("Install git at [link]https://git-scm.com/downloads[/link] and make sure it is in your PATH.")
|
|
42
|
+
sys.exit(2)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _require_uv() -> None:
|
|
46
|
+
"""Exit with a clear message if uv is not found in PATH."""
|
|
47
|
+
if shutil.which("uv") is None:
|
|
48
|
+
err_console.print("[bold red]Error:[/bold red] uv is not installed or not found in PATH.")
|
|
49
|
+
err_console.print("Install uv at [link]https://docs.astral.sh/uv/getting-started/installation/[/link] and make sure it is in your PATH.")
|
|
50
|
+
sys.exit(2)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _needs_action(status: str) -> bool:
|
|
54
|
+
return any(status == s or status.startswith(s) for s in _ACTIONABLE)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _exit_code(statuses: list[str]) -> int:
|
|
58
|
+
return 1 if any(_needs_action(s) for s in statuses) else 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@click.group()
|
|
62
|
+
def _cli() -> None:
|
|
63
|
+
"""projscan — git repository and uv tool scanner."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@_cli.command("repos")
|
|
67
|
+
@click.argument("paths", nargs=-1, type=click.Path(file_okay=False))
|
|
68
|
+
@click.option("--no-fetch", is_flag=True, help="Skip git fetch (use local cache).")
|
|
69
|
+
@click.option("--depth", default=3, show_default=True, metavar="INTEGER", help="Max recursion depth.")
|
|
70
|
+
@click.option("--json", "as_json", is_flag=True, help="JSON output (machine-readable).")
|
|
71
|
+
@click.option("--concurrency", default=16, show_default=True, metavar="INTEGER", help="Max parallel scans.")
|
|
72
|
+
def repos_cmd(
|
|
73
|
+
paths: tuple[str, ...],
|
|
74
|
+
no_fetch: bool,
|
|
75
|
+
depth: int,
|
|
76
|
+
as_json: bool,
|
|
77
|
+
concurrency: int,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Scan one or more directories and show the status of each git repository.
|
|
80
|
+
|
|
81
|
+
Defaults to the current directory if no PATH is given.
|
|
82
|
+
|
|
83
|
+
\b
|
|
84
|
+
Examples:
|
|
85
|
+
projscan repos ~/code
|
|
86
|
+
projscan repos --no-fetch --json ~/projects | jq '.[] | select(.status != "à jour")'
|
|
87
|
+
"""
|
|
88
|
+
_require_git()
|
|
89
|
+
roots = [Path(p) for p in paths] if paths else [Path.cwd()]
|
|
90
|
+
try:
|
|
91
|
+
if as_json:
|
|
92
|
+
infos = asyncio.run(
|
|
93
|
+
scan_paths(roots, fetch=not no_fetch, max_depth=depth, concurrency=concurrency)
|
|
94
|
+
)
|
|
95
|
+
click.echo(json.dumps([i.as_dict() for i in infos], ensure_ascii=False, indent=2))
|
|
96
|
+
else:
|
|
97
|
+
with Progress(
|
|
98
|
+
SpinnerColumn(),
|
|
99
|
+
TextColumn("[progress.description]{task.description}"),
|
|
100
|
+
console=console,
|
|
101
|
+
transient=True,
|
|
102
|
+
) as progress:
|
|
103
|
+
progress.add_task(
|
|
104
|
+
f"Scanning{' (network fetch)' if not no_fetch else ''}…",
|
|
105
|
+
total=None,
|
|
106
|
+
)
|
|
107
|
+
infos = asyncio.run(
|
|
108
|
+
scan_paths(roots, fetch=not no_fetch, max_depth=depth, concurrency=concurrency)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if not infos:
|
|
112
|
+
console.print("[dim]No git repositories found.[/dim]")
|
|
113
|
+
sys.exit(0)
|
|
114
|
+
|
|
115
|
+
console.print(repos_table(infos))
|
|
116
|
+
|
|
117
|
+
total = len(infos)
|
|
118
|
+
ok = sum(1 for i in infos if i.status == "up to date")
|
|
119
|
+
nok = total - ok
|
|
120
|
+
console.print(
|
|
121
|
+
f"[dim]{total} repo{'s' if total > 1 else ''}, "
|
|
122
|
+
f"{ok} up to date, {nok} to update[/dim]"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
sys.exit(_exit_code([i.status for i in infos]))
|
|
126
|
+
except SystemExit:
|
|
127
|
+
raise
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
err_console.print(f"[bold red]Error:[/bold red] {exc}")
|
|
130
|
+
sys.exit(2)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@_cli.command("tools")
|
|
134
|
+
@click.option("--tool-dir", "tool_dir", default=None, type=click.Path(file_okay=False), help="uv tools root (auto-detected by default).")
|
|
135
|
+
@click.option("--no-remote", is_flag=True, help="Skip remote checks (list local installs only).")
|
|
136
|
+
@click.option("--json", "as_json", is_flag=True, help="JSON output (machine-readable).")
|
|
137
|
+
def tools_cmd(tool_dir: str | None, no_remote: bool, as_json: bool) -> None:
|
|
138
|
+
"""Scan uv tools installed from git and check for updates.
|
|
139
|
+
|
|
140
|
+
\b
|
|
141
|
+
Examples:
|
|
142
|
+
projscan tools
|
|
143
|
+
projscan tools --no-remote --json
|
|
144
|
+
projscan tools --tool-dir ~/.local/share/uv/tools
|
|
145
|
+
"""
|
|
146
|
+
_require_uv()
|
|
147
|
+
if not no_remote:
|
|
148
|
+
_require_git()
|
|
149
|
+
root = Path(tool_dir) if tool_dir else None
|
|
150
|
+
try:
|
|
151
|
+
if as_json:
|
|
152
|
+
infos = asyncio.run(
|
|
153
|
+
scan_tools(root, check_remote=not no_remote)
|
|
154
|
+
)
|
|
155
|
+
click.echo(json.dumps([i.as_dict() for i in infos], ensure_ascii=False, indent=2))
|
|
156
|
+
else:
|
|
157
|
+
with Progress(
|
|
158
|
+
SpinnerColumn(),
|
|
159
|
+
TextColumn("[progress.description]{task.description}"),
|
|
160
|
+
console=console,
|
|
161
|
+
transient=True,
|
|
162
|
+
) as progress:
|
|
163
|
+
progress.add_task(
|
|
164
|
+
f"Scanning tools{' (checking remotes)' if not no_remote else ''}…",
|
|
165
|
+
total=None,
|
|
166
|
+
)
|
|
167
|
+
infos = asyncio.run(
|
|
168
|
+
scan_tools(root, check_remote=not no_remote)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if not infos:
|
|
172
|
+
console.print("[dim]No uv tools found (or uv not installed).[/dim]")
|
|
173
|
+
sys.exit(0)
|
|
174
|
+
|
|
175
|
+
console.print(tools_table(infos))
|
|
176
|
+
|
|
177
|
+
total = len(infos)
|
|
178
|
+
ok = sum(1 for i in infos if i.status == "up to date")
|
|
179
|
+
nok = total - ok
|
|
180
|
+
console.print(
|
|
181
|
+
f"[dim]{total} tool{'s' if total > 1 else ''}, "
|
|
182
|
+
f"{ok} up to date, {nok} to update[/dim]"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
sys.exit(_exit_code([i.status for i in infos]))
|
|
186
|
+
except SystemExit:
|
|
187
|
+
raise
|
|
188
|
+
except Exception as exc:
|
|
189
|
+
err_console.print(f"[bold red]Error:[/bold red] {exc}")
|
|
190
|
+
sys.exit(2)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def main() -> None:
|
|
194
|
+
"""Entry point for the projscan script."""
|
|
195
|
+
try:
|
|
196
|
+
_cli()
|
|
197
|
+
except KeyboardInterrupt:
|
|
198
|
+
sys.exit(130)
|
projscan/git.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""projscan.git — scan de dépôts git locaux pour un dashboard "suis-je à jour ?".
|
|
2
|
+
|
|
3
|
+
Conçu pour être réutilisé dans un worker Textual : toutes les fonctions d'I/O sont
|
|
4
|
+
async (asyncio.create_subprocess_exec), et scan_paths() parallélise via un Semaphore.
|
|
5
|
+
|
|
6
|
+
Pour chaque repo trouvé on remonte :
|
|
7
|
+
- name : nom du dossier
|
|
8
|
+
- version : git describe --tags, sinon project.version de pyproject.toml
|
|
9
|
+
- commit : hash court du HEAD
|
|
10
|
+
- subject / when : message + date relative du dernier commit
|
|
11
|
+
- branch : branche courante
|
|
12
|
+
- upstream : nom de l'upstream (ou None s'il n'y en a pas)
|
|
13
|
+
- behind / ahead : nb de commits vs upstream (None si pas d'upstream)
|
|
14
|
+
- status : libellé synthétique (à jour / en retard / ...)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import tomllib
|
|
21
|
+
from dataclasses import dataclass, asdict
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class RepoInfo:
|
|
27
|
+
name: str
|
|
28
|
+
path: str
|
|
29
|
+
version: str | None
|
|
30
|
+
commit: str | None
|
|
31
|
+
subject: str | None
|
|
32
|
+
when: str | None
|
|
33
|
+
branch: str | None
|
|
34
|
+
upstream: str | None
|
|
35
|
+
behind: int | None
|
|
36
|
+
ahead: int | None
|
|
37
|
+
status: str
|
|
38
|
+
error: str | None = None
|
|
39
|
+
|
|
40
|
+
def as_dict(self) -> dict:
|
|
41
|
+
return asdict(self)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _git(repo: Path, *args: str, timeout: float = 30.0) -> tuple[int, str, str]:
|
|
45
|
+
"""Lance une commande git dans `repo`. Renvoie (returncode, stdout, stderr)."""
|
|
46
|
+
proc = await asyncio.create_subprocess_exec(
|
|
47
|
+
"git",
|
|
48
|
+
"-C",
|
|
49
|
+
str(repo),
|
|
50
|
+
*args,
|
|
51
|
+
stdout=asyncio.subprocess.PIPE,
|
|
52
|
+
stderr=asyncio.subprocess.PIPE,
|
|
53
|
+
)
|
|
54
|
+
try:
|
|
55
|
+
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
56
|
+
except asyncio.TimeoutError:
|
|
57
|
+
proc.kill()
|
|
58
|
+
await proc.wait()
|
|
59
|
+
return 124, "", "timeout"
|
|
60
|
+
return proc.returncode, out.decode(errors="replace").strip(), err.decode(errors="replace").strip()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_git_repo(path: Path) -> bool:
|
|
64
|
+
"""Un dépôt git "racine" : présence d'un .git (dossier classique ou worktree)."""
|
|
65
|
+
return (path / ".git").exists()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read_pyproject_version(repo: Path) -> str | None:
|
|
69
|
+
pp = repo / "pyproject.toml"
|
|
70
|
+
if not pp.is_file():
|
|
71
|
+
return None
|
|
72
|
+
try:
|
|
73
|
+
data = tomllib.loads(pp.read_text(encoding="utf-8"))
|
|
74
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
75
|
+
return None
|
|
76
|
+
# project.version (PEP 621) puis tool.poetry.version en repli
|
|
77
|
+
v = data.get("project", {}).get("version")
|
|
78
|
+
if v:
|
|
79
|
+
return str(v)
|
|
80
|
+
v = data.get("tool", {}).get("poetry", {}).get("version")
|
|
81
|
+
return str(v) if v else None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _version(repo: Path) -> str | None:
|
|
85
|
+
"""git describe --tags si possible, sinon la version du pyproject."""
|
|
86
|
+
rc, out, _ = await _git(repo, "describe", "--tags", "--always", "--dirty")
|
|
87
|
+
if rc == 0 and out:
|
|
88
|
+
# describe renvoie le hash court s'il n'y a aucun tag ; dans ce cas on
|
|
89
|
+
# préfère pyproject s'il existe, sinon on garde le hash.
|
|
90
|
+
py = _read_pyproject_version(repo)
|
|
91
|
+
# heuristique : si describe ne ressemble pas à un tag (pas de chiffre.point)
|
|
92
|
+
# et qu'on a une version pyproject, on prend pyproject.
|
|
93
|
+
looks_like_tag = any(c.isdigit() for c in out) and "." in out
|
|
94
|
+
if looks_like_tag:
|
|
95
|
+
return out
|
|
96
|
+
if py:
|
|
97
|
+
return py
|
|
98
|
+
return out
|
|
99
|
+
return _read_pyproject_version(repo)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def scan_repo(path: Path, *, fetch: bool = True) -> RepoInfo:
|
|
103
|
+
"""Scanne un dépôt unique. Si fetch=True, met à jour les refs distantes d'abord."""
|
|
104
|
+
name = path.name
|
|
105
|
+
try:
|
|
106
|
+
if fetch:
|
|
107
|
+
# --quiet, ignore les erreurs (offline, pas de remote, auth...) :
|
|
108
|
+
# on continue avec les refs locales.
|
|
109
|
+
await _git(path, "fetch", "--quiet", "--all", timeout=60.0)
|
|
110
|
+
|
|
111
|
+
rc, log, _ = await _git(path, "log", "-1", "--format=%h%x1f%s%x1f%cr")
|
|
112
|
+
commit = subject = when = None
|
|
113
|
+
if rc == 0 and log:
|
|
114
|
+
parts = log.split("\x1f")
|
|
115
|
+
commit = parts[0] if len(parts) > 0 else None
|
|
116
|
+
subject = parts[1] if len(parts) > 1 else None
|
|
117
|
+
when = parts[2] if len(parts) > 2 else None
|
|
118
|
+
|
|
119
|
+
_, branch, _ = await _git(path, "rev-parse", "--abbrev-ref", "HEAD")
|
|
120
|
+
branch = branch or None
|
|
121
|
+
|
|
122
|
+
rc_up, upstream, _ = await _git(
|
|
123
|
+
path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"
|
|
124
|
+
)
|
|
125
|
+
upstream = upstream if rc_up == 0 and upstream else None
|
|
126
|
+
|
|
127
|
+
behind = ahead = None
|
|
128
|
+
if upstream:
|
|
129
|
+
rc_c, counts, _ = await _git(
|
|
130
|
+
path, "rev-list", "--left-right", "--count", "HEAD...@{u}"
|
|
131
|
+
)
|
|
132
|
+
if rc_c == 0 and counts:
|
|
133
|
+
# format : "<ahead>\t<behind>" (gauche=HEAD, droite=upstream)
|
|
134
|
+
a, _, b = counts.partition("\t")
|
|
135
|
+
try:
|
|
136
|
+
ahead, behind = int(a.strip()), int(b.strip())
|
|
137
|
+
except ValueError:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
version = await _version(path)
|
|
141
|
+
|
|
142
|
+
status = _status_label(upstream, behind, ahead)
|
|
143
|
+
return RepoInfo(
|
|
144
|
+
name=name,
|
|
145
|
+
path=str(path),
|
|
146
|
+
version=version,
|
|
147
|
+
commit=commit,
|
|
148
|
+
subject=subject,
|
|
149
|
+
when=when,
|
|
150
|
+
branch=branch,
|
|
151
|
+
upstream=upstream,
|
|
152
|
+
behind=behind,
|
|
153
|
+
ahead=ahead,
|
|
154
|
+
status=status,
|
|
155
|
+
)
|
|
156
|
+
except Exception as exc: # robustesse : un repo cassé ne doit pas tuer le scan
|
|
157
|
+
return RepoInfo(
|
|
158
|
+
name=name, path=str(path), version=None, commit=None, subject=None,
|
|
159
|
+
when=None, branch=None, upstream=None, behind=None, ahead=None,
|
|
160
|
+
status="error", error=f"{type(exc).__name__}: {exc}",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _status_label(upstream: str | None, behind: int | None, ahead: int | None) -> str:
|
|
165
|
+
if upstream is None:
|
|
166
|
+
return "no upstream"
|
|
167
|
+
if behind is None or ahead is None:
|
|
168
|
+
return "unknown"
|
|
169
|
+
if behind and ahead:
|
|
170
|
+
return f"diverged (↓{behind} ↑{ahead})"
|
|
171
|
+
if behind:
|
|
172
|
+
return f"behind (↓{behind})"
|
|
173
|
+
if ahead:
|
|
174
|
+
return f"ahead (↑{ahead})"
|
|
175
|
+
return "up to date"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def find_repos(roots: list[Path], *, max_depth: int = 3) -> list[Path]:
|
|
179
|
+
"""Parcourt récursivement les racines et renvoie tous les dépôts git.
|
|
180
|
+
|
|
181
|
+
S'arrête de descendre dès qu'un .git est trouvé (on ne scanne pas l'intérieur
|
|
182
|
+
d'un repo). max_depth limite la profondeur pour éviter d'exploser sur un home.
|
|
183
|
+
"""
|
|
184
|
+
found: list[Path] = []
|
|
185
|
+
seen: set[Path] = set()
|
|
186
|
+
|
|
187
|
+
def walk(d: Path, depth: int) -> None:
|
|
188
|
+
if depth > max_depth or d in seen:
|
|
189
|
+
return
|
|
190
|
+
seen.add(d)
|
|
191
|
+
if is_git_repo(d):
|
|
192
|
+
found.append(d)
|
|
193
|
+
return # ne pas descendre dans un repo
|
|
194
|
+
try:
|
|
195
|
+
children = [c for c in d.iterdir() if c.is_dir() and not c.is_symlink()]
|
|
196
|
+
except (PermissionError, OSError):
|
|
197
|
+
return
|
|
198
|
+
for c in children:
|
|
199
|
+
if c.name in {".git", "node_modules", "__pycache__", ".venv", "venv"}:
|
|
200
|
+
continue
|
|
201
|
+
walk(c, depth + 1)
|
|
202
|
+
|
|
203
|
+
for root in roots:
|
|
204
|
+
root = Path(root).expanduser()
|
|
205
|
+
if root.is_dir():
|
|
206
|
+
walk(root, 0)
|
|
207
|
+
return sorted(found)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def scan_paths(
|
|
211
|
+
roots: list[Path], *, fetch: bool = True, max_depth: int = 3, concurrency: int = 16
|
|
212
|
+
) -> list[RepoInfo]:
|
|
213
|
+
"""Trouve et scanne tous les repos sous `roots`, en parallèle (borné)."""
|
|
214
|
+
repos = find_repos(roots, max_depth=max_depth)
|
|
215
|
+
sem = asyncio.Semaphore(concurrency)
|
|
216
|
+
|
|
217
|
+
async def bounded(p: Path) -> RepoInfo:
|
|
218
|
+
async with sem:
|
|
219
|
+
return await scan_repo(p, fetch=fetch)
|
|
220
|
+
|
|
221
|
+
return list(await asyncio.gather(*(bounded(p) for p in repos)))
|
projscan/render.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""projscan.render — rendu Rich partagé entre les deux commandes CLI.
|
|
2
|
+
|
|
3
|
+
Toutes les fonctions retournent des objets Rich (Table, Text) sans écrire
|
|
4
|
+
directement dans la console, pour pouvoir être réutilisées dans Textual.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from rich import box
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from projscan.git import RepoInfo
|
|
14
|
+
from projscan.uv import ToolInfo
|
|
15
|
+
|
|
16
|
+
# Mapping status -> Rich style. Variable-prefix statuses (behind / diverged)
|
|
17
|
+
# are handled by status_style() below.
|
|
18
|
+
_STATUS_STYLES: dict[str, str] = {
|
|
19
|
+
"up to date": "green",
|
|
20
|
+
"ahead": "cyan",
|
|
21
|
+
"update available": "yellow",
|
|
22
|
+
"no upstream": "dim",
|
|
23
|
+
"not git": "dim",
|
|
24
|
+
"unknown": "dim",
|
|
25
|
+
"error": "bold red",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Display priority: actionable items bubble to the top.
|
|
29
|
+
_STATUS_PRIORITY: dict[str, int] = {
|
|
30
|
+
"error": 0,
|
|
31
|
+
"behind": 1,
|
|
32
|
+
"update available": 2,
|
|
33
|
+
"diverged": 3,
|
|
34
|
+
"unknown": 4,
|
|
35
|
+
"ahead": 5,
|
|
36
|
+
"no upstream": 6,
|
|
37
|
+
"not git": 7,
|
|
38
|
+
"up to date": 8,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def status_style(status: str) -> str:
|
|
43
|
+
"""Return the Rich style corresponding to the given status string."""
|
|
44
|
+
if status.startswith("behind"):
|
|
45
|
+
return "red"
|
|
46
|
+
if status.startswith("diverged"):
|
|
47
|
+
return "magenta"
|
|
48
|
+
if status.startswith("ahead"):
|
|
49
|
+
return "cyan"
|
|
50
|
+
return _STATUS_STYLES.get(status, "dim")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _sort_priority(status: str) -> int:
|
|
54
|
+
for prefix, priority in _STATUS_PRIORITY.items():
|
|
55
|
+
if status == prefix or status.startswith(prefix):
|
|
56
|
+
return priority
|
|
57
|
+
return 9
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _shorten_url(url: str) -> str:
|
|
61
|
+
"""Retire le schéma d'une URL pour économiser de la place."""
|
|
62
|
+
if "://" in url:
|
|
63
|
+
_, _, rest = url.partition("://")
|
|
64
|
+
return rest
|
|
65
|
+
return url
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def repos_table(infos: list[RepoInfo]) -> Table:
|
|
69
|
+
"""Construit une Table Rich pour les RepoInfo, triée par priorité d'action."""
|
|
70
|
+
table = Table(
|
|
71
|
+
box=box.ROUNDED,
|
|
72
|
+
show_header=True,
|
|
73
|
+
header_style="bold",
|
|
74
|
+
expand=False,
|
|
75
|
+
highlight=False,
|
|
76
|
+
)
|
|
77
|
+
table.add_column("Name", style="bold", no_wrap=True, min_width=12)
|
|
78
|
+
table.add_column("Version", no_wrap=True)
|
|
79
|
+
table.add_column("Branch", no_wrap=True)
|
|
80
|
+
table.add_column("Status", no_wrap=True)
|
|
81
|
+
table.add_column("Commit", no_wrap=True, style="dim")
|
|
82
|
+
table.add_column("Message", max_width=48)
|
|
83
|
+
table.add_column("When", no_wrap=True, style="dim")
|
|
84
|
+
|
|
85
|
+
sorted_infos = sorted(infos, key=lambda r: (_sort_priority(r.status), r.name.lower()))
|
|
86
|
+
|
|
87
|
+
for r in sorted_infos:
|
|
88
|
+
table.add_row(
|
|
89
|
+
r.name,
|
|
90
|
+
r.version or "",
|
|
91
|
+
r.branch or "",
|
|
92
|
+
Text(r.status, style=status_style(r.status)),
|
|
93
|
+
r.commit or "",
|
|
94
|
+
r.subject or "",
|
|
95
|
+
r.when or "",
|
|
96
|
+
)
|
|
97
|
+
return table
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def tools_table(infos: list[ToolInfo]) -> Table:
|
|
101
|
+
"""Construit une Table Rich pour les ToolInfo, triée par priorité d'action."""
|
|
102
|
+
table = Table(
|
|
103
|
+
box=box.ROUNDED,
|
|
104
|
+
show_header=True,
|
|
105
|
+
header_style="bold",
|
|
106
|
+
expand=False,
|
|
107
|
+
highlight=False,
|
|
108
|
+
)
|
|
109
|
+
table.add_column("Name", style="bold", no_wrap=True, min_width=12)
|
|
110
|
+
table.add_column("Version", no_wrap=True)
|
|
111
|
+
table.add_column("Source", max_width=40)
|
|
112
|
+
table.add_column("Ref", no_wrap=True)
|
|
113
|
+
table.add_column("Commit", no_wrap=True, style="dim")
|
|
114
|
+
table.add_column("Status", no_wrap=True)
|
|
115
|
+
|
|
116
|
+
sorted_infos = sorted(infos, key=lambda t: (_sort_priority(t.status), t.name.lower()))
|
|
117
|
+
|
|
118
|
+
for t in sorted_infos:
|
|
119
|
+
url_short = _shorten_url(t.url) if t.url else ""
|
|
120
|
+
commit_short = (t.installed_commit or "")[:8]
|
|
121
|
+
table.add_row(
|
|
122
|
+
t.name,
|
|
123
|
+
t.version or "",
|
|
124
|
+
url_short,
|
|
125
|
+
t.ref or "",
|
|
126
|
+
commit_short,
|
|
127
|
+
Text(t.status, style=status_style(t.status)),
|
|
128
|
+
)
|
|
129
|
+
return table
|
projscan/uv.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""projscan.uv — scan des outils installés par `uv tool` depuis des sources git.
|
|
2
|
+
|
|
3
|
+
Objectif : pour chaque tool installé via `uv tool install git+https://...`,
|
|
4
|
+
savoir s'il est à jour par rapport à la branche/ref distante, SANS cloner.
|
|
5
|
+
|
|
6
|
+
Stratégie :
|
|
7
|
+
1. localiser la racine des tools (`uv tool dir`, repli sur les chemins connus)
|
|
8
|
+
2. pour chaque tool, retrouver l'URL git + le commit installé + la ref demandée
|
|
9
|
+
- source fiable : uv-receipt.toml (métadonnées uv)
|
|
10
|
+
- repli PEP 610 : <venv>/.../<pkg>-*.dist-info/direct_url.json
|
|
11
|
+
3. interroger le HEAD distant via `git ls-remote <url> <ref>` (1 requête, 0 objet)
|
|
12
|
+
4. comparer : installed_commit == remote_commit -> à jour, sinon MAJ dispo
|
|
13
|
+
|
|
14
|
+
Pensé pour Textual : fonctions d'I/O async, scan parallèle borné, dataclass.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import tomllib
|
|
23
|
+
from dataclasses import dataclass, asdict
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ToolInfo:
|
|
29
|
+
name: str
|
|
30
|
+
version: str | None
|
|
31
|
+
url: str | None # URL git source (None si non-git / PyPI)
|
|
32
|
+
ref: str | None # branche/tag demandé (ex "main"), peut être None
|
|
33
|
+
installed_commit: str | None
|
|
34
|
+
remote_commit: str | None
|
|
35
|
+
status: str # à jour / MAJ dispo / inconnu / pas git / erreur
|
|
36
|
+
error: str | None = None
|
|
37
|
+
|
|
38
|
+
def as_dict(self) -> dict:
|
|
39
|
+
return asdict(self)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --- localisation de la racine uv tools --------------------------------------
|
|
43
|
+
|
|
44
|
+
async def uv_tool_dir() -> Path | None:
|
|
45
|
+
"""Racine des environnements de tools uv (ex ~/.local/share/uv/tools)."""
|
|
46
|
+
try:
|
|
47
|
+
proc = await asyncio.create_subprocess_exec(
|
|
48
|
+
"uv", "tool", "dir",
|
|
49
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
|
50
|
+
)
|
|
51
|
+
out, _ = await asyncio.wait_for(proc.communicate(), timeout=15.0)
|
|
52
|
+
if proc.returncode == 0:
|
|
53
|
+
p = Path(out.decode().strip())
|
|
54
|
+
if p.is_dir():
|
|
55
|
+
return p
|
|
56
|
+
except (FileNotFoundError, asyncio.TimeoutError):
|
|
57
|
+
pass
|
|
58
|
+
# replis usuels
|
|
59
|
+
for cand in (
|
|
60
|
+
os.environ.get("UV_TOOL_DIR"),
|
|
61
|
+
"~/.local/share/uv/tools",
|
|
62
|
+
"~/Library/Application Support/uv/tools", # macOS
|
|
63
|
+
):
|
|
64
|
+
if cand:
|
|
65
|
+
p = Path(cand).expanduser()
|
|
66
|
+
if p.is_dir():
|
|
67
|
+
return p
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --- extraction des infos d'installation -------------------------------------
|
|
72
|
+
|
|
73
|
+
def _parse_direct_url(data: dict) -> dict:
|
|
74
|
+
"""Extrait url/commit/ref d'un direct_url.json (PEP 610)."""
|
|
75
|
+
url = data.get("url")
|
|
76
|
+
vcs = data.get("vcs_info") or {}
|
|
77
|
+
return {
|
|
78
|
+
"url": url,
|
|
79
|
+
"installed_commit": vcs.get("commit_id"),
|
|
80
|
+
"ref": vcs.get("requested_revision"), # peut être absent
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _read_dist_info_version(tool_path: Path) -> str | None:
|
|
85
|
+
"""Lit la version installée depuis *.dist-info/METADATA (header PEP 566 'Version:').
|
|
86
|
+
|
|
87
|
+
Fallback universel : couvre les tools PyPI où le receipt ne stocke pas la version.
|
|
88
|
+
"""
|
|
89
|
+
for dist_info in tool_path.rglob("*.dist-info"):
|
|
90
|
+
metadata = dist_info / "METADATA"
|
|
91
|
+
if not metadata.is_file():
|
|
92
|
+
continue
|
|
93
|
+
try:
|
|
94
|
+
for line in metadata.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
95
|
+
if line.lower().startswith("version:"):
|
|
96
|
+
return line.split(":", 1)[1].strip()
|
|
97
|
+
except OSError:
|
|
98
|
+
continue
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _find_direct_url_json(tool_path: Path) -> dict | None:
|
|
103
|
+
"""Cherche un direct_url.json dans le venv du tool (layout uv variable)."""
|
|
104
|
+
# uv place le venv soit directement dans tool_path, soit dans un sous-dossier.
|
|
105
|
+
for dist_info in tool_path.rglob("*.dist-info"):
|
|
106
|
+
du = dist_info / "direct_url.json"
|
|
107
|
+
if du.is_file():
|
|
108
|
+
try:
|
|
109
|
+
return json.loads(du.read_text(encoding="utf-8"))
|
|
110
|
+
except (json.JSONDecodeError, OSError):
|
|
111
|
+
continue
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _read_receipt(tool_path: Path) -> dict | None:
|
|
116
|
+
"""Lit uv-receipt.toml et tente d'en extraire url/commit/ref + version.
|
|
117
|
+
|
|
118
|
+
Le schéma du receipt a évolué selon les versions d'uv ; on reste tolérant
|
|
119
|
+
et on récupère ce qu'on peut, peu importe l'emplacement exact des clés.
|
|
120
|
+
"""
|
|
121
|
+
receipt = tool_path / "uv-receipt.toml"
|
|
122
|
+
if not receipt.is_file():
|
|
123
|
+
return None
|
|
124
|
+
try:
|
|
125
|
+
data = tomllib.loads(receipt.read_text(encoding="utf-8"))
|
|
126
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
found: dict = {"url": None, "installed_commit": None, "ref": None, "version": None}
|
|
130
|
+
|
|
131
|
+
def walk(obj: object) -> None:
|
|
132
|
+
if isinstance(obj, dict):
|
|
133
|
+
# clés git possibles selon les versions d'uv
|
|
134
|
+
for k, v in obj.items():
|
|
135
|
+
if isinstance(v, str):
|
|
136
|
+
lk = k.lower()
|
|
137
|
+
# clé "git" explicite -> on prend la valeur telle quelle
|
|
138
|
+
# (couvre les URLs http(s)/ssh ET les chemins git locaux).
|
|
139
|
+
if lk == "git" and v:
|
|
140
|
+
found["url"] = found["url"] or v
|
|
141
|
+
elif lk in {"url", "repository"} and (
|
|
142
|
+
"://" in v or v.startswith("git@") or v.endswith(".git")
|
|
143
|
+
):
|
|
144
|
+
found["url"] = found["url"] or v
|
|
145
|
+
elif lk in {"rev", "commit", "commit_id", "precise"} and _looks_like_sha(v):
|
|
146
|
+
found["installed_commit"] = found["installed_commit"] or v
|
|
147
|
+
elif lk in {"reference", "requested_revision", "branch", "tag", "ref"}:
|
|
148
|
+
found["ref"] = found["ref"] or v
|
|
149
|
+
elif lk == "version":
|
|
150
|
+
found["version"] = found["version"] or v
|
|
151
|
+
for v in obj.values():
|
|
152
|
+
walk(v)
|
|
153
|
+
elif isinstance(obj, list):
|
|
154
|
+
for v in obj:
|
|
155
|
+
walk(v)
|
|
156
|
+
|
|
157
|
+
walk(data)
|
|
158
|
+
return found
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _looks_like_sha(s: str) -> bool:
|
|
162
|
+
return 7 <= len(s) <= 40 and all(c in "0123456789abcdef" for c in s.lower())
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _split_git_url(raw: str) -> tuple[str, str | None]:
|
|
166
|
+
"""Sépare une URL git de sa ref encodée.
|
|
167
|
+
|
|
168
|
+
uv (et pip) encodent parfois la ref dans l'URL :
|
|
169
|
+
- https://host/repo.git?rev=main -> (https://host/repo.git, "main")
|
|
170
|
+
- https://host/repo.git@v1.2 -> (https://host/repo.git, "v1.2")
|
|
171
|
+
- https://host/repo.git#main -> (https://host/repo.git, "main")
|
|
172
|
+
- git+https://host/repo.git -> (https://host/repo.git, None)
|
|
173
|
+
Renvoie (url_propre, ref|None). ls-remote a besoin de l'URL SANS la ref.
|
|
174
|
+
"""
|
|
175
|
+
ref: str | None = None
|
|
176
|
+
url = raw.strip()
|
|
177
|
+
|
|
178
|
+
# préfixe pip "git+"
|
|
179
|
+
if url.startswith("git+"):
|
|
180
|
+
url = url[4:]
|
|
181
|
+
|
|
182
|
+
# ?rev=... ou &rev=... (paramètre de requête)
|
|
183
|
+
for sep in ("?", "&"):
|
|
184
|
+
if sep in url:
|
|
185
|
+
base, _, query = url.partition(sep)
|
|
186
|
+
for part in query.split("&"):
|
|
187
|
+
k, _, v = part.partition("=")
|
|
188
|
+
if k in {"rev", "ref", "branch", "tag"} and v:
|
|
189
|
+
ref = ref or v
|
|
190
|
+
# on retire toute la query de l'URL
|
|
191
|
+
url = base
|
|
192
|
+
|
|
193
|
+
# fragment #ref
|
|
194
|
+
if "#" in url:
|
|
195
|
+
url, _, frag = url.partition("#")
|
|
196
|
+
# fragments du style "egg=...&subdirectory=..." : ignorer; sinon = ref
|
|
197
|
+
if frag and "=" not in frag:
|
|
198
|
+
ref = ref or frag
|
|
199
|
+
|
|
200
|
+
# suffixe @ref (après le .git), mais PAS le @ de git@host (scp-like)
|
|
201
|
+
# on ne traite le @ que s'il suit ".git"
|
|
202
|
+
git_at = url.rfind(".git@")
|
|
203
|
+
if git_at != -1:
|
|
204
|
+
ref = ref or url[git_at + len(".git@"):]
|
|
205
|
+
url = url[: git_at + len(".git")]
|
|
206
|
+
|
|
207
|
+
return url, ref
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def read_install_info(tool_path: Path) -> dict:
|
|
211
|
+
"""Combine receipt (prioritaire) et direct_url.json (repli/complément)."""
|
|
212
|
+
info: dict = {"url": None, "installed_commit": None, "ref": None, "version": None}
|
|
213
|
+
|
|
214
|
+
receipt = _read_receipt(tool_path)
|
|
215
|
+
if receipt:
|
|
216
|
+
for k in info:
|
|
217
|
+
info[k] = info[k] or receipt.get(k)
|
|
218
|
+
|
|
219
|
+
# complète les trous avec direct_url.json
|
|
220
|
+
if not all((info["url"], info["installed_commit"])):
|
|
221
|
+
du = _find_direct_url_json(tool_path)
|
|
222
|
+
if du:
|
|
223
|
+
parsed = _parse_direct_url(du)
|
|
224
|
+
for k in ("url", "installed_commit", "ref"):
|
|
225
|
+
info[k] = info[k] or parsed.get(k)
|
|
226
|
+
|
|
227
|
+
# nettoie l'URL (retire ?rev=/@ref/#frag) et récupère la ref encodée s'il
|
|
228
|
+
# n'y en avait pas. ls-remote a besoin de l'URL nue.
|
|
229
|
+
if info["url"]:
|
|
230
|
+
clean_url, url_ref = _split_git_url(info["url"])
|
|
231
|
+
info["url"] = clean_url
|
|
232
|
+
info["ref"] = info["ref"] or url_ref
|
|
233
|
+
|
|
234
|
+
# dernier recours : lire la version depuis dist-info/METADATA
|
|
235
|
+
if not info["version"]:
|
|
236
|
+
info["version"] = _read_dist_info_version(tool_path)
|
|
237
|
+
return info
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# --- HEAD distant sans clone -------------------------------------------------
|
|
241
|
+
|
|
242
|
+
async def remote_head(url: str, ref: str | None = None, *, timeout: float = 30.0) -> str | None:
|
|
243
|
+
"""SHA du HEAD distant via ls-remote. ref None -> HEAD par défaut du repo."""
|
|
244
|
+
args = ["git", "ls-remote", url]
|
|
245
|
+
if ref:
|
|
246
|
+
args.append(ref)
|
|
247
|
+
try:
|
|
248
|
+
proc = await asyncio.create_subprocess_exec(
|
|
249
|
+
*args,
|
|
250
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
|
251
|
+
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, # pas de prompt auth
|
|
252
|
+
)
|
|
253
|
+
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
254
|
+
except (asyncio.TimeoutError, FileNotFoundError):
|
|
255
|
+
return None
|
|
256
|
+
if proc.returncode != 0:
|
|
257
|
+
return None
|
|
258
|
+
text = out.decode(errors="replace").strip()
|
|
259
|
+
if not text:
|
|
260
|
+
return None
|
|
261
|
+
# ls-remote peut renvoyer plusieurs lignes (ex refs/heads/main + refs/tags).
|
|
262
|
+
# On préfère une ligne refs/heads/<ref> si ref donné, sinon la 1re.
|
|
263
|
+
lines = [ln for ln in text.splitlines() if ln.strip()]
|
|
264
|
+
if ref:
|
|
265
|
+
for ln in lines:
|
|
266
|
+
sha, _, name = ln.partition("\t")
|
|
267
|
+
if name.endswith(f"refs/heads/{ref}") or name == ref:
|
|
268
|
+
return sha.strip()
|
|
269
|
+
return lines[0].split("\t")[0].strip()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _same_commit(a: str | None, b: str | None) -> bool:
|
|
273
|
+
"""Compare deux SHA possiblement de longueurs différentes (court vs long)."""
|
|
274
|
+
if not a or not b:
|
|
275
|
+
return False
|
|
276
|
+
a, b = a.lower(), b.lower()
|
|
277
|
+
n = min(len(a), len(b))
|
|
278
|
+
return n >= 7 and a[:n] == b[:n]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# --- scan d'un tool ----------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
async def scan_tool(name: str, tool_path: Path, *, check_remote: bool = True) -> ToolInfo:
|
|
284
|
+
"""Scanne un tool uv installé et détermine si une mise à jour est disponible."""
|
|
285
|
+
try:
|
|
286
|
+
info = read_install_info(tool_path)
|
|
287
|
+
url = info["url"]
|
|
288
|
+
installed = info["installed_commit"]
|
|
289
|
+
ref = info["ref"]
|
|
290
|
+
version = info["version"]
|
|
291
|
+
|
|
292
|
+
if not url:
|
|
293
|
+
return ToolInfo(name, version, None, None, installed, None, "not git")
|
|
294
|
+
|
|
295
|
+
remote = None
|
|
296
|
+
if check_remote:
|
|
297
|
+
remote = await remote_head(url, ref)
|
|
298
|
+
|
|
299
|
+
if remote is None:
|
|
300
|
+
status = "unknown" # offline, auth, repo privé...
|
|
301
|
+
elif _same_commit(installed, remote):
|
|
302
|
+
status = "up to date"
|
|
303
|
+
else:
|
|
304
|
+
status = "update available"
|
|
305
|
+
|
|
306
|
+
return ToolInfo(name, version, url, ref, installed, remote, status)
|
|
307
|
+
except Exception as exc:
|
|
308
|
+
return ToolInfo(name, None, None, None, None, None, "error",
|
|
309
|
+
error=f"{type(exc).__name__}: {exc}")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def find_tools(tools_root: Path) -> list[tuple[str, Path]]:
|
|
313
|
+
"""Liste (nom, chemin) des tools : chaque sous-dossier direct de la racine."""
|
|
314
|
+
out: list[tuple[str, Path]] = []
|
|
315
|
+
try:
|
|
316
|
+
for d in sorted(tools_root.iterdir()):
|
|
317
|
+
if d.is_dir() and not d.name.startswith("."):
|
|
318
|
+
out.append((d.name, d))
|
|
319
|
+
except (OSError, PermissionError):
|
|
320
|
+
pass
|
|
321
|
+
return out
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
async def scan_tools(
|
|
325
|
+
tools_root: Path | None = None, *, check_remote: bool = True, concurrency: int = 16
|
|
326
|
+
) -> list[ToolInfo]:
|
|
327
|
+
"""Scanne tous les uv tools. Trouve la racine automatiquement si non fournie."""
|
|
328
|
+
root = tools_root or await uv_tool_dir()
|
|
329
|
+
if root is None:
|
|
330
|
+
return []
|
|
331
|
+
tools = find_tools(root)
|
|
332
|
+
sem = asyncio.Semaphore(concurrency)
|
|
333
|
+
|
|
334
|
+
async def bounded(item: tuple[str, Path]) -> ToolInfo:
|
|
335
|
+
name, path = item
|
|
336
|
+
async with sem:
|
|
337
|
+
return await scan_tool(name, path, check_remote=check_remote)
|
|
338
|
+
|
|
339
|
+
return list(await asyncio.gather(*(bounded(t) for t in tools)))
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: projscan
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scanner de dépôts git locaux et de tools uv installés.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: click>=8
|
|
7
|
+
Requires-Dist: rich>=13
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# projscan
|
|
11
|
+
|
|
12
|
+
Scanner de dépôts git locaux et de tools `uv` installés depuis git.
|
|
13
|
+
Répond à une question simple : **suis-je à jour ?**
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv tool install git+https://github.com/Caymaar/projscan.git
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Ou en mode développement :
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
git clone ...
|
|
25
|
+
cd projscan
|
|
26
|
+
uv sync
|
|
27
|
+
uv run projscan --help
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Commandes
|
|
31
|
+
|
|
32
|
+
### `projscan repos [PATHS]...`
|
|
33
|
+
|
|
34
|
+
Scanne récursivement un ou plusieurs dossiers et affiche l'état de chaque dépôt git.
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
╭──────────────────────┬─────────┬─────────┬─────────────────────┬────────┬────────────────────┬─────────────╮
|
|
38
|
+
│ Nom │ Version │ Branche │ Statut │ Commit │ Message │ Quand │
|
|
39
|
+
├──────────────────────┼─────────┼─────────┼─────────────────────┼────────┼────────────────────┼─────────────┤
|
|
40
|
+
│ mon-api │ 2.1.0 │ main │ en retard (↓3) │ a4f2c1 │ fix: timeout retry │ 2 days ago │
|
|
41
|
+
│ mon-dashboard │ 1.0.0 │ main │ divergé (↓1 ↑2) │ b8e3d9 │ feat: dark mode │ 5 hours ago │
|
|
42
|
+
│ projscan │ 0.1.0 │ main │ à jour │ c1a2b3 │ initial commit │ 1 hour ago │
|
|
43
|
+
╰──────────────────────┴─────────┴─────────┴─────────────────────┴────────┴────────────────────┴─────────────╯
|
|
44
|
+
3 repos, 1 à jour, 2 à mettre à jour
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Options :**
|
|
48
|
+
|
|
49
|
+
| Option | Description |
|
|
50
|
+
|--------|-------------|
|
|
51
|
+
| `--no-fetch` | Ne pas faire `git fetch` — utilise le cache local (rapide) |
|
|
52
|
+
| `--depth INTEGER` | Profondeur max de récursion (défaut : 3) |
|
|
53
|
+
| `--json` | Sortie JSON machine-readable |
|
|
54
|
+
| `--concurrency INTEGER` | Scans parallèles (défaut : 16) |
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# Scanne ~/code et ~/work
|
|
58
|
+
projscan repos ~/code ~/work
|
|
59
|
+
|
|
60
|
+
# Rapide, sans réseau
|
|
61
|
+
projscan repos --no-fetch ~/code
|
|
62
|
+
|
|
63
|
+
# Pipe JSON vers jq
|
|
64
|
+
projscan repos --no-fetch --json ~/code | jq '.[] | select(.status != "à jour")'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### `projscan tools`
|
|
68
|
+
|
|
69
|
+
Scanne les tools installés via `uv tool install git+https://...` et vérifie si des mises à jour sont disponibles (via `git ls-remote`, sans cloner).
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
╭────────────────┬─────────┬───────────────────────────────┬────────┬──────────┬───────────╮
|
|
73
|
+
│ Nom │ Version │ Source │ Ref │ Commit │ Statut │
|
|
74
|
+
├────────────────┼─────────┼───────────────────────────────┼────────┼──────────┼───────────┤
|
|
75
|
+
│ mon-tool │ 1.2.0 │ github.com/org/mon-tool.git │ main │ f10c2123 │ MAJ dispo │
|
|
76
|
+
│ autre-tool │ 0.9.1 │ github.com/org/autre-tool.git │ main │ fc9e74c5 │ à jour │
|
|
77
|
+
╰────────────────┴─────────┴───────────────────────────────┴────────┴──────────┴───────────╯
|
|
78
|
+
2 tools, 1 à jour, 1 à mettre à jour
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Options :**
|
|
82
|
+
|
|
83
|
+
| Option | Description |
|
|
84
|
+
|--------|-------------|
|
|
85
|
+
| `--tool-dir PATH` | Racine des tools uv (détection auto par défaut) |
|
|
86
|
+
| `--no-remote` | Liste locale sans vérifier les remotes |
|
|
87
|
+
| `--json` | Sortie JSON machine-readable |
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
projscan tools
|
|
91
|
+
projscan tools --no-remote --json
|
|
92
|
+
projscan tools --tool-dir ~/.local/share/uv/tools
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Codes de sortie
|
|
96
|
+
|
|
97
|
+
| Code | Signification |
|
|
98
|
+
|------|---------------|
|
|
99
|
+
| `0` | Tout est à jour |
|
|
100
|
+
| `1` | Au moins un item à mettre à jour (ou en erreur) |
|
|
101
|
+
| `2` | Erreur d'exécution fatale |
|
|
102
|
+
| `130` | Interruption clavier (Ctrl-C) |
|
|
103
|
+
|
|
104
|
+
Utile en script ou en CI :
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
projscan repos --no-fetch ~/code || echo "Des repos sont en retard !"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Couleurs de statut
|
|
111
|
+
|
|
112
|
+
| Statut | Couleur | Signification |
|
|
113
|
+
|--------|---------|---------------|
|
|
114
|
+
| `à jour` | vert | Synchronisé avec l'upstream |
|
|
115
|
+
| `en retard (↓N)` | rouge | N commits distants non récupérés |
|
|
116
|
+
| `en avance (↑N)` | cyan | N commits locaux non poussés |
|
|
117
|
+
| `divergé (↓N ↑M)` | magenta | Branches divergées |
|
|
118
|
+
| `MAJ dispo` | jaune | Nouvelle version du tool disponible |
|
|
119
|
+
| `pas d'upstream` | gris | Pas de tracking branch configurée |
|
|
120
|
+
| `pas git` | gris | Tool installé depuis PyPI, pas git |
|
|
121
|
+
| `inconnu` | gris | Remote inaccessible (offline, auth...) |
|
|
122
|
+
| `erreur` | rouge gras | Erreur lors du scan |
|
|
123
|
+
|
|
124
|
+
## Usage comme bibliothèque
|
|
125
|
+
|
|
126
|
+
`projscan` est conçu pour être importé dans une app Textual ou tout autre code async.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from pathlib import Path
|
|
130
|
+
from projscan import scan_paths, scan_tools, RepoInfo, ToolInfo
|
|
131
|
+
|
|
132
|
+
# Scanne des dépôts git
|
|
133
|
+
repos: list[RepoInfo] = await scan_paths(
|
|
134
|
+
[Path("~/code")],
|
|
135
|
+
fetch=True,
|
|
136
|
+
max_depth=3,
|
|
137
|
+
concurrency=16,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
for repo in repos:
|
|
141
|
+
print(f"{repo.name}: {repo.status}")
|
|
142
|
+
|
|
143
|
+
# Scanne les tools uv
|
|
144
|
+
tools: list[ToolInfo] = await scan_tools(check_remote=True)
|
|
145
|
+
|
|
146
|
+
for tool in tools:
|
|
147
|
+
if tool.status == "MAJ dispo":
|
|
148
|
+
print(f"Update available: {tool.name}")
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Les dataclasses exposent une méthode `.as_dict()` pour la sérialisation JSON :
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
import json
|
|
155
|
+
print(json.dumps([r.as_dict() for r in repos], ensure_ascii=False))
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
L'API publique complète :
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from projscan import (
|
|
162
|
+
# Git
|
|
163
|
+
RepoInfo, scan_paths, scan_repo, find_repos,
|
|
164
|
+
# UV tools
|
|
165
|
+
ToolInfo, scan_tools, scan_tool, uv_tool_dir,
|
|
166
|
+
)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Développement
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
uv sync
|
|
173
|
+
uv run pytest # 52 tests, ~5s
|
|
174
|
+
uv run projscan repos . # test en réel
|
|
175
|
+
uv run projscan tools # test en réel
|
|
176
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
projscan/__init__.py,sha256=sgqrRDKm3sj7qobhadsH1pYMYKU4e7ZbNspChXRgZkg,681
|
|
2
|
+
projscan/cli.py,sha256=UNUDJVgQKff10t-hr6A2_dMMC2tgJGS4ESA4W0ThG2s,6813
|
|
3
|
+
projscan/git.py,sha256=lwkziO-7012gbFc6fDPs-zSoqisJLaG5WHLhBM8q8E8,7709
|
|
4
|
+
projscan/render.py,sha256=PFwhsXzS-403YVDwIbu06n_mPttvh-6PETE7nbmUgbo,3846
|
|
5
|
+
projscan/uv.py,sha256=1_kX00NN09AbV5JnK_J-5iUcWI5O2w7A1eMpwpmsvYA,12521
|
|
6
|
+
projscan-0.1.0.dist-info/METADATA,sha256=9uq6XIvWIQIb3edHzp1BztmgWK85NCtsoGuZWSOlbDA,6670
|
|
7
|
+
projscan-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
projscan-0.1.0.dist-info/entry_points.txt,sha256=hH1yaUyPwyyS06ICDTfBAyOF3AnjaDsPimiYgwq1WpI,47
|
|
9
|
+
projscan-0.1.0.dist-info/RECORD,,
|