[{"data":1,"prerenderedAt":999},["ShallowReactive",2],{"page-\u002Fadvanced-pytest-architecture-configuration\u002Fbuilding-custom-pytest-plugins\u002Fpackaging-a-pytest-plugin-with-entry-points\u002F":3},{"id":4,"title":5,"body":6,"description":965,"extension":966,"meta":967,"navigation":117,"path":995,"seo":996,"stem":997,"__hash__":998},"content\u002Fadvanced-pytest-architecture-configuration\u002Fbuilding-custom-pytest-plugins\u002Fpackaging-a-pytest-plugin-with-entry-points\u002Findex.md","Packaging a pytest Plugin with Entry Points",{"type":7,"value":8,"toc":954},"minimark",[9,18,23,58,62,69,79,178,189,264,267,289,420,424,437,441,444,487,490,493,594,598,609,628,631,635,638,648,699,710,731,738,814,818,872,876,885,895,909,913,944,950],[10,11,12,13,17],"p",{},"A plugin that lives in ",[14,15,16],"code",{},"conftest.py"," works only in the directory tree that contains it. The moment a second repository needs the same markers, fixtures or reporting hooks, the copy-paste starts — and the two copies diverge within a quarter. Packaging turns the plugin into a dependency: one version, installed where it is needed, discovered automatically by every pytest run in that environment.",[19,20,22],"h2",{"id":21},"prerequisites","Prerequisites",[24,25,26,46],"ul",{},[27,28,29,33,34,37,38,41,42,45],"li",{},[30,31,32],"strong",{},"pytest 7.0+",", ",[30,35,36],{},"pip 23+"," and a PEP 621 ",[14,39,40],{},"pyproject.toml","; the entry-point group name has been ",[14,43,44],{},"pytest11"," since pytest 2.",[27,47,48,49,51,52,57],{},"A plugin module whose hooks already work from a ",[14,50,16],{},", as built in ",[53,54,56],"a",{"href":55},"\u002Fadvanced-pytest-architecture-configuration\u002Fbuilding-custom-pytest-plugins\u002F","building custom pytest plugins",".",[19,59,61],{"id":60},"solution","Solution",[10,63,64,65,68],{},"The whole mechanism is one metadata table. Move the hooks into an importable module, then declare it under ",[14,66,67],{},"project.entry-points.pytest11",":",[70,71,76],"pre",{"className":72,"code":74,"language":75},[73],"language-text","myplugin\u002F\n├── pyproject.toml\n└── src\u002F\n    └── myplugin\u002F\n        ├── __init__.py\n        └── hooks.py          # the hook implementations\n","text",[14,77,74],{"__ignoreMap":78},"",[70,80,84],{"className":81,"code":82,"language":83,"meta":78,"style":78},"language-toml shiki shiki-themes github-light github-dark","# pyproject.toml\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"pytest-slowguard\"\nversion = \"0.1.0\"\ndescription = \"Fails the run when a test exceeds its declared budget\"\nrequires-python = \">=3.9\"\ndependencies = [\"pytest>=7.0\"]\n\n# The entry point: \u003Cplugin name> = \"\u003Cimportable module>\"\n[project.entry-points.pytest11]\nslowguard = \"myplugin.hooks\"\n","toml",[14,85,86,94,100,106,112,119,125,131,137,143,149,155,160,166,172],{"__ignoreMap":78},[87,88,91],"span",{"class":89,"line":90},"line",1,[87,92,93],{},"# pyproject.toml\n",[87,95,97],{"class":89,"line":96},2,[87,98,99],{},"[build-system]\n",[87,101,103],{"class":89,"line":102},3,[87,104,105],{},"requires = [\"hatchling\"]\n",[87,107,109],{"class":89,"line":108},4,[87,110,111],{},"build-backend = \"hatchling.build\"\n",[87,113,115],{"class":89,"line":114},5,[87,116,118],{"emptyLinePlaceholder":117},true,"\n",[87,120,122],{"class":89,"line":121},6,[87,123,124],{},"[project]\n",[87,126,128],{"class":89,"line":127},7,[87,129,130],{},"name = \"pytest-slowguard\"\n",[87,132,134],{"class":89,"line":133},8,[87,135,136],{},"version = \"0.1.0\"\n",[87,138,140],{"class":89,"line":139},9,[87,141,142],{},"description = \"Fails the run when a test exceeds its declared budget\"\n",[87,144,146],{"class":89,"line":145},10,[87,147,148],{},"requires-python = \">=3.9\"\n",[87,150,152],{"class":89,"line":151},11,[87,153,154],{},"dependencies = [\"pytest>=7.0\"]\n",[87,156,158],{"class":89,"line":157},12,[87,159,118],{"emptyLinePlaceholder":117},[87,161,163],{"class":89,"line":162},13,[87,164,165],{},"# The entry point: \u003Cplugin name> = \"\u003Cimportable module>\"\n",[87,167,169],{"class":89,"line":168},14,[87,170,171],{},"[project.entry-points.pytest11]\n",[87,173,175],{"class":89,"line":174},15,[87,176,177],{},"slowguard = \"myplugin.hooks\"\n",[10,179,180,181,184,185,188],{},"The left-hand name is what pytest calls the plugin — it is the name in ",[14,182,183],{},"-p no:slowguard"," and in ",[14,186,187],{},"--trace-config"," output. The right-hand value is an importable module, not a file path; pytest imports it and registers every hook implementation it finds at module level.",[70,190,194],{"className":191,"code":192,"language":193,"meta":78,"style":78},"language-python shiki shiki-themes github-light github-dark","# src\u002Fmyplugin\u002Fhooks.py\nimport pytest\n\ndef pytest_addoption(parser):\n    parser.addini(\"slow_budget\", \"seconds a test may take\", default=\"2.0\")\n\n@pytest.hookimpl(hookwrapper=True)\ndef pytest_runtest_makereport(item, call):\n    outcome = yield\n    report = outcome.get_result()\n    budget = float(item.config.getini(\"slow_budget\"))\n    if report.when == \"call\" and report.duration > budget:\n        report.outcome = \"failed\"\n        report.longrepr = f\"took {report.duration:.2f}s, budget {budget:.2f}s\"\n","python",[14,195,196,201,206,210,215,220,224,229,234,239,244,249,254,259],{"__ignoreMap":78},[87,197,198],{"class":89,"line":90},[87,199,200],{},"# src\u002Fmyplugin\u002Fhooks.py\n",[87,202,203],{"class":89,"line":96},[87,204,205],{},"import pytest\n",[87,207,208],{"class":89,"line":102},[87,209,118],{"emptyLinePlaceholder":117},[87,211,212],{"class":89,"line":108},[87,213,214],{},"def pytest_addoption(parser):\n",[87,216,217],{"class":89,"line":114},[87,218,219],{},"    parser.addini(\"slow_budget\", \"seconds a test may take\", default=\"2.0\")\n",[87,221,222],{"class":89,"line":121},[87,223,118],{"emptyLinePlaceholder":117},[87,225,226],{"class":89,"line":127},[87,227,228],{},"@pytest.hookimpl(hookwrapper=True)\n",[87,230,231],{"class":89,"line":133},[87,232,233],{},"def pytest_runtest_makereport(item, call):\n",[87,235,236],{"class":89,"line":139},[87,237,238],{},"    outcome = yield\n",[87,240,241],{"class":89,"line":145},[87,242,243],{},"    report = outcome.get_result()\n",[87,245,246],{"class":89,"line":151},[87,247,248],{},"    budget = float(item.config.getini(\"slow_budget\"))\n",[87,250,251],{"class":89,"line":157},[87,252,253],{},"    if report.when == \"call\" and report.duration > budget:\n",[87,255,256],{"class":89,"line":162},[87,257,258],{},"        report.outcome = \"failed\"\n",[87,260,261],{"class":89,"line":168},[87,262,263],{},"        report.longrepr = f\"took {report.duration:.2f}s, budget {budget:.2f}s\"\n",[10,265,266],{},"Install it into the target environment and it is live for every run in that environment, with no configuration in the consuming project:",[70,268,272],{"className":269,"code":270,"language":271,"meta":78,"style":78},"language-console shiki shiki-themes github-light github-dark","$ pip install -e .                 # while developing the plugin\n$ pip install pytest-slowguard     # in the consuming project\n$ pytest -q                        # the plugin is already registered\n","console",[14,273,274,279,284],{"__ignoreMap":78},[87,275,276],{"class":89,"line":90},[87,277,278],{},"$ pip install -e .                 # while developing the plugin\n",[87,280,281],{"class":89,"line":96},[87,282,283],{},"$ pip install pytest-slowguard     # in the consuming project\n",[87,285,286],{"class":89,"line":102},[87,287,288],{},"$ pytest -q                        # the plugin is already registered\n",[290,291,294,416],"figure",{"className":292},[293],"diagram",[295,296,303,304,303,308,303,312,303,330,303,338,303,346,303,355,303,361,303,366,303,372,303,375,303,379,303,382,303,386,303,389,303,393,303,396,303,400,303,403,303,407,303,410],"svg",{"viewBox":297,"role":298,"ariaLabelledBy":299,"xmlns":302},"0 0 760 172","img",[300,301],"pluginboot-t","pluginboot-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[305,306,307],"title",{"id":300},"How pytest finds an installed plugin",[309,310,311],"desc",{"id":301},"A left-to-right startup sequence: pytest scans installed distributions for the pytest11 entry point group, imports each named module, registers the hook implementations it finds, and only then loads conftest files and begins collection.",[313,314,315,316,303],"defs",{},"\n    ",[317,318,325],"marker",{"id":319,"viewBox":320,"refX":321,"refY":322,"markerWidth":323,"markerHeight":323,"orient":324},"pluginboot-a","0 0 10 10","9","5","7","auto-start-reverse",[326,327],"path",{"d":328,"fill":329},"M0 0 L10 5 L0 10 z","#81b29a",[331,332],"rect",{"x":333,"y":333,"width":334,"height":335,"rx":336,"fill":337},"0","760","172","14","#fffdf8",[75,339,307],{"x":340,"y":341,"textAnchor":342,"fontSize":343,"fontWeight":344,"fill":345},"380","30","middle","15","700","#3d405b",[331,347],{"x":348,"y":349,"width":350,"height":351,"rx":352,"fill":353,"stroke":329,"strokeWidth":354},"26","62","142","76","12","#f4f1de","1.8",[75,356,360],{"x":357,"y":358,"textAnchor":342,"fontSize":359,"fontWeight":344,"fill":345},"97","96","12.5","scan entry points",[75,362,365],{"x":357,"y":363,"textAnchor":342,"fontSize":364,"fill":345},"113","11","group pytest11",[89,367],{"x1":368,"y1":369,"x2":370,"y2":369,"stroke":329,"strokeWidth":354,"markerEnd":371},"176","100","208","url(#pluginboot-a)",[331,373],{"x":374,"y":349,"width":350,"height":351,"rx":352,"fill":337,"stroke":329,"strokeWidth":354},"214",[75,376,378],{"x":377,"y":358,"textAnchor":342,"fontSize":359,"fontWeight":344,"fill":345},"286","import the module",[75,380,381],{"x":377,"y":363,"textAnchor":342,"fontSize":364,"fill":345},"by dotted path",[89,383],{"x1":384,"y1":369,"x2":385,"y2":369,"stroke":329,"strokeWidth":354,"markerEnd":371},"364","396",[331,387],{"x":388,"y":349,"width":350,"height":351,"rx":352,"fill":353,"stroke":329,"strokeWidth":354},"403",[75,390,392],{"x":391,"y":358,"textAnchor":342,"fontSize":359,"fontWeight":344,"fill":345},"474","register hooks",[75,394,395],{"x":391,"y":363,"textAnchor":342,"fontSize":364,"fill":345},"module-level impls",[89,397],{"x1":398,"y1":369,"x2":399,"y2":369,"stroke":329,"strokeWidth":354,"markerEnd":371},"552","584",[331,401],{"x":402,"y":349,"width":350,"height":351,"rx":352,"fill":337,"stroke":329,"strokeWidth":354},"592",[75,404,406],{"x":405,"y":358,"textAnchor":342,"fontSize":359,"fontWeight":344,"fill":345},"663","load conftests",[75,408,409],{"x":405,"y":363,"textAnchor":342,"fontSize":364,"fill":345},"after plugins",[75,411,415],{"x":340,"y":412,"textAnchor":342,"fontSize":413,"fontStyle":414,"fill":345},"164","11.5","italic","-p no:\u003Cname> removes a plugin at the first stage, before its module is imported.",[417,418,419],"figcaption",{},"Installed plugins register before any conftest is read, which is why a plugin fixture can be overridden by a local conftest but not the other way round.",[19,421,423],{"id":422},"why-this-works","Why this works",[10,425,426,427,430,431,433,434,436],{},"Python packaging writes entry-point metadata into the installed distribution, and ",[14,428,429],{},"importlib.metadata"," can enumerate every entry in a given group across the whole environment. At startup pytest queries the ",[14,432,44],{}," group, imports each module, and hands it to pluggy's registry — the same registry ",[14,435,16],{}," files are added to later. From pluggy's point of view there is no difference between a hook that arrived from an installed package and one that arrived from a conftest; only the registration order differs, and that order is what determines which fixture definition wins when names collide.",[19,438,440],{"id":439},"verifying-registration-in-a-clean-environment","Verifying registration in a clean environment",[10,442,443],{},"The failure mode that wastes the most time is a plugin that works in the development checkout and silently does nothing after installation, usually because the module moved or the wheel excluded it. Three checks catch it before release.",[70,445,447],{"className":269,"code":446,"language":271,"meta":78,"style":78},"$ python -c \"from importlib.metadata import entry_points; \\\n             print([e for e in entry_points(group='pytest11')])\"\n[EntryPoint(name='slowguard', value='myplugin.hooks', group='pytest11')]\n\n$ pytest --trace-config -q 2>&1 | grep slowguard\n  plugin name: slowguard, class: \u003Cmodule 'myplugin.hooks' from '...'>\n\n$ pytest -p no:slowguard -q        # confirms the name is what you think it is\n",[14,448,449,454,459,464,468,473,478,482],{"__ignoreMap":78},[87,450,451],{"class":89,"line":90},[87,452,453],{},"$ python -c \"from importlib.metadata import entry_points; \\\n",[87,455,456],{"class":89,"line":96},[87,457,458],{},"             print([e for e in entry_points(group='pytest11')])\"\n",[87,460,461],{"class":89,"line":102},[87,462,463],{},"[EntryPoint(name='slowguard', value='myplugin.hooks', group='pytest11')]\n",[87,465,466],{"class":89,"line":108},[87,467,118],{"emptyLinePlaceholder":117},[87,469,470],{"class":89,"line":114},[87,471,472],{},"$ pytest --trace-config -q 2>&1 | grep slowguard\n",[87,474,475],{"class":89,"line":121},[87,476,477],{},"  plugin name: slowguard, class: \u003Cmodule 'myplugin.hooks' from '...'>\n",[87,479,480],{"class":89,"line":127},[87,481,118],{"emptyLinePlaceholder":117},[87,483,484],{"class":89,"line":133},[87,485,486],{},"$ pytest -p no:slowguard -q        # confirms the name is what you think it is\n",[10,488,489],{},"The first proves the metadata exists, the second proves pytest imported the module, and the third proves the plugin name is the one documented for users disabling it. Run all three against a wheel installed into a fresh virtualenv, not against the editable install — an editable install resolves modules from the source tree and will happily import a file the wheel does not contain.",[10,491,492],{},"Add the same three checks to the plugin's own CI as a job that builds the wheel, installs it into a clean environment, and runs a smoke test. Packaging errors are exactly the class of bug that unit tests cannot see, because the tests import the module directly rather than through the entry point.",[290,494,496,591],{"className":495},[293],[295,497,303,502,303,505,303,508,303,511,303,513,303,522,303,526,303,530,303,534,303,538,303,542,303,545,303,548,303,551,303,554,303,557,303,560,303,562,303,566,303,569,303,572,303,575,303,579,303,581,303,584,303,588],{"viewBox":498,"role":298,"ariaLabelledBy":499,"xmlns":302},"0 0 760 270",[500,501],"pluginsource-t","pluginsource-d",[305,503,504],{"id":500},"Three ways a plugin can be loaded",[309,506,507],{"id":501},"A table comparing an installed entry-point plugin, a conftest.py, and the -p command line flag, across when each registers, how widely it applies, and the usual reason to choose it.",[331,509],{"x":333,"y":333,"width":334,"height":510,"rx":336,"fill":337},"270",[75,512,504],{"x":340,"y":341,"textAnchor":342,"fontSize":343,"fontWeight":344,"fill":345},[331,514],{"x":515,"y":516,"width":517,"height":518,"rx":519,"fill":520,"stroke":345,"strokeWidth":521},"24","52","712","40","10","#f2cc8f","1.5",[75,523,525],{"x":524,"y":351,"fontSize":352,"fontWeight":344,"fill":345},"38","Criterion",[75,527,529],{"x":528,"y":351,"textAnchor":342,"fontSize":352,"fontWeight":344,"fill":345},"390","Registers",[75,531,533],{"x":532,"y":351,"textAnchor":342,"fontSize":352,"fontWeight":344,"fill":345},"620","Scope",[331,535],{"x":515,"y":536,"width":517,"height":518,"fill":353,"stroke":345,"strokeWidth":537},"92","1",[75,539,541],{"x":524,"y":540,"fontSize":413,"fill":345},"116","pytest11 entry point",[75,543,544],{"x":528,"y":540,"textAnchor":342,"fontSize":413,"fill":345},"at startup, first",[75,546,547],{"x":532,"y":540,"textAnchor":342,"fontSize":413,"fill":345},"the environment",[331,549],{"x":515,"y":550,"width":517,"height":518,"fill":337,"stroke":345,"strokeWidth":537},"132",[75,552,16],{"x":524,"y":553,"fontSize":413,"fill":345},"156",[75,555,556],{"x":528,"y":553,"textAnchor":342,"fontSize":413,"fill":345},"at collection",[75,558,559],{"x":532,"y":553,"textAnchor":342,"fontSize":413,"fill":345},"a directory tree",[331,561],{"x":515,"y":335,"width":517,"height":518,"fill":353,"stroke":345,"strokeWidth":537},[75,563,565],{"x":524,"y":564,"fontSize":413,"fill":345},"196","-p module \u002F addopts",[75,567,568],{"x":528,"y":564,"textAnchor":342,"fontSize":413,"fill":345},"at startup",[75,570,571],{"x":532,"y":564,"textAnchor":342,"fontSize":413,"fill":345},"one invocation",[331,573],{"x":515,"y":574,"width":517,"height":518,"fill":337,"stroke":345,"strokeWidth":537},"212",[75,576,578],{"x":524,"y":577,"fontSize":413,"fill":345},"236","pytest_plugins in rootdir conftest",[75,580,556],{"x":528,"y":577,"textAnchor":342,"fontSize":413,"fill":345},[75,582,583],{"x":532,"y":577,"textAnchor":342,"fontSize":413,"fill":345},"the project",[89,585],{"x1":586,"y1":516,"x2":586,"y2":587,"stroke":345,"strokeWidth":537},"274","252",[89,589],{"x1":590,"y1":516,"x2":590,"y2":587,"stroke":345,"strokeWidth":537},"505",[417,592,593],{},"Registration order decides precedence: installed plugins are outermost, and a local conftest can override the fixtures they provide.",[19,595,597],{"id":596},"choosing-a-name-and-a-support-window","Choosing a name and a support window",[10,599,600,601,604,605,608],{},"Two decisions outlive the code. The distribution name should start with ",[14,602,603],{},"pytest-",", which is the ecosystem convention and how people find plugins on PyPI; the entry-point name should be short and namespaced enough that ",[14,606,607],{},"-p no:\u003Cname>"," is unambiguous in a project with thirty plugins installed.",[10,610,611,612,615,616,619,620,623,624,627],{},"The support window is a ",[14,613,614],{},"requires-python"," and a pytest version range, and the range matters more than most authors expect. Hook signatures do change across major pytest versions — ",[14,617,618],{},"hookwrapper=True"," gained a modern ",[14,621,622],{},"wrapper=True"," alternative in pytest 8, and several hooks changed arguments in 7 — so pinning ",[14,625,626],{},"pytest>=7.0,\u003C9"," states what you have actually tested rather than implying support you have not. Run the plugin's test suite against every supported pytest major in CI; a matrix of two or three versions costs minutes and prevents the most common bug report a plugin receives.",[10,629,630],{},"For internal plugins that will never see PyPI, the same mechanics apply with a private index or a direct VCS dependency. The value is unchanged: one implementation, one version to upgrade, and a dependency graph that tells you which repositories are affected when the hook behaviour changes.",[19,632,634],{"id":633},"declaring-options-and-defaults-the-packaged-way","Declaring options and defaults the packaged way",[10,636,637],{},"A plugin that only registers hooks is easy to package. One that needs configuration has to decide where that configuration lives, and the answer determines how the plugin behaves in a repository it does not control.",[10,639,640,643,644,647],{},[14,641,642],{},"pytest_addoption"," is the single entry point for both command-line flags and ini options, and the difference between them matters. A flag suits a per-run decision — ",[14,645,646],{},"--slowguard-off"," for a debugging session. An ini option suits a project-level policy — the budget every test in this repository must meet. Declaring both, with the flag overriding the ini value, is the shape users expect:",[70,649,651],{"className":191,"code":650,"language":193,"meta":78,"style":78},"# src\u002Fmyplugin\u002Fhooks.py\ndef pytest_addoption(parser):\n    group = parser.getgroup(\"slowguard\")\n    group.addoption(\"--slow-budget\", type=float, default=None,\n                    help=\"override the per-test budget for this run\")\n    parser.addini(\"slow_budget\", \"seconds a test may take\", default=\"2.0\")\n\ndef resolve_budget(config) -> float:\n    # Command line wins; otherwise the project's ini value; otherwise the default.\n    return config.getoption(\"--slow-budget\") or float(config.getini(\"slow_budget\"))\n",[14,652,653,657,661,666,671,676,680,684,689,694],{"__ignoreMap":78},[87,654,655],{"class":89,"line":90},[87,656,200],{},[87,658,659],{"class":89,"line":96},[87,660,214],{},[87,662,663],{"class":89,"line":102},[87,664,665],{},"    group = parser.getgroup(\"slowguard\")\n",[87,667,668],{"class":89,"line":108},[87,669,670],{},"    group.addoption(\"--slow-budget\", type=float, default=None,\n",[87,672,673],{"class":89,"line":114},[87,674,675],{},"                    help=\"override the per-test budget for this run\")\n",[87,677,678],{"class":89,"line":121},[87,679,219],{},[87,681,682],{"class":89,"line":127},[87,683,118],{"emptyLinePlaceholder":117},[87,685,686],{"class":89,"line":133},[87,687,688],{},"def resolve_budget(config) -> float:\n",[87,690,691],{"class":89,"line":139},[87,692,693],{},"    # Command line wins; otherwise the project's ini value; otherwise the default.\n",[87,695,696],{"class":89,"line":145},[87,697,698],{},"    return config.getoption(\"--slow-budget\") or float(config.getini(\"slow_budget\"))\n",[10,700,701,702,705,706,709],{},"Grouping the options with ",[14,703,704],{},"parser.getgroup"," keeps ",[14,707,708],{},"pytest --help"," readable once several plugins are installed — ungrouped options land in a flat list where nobody can tell which plugin owns what.",[10,711,712,713,715,716,719,720,722,723,726,727,730],{},"Two packaging details follow from having options. Document the ini keys in the README with a copy-pasteable ",[14,714,40],{}," block, because ",[14,717,718],{},"--help"," shows flags prominently and ini options only in a separate section that users rarely read. And never read configuration at import time: ",[14,721,642],{}," runs before the ini file is parsed, so any module-level ",[14,724,725],{},"getini"," call fails. Resolve configuration inside a hook or a fixture, where the fully parsed ",[14,728,729],{},"config"," object is available.",[10,732,733,734,737],{},"Finally, decide what happens with no configuration at all. A plugin that fails the run because a required ini key is missing will be uninstalled within a day; one that applies a sensible default and reports what it used on the first run is the one that survives. State the effective configuration once per session in ",[14,735,736],{},"pytest_report_header",", which puts it at the top of every CI log.",[290,739,741,811],{"className":740},[293],[295,742,303,746,303,749,303,752,303,754,303,756,303,758,303,760,303,763,303,766,303,768,303,771,303,774,303,776,303,778,303,781,303,783,303,786,303,788,303,791,303,794,303,796,303,798,303,801,303,804,303,807,303,809],{"viewBox":498,"role":298,"ariaLabelledBy":743,"xmlns":302},[744,745],"pluginopts-t","pluginopts-d",[305,747,748],{"id":744},"Where plugin configuration should live",[309,750,751],{"id":745},"A table comparing a command-line flag, an ini option, an environment variable and a hard-coded default across who sets each, how long the setting lasts, and what it suits.",[331,753],{"x":333,"y":333,"width":334,"height":510,"rx":336,"fill":337},[75,755,748],{"x":340,"y":341,"textAnchor":342,"fontSize":343,"fontWeight":344,"fill":345},[331,757],{"x":515,"y":516,"width":517,"height":518,"rx":519,"fill":520,"stroke":345,"strokeWidth":521},[75,759,525],{"x":524,"y":351,"fontSize":352,"fontWeight":344,"fill":345},[75,761,762],{"x":528,"y":351,"textAnchor":342,"fontSize":352,"fontWeight":344,"fill":345},"Set by",[75,764,765],{"x":532,"y":351,"textAnchor":342,"fontSize":352,"fontWeight":344,"fill":345},"Lasts",[331,767],{"x":515,"y":536,"width":517,"height":518,"fill":353,"stroke":345,"strokeWidth":537},[75,769,770],{"x":524,"y":540,"fontSize":413,"fill":345},"--flag",[75,772,773],{"x":528,"y":540,"textAnchor":342,"fontSize":413,"fill":345},"the person running",[75,775,571],{"x":532,"y":540,"textAnchor":342,"fontSize":413,"fill":345},[331,777],{"x":515,"y":550,"width":517,"height":518,"fill":337,"stroke":345,"strokeWidth":537},[75,779,780],{"x":524,"y":553,"fontSize":413,"fill":345},"ini option",[75,782,583],{"x":528,"y":553,"textAnchor":342,"fontSize":413,"fill":345},[75,784,785],{"x":532,"y":553,"textAnchor":342,"fontSize":413,"fill":345},"every run in the repo",[331,787],{"x":515,"y":335,"width":517,"height":518,"fill":353,"stroke":345,"strokeWidth":537},[75,789,790],{"x":524,"y":564,"fontSize":413,"fill":345},"environment variable",[75,792,793],{"x":528,"y":564,"textAnchor":342,"fontSize":413,"fill":345},"CI configuration",[75,795,547],{"x":532,"y":564,"textAnchor":342,"fontSize":413,"fill":345},[331,797],{"x":515,"y":574,"width":517,"height":518,"fill":337,"stroke":345,"strokeWidth":537},[75,799,800],{"x":524,"y":577,"fontSize":413,"fill":345},"code default",[75,802,803],{"x":528,"y":577,"textAnchor":342,"fontSize":413,"fill":345},"the plugin author",[75,805,806],{"x":532,"y":577,"textAnchor":342,"fontSize":413,"fill":345},"until overridden",[89,808],{"x1":586,"y1":516,"x2":586,"y2":587,"stroke":345,"strokeWidth":537},[89,810],{"x1":590,"y1":516,"x2":590,"y2":587,"stroke":345,"strokeWidth":537},[417,812,813],{},"Offer all four in that precedence order, and report the effective value in the run header so nobody has to guess which one applied.",[19,815,817],{"id":816},"edge-cases-and-failure-modes","Edge cases and failure modes",[24,819,820,830,840,846,854],{},[27,821,822,829],{},[30,823,824,825,828],{},"The entry point value must be a module, not a package with the hooks in ",[14,826,827],{},"__init__"," only by accident."," Point it at the module that defines the hooks; pointing at the package works only if the hooks are re-exported there.",[27,831,832,835,836,839],{},[30,833,834],{},"Editable installs mask missing files."," Always smoke-test a built wheel in a fresh virtualenv, or a ",[14,837,838],{},"src","-layout mistake ships to users.",[27,841,842,845],{},[30,843,844],{},"Plugins load before conftest files",", so a fixture defined in both places resolves to the conftest one. That is usually desirable, but it means a plugin cannot force a fixture on a project that overrides it.",[27,847,848,853],{},[30,849,850,852],{},[14,851,607],{}," uses the entry-point name."," Users who try the module path get \"plugin not found\", which is worth documenting in the README.",[27,855,856,859,860,863,864,867,868,57],{},[30,857,858],{},"Two plugins implementing the same hook both run."," Ordering follows ",[14,861,862],{},"tryfirst","\u002F",[14,865,866],{},"trylast"," and registration order, exactly as described in ",[53,869,871],{"href":870},"\u002Fadvanced-pytest-architecture-configuration\u002Fbuilding-custom-pytest-plugins\u002Fwriting-a-hookwrapper-for-test-reports\u002F","writing a hookwrapper for test reports",[19,873,875],{"id":874},"frequently-asked-questions","Frequently Asked Questions",[10,877,878,881,882,884],{},[30,879,880],{},"What is the pytest11 entry point group?","\nIt is the group name pytest scans at startup to discover installed plugins. Every entry in the ",[14,883,44],{}," group maps a plugin name to an importable module, and pytest imports and registers each one automatically.",[10,886,887,890,891,894],{},[30,888,889],{},"Why is my plugin not loaded after pip install?","\nUsually because the entry point points at a module path that does not exist in the installed wheel, or because the package was installed without the metadata. Check with ",[14,892,893],{},"pytest --trace-config",", which lists every registered plugin and its module.",[10,896,897,900,901,904,905,908],{},[30,898,899],{},"How do I stop my plugin from loading in one project?","\nRun pytest with ",[14,902,903],{},"-p no:pluginname",", or set the same value in ",[14,906,907],{},"addopts",". The name is the entry point name, not the module path.",[19,910,912],{"id":911},"related","Related",[24,914,915,921,927,934],{},[27,916,917,920],{},[53,918,919],{"href":55},"Building custom pytest plugins"," — the hooks and fixtures this package ships.",[27,922,923,926],{},[53,924,925],{"href":870},"Writing a hookwrapper for test reports"," — the report-enrichment pattern most internal plugins start from.",[27,928,929,933],{},[53,930,932],{"href":931},"\u002Fadvanced-pytest-architecture-configuration\u002Fmanaging-conftest-hierarchies\u002Fsharing-fixtures-without-conftest-py\u002F","Sharing fixtures without conftest.py"," — the lighter-weight alternative when the fixtures stay inside one repository.",[27,935,936,940,941,943],{},[53,937,939],{"href":938},"\u002Fadvanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002F","Pytest configuration best practices"," — where ",[14,942,907],{}," and ini options declared by a plugin belong.",[10,945,946,947],{},"← Back to ",[53,948,949],{"href":55},"Building Custom Pytest Plugins",[951,952,953],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":78,"searchDepth":96,"depth":96,"links":955},[956,957,958,959,960,961,962,963,964],{"id":21,"depth":96,"text":22},{"id":60,"depth":96,"text":61},{"id":422,"depth":96,"text":423},{"id":439,"depth":96,"text":440},{"id":596,"depth":96,"text":597},{"id":633,"depth":96,"text":634},{"id":816,"depth":96,"text":817},{"id":874,"depth":96,"text":875},{"id":911,"depth":96,"text":912},"Ship a pytest plugin as an installable package: the pytest11 entry point, pyproject metadata, plugin discovery order, and verifying registration in a clean env.","md",{"slug":968,"type":969,"breadcrumb":970,"datePublished":971,"dateModified":971,"faq":972,"howto":979},"packaging-a-pytest-plugin-with-entry-points","article","Packaging a Plugin","2026-08-01",[973,975,977],{"q":880,"a":974},"It is the group name pytest scans at startup to discover installed plugins. Every entry in the pytest11 group maps a plugin name to an importable module, and pytest imports and registers each one automatically.",{"q":889,"a":976},"Usually because the entry point points at a module path that does not exist in the installed wheel, or because the package was installed without the metadata. Check with pytest --trace-config, which lists every registered plugin and its module.",{"q":899,"a":978},"Run pytest with -p no:pluginname, or set the same value in addopts. The name is the entry point name, not the module path.",{"name":980,"description":981,"steps":982},"How to package a pytest plugin with entry points","Turn a plugin module into an installable distribution that pytest discovers automatically.",[983,986,989,992],{"name":984,"text":985},"Put the hooks in an importable module","Move the hook implementations out of conftest.py into a package module that can be imported by path.",{"name":987,"text":988},"Declare the pytest11 entry point","Add a project.entry-points.pytest11 table in pyproject.toml mapping a plugin name to the module.",{"name":990,"text":991},"Install the package in the target environment","Install with pip so the entry point metadata is written and pytest can discover it.",{"name":993,"text":994},"Verify registration","Run pytest --trace-config and confirm the plugin name and module appear in the registered list.","\u002Fadvanced-pytest-architecture-configuration\u002Fbuilding-custom-pytest-plugins\u002Fpackaging-a-pytest-plugin-with-entry-points",{"title":5,"description":965},"advanced-pytest-architecture-configuration\u002Fbuilding-custom-pytest-plugins\u002Fpackaging-a-pytest-plugin-with-entry-points\u002Findex","Cz4LVs9msHnONuwbE7GXX_eY8V6uGD6iohg0SHIeVjM",1785613404376]