flaky-detector-cli 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.
- flaky_detector_cli-0.1.0/LICENSE +21 -0
- flaky_detector_cli-0.1.0/PKG-INFO +85 -0
- flaky_detector_cli-0.1.0/README.md +65 -0
- flaky_detector_cli-0.1.0/pyproject.toml +35 -0
- flaky_detector_cli-0.1.0/setup.cfg +4 -0
- flaky_detector_cli-0.1.0/src/flaky_detector_cli/__init__.py +1 -0
- flaky_detector_cli-0.1.0/src/flaky_detector_cli/__main__.py +4 -0
- flaky_detector_cli-0.1.0/src/flaky_detector_cli/cli.py +201 -0
- flaky_detector_cli-0.1.0/src/flaky_detector_cli.egg-info/PKG-INFO +85 -0
- flaky_detector_cli-0.1.0/src/flaky_detector_cli.egg-info/SOURCES.txt +12 -0
- flaky_detector_cli-0.1.0/src/flaky_detector_cli.egg-info/dependency_links.txt +1 -0
- flaky_detector_cli-0.1.0/src/flaky_detector_cli.egg-info/entry_points.txt +2 -0
- flaky_detector_cli-0.1.0/src/flaky_detector_cli.egg-info/top_level.txt +2 -0
- flaky_detector_cli-0.1.0/src/tests/test_cli.py +149 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Your Name
|
|
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
|
|
7
|
+
deal in the Software without restriction, including without limitation the
|
|
8
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
|
9
|
+
sell 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
|
|
13
|
+
all 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
|
|
20
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
21
|
+
DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flaky-detector-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Upload JUnit XML test reports to a flaky-detector project from the command line or CI.
|
|
5
|
+
Author: Your Name
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/YOURNAME/flaky-detector
|
|
8
|
+
Project-URL: Repository, https://github.com/YOURNAME/flaky-detector
|
|
9
|
+
Keywords: testing,pytest,junit,ci,flaky-tests
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Testing
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# flaky-detector-cli
|
|
22
|
+
|
|
23
|
+
A tiny, zero-dependency command-line tool that uploads a JUnit XML test
|
|
24
|
+
report to a [flaky-detector](https://github.com/YOURNAME/flaky-detector)
|
|
25
|
+
project — the same thing the project's GitHub Actions snippet does with
|
|
26
|
+
`curl`, packaged as a real command.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install flaky-detector-cli
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pytest --junitxml=report.xml
|
|
36
|
+
flaky-detector submit report.xml
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
By default it reads two environment variables — the same two names your
|
|
40
|
+
flaky-detector project's CI snippet already uses:
|
|
41
|
+
|
|
42
|
+
- `FLAKY_DETECTOR_URL` — your backend's base URL
|
|
43
|
+
- `FLAKY_DETECTOR_TOKEN` — the project's upload token
|
|
44
|
+
|
|
45
|
+
Everything else is auto-detected. In GitHub Actions, it reads `GITHUB_SHA`,
|
|
46
|
+
`GITHUB_REF_NAME`, `GITHUB_RUN_ID`, and `GITHUB_RUN_ATTEMPT` automatically.
|
|
47
|
+
Running locally, it falls back to your current `git` checkout for the
|
|
48
|
+
commit and branch. You can override any of it explicitly:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
flaky-detector submit report.xml \
|
|
52
|
+
--url https://your-backend.example.com \
|
|
53
|
+
--token YOUR_TOKEN \
|
|
54
|
+
--commit abc1234 \
|
|
55
|
+
--branch main \
|
|
56
|
+
--run-id 42 \
|
|
57
|
+
--run-attempt 1
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Why not `curl`?
|
|
61
|
+
|
|
62
|
+
`curl` works fine and is what the project's own onboarding flow suggests
|
|
63
|
+
first. This exists for people who'd rather not hand-build a multipart
|
|
64
|
+
`curl` command in CI YAML, and for local use: run your suite, then
|
|
65
|
+
`flaky-detector submit report.xml` to see the result on your dashboard
|
|
66
|
+
without touching CI at all.
|
|
67
|
+
|
|
68
|
+
## Exit codes
|
|
69
|
+
|
|
70
|
+
- `0` — uploaded successfully
|
|
71
|
+
- `1` — the server rejected the upload (bad token, malformed report, etc.);
|
|
72
|
+
stderr has the reason
|
|
73
|
+
- `2` — a required argument is missing (no URL/token, and none could be
|
|
74
|
+
auto-detected)
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
git clone https://github.com/YOURNAME/flaky-detector
|
|
80
|
+
cd flaky-detector/cli
|
|
81
|
+
pip install -e .
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
No dependencies to install for either running or developing this tool —
|
|
85
|
+
only the Python standard library.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# flaky-detector-cli
|
|
2
|
+
|
|
3
|
+
A tiny, zero-dependency command-line tool that uploads a JUnit XML test
|
|
4
|
+
report to a [flaky-detector](https://github.com/YOURNAME/flaky-detector)
|
|
5
|
+
project — the same thing the project's GitHub Actions snippet does with
|
|
6
|
+
`curl`, packaged as a real command.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install flaky-detector-cli
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pytest --junitxml=report.xml
|
|
16
|
+
flaky-detector submit report.xml
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
By default it reads two environment variables — the same two names your
|
|
20
|
+
flaky-detector project's CI snippet already uses:
|
|
21
|
+
|
|
22
|
+
- `FLAKY_DETECTOR_URL` — your backend's base URL
|
|
23
|
+
- `FLAKY_DETECTOR_TOKEN` — the project's upload token
|
|
24
|
+
|
|
25
|
+
Everything else is auto-detected. In GitHub Actions, it reads `GITHUB_SHA`,
|
|
26
|
+
`GITHUB_REF_NAME`, `GITHUB_RUN_ID`, and `GITHUB_RUN_ATTEMPT` automatically.
|
|
27
|
+
Running locally, it falls back to your current `git` checkout for the
|
|
28
|
+
commit and branch. You can override any of it explicitly:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
flaky-detector submit report.xml \
|
|
32
|
+
--url https://your-backend.example.com \
|
|
33
|
+
--token YOUR_TOKEN \
|
|
34
|
+
--commit abc1234 \
|
|
35
|
+
--branch main \
|
|
36
|
+
--run-id 42 \
|
|
37
|
+
--run-attempt 1
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Why not `curl`?
|
|
41
|
+
|
|
42
|
+
`curl` works fine and is what the project's own onboarding flow suggests
|
|
43
|
+
first. This exists for people who'd rather not hand-build a multipart
|
|
44
|
+
`curl` command in CI YAML, and for local use: run your suite, then
|
|
45
|
+
`flaky-detector submit report.xml` to see the result on your dashboard
|
|
46
|
+
without touching CI at all.
|
|
47
|
+
|
|
48
|
+
## Exit codes
|
|
49
|
+
|
|
50
|
+
- `0` — uploaded successfully
|
|
51
|
+
- `1` — the server rejected the upload (bad token, malformed report, etc.);
|
|
52
|
+
stderr has the reason
|
|
53
|
+
- `2` — a required argument is missing (no URL/token, and none could be
|
|
54
|
+
auto-detected)
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
git clone https://github.com/YOURNAME/flaky-detector
|
|
60
|
+
cd flaky-detector/cli
|
|
61
|
+
pip install -e .
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
No dependencies to install for either running or developing this tool —
|
|
65
|
+
only the Python standard library.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "flaky-detector-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Upload JUnit XML test reports to a flaky-detector project from the command line or CI."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Your Name" }]
|
|
13
|
+
keywords = ["testing", "pytest", "junit", "ci", "flaky-tests"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Software Development :: Testing",
|
|
21
|
+
]
|
|
22
|
+
# Deliberately zero runtime dependencies: argparse, urllib, subprocess are
|
|
23
|
+
# all standard library. Keeps `pip install` instant and avoids ever needing
|
|
24
|
+
# a dependency-conflict fix for a tool this small.
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/YOURNAME/flaky-detector"
|
|
29
|
+
Repository = "https://github.com/YOURNAME/flaky-detector"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
flaky-detector = "flaky_detector_cli.cli:main"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""
|
|
2
|
+
flaky-detector-cli: a tiny, zero-dependency command that uploads a JUnit XML
|
|
3
|
+
report to a flaky-detector project.
|
|
4
|
+
|
|
5
|
+
flaky-detector submit report.xml
|
|
6
|
+
|
|
7
|
+
Reads FLAKY_DETECTOR_URL and FLAKY_DETECTOR_TOKEN from the environment by
|
|
8
|
+
default (the same two names your GitHub Actions workflow secret/variable
|
|
9
|
+
already use), or pass --url/--token explicitly. Everything else (commit sha,
|
|
10
|
+
branch, run id/attempt) is auto-detected: from GitHub Actions' own env vars
|
|
11
|
+
when running in CI, falling back to your local `git` checkout otherwise.
|
|
12
|
+
|
|
13
|
+
No third-party dependencies: just the standard library.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import mimetypes
|
|
20
|
+
import os
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
import uuid
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from . import __version__
|
|
30
|
+
|
|
31
|
+
DEFAULT_TIMEOUT = 30
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _git(*args: str) -> str | None:
|
|
35
|
+
try:
|
|
36
|
+
out = subprocess.run(
|
|
37
|
+
["git", *args], capture_output=True, text=True, timeout=5, check=True
|
|
38
|
+
)
|
|
39
|
+
value = out.stdout.strip()
|
|
40
|
+
return value or None
|
|
41
|
+
except Exception:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def detect_commit_sha() -> str | None:
|
|
46
|
+
return os.environ.get("GITHUB_SHA") or _git("rev-parse", "HEAD")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def detect_branch() -> str | None:
|
|
50
|
+
ref = os.environ.get("GITHUB_REF_NAME")
|
|
51
|
+
if ref:
|
|
52
|
+
return ref
|
|
53
|
+
branch = _git("rev-parse", "--abbrev-ref", "HEAD")
|
|
54
|
+
return None if branch == "HEAD" else branch # "HEAD" means a detached checkout, not a real branch
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def detect_run_id() -> str:
|
|
58
|
+
return os.environ.get("GITHUB_RUN_ID") or f"local-{int(time.time())}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def detect_run_attempt() -> str:
|
|
62
|
+
return os.environ.get("GITHUB_RUN_ATTEMPT") or "1"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def encode_multipart(fields: dict[str, str], file_field: str, file_path: Path) -> tuple[bytes, str]:
|
|
66
|
+
"""Hand-rolled multipart/form-data body (stdlib only, no `requests`)."""
|
|
67
|
+
boundary = uuid.uuid4().hex
|
|
68
|
+
parts: list[bytes] = []
|
|
69
|
+
|
|
70
|
+
for name, value in fields.items():
|
|
71
|
+
parts.append(
|
|
72
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode()
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
content_type = mimetypes.guess_type(file_path.name)[0] or "application/xml"
|
|
76
|
+
parts.append(
|
|
77
|
+
(
|
|
78
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="{file_field}"; '
|
|
79
|
+
f'filename="{file_path.name}"\r\nContent-Type: {content_type}\r\n\r\n'
|
|
80
|
+
).encode()
|
|
81
|
+
+ file_path.read_bytes()
|
|
82
|
+
+ b"\r\n"
|
|
83
|
+
)
|
|
84
|
+
parts.append(f"--{boundary}--\r\n".encode())
|
|
85
|
+
|
|
86
|
+
body = b"".join(parts)
|
|
87
|
+
return body, f"multipart/form-data; boundary={boundary}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SubmitError(RuntimeError):
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def submit_report(
|
|
95
|
+
*,
|
|
96
|
+
url: str,
|
|
97
|
+
token: str,
|
|
98
|
+
report_path: Path,
|
|
99
|
+
commit_sha: str,
|
|
100
|
+
branch: str,
|
|
101
|
+
run_id: str,
|
|
102
|
+
run_attempt: str,
|
|
103
|
+
job_name: str = "",
|
|
104
|
+
) -> dict:
|
|
105
|
+
if not report_path.exists():
|
|
106
|
+
raise SubmitError(f"Report file not found: {report_path}")
|
|
107
|
+
|
|
108
|
+
fields = {
|
|
109
|
+
"run_id": run_id,
|
|
110
|
+
"run_attempt": run_attempt,
|
|
111
|
+
"job_name": job_name,
|
|
112
|
+
"commit_sha": commit_sha,
|
|
113
|
+
"branch": branch,
|
|
114
|
+
}
|
|
115
|
+
body, content_type = encode_multipart(fields, "report", report_path)
|
|
116
|
+
|
|
117
|
+
request = urllib.request.Request(
|
|
118
|
+
url.rstrip("/") + "/api/ingest/",
|
|
119
|
+
data=body,
|
|
120
|
+
method="POST",
|
|
121
|
+
headers={
|
|
122
|
+
"Authorization": f"Bearer {token}",
|
|
123
|
+
"Content-Type": content_type,
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
try:
|
|
127
|
+
with urllib.request.urlopen(request, timeout=DEFAULT_TIMEOUT) as resp:
|
|
128
|
+
return json.loads(resp.read().decode())
|
|
129
|
+
except urllib.error.HTTPError as exc:
|
|
130
|
+
detail = exc.read().decode(errors="replace")
|
|
131
|
+
raise SubmitError(f"Server rejected the upload ({exc.code}): {detail}") from exc
|
|
132
|
+
except urllib.error.URLError as exc:
|
|
133
|
+
raise SubmitError(f"Could not reach {url}: {exc.reason}") from exc
|
|
134
|
+
except OSError as exc:
|
|
135
|
+
raise SubmitError(f"Connection to {url} failed: {exc}") from exc
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
139
|
+
parser = argparse.ArgumentParser(prog="flaky-detector", description=__doc__.split("\n\n")[0])
|
|
140
|
+
parser.add_argument("--version", action="version", version=f"flaky-detector-cli {__version__}")
|
|
141
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
142
|
+
|
|
143
|
+
submit = sub.add_parser("submit", help="Upload a JUnit XML report")
|
|
144
|
+
submit.add_argument("report", type=Path, help="Path to the JUnit XML report, e.g. report.xml")
|
|
145
|
+
submit.add_argument("--url", default=os.environ.get("FLAKY_DETECTOR_URL"),
|
|
146
|
+
help="Backend base URL (env: FLAKY_DETECTOR_URL)")
|
|
147
|
+
submit.add_argument("--token", default=os.environ.get("FLAKY_DETECTOR_TOKEN"),
|
|
148
|
+
help="Project upload token (env: FLAKY_DETECTOR_TOKEN)")
|
|
149
|
+
submit.add_argument("--commit", default=None, help="Commit SHA (auto-detected if omitted)")
|
|
150
|
+
submit.add_argument("--branch", default=None, help="Branch name (auto-detected if omitted)")
|
|
151
|
+
submit.add_argument("--run-id", default=None, help="CI run id (auto-detected if omitted)")
|
|
152
|
+
submit.add_argument("--run-attempt", default=None, help="CI run attempt (auto-detected if omitted)")
|
|
153
|
+
submit.add_argument("--job-name", default="", help="Job name, for matrix builds")
|
|
154
|
+
return parser
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main(argv: list[str] | None = None) -> int:
|
|
158
|
+
args = build_parser().parse_args(argv)
|
|
159
|
+
|
|
160
|
+
if args.command == "submit":
|
|
161
|
+
if not args.url:
|
|
162
|
+
print("error: no backend URL given (pass --url or set FLAKY_DETECTOR_URL)", file=sys.stderr)
|
|
163
|
+
return 2
|
|
164
|
+
if not args.token:
|
|
165
|
+
print("error: no token given (pass --token or set FLAKY_DETECTOR_TOKEN)", file=sys.stderr)
|
|
166
|
+
return 2
|
|
167
|
+
|
|
168
|
+
commit_sha = args.commit or detect_commit_sha()
|
|
169
|
+
branch = args.branch or detect_branch()
|
|
170
|
+
if not commit_sha:
|
|
171
|
+
print("error: could not determine commit SHA (pass --commit)", file=sys.stderr)
|
|
172
|
+
return 2
|
|
173
|
+
if not branch:
|
|
174
|
+
print("error: could not determine branch (pass --branch)", file=sys.stderr)
|
|
175
|
+
return 2
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
result = submit_report(
|
|
179
|
+
url=args.url,
|
|
180
|
+
token=args.token,
|
|
181
|
+
report_path=args.report,
|
|
182
|
+
commit_sha=commit_sha,
|
|
183
|
+
branch=branch,
|
|
184
|
+
run_id=args.run_id or detect_run_id(),
|
|
185
|
+
run_attempt=args.run_attempt or detect_run_attempt(),
|
|
186
|
+
job_name=args.job_name,
|
|
187
|
+
)
|
|
188
|
+
except SubmitError as exc:
|
|
189
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
190
|
+
return 1
|
|
191
|
+
|
|
192
|
+
verb = "Created" if result.get("created") else "Already recorded"
|
|
193
|
+
print(f"{verb} run {result.get('run')}: "
|
|
194
|
+
f"{result.get('tests_in_report', 0)} tests, {result.get('tests_scored', 0)} scored.")
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
return 2
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flaky-detector-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Upload JUnit XML test reports to a flaky-detector project from the command line or CI.
|
|
5
|
+
Author: Your Name
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/YOURNAME/flaky-detector
|
|
8
|
+
Project-URL: Repository, https://github.com/YOURNAME/flaky-detector
|
|
9
|
+
Keywords: testing,pytest,junit,ci,flaky-tests
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Testing
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# flaky-detector-cli
|
|
22
|
+
|
|
23
|
+
A tiny, zero-dependency command-line tool that uploads a JUnit XML test
|
|
24
|
+
report to a [flaky-detector](https://github.com/YOURNAME/flaky-detector)
|
|
25
|
+
project — the same thing the project's GitHub Actions snippet does with
|
|
26
|
+
`curl`, packaged as a real command.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install flaky-detector-cli
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pytest --junitxml=report.xml
|
|
36
|
+
flaky-detector submit report.xml
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
By default it reads two environment variables — the same two names your
|
|
40
|
+
flaky-detector project's CI snippet already uses:
|
|
41
|
+
|
|
42
|
+
- `FLAKY_DETECTOR_URL` — your backend's base URL
|
|
43
|
+
- `FLAKY_DETECTOR_TOKEN` — the project's upload token
|
|
44
|
+
|
|
45
|
+
Everything else is auto-detected. In GitHub Actions, it reads `GITHUB_SHA`,
|
|
46
|
+
`GITHUB_REF_NAME`, `GITHUB_RUN_ID`, and `GITHUB_RUN_ATTEMPT` automatically.
|
|
47
|
+
Running locally, it falls back to your current `git` checkout for the
|
|
48
|
+
commit and branch. You can override any of it explicitly:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
flaky-detector submit report.xml \
|
|
52
|
+
--url https://your-backend.example.com \
|
|
53
|
+
--token YOUR_TOKEN \
|
|
54
|
+
--commit abc1234 \
|
|
55
|
+
--branch main \
|
|
56
|
+
--run-id 42 \
|
|
57
|
+
--run-attempt 1
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Why not `curl`?
|
|
61
|
+
|
|
62
|
+
`curl` works fine and is what the project's own onboarding flow suggests
|
|
63
|
+
first. This exists for people who'd rather not hand-build a multipart
|
|
64
|
+
`curl` command in CI YAML, and for local use: run your suite, then
|
|
65
|
+
`flaky-detector submit report.xml` to see the result on your dashboard
|
|
66
|
+
without touching CI at all.
|
|
67
|
+
|
|
68
|
+
## Exit codes
|
|
69
|
+
|
|
70
|
+
- `0` — uploaded successfully
|
|
71
|
+
- `1` — the server rejected the upload (bad token, malformed report, etc.);
|
|
72
|
+
stderr has the reason
|
|
73
|
+
- `2` — a required argument is missing (no URL/token, and none could be
|
|
74
|
+
auto-detected)
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
git clone https://github.com/YOURNAME/flaky-detector
|
|
80
|
+
cd flaky-detector/cli
|
|
81
|
+
pip install -e .
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
No dependencies to install for either running or developing this tool —
|
|
85
|
+
only the Python standard library.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/flaky_detector_cli/__init__.py
|
|
5
|
+
src/flaky_detector_cli/__main__.py
|
|
6
|
+
src/flaky_detector_cli/cli.py
|
|
7
|
+
src/flaky_detector_cli.egg-info/PKG-INFO
|
|
8
|
+
src/flaky_detector_cli.egg-info/SOURCES.txt
|
|
9
|
+
src/flaky_detector_cli.egg-info/dependency_links.txt
|
|
10
|
+
src/flaky_detector_cli.egg-info/entry_points.txt
|
|
11
|
+
src/flaky_detector_cli.egg-info/top_level.txt
|
|
12
|
+
src/tests/test_cli.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import MagicMock, patch
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
|
12
|
+
|
|
13
|
+
from flaky_detector_cli.cli import (
|
|
14
|
+
build_parser,
|
|
15
|
+
detect_branch,
|
|
16
|
+
detect_commit_sha,
|
|
17
|
+
detect_run_attempt,
|
|
18
|
+
detect_run_id,
|
|
19
|
+
encode_multipart,
|
|
20
|
+
main,
|
|
21
|
+
submit_report,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_encode_multipart_contains_all_fields_and_file_content(tmp_path=None):
|
|
26
|
+
tmp = Path(tempfile.mkdtemp())
|
|
27
|
+
report = tmp / "report.xml"
|
|
28
|
+
report.write_text("<testsuite></testsuite>")
|
|
29
|
+
|
|
30
|
+
body, content_type = encode_multipart(
|
|
31
|
+
{"run_id": "42", "branch": "main"}, "report", report
|
|
32
|
+
)
|
|
33
|
+
text = body.decode()
|
|
34
|
+
|
|
35
|
+
assert content_type.startswith("multipart/form-data; boundary=")
|
|
36
|
+
boundary = content_type.split("boundary=")[1]
|
|
37
|
+
assert text.count(f"--{boundary}") == 4 # 2 field parts + 1 file part + 1 closing boundary
|
|
38
|
+
assert text.strip().endswith(f"--{boundary}--")
|
|
39
|
+
assert 'name="run_id"' in text and "\r\n42\r\n" in text
|
|
40
|
+
assert 'name="branch"' in text and "\r\nmain\r\n" in text
|
|
41
|
+
assert 'name="report"; filename="report.xml"' in text
|
|
42
|
+
assert "<testsuite></testsuite>" in text
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_detect_commit_sha_prefers_github_env():
|
|
46
|
+
with patch.dict(os.environ, {"GITHUB_SHA": "deadbeef"}, clear=False):
|
|
47
|
+
assert detect_commit_sha() == "deadbeef"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_detect_branch_prefers_github_env():
|
|
51
|
+
with patch.dict(os.environ, {"GITHUB_REF_NAME": "main"}, clear=False):
|
|
52
|
+
assert detect_branch() == "main"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_detect_branch_falls_back_to_git_in_a_real_repo():
|
|
56
|
+
tmp = Path(tempfile.mkdtemp())
|
|
57
|
+
subprocess.run(["git", "init", "-q"], cwd=tmp, check=True)
|
|
58
|
+
subprocess.run(["git", "config", "user.email", "a@b.c"], cwd=tmp, check=True)
|
|
59
|
+
subprocess.run(["git", "config", "user.name", "a"], cwd=tmp, check=True)
|
|
60
|
+
(tmp / "f.txt").write_text("x")
|
|
61
|
+
subprocess.run(["git", "add", "."], cwd=tmp, check=True)
|
|
62
|
+
subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=tmp, check=True)
|
|
63
|
+
|
|
64
|
+
env = {k: v for k, v in os.environ.items() if k != "GITHUB_REF_NAME"}
|
|
65
|
+
cwd = os.getcwd()
|
|
66
|
+
try:
|
|
67
|
+
os.chdir(tmp)
|
|
68
|
+
with patch.dict(os.environ, env, clear=True):
|
|
69
|
+
branch = detect_branch()
|
|
70
|
+
sha = detect_commit_sha()
|
|
71
|
+
finally:
|
|
72
|
+
os.chdir(cwd)
|
|
73
|
+
assert branch in ("main", "master") # depends on the machine's git default
|
|
74
|
+
assert sha is not None and len(sha) == 40
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_detect_run_id_falls_back_to_a_local_placeholder():
|
|
78
|
+
env = {k: v for k, v in os.environ.items() if k != "GITHUB_RUN_ID"}
|
|
79
|
+
with patch.dict(os.environ, env, clear=True):
|
|
80
|
+
assert detect_run_id().startswith("local-")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_detect_run_attempt_defaults_to_one():
|
|
84
|
+
env = {k: v for k, v in os.environ.items() if k != "GITHUB_RUN_ATTEMPT"}
|
|
85
|
+
with patch.dict(os.environ, env, clear=True):
|
|
86
|
+
assert detect_run_attempt() == "1"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_submit_report_raises_on_missing_file():
|
|
90
|
+
try:
|
|
91
|
+
submit_report(
|
|
92
|
+
url="https://x", token="t", report_path=Path("/no/such/file.xml"),
|
|
93
|
+
commit_sha="a", branch="main", run_id="1", run_attempt="1",
|
|
94
|
+
)
|
|
95
|
+
raise AssertionError("expected SubmitError")
|
|
96
|
+
except Exception as e:
|
|
97
|
+
assert "not found" in str(e)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_build_parser_requires_a_subcommand():
|
|
101
|
+
parser = build_parser()
|
|
102
|
+
try:
|
|
103
|
+
parser.parse_args([])
|
|
104
|
+
raise AssertionError("expected SystemExit")
|
|
105
|
+
except SystemExit:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_main_errors_cleanly_with_no_token():
|
|
110
|
+
tmp = Path(tempfile.mkdtemp())
|
|
111
|
+
report = tmp / "r.xml"
|
|
112
|
+
report.write_text("<testsuite></testsuite>")
|
|
113
|
+
env = {k: v for k, v in os.environ.items() if k != "FLAKY_DETECTOR_TOKEN"}
|
|
114
|
+
err = io.StringIO()
|
|
115
|
+
with patch.dict(os.environ, env, clear=True), redirect_stderr(err):
|
|
116
|
+
code = main(["submit", str(report), "--url", "https://x"])
|
|
117
|
+
assert code == 2
|
|
118
|
+
assert "token" in err.getvalue()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@patch("flaky_detector_cli.cli.urllib.request.urlopen")
|
|
122
|
+
def test_main_success_path_prints_summary(mock_urlopen):
|
|
123
|
+
tmp = Path(tempfile.mkdtemp())
|
|
124
|
+
report = tmp / "r.xml"
|
|
125
|
+
report.write_text("<testsuite></testsuite>")
|
|
126
|
+
|
|
127
|
+
response = MagicMock()
|
|
128
|
+
response.read.return_value = json.dumps(
|
|
129
|
+
{"run": 7, "created": True, "tests_in_report": 3, "tests_scored": 3}
|
|
130
|
+
).encode()
|
|
131
|
+
mock_urlopen.return_value.__enter__.return_value = response
|
|
132
|
+
|
|
133
|
+
out = io.StringIO()
|
|
134
|
+
with redirect_stdout(out):
|
|
135
|
+
code = main([
|
|
136
|
+
"submit", str(report),
|
|
137
|
+
"--url", "https://api.example.com",
|
|
138
|
+
"--token", "sekret",
|
|
139
|
+
"--commit", "a" * 40,
|
|
140
|
+
"--branch", "main",
|
|
141
|
+
"--run-id", "1",
|
|
142
|
+
])
|
|
143
|
+
assert code == 0
|
|
144
|
+
assert "Created run 7" in out.getvalue()
|
|
145
|
+
assert "3 tests, 3 scored" in out.getvalue()
|
|
146
|
+
|
|
147
|
+
sent_request = mock_urlopen.call_args[0][0]
|
|
148
|
+
assert sent_request.full_url == "https://api.example.com/api/ingest/"
|
|
149
|
+
assert sent_request.get_header("Authorization") == "Bearer sekret"
|