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.
Files changed (30) hide show
  1. {mcward_cli-1.1.0 → mcward_cli-1.2.0}/.gitignore +2 -0
  2. {mcward_cli-1.1.0 → mcward_cli-1.2.0}/PKG-INFO +3 -3
  3. {mcward_cli-1.1.0 → mcward_cli-1.2.0}/pyproject.toml +1 -1
  4. {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/__init__.py +2 -10
  5. {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/commands/daemon.py +7 -3
  6. {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/commands/install.py +1 -1
  7. {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/commands/list.py +1 -3
  8. {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/commands/test.py +37 -3
  9. {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/datapacks.py +4 -5
  10. {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/environments.py +30 -41
  11. mcward_cli-1.2.0/src/mcward/cli/reporters/__init__.py +5 -0
  12. mcward_cli-1.2.0/src/mcward/cli/reporters/coverage.py +159 -0
  13. {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/reporters/github.py +4 -2
  14. {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/reporters/live.py +90 -21
  15. mcward_cli-1.2.0/src/mcward/cli/reports/__init__.py +87 -0
  16. mcward_cli-1.2.0/src/mcward/cli/reports/html.py +491 -0
  17. mcward_cli-1.2.0/src/mcward/cli/reports/junit.py +50 -0
  18. mcward_cli-1.2.0/src/mcward/cli/reports/lcov.py +56 -0
  19. mcward_cli-1.2.0/src/mcward/cli/ui.py +70 -0
  20. mcward_cli-1.2.0/tests/test_coverage_report.py +187 -0
  21. {mcward_cli-1.1.0 → mcward_cli-1.2.0}/tests/test_discovery.py +13 -0
  22. {mcward_cli-1.1.0 → mcward_cli-1.2.0}/tests/test_live.py +56 -6
  23. mcward_cli-1.2.0/tests/test_reports.py +284 -0
  24. mcward_cli-1.1.0/src/mcward/cli/reporters/__init__.py +0 -9
  25. mcward_cli-1.1.0/src/mcward/cli/ui.py +0 -59
  26. {mcward_cli-1.1.0 → mcward_cli-1.2.0}/README.md +0 -0
  27. {mcward_cli-1.1.0 → mcward_cli-1.2.0}/src/mcward/cli/commands/__init__.py +0 -0
  28. {mcward_cli-1.1.0 → mcward_cli-1.2.0}/tests/test_cli.py +0 -0
  29. {mcward_cli-1.1.0 → mcward_cli-1.2.0}/tests/test_environments.py +0 -0
  30. {mcward_cli-1.1.0 → mcward_cli-1.2.0}/tests/test_github.py +0 -0
@@ -23,6 +23,8 @@
23
23
  bin/
24
24
  build/
25
25
  classes/
26
+ coverage.html
27
+ coverage.lcov
26
28
  coverage.xml
27
29
  hs_err_*.log
28
30
  out/
@@ -1,7 +1,7 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: mcward-cli
3
- Version: 1.1.0
4
- Summary: Command-line interface for Ward - A testing framework for Minecraft datapacks using mcfunction.
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 - A testing framework for Minecraft datapacks using mcfunction."
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
- """Check whether the first arg is meant for test rather than a subcommand.
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 glob pre-expansion would resolve -p patterns before
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
- """Daemon commands - start, stop, and status."""
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 = [cast(RunningEnvironment, manager.get(v.name)) for v in manager.list_running()]
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 = [cast(RunningEnvironment, manager.get(v.name)) for v in manager.list_running()]
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
- """Install and clean commands - version management."""
1
+ """The install and clean commands."""
2
2
 
3
3
  import rich_click as click
4
4
 
@@ -1,4 +1,4 @@
1
- """List command - show available and installed versions."""
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
- """Test command - run datapack tests."""
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
- """Discover datapacks — directories or zips — from glob patterns."""
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
- paths.update(p.resolve() for p in cwd.glob(pattern))
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 v in versions:
31
- k = (v.year, v.major)
32
- if v.is_snapshot and k in seen:
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(v)
35
- seen.add(k)
33
+ curated.append(version)
34
+ seen.add(line)
36
35
  return curated
37
36
 
38
37
 
39
- def get_environment(version: Version | str) -> Environment:
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 [manager.get(v if isinstance(v, str) else v.name) for v in versions]
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
- """Select an installed version."""
54
- versions = manager.list_installed()
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
- """Select a running version."""
62
- versions = manager.list_running()
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
- """Select an available version."""
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
- for v in curated:
78
- title = [("", v.name)]
79
- if v == latest:
80
- title.append(("dim", " (latest)"))
81
- elif v == snapshot:
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
- """Resolve which versions to test based on pack format range."""
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
- choices = [Choice(title=[("", "Use all versions "), ("dim", "(Recommended)")], value="all")]
103
- choices.extend(Choice(title=f"Use {v.name} only", value=v.name) for v in versions)
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,5 @@
1
+ """Reporters present a test run: each module exposes the same ``run()``."""
2
+
3
+ from . import github, live
4
+
5
+ __all__ = ["github", "live"]
@@ -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)