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 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ werr = werrlib.main:console_entry
@@ -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, " ")