pytestHTML Reporter
Home Docs Configuration
Reference

Configuration

Every setting has three homes: the command line, an ini file, and — since 0.4.3 — a named profile that holds a whole shape of a run under one word. This page lists all 29 flags and all 27 ini keys, says which of the six layers wins, names the values that fail the run rather than falling back, and ends with three configurations you can copy.

Where configuration lives

The plugin adds 29 command-line options to pytest, under the option group report generator, and registers 27 ini keys. The ini keys are read wherever pytest reads ini options:

There is no third file: everything below lives in a file pytest already opens. Since 0.4.3 the plugin also reads sections of its own in those same files — [tool.pytest-html-reporter] and a table per named profile — which is a layer above these keys and below the command line. The four blocks below are the same configuration written four ways.

shell Everything on one command
$ pytest tests/ \
    --html-report=./report \
    --environment=staging \
    --build-info branch=main \
    --build-info team=payments \
    --archive-count=20 \
    --archive-days=30 \
    --report-logs=failed \
    --report-open=none \
    --report-link 'Coverage=htmlcov/index.html'

Every ini key is a plain string key with an empty default, except build_info, report_link and report_link_pattern, which are declared as pytest linelists: one entry per line in an ini file, a list of strings in TOML. Numbers are quoted in TOML because the keys are strings, not integers.

addopts, and the one flag with no ini key

--title is the only option in the group without an ini key. To set it in a file, put it in pytest's own addopts.

ini The file route for --title
[pytest]
addopts = -v -rf --title='Payments regression'
html_report = ./report
Carefuladdopts is prepended to the command line, so anything written there is a command-line value. addopts = --report-logs=all beats report_logs = failed in the very same file. Pick one route per setting.

Precedence — what wins

When settings are resolved

Everything is settled in pytest_configure, before the first test runs. A bad value therefore fails the run at the start rather than after an hour of tests. Two answers are resolved once and written back onto the config, because a pytest-xdist worker is a separate process handed a copy of the options and has to agree with the controller:

Invalid values: what fails and what falls back

The two routes are not validated the same way. An out-of-range value on the command line is rejected by pytest's own argument parser, because those options declare their choices. An out-of-range value in an ini file is handled per setting, and the split is deliberate: a setting whose default is the answer you were trying to avoid raises, and a setting whose default is harmless falls back.

Bad ini value inResult
report_logs, report_attachments, report_screenshots, report_steps, report_coverageSilently falls back to the effective default (all, all, failed, all, auto).
report_log_limit, report_attachment_limit, report_step_limit, report_coverage_limitA non-numeric value silently falls back to 10000, 20000, 500 and 500.
report_openpytest.UsageError. Somebody who wrote report_open = off has said they do not want a browser; opening one anyway is the single outcome they were avoiding.
report_junit_xpasspytest.UsageError, for the same reason: the point of setting it is disagreeing with pass.
archive_count, archive_days, archive_sincepytest.UsageError, naming the value it could not read. A retention limit that silently did nothing would grow the report for months before anyone noticed.
Screenshot: assets/img/shots/terminal-report-open-usage-error.png A terminal, dark theme, ~900px wide, showing a pytest run started with report_open = off in pytest.ini and exiting immediately with ERROR: --report-open takes auto, always or none, not 'off' — no test output above it, which is the point: the run stopped before collection.

Limits, zero, and negative numbers

All four numeric limits — report_log_limit, report_attachment_limit, report_step_limit, report_coverage_limit — mean no limit at 0, not "keep nothing". A negative number is clamped to zero, so it also means unlimited. To keep nothing, use the mode beside the limit: report_logs = none, report_attachments = none, report_steps = none.

Date placeholders and shell variables

Two settings run through strftime: html_report and report_junit. Only the directives strftime documents are substituted (%Y %m %d %H %M and the rest of that set); %% becomes a literal percent, and any other %X is left alone, so a folder named 100% pass survives being expanded. Expansion happens once, at configure time.

shell One folder a day, one file a minute
$ pytest tests/ --html-report=./reports/%Y%m%d/report_%H%M.html

~ and $VARS are expanded in the report folder and in report_coverage_file. Nothing else in an ini file is expanded — a build_info line reading commit=$GITHUB_SHA reaches the report as those eleven characters, not as the commit. Pass a variable through the flag instead, where the shell expands it before pytest sees it:

shell The shell expands it; the ini file would not
$ pytest tests/ --build-info commit="$GITHUB_SHA" --build-info run="$GITHUB_RUN_ID"

Because the two sources add rather than replace, the fixed rows can stay in the ini file and only the moving ones need to be on the command line.

Screenshot: assets/img/shots/report-environment-panel-settings.png The Environment panel on the Dashboard tab, light theme, ~700px wide, cropped to the panel. It must show the Environment row reading staging, at least three build_info rows (branch, team, commit) and the Captured output row reading something like all tests: stdout, stderr and logging, logging from WARNING — this panel is how a reader checks what the run actually resolved.

Named configuration profiles

New in 0.4.3A profile is a set of these settings written down under a name — local, ci, nightly — and chosen in one word with --report-profile=ci. It is a layer above the plain ini keys and below the command line, and a run that names no profile resolves byte for byte the way it did before.

A suite is run in more than one shape. On a laptop you want the browser to open, every log kept and a handful of builds in the archive; on CI you want no browser, logs only where something failed, a JUnit xml beside the report and a month of history. That was two long command lines living in two places — a Makefile target and a workflow file — which drift the moment one of them is edited, and the drift is invisible until somebody reads a report missing the thing they went looking for.

A profile is those two shapes written down once, under a name:

toml A table per profile, under [tool.pytest-html-reporter.profiles]
[tool.pytest-html-reporter.profiles.local]
open = "auto"
logs = "all"
screenshots = "failed"
archive_count = 10

[tool.pytest-html-reporter.profiles.ci]
open = "none"
logs = "failed"
screenshots = "failed"
junit = "report/junit.xml"
archive_days = 30

Both files are read: the one pytest chose as this run's configuration file first, then pyproject.toml in the rootdir. A repository that keeps a pytest.ini therefore does not have to grow a pyproject.toml to say ci in a single word. When both define the same name, the ini file's definition is the one used, whole rather than merged — half a profile from each file would be a shape nobody composed, and which half came from where would depend on which file pytest happened to pick.

The underscore spelling of the distribution name works in either file, since pytest_html_reporter is what half of everybody types first. In a setup.cfg, where pytest's own section is [tool:pytest], the sections may be written [tool:pytest-html-reporter.profiles.ci] to match.

Choosing one

Four routes, highest first. The first two name a profile for one run; the last two pin the one a bare pytest uses, so the shape the repository agreed on is what happens by default.

shell This run, whatever the files pin
$ pytest --report-profile=ci
$ pytest --report-profile=none          # this run uses no profile at all

--report-profile=none is how a single run opts back out of a pinned default — which is why no profile can be called none. The name is matched case-insensitively, so --report-profile=CI finds ci, and the report shows the name as it was written down rather than as it was typed.

NoteA name that is not defined fails the run at configure time, lists the profiles that are defined and offers the nearest one — rather than running the suite for forty minutes under settings nobody chose.

Settings every profile shares

What is true of every shape of a run — the title, whether the Coverage tab is built — goes in the tool table itself, and a profile then says only what it changes. That table also applies to a run naming no profile at all, so it is a layer rather than something that only sometimes exists.

toml Everything true of every shape of the run
[tool.pytest-html-reporter]
title = "PAYMENTS"
coverage = "auto"
links = { Coverage = "htmlcov/index.html" }

[tool.pytest-html-reporter.profiles.ci]
open = "none"
logs = "failed"

Overriding from the environment

Every setting has 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 — and it sits above the profile.

shell One job saying otherwise, without editing a committed file
$ 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
$ PYTEST_HTML_REPORTER_PROFILE=ci pytest

That order 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 — PYTEST_HTML_REPORTER_JUNIT= in a matrix leg that left its value blank means "I am not saying", not "write no xml". A list takes one entry per line.

What wins, all six layers

Highest first. Everything a profile settles is written onto the very option the flag writes onto, which is why nothing else in the plugin had to learn that profiles exist — and why an xdist worker, handed a copy of those same options, is shaped by the same profile the controller was.

  1. a flag on the command line — including one reached through addopts;
  2. a PYTEST_HTML_REPORTER_* environment variable;
  3. the selected profile;
  4. the [tool.pytest-html-reporter] table every profile shares;
  5. the plain ini key in [pytest];
  6. the option's own default.

A profile is a set of overrides rather than a replacement configuration: everything it is silent about goes on being answered exactly as it was, which is what makes adopting one cheap. build_info, links and link_patterns are the exception and add up rather than replace, the way --build-info has always added to the ini key, so a profile wanting one more row does not restate the rows that were already there.

A label named by more than one layer is one row, and it is the highest layer's answer — at every layer of the list above, not only against the ini key:

toml The repository's rows, and the profile's own
[tool.pytest-html-reporter]
build_info = { branch = "main", team = "payments" }

[tool.pytest-html-reporter.profiles.ci]
build_info = { branch = "release" }

That shows branch = hotfix and team = payments: the label three layers argued over takes the highest answer, and the row nobody argued over is kept. The row also stays where the layer that introduced it put it, so overriding a value does not reorder the panel. Before 0.4.3 the obvious way to write a profile — copy a block of ini keys under a name and leave the originals where they are — rendered every one of those rows twice, and a report_link_pattern in the ini file quietly took back a marker the command line had just claimed.

The settings a profile can carry

All 27 — every option in the group except --report-profile and --report-show-config, which choose the profile rather than being chosen by it. The ini spelling of each works as well as the short one, so a block of ini keys can be moved under a name without being rewritten.

In a profileOn the command lineValue
path--html-reporthtml_report and report also name it
title--titletext — the one setting with no ini key of its own
open--report-openauto | always | none
logs--report-logsall | failed | none
log_limit--report-log-limitinteger, 0 is no limit
attachments--report-attachmentsall | failed | none
attachment_limit--report-attachment-limitinteger, 0 is no limit
screenshots--report-screenshotsfailed | all | none
steps--report-stepsall | failed | none
step_limit--report-step-limitinteger, 0 keeps every one
coverage--report-coverageauto | none
coverage_file--report-coverage-filepath
coverage_limit--report-coverage-limitinteger, 0 lists every file
environment--environmenttext
build_info--build-infoa list, or a table of key = "value"added to
links--report-linka list, or a table of Label = "url"added to
link_patterns--report-link-patterna list, or a table of marker = "url"added to
archive_count--archive-countinteger
archive_days--archive-daysnumber of days
archive_since--archive-sincedate or datetime
junit--report-junitpath
junit_xpass--report-junit-xpasspass | fail | skip
packages--report-packagestrue / false
shard--report-shardid, e.g. 1/4
shard_merge--report-shard-mergetrue / false
shard_run--report-shard-runtoken
shard_reset--report-shard-resettrue / false

A wrong value fails the run, not the report

A profile bypasses argparse entirely, so every check the command line would have made is made when the profile is applied — at configure time, before a test is collected, with one line naming the file:

output Three ways to be told at configure time instead of after the suite
ERROR: profile 'ci' in pyproject.toml: logs takes all, failed, none, not 'fail'
ERROR: profile 'ci' in pyproject.toml: unknown setting 'screenshot'. Did you mean 'screenshots'?
ERROR: --report-profile=cli: no such profile. Defined: ci, local. Did you mean 'ci'?

Without that check, logs = "fail" would land on the option, fail the helper's own test for a value it knows and fall through to all: a run that kept every log because six letters were typed instead of seven, and a report nobody can tell was misconfigured. An unknown key fails the same way and offers the nearest name; an unknown profile lists the ones that are defined.

Seeing what a run resolved

Six layers is more precedence than anybody holds in their head, and getting it wrong is silent: the run is green and the report is simply not the one that was configured. Two commands print the answer in provenance rather than in values — which layer decided each setting, and which layers it overrode.

shell Resolves the files and prints the table, running nothing
$ pytest-html-reporter config --profile=ci

--profile is optional; 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 thing as a document for a CI step to assert on. Every value shown 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. See the CLI reference for both.

TipThe report says which profile drew it. The Environment panel carries a Profile row naming it, so a report found on a CI server months later answers "where did the logs go" with "this was built with ci, which keeps them only on failures" — which is the same fact. See the Environment dialog.

Every ini key

All 27, with the value you get when the key is absent. Each one mirrors the flag of the same name with underscores for dashes; the long-form explanation of what each does lives in the CLI reference.

KeyTypeEffective defaultWhat it does
report_profilestringempty — no profileThe named profile a bare pytest uses, e.g. ci. New in 0.4.3, and mirrors --report-profile; everything the profile sets overrides the plain keys in this same file.
html_reportpath../pytest_html_report.htmlWhere the build is written. A value containing .html names the file; anything else names a folder and the file is pytest_html_report.html. Date placeholders expanded.
archive_countinteger as textempty — every build keptBuilds kept in Archives. Empty and 0 are different answers: empty keeps everything, 0 deletes archive/ and drops the Archives section entirely, and N keeps N-1 files plus the build being written.
archive_daysnumberno age limitKeep only builds run in the last N days; fractions allowed, 0.5 is twelve hours.
archive_sincedate or datetimeno cutDelete every archived build older than this. Read as YYYY-MM-DD or YYYY-MM-DD HH:MM, in local time.
environmentstringemptyNames the environment under test. Adds an Environment row and a badge cut at 10 characters.
build_infolinelistemptyExtra KEY=VALUE rows for the Environment panel, one per line. Added to whatever --build-info gave. Split on the first =.
report_logsall | failed | noneallWhose captured stdout, stderr and logging is kept.
report_log_limitinteger10000Characters of output kept per test. The end survives, cut back to a whole line, with a note saying how much was dropped. 0 keeps everything.
report_attachmentsall | failed | noneallWhose attachments — the payloads handed to attach_text, attach_json, attach_file and attach_api — are kept.
report_attachment_limitinteger20000Characters kept per payload. The start survives, which is where a response puts its status and its error field. 0 keeps everything.
report_screenshotsfailed | all | nonefailedWhen a live Selenium driver or Playwright page is photographed without the suite asking. An image handed to attach() is kept whatever this says. See Screenshots.
report_stepsall | failed | noneallWhose step trees are kept — both step() and a pytest-bdd scenario's Given/When/Then. See Steps and BDD.
report_step_limitinteger500Steps recorded per test, so a step inside a ten-thousand-row loop cannot write ten thousand lines into the page. 0 keeps every one.
report_coverageauto | noneautoWhether the Coverage tab is built from whatever coverage this run produced.
report_coverage_filepathempty — go lookingRead coverage from this file — a coverage.json, a Cobertura coverage.xml or a .coverage data file — instead of discovering one.
report_coverage_limitinteger500Files listed on the Coverage tab, least-covered first. 0 lists every one.
report_linklinelistemptySide-nav entries, one LABEL=URL per line. Added to whatever --report-link gave. Relative paths are kept; a scheme that is not http://, https:// or mailto: is dropped.
report_link_patternlinelistemptyTurns a marker into a link on the test it is written on, one MARKER=URL per line, where {} is the marker's argument — jira = https://acme.atlassian.net/browse/{}. Added to whatever --report-link-pattern gave. The argument is percent-encoded, a template with no {} is a fixed destination, and the same scheme sieve as report_link applies. Naming a marker here also writes it into the JUnit xml and registers it with pytest. See Traceability markers.
report_openauto | always | noneautoWhether the finished report is handed to a browser. auto needs an interactive terminal, no CI variable and a graphical session. A fourth value fails the run.
report_packagesboolean textfalseWhether the Environment panel carries a Packages row listing every installed distribution and its version, the way pip freeze reads. Truthy values are 1, true, yes, on. See the packages inventory below.
report_shardstringempty — not a shardNames this run as one leg of a sharded matrix, e.g. 1/4. The leg writes a bundle under <report>/shards/ and no report of its own.
report_shard_mergeboolean textfalseWhether this leg also merges every bundle beside it and renders one report. Truthy values are 1, true, yes, on; anything else is false. Needs report_shard too.
report_shard_runstringempty → detected from CINames the CI run this leg belongs to, so a merging leg merges only this run's bundles.
report_shard_resetboolean textfalseWhether this leg deletes <report>/shards before writing into it. Same truthy values. It deletes the other legs' work, so it is never implied.
report_junitpathempty — no XMLAlso write a JUnit XML of this run to this path. Date placeholders expanded. Ignored on a shard that is not also the merge leg.
report_junit_xpasspass | fail | skippassHow an xpassed test is written to the XML. pass is what pytest's own --junitxml does. A fourth value fails the run.
NoteIni values are read defensively: a pytest build where a key was never registered, and the standalone pytest-html-reporter merge command, which has no ini file at all, both fall through to the option defaults instead of raising.
Screenshot: assets/img/shots/report-sidenav-custom-links.png The report's left side nav, dark theme, ~320px wide, cropped to the nav column, showing the built-in tabs and two entries added by report_link — "Coverage" pointing at htmlcov/index.html and "CI job" pointing at an external URL — so the reader can see where a LABEL=URL line lands.

The packages inventory

--report-packages, or report_packages in the ini file, adds one more row to the Environment panel: Packages, carrying every installed distribution and its version the way pip freeze reads them, with the count in the label — Packages (214). Names are sorted case-insensitively and a distribution whose metadata cannot be read is skipped rather than costing the other two hundred.

ini Set it once for the job rather than per run
[pytest]
report_packages = true

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 above it answers a much smaller question, and the library whose new minor version broke the suite last night is almost never a pytest plugin. See Security & privacy for what else the panel already carries.

Listing what was installed is a property of the job rather than of one run — either every build of this suite should carry the inventory or none of them should — so the ini file is usually where it belongs. It is a switch rather than a value: the flag turns it on and so does a truthy key, and there is no flag that turns off an ini file which has already said yes.

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.

Every command-line flag

All 29, with the ini key each one overrides. The CLI reference has the long-form entry for each, including the standalone pytest-html-reporter merge, junit and inspect commands, which are a separate surface.

FlagValueDefaultIni key
--report-profilename, or nonethe pinned default, else no profilereport_profile
--report-show-configflagoffnone — it prints, it does not set
--html-reportfolder or .html path./pytest_html_report.htmlhtml_report
--titlestringPYTEST REPORTnone — use addopts
--environmentstringunsetenvironment
--build-infoKEY=VALUE, repeatablenonebuild_info — added to
--report-linkLABEL=URL, repeatablenonereport_link — added to
--report-link-patternMARKER=URL, repeatablenonereport_link_pattern — added to
--report-openauto | always | noneautoreport_open
--report-packagesflagoffreport_packages
--archive-countintegerevery build keptarchive_count
--archive-daysnumber of daysno age limitarchive_days
--archive-sincedate or datetimeno cutarchive_since
--report-logsall | failed | noneallreport_logs
--report-log-limitinteger, 0 = no limit10000report_log_limit
--report-attachmentsall | failed | noneallreport_attachments
--report-attachment-limitinteger, 0 = no limit20000report_attachment_limit
--report-screenshotsfailed | all | nonefailedreport_screenshots
--report-stepsall | failed | noneallreport_steps
--report-step-limitinteger, 0 = no limit500report_step_limit
--report-coverageauto | noneautoreport_coverage
--report-coverage-filepathdiscover onereport_coverage_file
--report-coverage-limitinteger, 0 = all files500report_coverage_limit
--report-shardid, e.g. 1/4not a shardreport_shard
--report-shard-mergeflagoffreport_shard_merge
--report-shard-runtokendetected from CIreport_shard_run
--report-shard-resetflagoffreport_shard_reset
--report-junitpathno XMLreport_junit
--report-junit-xpasspass | fail | skippassreport_junit_xpass
NoteThe plugin registers itself on every run and --html-report defaults to ., so a plain pytest with the package installed writes ./pytest_html_report.html without being asked. Point html_report at a folder to keep it out of the repository root.

pytest's own options that change the report

These belong to pytest and pytest-cov, not to this plugin, but the reporter can only show what those hand it. Four of the five most common "why is this empty" answers are here.

Capture — the usual cause of an empty Logs column

The plugin reads pytest's own capture option and says what it found in the Environment panel. Under -s there is nothing any reporter can do: pytest never takes stdout and stderr in, they go straight to the terminal.

--captureWhat reaches the Logs column
fd defaultEverything, including output written by subprocesses and C extensions.
sysEverything Python itself writes; a subprocess's output is not captured.
tee-sysAs sys, and it still prints live to the terminal while the run goes.
no (same as -s)Logging only. stdout and stderr are gone, and the report says so in a banner above the Test Metrics table.

If you were running with -s to watch output go past, --capture=tee-sys is the replacement that gives you both. Stay on -s if you drop into pdb.

Screenshot: assets/img/shots/report-logs-notice-capture-off.png The top of the Test Metrics tab after a run with -s, light theme, ~1000px wide, cropped to the notice and the first three table rows. The banner must be legible — "stdout and stderr are not captured while pytest runs with -s / --capture=no, so only logging output reaches this column" — with the Logs column showing dashes beneath it.

Log level

Logging is captured from WARNING up until something lowers it, so log.info(...) and log.debug(...) are not in the report until you say so. The plugin reads both the flag and pytest's log_level ini key, and prints whichever it found in the Environment panel's Captured output row.

shell
$ pytest tests/ --html-report=./report --log-level=INFO

pytest-cov, and what the Coverage tab reads

OptionEffect on the report
--cov=my_packageWithout it nothing measures coverage and the tab shows its setup guide. Point it at the code under test, not at the tests — a --cov=src against a project with no src directory measures nothing, and the tab says so, naming the --cov that matched nothing.
--cov-branchBranch coverage is folded into the percentage exactly as pytest-cov folds it in, Branches and Partial tiles join the summary, and the table's Branches column fills in with counts instead of dashes.
--cov-fail-under=80Becomes the line the coverage ring is coloured at, replacing the default 90/75 bands, and the tab says so.
--cov-report=htmlWrites htmlcov/, and the tab then offers a relative link to the annotated source — never an embed, so the report stays one file. The link appears only when the folder was written after this run started, so a stale htmlcov from last week cannot masquerade as fresh.
--cov-report=jsonWrites coverage.json, which is the first thing discovery looks for. Name a file explicitly with --report-coverage-file when it lives somewhere else.

pytest-xdist

-n and --dist change nothing about the configuration above. Every worker sends its records back to the controller, which writes one report, one build in Archives and one row per test, in collection order. The report path and the shard settings are resolved once and handed to the workers so all of them agree. See CI integrations for the sharded case, where separate machines each write a bundle and one job merges them.

Three configurations worth copying

The first two are the same suite in two shapes, which is exactly what a named profile is for: written as local and ci in one file, they stop drifting apart and become one word on the command line. They are given here as plain ini files because that is what a profile is a set of — moving a block of these keys under a name needs nothing rewritten.

A CI build

Failures are what anybody opens a CI report for, so the passing tests give up their logs, attachments and steps. The XML is there for the build system's own test view; the HTML is the artifact people read.

ini pytest.ini, committed to the repository
[pytest]
addopts = -q -rf --capture=tee-sys --title='Payments regression'
html_report = ./report
report_open = none
report_logs = failed
report_attachments = failed
report_steps = failed
report_screenshots = failed
report_junit = ./report/junit.xml
archive_days = 30
report_link =
    Coverage=htmlcov/index.html
report_link_pattern =
    jira = https://acme.atlassian.net/browse/{}
shell The command — only the values that move are on it
$ pytest tests/ -n auto \
    --cov=my_package --cov-report=html --cov-fail-under=80 \
    --environment=staging \
    --build-info branch="$GITHUB_REF_NAME" \
    --build-info commit="$GITHUB_SHA" \
    --build-info run="$GITHUB_RUN_ID"

Notes on the choices, in the order they appear:

CI integrations has the full workflow files, and the GitHub Action wraps this into a step.

A local suite

The opposite trade: keep everything, and let the report open itself when the run ends.

ini pytest.ini for a suite you run all day
[pytest]
addopts = -v -rf --capture=tee-sys --title='Checkout suite'
log_level = INFO
html_report = ./report
report_open = auto
report_logs = all
report_screenshots = all
report_steps = all
archive_count = 20
TipKeep html_report pointed at one stable folder. Archives, trends and each test's history live in the folder the report is written to, so a dated path like ./reports/%Y%m%d/report_%H%M.html starts a fresh history every day — right for a nightly job that publishes each run on its own, wrong for a suite whose trend line you want to watch.

Keeping the HTML file small

The report is one self-contained file, so everything it keeps is weight. Four things drive the size: retained builds, captured output, attachments and step trees. A retained build costs roughly 5KB of the page, so an hourly run with no retention limit reaches a multi-megabyte report inside a couple of months.

ini Every size lever, turned down but not off
[pytest]
html_report = ./report
archive_count = 10
report_logs = failed
report_log_limit = 2000
report_attachments = failed
report_attachment_limit = 5000
report_steps = failed
report_step_limit = 100
report_screenshots = failed
report_coverage_limit = 100
If it is still too bigSetWhat you lose
History is the bulk of itarchive_count = 0The Archives section goes entirely, archive/ is deleted, and Analytics and the trend chart have nothing to read.
A few chatty tests dominatereport_log_limit = 500Only the last 500 characters of each test's output, cut to a whole line, with a note saying how much went.
Screenshots are the weightreport_screenshots = noneNo automatic photographs. Images you handed to attach() yourself are still kept — this only governs the ones nobody asked for.
Nothing reads the outputreport_logs = noneThe Logs column entirely, and no size cost at all. The Environment panel says disabled (--report-logs=none) so a reader is not left guessing.
The coverage table is longreport_coverage = noneThe Coverage tab, and the coverage entry in output.json, so no coverage delta on the next build either.

Attachments and step trees are held outside the metrics table, so trimming them does not change the search box or the CSV, Excel and print exports — those read the table only.