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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ projscan = projscan.cli:main