depscout 0.1.0__tar.gz

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.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ .pytest_cache/
3
+ .venv
4
+ .depscout
5
+ eval/
6
+ uv.lock
7
+ tests/
8
+ .python-version
depscout-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Miguel Soares
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: depscout
3
+ Version: 0.1.0
4
+ Summary: Upgrade intelligence for your Python dependencies
5
+ Project-URL: Homepage, https://github.com/zemmsoares/depscout
6
+ Project-URL: Issues, https://github.com/zemmsoares/depscout/issues
7
+ Author: Miguel Soares
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: httpx>=0.25.0
17
+ Requires-Dist: ollama>=0.6.1
18
+ Requires-Dist: rich>=14.0.0
19
+ Requires-Dist: typer>=0.12.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # depscout
23
+
24
+ Scans your Python project dependencies and uses an LLM to flag what's worth acting on - outdated packages, unmaintained libraries, and better alternatives.
25
+
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install depscout
31
+ # or
32
+ uv tool install depscout
33
+ ```
34
+
35
+ ## Setup
36
+
37
+ **Ollama (Local):**
38
+ ```bash
39
+ depscout config provider ollama
40
+ ollama pull qwen2.5:4b
41
+ depscout config model qwen2.5:4b
42
+ ```
43
+
44
+ **OpenAI:**
45
+ ```bash
46
+ depscout config provider openai
47
+ depscout config openai-key sk-...
48
+ depscout config model gpt-4o-mini
49
+ ```
50
+
51
+ **GitHub token (optional):** avoids rate limits if you have many dependencies
52
+ ```bash
53
+ depscout config github-token ghp_...
54
+ ```
55
+
56
+ ## Commands
57
+
58
+ ```
59
+ depscout scan [PATH] AI analysis — surfaces insights
60
+ depscout check [PATH] Version check only, no AI
61
+ depscout status Show current config
62
+ depscout config List all config options
63
+ ```
64
+
65
+ ## Contributing
66
+ PRs are welcome!
@@ -0,0 +1,45 @@
1
+ # depscout
2
+
3
+ Scans your Python project dependencies and uses an LLM to flag what's worth acting on - outdated packages, unmaintained libraries, and better alternatives.
4
+
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install depscout
10
+ # or
11
+ uv tool install depscout
12
+ ```
13
+
14
+ ## Setup
15
+
16
+ **Ollama (Local):**
17
+ ```bash
18
+ depscout config provider ollama
19
+ ollama pull qwen2.5:4b
20
+ depscout config model qwen2.5:4b
21
+ ```
22
+
23
+ **OpenAI:**
24
+ ```bash
25
+ depscout config provider openai
26
+ depscout config openai-key sk-...
27
+ depscout config model gpt-4o-mini
28
+ ```
29
+
30
+ **GitHub token (optional):** avoids rate limits if you have many dependencies
31
+ ```bash
32
+ depscout config github-token ghp_...
33
+ ```
34
+
35
+ ## Commands
36
+
37
+ ```
38
+ depscout scan [PATH] AI analysis — surfaces insights
39
+ depscout check [PATH] Version check only, no AI
40
+ depscout status Show current config
41
+ depscout config List all config options
42
+ ```
43
+
44
+ ## Contributing
45
+ PRs are welcome!
File without changes
@@ -0,0 +1,242 @@
1
+ import json
2
+ import os
3
+ import re
4
+ from datetime import date
5
+
6
+ import ollama
7
+
8
+ import depscout.deps as _deps
9
+ from depscout import config as cfg
10
+
11
+ DEFAULT_OPENAI_MODEL = "gpt-4.1"
12
+
13
+
14
+ def _build_prompt(deps):
15
+ lines = []
16
+ for name, info in deps.items():
17
+ current = info.get("current", "unknown")
18
+ latest = info.get("latest", "unknown")
19
+ changelog = info.get("changelog", "")
20
+
21
+ version_status = f"{current} → {latest}" if current != latest else f"{current} (up to date)"
22
+ lines.append(f"- {name}: {version_status}")
23
+ if info.get("summary"):
24
+ lines.append(f" summary: {info['summary']}")
25
+ if info.get("github_description") and info.get("github_description") != info.get("summary"):
26
+ lines.append(f" github description: {info['github_description']}")
27
+ if info.get("github_readme"):
28
+ lines.append(f" github readme (first 500 chars): {info['github_readme'][:500]}")
29
+ if info.get("dev_status"):
30
+ lines.append(f" status: {info['dev_status']}")
31
+ if info.get("last_release_date"):
32
+ lines.append(f" last release: {info['last_release_date']}")
33
+ if info.get("pushed_at"):
34
+ lines.append(f" last commit: {info['pushed_at']}")
35
+ if info.get("stars") is not None:
36
+ lines.append(f" stars: {info['stars']}, forks: {info.get('forks', 'unknown')}, open issues: {info.get('open_issues', 'unknown')}")
37
+ if info.get("archived") or info.get("disabled"):
38
+ lines.append(f" archived: yes")
39
+ if info.get("topics"):
40
+ lines.append(f" topics: {', '.join(info['topics'])}")
41
+ if info.get("requires_python"):
42
+ lines.append(f" requires python: {info['requires_python']}")
43
+ if info.get("vulnerabilities"):
44
+ cves = ", ".join(v.get("id", "unknown") for v in info["vulnerabilities"])
45
+ lines.append(f" known vulnerabilities: {cves}")
46
+ if changelog:
47
+ for entry in changelog:
48
+ notes = entry["notes"].replace("\r\n", "\n").replace("\r", "\n")
49
+ notes = re.sub(r"<!--.*?-->", "", notes, flags=re.DOTALL)
50
+ notes = re.sub(r"^#{1,6}\s+.*$", "", notes, flags=re.MULTILINE)
51
+ notes = re.sub(r"\*\*Full Changelog\*\*.*", "", notes)
52
+ notes = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", notes)
53
+ notes = re.sub(r"\n{2,}", "\n", notes).strip()
54
+ lines.append(f" [{entry['version']}] {notes[:500]}")
55
+
56
+ dep_summary = "\n".join(lines)
57
+
58
+ return f"""You are a Python dependency advisor. A developer ran your tool on their project.
59
+ Today's date is {date.today()}.
60
+
61
+ Here are their dependencies:
62
+ {dep_summary}
63
+
64
+ Generate 0 to 5 concise insights that would actually help this developer.
65
+
66
+ MANDATORY — always generate an insight if any of these are true:
67
+ - The changelog contains a CVE or security advisory (non-negotiable, always flag)
68
+ - The package is significantly behind and breaking changes affect the developer
69
+
70
+ OPTIONAL — generate an insight if genuinely useful:
71
+ - A better modern alternative exists that the community now prefers
72
+ - The package is unmaintained or losing community support
73
+ - A deprecated pattern or API is in use
74
+
75
+ If none of the above apply, return an empty array [].
76
+
77
+ Rules:
78
+ - One insight per package maximum
79
+ - Every insight must include all four fields: package, title, body, category
80
+ - Do not add any fields beyond the four specified
81
+ - Return ONLY a raw JSON array — no markdown, no code fences, no explanation
82
+
83
+ Each object must have EXACTLY these four keys, no more, no less:
84
+ "package" — the exact package name this insight is about
85
+ "title" — one short sentence under 70 characters
86
+ "body" — 1 to 2 sentences of concrete, specific explanation
87
+ "category" — exactly one of: outdated, alternative, pattern, unmaintained
88
+
89
+ Example of valid output format (these are NOT based on the dependencies above):
90
+ [
91
+ {{
92
+ "package": "example-pkg",
93
+ "title": "example-pkg is 6 major versions behind",
94
+ "body": "You are on 0.1.0 but the latest is 0.6.1. The client API changed significantly — generate() responses are now typed objects, not plain dicts.",
95
+ "category": "outdated"
96
+ }},
97
+ {{
98
+ "package": "another-pkg",
99
+ "title": "another-pkg 2.23.0 has unpatched CVEs — upgrade to 2.32.4+",
100
+ "body": "CVE-2024-47081 (credential leak via netrc) and a cert verification bypass were fixed in 2.32.x. You are 9 versions behind and exposed to these vulnerabilities.",
101
+ "category": "outdated"
102
+ }}
103
+ ]
104
+
105
+ Now generate insights for the dependencies listed above:"""
106
+
107
+
108
+ def _parse_response(raw):
109
+ raw = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
110
+ raw = re.sub(r"```[a-z]*\n?", "", raw).strip()
111
+ start = raw.find("[")
112
+ end = raw.rfind("]") + 1
113
+ if start == -1 or end == 0:
114
+ return []
115
+ try:
116
+ insights = json.loads(raw[start:end])
117
+ required = {"package", "title", "body", "category"}
118
+ return [i for i in insights if required.issubset(i.keys())]
119
+ except json.JSONDecodeError:
120
+ return []
121
+
122
+
123
+ def _deduplicate(insights):
124
+ seen = set()
125
+ result = []
126
+ for insight in insights:
127
+ pkg = insight.get("package", "")
128
+ if pkg not in seen:
129
+ seen.add(pkg)
130
+ result.append(insight)
131
+ return result
132
+
133
+
134
+ def _filter_factual_errors(insights, deps):
135
+ result = []
136
+ for insight in insights:
137
+ pkg = insight.get("package", "")
138
+ info = deps.get(pkg)
139
+ if insight.get("category") == "outdated" and info:
140
+ if info.get("current") == info.get("latest"):
141
+ continue
142
+ result.append(insight)
143
+ return result
144
+
145
+
146
+ def _resolve_ollama_model(configured_model):
147
+ if configured_model:
148
+ return configured_model
149
+ try:
150
+ installed = [m.model for m in ollama.list().models]
151
+ except Exception:
152
+ raise RuntimeError(
153
+ "Could not connect to Ollama. Make sure it is running: ollama serve\n"
154
+ "Or use OpenAI instead: depscout config openai-key sk-..."
155
+ )
156
+ if not installed:
157
+ raise RuntimeError(
158
+ "No Ollama models installed. Pull one first, for example:\n"
159
+ " ollama pull qwen2.5:4b\n"
160
+ "Or use OpenAI instead: depscout config openai-key sk-..."
161
+ )
162
+ if len(installed) == 1:
163
+ return installed[0]
164
+ names = "\n".join(f" {m}" for m in installed)
165
+ raise RuntimeError(
166
+ f"Multiple Ollama models installed. Pick one:\n{names}\n\n"
167
+ f"Run: depscout config model <model-name>"
168
+ )
169
+
170
+
171
+ def _resolve_provider():
172
+ provider = os.environ.get("DEPSCOUT_PROVIDER") or cfg.get("provider")
173
+ model = os.environ.get("DEPSCOUT_MODEL") or cfg.get("model")
174
+ api_key = os.environ.get("OPENAI_API_KEY") or cfg.get("openai_key")
175
+
176
+ if provider == "openai":
177
+ if not api_key:
178
+ raise RuntimeError(
179
+ "Provider is set to 'openai' but no API key found.\n"
180
+ "Run: depscout config openai-key sk-..."
181
+ )
182
+ return "openai", model or DEFAULT_OPENAI_MODEL, api_key
183
+
184
+ return "ollama", _resolve_ollama_model(model), None
185
+
186
+
187
+ def _call_llm(prompt):
188
+ provider, model, api_key = _resolve_provider()
189
+
190
+ if provider == "openai":
191
+ import httpx
192
+ r = httpx.post(
193
+ "https://api.openai.com/v1/chat/completions",
194
+ headers={"Authorization": f"Bearer {api_key}"},
195
+ json={"model": model, "messages": [{"role": "user", "content": prompt}]},
196
+ timeout=30,
197
+ )
198
+ r.raise_for_status()
199
+ return r.json()["choices"][0]["message"]["content"], model
200
+
201
+ response = ollama.generate(model=model, prompt=prompt, options={"num_ctx": 8192}, think=False)
202
+ return response["response"], model
203
+
204
+
205
+ def _save_debug(prompt, raw_response, insights, model_used):
206
+ from datetime import datetime
207
+ if _deps.CACHE_DIR is None:
208
+ return
209
+ debug_dir = _deps.CACHE_DIR / "debug"
210
+ debug_dir.mkdir(exist_ok=True)
211
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
212
+ with open(debug_dir / f"{timestamp}.json", "w") as f:
213
+ json.dump({
214
+ "model": model_used,
215
+ "timestamp": timestamp,
216
+ "prompt": prompt,
217
+ "raw_response": raw_response,
218
+ "parsed_insights": insights,
219
+ }, f, indent=2)
220
+
221
+
222
+ def analyze(deps=None):
223
+ if deps is None:
224
+ with open(_deps.DEPS_FILE) as f:
225
+ deps = json.load(f)
226
+
227
+ prompt = _build_prompt(deps)
228
+ raw, model_used = _call_llm(prompt)
229
+
230
+ if not raw.strip():
231
+ raise RuntimeError(f"Model {model_used!r} returned an empty response.")
232
+
233
+ insights = _parse_response(raw)
234
+ insights = _deduplicate(insights)
235
+ insights = _filter_factual_errors(insights, deps)
236
+
237
+ # _save_debug(prompt, raw, insights, model_used)
238
+
239
+ with open(_deps.CACHE_DIR / "insights.json", "w") as f:
240
+ json.dump(insights, f, indent=4)
241
+
242
+ return insights
@@ -0,0 +1,191 @@
1
+ import importlib.metadata
2
+ import os
3
+
4
+ import typer
5
+ from rich.console import Console
6
+
7
+ from depscout.analyst import analyze
8
+ from depscout.deps import scan as collect
9
+ from depscout.enrich import enrich
10
+ from depscout import config as cfg
11
+
12
+ app = typer.Typer(
13
+ no_args_is_help=True,
14
+ add_completion=False,
15
+ help="Upgrade intelligence for your Python dependencies.",
16
+ pretty_exceptions_enable=False,
17
+ )
18
+ console = Console()
19
+
20
+ CATEGORY_COLORS = {
21
+ "outdated": "yellow",
22
+ "alternative": "cyan",
23
+ "pattern": "magenta",
24
+ "unmaintained": "red",
25
+ }
26
+
27
+ CONFIG_KEYS = {
28
+ "provider": ("ollama or openai", "depscout config provider ollama"),
29
+ "model": ("model name to use", "depscout config model qwen2.5:4b"),
30
+ "openai-key": ("OpenAI API key", "depscout config openai-key sk-..."),
31
+ "github-token": ("GitHub token for richer analysis and higher rate limits", "depscout config github-token ghp_..."),
32
+ }
33
+
34
+
35
+ def _render_insights(insights):
36
+ if not insights:
37
+ console.print("[green]All dependencies look good.[/green]")
38
+ return
39
+
40
+ console.print(f"\n [bold]{len(insights)} insights for your project[/bold]\n")
41
+
42
+ for insight in insights:
43
+ package = insight.get("package", "")
44
+ title = insight.get("title", "")
45
+ body = insight.get("body", "")
46
+ category = insight.get("category", "")
47
+ color = CATEGORY_COLORS.get(category, "white")
48
+
49
+ console.print(f" [bold]{package}[/bold] [dim {color}]{category}[/dim {color}]")
50
+ console.print(f" [bold {color}]▸ {title}[/bold {color}]")
51
+ console.print(f" [dim]{body}[/dim]")
52
+ console.print()
53
+
54
+
55
+ @app.command()
56
+ def scan(path: str = typer.Argument(".", help="Project root to scan")):
57
+ """Analyze dependencies with AI and surface actionable insights."""
58
+ with console.status("[dim]Collecting dependencies...[/dim]"):
59
+ deps = collect(path)
60
+
61
+ if not deps:
62
+ console.print("[yellow]No dependencies found.[/yellow] Make sure the directory contains a pyproject.toml or requirements*.txt file.")
63
+ raise typer.Exit(1)
64
+
65
+ with console.status("[dim]Fetching changelogs...[/dim]"):
66
+ enrich()
67
+
68
+ try:
69
+ with console.status("[dim]Analyzing with LLM...[/dim]"):
70
+ insights = analyze()
71
+ except RuntimeError as e:
72
+ console.print(f"[red]Analysis failed:[/red] {e}")
73
+ raise typer.Exit(1)
74
+ except Exception as e:
75
+ if "connection" in str(e).lower() or "refused" in str(e).lower():
76
+ console.print("[red]Could not connect to Ollama.[/red] Make sure Ollama is running: [bold]ollama serve[/bold]")
77
+ else:
78
+ console.print(f"[red]Unexpected error:[/red] {e}")
79
+ raise typer.Exit(1)
80
+
81
+ _render_insights(insights)
82
+
83
+
84
+ @app.command()
85
+ def check(path: str = typer.Argument(".", help="Project root to scan")):
86
+ """Check for newer versions without AI — fast and free."""
87
+ with console.status("[dim]Fetching latest versions from PyPI...[/dim]"):
88
+ deps = collect(path)
89
+
90
+ if not deps:
91
+ console.print("[yellow]No dependencies found.[/yellow] Make sure the directory contains a pyproject.toml or requirements*.txt file.")
92
+ raise typer.Exit(1)
93
+
94
+ outdated = {name: info for name, info in deps.items() if info.get("current") and info.get("latest") and info["current"] != info["latest"]}
95
+ up_to_date = {name: info for name, info in deps.items() if name not in outdated}
96
+
97
+ if not outdated:
98
+ console.print(f"\n [green]All {len(deps)} dependencies are up to date.[/green]\n")
99
+ return
100
+
101
+ pkg_width = max(len(n) for n in outdated) + 2
102
+
103
+ console.print(f"\n [bold]{len(outdated)} of {len(deps)} packages have updates[/bold]\n")
104
+ for name, info in sorted(outdated.items()):
105
+ console.print(f" [bold]{name:<{pkg_width}}[/bold][yellow]{info['current']}[/yellow] → [green]{info['latest']}[/green]")
106
+
107
+ if up_to_date:
108
+ console.print(f"\n [dim]{len(up_to_date)} packages are current[/dim]")
109
+ console.print()
110
+
111
+
112
+ @app.command()
113
+ def status():
114
+ """Show active provider, model, and configuration."""
115
+ from depscout.analyst import _resolve_provider
116
+ import ollama
117
+
118
+ console.print()
119
+
120
+ try:
121
+ provider, model, _ = _resolve_provider()
122
+ console.print(f" [bold]provider[/bold] {provider}")
123
+ console.print(f" [bold]model[/bold] {model}")
124
+ except RuntimeError as e:
125
+ console.print(f" [bold]provider[/bold] [red]not configured[/red] [dim]{e}[/dim]")
126
+
127
+ github_token = os.environ.get("GITHUB_TOKEN") or cfg.get("github_token")
128
+ console.print(f" [bold]github[/bold] {'[green]token set[/green]' if github_token else '[dim]no token — rate-limited[/dim]'}")
129
+
130
+ openai_key = os.environ.get("OPENAI_API_KEY") or cfg.get("openai_key")
131
+ console.print(f" [bold]openai key[/bold] {'[green]set[/green]' if openai_key else '[dim]not set[/dim]'}")
132
+
133
+ try:
134
+ installed = [m.model for m in ollama.list().models]
135
+ models_str = ", ".join(installed) if installed else "no models installed"
136
+ console.print(f" [bold]ollama[/bold] [green]running[/green] [dim]{models_str}[/dim]")
137
+ except Exception:
138
+ console.print(f" [bold]ollama[/bold] [dim]not running[/dim]")
139
+
140
+ console.print()
141
+ console.print(f" [dim]config file: {cfg.CONFIG_FILE}[/dim]")
142
+ console.print()
143
+
144
+
145
+ @app.command()
146
+ def config(
147
+ key: str = typer.Argument(None, help="Config key"),
148
+ value: str = typer.Argument(None, help="Value to set"),
149
+ ):
150
+ """Set a configuration value.
151
+
152
+ Available keys:
153
+
154
+ provider ollama or openai
155
+ model model name (e.g. qwen2.5:4b or gpt-4o-mini)
156
+ openai-key your OpenAI API key
157
+ github-token GitHub token for richer analysis
158
+
159
+ Examples:
160
+
161
+ depscout config provider openai
162
+ depscout config model gpt-4o-mini
163
+ depscout config openai-key sk-...
164
+ depscout config github-token ghp_...
165
+ """
166
+ if key is None or value is None:
167
+ console.print()
168
+ console.print(" [bold]Available config keys:[/bold]\n")
169
+ key_width = max(len(k) for k in CONFIG_KEYS) + 2
170
+ for k, (desc, example) in CONFIG_KEYS.items():
171
+ console.print(f" [bold]{k:<{key_width}}[/bold][dim]{desc}[/dim]")
172
+ console.print(f" {' ' * key_width}[dim italic]{example}[/dim italic]")
173
+ console.print()
174
+ raise typer.Exit()
175
+
176
+ cfg.set(key.replace("-", "_"), value)
177
+ console.print(f"[dim]{key} saved.[/dim]")
178
+
179
+
180
+ @app.command()
181
+ def version():
182
+ """Show depscout version."""
183
+ try:
184
+ v = importlib.metadata.version("depscout")
185
+ except importlib.metadata.PackageNotFoundError:
186
+ v = "dev"
187
+ console.print(f"depscout {v}")
188
+
189
+
190
+ def entrypoint():
191
+ app()
@@ -0,0 +1,26 @@
1
+ import json
2
+ import os
3
+ import pathlib
4
+
5
+ CONFIG_FILE = pathlib.Path.home() / ".config" / "depscout" / "config.json"
6
+
7
+
8
+ def get(key):
9
+ try:
10
+ with open(CONFIG_FILE) as f:
11
+ return json.load(f).get(key)
12
+ except Exception:
13
+ return None
14
+
15
+
16
+ def set(key, value):
17
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
18
+ data = {}
19
+ try:
20
+ with open(CONFIG_FILE) as f:
21
+ data = json.load(f)
22
+ except Exception:
23
+ pass
24
+ data[key] = value
25
+ with open(CONFIG_FILE, "w") as f:
26
+ json.dump(data, f, indent=2)
@@ -0,0 +1,133 @@
1
+ import glob
2
+ import hashlib
3
+ import importlib.metadata
4
+ import json
5
+ import os
6
+ import pathlib
7
+ import re
8
+ import tomllib
9
+
10
+ import httpx
11
+
12
+
13
+ def _cache_dir(root: str) -> pathlib.Path:
14
+ project_hash = hashlib.md5(str(pathlib.Path(root).resolve()).encode()).hexdigest()[:8]
15
+ return pathlib.Path.home() / ".cache" / "depscout" / project_hash
16
+
17
+ CACHE_DIR: pathlib.Path | None = None
18
+ DEPS_FILE: str | None = None
19
+
20
+
21
+ def _parse_dep_spec(dep_str):
22
+ dep_str = dep_str.strip()
23
+ match = re.match(r"^([A-Za-z0-9_.-]+).*?==([A-Za-z0-9_.]+)", dep_str)
24
+ if match:
25
+ return match.group(1).lower(), match.group(2)
26
+ name = re.split(r"[><=!;\[\s]", dep_str)[0].strip().lower()
27
+ return name, None
28
+
29
+
30
+ def _parse_pyproject(path):
31
+ with open(path, "rb") as f:
32
+ data = tomllib.load(f)
33
+ deps = {}
34
+ for dep in data.get("project", {}).get("dependencies", []):
35
+ name, version = _parse_dep_spec(dep)
36
+ if name and name != "python":
37
+ deps[name] = version
38
+ return deps
39
+
40
+
41
+ def _parse_requirements(path):
42
+ deps = {}
43
+ with open(path) as f:
44
+ for line in f:
45
+ line = line.strip()
46
+ if not line or line.startswith("#") or line.startswith("-"):
47
+ continue
48
+ name, version = _parse_dep_spec(line)
49
+ if name:
50
+ deps[name] = version
51
+ return deps
52
+
53
+
54
+ def _normalize_github_url(url):
55
+ if not url:
56
+ return None
57
+ match = re.search(r"github\.com/([^/]+/[^/\s?#]+)", url)
58
+ if not match:
59
+ return None
60
+ return f"https://github.com/{match.group(1).rstrip('/')}"
61
+
62
+
63
+ def _pypi_info(name):
64
+ try:
65
+ r = httpx.get(f"https://pypi.org/pypi/{name}/json", timeout=5)
66
+ if r.status_code == 200:
67
+ data = r.json()
68
+ info = data["info"]
69
+ urls = info.get("project_urls") or {}
70
+ raw_github = next((u for u in urls.values() if "github.com" in u), None)
71
+ github_url = _normalize_github_url(raw_github)
72
+ classifiers = info.get("classifiers") or []
73
+ dev_status = next((c.split(" :: ")[-1] for c in classifiers if c.startswith("Development Status")), None)
74
+ latest_files = data["releases"].get(info["version"], [])
75
+ last_release_date = latest_files[0]["upload_time"][:10] if latest_files else None
76
+ docs_url = urls.get("Documentation") or urls.get("Docs") or info.get("docs_url")
77
+ return {
78
+ "latest": info["version"],
79
+ "github_url": github_url,
80
+ "docs_url": docs_url,
81
+ "summary": info.get("summary"),
82
+ "description": info.get("description"),
83
+ "dev_status": dev_status,
84
+ "last_release_date": last_release_date,
85
+ "requires_python": info.get("requires_python"),
86
+ "requires_dist": info.get("requires_dist") or [],
87
+ "vulnerabilities": data.get("vulnerabilities") or [],
88
+ }
89
+ except Exception:
90
+ pass
91
+ return None
92
+
93
+
94
+ def scan(root="."):
95
+ global CACHE_DIR, DEPS_FILE
96
+ CACHE_DIR = _cache_dir(root)
97
+ DEPS_FILE = str(CACHE_DIR / "deps.json")
98
+
99
+ deps_by_name = {}
100
+
101
+ pyproject = os.path.join(root, "pyproject.toml")
102
+ if os.path.exists(pyproject):
103
+ deps_by_name.update(_parse_pyproject(pyproject))
104
+
105
+ for req in glob.glob(os.path.join(root, "requirements*.txt")):
106
+ deps_by_name.update(_parse_requirements(req))
107
+
108
+ deps = {}
109
+ for name, _ in deps_by_name.items():
110
+ try:
111
+ current = importlib.metadata.version(name)
112
+ except Exception:
113
+ current = None
114
+ info = _pypi_info(name)
115
+ deps[name] = {
116
+ "current": current,
117
+ "latest": info["latest"] if info else None,
118
+ "github_url": info["github_url"] if info else None,
119
+ "docs_url": info["docs_url"] if info else None,
120
+ "summary": info["summary"] if info else None,
121
+ "description": info["description"] if info else None,
122
+ "dev_status": info["dev_status"] if info else None,
123
+ "last_release_date": info["last_release_date"] if info else None,
124
+ "requires_python": info["requires_python"] if info else None,
125
+ "requires_dist": info["requires_dist"] if info else [],
126
+ "vulnerabilities": info["vulnerabilities"] if info else [],
127
+ }
128
+
129
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
130
+ with open(DEPS_FILE, "w") as f:
131
+ json.dump(deps, f, indent=4)
132
+
133
+ return deps
@@ -0,0 +1,111 @@
1
+ import json
2
+ import os
3
+ import base64
4
+
5
+ import httpx
6
+
7
+ import depscout.deps as _deps
8
+ from depscout import config as cfg
9
+
10
+
11
+ def _github_headers():
12
+ token = os.environ.get("GITHUB_TOKEN") or cfg.get("github_token")
13
+ return {"Authorization": f"Bearer {token}"} if token else {}
14
+
15
+
16
+ def _github_api(path, params=""):
17
+ url = f"https://api.github.com/{path.lstrip('/')}{params}"
18
+ try:
19
+ r = httpx.get(url, headers=_github_headers(), timeout=5)
20
+ if r.status_code == 200:
21
+ return r.json()
22
+ except Exception:
23
+ pass
24
+ return None
25
+
26
+
27
+ def _repo_path(github_url):
28
+ return github_url.replace("https://github.com/", "").rstrip("/")
29
+
30
+
31
+ def _fetch_repo_info(github_url):
32
+ data = _github_api(f"repos/{_repo_path(github_url)}")
33
+ if not data:
34
+ return {}
35
+ return {
36
+ "github_description": data.get("description"),
37
+ "stars": data.get("stargazers_count"),
38
+ "forks": data.get("forks_count"),
39
+ "open_issues": data.get("open_issues_count"),
40
+ "license": data["license"]["name"] if data.get("license") else None,
41
+ "homepage": data.get("homepage"),
42
+ "topics": data.get("topics") or [],
43
+ "archived": data.get("archived", False),
44
+ "pushed_at": (data.get("pushed_at") or "")[:10],
45
+ }
46
+
47
+
48
+ def _fetch_readme(github_url):
49
+ data = _github_api(f"repos/{_repo_path(github_url)}/readme")
50
+ if not data or not data.get("content"):
51
+ return None
52
+ try:
53
+ return base64.b64decode(data["content"]).decode("utf-8", errors="ignore")
54
+ except Exception:
55
+ return None
56
+
57
+
58
+ def _fetch_changelog(github_url, current_version):
59
+ data = _github_api(f"repos/{_repo_path(github_url)}/releases", "?per_page=50")
60
+ if not data:
61
+ return []
62
+
63
+ entries = []
64
+ for release in data:
65
+ if release.get("draft") or release.get("prerelease"):
66
+ continue
67
+ tag = release.get("tag_name", "").lstrip("v")
68
+ body = release.get("body", "").strip()
69
+ if tag == current_version:
70
+ break
71
+ if tag and body:
72
+ entries.append({"version": release["tag_name"], "notes": body})
73
+
74
+ return entries[:10]
75
+
76
+
77
+ def enrich():
78
+ with open(_deps.DEPS_FILE) as f:
79
+ deps = json.load(f)
80
+
81
+ changed = False
82
+ for _, info in deps.items():
83
+ github_url = info.get("github_url")
84
+ if not github_url:
85
+ continue
86
+
87
+ repo_info = _fetch_repo_info(github_url)
88
+ if repo_info:
89
+ info.update(repo_info)
90
+ changed = True
91
+
92
+ readme = _fetch_readme(github_url)
93
+ if readme is not None:
94
+ info["github_readme"] = readme
95
+ changed = True
96
+
97
+ current = info.get("current")
98
+ latest = info.get("latest")
99
+ is_outdated = current and latest and current != latest
100
+ already_enriched = "changelog" in info and info.get("changelog_fetched_for_version") == latest
101
+
102
+ if is_outdated and not already_enriched:
103
+ info["changelog"] = _fetch_changelog(github_url, current)
104
+ info["changelog_fetched_for_version"] = latest
105
+ changed = True
106
+
107
+ if changed:
108
+ with open(_deps.DEPS_FILE, "w") as f:
109
+ json.dump(deps, f, indent=4)
110
+
111
+ return deps
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "depscout"
3
+ version = "0.1.0"
4
+ description = "Upgrade intelligence for your Python dependencies"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+
9
+ authors = [
10
+ { name = "Miguel Soares" }
11
+ ]
12
+
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: OS Independent",
17
+ "Intended Audience :: Developers",
18
+ "Topic :: Software Development :: Libraries :: Python Modules",
19
+ ]
20
+
21
+ import-names = ["depscout"]
22
+ license-files = ["LICENSE"]
23
+
24
+ dependencies = [
25
+ "rich>=14.0.0",
26
+ "typer>=0.12.0",
27
+ "httpx>=0.25.0",
28
+ "ollama>=0.6.1",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/zemmsoares/depscout"
33
+ Issues = "https://github.com/zemmsoares/depscout/issues"
34
+
35
+ [project.scripts]
36
+ depscout = "depscout.cli:entrypoint"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["depscout"]
40
+
41
+ [build-system]
42
+ requires = ["hatchling"]
43
+ build-backend = "hatchling.build"