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.
- depscout-0.1.0/.gitignore +8 -0
- depscout-0.1.0/LICENSE +21 -0
- depscout-0.1.0/PKG-INFO +66 -0
- depscout-0.1.0/README.md +45 -0
- depscout-0.1.0/depscout/__init__.py +0 -0
- depscout-0.1.0/depscout/analyst.py +242 -0
- depscout-0.1.0/depscout/cli.py +191 -0
- depscout-0.1.0/depscout/config.py +26 -0
- depscout-0.1.0/depscout/deps.py +133 -0
- depscout-0.1.0/depscout/enrich.py +111 -0
- depscout-0.1.0/pyproject.toml +43 -0
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.
|
depscout-0.1.0/PKG-INFO
ADDED
|
@@ -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!
|
depscout-0.1.0/README.md
ADDED
|
@@ -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"
|