pytestHTML Reporter
Home Docs CLI reference
Reference

CLI reference

Twenty-nine pytest options and one standalone command with four subcommands. Every default below is the one the source actually uses, not the one the help text rounds off.

The flags are ordinary pytest options

The plugin registers its options through pytest_addoption, in an option group named report generator. They are pytest options like any other, so pytest --help lists them under that heading and everywhere pytest reads a command line will take them: the shell, addopts in an ini file, a tox command, a Makefile target, the args of a CI step.

The plugin loads through the pytest11 entry point, so nothing needs importing and no conftest.py has to mention it. That also means it is always on. --html-report defaults to ., so a plain pytest run in an environment where the package is installed writes ./pytest_html_report.html whether anybody asked for a report or not.

shell
$ pytest tests/ --html-report=./report --title="Payments Regression"

Twenty-seven of the twenty-nine options also have an ini key, so most of this page has a second, quieter form in the configuration reference. --title is one exception, and --report-show-config is the other: it prints what a run resolved rather than setting anything.

Quick reference

Every option the plugin adds to pytest, in the order the groups below take them. Defaults are what the run uses when neither the flag nor the ini key says anything.

FlagValueDefaultWhat it does
--report-profilea profile name, or nonethe pinned default, else noneUse the settings written down under that name.
--report-show-configflagoffPrint what this run resolved, and out of which layer, in the pytest header.
--html-reportfolder or .html path../pytest_html_report.htmlWhere the build is written. Date placeholders are expanded.
--titletextPYTEST REPORTThe report title. The displayed copy is cut at 20 characters.
--environmenttextunsetNames the environment under test; badge cut at 10 characters.
--build-infoKEY=VALUE, repeatablenoneExtra rows in the Environment panel.
--report-packagesflagoffAdd a Packages row listing every installed distribution.
--report-linkLABEL=URL, repeatablenoneExtra entries in the report's side nav.
--report-link-patternMARKER=URL, repeatablenoneTurns a marker into a link on the test it is written on.
--report-openauto | always | noneautoWhether the finished report is handed to a browser.
--archive-countinteger, kept as text"" (keep every build)How many builds the Archives section shows and keeps.
--archive-daysnumber of daysno age limitDelete archived builds older than DAYS days.
--archive-sinceYYYY-MM-DD or with a timeno date limitDelete every archived build older than DATE.
--report-logsall | failed | noneallWhose captured stdout, stderr and logging is kept.
--report-log-limitint10000Characters of captured output kept per test; 0 is no limit.
--report-attachmentsall | failed | noneallWhose attachments are kept.
--report-attachment-limitint20000Characters kept per attached payload; 0 is no limit.
--report-screenshotsfailed | all | nonefailedWhen a live browser is photographed without the suite asking.
--report-stepsall | failed | noneallWhose step() and Given/When/Then trees are kept.
--report-step-limitint500Steps kept per test; 0 keeps every one.
--report-coverageauto | noneautoWhether the Coverage tab is built at all.
--report-coverage-filepathgo lookingRead coverage from this file instead of discovering one.
--report-coverage-limitint500Files listed on the Coverage tab; 0 lists every one.
--report-shardtext (ID)"" (not a shard)Names this process one leg of a sharded run; writes a bundle, no report.
--report-shard-mergeflagoffAfter writing this leg's bundle, merge every bundle beside it.
--report-shard-runtext (TOKEN)CI auto-detection, else ""Which CI run this leg belongs to, so a merge takes only this run's legs.
--report-shard-resetflagoffDelete <report>/shards before this leg writes into it.
--report-junitpath"" (no XML)Also write a JUnit XML of this run. Date placeholders are expanded.
--report-junit-xpasspass | fail | skippassHow an xpassed test is written to the JUnit XML.

How a flag is read

Precedence

For every setting the command-line flag wins, and the ini key is consulted only when the flag is unset or an empty or whitespace string. Three settings break that rule on purpose: --build-info, --report-link and --report-link-pattern are additive. The command-line entries come first, then the ini lines, and both appear in the report. Every other setting is override-only.

Since 0.4.3 there are two layers between those two: a PYTEST_HTML_REPORTER_* variable, and then the profile this run selected together with the [tool.pytest-html-reporter] table it shares. Six layers in all, and Configuration lists them in order. A run that names no profile and sets no variable resolves exactly as this page describes, because a profile reaches the run by being written onto the same options the flags parse into.

When it happens

All of it is resolved in pytest_configure, before a single test runs. The report path is expanded and written back onto config.option.path, and the three shard options are written back the same way, so that an xdist worker — a separate process handed a copy of these options — agrees with the controller about where things go even if the run crosses a minute boundary while a %M placeholder is in the path.

Resolving early is also why a bad value fails before the tests rather than after them. --report-open=off and --report-junit-xpass=fatal both end the run at configure time, when the cost is nothing, instead of after forty minutes of tests.

Validation is not symmetric between flags and ini keys

A command-line value for an option that declares choices is rejected by pytest's own argument parser. An ini value is read by the plugin's own helper, and those helpers do not all answer the same way:

NoteThe split is deliberate. A misspelled report_logs costs you some detail in the report; a misspelled report_open or report_junit_xpass gives you exactly the behaviour you set the key to avoid, so those refuse to guess.

Every numeric limit clamps at zero, and zero means unlimited

All four limit helpers do max(limit, 0), and every consumer treats 0 as no limit. A negative value therefore means unlimited, not "keep nothing". There is no way to spell "keep zero characters" with a limit; that is what --report-logs=none and its siblings are for.

Date and time placeholders

Two settings run through strftime: --html-report / html_report and --report-junit / report_junit. Only the directives strftime documents are substituted — aAbBcdfGHIjmMpSuUVwWxXyYzZ%% passes through as a literal percent, and any other %X is left exactly as written. A folder called 100% pass survives being expanded.

On the pytest command line a literal percent has to be doubled, because pytest's own help and config machinery consumes one level of it. In an ini file a single percent is right.

shell Doubled % on the command line
$ pytest --html-report=./reports/%%Y%%m%%d/report_%%H%%M.html

Profiles, and what a run resolved

Two options that set nothing themselves. The first says which written-down shape of a run this is; the second says what that came to. Configuration has the whole of profiles — where they are written, what they may carry, and the six layers they sit between.

--report-profile
NAME default: the pinned one, else none ini: report_profile new in 0.4.3

Uses the settings written down under NAME — as [tool.pytest-html-reporter.profiles.NAME] in pyproject.toml, or [pytest-html-reporter.profiles.NAME] in pytest.ini, tox.ini or setup.cfg — so that local and ci are one word each rather than two long command lines kept in two places.

Both files are read, the ini file pytest chose for this run first, and a name defined in both is taken from the ini file whole rather than merged. The name is matched case-insensitively; an unknown one fails the run at configure time, lists the profiles that are defined and offers the nearest.

shell This run
$ pytest tests/ --report-profile=ci
$ pytest tests/ --report-profile=none     # ignore the pinned default

none is the way one run opts back out of a profile the repository pinned as its default — which is why no profile can be called none. Everything the profile settles is written onto the very options the flags parse into, so an xdist worker is shaped by the same profile the controller is, and the Environment panel carries a Profile row naming it.

--report-show-config
flag default: off no ini key new in 0.4.3

Prints the settings this run resolved — each one's value, which layer decided it and which layers it overrode — and then runs the suite as usual. The lines go into the pytest header rather than to stdout of their own, because the header is the part of the output that gets pasted into an issue when somebody is asking why their setting did not take.

shell The suite still runs; the header says what it is running under
$ pytest tests/ --report-profile=ci --report-show-config

It is resolved before the profile is applied, which is the only moment the question can be answered: once a profile has been written onto config.option there is nothing left to tell a value somebody typed from a value a profile chose. To ask the same question without running anything, use pytest-html-reporter config.

Output location and identity

Five options that decide where the build is written and what the page says about itself.

--html-report
path default: . ini: html_report

Where the build is written. The value is expanded through strftime once, at configure time, and then read as either a file or a folder: if the string contains .html anywhere it names a file, and the folder is the part before the last /; otherwise the string names a folder and the file inside it is pytest_html_report.html. ~ and $VARS are expanded and the folder is made absolute.

The resolved folder is the report base, and everything else the build produces lives beside the page: archive/, output.json, pytest_screenshots/ and, on a sharded run, shards/.

shell Folder, file, and a file per run
$ pytest --html-report=./report                  # ./report/pytest_html_report.html
$ pytest --html-report=./report/index.html       # ./report/index.html
$ pytest --html-report=./reports/%%Y%%m%%d.html  # ./reports/20260903.html
CarefulA folder whose own name contains .html./my.html.d — is read as a file name, and the report lands in the current directory. The shard machinery reproduces this quirk line for line on purpose, so the shard folder and the report folder can never disagree. The merge command refuses such a path instead of obeying it.

One consequence worth knowing: the previous run's screenshots are swept on every configure by building <raw path>/pytest_screenshots. With a folder path that folder exists and is cleaned. With a file path — ./report/report.html — it looks for ./report/report.html/pytest_screenshots, which does not exist, and nothing is removed.

--title
text default: PYTEST REPORT no ini key

The report title. The full string is kept for the heading's tooltip; the displayed copy is hard-cut at 20 characters with no ellipsis, and a class on the element tells the page to fade the tail so the cut reads as a cut rather than as a typo.

This is the only option in the group with no ini key. If you want it fixed per project, put it in addopts.

shell
$ pytest --html-report=./report --title="Payments Regression"
--environment
text default: unset ini: environment

Names the environment the suite ran against. It adds an Environment row to the Environment panel and a badge beside the title, cut at 10 characters with the full name in the tooltip.

The value is recorded into each shard's own bundle. A merging leg deliberately passes an empty environment through, so a merged report reports each leg's own value rather than whichever leg happened to run last.

shell
$ pytest --html-report=./report --environment=staging
--build-info
KEY=VALUE repeatable ini: build_info (added to)

Adds a row to the Environment panel. Repeat the flag for as many rows as you want. Each entry is split on the first =, so a value may contain more of them; key and value are stripped, a blank entry is skipped, and an entry with no = at all yields an empty value rather than being dropped.

This and --report-link are the two settings that combine with their ini key instead of overriding it. Command-line entries come first, then the build_info lines — so a project can pin team=payments in pytest.ini and let CI add the branch and the run id on top.

shell Two rows from CI, on top of whatever the ini file already says
$ pytest --html-report=./report \
    --build-info branch="$GITHUB_REF_NAME" \
    --build-info run="$GITHUB_RUN_ID"
--report-packages
flag default: off ini: report_packages new in 0.4.2

Adds one more row to the Environment panel: Packages, holding every installed distribution and its version the way pip freeze reads them, with the count in the label — Packages (214). Names sort case-insensitively, and a distribution whose metadata cannot be read is skipped rather than costing the other two hundred.

It is off by default on purpose. It is a few hundred entries nobody reads until the day the report is the only surviving record of what was installed — and it publishes a full dependency inventory into a file that gets attached to tickets and passed around. The Plugins row answers a much smaller question, and the library whose new minor version broke the suite last night is almost never a pytest plugin.

It is a switch rather than a value, like --report-shard-merge and --report-shard-reset: the flag turns it on, and so does a truthy report_packages ini key (1, true, yes, on). There is nothing on the command line that turns off an ini file which has already said yes. Listing what was installed is a property of the job rather than of one run, so the ini file is usually where it belongs.

shell One run's inventory, on demand
$ pytest tests/ --html-report=./report --report-packages

On a sharded run each leg collects the list for itself, on the machine that actually imported those versions. The merged report shows one list when every leg agrees and one row per leg when they do not, which is the case the row exists for.

Screenshot: assets/img/shots/environment-panel-flags.png The Environment panel of a report at 1440px wide, light theme, run with --environment=staging --build-info branch=main --build-info team=payments. Show the rows in order — Environment, branch, team, CI, Pipeline, Commit, Captured output, Host, Platform, Python, Interpreter, pytest, Plugins, Arguments, Root, Generated — with callouts naming which flag produced the first three and which rows were detected without one.
--report-link
LABEL=URL repeatable ini: report_link (added to)

Adds an entry to the report's side nav. The entry is split on the first =; both halves must be non-empty or the entry is discarded.

Relative paths are kept as they are, because linking ./htmlcov/index.html that sits beside the report is the point of the option. An absolute URL has to carry a scheme of http://, https:// or mailto:; anything else is silently dropped, so a javascript: or data: URL cannot reach a page that gets published and passed around. A one-letter scheme is not treated as a scheme at all, so C:/reports is read as the Windows path it is.

shell The coverage HTML beside the report, and a runbook elsewhere
$ pytest --cov=src --cov-report=html --html-report=./report \
    --report-link 'Coverage=htmlcov/index.html' \
    --report-link 'Runbook=https://wiki.example.com/runbook'
--report-link-pattern
MARKER=URL repeatable ini: report_link_pattern (added to) new in 0.4.1

Turns a marker into a link on the test it is written on. @pytest.mark.jira("PROJ-123") is already collected and already shown, but as a flat badge, because nothing in the report knows that PROJ-123 is an issue rather than a word. Name the marker here, put {} where its argument goes, and every test carrying it gets a badge that opens the ticket.

The entry is split on the first =, and both halves must be non-empty or it is discarded. A template with no {} is a fixed destination that the marker's presence links to — docs=https://wiki/testing — not an error and not a placeholder somebody forgot.

The id is percent-encoded on the way into the URL, because it is pasted from a tracker and arrives with whatever that tracker allows in one. The template is substituted rather than formatted: str.format reads every brace in a string, and a URL is a place people put them, so a templated Confluence link would throw KeyError instead of rendering. The scheme goes through the same sieve as --report-link, and for a stronger reason — this URL is built per test out of whatever a marker said.

Naming a marker here does two more things. It is registered with pytest, so --strict-markers accepts it and no run prints PytestUnknownMarkWarning for it; and its id is written into the JUnit XML as a <property> on the testcase, which is the half Xray, Zephyr and TestRail actually read. The built-in owner and severity markers need none of this configuration and are written to the XML anyway.

shell Two trackers, and a marker whose presence alone links somewhere
$ pytest --html-report=./report \
    --report-link-pattern 'jira=https://acme.atlassian.net/browse/{}' \
    --report-link-pattern 'testcase=https://acme.testrail.io/index.php?/cases/view/{}' \
    --report-link-pattern 'docs=https://wiki.example.com/testing'

A marker with no pattern keeps the badge it has today, so this is opt-in per marker and nothing changes for a suite that sets none. See markers that link.

--report-open
auto | always | none default: auto ini: report_open

Whether the finished report is handed to a browser. auto opens it only when all three of these hold:

  • Somebody is sitting thereThe original stdout is a tty. pytest replaced sys.stdout with a capture long ago, so a run piped into a log file or started by cron is correctly read as unattended.
  • It is not a build agentSeventeen environment variables are consulted; see Environment variables below.
  • There is a graphical sessionAssumed on macOS and Windows; on anything else DISPLAY or WAYLAND_DISPLAY has to be set. Without this check, webbrowser on a headless Linux box falls through to a console browser and opens the report in the terminal, on top of the summary the run just printed.

The report is opened as a file:// URL with new=2, which asks for a tab rather than a window so a run in a loop does not bury the desktop. Every failure path is swallowed: the tests are over and the report is written by the time any of this runs, and a machine with no browser on it is not a failed build.

shell
$ pytest --html-report=./report --report-open=none     # never
$ pytest --html-report=./report --report-open=always   # even in a container
NoteA value outside the three is a usage error, raised at configure time before any test runs. Somebody who wrote report_open = off has said they do not want a browser, and silently opening one anyway is the one outcome they were trying to avoid.

Archives and retention

The three retention limits intersect. A build has to satisfy every limit that is set to survive: the day-based and date-based cutoffs are reduced to the stricter of the two, so neither can widen the other, and a file then has to be both newer than that cutoff and among the newest keep to be kept.

Archived builds are named output_<start timestamp>.json, and the age of a build is read out of the file name, not its mtime. A checkout into a fresh CI workspace gives every file the mtime of the clone; reading ages from mtimes there would keep everything for ever. The mtime is only the fallback for files named by an older version of the plugin.

--archive-count
integer, kept as text default: "" ini: archive_count

How many builds the Archives section shows and keeps on disk. This is handled as text rather than as a number, because empty and 0 are different answers and both get asked about later:

ValueWhat happens
"" (unset)With no day or date limit either, nothing is ever deleted.
0The whole archive/ directory is removed and the report has no Archives section at all.
NThe build being written now is displayed alongside the archived ones and counts against the limit, so N - 1 files stay on disk.

A non-integer is a usage error — --archive-count takes a number of builds to keep, not 'nine' — and so is a negative number.

shell
$ pytest --html-report=./report --archive-count=20
--archive-days
float (DAYS) default: no age limit ini: archive_days

Keep only the builds run in the last DAYS days; older archived builds are deleted. Fractions are allowed, so 0.5 is twelve hours — useful on a suite that runs on every commit rather than nightly.

A non-numeric value is a usage error, and so is a negative one.

shell At most 50 builds, and none older than 14 days — both have to hold
$ pytest --html-report=./report --archive-count=50 --archive-days=14
--archive-since
date (DATE) default: no date limit ini: archive_since

Delete every archived build older than DATE. Three formats are tried in order: %Y-%m-%d %H:%M:%S, %Y-%m-%d %H:%M, then %Y-%m-%d. All are read in local time, so a bare date means midnight local. Anything else is a usage error naming the two shapes it accepts.

Use this for a hard line — the day a release branch was cut, the day a flaky suite was rewritten — where a rolling window of days would keep sliding past it.

shell
$ pytest --html-report=./report --archive-since='2026-06-01 09:00'

What the report keeps: logs, attachments, screenshots and steps

Four all | failed | none modes, three of them with a numeric cap beside them. Every one of these flags defaults to unset on the command line so the ini key can be seen; the effective defaults live in the helpers and differ per feature, because photographing a passing test and recording the steps of a passing test are not the same kind of cost.

--report-logs
all | failed | none default: all ini: report_logs

Whose captured stdout, stderr and logging output is kept. none is more than a display setting: it stops the reporter collecting sections in pytest_runtest_makereport at all, so nothing is buffered in the first place.

Sections are keyed by pytest's own section title and the last write wins, so a test retried by pytest-rerunfailures shows the attempt actually being reported rather than every attempt's output stacked up.

NoteUnder -s / --capture=no pytest does not capture stdout and stderr at all, so only logging output reaches the Logs column. The report renders a banner saying so rather than leaving you to guess. See the third-party options below.
shell
$ pytest --html-report=./report --report-logs=failed
--report-log-limit
int default: 10000 ini: report_log_limit

The maximum number of characters of captured output kept per test, counted across all of that test's sections. 0 keeps everything, and so does any negative number.

The tail is what survives, because logs read chronologically and the interesting part of a log is the end. The cut is made at a line boundary rather than mid-line, and a synthetic Trimmed section is prepended reading N earlier characters dropped - raise --report-log-limit to keep them., so the page never quietly pretends the output was that short.

shell Keep every character, for a suite whose logs are the evidence
$ pytest --html-report=./report --report-logs=failed --report-log-limit=0
--report-attachments
all | failed | none default: all ini: report_attachments

Whose attachments are kept — the payloads handed to attach_text(), attach_json(), attach_file() and attach_api(). These are things the suite asked for by name, which is why the default is all; drop to failed when an API-heavy suite is making the page large.

Images handed to attach() are not attachments in this sense and are governed by nothing — see --report-screenshots. The functions themselves are documented in the Python API.

shell
$ pytest --html-report=./report --report-attachments=failed
--report-attachment-limit
int default: 20000 ini: report_attachment_limit

Maximum characters kept per attached payload. 0 — and any negative value — keeps everything. The limit is per payload, not per test, so a test that attaches six responses can carry six times this much.

shell
$ pytest --html-report=./report --report-attachment-limit=50000
--report-screenshots
failed | all | none default: failed ini: report_screenshots

When the reporter photographs a live Selenium driver or Playwright page without the suite asking. The picture is taken in pytest_runtest_teardown, before the fixture finalizers run and close the browser, which is the last moment the page still exists.

The default is failed because photographing a run that passed produces a great many pictures of pages that were fine, and every one of them costs a round trip to the browser.

TipThis governs only the automatic captures. An image handed to attach() was asked for and is always kept, whatever this says — including on a passing test under --report-screenshots=none.
shell Photograph everything while chasing a visual regression
$ pytest tests/ui --html-report=./report --report-screenshots=all

More about how the picture is taken, and about attaching your own, in Screenshots.

--report-steps
all | failed | none default: all ini: report_steps

Whose test steps are kept — both the pieces named with step() and the Given/When/Then of a pytest-bdd scenario.

The default is all rather than failed, unlike a log, because the steps of a test that passed are what a later failure gets read against, and they cost a line each.

shell
$ pytest --html-report=./report --report-steps=failed

Steps and BDD covers what a step tree looks like.

--report-step-limit
int default: 500 ini: report_step_limit

Maximum steps kept per test. 0 keeps every one, and so does any negative value, which is clamped to 0 — enforcement is if limit() and len(steps) >= limit(), and a falsey limit never fires.

This one is stored on class-level config state rather than on the reporter, because step() is called by the test long before the reporter object is reachable from it. What it is for: a step called inside a ten-thousand-row loop would otherwise write ten thousand lines into the page, and the tree stops being readable long before it stops being generated.

shell
$ pytest --html-report=./report --report-step-limit=0
shell A lean CI build: evidence only where something went wrong
$ pytest --html-report=./report \
    --report-logs=failed \
    --report-attachments=failed \
    --report-screenshots=none \
    --report-steps=failed

Coverage

The Coverage tab is built from whatever coverage the run produced. Coverage is read before output.json is written, so the percentage is archived with the build and the next build can show a delta and a trend line.

--report-coverage
auto | none default: auto ini: report_coverage

Whether the Coverage tab is built at all. none switches it off, including the coverage entry in output.json — so the number is not archived and does not appear on the trend either.

shell
$ pytest --cov=src --html-report=./report --report-coverage=none
--report-coverage-file
path default: go looking ini: report_coverage_file

Read coverage from this file instead of discovering one. Three kinds are understood, detected from the contents rather than the extension: a coverage.py coverage.json, a Cobertura coverage.xml, and a .coverage data file.

With no file named, discovery looks for coverage.json and then coverage.xml. Name the file when the coverage was measured somewhere else — a previous job, a different container — and the file you want is not the one discovery would find first.

shell
$ pytest --html-report=./report --report-coverage-file=./artifacts/coverage.xml
--report-coverage-limit
int default: 500 ini: report_coverage_limit

How many files the Coverage tab lists, least-covered first. 0 — and any negative value — lists every one. The order is what makes a limit tolerable here: the files a limit cuts off are the ones already well covered.

shell The ring is coloured against the project's own --cov-fail-under
$ pytest --cov=src --cov-report=json --cov-fail-under=85 \
    --html-report=./report \
    --report-coverage-file=./coverage.json \
    --report-coverage-limit=0
How the ring is colouredWith no target the grade bands apply: 90 or more is strong, 75 or more is fair, below that is low. With a target — the live fail_under when pytest-cov is running, otherwise its --cov-fail-under option — it is strong at or above the target and low below it. A target of zero or less counts as no target.

Sharded runs

A build is one set of totals, one archived output.json, one rotation of archive/, one point on the trend chart and one entry in every per-test history. So exactly one process may write into a report folder. Four matrix legs writing four reports into one folder do not add up to a build; they overwrite each other's output.json and manufacture four builds out of one.

A leg named with --report-shard therefore writes a bundle and nothing else — no page, no output.json, no archive rotation:

shell What one leg leaves behind under --html-report=./report
report/
└── shards/
    └── 1-4/
        ├── records.json
        └── pytest_screenshots/

The bundle is lossless: the leg's records verbatim, plus a description of the run — session start and end, exit status, run token, collected count, hostname, platform, Python and pytest versions, plugin versions, invocation arguments, rootdir, environment, build info, the capture row, and the xdist worker ids — and the leg's coverage summary.

Diagram: assets/img/shots/shard-layout.png A file-tree diagram of a merged report folder: report/ holding pytest_html_report.html, output.json, archive/, pytest_screenshots/ and shards/1-4 … 4-4, each with records.json and pytest_screenshots/. Draw four arrows from four CI jobs into the four shard folders, and one arrow from the merge command into the four top-level files. Both themes.
--report-shard
text (ID) default: not a shard ini: report_shard

Names this process as one leg of a sharded run — 1/4, e2e, py311-linux. The leg writes its records to <report>/shards/<id> and no report of its own.

The id has to be a directory name, so it is sanitised: anything outside [A-Za-z0-9._-] becomes -, runs of - collapse to one, - and . are stripped from both ends, and the result is truncated to 64 characters. The raw value is what travels into the bundle as the shard's label, because 1/4 reads better in the report than 1-4.

An id that sanitises to nothing — .., // — is a usage error rather than a silent fallback. Both . and .. are made entirely of characters the pattern considers safe, and a shard directory that resolved to the report base itself would empty the report's screenshots and drop its bundle in the wrong place.

shell
$ pytest tests/unit --html-report=./report --report-shard=1/4
Under xdistA leg run with -n 8 is still one leg. Workers return early from the terminal summary, so the bundle is written once by the controller — which by then holds every worker's records — rather than once per worker.
--report-shard-merge
flag default: off ini: report_shard_merge

After writing this leg's bundle, merge every bundle beside it and render one report. This is for sequential legs on one machine: three runs need three commands rather than four.

It requires --report-shard — a merging leg still has to say which leg it is — and the run aborts at configure time if it is missing. A merge failure after the tests is written to stderr and the leg returns without a report rather than raising an internal error once every test has already passed; the bundles stay on disk for pytest-html-reporter merge to pick up. Every bundle that was merged is named on stderr with the time it finished.

shell Three sequential legs — the first resets, the last merges
$ pytest tests/unit        --html-report=./report --report-shard=unit --report-shard-reset
$ pytest tests/integration --html-report=./report --report-shard=integration
$ pytest tests/e2e         --html-report=./report --report-shard=e2e  --report-shard-merge

The ini form takes 1, true, yes or on, case-insensitively; anything else is false.

--report-shard-run
text (TOKEN) default: CI auto-detection ini: report_shard_run

Names the CI run this leg belongs to. The token is stamped into every bundle, and a merging leg merges only the bundles carrying the same token — the ones it puts aside are named on stderr, along with advice to clear the folder or give the first leg --report-shard-reset.

When nothing is given, the token is derived from the CI system's own variables (see the table below). A leg with no token at all merges everything, which is what the feature has always done.

shell Naming it explicitly, so a re-run cannot merge the first attempt's bundles
$ pytest --html-report=./report --report-shard=1/4 \
    --report-shard-run="$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
--report-shard-reset
flag default: off ini: report_shard_reset

Delete <report>/shards entirely before this leg writes into it. For the first leg of a sequential run, where the legs share one persistent --html-report and the folder accumulates: a leg renamed or dropped since the last run would otherwise leave a bundle behind that the next merge reports as part of this build — six tests when four ran.

CarefulThis is never implied. Not by --report-shard, not by --report-shard-merge. It deletes the other legs' work, so it has to be the flag somebody typed. Never put it on a parallel matrix leg — the legs have no ordering between them, and any one of them could delete the rest.

It is applied before the leg's own directory is dealt with, since it takes that directory with it.

shell
$ pytest tests/unit --html-report=./report --report-shard=unit --report-shard-reset

What a leg cleans, and what it leaves alone

A shard leg removes only its own <base>/shards/<id> directory, and removes the whole directory rather than just its screenshots. A records.json left by a previous, larger run of the same leg would otherwise survive and be what the merge reads.

It never sweeps the report base, because the legs of one matrix are pointed at one --html-report and a second leg cleaning the shared folder would take the first leg's screenshots with it, leaving the merge holding records that name pictures which are no longer there.

yaml A parallel matrix: every leg writes a bundle, one later job merges them
jobs:
  test:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - run: pytest --html-report=./report --report-shard=${{ matrix.shard }}/4
      - uses: actions/upload-artifact@v4
        with:
          name: shard-${{ matrix.shard }}
          path: report/shards/

  report:
    needs: test
    steps:
      - uses: actions/download-artifact@v4
        with: { path: ./artifacts }
      - run: |
          pytest-html-reporter merge ./artifacts \
            --html-report ./report \
            --junit-xml ./report/junit.xml \
            --strict

CI integrations has the same shape for GitLab, Jenkins and the rest.

JUnit XML from a pytest run

--report-junit
path default: no XML ini: report_junit

Also write a JUnit XML of this run to PATH, for a CI system that reads XML. Date and time placeholders are expanded exactly as they are for --html-report.

The XML is written before the guard that skips a run which collected nothing, deliberately: a run that collected nothing still owes its CI system a document saying so, because a missing file reads as "the job never ran".

A write failure is reported on stderr as pytest-html-reporter: --report-junit could not write <path>: <error> and never raises. The run has already finished, and losing the HTML report, output.json and the archived build over a mistyped XML path would be the wrong trade.

shell
$ pytest --html-report=./report --report-junit=./report/junit-%%Y%%m%%d.xml
Ignored on a shardA shard that is not also the merge leg renders nothing, so it writes no XML. This is said out loud on stderr at configure time rather than silently, because a CI author expecting four shard XMLs plus a merged one would have a **/*.xml glob counting every test in the matrix twice. Pass --junit-xml to pytest-html-reporter merge, or run the leg with --report-shard-merge.
--report-junit-xpass
pass | fail | skip default: pass ini: report_junit_xpass

How an unexpectedly passing test is written down. pass is what pytest's own --junitxml does.

This is the only shaping flag a pytest run has for the XML. The equivalents of --junit-suite-name, --junit-hostname, --junit-logging and --junit-attachments exist only on the standalone command below.

A value outside the three is a usage error rather than a fallback, and that is on purpose: the default is the one answer a team that reached for this flag was trying to avoid.

shell
$ pytest --html-report=./report --report-junit=./junit.xml --report-junit-xpass=fail
NoteThis plugin's xPASS is only ever the non-strict kind. A strict xfail that passes is reported by pytest as a plain failure with [XPASS(strict)] in the text, and never reaches this code as an xPASS at all.

The standalone command

Installing the package also installs a console script, pytest-html-reporter. It exists because merging a sharded run is its own process, not another pytest run: a report folder has exactly one writer per build, and a fifth pytest started in it to do the merging would sweep away the very screenshots it was sent to collect. Since 0.4.3 it also answers a question that has nothing to do with merging — what a run in this project would resolve its settings to — because that one is asked before a run rather than after it.

shell
pytest-html-reporter [--version] COMMAND [PATH ...] [options]

It is argparse and the standard library only — a merge command that needed a dependency of its own would be a command CI could not run. --version prints pytest-html-reporter 0.4.3 and exits. There is no default subcommand: running it bare is an argparse usage error and exits 2.

Three of the four subcommands are three views of one merge. The fourth reads configuration files and writes nothing at all.

merge

The whole of it — the report, and the JUnit XML when asked. Requires --html-report.

junit

Stops after the XML, for a pipeline that publishes results and keeps no HTML. Requires -o.

inspect

Stops before anything is written — the pre-flight check for "four artifacts were downloaded and three are here".

config

New in 0.4.3, and reads no bundles at all: what a pytest run here would resolve, and which layer decided each setting.

Every flag the command takes

FlagValueDefaultOnWhat it does
--versiontop levelPrint the version and exit.
PATH ...paths.all threeDirectories to walk for bundles, or records.json files named directly.
--on-duplicatemerge | first | last | worst | errormergeall threeWhat to do when one nodeid ran in more than one shard.
--ordershard | nameshardall threeRow order within each suite.
--strip-path-prefixtext, repeatablenoneall threeStrip this prefix from every nodeid, so different checkout roots group into one suite.
-q, --quietflagoffall threeSuppress the per-decision notes on stderr.
--strictflagoffmerge, junitExit 1 when the merge was not complete.
--exit-codeflagoffmerge, junitExit 1 when the merged build has any failure or error.
--dry-runflagoffmerge, junitMerge and print the summary, writing nothing.
--html-reportpath — requiredmergeWhere the merged report is written.
--junit-xmlpathnonemergeAlso write the merged JUnit XML here.
--report-link-patternMARKER=URL, repeatablenonemergeTurns a marker into a link, and writes its id into the merged XML as a testcase property. Read off argv, there being no ini file here.
--start-timeearliest | now | unix timestampearliestmergeThe moment this build is filed under.
--report-coverage-filepathnonemergeOne already-combined coverage report.
--coverage-datapath, repeatablenonemerge.coverage data files to combine with the coverage package.
--report-coverage-limitint500mergeFiles listed on the Coverage tab; 0 lists every one.
--coverage-targetfloatnonemergeThe percentage that colours the coverage ring.
--titletextPYTEST REPORTmergeThe merged report's title.
--environmenttextthe shards' own valuemergeThe environment under test.
--build-infoK=V, repeatablenonemergeA row for the Environment panel.
--report-linkLABEL=URL, repeatablenonemergeA link in the merged report's nav.
--archive-countinteger as text""mergeHow many builds to keep.
--archive-daysnumberno limitmergeHow many days of build history to keep.
--archive-sincedateno limitmergeThe oldest build to keep.
--report-openauto | always | nonenonemergeWhether the finished report is opened in a browser.
--copy-assetsflagonmergeCopy the screenshots the merged records name into the report.
--no-copy-assetsflagmergeLeave the images in the shard folders.
--junit-suite-nametextpytestmerge, junitThe <testsuite> name attribute.
--junit-hostnametextthe shards' hostmerge, junitThe hostname attribute.
--junit-xpasspass | fail | skippassmerge, junitHow an unexpectedly passing test is written down.
--junit-loggingno | all | failednomerge, junitWhich tests carry their captured output into the XML.
--junit-attachmentsflagonmerge, junitWrite [[ATTACHMENT|…]] lines for screenshots.
--no-junit-attachmentsflagmerge, junitLeave the screenshots out of the XML.
-o, --outputpath — requiredjunitWhere the merged JUnit XML is written.
--jsonflagoffinspect, configPrint the listing, or the resolved settings, as a JSON document on stdout.
PATHone path.configThe project to read — the directory pytest would call its rootdir.
--profilenamethe pinned defaultconfigResolve as though --report-profile=NAME had been passed.
--allflagoffconfigAlso list the settings nobody named, at their defaults.

PATH, and how bundles are found

PATH ...
positional, zero or more default: . merge, junit, inspect

Directories to search for shard bundles, or records.json files named directly. The everyday shape is one folder holding the artifacts four CI jobs uploaded, and the everyday command is run from inside it — which is why the default is the current directory.

Discovery expands ~ and $VARS, takes a named file as-is, and otherwise walks a directory collecting every records.json. Subdirectories and candidates are sorted at every level, so the same folder yields the same list twice running, and results are deduplicated by absolute path.

A file that is not a bundle — the wrong schema, or not JSON at all — is skipped with a note. A bundle written by a newer pytest-html-reporter is a different matter: it stops the merge and is named, rather than being read hopefully, because silently dropping a quarter of a matrix produces a report that is wrong in a way nobody looking at it can see.

Bundles are ordered by a natural sort of the shard id, then the id, then the file path — never load order — so two CI jobs downloading the same four artifacts in different orders produce the same report.

shell
$ pytest-html-reporter merge ./artifacts --html-report ./report
$ pytest-html-reporter merge ./artifacts/shard-1/records.json \
    ./artifacts/shard-2/records.json --html-report ./report
Two bundles, one idNot an error. The one that finished later wins, both are named in a note, and the count is reported as "bundles superseded by a newer copy" — which feeds --strict.

Shaping flags — what the merged record list is

These three are on all three subcommands, because they decide the answer rather than the output. inspect reporting a different set of tests from the merge that follows it would make it useless as a pre-flight check.

--on-duplicate
merge | first | last | worst | error default: merge merge, junit, inspect

What to do when one nodeid ran in more than one shard — a badly split matrix, or a leg re-run over the top of another.

PolicyWhat it keeps
mergeFolds the attempts together and counts them as reruns.
firstThe first attempt in bundle order.
lastThe last attempt in bundle order.
worstThe most severe outcome: ERROR > FAIL > xPASS > SKIP > xFAIL > PASS. A status this version has never heard of scores below all of them, so it can never outrank a genuine FAIL.
errorNothing — the merge stops and exits 2.

Every fold is reported on stderr whichever policy is chosen.

shell Refuse to publish a build whose shards overlapped
$ pytest-html-reporter merge ./artifacts --html-report ./report --on-duplicate error
--order
shard | name default: shard merge, junit, inspect

Row order. shard keeps each suite's rows in shard order, which is the order they ran in. name sorts by suite and test name, which is what you want when the report is being diffed against yesterday's by eye.

shell
$ pytest-html-reporter merge ./artifacts --html-report ./report --order name
--strip-path-prefix
text (PREFIX) repeatable merge, junit, inspect

Strip this prefix from the front of every nodeid, so shards that ran under different checkout roots group into one suite instead of four. Applied after separators are normalised — backslashes become slashes, repeated slashes collapse, a leading ./ is removed — so one prefix covers a Windows leg and a Linux leg.

A prefix that would swallow a whole nodeid is not applied to it: the original id is worth more than an empty one, which would be quarantined.

shell A GitHub runner and a container that checked out somewhere else
$ pytest-html-reporter merge ./artifacts --html-report ./report \
    --strip-path-prefix /home/runner/work/repo/ \
    --strip-path-prefix /src/

merge — one report out of many bundles

Reads every bundle under PATH ..., merges them, and drives the ordinary render pipeline exactly once. The merged build is assembled by the same code every other build in that report's history was assembled by; nothing here re-implements a page, a total or a template.

Three things are validated before anything is written, in this order: --html-report, then --start-time, then the three retention values. The retention check asks the very helpers a pytest run asks, so --archive-count nine is refused by the merge in the same words and for the same reason — and it is asked first, because those three are read deep inside the render, where a usage error would abort a build that had already rotated the archive.

merge --html-report
path required

Where the merged report is written: a folder, or the .html file itself. Two values are refused — an empty or whitespace one, and a path with .html in it that does not end in .html, which the render pipeline would read as a file name and drop into the current directory. The run has already happened by the time anybody goes looking for the report, so this one is refused rather than obeyed.

Pointing the merge at the same folder the shards wrote into is safe. The merge clears <base>/pytest_screenshots before staging images, while every shard's copies live on under <base>/shards/<id>/pytest_screenshots.

shell
$ pytest-html-reporter merge ./report --html-report ./report   # safe
$ pytest-html-reporter merge ./artifacts --html-report ./my.html.d
pytest-html-reporter: --html-report './my.html.d' has '.html' in it without ending in it, ...
merge --start-time
earliest | now | unix timestamp default: earliest

The moment this build is filed under. It names the archive file, stamps output.json, labels the trend point, orders builds in Analytics, and dates the build — so a matrix that started before midnight and merged after it is dated the day it ran, not the day it finished being collected.

It is validated up front, before --dry-run too, so a typo is reported rather than a build promised that could not have been stamped.

shell
$ pytest-html-reporter merge ./artifacts --html-report ./report --start-time now
merge --coverage-data
path repeatable needs the coverage package

.coverage data files, or directories holding them, to combine into the merged build's Coverage tab. A directory contributes every entry whose name starts with .coverage, sorted.

The files are copied into a temporary directory before being combined, because coverage.combine() deletes what it reads — and those are somebody's CI artifacts, very often the only copy.

If the coverage package is not installed in the merging environment, or nothing matched, or the combine failed, the merge still succeeds and reports a notice instead. Losing the whole report over a coverage number would be the wrong trade.

shell Four legs' data files into one number, coloured against the project's target
$ pip install coverage
$ pytest-html-reporter merge ./artifacts --html-report ./report \
    --coverage-data ./artifacts --coverage-target 85
How the merge chooses a coverage numberFive branches, in order, and no sixth: coverage switched off gives nothing — --report-coverage=none, which only a --report-shard-merge leg can carry, since the standalone command has no such flag; a named --report-coverage-file is read; --coverage-data is combined; if exactly one shard measured coverage, its number is used with a notice saying whose share it is; if more than one did, the merge refuses to reconcile them and says how to combine them. There is deliberately no discovery step — discovery searches the working directory, and a stale coverage.json on the merging machine would become this build's archived number.
merge --copy-assets / --no-copy-assets
flag pair default: copy

Copy the screenshots the merged records name into the report, prefixing each with the shard that took it. The prefix is not cosmetic: screenshot names restart their counter in every process, so two machines' first screenshots would otherwise collide.

Staging is also what discovers that an image a bundle named is not in the bundle. Those are reported as missing screenshots and feed --strict. --no-copy-assets leaves the images in the shard folders, which means the report's links only resolve where those folders are.

shell
$ pytest-html-reporter merge ./artifacts --html-report ./report --no-copy-assets

A full CI merge, and what it prints

shell The command
$ ls ./artifacts
shard-1/records.json  shard-2/records.json  shard-3/records.json  shard-4/records.json

$ pytest-html-reporter merge ./artifacts \
    --html-report ./report \
    --junit-xml ./report/junit.xml \
    --title 'Nightly Matrix' \
    --environment staging \
    --build-info branch=main \
    --report-link 'Coverage=htmlcov/index.html' \
    --archive-count 50 \
    --on-duplicate merge \
    --strict
shell The summary, on stdout — the per-decision notes go to stderr
merged 4 shards: 412 tests
  shard 1-4: 103 tests, finished 2026-09-03 11:04:19
  shard 2-4: 103 tests, finished 2026-09-03 11:05:02
  shard 3-4: 103 tests, finished 2026-09-03 11:04:51
  shard 4-4: 103 tests, finished 2026-09-03 11:05:44
  PASS 401, FAIL 8, SKIP 3
  ran on runner-1, runner-2 over 6 mins 12 secs
  report: /home/runner/work/repo/report/pytest_html_report.html
  junit:  /home/runner/work/repo/report/junit.xml

The report path is named only once the file is on disk. A render writes no page for a build with no records, and a merge of shards that collected nothing between them lands exactly there — printing a path anyway would send the next pipeline step off to publish a file that was never written.

junit — the XML without the report

Merges exactly the way merge does — same discovery, same shaping flags, same duplicate policy — and writes only the XML. No report, so no screenshots are copied and no archive is rotated. It is a subcommand rather than a flag on merge because a pipeline publishing results into GitLab or Azure and keeping no HTML should not have to write a build it will throw away.

junit -o, --output
path (FILE) required

Where the merged JUnit XML is written. Unlike merge, a write failure here means the one thing this subcommand exists to produce was not produced, so it exits 2 with no summary.

Screenshots are renamed exactly as a merge would rename them while copying — each prefixed with its own shard, not the row's, because the duplicate policy can back-fill one shard's screenshots onto another shard's record. Attachment lines therefore use the same relative paths the HTML page uses, which is correct whenever this XML sits beside a report merged from the same bundles.

shell Publishing test results without publishing a report
$ pytest-html-reporter junit ./artifacts \
    -o ./results/merged-junit.xml \
    --junit-suite-name payments \
    --junit-logging failed \
    --junit-xpass fail \
    --strict

The six JUnit shaping flags

Shared by merge and junit. An unset flag is dropped rather than imposed: --junit-hostname arrives as an empty string when nobody typed it, and passing that through would blank out the host the shards themselves reported.

FlagDefaultWhat it sets
--junit-suite-namepytestThe <testsuite> name attribute.
--junit-hostnamethe shards' host, else mergedThe hostname attribute — never the machine doing the merging, which ran none of the tests and which Azure would otherwise name as the agent that did.
--junit-xpasspassHow an unexpectedly passing test is written down. pass matches pytest's own --junitxml.
--junit-loggingnoWhich tests carry their captured output. The spelling and the default are pytest's own junit_logging ini key, so a team moving off --junitxml does not have to learn a second vocabulary. failed means FAIL or ERROR.
--junit-attachmentsonWrite [[ATTACHMENT|…]] lines for screenshots, for a CI system that ingests them.
--no-junit-attachmentsLeave the screenshots out.

Four facts about a merged document are settled by the merge itself, whatever the flags say, so that this command and a --report-shard-merge leg cannot drift apart: the timestamp is the earliest shard's start; the time is the whole matrix's wall span; the shard list carries every leg's label and host; and the rerun and fold counts are the merged totals.

Why the span mattersAzure computes the end of a run as timestamp + SUM(testcase@time). Left to the defaults it would read a forty-minute matrix as the few seconds the merge itself took.

inspect — a pre-flight check that writes nothing

Prints one line per shard bundle and the summary of the merge they would produce. The question it answers is "did all four artifacts arrive, and do they overlap" — asked before a merge in CI, and asked by hand when the totals on a merged report were not the totals somebody expected.

It takes PATH ..., the three shaping flags, -q and --json, and nothing else. --strict, --exit-code and --dry-run are not offered: it writes nothing, so a dry run would be a no-op, and it returns 0 whenever bundles were found — even when duplicates or quarantined records were reported.

shell One fixed-width line per bundle, then the summary
$ pytest-html-reporter inspect ./artifacts
1-4                     103 records    0 collect  runner-1         ./artifacts/shard-1/records.json
2-4                     103 records    1 collect  runner-2         ./artifacts/shard-2/records.json
merged 2 shards: 206 tests
  PASS 200, FAIL 6
  1 collection record
  ran on runner-1, runner-2 over 3 mins 04 secs
inspect --json
flag default: off

Print the same thing as a JSON document on stdout, indented and with sorted keys. It carries a bundles array — id, label, path, hostname, platform, python, pytest, exitstatus, session_start, session_end, records, collect, coverage — and a summary object with shards, tests, collects, statuses, reruns, duplicates_folded, quarantined, unreadable, superseded, unrecognised, session_start, session_end, wall, hosts, environment, coverage_notice and notes.

The reason to ask for JSON is that something downstream is going to check four shards arrived and none of them was empty. Notes stay on stderr precisely so this stays a document.

shell Fail the job before merging if an artifact went missing
$ count=$(pytest-html-reporter inspect ./artifacts --json \
    | python -c 'import json,sys; print(json.load(sys.stdin)["summary"]["shards"])')
$ test "$count" -eq 4 || { echo "only $count shards arrived"; exit 1; }

config — what a run would resolve

The one subcommand that reads no bundles. It reads the configuration files a pytest run in this project would read — the ini file and pyproject.toml — and prints every setting somebody named, its value, which layer decided it and which layers it overrode. Nothing is run and nothing is written.

shell Four ways to ask, none of which runs a test
$ pytest-html-reporter config                    # the profile a bare pytest would use
$ pytest-html-reporter config --profile=ci       # as though --report-profile=ci
$ pytest-html-reporter config ../service --all   # another project, defaults included
$ pytest-html-reporter config --json             # the same answer as a document

It exists because six layers is more precedence than anybody holds in their head, and every way of getting it wrong is silent: the run is green and the report is simply not the one that was configured. Answering it in provenance rather than in values is the whole point — the values were never the hard part.

output Which layer decided each setting, and what it overrode
Profile: ci
Files read: pytest.ini, pyproject.toml
Profiles defined: ci, local

Setting      Value             Source
-------------------------------------
path         report/ci         profile 'ci' in pyproject.toml
build_info   branch=hotfix     the environment
                               over profile 'ci' in pyproject.toml
                               over [tool.pytest-html-reporter] in pyproject.toml
build_info   team=payments     [tool.pytest-html-reporter] in pyproject.toml
build_info   lane=nightly      the build_info ini key
logs         all               the environment
                               over profile 'ci' in pyproject.toml
screenshots  failed            [tool.pytest-html-reporter] in pyproject.toml
open         none              profile 'ci' in pyproject.toml
junit        report/junit.xml  profile 'ci' in pyproject.toml

PATH is the project to read and defaults to the working directory. --profile resolves as though --report-profile=NAME had been passed; without it the command resolves the profile a bare pytest would use, including one pinned as the default. --all also lists the settings nobody named, at their defaults, and --json prints the same document for a CI step to assert on rather than a table for a person to read.

NoteEvery value it prints is what gets written onto config.option, which is the copy an xdist worker is handed — so what the table says is what the workers ran with. To ask the same question of a run that is actually happening, pass --report-show-config to pytest instead and read it in the header.

Exit codes and output streams

The summary goes to stdout — it is the answer to the question that was asked. Every per-decision note goes to stderr, prefixed pytest-html-reporter: : a duplicate folded, a record quarantined, an image a bundle promised and did not carry, a file walked past because it was not a bundle. A pipeline that captures one very often wants to discard the other. The summary still prints under -q; only the notes are suppressed, and warnings are still collected so --strict keeps seeing them.

CodeWas anything written?When
0YesThe merge ran and nothing the caller asked to be told about happened.
1Yes — always with a report--strict and the merge was not clean (quarantined records, unreadable files, duplicate folds, missing screenshots, superseded bundles, JUnit warnings); or --exit-code and the build has a failure or error; or, on merge, --junit-xml could not be written after the report was already on disk.
2NoAn argparse usage error (unknown flag, missing subcommand, missing --html-report or -o); an unusable --html-report, --start-time or retention value; no bundles under the given paths; a bundle written by a newer version; --on-duplicate error finding a duplicate; or, on junit, the XML could not be written.
--strict vs --exit-code
flags both default off merge, junit

They answer two different questions and are deliberately separate. --strict is a verdict about the merge being complete; --exit-code is a verdict about the tests. Either way the report is written: a merge that exits 1 has still produced the page that explains why.

shell Publish the report either way, but fail the job on either count
$ pytest-html-reporter merge ./artifacts --html-report ./report --strict --exit-code
$ echo $?

python -m pytest_html_reporter

An exact alias for the console script, flag for flag and exit code for exit code. It exists because the console script is only on PATH once the package has been installed with its entry points, and the everyday merge runs in a CI container that pip-installed a wheel, a tox environment, or a checkout somebody is trying the feature out in. python -m works in all three, so a pipeline never has to find out which one it is in.

shell
$ python -m pytest_html_reporter merge ./artifacts --html-report ./report
$ python -m pytest_html_reporter inspect ./artifacts --json
$ python -m pytest_html_reporter --version

Entry points and dependencies

Two entry-point groups, from setup.py. The pytest11 one is what makes the plugin load automatically wherever the package is installed. The console_scripts one is the standalone command above.

python setup.py
entry_points={
    "pytest11": [
        "reporter = pytest_html_reporter.plugin",
    ],
    "console_scripts": [
        "pytest-html-reporter = pytest_html_reporter.cli:main",
    ],
}

install_requires is pytest and Pillow, neither pinned, and python_requires is >=3.7, raised from >=3.5 in 0.4.1 because the async step machinery is contextvars. coverage is the one optional dependency, imported lazily and only by --coverage-data. Everything else the plugin integrates with — pytest-xdist, pytest-rerunfailures, pytest-bdd, pytest-cov — is detected at runtime and never declared.

Environment variables

One set of variables is the plugin's own, and it is an override rather than a configuration file. The other four are set by other things — the CI system, the desktop session — and are read to answer questions about where the run is happening, plus whatever the shell puts into a path.

Overriding a setting (PYTEST_HTML_REPORTER_*)

New in 0.4.3Every setting a profile can carry also answers to a PYTEST_HTML_REPORTER_ variable, under both its short name and its ini spelling: PYTEST_HTML_REPORTER_LOGS and PYTEST_HTML_REPORTER_REPORT_LOGS reach the same option. PYTEST_HTML_REPORTER_PROFILE selects the profile itself.
shell One job, without editing what everyone else reads
$ PYTEST_HTML_REPORTER_PROFILE=ci pytest
$ PYTEST_HTML_REPORTER_LOGS=all pytest --report-profile=ci
$ PYTEST_HTML_REPORTER_JUNIT=out/junit.xml pytest
$ PYTEST_HTML_REPORTER_BUILD_INFO="commit=$GITHUB_SHA" pytest

It sits above the profile and below the command line, which is what an override is for: the profile is what the repository committed, and the variable is one job, one machine or one debugging session saying otherwise without editing a file everybody else reads. A variable set to nothing is not an answer — a matrix leg that left PYTEST_HTML_REPORTER_JUNIT= blank means "I am not saying", not "write no xml" — and a list setting takes one entry per line.

A value that the flag would have refused fails the run at configure time, exactly as one written into a profile does. Configuration lists the 27 settings and the spellings each answers to.

Is this a build agent? (--report-open=auto)

Seventeen variables, and any of them being set to something other than a no-value makes the run count as CI, which stops auto from opening a browser:

CI, CONTINUOUS_INTEGRATION, BUILD_ID, BUILD_NUMBER, GITHUB_ACTIONS, GITLAB_CI, JENKINS_URL, HUDSON_URL, TEAMCITY_VERSION, TF_BUILD, CIRCLECI, TRAVIS, BUILDKITE, APPVEYOR, DRONE, BITBUCKET_BUILD_NUMBER, CODEBUILD_BUILD_ID.

A variable set to "", 0, false, no or off counts as not CI. CI=false is a real thing to write, and reading it as "this is CI" gets it exactly backwards.

Which build was this? (the Environment panel)

New in 0.4.2A second, larger set of variables fills in the panel's CI, Pipeline, Branch and Commit rows. This is a different question from the one above — that one asks only is this a build agent, and this one asks which build.

The system is identified by the variable that actually names it, tested in this order, and the first that answers wins:

SystemIdentified byPipeline URL from
GitHub ActionsGITHUB_ACTIONSAssembled from GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID and GITHUB_RUN_ATTEMPT
GitLab CIGITLAB_CICI_PIPELINE_URL, else CI_JOB_URL
CircleCICIRCLECICIRCLE_BUILD_URL
BuildkiteBUILDKITEBUILDKITE_BUILD_URL
Azure PipelinesTF_BUILDAssembled from the collection URI, project and build id
Travis CITRAVISTRAVIS_BUILD_WEB_URL
AppVeyorAPPVEYORAssembled from the account, project slug and build id, on APPVEYOR_URL
DroneDRONEDRONE_BUILD_LINK
Bitbucket PipelinesBITBUCKET_BUILD_NUMBERAssembled from the repository and build number
SemaphoreSEMAPHOREAssembled from SEMAPHORE_ORGANIZATION_URL and SEMAPHORE_WORKFLOW_ID
AWS CodeBuildCODEBUILD_BUILD_IDCODEBUILD_PUBLIC_BUILD_URL
TeamCityTEAMCITY_VERSIONBUILD_URL
JenkinsJENKINS_URL, else HUDSON_URLBUILD_URL, else the job's own page — a self-hosted controller with no root url configured sets the pieces and not the url
Anything elseCI, else CONTINUOUS_INTEGRATIONBUILD_URL

Jenkins is tested last on purpose. BUILD_NUMBER and BUILD_URL are set by several of the systems above, and JENKINS_URL is what actually says Jenkins. Where a system publishes its own build url that url is used as given rather than assembled, so a self-hosted install or a reverse proxy is linked correctly; the rest are built from the documented variables. A GitHub Actions re-run links its own attempt, because GITHUB_RUN_ID does not change on a re-run and the bare run url would open the latest one.

Branch is read from the first of GITHUB_HEAD_REF, GITHUB_REF_NAME, CI_COMMIT_REF_NAME, CIRCLE_BRANCH, BUILDKITE_BRANCH, BUILD_SOURCEBRANCHNAME, TRAVIS_PULL_REQUEST_BRANCH, TRAVIS_BRANCH, APPVEYOR_REPO_BRANCH, DRONE_BRANCH, BITBUCKET_BRANCH, SEMAPHORE_GIT_BRANCH, GIT_BRANCH and BRANCH_NAME that is set, and Commit from GITHUB_SHA, CI_COMMIT_SHA, CIRCLE_SHA1, BUILDKITE_COMMIT, BUILD_SOURCEVERSION, TRAVIS_COMMIT, APPVEYOR_REPO_COMMIT, DRONE_COMMIT_SHA, BITBUCKET_COMMIT, SEMAPHORE_GIT_SHA, CODEBUILD_RESOLVED_SOURCE_VERSION, GIT_COMMIT and BUILD_VCS_NUMBER. Only when none of them answers is git asked — a CI checkout is a detached HEAD, where git itself answers HEAD, which is true and useless.

NoteNone of this overrules you. A branch, commit, ci or pipeline already named through --build-info or the build_info ini key is the answer shown, and the detected value is dropped rather than rendered beside it disagreeing.

Is there a screen? (--report-open=auto)

On macOS and Windows a graphical session is assumed. Everywhere else DISPLAY or WAYLAND_DISPLAY has to be set.

Which CI run is this? (--report-shard-run)

Consulted in this order when no token is given. Each entry's first variable has to be set for that entry to answer; the rest are appended when present. The token is written as <system>:<values joined by ->, so Jenkins build 41 and Drone build 41 cannot collide.

SystemVariablesWhy that one
githubGITHUB_RUN_ID + GITHUB_RUN_ATTEMPTThe run id does not change when a workflow is re-run, so the attempt number is carried beside it — a re-run must not answer the token its first attempt's bundles are stamped with.
gitlabCI_PIPELINE_IDUnique across the whole instance.
jenkinsBUILD_TAGjenkins-${JOB_NAME}-${BUILD_NUMBER}, preferred to the bare number.
jenkinsBUILD_NUMBERThe fallback; it only counts within one job.
circleciCIRCLE_WORKFLOW_IDThe same for every job in a workflow instance — which is precisely the matrix.
buildkiteBUILDKITE_BUILD_IDThe build's uuid.
azureBUILD_BUILDIDHow Build.BuildId reaches a script.
travisTRAVIS_BUILD_IDThe build, not the job.
appveyorAPPVEYOR_BUILD_IDThe build, not the job.
droneDRONE_BUILD_NUMBERThe build number within the repository.
teamcityBUILD_VCS_NUMBERA last resort — it is a revision, not a run id, so two runs of the same commit share it.

Variables inside a path

--html-report expands ~ and $VARS when it resolves the report folder, so --html-report="$RUNNER_TEMP/report" works even where the shell would not have expanded it. PATH ... on the merge command does the same.

Third-party options that change what the report can show

These are pytest's, pytest-cov's and pytest-xdist's own flags, not this plugin's. They are here because each one changes what the report is able to display, and every one of them is a common cause of "the report is missing something".

OptionFromWhy it matters to the report
--capture / -spytestfd (the default) keeps everything, including subprocess and C-extension output; sys keeps everything Python writes; tee-sys keeps it and prints it live. no — which is what -s means — keeps logging only, so stdout and stderr are gone from the Logs column and the report says so in a banner.
--log-levelpytestUnset means WARNING and above. Lower it to INFO or DEBUG to get log.info() and log.debug() into the Logs column at all.
--covpytest-covThe import name or path of the code under test — your package, not your tests. A wrong value here is the usual cause of an empty Coverage tab.
--cov-branchpytest-covFolds branch coverage into the percentage exactly as pytest-cov folds it in, and adds a Branches column to the coverage table.
--cov-fail-underpytest-covWhen set, it becomes the line the coverage ring's colour is drawn at, replacing the 90/75 grade bands — and the tab says that is what happened.
--cov-report=htmlpytest-covWrites htmlcov/. The Coverage tab then links to it — never embeds it — and only when the folder was written by this run.
-n / --distpytest-xdistWorkers are merged into one report, one build in Archives, one row per test, listed in collection order. The controller writes the report; the workers never do.

The same settings in an ini file

Twenty-seven of these twenty-nine options have an ini key, so a project can settle them once instead of typing them on every run. The keys are read wherever pytest reads ini options: [pytest] in pytest.ini or tox.ini, [tool:pytest] in setup.cfg, and [tool.pytest.ini_options] in pyproject.toml.

Configuration lists every key with its type and its effective default, including the three — build_info, report_link and report_link_pattern — that are line lists and combine with the flag rather than being overridden by it.