mcward-cli 1.1.0__tar.gz → 1.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mcward_cli-1.1.0 → mcward_cli-1.2.0}/.gitignore +2 -0
- {mcward_cli-1.1.0 → mcward_cli-1.2.0}/PKG-INFO +3 -3
- {mcward_cli-1.1.0 → mcward_cli-1.2.0}/pyproject.toml +1 -1
- {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/__init__.py +2 -10
- {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/commands/daemon.py +7 -3
- {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/commands/install.py +1 -1
- {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/commands/list.py +1 -3
- {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/commands/test.py +37 -3
- {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/datapacks.py +4 -5
- {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/environments.py +30 -41
- mcward_cli-1.2.0/src/mcward/cli/reporters/__init__.py +5 -0
- mcward_cli-1.2.0/src/mcward/cli/reporters/coverage.py +159 -0
- {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/reporters/github.py +4 -2
- {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/reporters/live.py +90 -21
- mcward_cli-1.2.0/src/mcward/cli/reports/__init__.py +87 -0
- mcward_cli-1.2.0/src/mcward/cli/reports/html.py +491 -0
- mcward_cli-1.2.0/src/mcward/cli/reports/junit.py +50 -0
- mcward_cli-1.2.0/src/mcward/cli/reports/lcov.py +56 -0
- mcward_cli-1.2.0/src/mcward/cli/ui.py +70 -0
- mcward_cli-1.2.0/tests/test_coverage_report.py +187 -0
- {mcward_cli-1.1.0 → mcward_cli-1.2.0}/tests/test_discovery.py +13 -0
- {mcward_cli-1.1.0 → mcward_cli-1.2.0}/tests/test_live.py +56 -6
- mcward_cli-1.2.0/tests/test_reports.py +284 -0
- mcward_cli-1.1.0/src/mcward/cli/reporters/__init__.py +0 -9
- mcward_cli-1.1.0/src/mcward/cli/ui.py +0 -59
- {mcward_cli-1.1.0 → mcward_cli-1.2.0}/README.md +0 -0
- {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/commands/__init__.py +0 -0
- {mcward_cli-1.1.0 → mcward_cli-1.2.0}/tests/test_cli.py +0 -0
- {mcward_cli-1.1.0 → mcward_cli-1.2.0}/tests/test_environments.py +0 -0
- {mcward_cli-1.1.0 → mcward_cli-1.2.0}/tests/test_github.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: mcward-cli
|
|
3
|
-
Version: 1.
|
|
4
|
-
Summary: Command-line interface for Ward
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Command-line interface for Ward, a testing framework for Minecraft datapacks using mcfunction.
|
|
5
5
|
Author-email: Aksiome <54895777+aksiome@users.noreply.github.com>
|
|
6
6
|
License: MPL-2.0
|
|
7
7
|
Requires-Python: >=3.14
|
|
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mcward-cli"
|
|
7
7
|
dynamic = ["version"]
|
|
8
|
-
description = "Command-line interface for Ward
|
|
8
|
+
description = "Command-line interface for Ward, a testing framework for Minecraft datapacks using mcfunction."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.14"
|
|
11
11
|
license = { text = "MPL-2.0" }
|
|
@@ -15,18 +15,12 @@ class DefaultCommandGroup(click.RichGroup):
|
|
|
15
15
|
"""Click group that routes to the test command by default."""
|
|
16
16
|
|
|
17
17
|
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
|
18
|
-
"""Parse args, routing to the test command if no subcommand is specified."""
|
|
19
18
|
if not args or self._is_test_invocation(args[0]):
|
|
20
19
|
return super().parse_args(ctx, ["test"] + args)
|
|
21
20
|
return super().parse_args(ctx, args)
|
|
22
21
|
|
|
23
22
|
def _is_test_invocation(self, arg: str) -> bool:
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
Only selectors (namespaced ids, wildcards) and options default to test;
|
|
27
|
-
a bare word that matches no command is left to click so typos produce
|
|
28
|
-
"No such command" instead of silently running zero tests.
|
|
29
|
-
"""
|
|
23
|
+
"""Whether the first argument is meant for test rather than a subcommand."""
|
|
30
24
|
if arg in self.commands or arg in ("-h", "--help"):
|
|
31
25
|
return False
|
|
32
26
|
return arg.startswith("-") or ":" in arg or "*" in arg
|
|
@@ -52,10 +46,8 @@ cli.add_command(test)
|
|
|
52
46
|
|
|
53
47
|
|
|
54
48
|
def main() -> None:
|
|
55
|
-
"""Entry point for the CLI."""
|
|
56
49
|
try:
|
|
57
|
-
# Click's Windows
|
|
58
|
-
# discover_datapacks gets them; patterns are resolved by ward itself
|
|
50
|
+
# discover_datapacks expands -p globs itself; Click's Windows expansion would eat them
|
|
59
51
|
cli(windows_expand_args=False)
|
|
60
52
|
except KeyboardInterrupt:
|
|
61
53
|
console.print("\n[yellow]Interrupted[/yellow]")
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""The start, stop and status commands."""
|
|
2
2
|
|
|
3
3
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
4
4
|
from typing import cast
|
|
@@ -51,7 +51,7 @@ def stop(version: str | None, all: bool = False) -> None:
|
|
|
51
51
|
print_warning(f"Version {env.version.name} is not running")
|
|
52
52
|
return
|
|
53
53
|
|
|
54
|
-
envs =
|
|
54
|
+
envs = _running_environments()
|
|
55
55
|
if not envs:
|
|
56
56
|
print_note("No versions running")
|
|
57
57
|
return
|
|
@@ -78,7 +78,7 @@ def stop(version: str | None, all: bool = False) -> None:
|
|
|
78
78
|
@click.command()
|
|
79
79
|
def status() -> None:
|
|
80
80
|
"""Show status of running daemons."""
|
|
81
|
-
envs =
|
|
81
|
+
envs = _running_environments()
|
|
82
82
|
if not envs:
|
|
83
83
|
print_note("No versions running")
|
|
84
84
|
return
|
|
@@ -98,3 +98,7 @@ def status() -> None:
|
|
|
98
98
|
table.add_row(env.version.name, str(env.process.pid), str(env.process.port), state)
|
|
99
99
|
|
|
100
100
|
console.print("", table)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _running_environments() -> list[RunningEnvironment]:
|
|
104
|
+
return [cast(RunningEnvironment, manager.get(v.name)) for v in manager.list_running()]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""The list command."""
|
|
2
2
|
|
|
3
3
|
import rich_click as click
|
|
4
4
|
|
|
@@ -21,7 +21,6 @@ def list_versions(ctx: click.Context, remote: bool) -> None:
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def _print_installed(installed: list[Version], command_path: str) -> None:
|
|
24
|
-
"""Print the locally installed versions."""
|
|
25
24
|
console.print("\n[bold]Installed Versions[/]:")
|
|
26
25
|
if not installed:
|
|
27
26
|
console.print(" [dim]No versions installed[/dim]")
|
|
@@ -71,6 +70,5 @@ def _print_version(
|
|
|
71
70
|
color: str = "green",
|
|
72
71
|
label: str = "",
|
|
73
72
|
) -> None:
|
|
74
|
-
"""Print a version entry to the console."""
|
|
75
73
|
label = f" [dim]({label})[/]" if label else ""
|
|
76
74
|
console.print(f" [bold {color}]{marker}[/] {version.name}{label}", highlight=False)
|
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""The test command."""
|
|
2
2
|
|
|
3
3
|
import sys
|
|
4
|
+
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
import rich_click as click
|
|
6
7
|
|
|
7
|
-
from mcward import WardError
|
|
8
|
+
from mcward import CoverageIgnores, WardError
|
|
8
9
|
|
|
9
10
|
from ..datapacks import DEFAULT_PATTERNS, discover_datapacks
|
|
10
11
|
from ..environments import manager, select_compatible, start_environments
|
|
11
12
|
from ..reporters import github, live
|
|
13
|
+
from ..reports import parse_coverage_report, report_session
|
|
12
14
|
from ..ui import console
|
|
13
15
|
|
|
14
16
|
|
|
@@ -33,11 +35,39 @@ from ..ui import console
|
|
|
33
35
|
default="live",
|
|
34
36
|
help="Result output: interactive live display, or GitHub Actions annotations",
|
|
35
37
|
)
|
|
38
|
+
@click.option(
|
|
39
|
+
"--coverage",
|
|
40
|
+
is_flag=True,
|
|
41
|
+
help="Record which function commands run and report line coverage",
|
|
42
|
+
)
|
|
43
|
+
@click.option(
|
|
44
|
+
"--coverage-report",
|
|
45
|
+
"coverage_reports",
|
|
46
|
+
multiple=True,
|
|
47
|
+
metavar="FORMAT[:PATH]",
|
|
48
|
+
help="Write coverage as 'lcov' or 'html', with an optional path (repeatable); "
|
|
49
|
+
"implies --coverage",
|
|
50
|
+
)
|
|
51
|
+
@click.option(
|
|
52
|
+
"--junit-xml",
|
|
53
|
+
type=click.Path(dir_okay=False, writable=True, path_type=Path),
|
|
54
|
+
default=None,
|
|
55
|
+
help="Write test results as JUnit XML",
|
|
56
|
+
)
|
|
57
|
+
@click.option(
|
|
58
|
+
"--verbose",
|
|
59
|
+
is_flag=True,
|
|
60
|
+
help="List every test and coverage row instead of collapsing large runs",
|
|
61
|
+
)
|
|
36
62
|
@click.argument("selector", default="*:*")
|
|
37
63
|
def test(
|
|
38
64
|
versions: tuple[str, ...],
|
|
39
65
|
packs: tuple[str, ...],
|
|
40
66
|
reporter: str,
|
|
67
|
+
coverage: bool,
|
|
68
|
+
coverage_reports: tuple[str, ...],
|
|
69
|
+
junit_xml: Path | None,
|
|
70
|
+
verbose: bool,
|
|
41
71
|
selector: str,
|
|
42
72
|
) -> None:
|
|
43
73
|
"""Run datapack tests."""
|
|
@@ -56,11 +86,15 @@ def test(
|
|
|
56
86
|
selected = versions or select_compatible(strictest.min_format, loosest.max_format)
|
|
57
87
|
paths = [datapack.path for datapack in datapacks]
|
|
58
88
|
|
|
89
|
+
specs = [parse_coverage_report(value) for value in coverage_reports]
|
|
90
|
+
enabled = coverage or bool(specs)
|
|
59
91
|
run = github.run if reporter == "github" else live.run
|
|
60
92
|
try:
|
|
93
|
+
ignores = CoverageIgnores.load()
|
|
61
94
|
envs = start_environments([manager.get(v) for v in selected])
|
|
62
95
|
console.print()
|
|
63
|
-
session = run(paths, envs, selector)
|
|
96
|
+
session = run(paths, envs, selector, coverage=enabled, verbose=verbose)
|
|
97
|
+
report_session(session, paths, specs, junit_xml, verbose, selector, ignores)
|
|
64
98
|
if session.failed:
|
|
65
99
|
sys.exit(1)
|
|
66
100
|
except WardError as e:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Datapack discovery, pack.mcmeta parsing and resource-to-file resolution."""
|
|
2
2
|
|
|
3
|
+
import glob
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
5
6
|
import zipfile
|
|
@@ -19,23 +20,21 @@ MCFUNCTION_FOLDERS = ("test", "function")
|
|
|
19
20
|
|
|
20
21
|
@dataclass(frozen=True)
|
|
21
22
|
class DataPack:
|
|
22
|
-
"""Datapack with its pack format range."""
|
|
23
|
-
|
|
24
23
|
path: Path
|
|
25
24
|
min_format: int
|
|
26
25
|
max_format: int
|
|
27
26
|
|
|
28
27
|
|
|
29
28
|
def discover_datapacks(patterns: Sequence[str]) -> list[DataPack]:
|
|
30
|
-
"""
|
|
31
|
-
cwd = Path.cwd()
|
|
29
|
+
"""Find the datapacks (directories or zips) matching the glob patterns."""
|
|
32
30
|
paths = set()
|
|
33
31
|
|
|
34
32
|
for pattern in patterns:
|
|
35
33
|
if (p := Path(pattern)).exists():
|
|
36
34
|
paths.add(p.resolve())
|
|
37
35
|
else:
|
|
38
|
-
|
|
36
|
+
matches = glob.glob(pattern, recursive=True, include_hidden=True)
|
|
37
|
+
paths.update(Path(m).resolve() for m in matches)
|
|
39
38
|
|
|
40
39
|
return [parse_datapack(p) for p in paths if _is_datapack(p)]
|
|
41
40
|
|
|
@@ -5,7 +5,6 @@ from concurrent.futures import ThreadPoolExecutor
|
|
|
5
5
|
from itertools import groupby
|
|
6
6
|
|
|
7
7
|
import rich_click as click
|
|
8
|
-
from questionary.prompts.common import Choice
|
|
9
8
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
10
9
|
|
|
11
10
|
from mcward import (
|
|
@@ -18,7 +17,7 @@ from mcward import (
|
|
|
18
17
|
VersionNotFoundError,
|
|
19
18
|
)
|
|
20
19
|
|
|
21
|
-
from .ui import console, print_note, print_success, select
|
|
20
|
+
from .ui import Option, console, print_note, print_success, select
|
|
22
21
|
|
|
23
22
|
manager = EnvironmentManager()
|
|
24
23
|
|
|
@@ -27,66 +26,49 @@ def curate_versions(versions: Iterable[Version]) -> list[Version]:
|
|
|
27
26
|
"""Keep all releases but only the newest snapshot of each (year, major) line."""
|
|
28
27
|
seen = set()
|
|
29
28
|
curated = []
|
|
30
|
-
for
|
|
31
|
-
|
|
32
|
-
if
|
|
29
|
+
for version in versions:
|
|
30
|
+
line = (version.year, version.major)
|
|
31
|
+
if version.is_snapshot and line in seen:
|
|
33
32
|
continue
|
|
34
|
-
curated.append(
|
|
35
|
-
seen.add(
|
|
33
|
+
curated.append(version)
|
|
34
|
+
seen.add(line)
|
|
36
35
|
return curated
|
|
37
36
|
|
|
38
37
|
|
|
39
|
-
def get_environment(version:
|
|
40
|
-
"""Get the environment for the given version."""
|
|
41
|
-
return get_environments([version])[0]
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def get_environments(versions: Sequence[Version | str]) -> list[Environment]:
|
|
45
|
-
"""Get environments for the given versions."""
|
|
38
|
+
def get_environment(version: str) -> Environment:
|
|
46
39
|
try:
|
|
47
|
-
return
|
|
40
|
+
return manager.get(version)
|
|
48
41
|
except VersionNotFoundError as e:
|
|
49
42
|
raise click.ClickException(str(e)) from e
|
|
50
43
|
|
|
51
44
|
|
|
52
45
|
def select_installed(message: str) -> str:
|
|
53
|
-
"""
|
|
54
|
-
|
|
55
|
-
if not versions:
|
|
56
|
-
raise click.ClickException("No versions installed")
|
|
57
|
-
return versions[0].name if len(versions) == 1 else select(message, [v.name for v in versions])
|
|
46
|
+
"""Prompt for an installed version, skipping the prompt when only one fits."""
|
|
47
|
+
return _select_version(manager.list_installed(), message, "No versions installed")
|
|
58
48
|
|
|
59
49
|
|
|
60
50
|
def select_running(message: str) -> str:
|
|
61
|
-
"""
|
|
62
|
-
|
|
63
|
-
if not versions:
|
|
64
|
-
raise click.ClickException("No versions running")
|
|
65
|
-
return versions[0].name if len(versions) == 1 else select(message, [v.name for v in versions])
|
|
51
|
+
"""Prompt for a running version, skipping the prompt when only one fits."""
|
|
52
|
+
return _select_version(manager.list_running(), message, "No versions running")
|
|
66
53
|
|
|
67
54
|
|
|
68
55
|
def select_available(message: str) -> str:
|
|
69
|
-
"""
|
|
56
|
+
"""Prompt for a version to install, flagging the latest and the snapshot."""
|
|
70
57
|
curated = curate_versions(manager.list_available())
|
|
71
58
|
if not curated:
|
|
72
59
|
raise click.ClickException("No versions available")
|
|
73
60
|
|
|
74
|
-
choices = []
|
|
75
61
|
snapshot = next((v for v in curated if v.is_snapshot), None)
|
|
76
62
|
latest = next((v for v in curated if not v.is_snapshot), None)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
title.append(("dim", " (snapshot)"))
|
|
83
|
-
choices.append(Choice(title))
|
|
84
|
-
|
|
85
|
-
return select(message, choices)
|
|
63
|
+
options = []
|
|
64
|
+
for version in curated:
|
|
65
|
+
hint = "(latest)" if version == latest else "(snapshot)" if version == snapshot else ""
|
|
66
|
+
options.append(Option(version.name, hint))
|
|
67
|
+
return select(message, options)
|
|
86
68
|
|
|
87
69
|
|
|
88
70
|
def select_compatible(min_format: int, max_format: int) -> list[str]:
|
|
89
|
-
"""
|
|
71
|
+
"""The versions to test, one per major line, asking when several qualify."""
|
|
90
72
|
compatible = manager.list_compatible(min_format, max_format)
|
|
91
73
|
if not compatible:
|
|
92
74
|
err = f"No compatible versions found for pack format range {min_format}..{max_format}"
|
|
@@ -98,10 +80,9 @@ def select_compatible(min_format: int, max_format: int) -> list[str]:
|
|
|
98
80
|
return [versions[0].name]
|
|
99
81
|
|
|
100
82
|
print_note("Pack format range supports multiple major versions")
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
name = select("Select version strategy:", choices)
|
|
83
|
+
options = [Option("Use all versions", "(Recommended)", value="all")]
|
|
84
|
+
options.extend(Option(f"Use {v.name} only", value=v.name) for v in versions)
|
|
85
|
+
name = select("Select version strategy:", options)
|
|
105
86
|
|
|
106
87
|
return [v.name for v in versions if name == "all" or v.name == name]
|
|
107
88
|
|
|
@@ -118,6 +99,14 @@ def start_environments(envs: Sequence[Environment]) -> list[RunningEnvironment]:
|
|
|
118
99
|
return list(executor.map(lambda env: _start_environment(env, progress), envs))
|
|
119
100
|
|
|
120
101
|
|
|
102
|
+
def _select_version(versions: list[Version], message: str, empty: str) -> str:
|
|
103
|
+
if not versions:
|
|
104
|
+
raise click.ClickException(empty)
|
|
105
|
+
if len(versions) == 1:
|
|
106
|
+
return versions[0].name
|
|
107
|
+
return select(message, [v.name for v in versions])
|
|
108
|
+
|
|
109
|
+
|
|
121
110
|
def _start_environment(env: Environment, progress: Progress) -> RunningEnvironment:
|
|
122
111
|
"""Start a single environment, reporting steps to the shared progress."""
|
|
123
112
|
if isinstance(env, UninstalledEnvironment):
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Coverage rendering shared by the terminal reporters."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
|
|
7
|
+
from mcward import (
|
|
8
|
+
CoverageReport,
|
|
9
|
+
CoverageTotals,
|
|
10
|
+
FunctionReport,
|
|
11
|
+
ResolvedCoverage,
|
|
12
|
+
ResourceReport,
|
|
13
|
+
Version,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_GAP_PATHS_SHOWN = 3
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def render_coverage(resolved: Mapping[Version, ResolvedCoverage], verbose: bool = False) -> Text:
|
|
20
|
+
"""The coverage summary, one section per version that reported it."""
|
|
21
|
+
text = Text()
|
|
22
|
+
for version, coverage in resolved.items():
|
|
23
|
+
reports = coverage.reports
|
|
24
|
+
if len(resolved) > 1:
|
|
25
|
+
text.append(f"{version.name}\n", "magenta")
|
|
26
|
+
_render_summary(text, reports)
|
|
27
|
+
_render_groups(text, reports)
|
|
28
|
+
if verbose:
|
|
29
|
+
_render_gaps(text, coverage.functions)
|
|
30
|
+
_render_uncalled(text, coverage.functions)
|
|
31
|
+
_render_condition_gaps(text, coverage.resources)
|
|
32
|
+
return text
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _render_summary(text: Text, reports: Sequence[CoverageReport]) -> None:
|
|
36
|
+
totals = CoverageTotals.of(reports)
|
|
37
|
+
text.append("Coverage: ", "bold")
|
|
38
|
+
text.append(_share(totals))
|
|
39
|
+
text.append(
|
|
40
|
+
f" ({totals.covered}/{totals.total}, {totals.touched}/{totals.files} files)\n", "dim"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _render_groups(text: Text, reports: Sequence[CoverageReport]) -> None:
|
|
45
|
+
"""One rollup row per group: combined share, covered count, files reached."""
|
|
46
|
+
key = _group_key(reports)
|
|
47
|
+
groups = sorted({key(report) for report in reports})
|
|
48
|
+
if len(groups) < 2:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
rows = []
|
|
52
|
+
for group in groups:
|
|
53
|
+
totals = CoverageTotals.of(report for report in reports if key(report) == group)
|
|
54
|
+
counts = f"{totals.covered}/{totals.total}"
|
|
55
|
+
rows.append((group, _share(totals, 6), counts, f"{totals.touched}/{totals.files} files"))
|
|
56
|
+
|
|
57
|
+
name_width = max(len(row[0]) for row in rows)
|
|
58
|
+
count_width = max(len(row[2]) for row in rows)
|
|
59
|
+
for group, share, counts, files in rows:
|
|
60
|
+
text.append(f" {group:<{name_width}} {share}")
|
|
61
|
+
text.append(f" {counts:>{count_width}}", "dim")
|
|
62
|
+
text.append(f" {files}\n", "dim")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _share(totals: CoverageTotals, width: int = 0) -> str:
|
|
66
|
+
return f"{totals.ratio:{width}.1%}" if totals.ratio is not None else f"{'—':>{width}}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _group_key(reports: Sequence[CoverageReport]) -> Callable[[CoverageReport], str]:
|
|
70
|
+
"""Group by namespace, or by resource type when there is only one."""
|
|
71
|
+
if len({report.namespace for report in reports}) > 1:
|
|
72
|
+
return lambda report: report.namespace
|
|
73
|
+
return lambda report: report.kind
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _render_gaps(text: Text, reports: Sequence[FunctionReport]) -> None:
|
|
77
|
+
gaps = [r for r in reports if r.touched and r.covered < r.total]
|
|
78
|
+
gaps.sort(key=lambda r: (r.covered - r.total, r.name))
|
|
79
|
+
|
|
80
|
+
for report in gaps:
|
|
81
|
+
text.append(" ~ ", "yellow")
|
|
82
|
+
text.append(report.name)
|
|
83
|
+
text.append(f" {report.covered}/{report.total}", "dim")
|
|
84
|
+
if detail := _describe_gaps(report):
|
|
85
|
+
text.append(f" {detail}", "dim yellow")
|
|
86
|
+
text.append("\n")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _render_condition_gaps(text: Text, resources: Sequence[ResourceReport]) -> None:
|
|
90
|
+
"""An untouched file gets counts only: paths would drown big generated files."""
|
|
91
|
+
gaps = [r for r in resources if r.covered < r.total]
|
|
92
|
+
gaps.sort(key=lambda r: (r.covered - r.total, r.name))
|
|
93
|
+
|
|
94
|
+
for resource in gaps:
|
|
95
|
+
symbol, style = ("~", "yellow") if resource.touched else ("✗", "red")
|
|
96
|
+
text.append(f" {symbol} ", style)
|
|
97
|
+
text.append(resource.name)
|
|
98
|
+
text.append(f" ({resource.kind})", "dim")
|
|
99
|
+
text.append(f" {resource.covered}/{resource.total}", "dim")
|
|
100
|
+
if resource.touched and (detail := _describe_nodes(resource)):
|
|
101
|
+
text.append(f" {detail}", "dim yellow")
|
|
102
|
+
text.append("\n")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _describe_nodes(resource: ResourceReport) -> str:
|
|
106
|
+
groups: dict[str, list[str]] = {"never": [], "blocked": [], "unreached": []}
|
|
107
|
+
for node in resource.counted:
|
|
108
|
+
if not node.evaluated:
|
|
109
|
+
groups["never"].append(node.path)
|
|
110
|
+
for run in resource.runs:
|
|
111
|
+
if run.blocked:
|
|
112
|
+
groups["blocked"].append(run.path)
|
|
113
|
+
elif not run.reached:
|
|
114
|
+
groups["unreached"].append(run.path)
|
|
115
|
+
return "; ".join(f"{label} {_some_paths(paths)}" for label, paths in groups.items() if paths)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _some_paths(paths: list[str]) -> str:
|
|
119
|
+
shown = ", ".join(path or "(root)" for path in paths[:_GAP_PATHS_SHOWN])
|
|
120
|
+
extra = len(paths) - _GAP_PATHS_SHOWN
|
|
121
|
+
return f"{shown} +{extra} more" if extra > 0 else shown
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _render_uncalled(text: Text, reports: Sequence[FunctionReport]) -> None:
|
|
125
|
+
for report in reports:
|
|
126
|
+
if report.lines and not report.touched:
|
|
127
|
+
text.append(" ✗ ", "red")
|
|
128
|
+
text.append(report.name)
|
|
129
|
+
text.append(f" 0/{report.total}\n", "dim")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _describe_gaps(report: FunctionReport) -> str:
|
|
133
|
+
if report.file is None:
|
|
134
|
+
return ""
|
|
135
|
+
unreached = [line.line for line in report.lines if not line.reached and line.line is not None]
|
|
136
|
+
guarded = [line.line for line in report.guarded if line.line is not None]
|
|
137
|
+
parts = []
|
|
138
|
+
if unreached:
|
|
139
|
+
parts.append(f"missing {_ranges(unreached)}")
|
|
140
|
+
if guarded:
|
|
141
|
+
parts.append(f"guarded {_ranges(guarded)}")
|
|
142
|
+
return "; ".join(parts)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _ranges(lines: Sequence[int]) -> str:
|
|
146
|
+
"""Consecutive line numbers folded into ranges: 5-6, 10."""
|
|
147
|
+
|
|
148
|
+
def fold(start: int, end: int) -> str:
|
|
149
|
+
return str(start) if start == end else f"{start}-{end}"
|
|
150
|
+
|
|
151
|
+
parts: list[str] = []
|
|
152
|
+
start = previous = lines[0]
|
|
153
|
+
for line in lines[1:]:
|
|
154
|
+
if line != previous + 1:
|
|
155
|
+
parts.append(fold(start, previous))
|
|
156
|
+
start = line
|
|
157
|
+
previous = line
|
|
158
|
+
parts.append(fold(start, previous))
|
|
159
|
+
return ", ".join(parts)
|
|
@@ -22,6 +22,8 @@ def run(
|
|
|
22
22
|
datapacks: Sequence[Path],
|
|
23
23
|
environments: Sequence[RunningEnvironment],
|
|
24
24
|
selector: str = "*:*",
|
|
25
|
+
coverage: bool = False,
|
|
26
|
+
verbose: bool = False,
|
|
25
27
|
resolve: FileResolver | None = None,
|
|
26
28
|
) -> TestSession:
|
|
27
29
|
"""Run tests, printing the final results view and GitHub annotations."""
|
|
@@ -30,8 +32,8 @@ def run(
|
|
|
30
32
|
resolve = resolve or pack_resolver(datapacks)
|
|
31
33
|
|
|
32
34
|
# Consume the stream without rendering; it always yields a starting frame
|
|
33
|
-
*_, session = run_tests(datapacks, environments, selector=selector)
|
|
34
|
-
console.print(render_session(session, resolve))
|
|
35
|
+
*_, session = run_tests(datapacks, environments, selector=selector, coverage=coverage)
|
|
36
|
+
console.print(render_session(session, resolve, verbose))
|
|
35
37
|
|
|
36
38
|
for command in annotations(session, resolve):
|
|
37
39
|
print(command, flush=True)
|