skillplus 0.1.2__tar.gz → 0.2.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.
- {skillplus-0.1.2 → skillplus-0.2.0}/PKG-INFO +31 -30
- {skillplus-0.1.2 → skillplus-0.2.0}/README.md +30 -29
- {skillplus-0.1.2 → skillplus-0.2.0}/pyproject.toml +1 -1
- {skillplus-0.1.2 → skillplus-0.2.0}/src/skillplus/__init__.py +2 -4
- {skillplus-0.1.2 → skillplus-0.2.0}/src/skillplus/client.py +136 -53
- {skillplus-0.1.2 → skillplus-0.2.0}/src/skillplus/types.py +17 -9
- skillplus-0.2.0/tests/test_client.py +241 -0
- {skillplus-0.1.2 → skillplus-0.2.0}/tests/test_v3_features.py +5 -7
- skillplus-0.1.2/tests/test_client.py +0 -223
- {skillplus-0.1.2 → skillplus-0.2.0}/.gitignore +0 -0
- {skillplus-0.1.2 → skillplus-0.2.0}/LICENSE +0 -0
- {skillplus-0.1.2 → skillplus-0.2.0}/src/skillplus/errors.py +0 -0
- {skillplus-0.1.2 → skillplus-0.2.0}/src/skillplus/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: skillplus
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Official Python SDK for SkillPlus
|
|
5
5
|
Project-URL: Homepage, https://skillplus.xyz
|
|
6
6
|
Project-URL: Documentation, https://docs.skillplus.xyz
|
|
@@ -32,52 +32,51 @@ Requires Python 3.10+.
|
|
|
32
32
|
|
|
33
33
|
## Quick start
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
skill has never been seen:
|
|
35
|
+
`query` gives a binary answer: is there a report, or not.
|
|
37
36
|
|
|
38
37
|
```python
|
|
39
38
|
from skillplus import SkillPlus
|
|
40
39
|
|
|
41
40
|
client = SkillPlus(api_key="skp_...")
|
|
42
41
|
|
|
43
|
-
|
|
42
|
+
result = client.query("https://www.skills.sh/vercel-labs/skills/find-skills")
|
|
44
43
|
# GitHub repositories work the same way: "https://github.com/owner/repo"
|
|
45
44
|
|
|
46
|
-
|
|
47
|
-
print(report.
|
|
48
|
-
|
|
49
|
-
print(report
|
|
45
|
+
if result.status == "found":
|
|
46
|
+
print(result.report.verdict) # "safe" | "medium" | "high" | "unknown" — UI labels: Safe / Caution / High Risk / Unrated
|
|
47
|
+
else:
|
|
48
|
+
print("no report yet", "(scan in progress)" if result.scanning else "")
|
|
50
49
|
```
|
|
51
50
|
|
|
52
51
|
Prefer `verdict` over the legacy `rating` field — it folds historical values
|
|
53
52
|
onto the current three-tier scale.
|
|
54
53
|
|
|
55
|
-
##
|
|
54
|
+
## The three integration patterns
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
`running` / `not_found` / `failed`) and never blocks:
|
|
56
|
+
**1. Show a report (frontend page)** — pure read, instant:
|
|
59
57
|
|
|
60
58
|
```python
|
|
61
|
-
result = client.query(
|
|
62
|
-
|
|
63
|
-
if result.status == "found" and result.report:
|
|
64
|
-
print(result.report.verdict)
|
|
59
|
+
result = client.query(url) # found | not_found, never blocks
|
|
65
60
|
```
|
|
66
61
|
|
|
67
|
-
|
|
62
|
+
**2. Scan in the background (batch pipelines)** — fire-and-forget, or wait:
|
|
68
63
|
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
```python
|
|
65
|
+
ack = client.scan(url) # returns immediately: ack.accepted / ack.scanning
|
|
66
|
+
report = client.scan(url, wait=True) # blocks until the report completes, returns it
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**3. Query-and-scan (skill detail pages)** — if it was never scanned, scan it:
|
|
71
70
|
|
|
72
71
|
```python
|
|
73
|
-
result = client.query(
|
|
74
|
-
|
|
75
|
-
skill_path="skills/example", # pick one skill in the repo
|
|
76
|
-
scan_if_missing=True, # queue a scan if no report exists
|
|
77
|
-
)
|
|
72
|
+
result = client.query(url, scan_if_missing=True) # triggers a scan, still returns immediately
|
|
73
|
+
result = client.query(url, wait=True) # scans if needed AND waits for the report
|
|
78
74
|
```
|
|
79
75
|
|
|
80
|
-
|
|
76
|
+
`wait=True` implies `scan_if_missing`; a failing scan raises `SkillPlusError`
|
|
77
|
+
(errors travel on the exception channel, never as a status value).
|
|
78
|
+
|
|
79
|
+
## Client options
|
|
81
80
|
|
|
82
81
|
```python
|
|
83
82
|
client = SkillPlus(
|
|
@@ -88,24 +87,26 @@ client = SkillPlus(
|
|
|
88
87
|
)
|
|
89
88
|
```
|
|
90
89
|
|
|
90
|
+
Waiting calls accept `wait_interval` (default 5 s) and `wait_timeout`
|
|
91
|
+
(default 600 s).
|
|
92
|
+
|
|
91
93
|
## Context manager
|
|
92
94
|
|
|
93
95
|
```python
|
|
94
96
|
from skillplus import SkillPlus
|
|
95
97
|
|
|
96
98
|
with SkillPlus(api_key="skp_...") as client:
|
|
97
|
-
|
|
98
|
-
print(
|
|
99
|
+
ack = client.scan("https://www.skills.sh/vercel-labs/skills/find-skills")
|
|
100
|
+
print(ack.accepted)
|
|
99
101
|
```
|
|
100
102
|
|
|
101
103
|
## Methods
|
|
102
104
|
|
|
103
105
|
| Method | Use case |
|
|
104
106
|
|---|---|
|
|
105
|
-
| `query(...)` |
|
|
106
|
-
| `scan(...)` |
|
|
107
|
-
| `
|
|
108
|
-
| `get_report(scan_id)` | Retrieve structured report data: verdict, findings, AI audit, supply-chain snapshot. |
|
|
107
|
+
| `query(url, ...)` | Binary check: `found` (report attached) or `not_found`. Options: `scan_if_missing`, `wait`. |
|
|
108
|
+
| `scan(url, ...)` | Trigger a scan: fire-and-forget ack, or `wait=True` for the finished report. `force=True` re-scans fresh reports. |
|
|
109
|
+
| `get_report(scan_id)` | Retrieve a report by id: verdict, findings, AI audit, supply-chain snapshot. |
|
|
109
110
|
| `get_badge(scan_id)` | Fetch the badge SVG for embedding. |
|
|
110
111
|
| `get_badge_url(scan_id)` | Generate a badge URL for READMEs, marketplaces, or internal portals. |
|
|
111
112
|
|
|
@@ -14,52 +14,51 @@ Requires Python 3.10+.
|
|
|
14
14
|
|
|
15
15
|
## Quick start
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
skill has never been seen:
|
|
17
|
+
`query` gives a binary answer: is there a report, or not.
|
|
19
18
|
|
|
20
19
|
```python
|
|
21
20
|
from skillplus import SkillPlus
|
|
22
21
|
|
|
23
22
|
client = SkillPlus(api_key="skp_...")
|
|
24
23
|
|
|
25
|
-
|
|
24
|
+
result = client.query("https://www.skills.sh/vercel-labs/skills/find-skills")
|
|
26
25
|
# GitHub repositories work the same way: "https://github.com/owner/repo"
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
print(report.
|
|
30
|
-
|
|
31
|
-
print(report
|
|
27
|
+
if result.status == "found":
|
|
28
|
+
print(result.report.verdict) # "safe" | "medium" | "high" | "unknown" — UI labels: Safe / Caution / High Risk / Unrated
|
|
29
|
+
else:
|
|
30
|
+
print("no report yet", "(scan in progress)" if result.scanning else "")
|
|
32
31
|
```
|
|
33
32
|
|
|
34
33
|
Prefer `verdict` over the legacy `rating` field — it folds historical values
|
|
35
34
|
onto the current three-tier scale.
|
|
36
35
|
|
|
37
|
-
##
|
|
36
|
+
## The three integration patterns
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
`running` / `not_found` / `failed`) and never blocks:
|
|
38
|
+
**1. Show a report (frontend page)** — pure read, instant:
|
|
41
39
|
|
|
42
40
|
```python
|
|
43
|
-
result = client.query(
|
|
44
|
-
|
|
45
|
-
if result.status == "found" and result.report:
|
|
46
|
-
print(result.report.verdict)
|
|
41
|
+
result = client.query(url) # found | not_found, never blocks
|
|
47
42
|
```
|
|
48
43
|
|
|
49
|
-
|
|
44
|
+
**2. Scan in the background (batch pipelines)** — fire-and-forget, or wait:
|
|
50
45
|
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
```python
|
|
47
|
+
ack = client.scan(url) # returns immediately: ack.accepted / ack.scanning
|
|
48
|
+
report = client.scan(url, wait=True) # blocks until the report completes, returns it
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**3. Query-and-scan (skill detail pages)** — if it was never scanned, scan it:
|
|
53
52
|
|
|
54
53
|
```python
|
|
55
|
-
result = client.query(
|
|
56
|
-
|
|
57
|
-
skill_path="skills/example", # pick one skill in the repo
|
|
58
|
-
scan_if_missing=True, # queue a scan if no report exists
|
|
59
|
-
)
|
|
54
|
+
result = client.query(url, scan_if_missing=True) # triggers a scan, still returns immediately
|
|
55
|
+
result = client.query(url, wait=True) # scans if needed AND waits for the report
|
|
60
56
|
```
|
|
61
57
|
|
|
62
|
-
|
|
58
|
+
`wait=True` implies `scan_if_missing`; a failing scan raises `SkillPlusError`
|
|
59
|
+
(errors travel on the exception channel, never as a status value).
|
|
60
|
+
|
|
61
|
+
## Client options
|
|
63
62
|
|
|
64
63
|
```python
|
|
65
64
|
client = SkillPlus(
|
|
@@ -70,24 +69,26 @@ client = SkillPlus(
|
|
|
70
69
|
)
|
|
71
70
|
```
|
|
72
71
|
|
|
72
|
+
Waiting calls accept `wait_interval` (default 5 s) and `wait_timeout`
|
|
73
|
+
(default 600 s).
|
|
74
|
+
|
|
73
75
|
## Context manager
|
|
74
76
|
|
|
75
77
|
```python
|
|
76
78
|
from skillplus import SkillPlus
|
|
77
79
|
|
|
78
80
|
with SkillPlus(api_key="skp_...") as client:
|
|
79
|
-
|
|
80
|
-
print(
|
|
81
|
+
ack = client.scan("https://www.skills.sh/vercel-labs/skills/find-skills")
|
|
82
|
+
print(ack.accepted)
|
|
81
83
|
```
|
|
82
84
|
|
|
83
85
|
## Methods
|
|
84
86
|
|
|
85
87
|
| Method | Use case |
|
|
86
88
|
|---|---|
|
|
87
|
-
| `query(...)` |
|
|
88
|
-
| `scan(...)` |
|
|
89
|
-
| `
|
|
90
|
-
| `get_report(scan_id)` | Retrieve structured report data: verdict, findings, AI audit, supply-chain snapshot. |
|
|
89
|
+
| `query(url, ...)` | Binary check: `found` (report attached) or `not_found`. Options: `scan_if_missing`, `wait`. |
|
|
90
|
+
| `scan(url, ...)` | Trigger a scan: fire-and-forget ack, or `wait=True` for the finished report. `force=True` re-scans fresh reports. |
|
|
91
|
+
| `get_report(scan_id)` | Retrieve a report by id: verdict, findings, AI audit, supply-chain snapshot. |
|
|
91
92
|
| `get_badge(scan_id)` | Fetch the badge SVG for embedding. |
|
|
92
93
|
| `get_badge_url(scan_id)` | Generate a badge URL for READMEs, marketplaces, or internal portals. |
|
|
93
94
|
|
|
@@ -11,10 +11,9 @@ from .types import (
|
|
|
11
11
|
QueryResult,
|
|
12
12
|
QueryStatus,
|
|
13
13
|
Rating,
|
|
14
|
+
ScanAck,
|
|
14
15
|
ScanIssue,
|
|
15
16
|
ScanReport,
|
|
16
|
-
ScanResult,
|
|
17
|
-
ScanStatus,
|
|
18
17
|
ScanSummary,
|
|
19
18
|
Severity,
|
|
20
19
|
SeverityNormalized,
|
|
@@ -34,10 +33,9 @@ __all__ = [
|
|
|
34
33
|
"QueryResult",
|
|
35
34
|
"QueryStatus",
|
|
36
35
|
"Rating",
|
|
36
|
+
"ScanAck",
|
|
37
37
|
"ScanIssue",
|
|
38
38
|
"ScanReport",
|
|
39
|
-
"ScanResult",
|
|
40
|
-
"ScanStatus",
|
|
41
39
|
"ScanSummary",
|
|
42
40
|
"Severity",
|
|
43
41
|
"SeverityNormalized",
|
|
@@ -13,9 +13,9 @@ from .types import (
|
|
|
13
13
|
AiReport,
|
|
14
14
|
PlatformListing,
|
|
15
15
|
QueryResult,
|
|
16
|
+
ScanAck,
|
|
16
17
|
ScanIssue,
|
|
17
18
|
ScanReport,
|
|
18
|
-
ScanResult,
|
|
19
19
|
ScanSummary,
|
|
20
20
|
SupplyChainHit,
|
|
21
21
|
SupplyChainReport,
|
|
@@ -50,7 +50,7 @@ class SkillPlus:
|
|
|
50
50
|
headers={
|
|
51
51
|
"Authorization": f"Bearer {api_key}",
|
|
52
52
|
"Content-Type": "application/json",
|
|
53
|
-
"User-Agent": "skillplus-python/0.
|
|
53
|
+
"User-Agent": "skillplus-python/0.2.0",
|
|
54
54
|
},
|
|
55
55
|
)
|
|
56
56
|
|
|
@@ -71,21 +71,34 @@ class SkillPlus:
|
|
|
71
71
|
*,
|
|
72
72
|
skill_path: str | None = None,
|
|
73
73
|
scan_if_missing: bool = False,
|
|
74
|
+
wait: bool = False,
|
|
75
|
+
wait_interval: float = 5.0,
|
|
76
|
+
wait_timeout: float = 600.0,
|
|
74
77
|
preferred_provider: str | None = None,
|
|
75
78
|
) -> QueryResult:
|
|
76
|
-
"""
|
|
79
|
+
"""Is there a report? Binary answer: ``found`` (report attached) or
|
|
80
|
+
``not_found`` (with ``scanning`` telling you whether one is on the way).
|
|
77
81
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
- ``scan_if_missing=True`` — queue a scan when nothing exists, still
|
|
83
|
+
return immediately.
|
|
84
|
+
- ``wait=True`` — block until a completed report exists (scanning
|
|
85
|
+
first if needed) and return ``found``; raises on failure/timeout.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
if wait:
|
|
89
|
+
return self._wait_for_completed(
|
|
90
|
+
repo_url,
|
|
91
|
+
skill_path=skill_path,
|
|
92
|
+
preferred_provider=preferred_provider,
|
|
93
|
+
prior=None,
|
|
94
|
+
interval=wait_interval,
|
|
95
|
+
timeout=wait_timeout,
|
|
96
|
+
)
|
|
97
|
+
payload = self._raw_query(
|
|
98
|
+
repo_url, skill_path=skill_path,
|
|
99
|
+
scan_if_missing=scan_if_missing, preferred_provider=preferred_provider,
|
|
87
100
|
)
|
|
88
|
-
return
|
|
101
|
+
return _map_server_state(payload)
|
|
89
102
|
|
|
90
103
|
def scan(
|
|
91
104
|
self,
|
|
@@ -94,10 +107,76 @@ class SkillPlus:
|
|
|
94
107
|
skill_path: str | None = None,
|
|
95
108
|
preferred_provider: str | None = None,
|
|
96
109
|
force: bool = False,
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
wait: bool = False,
|
|
111
|
+
wait_interval: float = 5.0,
|
|
112
|
+
wait_timeout: float = 600.0,
|
|
113
|
+
) -> ScanAck | ScanReport:
|
|
114
|
+
"""Trigger a scan.
|
|
115
|
+
|
|
116
|
+
- Default: fire-and-forget — returns a :class:`ScanAck` immediately
|
|
117
|
+
(``accepted=False`` means deduplicated: already scanning, or a
|
|
118
|
+
fresh report exists and ``force`` was not set).
|
|
119
|
+
- ``wait=True`` — block until the resulting report completes and
|
|
120
|
+
return the report itself.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
if wait:
|
|
124
|
+
prior: ScanReport | None = None
|
|
125
|
+
if force:
|
|
126
|
+
current = self.query(repo_url, skill_path=skill_path)
|
|
127
|
+
prior = current.report if current.status == "found" else None
|
|
128
|
+
self._raw_scan(
|
|
129
|
+
repo_url, skill_path=skill_path,
|
|
130
|
+
preferred_provider=preferred_provider, force=force,
|
|
131
|
+
)
|
|
132
|
+
found = self._wait_for_completed(
|
|
133
|
+
repo_url,
|
|
134
|
+
skill_path=skill_path,
|
|
135
|
+
preferred_provider=preferred_provider,
|
|
136
|
+
prior=prior,
|
|
137
|
+
interval=wait_interval,
|
|
138
|
+
timeout=wait_timeout,
|
|
139
|
+
)
|
|
140
|
+
assert found.report is not None
|
|
141
|
+
return found.report
|
|
99
142
|
|
|
100
|
-
payload = self.
|
|
143
|
+
payload = self._raw_scan(
|
|
144
|
+
repo_url, skill_path=skill_path,
|
|
145
|
+
preferred_provider=preferred_provider, force=force,
|
|
146
|
+
)
|
|
147
|
+
status = payload.get("status")
|
|
148
|
+
message = payload.get("message") or ""
|
|
149
|
+
if status == "queued":
|
|
150
|
+
return ScanAck(accepted=True, scanning=True, message=message or "Scan queued")
|
|
151
|
+
if status == "running":
|
|
152
|
+
return ScanAck(accepted=False, scanning=True, message=message or "Scan already in progress")
|
|
153
|
+
if status == "found":
|
|
154
|
+
return ScanAck(
|
|
155
|
+
accepted=False, scanning=False,
|
|
156
|
+
message="A fresh report already exists (pass force=True to re-scan)",
|
|
157
|
+
)
|
|
158
|
+
raise SkillPlusError(message or "Scan failed", detail=status)
|
|
159
|
+
|
|
160
|
+
def _raw_query(
|
|
161
|
+
self, repo_url: str, *, skill_path: str | None,
|
|
162
|
+
scan_if_missing: bool, preferred_provider: str | None,
|
|
163
|
+
) -> dict[str, Any]:
|
|
164
|
+
return self._request_json(
|
|
165
|
+
"POST",
|
|
166
|
+
"/api/sdk/query",
|
|
167
|
+
json={
|
|
168
|
+
"repo_url": repo_url,
|
|
169
|
+
"skill_path": skill_path,
|
|
170
|
+
"scan_if_missing": scan_if_missing,
|
|
171
|
+
"preferred_provider": preferred_provider,
|
|
172
|
+
},
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def _raw_scan(
|
|
176
|
+
self, repo_url: str, *, skill_path: str | None,
|
|
177
|
+
preferred_provider: str | None, force: bool,
|
|
178
|
+
) -> dict[str, Any]:
|
|
179
|
+
return self._request_json(
|
|
101
180
|
"POST",
|
|
102
181
|
"/api/sdk/scan",
|
|
103
182
|
json={
|
|
@@ -107,37 +186,43 @@ class SkillPlus:
|
|
|
107
186
|
"force": force,
|
|
108
187
|
},
|
|
109
188
|
)
|
|
110
|
-
return _parse_scan_result(payload)
|
|
111
189
|
|
|
112
|
-
def
|
|
190
|
+
def _wait_for_completed(
|
|
113
191
|
self,
|
|
114
192
|
repo_url: str,
|
|
115
193
|
*,
|
|
116
|
-
skill_path: str | None
|
|
117
|
-
preferred_provider: str | None
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
194
|
+
skill_path: str | None,
|
|
195
|
+
preferred_provider: str | None,
|
|
196
|
+
prior: ScanReport | None,
|
|
197
|
+
interval: float,
|
|
198
|
+
timeout: float,
|
|
199
|
+
) -> QueryResult:
|
|
200
|
+
"""Poll until a completed report exists; a ``prior`` report (from a
|
|
201
|
+
forced re-scan) must be superseded before we accept an answer."""
|
|
124
202
|
|
|
125
203
|
deadline = time.monotonic() + timeout
|
|
126
204
|
while True:
|
|
127
|
-
|
|
128
|
-
repo_url,
|
|
129
|
-
|
|
130
|
-
scan_if_missing=True,
|
|
131
|
-
preferred_provider=preferred_provider,
|
|
205
|
+
payload = self._raw_query(
|
|
206
|
+
repo_url, skill_path=skill_path,
|
|
207
|
+
scan_if_missing=True, preferred_provider=preferred_provider,
|
|
132
208
|
)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
209
|
+
status = payload.get("status")
|
|
210
|
+
if status == "failed":
|
|
211
|
+
raise SkillPlusError(
|
|
212
|
+
f"Scan failed: {payload.get('message') or ''}", detail="failed"
|
|
213
|
+
)
|
|
214
|
+
report_payload = payload.get("report")
|
|
215
|
+
if (
|
|
216
|
+
status == "found"
|
|
217
|
+
and report_payload
|
|
218
|
+
and report_payload.get("status") == "completed"
|
|
219
|
+
and (prior is None or report_payload.get("scanned_at") != prior.scanned_at)
|
|
220
|
+
):
|
|
221
|
+
return _map_server_state(payload)
|
|
137
222
|
if time.monotonic() + interval > deadline:
|
|
138
223
|
raise SkillPlusError(
|
|
139
|
-
f"Timed out waiting for report (last status: {
|
|
140
|
-
detail=
|
|
224
|
+
f"Timed out waiting for report (last status: {status})",
|
|
225
|
+
detail=status,
|
|
141
226
|
)
|
|
142
227
|
time.sleep(interval)
|
|
143
228
|
|
|
@@ -219,23 +304,21 @@ class SkillPlus:
|
|
|
219
304
|
return response
|
|
220
305
|
|
|
221
306
|
|
|
222
|
-
def
|
|
307
|
+
def _map_server_state(payload: dict[str, Any]) -> QueryResult:
|
|
308
|
+
"""Fold the server's five-state machine onto the binary query contract."""
|
|
309
|
+
status = payload.get("status")
|
|
310
|
+
if status == "found" and payload.get("report"):
|
|
311
|
+
return QueryResult(
|
|
312
|
+
status="found",
|
|
313
|
+
message=payload.get("message") or "Report found",
|
|
314
|
+
report=_parse_report(payload["report"]),
|
|
315
|
+
scanning=False,
|
|
316
|
+
)
|
|
223
317
|
return QueryResult(
|
|
224
|
-
status=
|
|
225
|
-
message=payload
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
report=_parse_report(payload["report"]) if payload.get("report") else None,
|
|
229
|
-
)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
def _parse_scan_result(payload: dict[str, Any]) -> ScanResult:
|
|
233
|
-
return ScanResult(
|
|
234
|
-
status=payload["status"],
|
|
235
|
-
message=payload["message"],
|
|
236
|
-
scan_id=payload.get("scan_id"),
|
|
237
|
-
poll_url=payload.get("poll_url"),
|
|
238
|
-
report=_parse_report(payload["report"]) if payload.get("report") else None,
|
|
318
|
+
status="not_found",
|
|
319
|
+
message=payload.get("message") or "Report not found",
|
|
320
|
+
report=None,
|
|
321
|
+
scanning=status in ("queued", "running"),
|
|
239
322
|
)
|
|
240
323
|
|
|
241
324
|
|
|
@@ -23,8 +23,8 @@ IssueCategory: TypeAlias = Literal[
|
|
|
23
23
|
"supply_chain",
|
|
24
24
|
"propagation",
|
|
25
25
|
]
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
# query() is binary: a report exists or it does not.
|
|
27
|
+
QueryStatus: TypeAlias = Literal["found", "not_found"]
|
|
28
28
|
AiReportStatus: TypeAlias = Literal["pending", "processing", "completed", "failed"]
|
|
29
29
|
|
|
30
30
|
|
|
@@ -160,17 +160,25 @@ class ScanReport:
|
|
|
160
160
|
|
|
161
161
|
@dataclass(slots=True)
|
|
162
162
|
class QueryResult:
|
|
163
|
+
"""Binary answer: ``found`` (report attached) or ``not_found``.
|
|
164
|
+
|
|
165
|
+
``scanning`` is a courtesy flag on not_found: True means a scan for this
|
|
166
|
+
skill is queued or running right now (check back shortly)."""
|
|
167
|
+
|
|
163
168
|
status: QueryStatus
|
|
164
169
|
message: str
|
|
165
|
-
scan_id: str | None = None
|
|
166
|
-
poll_url: str | None = None
|
|
167
170
|
report: ScanReport | None = None
|
|
171
|
+
scanning: bool = False
|
|
168
172
|
|
|
169
173
|
|
|
170
174
|
@dataclass(slots=True)
|
|
171
|
-
class
|
|
172
|
-
|
|
175
|
+
class ScanAck:
|
|
176
|
+
"""Acknowledgement of a fire-and-forget scan() call.
|
|
177
|
+
|
|
178
|
+
``accepted`` is True only when THIS call queued a new scan; False means
|
|
179
|
+
it was deduplicated (already scanning, or a fresh report exists and
|
|
180
|
+
``force`` was not set)."""
|
|
181
|
+
|
|
182
|
+
accepted: bool
|
|
183
|
+
scanning: bool
|
|
173
184
|
message: str
|
|
174
|
-
scan_id: str | None = None
|
|
175
|
-
poll_url: str | None = None
|
|
176
|
-
report: ScanReport | None = None
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import respx
|
|
7
|
+
from httpx import Response
|
|
8
|
+
|
|
9
|
+
from skillplus import SkillPlus, SkillPlusError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
REPORT_PAYLOAD = {
|
|
13
|
+
"scan_id": "scan_123",
|
|
14
|
+
"skill_name": "example",
|
|
15
|
+
"source": "github:owner/repo",
|
|
16
|
+
"skill_path": "skills/example",
|
|
17
|
+
"tree_sha": "abc123",
|
|
18
|
+
"rule_version": "rules-v1",
|
|
19
|
+
"valid_skill": True,
|
|
20
|
+
"rating": "safe",
|
|
21
|
+
"scanned_at": "2026-06-10T00:00:00Z",
|
|
22
|
+
"cached": True,
|
|
23
|
+
"scan_duration_ms": 1200,
|
|
24
|
+
"summary": {
|
|
25
|
+
"total_issues": 1,
|
|
26
|
+
"danger": 0,
|
|
27
|
+
"warning": 1,
|
|
28
|
+
"info": 0,
|
|
29
|
+
"files_scanned": 4,
|
|
30
|
+
"by_category": {"url": 1},
|
|
31
|
+
},
|
|
32
|
+
"issues": [
|
|
33
|
+
{
|
|
34
|
+
"rule_id": "external-url",
|
|
35
|
+
"severity": "warning",
|
|
36
|
+
"category": "url",
|
|
37
|
+
"message": "External URL found",
|
|
38
|
+
"file": "SKILL.md",
|
|
39
|
+
"line": 12,
|
|
40
|
+
"snippet": "https://example.com",
|
|
41
|
+
"domain": "example.com",
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"report_url": "https://skillplus.xyz/report/scan_123",
|
|
45
|
+
"badge_url": "https://skillplus.xyz/api/report/scan_123/badge.svg",
|
|
46
|
+
"blacklisted": False,
|
|
47
|
+
"whitelisted": False,
|
|
48
|
+
"status": "completed",
|
|
49
|
+
"ai_report": {
|
|
50
|
+
"status": "completed",
|
|
51
|
+
"risk_level": "low",
|
|
52
|
+
"refined_rating": "safe",
|
|
53
|
+
"false_positives": [
|
|
54
|
+
{"rule_id": "external-url", "file": "SKILL.md", "reason": "Documentation link"}
|
|
55
|
+
],
|
|
56
|
+
"analysis": [
|
|
57
|
+
{
|
|
58
|
+
"category": "Command Execution",
|
|
59
|
+
"status": "pass",
|
|
60
|
+
"severity": "safe",
|
|
61
|
+
"title": "No command execution",
|
|
62
|
+
"description": "No risky command execution was found.",
|
|
63
|
+
"evidence": ["No shell usage"],
|
|
64
|
+
"confidence": 0.98,
|
|
65
|
+
}
|
|
66
|
+
],
|
|
67
|
+
"summary": "No meaningful risk found.",
|
|
68
|
+
"categories_checked": [],
|
|
69
|
+
"tokens_used": 500,
|
|
70
|
+
"duration_ms": 800,
|
|
71
|
+
"error_message": None,
|
|
72
|
+
},
|
|
73
|
+
"external_links": {"website": "https://example.com"},
|
|
74
|
+
"platform_listings": [
|
|
75
|
+
{"platform": "skills.sh", "url": "https://skills.sh/owner/repo/example", "installs": 10}
|
|
76
|
+
],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
SERVER_QUEUED = {"status": "queued", "message": "Scan queued", "scan_id": None, "poll_url": None}
|
|
81
|
+
SERVER_RUNNING = {"status": "running", "message": "Scan already in progress", "scan_id": "s1", "poll_url": "/x"}
|
|
82
|
+
SERVER_NOT_FOUND = {"status": "not_found", "message": "Report not found", "scan_id": None, "poll_url": None}
|
|
83
|
+
SERVER_FAILED = {"status": "failed", "message": "boom", "scan_id": None, "poll_url": None}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def server_found(report=None):
|
|
87
|
+
return {
|
|
88
|
+
"status": "found",
|
|
89
|
+
"message": "Report found",
|
|
90
|
+
"scan_id": "scan_123",
|
|
91
|
+
"poll_url": "/api/scan/status/scan_123",
|
|
92
|
+
"report": report or REPORT_PAYLOAD,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@respx.mock
|
|
97
|
+
def test_query_sends_auth_and_request_body() -> None:
|
|
98
|
+
route = respx.post("https://skillplus.xyz/api/sdk/query").mock(
|
|
99
|
+
return_value=Response(200, json=server_found())
|
|
100
|
+
)
|
|
101
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
102
|
+
result = client.query(
|
|
103
|
+
"https://github.com/owner/repo",
|
|
104
|
+
skill_path="skills/example",
|
|
105
|
+
scan_if_missing=True,
|
|
106
|
+
preferred_provider="github",
|
|
107
|
+
)
|
|
108
|
+
request = route.calls.last.request
|
|
109
|
+
assert request.headers["Authorization"] == "Bearer skp_test"
|
|
110
|
+
assert json.loads(request.read()) == {
|
|
111
|
+
"repo_url": "https://github.com/owner/repo",
|
|
112
|
+
"skill_path": "skills/example",
|
|
113
|
+
"scan_if_missing": True,
|
|
114
|
+
"preferred_provider": "github",
|
|
115
|
+
}
|
|
116
|
+
assert result.status == "found"
|
|
117
|
+
assert result.scanning is False
|
|
118
|
+
assert result.report is not None
|
|
119
|
+
assert result.report.scan_id == "scan_123"
|
|
120
|
+
assert result.report.summary.total_issues == 1
|
|
121
|
+
assert result.report.issues[0].rule_id == "external-url"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@respx.mock
|
|
125
|
+
def test_query_folds_in_progress_onto_not_found_scanning() -> None:
|
|
126
|
+
for server, scanning in ((SERVER_QUEUED, True), (SERVER_RUNNING, True),
|
|
127
|
+
(SERVER_NOT_FOUND, False), (SERVER_FAILED, False)):
|
|
128
|
+
respx.post("https://skillplus.xyz/api/sdk/query").mock(
|
|
129
|
+
return_value=Response(200, json=server)
|
|
130
|
+
)
|
|
131
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
132
|
+
result = client.query("https://skills.sh/o/r/s")
|
|
133
|
+
assert result.status == "not_found"
|
|
134
|
+
assert result.report is None
|
|
135
|
+
assert result.scanning is scanning
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@respx.mock
|
|
139
|
+
def test_query_wait_polls_until_completed() -> None:
|
|
140
|
+
route = respx.post("https://skillplus.xyz/api/sdk/query")
|
|
141
|
+
route.side_effect = [
|
|
142
|
+
Response(200, json=SERVER_QUEUED),
|
|
143
|
+
Response(200, json=SERVER_RUNNING),
|
|
144
|
+
Response(200, json=server_found()),
|
|
145
|
+
]
|
|
146
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
147
|
+
result = client.query("https://skills.sh/o/r/s", wait=True, wait_interval=0.01)
|
|
148
|
+
assert result.status == "found"
|
|
149
|
+
assert result.report is not None
|
|
150
|
+
assert route.call_count == 3
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@respx.mock
|
|
154
|
+
def test_query_wait_raises_on_scan_failure() -> None:
|
|
155
|
+
route = respx.post("https://skillplus.xyz/api/sdk/query")
|
|
156
|
+
route.side_effect = [Response(200, json=SERVER_QUEUED), Response(200, json=SERVER_FAILED)]
|
|
157
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
158
|
+
with pytest.raises(SkillPlusError) as excinfo:
|
|
159
|
+
client.query("https://skills.sh/o/r/s", wait=True, wait_interval=0.01)
|
|
160
|
+
assert "failed" in str(excinfo.value).lower()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@respx.mock
|
|
164
|
+
def test_query_wait_times_out() -> None:
|
|
165
|
+
respx.post("https://skillplus.xyz/api/sdk/query").mock(
|
|
166
|
+
return_value=Response(200, json=SERVER_QUEUED)
|
|
167
|
+
)
|
|
168
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
169
|
+
with pytest.raises(SkillPlusError) as excinfo:
|
|
170
|
+
client.query("https://skills.sh/o/r/s", wait=True, wait_interval=0.01, wait_timeout=0.02)
|
|
171
|
+
assert "Timed out" in str(excinfo.value)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@respx.mock
|
|
175
|
+
def test_scan_ack_semantics() -> None:
|
|
176
|
+
for server, accepted, scanning in ((SERVER_QUEUED, True, True),
|
|
177
|
+
(SERVER_RUNNING, False, True),
|
|
178
|
+
(server_found(), False, False)):
|
|
179
|
+
respx.post("https://skillplus.xyz/api/sdk/scan").mock(
|
|
180
|
+
return_value=Response(200, json=server)
|
|
181
|
+
)
|
|
182
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
183
|
+
ack = client.scan("https://skills.sh/o/r/s")
|
|
184
|
+
assert (ack.accepted, ack.scanning) == (accepted, scanning)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@respx.mock
|
|
188
|
+
def test_scan_wait_triggers_then_returns_report() -> None:
|
|
189
|
+
scan_route = respx.post("https://skillplus.xyz/api/sdk/scan").mock(
|
|
190
|
+
return_value=Response(200, json=SERVER_QUEUED)
|
|
191
|
+
)
|
|
192
|
+
query_route = respx.post("https://skillplus.xyz/api/sdk/query")
|
|
193
|
+
query_route.side_effect = [Response(200, json=SERVER_RUNNING), Response(200, json=server_found())]
|
|
194
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
195
|
+
report = client.scan("https://skills.sh/o/r/s", wait=True, wait_interval=0.01)
|
|
196
|
+
assert report.scan_id == "scan_123"
|
|
197
|
+
assert scan_route.call_count == 1
|
|
198
|
+
assert query_route.call_count == 2
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@respx.mock
|
|
202
|
+
def test_scan_wait_force_waits_for_superseding_report() -> None:
|
|
203
|
+
import copy
|
|
204
|
+
old = copy.deepcopy(REPORT_PAYLOAD)
|
|
205
|
+
new = copy.deepcopy(REPORT_PAYLOAD)
|
|
206
|
+
new["scanned_at"] = "2026-07-18T12:00:00Z"
|
|
207
|
+
respx.post("https://skillplus.xyz/api/sdk/scan").mock(
|
|
208
|
+
return_value=Response(200, json=SERVER_QUEUED)
|
|
209
|
+
)
|
|
210
|
+
query_route = respx.post("https://skillplus.xyz/api/sdk/query")
|
|
211
|
+
query_route.side_effect = [
|
|
212
|
+
Response(200, json=server_found(old)), # prior capture
|
|
213
|
+
Response(200, json=server_found(old)), # still the old report
|
|
214
|
+
Response(200, json=server_found(new)), # rescanned
|
|
215
|
+
]
|
|
216
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
217
|
+
report = client.scan("https://skills.sh/o/r/s", force=True, wait=True, wait_interval=0.01)
|
|
218
|
+
assert report.scanned_at == "2026-07-18T12:00:00Z"
|
|
219
|
+
assert query_route.call_count == 3
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@respx.mock
|
|
223
|
+
def test_http_error_raises_skillplus_error() -> None:
|
|
224
|
+
respx.post("https://skillplus.xyz/api/sdk/query").mock(
|
|
225
|
+
return_value=Response(401, json={"detail": "Invalid API key"})
|
|
226
|
+
)
|
|
227
|
+
with SkillPlus(api_key="skp_bad") as client:
|
|
228
|
+
with pytest.raises(SkillPlusError) as excinfo:
|
|
229
|
+
client.query("https://skills.sh/o/r/s")
|
|
230
|
+
assert excinfo.value.status_code == 401
|
|
231
|
+
assert "Invalid API key" in str(excinfo.value)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@respx.mock
|
|
235
|
+
def test_get_badge_and_badge_url() -> None:
|
|
236
|
+
respx.get("https://skillplus.xyz/api/report/scan_123/badge.svg").mock(
|
|
237
|
+
return_value=Response(200, text="<svg/>")
|
|
238
|
+
)
|
|
239
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
240
|
+
assert client.get_badge("scan_123") == "<svg/>"
|
|
241
|
+
assert client.get_badge_url("scan_123") == "https://skillplus.xyz/api/report/scan_123/badge.svg"
|
|
@@ -141,7 +141,7 @@ def test_no_retry_when_disabled() -> None:
|
|
|
141
141
|
|
|
142
142
|
|
|
143
143
|
@respx.mock
|
|
144
|
-
def
|
|
144
|
+
def test_query_wait_polls_until_completed_v3() -> None:
|
|
145
145
|
queued = {"status": "queued", "message": "Scan queued", "scan_id": None, "poll_url": None}
|
|
146
146
|
found = {
|
|
147
147
|
"status": "found",
|
|
@@ -154,23 +154,21 @@ def test_wait_for_report_polls_until_completed() -> None:
|
|
|
154
154
|
route.side_effect = [Response(200, json=queued), Response(200, json=found)]
|
|
155
155
|
|
|
156
156
|
with SkillPlus(api_key="skp_test") as client:
|
|
157
|
-
|
|
158
|
-
"https://github.com/owner/repo", interval=0.01, timeout=5.0
|
|
159
|
-
)
|
|
157
|
+
result = client.query("https://github.com/owner/repo", wait=True, wait_interval=0.01)
|
|
160
158
|
|
|
161
|
-
assert report.scan_id == "scan_123"
|
|
159
|
+
assert result.report is not None and result.report.scan_id == "scan_123"
|
|
162
160
|
assert route.call_count == 2
|
|
163
161
|
|
|
164
162
|
|
|
165
163
|
@respx.mock
|
|
166
|
-
def
|
|
164
|
+
def test_query_wait_times_out_v3() -> None:
|
|
167
165
|
queued = {"status": "queued", "message": "Scan queued", "scan_id": None, "poll_url": None}
|
|
168
166
|
respx.post("https://skillplus.xyz/api/sdk/query").mock(
|
|
169
167
|
return_value=Response(200, json=queued)
|
|
170
168
|
)
|
|
171
169
|
with SkillPlus(api_key="skp_test") as client:
|
|
172
170
|
with pytest.raises(SkillPlusError) as excinfo:
|
|
173
|
-
client.
|
|
171
|
+
client.query("https://github.com/owner/repo", wait=True, wait_interval=0.01, wait_timeout=0.02)
|
|
174
172
|
assert "Timed out" in str(excinfo.value)
|
|
175
173
|
|
|
176
174
|
|
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
import respx
|
|
7
|
-
from httpx import Response
|
|
8
|
-
|
|
9
|
-
from skillplus import SkillPlus, SkillPlusError
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
REPORT_PAYLOAD = {
|
|
13
|
-
"scan_id": "scan_123",
|
|
14
|
-
"skill_name": "example",
|
|
15
|
-
"source": "github:owner/repo",
|
|
16
|
-
"skill_path": "skills/example",
|
|
17
|
-
"tree_sha": "abc123",
|
|
18
|
-
"rule_version": "rules-v1",
|
|
19
|
-
"valid_skill": True,
|
|
20
|
-
"rating": "safe",
|
|
21
|
-
"scanned_at": "2026-06-10T00:00:00Z",
|
|
22
|
-
"cached": True,
|
|
23
|
-
"scan_duration_ms": 1200,
|
|
24
|
-
"summary": {
|
|
25
|
-
"total_issues": 1,
|
|
26
|
-
"danger": 0,
|
|
27
|
-
"warning": 1,
|
|
28
|
-
"info": 0,
|
|
29
|
-
"files_scanned": 4,
|
|
30
|
-
"by_category": {"url": 1},
|
|
31
|
-
},
|
|
32
|
-
"issues": [
|
|
33
|
-
{
|
|
34
|
-
"rule_id": "external-url",
|
|
35
|
-
"severity": "warning",
|
|
36
|
-
"category": "url",
|
|
37
|
-
"message": "External URL found",
|
|
38
|
-
"file": "SKILL.md",
|
|
39
|
-
"line": 12,
|
|
40
|
-
"snippet": "https://example.com",
|
|
41
|
-
"domain": "example.com",
|
|
42
|
-
}
|
|
43
|
-
],
|
|
44
|
-
"report_url": "https://skillplus.xyz/report/scan_123",
|
|
45
|
-
"badge_url": "https://skillplus.xyz/api/report/scan_123/badge.svg",
|
|
46
|
-
"blacklisted": False,
|
|
47
|
-
"whitelisted": False,
|
|
48
|
-
"status": "completed",
|
|
49
|
-
"ai_report": {
|
|
50
|
-
"status": "completed",
|
|
51
|
-
"risk_level": "low",
|
|
52
|
-
"refined_rating": "safe",
|
|
53
|
-
"false_positives": [
|
|
54
|
-
{"rule_id": "external-url", "file": "SKILL.md", "reason": "Documentation link"}
|
|
55
|
-
],
|
|
56
|
-
"analysis": [
|
|
57
|
-
{
|
|
58
|
-
"category": "Command Execution",
|
|
59
|
-
"status": "pass",
|
|
60
|
-
"severity": "safe",
|
|
61
|
-
"title": "No command execution",
|
|
62
|
-
"description": "No risky command execution was found.",
|
|
63
|
-
"evidence": ["No shell usage"],
|
|
64
|
-
"confidence": 0.98,
|
|
65
|
-
}
|
|
66
|
-
],
|
|
67
|
-
"summary": "No meaningful risk found.",
|
|
68
|
-
"categories_checked": [],
|
|
69
|
-
"tokens_used": 500,
|
|
70
|
-
"duration_ms": 800,
|
|
71
|
-
"error_message": None,
|
|
72
|
-
},
|
|
73
|
-
"external_links": {"website": "https://example.com"},
|
|
74
|
-
"platform_listings": [
|
|
75
|
-
{"platform": "skills.sh", "url": "https://skills.sh/owner/repo/example", "installs": 10}
|
|
76
|
-
],
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
@respx.mock
|
|
81
|
-
def test_query_sends_auth_and_request_body() -> None:
|
|
82
|
-
route = respx.post("https://skillplus.xyz/api/sdk/query").mock(
|
|
83
|
-
return_value=Response(
|
|
84
|
-
200,
|
|
85
|
-
json={
|
|
86
|
-
"status": "found",
|
|
87
|
-
"message": "Report found",
|
|
88
|
-
"scan_id": "scan_123",
|
|
89
|
-
"poll_url": "/api/scan/status/scan_123",
|
|
90
|
-
"report": REPORT_PAYLOAD,
|
|
91
|
-
},
|
|
92
|
-
)
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
with SkillPlus(api_key="skp_test") as client:
|
|
96
|
-
result = client.query(
|
|
97
|
-
"https://github.com/owner/repo",
|
|
98
|
-
skill_path="skills/example",
|
|
99
|
-
scan_if_missing=True,
|
|
100
|
-
preferred_provider="github",
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
request = route.calls.last.request
|
|
104
|
-
assert request.headers["Authorization"] == "Bearer skp_test"
|
|
105
|
-
assert request.headers["Content-Type"] == "application/json"
|
|
106
|
-
assert json.loads(request.read()) == {
|
|
107
|
-
"repo_url": "https://github.com/owner/repo",
|
|
108
|
-
"skill_path": "skills/example",
|
|
109
|
-
"scan_if_missing": True,
|
|
110
|
-
"preferred_provider": "github",
|
|
111
|
-
}
|
|
112
|
-
assert result.status == "found"
|
|
113
|
-
assert result.scan_id == "scan_123"
|
|
114
|
-
assert result.report is not None
|
|
115
|
-
assert result.report.rating == "safe"
|
|
116
|
-
assert result.report.summary.warning == 1
|
|
117
|
-
assert result.report.issues[0].rule_id == "external-url"
|
|
118
|
-
assert result.report.ai_report is not None
|
|
119
|
-
assert result.report.ai_report.false_positives[0].reason == "Documentation link"
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
@respx.mock
|
|
123
|
-
def test_query_without_report_returns_status_envelope() -> None:
|
|
124
|
-
respx.post("https://skillplus.xyz/api/sdk/query").mock(
|
|
125
|
-
return_value=Response(
|
|
126
|
-
200,
|
|
127
|
-
json={
|
|
128
|
-
"status": "queued",
|
|
129
|
-
"message": "Scan queued",
|
|
130
|
-
"scan_id": "scan_queued",
|
|
131
|
-
"poll_url": "/api/scan/status/scan_queued",
|
|
132
|
-
"report": None,
|
|
133
|
-
},
|
|
134
|
-
)
|
|
135
|
-
)
|
|
136
|
-
|
|
137
|
-
client = SkillPlus(api_key="skp_test")
|
|
138
|
-
try:
|
|
139
|
-
result = client.query("https://github.com/owner/repo", scan_if_missing=True)
|
|
140
|
-
finally:
|
|
141
|
-
client.close()
|
|
142
|
-
|
|
143
|
-
assert result.status == "queued"
|
|
144
|
-
assert result.scan_id == "scan_queued"
|
|
145
|
-
assert result.report is None
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
@respx.mock
|
|
149
|
-
def test_scan_sends_force_flag() -> None:
|
|
150
|
-
route = respx.post("https://skillplus.xyz/api/sdk/scan").mock(
|
|
151
|
-
return_value=Response(
|
|
152
|
-
200,
|
|
153
|
-
json={
|
|
154
|
-
"status": "running",
|
|
155
|
-
"message": "Scan already running",
|
|
156
|
-
"scan_id": "scan_running",
|
|
157
|
-
"poll_url": "/api/scan/status/scan_running",
|
|
158
|
-
"report": None,
|
|
159
|
-
},
|
|
160
|
-
)
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
with SkillPlus(api_key="skp_test") as client:
|
|
164
|
-
result = client.scan("https://github.com/owner/repo", skill_path="skills/example", force=True)
|
|
165
|
-
|
|
166
|
-
assert json.loads(route.calls.last.request.read()) == {
|
|
167
|
-
"repo_url": "https://github.com/owner/repo",
|
|
168
|
-
"skill_path": "skills/example",
|
|
169
|
-
"preferred_provider": None,
|
|
170
|
-
"force": True,
|
|
171
|
-
}
|
|
172
|
-
assert result.status == "running"
|
|
173
|
-
assert result.report is None
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
@respx.mock
|
|
177
|
-
def test_get_report_parses_report() -> None:
|
|
178
|
-
respx.get("https://skillplus.xyz/api/report/scan_123").mock(
|
|
179
|
-
return_value=Response(200, json=REPORT_PAYLOAD)
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
with SkillPlus(api_key="skp_test") as client:
|
|
183
|
-
report = client.get_report("scan_123")
|
|
184
|
-
|
|
185
|
-
assert report.scan_id == "scan_123"
|
|
186
|
-
assert report.platform_listings[0].platform == "skills.sh"
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
@respx.mock
|
|
190
|
-
def test_get_badge_returns_svg_text() -> None:
|
|
191
|
-
respx.get("https://skillplus.xyz/api/report/scan_123/badge.svg").mock(
|
|
192
|
-
return_value=Response(200, text="<svg></svg>")
|
|
193
|
-
)
|
|
194
|
-
|
|
195
|
-
with SkillPlus(api_key="skp_test") as client:
|
|
196
|
-
badge = client.get_badge("scan_123")
|
|
197
|
-
|
|
198
|
-
assert badge == "<svg></svg>"
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
def test_get_badge_url_uses_hosted_service() -> None:
|
|
202
|
-
client = SkillPlus(api_key="skp_test")
|
|
203
|
-
try:
|
|
204
|
-
assert (
|
|
205
|
-
client.get_badge_url("scan_123")
|
|
206
|
-
== "https://skillplus.xyz/api/report/scan_123/badge.svg"
|
|
207
|
-
)
|
|
208
|
-
finally:
|
|
209
|
-
client.close()
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
@respx.mock
|
|
213
|
-
def test_non_ok_response_raises_skillplus_error() -> None:
|
|
214
|
-
respx.post("https://skillplus.xyz/api/sdk/query").mock(
|
|
215
|
-
return_value=Response(401, json={"detail": "Invalid API key"})
|
|
216
|
-
)
|
|
217
|
-
|
|
218
|
-
with SkillPlus(api_key="bad_key") as client:
|
|
219
|
-
with pytest.raises(SkillPlusError) as exc_info:
|
|
220
|
-
client.query("https://github.com/owner/repo")
|
|
221
|
-
|
|
222
|
-
assert exc_info.value.status_code == 401
|
|
223
|
-
assert exc_info.value.message == "Invalid API key"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|