Skip to content

Markdown Reporter

Markdown report generator for ANTA test results.

ANTAReport

ANTAReport(
    mdfile: TextIO,
    results: ResultManager,
    extra_data: dict[str, Any] | None = None,
)

Bases: MDReportBase

Generate the # ANTA Report section of the markdown report.

generate_section

generate_section() -> None

Generate the # ANTA Report section of the markdown report.

Source code in anta/reporter/md_reporter.py
262
263
264
265
266
def generate_section(self) -> None:
    """Generate the `# ANTA Report` section of the markdown report."""
    self.write_heading(heading_level=1)
    toc = MD_REPORT_TOC_WITH_RUN_OVERVIEW if self.extra_data else MD_REPORT_TOC
    self.mdfile.write(toc + "\n\n")

MDReportBase

MDReportBase(
    mdfile: TextIO,
    results: ResultManager,
    extra_data: dict[str, Any] | None = None,
)

Bases: ABC

Base class for all sections subclasses.

Every subclasses must implement the generate_section method that uses the ResultManager object to generate and write content to the provided markdown file.

Parameters:

Name Type Description Default
mdfile TextIO

An open file object to write the markdown data into.

required
results ResultManager

The ResultsManager instance containing all test results.

required
extra_data dict[str, Any] | None

Optional extra data dictionary. Can be used by subclasses to render additional data.

None

format_snake_case_to_title_case

format_snake_case_to_title_case(value: str) -> str

Format a snake_case string to a Title Cased string with spaces, handling known network protocol or feature acronyms.

Parameters:

Name Type Description Default
value str

A string value to be formatted.

required

Returns:

Type Description
str

The value formatted in Title Cased.

Example
  • “hello_world” becomes “Hello World”
  • “anta_version” becomes “ANTA Version”
Source code in anta/reporter/md_reporter.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
def format_snake_case_to_title_case(self, value: str) -> str:
    """Format a snake_case string to a Title Cased string with spaces, handling known network protocol or feature acronyms.

    Parameters
    ----------
    value
        A string value to be formatted.

    Returns
    -------
    str
        The value formatted in Title Cased.

    Example
    -------
    - "hello_world" becomes "Hello World"
    - "anta_version" becomes "ANTA Version"
    """
    if not value:
        return ""

    parts = value.split("_")
    processed_parts = []
    for part in parts:
        if part.lower() in ACRONYM_CATEGORIES:
            processed_parts.append(part.upper())
        else:
            processed_parts.append(part.capitalize())

    return " ".join(processed_parts)

format_timedelta

format_timedelta(value: timedelta) -> str

Format a timedelta object into a human-readable string.

Handles positive timedelta values. Milliseconds are shown only if they are the sole component of a duration less than 1 second. Does not format “days”; 2 days will return 48 hours.

Parameters:

Name Type Description Default
value timedelta

The timedelta object to be formatted.

required

Returns:

Type Description
str

The timedelta object formatted to a human-readable string.

Source code in anta/reporter/md_reporter.py
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
def format_timedelta(self, value: timedelta) -> str:
    """Format a timedelta object into a human-readable string.

    Handles positive timedelta values. Milliseconds are shown only
    if they are the sole component of a duration less than 1 second.
    Does not format "days"; 2 days will return 48 hours.

    Parameters
    ----------
    value
        The timedelta object to be formatted.

    Returns
    -------
    str
        The timedelta object formatted to a human-readable string.
    """
    total_seconds = int(value.total_seconds())

    if total_seconds < 0:
        return "Invalid duration"

    if total_seconds == 0 and value.microseconds == 0:
        return "0 seconds"

    parts = []

    hours = total_seconds // 3600
    if hours > 0:
        parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
    minutes = (total_seconds % 3600) // 60
    if minutes > 0:
        parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
    seconds = total_seconds % 60
    if seconds > 0:
        parts.append(f"{seconds} second{'s' if seconds != 1 else ''}")
    milliseconds = value.microseconds // 1000
    if milliseconds > 0 and not parts and total_seconds == 0:
        parts.append(f"{milliseconds} millisecond{'s' if milliseconds != 1 else ''}")

    return ", ".join(parts) if parts else "0 seconds"

format_value

format_value(value: Any) -> str

Format different types of values for display in the report.

Handles datetime, timedelta, lists, and other types by converting them to human-readable string representations.

Handles only positive timedelta values.

Parameters:

Name Type Description Default
value Any

A value of any type to be formatted.

required

Returns:

Type Description
str

The value formatted to a human-readable string.

Example
  • datetime.now() becomes “YYYY-MM-DD HH:MM:SS.milliseconds”
  • timedelta(hours=1, minutes=5, seconds=30) becomes “1 hour, 5 minutes, 30 seconds”
  • [“item1”, “item2”] becomes “item1, item2”
  • 123 becomes “123”
Source code in anta/reporter/md_reporter.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
def format_value(self, value: Any) -> str:  # noqa: ANN401
    """Format different types of values for display in the report.

    Handles datetime, timedelta, lists, and other types by converting them to
    human-readable string representations.

    Handles only positive timedelta values.

    Parameters
    ----------
    value
        A value of any type to be formatted.

    Returns
    -------
    str
        The value formatted to a human-readable string.

    Example
    -------
    - datetime.now() becomes "YYYY-MM-DD HH:MM:SS.milliseconds"
    - timedelta(hours=1, minutes=5, seconds=30) becomes "1 hour, 5 minutes, 30 seconds"
    - ["item1", "item2"] becomes "item1, item2"
    - 123 becomes "123"
    """
    if isinstance(value, datetime):
        return value.isoformat(sep=" ", timespec="milliseconds")

    if isinstance(value, timedelta):
        return self.format_timedelta(value)

    if isinstance(value, list):
        return ", ".join(str(v_item) for v_item in value)

    return str(value)

generate_heading_name

generate_heading_name() -> str

Generate a formatted heading name based on the class name.

Returns:

Type Description
str

Formatted header name.

Example
  • ANTAReport will become ANTA Report.
  • TestResultsSummary will become Test Results Summary.
Source code in anta/reporter/md_reporter.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def generate_heading_name(self) -> str:
    """Generate a formatted heading name based on the class name.

    Returns
    -------
    str
        Formatted header name.

    Example
    -------
    - `ANTAReport` will become `ANTA Report`.
    - `TestResultsSummary` will become `Test Results Summary`.
    """
    class_name = self.__class__.__name__

    # Split the class name into words, keeping acronyms together
    words = re.findall(r"[A-Z]?[a-z]+|[A-Z]+(?=[A-Z][a-z]|\d|\W|$)|\d+", class_name)

    # Capitalize each word, but keep acronyms in all caps
    formatted_words = [word if word.isupper() else word.capitalize() for word in words]

    return " ".join(formatted_words)

generate_rows

generate_rows() -> Generator[str, None, None]

Generate the rows of a markdown table for a specific report section.

Subclasses can implement this method to generate the content of the table rows.

Source code in anta/reporter/md_reporter.py
61
62
63
64
65
66
67
def generate_rows(self) -> Generator[str, None, None]:
    """Generate the rows of a markdown table for a specific report section.

    Subclasses can implement this method to generate the content of the table rows.
    """
    msg = "Subclasses should implement this method"
    raise NotImplementedError(msg)

generate_section abstractmethod

generate_section() -> None

Abstract method to generate a specific section of the markdown report.

Must be implemented by subclasses.

Source code in anta/reporter/md_reporter.py
52
53
54
55
56
57
58
59
@abstractmethod
def generate_section(self) -> None:
    """Abstract method to generate a specific section of the markdown report.

    Must be implemented by subclasses.
    """
    msg = "Must be implemented by subclasses"
    raise NotImplementedError(msg)

safe_markdown

safe_markdown(text: str | None) -> str

Escape markdown characters in the text to prevent markdown rendering issues.

Parameters:

Name Type Description Default
text str | None

The text to escape markdown characters from.

required

Returns:

Type Description
str

The text with escaped markdown characters.

Source code in anta/reporter/md_reporter.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def safe_markdown(self, text: str | None) -> str:
    """Escape markdown characters in the text to prevent markdown rendering issues.

    Parameters
    ----------
    text
        The text to escape markdown characters from.

    Returns
    -------
    str
        The text with escaped markdown characters.
    """
    # Custom field from a TestResult object can be None
    if text is None:
        return ""

    # Replace newlines with <br> to preserve line breaks in HTML
    return text.replace("\n", "<br>")

write_heading

write_heading(heading_level: int) -> None

Write a markdown heading to the markdown file.

The heading name used is the class name.

Parameters:

Name Type Description Default
heading_level int

The level of the heading (1-6).

required
Example

## Test Results Summary

Source code in anta/reporter/md_reporter.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def write_heading(self, heading_level: int) -> None:
    """Write a markdown heading to the markdown file.

    The heading name used is the class name.

    Parameters
    ----------
    heading_level
        The level of the heading (1-6).

    Example
    -------
    `## Test Results Summary`
    """
    # Ensure the heading level is within the valid range of 1 to 6
    heading_level = max(1, min(heading_level, 6))
    heading_name = self.generate_heading_name()
    heading = "#" * heading_level + " " + heading_name
    self.mdfile.write(f"{heading}\n\n")

write_table

write_table(
    table_heading: list[str], *, last_table: bool = False
) -> None

Write a markdown table with a table heading and multiple rows to the markdown file.

Parameters:

Name Type Description Default
table_heading list[str]

List of strings to join for the table heading.

required
last_table bool

Flag to determine if it’s the last table of the markdown file to avoid unnecessary new line. Defaults to False.

False
Source code in anta/reporter/md_reporter.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
def write_table(self, table_heading: list[str], *, last_table: bool = False) -> None:
    """Write a markdown table with a table heading and multiple rows to the markdown file.

    Parameters
    ----------
    table_heading
        List of strings to join for the table heading.
    last_table
        Flag to determine if it's the last table of the markdown file to avoid unnecessary new line. Defaults to False.
    """
    self.mdfile.write("\n".join(table_heading) + "\n")
    for row in self.generate_rows():
        self.mdfile.write(row)
    if not last_table:
        self.mdfile.write("\n")

MDReportGenerator

Class responsible for generating a Markdown report based on the provided ResultManager object.

It aggregates different report sections, each represented by a subclass of MDReportBase, and sequentially generates their content into a markdown file.

This class provides two methods for generating the report:

  • generate: Uses a single result manager instance to generate all sections defined in the DEFAULT_SECTIONS class variable list.

  • generate_sections: A custom list of sections is provided. Each section uses its own dedicated result manager instance, allowing greater flexibility or isolation between section generations.

generate classmethod

generate(
    results: ResultManager,
    md_filename: Path,
    extra_data: dict[str, Any] | None = None,
) -> None

Generate the sections of the markdown report defined in DEFAULT_SECTIONS using a single result manager instance for all sections.

Parameters:

Name Type Description Default
results ResultManager

The ResultsManager instance containing all test results.

required
md_filename Path

The path to the markdown file to write the report into.

required
extra_data dict[str, Any] | None

Optional extra data dictionary that can be used by the section generators to render additional data.

None
Source code in anta/reporter/md_reporter.py
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
@classmethod
def generate(cls, results: ResultManager, md_filename: Path, extra_data: dict[str, Any] | None = None) -> None:
    """Generate the sections of the markdown report defined in DEFAULT_SECTIONS using a single result manager instance for all sections.

    Parameters
    ----------
    results
        The ResultsManager instance containing all test results.
    md_filename
        The path to the markdown file to write the report into.
    extra_data
        Optional extra data dictionary that can be used by the section generators to render additional data.
    """
    try:
        with md_filename.open("w", encoding="utf-8") as mdfile:
            for section in cls.DEFAULT_SECTIONS:
                section(mdfile, results, extra_data).generate_section()
    except OSError as exc:
        message = f"OSError caught while writing the Markdown file '{md_filename.resolve()}'."
        anta_log_exception(exc, message, logger)
        raise

generate_sections classmethod

generate_sections(
    sections: list[
        tuple[type[MDReportBase], ResultManager]
    ],
    md_filename: Path,
    extra_data: dict[str, Any] | None = None,
) -> None

Generate the different sections of the markdown report provided in the sections argument with each section using its own result manager instance.

Parameters:

Name Type Description Default
sections list[tuple[type[MDReportBase], ResultManager]]

A list of tuples, where each tuple contains a subclass of MDReportBase and an instance of ResultManager.

required
md_filename Path

The path to the markdown file to write the report into.

required
extra_data dict[str, Any] | None

Optional extra data dictionary that can be used by the section generators to render additional data.

None
Source code in anta/reporter/md_reporter.py
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
@classmethod
def generate_sections(cls, sections: list[tuple[type[MDReportBase], ResultManager]], md_filename: Path, extra_data: dict[str, Any] | None = None) -> None:
    """Generate the different sections of the markdown report provided in the sections argument with each section using its own result manager instance.

    Parameters
    ----------
    sections
        A list of tuples, where each tuple contains a subclass of `MDReportBase` and an instance of `ResultManager`.
    md_filename
        The path to the markdown file to write the report into.
    extra_data
        Optional extra data dictionary that can be used by the section generators to render additional data.
    """
    try:
        with md_filename.open("w", encoding="utf-8") as md_file:
            for section, rm in sections:
                section(md_file, rm, extra_data).generate_section()
    except OSError as exc:
        message = f"OSError caught while writing the Markdown file '{md_filename.resolve()}'."
        anta_log_exception(exc, message, logger)
        raise

RunOverview

RunOverview(
    mdfile: TextIO,
    results: ResultManager,
    extra_data: dict[str, Any] | None = None,
)

Bases: MDReportBase

Generate the ## Run Overview section of the markdown report.

The extra_data dictionary containing the desired run information must be provided to the initializer to generate this section.

generate_section

generate_section() -> None

Generate the ## Run Overview section of the markdown report.

Source code in anta/reporter/md_reporter.py
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
def generate_section(self) -> None:
    """Generate the `## Run Overview` section of the markdown report."""
    if not self.extra_data:
        return

    md_lines = []
    for key, value in self.extra_data.items():
        label = self.format_snake_case_to_title_case(key)
        item_prefix = f"- **{label}:**"
        placeholder_for_none = "None"

        if isinstance(value, list):
            if not value:
                md_lines.append(f"{item_prefix} {placeholder_for_none}")
            else:
                md_lines.append(item_prefix)
                md_lines.extend([f"  - {item!s}" for item in value])
        elif isinstance(value, dict):
            if not value:
                md_lines.append(f"{item_prefix} {placeholder_for_none}")
            else:
                md_lines.append(item_prefix)
                for k, v_list_or_scalar in value.items():
                    sub_label = self.format_snake_case_to_title_case(k)
                    sub_value_str = self.format_value(v_list_or_scalar)
                    md_lines.append(f"  - {sub_label}: {sub_value_str}")
        # Scalar values
        else:
            formatted_value = self.format_value(value)
            md_lines.append(f"{item_prefix} {formatted_value}")

    self.write_heading(heading_level=2)
    self.mdfile.write("\n".join(md_lines))
    self.mdfile.write("\n\n")

SummaryTotals

SummaryTotals(
    mdfile: TextIO,
    results: ResultManager,
    extra_data: dict[str, Any] | None = None,
)

Bases: MDReportBase

Generate the ### Summary Totals section of the markdown report.

generate_rows

generate_rows() -> Generator[str, None, None]

Generate the rows of the summary totals table.

Source code in anta/reporter/md_reporter.py
328
329
330
331
332
333
334
335
336
def generate_rows(self) -> Generator[str, None, None]:
    """Generate the rows of the summary totals table."""
    yield (
        f"| {self.results.get_total_results()} "
        f"| {self.results.get_total_results({AntaTestStatus.SUCCESS})} "
        f"| {self.results.get_total_results({AntaTestStatus.SKIPPED})} "
        f"| {self.results.get_total_results({AntaTestStatus.FAILURE})} "
        f"| {self.results.get_total_results({AntaTestStatus.ERROR})} |\n"
    )

generate_section

generate_section() -> None

Generate the ### Summary Totals section of the markdown report.

Source code in anta/reporter/md_reporter.py
338
339
340
341
def generate_section(self) -> None:
    """Generate the `### Summary Totals` section of the markdown report."""
    self.write_heading(heading_level=3)
    self.write_table(table_heading=self.TABLE_HEADING)

SummaryTotalsDeviceUnderTest

SummaryTotalsDeviceUnderTest(
    mdfile: TextIO,
    results: ResultManager,
    extra_data: dict[str, Any] | None = None,
)

Bases: MDReportBase

Generate the ### Summary Totals Devices Under Tests section of the markdown report.

generate_rows

generate_rows() -> Generator[str, None, None]

Generate the rows of the summary totals device under test table.

Source code in anta/reporter/md_reporter.py
352
353
354
355
356
357
358
359
360
361
def generate_rows(self) -> Generator[str, None, None]:
    """Generate the rows of the summary totals device under test table."""
    for device, stat in self.results.device_stats.items():
        total_tests = stat.tests_success_count + stat.tests_skipped_count + stat.tests_failure_count + stat.tests_error_count + stat.tests_unset_count
        categories_skipped = ", ".join(sorted(convert_categories(list(stat.categories_skipped))))
        categories_failed = ", ".join(sorted(convert_categories(list(stat.categories_failed))))
        yield (
            f"| {device} | {total_tests} | {stat.tests_success_count} | {stat.tests_skipped_count} | {stat.tests_failure_count} | {stat.tests_error_count} "
            f"| {categories_skipped or '-'} | {categories_failed or '-'} |\n"
        )

generate_section

generate_section() -> None

Generate the ### Summary Totals Devices Under Tests section of the markdown report.

Source code in anta/reporter/md_reporter.py
363
364
365
366
def generate_section(self) -> None:
    """Generate the `### Summary Totals Devices Under Tests` section of the markdown report."""
    self.write_heading(heading_level=3)
    self.write_table(table_heading=self.TABLE_HEADING)

SummaryTotalsPerCategory

SummaryTotalsPerCategory(
    mdfile: TextIO,
    results: ResultManager,
    extra_data: dict[str, Any] | None = None,
)

Bases: MDReportBase

Generate the ### Summary Totals Per Category section of the markdown report.

generate_rows

generate_rows() -> Generator[str, None, None]

Generate the rows of the summary totals per category table.

Source code in anta/reporter/md_reporter.py
377
378
379
380
381
382
383
384
385
def generate_rows(self) -> Generator[str, None, None]:
    """Generate the rows of the summary totals per category table."""
    for category, stat in self.results.category_stats.items():
        converted_category = convert_categories([category])[0]
        total_tests = stat.tests_success_count + stat.tests_skipped_count + stat.tests_failure_count + stat.tests_error_count + stat.tests_unset_count
        yield (
            f"| {converted_category} | {total_tests} | {stat.tests_success_count} | {stat.tests_skipped_count} | {stat.tests_failure_count} "
            f"| {stat.tests_error_count} |\n"
        )

generate_section

generate_section() -> None

Generate the ### Summary Totals Per Category section of the markdown report.

Source code in anta/reporter/md_reporter.py
387
388
389
390
def generate_section(self) -> None:
    """Generate the `### Summary Totals Per Category` section of the markdown report."""
    self.write_heading(heading_level=3)
    self.write_table(table_heading=self.TABLE_HEADING)

TestResults

TestResults(
    mdfile: TextIO,
    results: ResultManager,
    extra_data: dict[str, Any] | None = None,
)

Bases: MDReportBase

Generates the ## Test Results section of the markdown report.

generate_rows

generate_rows() -> Generator[str, None, None]

Generate the rows of the all test results table.

Source code in anta/reporter/md_reporter.py
401
402
403
404
405
406
407
408
409
def generate_rows(self) -> Generator[str, None, None]:
    """Generate the rows of the all test results table."""
    for result in self.results.results:
        messages = self.safe_markdown(result.messages[0]) if len(result.messages) == 1 else self.safe_markdown("<br>".join(result.messages))
        categories = ", ".join(sorted(convert_categories(result.categories)))
        yield (
            f"| {result.name or '-'} | {categories or '-'} | {result.test or '-'} "
            f"| {result.description or '-'} | {self.safe_markdown(result.custom_field) or '-'} | {result.result or '-'} | {messages or '-'} |\n"
        )

generate_section

generate_section() -> None

Generate the ## Test Results section of the markdown report.

Source code in anta/reporter/md_reporter.py
411
412
413
414
def generate_section(self) -> None:
    """Generate the `## Test Results` section of the markdown report."""
    self.write_heading(heading_level=2)
    self.write_table(table_heading=self.TABLE_HEADING, last_table=True)

TestResultsSummary

TestResultsSummary(
    mdfile: TextIO,
    results: ResultManager,
    extra_data: dict[str, Any] | None = None,
)

Bases: MDReportBase

Generate the ## Test Results Summary section of the markdown report.

generate_section

generate_section() -> None

Generate the ## Test Results Summary section of the markdown report.

Source code in anta/reporter/md_reporter.py
315
316
317
def generate_section(self) -> None:
    """Generate the `## Test Results Summary` section of the markdown report."""
    self.write_heading(heading_level=2)