werr 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.
- werr-0.1.0/LICENSE +21 -0
- werr-0.1.0/PKG-INFO +95 -0
- werr-0.1.0/README.md +73 -0
- werr-0.1.0/pyproject.toml +68 -0
- werr-0.1.0/setup.cfg +4 -0
- werr-0.1.0/werr.egg-info/.gitignore +1 -0
- werr-0.1.0/werr.egg-info/PKG-INFO +95 -0
- werr-0.1.0/werr.egg-info/SOURCES.txt +17 -0
- werr-0.1.0/werr.egg-info/dependency_links.txt +1 -0
- werr-0.1.0/werr.egg-info/entry_points.txt +2 -0
- werr-0.1.0/werr.egg-info/top_level.txt +1 -0
- werr-0.1.0/werrlib/__init__.py +1 -0
- werr-0.1.0/werrlib/cli.py +96 -0
- werr-0.1.0/werrlib/cmd.py +64 -0
- werr-0.1.0/werrlib/config.py +35 -0
- werr-0.1.0/werrlib/main.py +35 -0
- werr-0.1.0/werrlib/report.py +195 -0
- werr-0.1.0/werrlib/task.py +34 -0
- werr-0.1.0/werrlib/xml.py +59 -0
werr-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joe Jenne
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE
|
werr-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: werr
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A python project task runner
|
|
5
|
+
Project-URL: Homepage, https://github.com/jhjn/werr
|
|
6
|
+
Project-URL: Repository, https://github.com/jhjn/werr
|
|
7
|
+
Keywords: tasks,task-runner,makefile,checks,uv,automation,werr
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
16
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Requires-Python: >=3.14
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# werr
|
|
24
|
+
|
|
25
|
+
A simple, opinionated, python project task runner.
|
|
26
|
+
|
|
27
|
+
A task is a configured sequence of commands to run.
|
|
28
|
+
|
|
29
|
+
```toml
|
|
30
|
+
[project]
|
|
31
|
+
name = "appletree"
|
|
32
|
+
version = "0.1.0"
|
|
33
|
+
dependencies = [
|
|
34
|
+
"pysunlight==24.1",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.werr]
|
|
38
|
+
# 'check' is the default task if `werr` is run with no arguments.
|
|
39
|
+
task.check = [
|
|
40
|
+
"black --check {project}",
|
|
41
|
+
"isort --check {project}",
|
|
42
|
+
"ruff check {project}",
|
|
43
|
+
"mypy {project}",
|
|
44
|
+
"pytest",
|
|
45
|
+
]
|
|
46
|
+
task.fix = [
|
|
47
|
+
"black {project}",
|
|
48
|
+
"isort {project}",
|
|
49
|
+
"ruff fix {project}",
|
|
50
|
+
]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Running `werr` executes each `check` command in sequence, printing which failed and how.
|
|
54
|
+
The tool returns a non-zero exit code if any command fails.
|
|
55
|
+
|
|
56
|
+
Running `werr fix` executes each `fix` command in sequence.
|
|
57
|
+
|
|
58
|
+
NOTE: All commands are run using `uv` (the only dependency of this project).
|
|
59
|
+
|
|
60
|
+
## Command Variables
|
|
61
|
+
|
|
62
|
+
The following `{...}` variables are provided by `werr` to be used in task commands:
|
|
63
|
+
|
|
64
|
+
- `{project}` - the absolute path to the directory containing the `pyproject.toml` file
|
|
65
|
+
|
|
66
|
+
## Structured Output
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
werr # interactive human readable output (default)
|
|
70
|
+
werr --json # emit lines of JSON representing the result of each command
|
|
71
|
+
werr --xml # print Junit XML for CI
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Custom Tasks
|
|
75
|
+
|
|
76
|
+
Define a custom task with `task.<name> = [ ... ]`
|
|
77
|
+
|
|
78
|
+
```toml
|
|
79
|
+
[tool.werr]
|
|
80
|
+
# ...
|
|
81
|
+
task.docs = [
|
|
82
|
+
"sphinx-build -b html {project}",
|
|
83
|
+
]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Running `werr docs` will build the documentation.
|
|
87
|
+
|
|
88
|
+
## New Project
|
|
89
|
+
|
|
90
|
+
A suggested workflow for creating a new project is:
|
|
91
|
+
|
|
92
|
+
1. `uv init`
|
|
93
|
+
2. `uv add --dev black ruff ty pytest`
|
|
94
|
+
3. add tasks to `[tool.werr]`
|
|
95
|
+
4. `uvx werr` or add to `dev` group and in venv just `werr`
|
werr-0.1.0/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# werr
|
|
2
|
+
|
|
3
|
+
A simple, opinionated, python project task runner.
|
|
4
|
+
|
|
5
|
+
A task is a configured sequence of commands to run.
|
|
6
|
+
|
|
7
|
+
```toml
|
|
8
|
+
[project]
|
|
9
|
+
name = "appletree"
|
|
10
|
+
version = "0.1.0"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"pysunlight==24.1",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[tool.werr]
|
|
16
|
+
# 'check' is the default task if `werr` is run with no arguments.
|
|
17
|
+
task.check = [
|
|
18
|
+
"black --check {project}",
|
|
19
|
+
"isort --check {project}",
|
|
20
|
+
"ruff check {project}",
|
|
21
|
+
"mypy {project}",
|
|
22
|
+
"pytest",
|
|
23
|
+
]
|
|
24
|
+
task.fix = [
|
|
25
|
+
"black {project}",
|
|
26
|
+
"isort {project}",
|
|
27
|
+
"ruff fix {project}",
|
|
28
|
+
]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Running `werr` executes each `check` command in sequence, printing which failed and how.
|
|
32
|
+
The tool returns a non-zero exit code if any command fails.
|
|
33
|
+
|
|
34
|
+
Running `werr fix` executes each `fix` command in sequence.
|
|
35
|
+
|
|
36
|
+
NOTE: All commands are run using `uv` (the only dependency of this project).
|
|
37
|
+
|
|
38
|
+
## Command Variables
|
|
39
|
+
|
|
40
|
+
The following `{...}` variables are provided by `werr` to be used in task commands:
|
|
41
|
+
|
|
42
|
+
- `{project}` - the absolute path to the directory containing the `pyproject.toml` file
|
|
43
|
+
|
|
44
|
+
## Structured Output
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
werr # interactive human readable output (default)
|
|
48
|
+
werr --json # emit lines of JSON representing the result of each command
|
|
49
|
+
werr --xml # print Junit XML for CI
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Custom Tasks
|
|
53
|
+
|
|
54
|
+
Define a custom task with `task.<name> = [ ... ]`
|
|
55
|
+
|
|
56
|
+
```toml
|
|
57
|
+
[tool.werr]
|
|
58
|
+
# ...
|
|
59
|
+
task.docs = [
|
|
60
|
+
"sphinx-build -b html {project}",
|
|
61
|
+
]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Running `werr docs` will build the documentation.
|
|
65
|
+
|
|
66
|
+
## New Project
|
|
67
|
+
|
|
68
|
+
A suggested workflow for creating a new project is:
|
|
69
|
+
|
|
70
|
+
1. `uv init`
|
|
71
|
+
2. `uv add --dev black ruff ty pytest`
|
|
72
|
+
3. add tasks to `[tool.werr]`
|
|
73
|
+
4. `uvx werr` or add to `dev` group and in venv just `werr`
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "werr"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A python project task runner"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.14"
|
|
11
|
+
scripts = { "werr" = "werrlib.main:console_entry" }
|
|
12
|
+
urls = { "Homepage" = "https://github.com/jhjn/werr", "Repository" = "https://github.com/jhjn/werr" }
|
|
13
|
+
keywords = ["tasks", "task-runner", "makefile", "checks", "uv", "automation", "werr"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Programming Language :: Python",
|
|
22
|
+
"Topic :: Software Development :: Build Tools",
|
|
23
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
24
|
+
"Topic :: Software Development :: Testing"
|
|
25
|
+
]
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = [
|
|
30
|
+
"black>=25.12.0",
|
|
31
|
+
"pytest>=9.0.2",
|
|
32
|
+
"ruff>=0.14.10",
|
|
33
|
+
"ty>=0.0.9",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[tool.uv]
|
|
37
|
+
package = true
|
|
38
|
+
|
|
39
|
+
[tool.werr]
|
|
40
|
+
task.check = [
|
|
41
|
+
"black --check {project}/werrlib",
|
|
42
|
+
"ruff check {project}/werrlib",
|
|
43
|
+
"ty check {project}/werrlib",
|
|
44
|
+
]
|
|
45
|
+
task.fix = [
|
|
46
|
+
"black {project}/werrlib",
|
|
47
|
+
"ruff check --fix {project}/werrlib",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint]
|
|
51
|
+
select = ["ALL"]
|
|
52
|
+
ignore = [
|
|
53
|
+
"COM812", # Trailing comma missing
|
|
54
|
+
"D203", # one-blank-line-before-class
|
|
55
|
+
"D205", # 1 blank line required between summary line and description
|
|
56
|
+
"D213", # Multi line summary on the second line
|
|
57
|
+
"D401", # First line of docstring should be in imperative mood
|
|
58
|
+
"EM101", # Exception must not use a string literal, assign to variable first
|
|
59
|
+
"EM102", # Exception must not use an f-string literal, assign to variable first
|
|
60
|
+
"FIX002", # Allow TODO comments for future enhancements
|
|
61
|
+
"S101", # Use of `assert` detected
|
|
62
|
+
"S602", # `subprocess` call with `shell=True` identified, security issue
|
|
63
|
+
"SIM108", # Use ternary operator
|
|
64
|
+
"T201", # `print` found
|
|
65
|
+
"TD002", # Missing author in TODO; try: `# TODO(<author_name>): ...` or `# TODO @<author_name>: ...`
|
|
66
|
+
"TD003", # Missing issue link for this TODO
|
|
67
|
+
"TRY003", # Avoid specifying long messages outside the exception class
|
|
68
|
+
]
|
werr-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: werr
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A python project task runner
|
|
5
|
+
Project-URL: Homepage, https://github.com/jhjn/werr
|
|
6
|
+
Project-URL: Repository, https://github.com/jhjn/werr
|
|
7
|
+
Keywords: tasks,task-runner,makefile,checks,uv,automation,werr
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
16
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Requires-Python: >=3.14
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# werr
|
|
24
|
+
|
|
25
|
+
A simple, opinionated, python project task runner.
|
|
26
|
+
|
|
27
|
+
A task is a configured sequence of commands to run.
|
|
28
|
+
|
|
29
|
+
```toml
|
|
30
|
+
[project]
|
|
31
|
+
name = "appletree"
|
|
32
|
+
version = "0.1.0"
|
|
33
|
+
dependencies = [
|
|
34
|
+
"pysunlight==24.1",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.werr]
|
|
38
|
+
# 'check' is the default task if `werr` is run with no arguments.
|
|
39
|
+
task.check = [
|
|
40
|
+
"black --check {project}",
|
|
41
|
+
"isort --check {project}",
|
|
42
|
+
"ruff check {project}",
|
|
43
|
+
"mypy {project}",
|
|
44
|
+
"pytest",
|
|
45
|
+
]
|
|
46
|
+
task.fix = [
|
|
47
|
+
"black {project}",
|
|
48
|
+
"isort {project}",
|
|
49
|
+
"ruff fix {project}",
|
|
50
|
+
]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Running `werr` executes each `check` command in sequence, printing which failed and how.
|
|
54
|
+
The tool returns a non-zero exit code if any command fails.
|
|
55
|
+
|
|
56
|
+
Running `werr fix` executes each `fix` command in sequence.
|
|
57
|
+
|
|
58
|
+
NOTE: All commands are run using `uv` (the only dependency of this project).
|
|
59
|
+
|
|
60
|
+
## Command Variables
|
|
61
|
+
|
|
62
|
+
The following `{...}` variables are provided by `werr` to be used in task commands:
|
|
63
|
+
|
|
64
|
+
- `{project}` - the absolute path to the directory containing the `pyproject.toml` file
|
|
65
|
+
|
|
66
|
+
## Structured Output
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
werr # interactive human readable output (default)
|
|
70
|
+
werr --json # emit lines of JSON representing the result of each command
|
|
71
|
+
werr --xml # print Junit XML for CI
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Custom Tasks
|
|
75
|
+
|
|
76
|
+
Define a custom task with `task.<name> = [ ... ]`
|
|
77
|
+
|
|
78
|
+
```toml
|
|
79
|
+
[tool.werr]
|
|
80
|
+
# ...
|
|
81
|
+
task.docs = [
|
|
82
|
+
"sphinx-build -b html {project}",
|
|
83
|
+
]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Running `werr docs` will build the documentation.
|
|
87
|
+
|
|
88
|
+
## New Project
|
|
89
|
+
|
|
90
|
+
A suggested workflow for creating a new project is:
|
|
91
|
+
|
|
92
|
+
1. `uv init`
|
|
93
|
+
2. `uv add --dev black ruff ty pytest`
|
|
94
|
+
3. add tasks to `[tool.werr]`
|
|
95
|
+
4. `uvx werr` or add to `dev` group and in venv just `werr`
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
werr.egg-info/.gitignore
|
|
5
|
+
werr.egg-info/PKG-INFO
|
|
6
|
+
werr.egg-info/SOURCES.txt
|
|
7
|
+
werr.egg-info/dependency_links.txt
|
|
8
|
+
werr.egg-info/entry_points.txt
|
|
9
|
+
werr.egg-info/top_level.txt
|
|
10
|
+
werrlib/__init__.py
|
|
11
|
+
werrlib/cli.py
|
|
12
|
+
werrlib/cmd.py
|
|
13
|
+
werrlib/config.py
|
|
14
|
+
werrlib/main.py
|
|
15
|
+
werrlib/report.py
|
|
16
|
+
werrlib/task.py
|
|
17
|
+
werrlib/xml.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
werrlib
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""werrlib - the python project task runner library."""
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The command line interface for the werr tool."""
|
|
2
|
+
|
|
3
|
+
import _colorize # ty: ignore[unresolved-import]
|
|
4
|
+
import argparse
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import report, task
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger("cli")
|
|
12
|
+
|
|
13
|
+
_colorize.set_theme(
|
|
14
|
+
_colorize.Theme(
|
|
15
|
+
argparse=_colorize.Argparse(
|
|
16
|
+
usage=_colorize.ANSIColors.BOLD_GREEN,
|
|
17
|
+
prog=_colorize.ANSIColors.BOLD_CYAN,
|
|
18
|
+
heading=_colorize.ANSIColors.BOLD_GREEN,
|
|
19
|
+
summary_long_option=_colorize.ANSIColors.CYAN,
|
|
20
|
+
summary_short_option=_colorize.ANSIColors.CYAN,
|
|
21
|
+
summary_label=_colorize.ANSIColors.CYAN,
|
|
22
|
+
summary_action=_colorize.ANSIColors.CYAN,
|
|
23
|
+
long_option=_colorize.ANSIColors.BOLD_CYAN,
|
|
24
|
+
short_option=_colorize.ANSIColors.BOLD_CYAN,
|
|
25
|
+
label=_colorize.ANSIColors.CYAN,
|
|
26
|
+
action=_colorize.ANSIColors.BOLD_CYAN,
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_parser() -> argparse.ArgumentParser:
|
|
33
|
+
"""Create a parser for the saturn CLI."""
|
|
34
|
+
parser = argparse.ArgumentParser(
|
|
35
|
+
prog="werr",
|
|
36
|
+
description="A simple python project task runner",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument("--verbose", action="store_true", help="Enable verbose logging")
|
|
39
|
+
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"-p",
|
|
42
|
+
"--project",
|
|
43
|
+
type=Path,
|
|
44
|
+
default=Path.cwd(),
|
|
45
|
+
help="Python project directory (defaults to cwd)",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"task",
|
|
50
|
+
nargs="?",
|
|
51
|
+
default=task.DEFAULT,
|
|
52
|
+
help=f"Task to run (defined in pyproject.toml, defaults to '{task.DEFAULT}')",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Output format selection - all options write to 'reporter' dest
|
|
56
|
+
# CLI is the default via set_defaults
|
|
57
|
+
output_fmt = parser.add_mutually_exclusive_group()
|
|
58
|
+
output_fmt.add_argument(
|
|
59
|
+
"--cli",
|
|
60
|
+
action="store_const",
|
|
61
|
+
const=report.CliReporter,
|
|
62
|
+
dest="reporter",
|
|
63
|
+
help="Print results to the console (default)",
|
|
64
|
+
)
|
|
65
|
+
output_fmt.add_argument(
|
|
66
|
+
"--xml",
|
|
67
|
+
action="store_const",
|
|
68
|
+
const=report.XmlReporter,
|
|
69
|
+
dest="reporter",
|
|
70
|
+
help="Print results as Junit XML",
|
|
71
|
+
)
|
|
72
|
+
output_fmt.add_argument(
|
|
73
|
+
"--json",
|
|
74
|
+
action="store_const",
|
|
75
|
+
const=report.JsonReporter,
|
|
76
|
+
dest="reporter",
|
|
77
|
+
help="Print results as lines of JSON",
|
|
78
|
+
)
|
|
79
|
+
parser.set_defaults(reporter=report.CliReporter)
|
|
80
|
+
|
|
81
|
+
return parser
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run(argv: list[str]) -> None:
|
|
85
|
+
"""Main entrypoint of the werr tool."""
|
|
86
|
+
args = _get_parser().parse_args(argv)
|
|
87
|
+
|
|
88
|
+
logging.basicConfig(
|
|
89
|
+
format="[%(levelname)s] %(message)s",
|
|
90
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
91
|
+
)
|
|
92
|
+
log.debug("Called with arguments: %s", argv)
|
|
93
|
+
|
|
94
|
+
success = task.run(args.project, args.task, args.reporter)
|
|
95
|
+
if not success:
|
|
96
|
+
sys.exit(1)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Wrappers of `subprocess` for custom werr functionality."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger("cmd")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class Result:
|
|
20
|
+
"""Information about a *completed* process."""
|
|
21
|
+
|
|
22
|
+
task: Command
|
|
23
|
+
returncode: int
|
|
24
|
+
duration: float
|
|
25
|
+
output: str
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def success(self) -> bool:
|
|
29
|
+
"""Return True if the process was successful."""
|
|
30
|
+
return self.returncode == 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class Command:
|
|
35
|
+
"""A command to be run as part of a task."""
|
|
36
|
+
|
|
37
|
+
command: str
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def name(self) -> str:
|
|
41
|
+
"""The name of the task."""
|
|
42
|
+
return self.command.split(" ")[0]
|
|
43
|
+
|
|
44
|
+
def resolved_command(self, projectdir: Path) -> str:
|
|
45
|
+
"""Return the command with the {...} variables substituted."""
|
|
46
|
+
return self.command.replace("{project}", str(projectdir.resolve()))
|
|
47
|
+
|
|
48
|
+
def run(self, projectdir: Path) -> Result:
|
|
49
|
+
"""Run the task using `uv` in isolated mode."""
|
|
50
|
+
command = f"uv run --project '{projectdir}' {self.resolved_command(projectdir)}"
|
|
51
|
+
log.debug("Running command: %s", command)
|
|
52
|
+
start = time.monotonic()
|
|
53
|
+
process = subprocess.run(
|
|
54
|
+
command,
|
|
55
|
+
shell=True,
|
|
56
|
+
check=False, # the returncode is checked manually
|
|
57
|
+
text=True,
|
|
58
|
+
stderr=subprocess.STDOUT,
|
|
59
|
+
stdout=subprocess.PIPE,
|
|
60
|
+
# env is a copy but without the `VIRTUAL_ENV` variable.
|
|
61
|
+
env=os.environ.copy() | {"VIRTUAL_ENV": ""},
|
|
62
|
+
)
|
|
63
|
+
duration = time.monotonic() - start
|
|
64
|
+
return Result(self, process.returncode, duration, process.stdout)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Loading of python project config for checking."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import tomllib as tomli
|
|
11
|
+
except ImportError:
|
|
12
|
+
import tomli # type: ignore[import]
|
|
13
|
+
|
|
14
|
+
from . import cmd
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger("config")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_project(pyproject: Path, task: str) -> tuple[str, list[cmd.Command]]:
|
|
20
|
+
"""Load the commands from the pyproject.toml file."""
|
|
21
|
+
with pyproject.open("rb") as f:
|
|
22
|
+
config = tomli.load(f)
|
|
23
|
+
|
|
24
|
+
# validation of [tool.werr] section
|
|
25
|
+
if "tool" not in config or "werr" not in config["tool"]:
|
|
26
|
+
raise ValueError("pyproject.toml does not contain a [tool.werr] section")
|
|
27
|
+
if (
|
|
28
|
+
"task" not in config["tool"]["werr"]
|
|
29
|
+
or task not in config["tool"]["werr"]["task"]
|
|
30
|
+
):
|
|
31
|
+
raise ValueError(f"[tool.werr] does not contain a `task.{task}` list")
|
|
32
|
+
|
|
33
|
+
return config["project"]["name"], [
|
|
34
|
+
cmd.Command(task) for task in config["tool"]["werr"]["task"][task]
|
|
35
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Entrypoint for the werr CLI tool."""
|
|
2
|
+
|
|
3
|
+
__all__ = ("console_entry",)
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import traceback
|
|
9
|
+
|
|
10
|
+
from . import cli
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger("saturn")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def console_entry() -> None:
|
|
16
|
+
"""Entrypoint for CLI usage."""
|
|
17
|
+
try:
|
|
18
|
+
cli.run(sys.argv[1:])
|
|
19
|
+
# TODO: Better process failure error reporting
|
|
20
|
+
except subprocess.CalledProcessError as e:
|
|
21
|
+
log.debug(traceback.format_exc())
|
|
22
|
+
log.error(str(e.stdout).strip()) # noqa: TRY400
|
|
23
|
+
log.error(str(e.stderr).strip()) # noqa: TRY400
|
|
24
|
+
sys.exit(e.returncode)
|
|
25
|
+
except Exception as e: # noqa: BLE001
|
|
26
|
+
log.debug(traceback.format_exc())
|
|
27
|
+
log.error(e) # noqa: TRY400
|
|
28
|
+
sys.exit(1)
|
|
29
|
+
except KeyboardInterrupt:
|
|
30
|
+
log.error("Interrupted by user.") # noqa: TRY400
|
|
31
|
+
sys.exit(130)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
console_entry()
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Manage the recording and reporting of tasks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import textwrap
|
|
9
|
+
from _colorize import ANSIColors as C # ty: ignore[unresolved-import]
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
|
|
12
|
+
from . import cmd, xml
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger("report")
|
|
15
|
+
ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_SUITENAME = "werr"
|
|
19
|
+
_TOTAL_HEAD_LEN = 25
|
|
20
|
+
_HEAD_PFX = " "
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Reporter(ABC):
|
|
24
|
+
"""A reporter for reporting the results of a task."""
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def emit_info(msg: str) -> None:
|
|
29
|
+
"""Print a message for an interactive reader."""
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def emit_start(cmd: cmd.Command) -> None:
|
|
34
|
+
"""What is printed before a command begins."""
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def emit_end(result: cmd.Result) -> None:
|
|
39
|
+
"""What is printed after a command completes."""
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def emit_summary(results: list[cmd.Result]) -> None:
|
|
44
|
+
"""What is printed after the task has completed."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CliReporter(Reporter):
|
|
48
|
+
"""A reporter for reporting the results of a task to the console."""
|
|
49
|
+
|
|
50
|
+
task_index = 0
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def emit_info(msg: str) -> None:
|
|
54
|
+
"""Print to console."""
|
|
55
|
+
print(msg)
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def emit_start(cmd: cmd.Command) -> None:
|
|
59
|
+
"""Emit the start of a command."""
|
|
60
|
+
prefix = f"[{CliReporter.task_index+1}]"
|
|
61
|
+
print(f"{prefix:<5} {cmd.name:<20} ", end="", flush=True)
|
|
62
|
+
CliReporter.task_index += 1
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def emit_end(result: cmd.Result) -> None:
|
|
66
|
+
"""Emit the end of a command."""
|
|
67
|
+
if result.success:
|
|
68
|
+
status = f"{C.GREEN}PASSED{C.RESET}"
|
|
69
|
+
else:
|
|
70
|
+
status = f"{C.RED}FAILED{C.RESET}"
|
|
71
|
+
|
|
72
|
+
print(f"({result.duration:>2.2f} secs) {status:>18}", flush=True)
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def emit_summary(results: list[cmd.Result]) -> None:
|
|
76
|
+
"""Print the summary line explaining what the net result was."""
|
|
77
|
+
successes = [result for result in results if result.success]
|
|
78
|
+
failures = [result for result in results if not result.success]
|
|
79
|
+
duration = sum(result.duration for result in results)
|
|
80
|
+
|
|
81
|
+
msg = (
|
|
82
|
+
f"Ran {len(results)} check{_plural(len(results))} in "
|
|
83
|
+
f"{duration:>2.2f} secs, "
|
|
84
|
+
f"{len(successes)} Passed, {len(failures)} Failed"
|
|
85
|
+
)
|
|
86
|
+
print(f"{C.RED if failures else C.GREEN}{msg}{C.RESET}")
|
|
87
|
+
|
|
88
|
+
if failures:
|
|
89
|
+
print("\nFailures:\n---------")
|
|
90
|
+
for result in failures:
|
|
91
|
+
CliReporter.emit_start(result.task)
|
|
92
|
+
print()
|
|
93
|
+
print(textwrap.indent(result.output, _HEAD_PFX))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class JsonReporter(Reporter):
|
|
97
|
+
"""A reporter for reporting the results of a task in lines of JSON."""
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def emit_info(msg: str) -> None:
|
|
101
|
+
"""Print nothing."""
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def emit_start(cmd: cmd.Command) -> None:
|
|
105
|
+
"""Print nothing."""
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def emit_end(result: cmd.Result) -> None:
|
|
109
|
+
"""Emit the end of a command."""
|
|
110
|
+
print(
|
|
111
|
+
json.dumps(
|
|
112
|
+
{
|
|
113
|
+
"task": result.task.name,
|
|
114
|
+
"command": result.task.command,
|
|
115
|
+
"duration": result.duration,
|
|
116
|
+
"output": ansi_escape.sub("", result.output),
|
|
117
|
+
"success": result.success,
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def emit_summary(results: list[cmd.Result]) -> None:
|
|
124
|
+
"""Print nothing."""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class XmlReporter(Reporter):
|
|
128
|
+
"""A reporter for reporting the results of a task as Junit XML."""
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def emit_info(msg: str) -> None:
|
|
132
|
+
"""Print nothing."""
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def emit_start(cmd: cmd.Command) -> None:
|
|
136
|
+
"""Print nothing."""
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def emit_end(result: cmd.Result) -> None:
|
|
140
|
+
"""Print nothing."""
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def emit_summary(results: list[cmd.Result]) -> None:
|
|
144
|
+
"""Print Junit XML summary."""
|
|
145
|
+
print(_create_xml(results))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _plural(size: int) -> str:
|
|
149
|
+
"""Return 's' if the size is not a single element."""
|
|
150
|
+
if size == 1:
|
|
151
|
+
return ""
|
|
152
|
+
return "s"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _create_xml(results: list[cmd.Result]) -> str:
|
|
156
|
+
"""Create a string representing the results as Junit XML."""
|
|
157
|
+
failures = [result for result in results if not result.success]
|
|
158
|
+
duration = sum(result.duration for result in results)
|
|
159
|
+
|
|
160
|
+
root = xml.Node(
|
|
161
|
+
"testsuites",
|
|
162
|
+
tests=len(results),
|
|
163
|
+
failures=len(failures),
|
|
164
|
+
errors=0,
|
|
165
|
+
skipped=0,
|
|
166
|
+
time=duration,
|
|
167
|
+
)
|
|
168
|
+
sa = xml.Node(
|
|
169
|
+
"testsuite",
|
|
170
|
+
name=_SUITENAME,
|
|
171
|
+
time=duration,
|
|
172
|
+
tests=len(results),
|
|
173
|
+
failures=len(failures),
|
|
174
|
+
errors=0,
|
|
175
|
+
skipped=0,
|
|
176
|
+
)
|
|
177
|
+
root.add_child(sa)
|
|
178
|
+
|
|
179
|
+
for result in results:
|
|
180
|
+
sa.add_child(_result_xml(result))
|
|
181
|
+
|
|
182
|
+
return root.to_document()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _result_xml(result: cmd.Result) -> xml.Node:
|
|
186
|
+
"""Create a single Junit XML testcase."""
|
|
187
|
+
node = xml.Node(
|
|
188
|
+
"testcase",
|
|
189
|
+
name=result.task.name,
|
|
190
|
+
time=result.duration,
|
|
191
|
+
classname=_SUITENAME,
|
|
192
|
+
)
|
|
193
|
+
if not result.success:
|
|
194
|
+
node.add_child(xml.Node("failure", ansi_escape.sub("", result.output)))
|
|
195
|
+
return node
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Orchestration of task execution."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import config, report
|
|
9
|
+
|
|
10
|
+
DEFAULT = "check"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(
|
|
14
|
+
projectdir: Path,
|
|
15
|
+
task: str = DEFAULT,
|
|
16
|
+
reporter: type[report.Reporter] = report.CliReporter,
|
|
17
|
+
) -> bool:
|
|
18
|
+
"""Run the specified task and return True if all are successful.
|
|
19
|
+
|
|
20
|
+
Emit results as we go.
|
|
21
|
+
"""
|
|
22
|
+
name, cmds = config.load_project(projectdir / "pyproject.toml", task)
|
|
23
|
+
reporter.emit_info(f"Project: {name} ({task})")
|
|
24
|
+
|
|
25
|
+
results = []
|
|
26
|
+
for cmd in cmds:
|
|
27
|
+
reporter.emit_start(cmd)
|
|
28
|
+
result = cmd.run(projectdir)
|
|
29
|
+
results.append(result)
|
|
30
|
+
reporter.emit_end(result)
|
|
31
|
+
|
|
32
|
+
reporter.emit_summary(results)
|
|
33
|
+
|
|
34
|
+
return all(result.success for result in results)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Abstractions for the creation and stringification of XML objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import textwrap
|
|
6
|
+
|
|
7
|
+
_Val = str | int | float
|
|
8
|
+
|
|
9
|
+
_HEADER = "<?xml version='1.0' encoding='utf-8'?>"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Node:
|
|
13
|
+
"""An XML element."""
|
|
14
|
+
|
|
15
|
+
name: str
|
|
16
|
+
attributes: dict[str, _Val]
|
|
17
|
+
|
|
18
|
+
# The content of an element is usually either text or more elements.
|
|
19
|
+
text: str
|
|
20
|
+
children: list[Node]
|
|
21
|
+
|
|
22
|
+
def __init__(self, _name: str, _text: str = "", **attributes: _Val) -> None:
|
|
23
|
+
"""Initialise an element by name, optional text content and tag attributes
|
|
24
|
+
set via kwargs.
|
|
25
|
+
"""
|
|
26
|
+
self.name = _name
|
|
27
|
+
self.attributes = attributes
|
|
28
|
+
|
|
29
|
+
self.text = _text
|
|
30
|
+
self.children = []
|
|
31
|
+
|
|
32
|
+
def add_child(self, child: Node) -> None:
|
|
33
|
+
"""Add a child node to the current node."""
|
|
34
|
+
self.children.append(child)
|
|
35
|
+
|
|
36
|
+
def __str__(self) -> str:
|
|
37
|
+
"""String XML representation of the node."""
|
|
38
|
+
attrs = " ".join((f'{name}="{val}"' for name, val in self.attributes.items()))
|
|
39
|
+
tags = f"<{self.name} {attrs}"
|
|
40
|
+
if self.text or self.children:
|
|
41
|
+
tags += ">\n" + _str_internal(self.text, self.children) + f"</{self.name}>"
|
|
42
|
+
else:
|
|
43
|
+
tags += "/>"
|
|
44
|
+
|
|
45
|
+
return tags
|
|
46
|
+
|
|
47
|
+
def to_document(self) -> str:
|
|
48
|
+
"""Create an XML document by prefixing the stringified root element with
|
|
49
|
+
a header.
|
|
50
|
+
"""
|
|
51
|
+
return f"{_HEADER}\n{self}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _str_internal(text: str, children: list[Node]) -> str:
|
|
55
|
+
"""Create a string representation of the inside of a node."""
|
|
56
|
+
if children:
|
|
57
|
+
text += "\n".join(str(child) for child in children) + "\n"
|
|
58
|
+
|
|
59
|
+
return textwrap.indent(text, " ")
|