verifaied 0.0.1__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.
- verifaied-0.0.1/.gitignore +52 -0
- verifaied-0.0.1/PKG-INFO +65 -0
- verifaied-0.0.1/README.md +47 -0
- verifaied-0.0.1/pyproject.toml +52 -0
- verifaied-0.0.1/src/verifaied/__init__.py +3 -0
- verifaied-0.0.1/src/verifaied/__main__.py +4 -0
- verifaied-0.0.1/src/verifaied/cli.py +189 -0
- verifaied-0.0.1/src/verifaied/client.py +103 -0
- verifaied-0.0.1/src/verifaied/config.py +25 -0
- verifaied-0.0.1/src/verifaied/uploader.py +135 -0
- verifaied-0.0.1/tests/__init__.py +0 -0
- verifaied-0.0.1/tests/test_cli.py +203 -0
- verifaied-0.0.1/tests/test_client.py +183 -0
- verifaied-0.0.1/tests/test_uploader.py +147 -0
- verifaied-0.0.1/uv.lock +346 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
.venv/
|
|
8
|
+
venv/
|
|
9
|
+
env/
|
|
10
|
+
*.egg-info/
|
|
11
|
+
dist/
|
|
12
|
+
build/
|
|
13
|
+
|
|
14
|
+
# Node
|
|
15
|
+
node_modules/
|
|
16
|
+
frontend/dist/
|
|
17
|
+
frontend/coverage/
|
|
18
|
+
|
|
19
|
+
# IDE
|
|
20
|
+
.idea/
|
|
21
|
+
.vscode/
|
|
22
|
+
*.swp
|
|
23
|
+
*.swo
|
|
24
|
+
|
|
25
|
+
# Environment
|
|
26
|
+
**/.env
|
|
27
|
+
**/.env.*
|
|
28
|
+
|
|
29
|
+
# Docker
|
|
30
|
+
pgdata/
|
|
31
|
+
|
|
32
|
+
# OS
|
|
33
|
+
.DS_Store
|
|
34
|
+
Thumbs.db
|
|
35
|
+
|
|
36
|
+
# Coverage
|
|
37
|
+
.coverage
|
|
38
|
+
coverage.json
|
|
39
|
+
htmlcov/
|
|
40
|
+
|
|
41
|
+
# Keys
|
|
42
|
+
*.pem
|
|
43
|
+
|
|
44
|
+
TODO.txt
|
|
45
|
+
|
|
46
|
+
# Terraform
|
|
47
|
+
**/.terraform/
|
|
48
|
+
*.tfstate
|
|
49
|
+
*.tfstate.backup
|
|
50
|
+
# Auto-loaded tfvars files hold env-specific secrets/IPs (e.g. allowlist.auto.tfvars).
|
|
51
|
+
# Plain terraform.tfvars is checked in — keep it free of sensitive values.
|
|
52
|
+
*.auto.tfvars
|
verifaied-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: verifaied
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Upload local pytest coverage to verifAIed for instant feedback
|
|
5
|
+
Project-URL: Homepage, https://pypi.org/project/verifaied/
|
|
6
|
+
Author: Kyle Richards
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: ai,coverage,llm,pytest,testing
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Topic :: Software Development :: Testing
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: httpx>=0.27.0
|
|
15
|
+
Requires-Dist: rich>=13.7.0
|
|
16
|
+
Requires-Dist: typer>=0.12.0
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# verifaied
|
|
20
|
+
|
|
21
|
+
CLI for uploading local pytest coverage to verifAIed so an LLM (or you) can see what's untested without waiting for CI.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv tool install verifaied # or
|
|
27
|
+
pipx install verifaied
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Configure
|
|
31
|
+
|
|
32
|
+
Set the API token (mint one from the verifAIed app) and, for self-hosted
|
|
33
|
+
or local backends, the API URL:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
export VERIFAIED_API_TOKEN=vr_live_...
|
|
37
|
+
export VERIFAIED_API_URL=http://localhost:8000 # default
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Use
|
|
41
|
+
|
|
42
|
+
After a pytest run that emits `coverage.json`:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pytest --cov --cov-report=json --junitxml=junit.xml
|
|
46
|
+
verifaied upload --repo <repository-id>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The CLI reads `coverage.json`, pulls the source for every file it
|
|
50
|
+
references from your working tree, and posts everything to
|
|
51
|
+
`/repositories/<id>/local-coverage`. The response prints a summary of
|
|
52
|
+
untested / partial / failing functions so you (or your LLM) can fix
|
|
53
|
+
them on the next iteration.
|
|
54
|
+
|
|
55
|
+
### Flags
|
|
56
|
+
|
|
57
|
+
- `--repo / -r <UUID>` — repository id (required)
|
|
58
|
+
- `--branch / -b <name>` — branch to attach the upload to (default:
|
|
59
|
+
`git branch --show-current`, then `local`)
|
|
60
|
+
- `--coverage <path>` — path to coverage.json (default: `./coverage.json`)
|
|
61
|
+
- `--junit <path>` — optional JUnit XML for failing-test detail
|
|
62
|
+
- `--commit-sha <sha>` — commit sha for display (default: `git rev-parse HEAD`)
|
|
63
|
+
- `--root <path>` — root the coverage paths are relative to (default: cwd)
|
|
64
|
+
- `--api-url <url>` — backend base URL (overrides `VERIFAIED_API_URL`)
|
|
65
|
+
- `--token <token>` — API token (overrides `VERIFAIED_API_TOKEN`)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# verifaied
|
|
2
|
+
|
|
3
|
+
CLI for uploading local pytest coverage to verifAIed so an LLM (or you) can see what's untested without waiting for CI.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv tool install verifaied # or
|
|
9
|
+
pipx install verifaied
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Configure
|
|
13
|
+
|
|
14
|
+
Set the API token (mint one from the verifAIed app) and, for self-hosted
|
|
15
|
+
or local backends, the API URL:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
export VERIFAIED_API_TOKEN=vr_live_...
|
|
19
|
+
export VERIFAIED_API_URL=http://localhost:8000 # default
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Use
|
|
23
|
+
|
|
24
|
+
After a pytest run that emits `coverage.json`:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pytest --cov --cov-report=json --junitxml=junit.xml
|
|
28
|
+
verifaied upload --repo <repository-id>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The CLI reads `coverage.json`, pulls the source for every file it
|
|
32
|
+
references from your working tree, and posts everything to
|
|
33
|
+
`/repositories/<id>/local-coverage`. The response prints a summary of
|
|
34
|
+
untested / partial / failing functions so you (or your LLM) can fix
|
|
35
|
+
them on the next iteration.
|
|
36
|
+
|
|
37
|
+
### Flags
|
|
38
|
+
|
|
39
|
+
- `--repo / -r <UUID>` — repository id (required)
|
|
40
|
+
- `--branch / -b <name>` — branch to attach the upload to (default:
|
|
41
|
+
`git branch --show-current`, then `local`)
|
|
42
|
+
- `--coverage <path>` — path to coverage.json (default: `./coverage.json`)
|
|
43
|
+
- `--junit <path>` — optional JUnit XML for failing-test detail
|
|
44
|
+
- `--commit-sha <sha>` — commit sha for display (default: `git rev-parse HEAD`)
|
|
45
|
+
- `--root <path>` — root the coverage paths are relative to (default: cwd)
|
|
46
|
+
- `--api-url <url>` — backend base URL (overrides `VERIFAIED_API_URL`)
|
|
47
|
+
- `--token <token>` — API token (overrides `VERIFAIED_API_TOKEN`)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "verifaied"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Upload local pytest coverage to verifAIed for instant feedback"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Kyle Richards" }]
|
|
9
|
+
keywords = ["pytest", "coverage", "testing", "ai", "llm"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Topic :: Software Development :: Testing",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"httpx>=0.27.0",
|
|
18
|
+
"typer>=0.12.0",
|
|
19
|
+
"rich>=13.7.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
verifaied = "verifaied.cli:app"
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://pypi.org/project/verifaied/"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["hatchling"]
|
|
30
|
+
build-backend = "hatchling.build"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/verifaied"]
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=8.0.0",
|
|
38
|
+
"pytest-httpx>=0.30.0",
|
|
39
|
+
"ruff>=0.8.0",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint]
|
|
46
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
47
|
+
# Typer's idiomatic API is `def cmd(arg: T = typer.Option(...))`. Ruff's
|
|
48
|
+
# B008 flags any function-call default, which would force us to wrap
|
|
49
|
+
# every option — overwhelming the actual signal. Disable for the file
|
|
50
|
+
# that uses Typer's surface; keep B008 enabled everywhere else.
|
|
51
|
+
[tool.ruff.lint.per-file-ignores]
|
|
52
|
+
"src/verifaied/cli.py" = ["B008"]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Typer entry point: `verifaied upload`.
|
|
2
|
+
|
|
3
|
+
The CLI surface is intentionally tiny — one verb, all options also
|
|
4
|
+
available as env vars. Exit codes are meaningful so CI pipelines can
|
|
5
|
+
fail builds on missing coverage:
|
|
6
|
+
|
|
7
|
+
0 success
|
|
8
|
+
1 usage / config / preflight error (missing files, bad json, no token)
|
|
9
|
+
2 HTTP/API error (auth, 404, 413, network)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from uuid import UUID
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
|
|
21
|
+
from verifaied import __version__
|
|
22
|
+
from verifaied.client import ApiError, UploadResult, upload
|
|
23
|
+
from verifaied.config import (
|
|
24
|
+
DEFAULT_API_URL,
|
|
25
|
+
ENV_API_TOKEN,
|
|
26
|
+
ENV_API_URL,
|
|
27
|
+
resolved_api_token,
|
|
28
|
+
resolved_api_url,
|
|
29
|
+
)
|
|
30
|
+
from verifaied.uploader import UploaderError, build_payload
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
add_completion=False,
|
|
34
|
+
no_args_is_help=True,
|
|
35
|
+
help="Upload local pytest coverage to verifAIed.",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
console = Console()
|
|
39
|
+
err_console = Console(stderr=True)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _version_callback(value: bool) -> None:
|
|
43
|
+
if value:
|
|
44
|
+
console.print(f"verifaied {__version__}")
|
|
45
|
+
raise typer.Exit()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.callback()
|
|
49
|
+
def main(
|
|
50
|
+
version: bool = typer.Option(
|
|
51
|
+
False,
|
|
52
|
+
"--version",
|
|
53
|
+
callback=_version_callback,
|
|
54
|
+
is_eager=True,
|
|
55
|
+
help="Show the CLI version and exit.",
|
|
56
|
+
),
|
|
57
|
+
) -> None:
|
|
58
|
+
"""verifAIed — instant coverage feedback for AI-written tests."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command("upload")
|
|
62
|
+
def upload_command(
|
|
63
|
+
repo: UUID = typer.Option(
|
|
64
|
+
...,
|
|
65
|
+
"--repo",
|
|
66
|
+
"-r",
|
|
67
|
+
help="Repository id (UUID) from the verifAIed dashboard.",
|
|
68
|
+
),
|
|
69
|
+
branch: str | None = typer.Option(
|
|
70
|
+
None,
|
|
71
|
+
"--branch",
|
|
72
|
+
"-b",
|
|
73
|
+
help="Branch name. Defaults to `git branch --show-current`.",
|
|
74
|
+
),
|
|
75
|
+
coverage: Path | None = typer.Option(
|
|
76
|
+
None,
|
|
77
|
+
"--coverage",
|
|
78
|
+
help="Path to the pytest-cov json report (default: ./coverage.json).",
|
|
79
|
+
),
|
|
80
|
+
junit: Path | None = typer.Option(
|
|
81
|
+
None,
|
|
82
|
+
"--junit",
|
|
83
|
+
help="Optional JUnit XML for failing-test detail.",
|
|
84
|
+
),
|
|
85
|
+
commit_sha: str | None = typer.Option(
|
|
86
|
+
None,
|
|
87
|
+
"--commit-sha",
|
|
88
|
+
help="Commit sha for display. Defaults to `git rev-parse HEAD`.",
|
|
89
|
+
),
|
|
90
|
+
root: Path | None = typer.Option(
|
|
91
|
+
None,
|
|
92
|
+
"--root",
|
|
93
|
+
help="Root the coverage paths are relative to (default: cwd).",
|
|
94
|
+
),
|
|
95
|
+
api_url: str | None = typer.Option(
|
|
96
|
+
None,
|
|
97
|
+
"--api-url",
|
|
98
|
+
help=f"Backend base URL. Defaults to ${ENV_API_URL} or {DEFAULT_API_URL}.",
|
|
99
|
+
),
|
|
100
|
+
token: str | None = typer.Option(
|
|
101
|
+
None,
|
|
102
|
+
"--token",
|
|
103
|
+
help=f"API token (vr_live_...). Defaults to ${ENV_API_TOKEN}.",
|
|
104
|
+
),
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Read coverage.json + working-tree source, POST to verifAIed."""
|
|
107
|
+
resolved_url = resolved_api_url(api_url)
|
|
108
|
+
resolved_token = resolved_api_token(token)
|
|
109
|
+
if not resolved_token:
|
|
110
|
+
err_console.print(
|
|
111
|
+
f"[red]error[/red]: no API token (set ${ENV_API_TOKEN} or pass --token)"
|
|
112
|
+
)
|
|
113
|
+
raise typer.Exit(code=1)
|
|
114
|
+
|
|
115
|
+
coverage_path = coverage or Path("coverage.json")
|
|
116
|
+
root_path = root or Path.cwd()
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
payload = build_payload(
|
|
120
|
+
coverage_path=coverage_path,
|
|
121
|
+
root=root_path,
|
|
122
|
+
branch=branch,
|
|
123
|
+
commit_sha=commit_sha,
|
|
124
|
+
junit_path=junit,
|
|
125
|
+
)
|
|
126
|
+
except UploaderError as e:
|
|
127
|
+
err_console.print(f"[red]error[/red]: {e}")
|
|
128
|
+
raise typer.Exit(code=1) from e
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
result = upload(
|
|
132
|
+
api_url=resolved_url,
|
|
133
|
+
token=resolved_token,
|
|
134
|
+
repo_id=repo,
|
|
135
|
+
payload=payload,
|
|
136
|
+
)
|
|
137
|
+
except ApiError as e:
|
|
138
|
+
err_console.print(f"[red]error[/red]: {e.message}")
|
|
139
|
+
raise typer.Exit(code=2) from e
|
|
140
|
+
|
|
141
|
+
_render(result, api_url=resolved_url, repo_id=repo)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _render(result: UploadResult, *, api_url: str, repo_id: UUID) -> None:
|
|
145
|
+
"""Print a compact, scannable summary. Designed for LLM tail-reads
|
|
146
|
+
too: the counts come first so the model sees the deltas without
|
|
147
|
+
scrolling past a function list."""
|
|
148
|
+
table = Table(show_header=False, box=None, padding=(0, 1))
|
|
149
|
+
table.add_row("branch", result.branch)
|
|
150
|
+
table.add_row("files received", str(result.files_received))
|
|
151
|
+
table.add_row("functions matched", str(result.functions_matched))
|
|
152
|
+
table.add_row("untested", str(len(result.untested)))
|
|
153
|
+
table.add_row("partial", str(len(result.partial)))
|
|
154
|
+
table.add_row("failing tests", str(len(result.failing_tests)))
|
|
155
|
+
console.print(table)
|
|
156
|
+
|
|
157
|
+
if result.functions_matched == 0:
|
|
158
|
+
err_console.print(
|
|
159
|
+
"[yellow]warning[/yellow]: 0 functions matched — your `files` keys "
|
|
160
|
+
"likely don't match the paths in coverage.json. Try --root."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
_print_function_list("untested", result.untested)
|
|
164
|
+
_print_function_list("partial", result.partial)
|
|
165
|
+
|
|
166
|
+
if result.failing_tests:
|
|
167
|
+
console.print("\n[bold]failing tests[/bold]")
|
|
168
|
+
for t in result.failing_tests[:10]:
|
|
169
|
+
node = t.get("node_id") or t.get("name") or "?"
|
|
170
|
+
console.print(f" - {node}")
|
|
171
|
+
if len(result.failing_tests) > 10:
|
|
172
|
+
console.print(f" ... and {len(result.failing_tests) - 10} more")
|
|
173
|
+
|
|
174
|
+
console.print(
|
|
175
|
+
f"\n[dim]→ {api_url.rstrip('/')}/repos/{repo_id}"
|
|
176
|
+
f"/branches/{result.branch}?analysisId={result.analysis_id}[/dim]"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _print_function_list(label: str, fns: list[dict]) -> None:
|
|
181
|
+
if not fns:
|
|
182
|
+
return
|
|
183
|
+
console.print(f"\n[bold]{label}[/bold]")
|
|
184
|
+
for fn in fns[:10]:
|
|
185
|
+
path = fn.get("file_path", "?")
|
|
186
|
+
name = fn.get("name", "?")
|
|
187
|
+
console.print(f" - {path}::{name}")
|
|
188
|
+
if len(fns) > 10:
|
|
189
|
+
console.print(f" ... and {len(fns) - 10} more")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Thin httpx wrapper around POST /repositories/{id}/local-coverage.
|
|
2
|
+
|
|
3
|
+
Kept separate from ``uploader.py`` so the file-walking logic can be
|
|
4
|
+
unit-tested without spinning a fake HTTP server, and so the HTTP layer
|
|
5
|
+
can be swapped if/when we add a streaming variant for huge uploads.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
from uuid import UUID
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from verifaied.uploader import Payload
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ApiError(Exception):
|
|
20
|
+
"""Raised for any non-2xx response. The CLI maps this to an exit
|
|
21
|
+
code + a friendly message; the raw status + detail are surfaced so
|
|
22
|
+
the user can tell 401 (token) from 404 (wrong repo) from 413 (too
|
|
23
|
+
big) without reading a stack trace."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, status_code: int, message: str) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.status_code = status_code
|
|
28
|
+
self.message = message
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class UploadResult:
|
|
33
|
+
analysis_id: str
|
|
34
|
+
branch: str
|
|
35
|
+
files_received: int
|
|
36
|
+
functions_matched: int
|
|
37
|
+
untested: list[dict[str, Any]]
|
|
38
|
+
partial: list[dict[str, Any]]
|
|
39
|
+
failing_tests: list[dict[str, Any]]
|
|
40
|
+
message: str
|
|
41
|
+
summary: dict[str, Any]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def upload(
|
|
45
|
+
*,
|
|
46
|
+
api_url: str,
|
|
47
|
+
token: str,
|
|
48
|
+
repo_id: UUID,
|
|
49
|
+
payload: Payload,
|
|
50
|
+
timeout: float = 60.0,
|
|
51
|
+
) -> UploadResult:
|
|
52
|
+
"""POST the payload and return the parsed response.
|
|
53
|
+
|
|
54
|
+
Times out at 60s by default — the synchronous endpoint persists rows
|
|
55
|
+
and re-runs the analyzer inline, so very large repos may push past
|
|
56
|
+
the default httpx 5s. Callers can override for CI use.
|
|
57
|
+
"""
|
|
58
|
+
body = {
|
|
59
|
+
"branch": payload.branch,
|
|
60
|
+
"commit_sha": payload.commit_sha,
|
|
61
|
+
"files": payload.files,
|
|
62
|
+
"coverage_json": payload.coverage_json,
|
|
63
|
+
}
|
|
64
|
+
if payload.junit_xml is not None:
|
|
65
|
+
body["junit_xml"] = payload.junit_xml
|
|
66
|
+
|
|
67
|
+
url = f"{api_url.rstrip('/')}/repositories/{repo_id}/local-coverage"
|
|
68
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
response = httpx.post(url, json=body, headers=headers, timeout=timeout)
|
|
72
|
+
except httpx.HTTPError as e:
|
|
73
|
+
raise ApiError(0, f"could not reach {url}: {e}") from e
|
|
74
|
+
|
|
75
|
+
if response.status_code >= 400:
|
|
76
|
+
raise ApiError(response.status_code, _detail(response))
|
|
77
|
+
|
|
78
|
+
data = response.json()
|
|
79
|
+
return UploadResult(
|
|
80
|
+
analysis_id=data["analysis_id"],
|
|
81
|
+
branch=data["branch"],
|
|
82
|
+
files_received=data["files_received"],
|
|
83
|
+
functions_matched=data["functions_matched"],
|
|
84
|
+
untested=data.get("untested") or [],
|
|
85
|
+
partial=data.get("partial") or [],
|
|
86
|
+
failing_tests=data.get("failing_tests") or [],
|
|
87
|
+
message=data.get("message") or "",
|
|
88
|
+
summary=data.get("summary") or {},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _detail(response: httpx.Response) -> str:
|
|
93
|
+
"""Pull a human-readable error message out of the response. Falls
|
|
94
|
+
back to status reason if the body isn't JSON or doesn't carry a
|
|
95
|
+
``detail`` field."""
|
|
96
|
+
try:
|
|
97
|
+
body = response.json()
|
|
98
|
+
except ValueError:
|
|
99
|
+
return f"{response.status_code} {response.reason_phrase}".strip()
|
|
100
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
101
|
+
if isinstance(detail, str) and detail:
|
|
102
|
+
return detail
|
|
103
|
+
return f"{response.status_code} {response.reason_phrase}".strip()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Env-var defaults for the CLI.
|
|
2
|
+
|
|
3
|
+
Kept thin on purpose — every option is also a CLI flag, so this module
|
|
4
|
+
exists only to centralize the env-var *names* and the dev-friendly
|
|
5
|
+
``http://localhost:8000`` default. No filesystem config files (yet); we
|
|
6
|
+
follow the 12-factor convention so the CLI behaves the same in shell,
|
|
7
|
+
pre-commit hooks, and CI.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
ENV_API_URL = "VERIFAIED_API_URL"
|
|
15
|
+
ENV_API_TOKEN = "VERIFAIED_API_TOKEN"
|
|
16
|
+
|
|
17
|
+
DEFAULT_API_URL = "http://localhost:8000"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolved_api_url(override: str | None) -> str:
|
|
21
|
+
return override or os.environ.get(ENV_API_URL) or DEFAULT_API_URL
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolved_api_token(override: str | None) -> str | None:
|
|
25
|
+
return override or os.environ.get(ENV_API_TOKEN)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Build the local-coverage POST payload from a pytest-cov json file.
|
|
2
|
+
|
|
3
|
+
The single risky thing in the CLI is the file-path round-trip: the
|
|
4
|
+
``files`` dict in the request must use the same string keys as the
|
|
5
|
+
``coverage.json`` records, otherwise the backend's AST analyzer can't
|
|
6
|
+
match function definitions to coverage rows and ``functions_matched``
|
|
7
|
+
comes back as zero. We resolve every path *relative to the same root*
|
|
8
|
+
the user gave us, and bail loudly if any source can't be read.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UploaderError(Exception):
|
|
21
|
+
"""Raised for any pre-flight problem (missing file, malformed json,
|
|
22
|
+
git failure). The CLI catches this and prints a friendly message
|
|
23
|
+
rather than a stack trace."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Payload:
|
|
28
|
+
branch: str
|
|
29
|
+
commit_sha: str | None
|
|
30
|
+
files: dict[str, str]
|
|
31
|
+
coverage_json: dict[str, Any]
|
|
32
|
+
junit_xml: str | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_coverage_json(path: Path) -> dict[str, Any]:
|
|
36
|
+
if not path.exists():
|
|
37
|
+
raise UploaderError(
|
|
38
|
+
f"coverage file not found: {path}. Run pytest with "
|
|
39
|
+
"`--cov --cov-report=json` first."
|
|
40
|
+
)
|
|
41
|
+
try:
|
|
42
|
+
data = json.loads(path.read_text())
|
|
43
|
+
except json.JSONDecodeError as e:
|
|
44
|
+
raise UploaderError(f"could not parse {path}: {e}") from e
|
|
45
|
+
if not isinstance(data, dict) or "files" not in data:
|
|
46
|
+
raise UploaderError(
|
|
47
|
+
f"{path} doesn't look like a pytest-cov json report "
|
|
48
|
+
"(missing top-level 'files' key)."
|
|
49
|
+
)
|
|
50
|
+
return data
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_files_referenced_by(coverage: dict[str, Any], root: Path) -> dict[str, str]:
|
|
54
|
+
"""Read each source file the coverage json references.
|
|
55
|
+
|
|
56
|
+
Keys are returned **unchanged** from the coverage json so the upload
|
|
57
|
+
payload matches the backend's expectations exactly. Values are the
|
|
58
|
+
file contents read from ``<root>/<key>``.
|
|
59
|
+
"""
|
|
60
|
+
files_block = coverage.get("files") or {}
|
|
61
|
+
if not isinstance(files_block, dict) or not files_block:
|
|
62
|
+
raise UploaderError("coverage json has no 'files' entries — nothing to upload.")
|
|
63
|
+
out: dict[str, str] = {}
|
|
64
|
+
missing: list[str] = []
|
|
65
|
+
for rel_path in files_block.keys():
|
|
66
|
+
source_path = root / rel_path
|
|
67
|
+
if not source_path.is_file():
|
|
68
|
+
missing.append(rel_path)
|
|
69
|
+
continue
|
|
70
|
+
out[rel_path] = source_path.read_text()
|
|
71
|
+
if missing:
|
|
72
|
+
joined = "\n ".join(missing)
|
|
73
|
+
raise UploaderError(
|
|
74
|
+
"could not read source for the following files referenced by "
|
|
75
|
+
f"coverage.json (looking under {root}):\n {joined}\n"
|
|
76
|
+
"Set --root to the directory pytest was run from."
|
|
77
|
+
)
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_junit(path: Path | None) -> str | None:
|
|
82
|
+
if path is None:
|
|
83
|
+
return None
|
|
84
|
+
if not path.exists():
|
|
85
|
+
raise UploaderError(f"JUnit file not found: {path}")
|
|
86
|
+
return path.read_text()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def detect_branch() -> str:
|
|
90
|
+
"""`git branch --show-current`, or the literal ``"local"`` if we're
|
|
91
|
+
outside a git repo or on a detached HEAD. The backend treats this as
|
|
92
|
+
a plain string; users on detached HEAD can pass --branch explicitly."""
|
|
93
|
+
out = _git("branch", "--show-current")
|
|
94
|
+
return out or "local"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def detect_commit_sha() -> str | None:
|
|
98
|
+
"""Short-circuits to None outside a git repo so the upload still
|
|
99
|
+
succeeds — the backend treats commit_sha as optional and uses its
|
|
100
|
+
own content hash for cache keying."""
|
|
101
|
+
return _git("rev-parse", "HEAD") or None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _git(*args: str) -> str:
|
|
105
|
+
try:
|
|
106
|
+
result = subprocess.run(
|
|
107
|
+
["git", *args],
|
|
108
|
+
capture_output=True,
|
|
109
|
+
text=True,
|
|
110
|
+
check=False,
|
|
111
|
+
)
|
|
112
|
+
except FileNotFoundError:
|
|
113
|
+
return ""
|
|
114
|
+
if result.returncode != 0:
|
|
115
|
+
return ""
|
|
116
|
+
return result.stdout.strip()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_payload(
|
|
120
|
+
*,
|
|
121
|
+
coverage_path: Path,
|
|
122
|
+
root: Path,
|
|
123
|
+
branch: str | None,
|
|
124
|
+
commit_sha: str | None,
|
|
125
|
+
junit_path: Path | None,
|
|
126
|
+
) -> Payload:
|
|
127
|
+
coverage_json = load_coverage_json(coverage_path)
|
|
128
|
+
files = load_files_referenced_by(coverage_json, root)
|
|
129
|
+
return Payload(
|
|
130
|
+
branch=branch or detect_branch(),
|
|
131
|
+
commit_sha=commit_sha or detect_commit_sha(),
|
|
132
|
+
files=files,
|
|
133
|
+
coverage_json=coverage_json,
|
|
134
|
+
junit_xml=load_junit(junit_path),
|
|
135
|
+
)
|
|
File without changes
|