[{"data":1,"prerenderedAt":1279},["ShallowReactive",2],{"page-\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmeasuring-cli-test-coverage\u002F":3,"content-directory":1031},{"id":4,"title":5,"body":6,"date":1018,"description":1019,"difficulty":1020,"draft":1021,"extension":1022,"meta":1023,"navigation":166,"path":1024,"seo":1025,"stem":1026,"tags":1027,"updated":1018,"__hash__":1030},"content\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmeasuring-cli-test-coverage\u002Findex.md","Measuring CLI Test Coverage",{"type":7,"value":8,"toc":997},"minimark",[9,13,18,55,59,77,81,255,259,262,274,290,311,314,335,339,342,345,359,369,457,463,539,559,563,570,607,641,651,655,658,661,668,671,700,707,711,722,725,739,746,750,753,763,766,770,777,876,883,887,890,894,899,902,906,909,913,919,923,930,934,944,948,955,959,993],[10,11,12],"p",{},"Coverage is a tool for finding paths nobody tested, not a number to maximise. For a command-line\ntool it is unusually easy to reach a comfortable percentage while leaving the parts users touch\nfirst — the entry point, the error boundary, the non-terminal branch — entirely unexercised.",[14,15,17],"h2",{"id":16},"tldr","TL;DR",[19,20,21,30,38,45,52],"ul",{},[22,23,24,25,29],"li",{},"Measure ",[26,27,28],"code",{},"src\u002F",", not the installed package, so line numbers point at your working tree.",[22,31,32,33,37],{},"Turn on ",[34,35,36],"strong",{},"branch coverage","; line coverage hides half-tested conditionals.",[22,39,40,41,44],{},"Set ",[26,42,43],{},"fail_under"," to a floor you already clear, and raise it when it improves.",[22,46,47,48,51],{},"Exclude unreachable code with a ",[34,49,50],{},"reason",", never with a blanket ignore.",[22,53,54],{},"Remember what coverage cannot see: the console script, lazy imports, and TTY-only branches.",[14,56,58],{"id":57},"prerequisites","Prerequisites",[10,60,61,62,65,66,69,70,72,73,76],{},"pytest, ",[26,63,64],{},"pytest-cov"," (or ",[26,67,68],{},"coverage"," directly), and a project laid out with the package under\n",[26,71,28],{},". Everything here is configured in ",[26,74,75],{},"pyproject.toml",".",[14,78,80],{"id":79},"a-configuration-that-tells-the-truth","A configuration that tells the truth",[82,83,88],"pre",{"className":84,"code":85,"language":86,"meta":87,"style":87},"language-toml shiki shiki-themes github-light github-dark","[tool.coverage.run]\nsource = [\"src\"]\nbranch = true\nparallel = true                     # combine data from subprocesses\nomit = [\"*\u002F__main__.py\"]\n\n[tool.coverage.report]\nshow_missing = true\nskip_covered = true\nfail_under = 85\nexclude_also = [\n  \"if TYPE_CHECKING:\",\n  \"raise NotImplementedError\",\n  \"if __name__ == .__main__.:\",\n  \"@overload\",\n]\n","toml","",[26,89,90,115,127,137,150,161,168,186,194,202,211,217,226,234,242,250],{"__ignoreMap":87},[91,92,95,99,103,105,107,109,112],"span",{"class":93,"line":94},"line",1,[91,96,98],{"class":97},"sVt8B","[",[91,100,102],{"class":101},"sScJk","tool",[91,104,76],{"class":97},[91,106,68],{"class":101},[91,108,76],{"class":97},[91,110,111],{"class":101},"run",[91,113,114],{"class":97},"]\n",[91,116,118,121,125],{"class":93,"line":117},2,[91,119,120],{"class":97},"source = [",[91,122,124],{"class":123},"sZZnC","\"src\"",[91,126,114],{"class":97},[91,128,130,133],{"class":93,"line":129},3,[91,131,132],{"class":97},"branch = ",[91,134,136],{"class":135},"sj4cs","true\n",[91,138,140,143,146],{"class":93,"line":139},4,[91,141,142],{"class":97},"parallel = ",[91,144,145],{"class":135},"true",[91,147,149],{"class":148},"sJ8bj","                     # combine data from subprocesses\n",[91,151,153,156,159],{"class":93,"line":152},5,[91,154,155],{"class":97},"omit = [",[91,157,158],{"class":123},"\"*\u002F__main__.py\"",[91,160,114],{"class":97},[91,162,164],{"class":93,"line":163},6,[91,165,167],{"emptyLinePlaceholder":166},true,"\n",[91,169,171,173,175,177,179,181,184],{"class":93,"line":170},7,[91,172,98],{"class":97},[91,174,102],{"class":101},[91,176,76],{"class":97},[91,178,68],{"class":101},[91,180,76],{"class":97},[91,182,183],{"class":101},"report",[91,185,114],{"class":97},[91,187,189,192],{"class":93,"line":188},8,[91,190,191],{"class":97},"show_missing = ",[91,193,136],{"class":135},[91,195,197,200],{"class":93,"line":196},9,[91,198,199],{"class":97},"skip_covered = ",[91,201,136],{"class":135},[91,203,205,208],{"class":93,"line":204},10,[91,206,207],{"class":97},"fail_under = ",[91,209,210],{"class":135},"85\n",[91,212,214],{"class":93,"line":213},11,[91,215,216],{"class":97},"exclude_also = [\n",[91,218,220,223],{"class":93,"line":219},12,[91,221,222],{"class":123},"  \"if TYPE_CHECKING:\"",[91,224,225],{"class":97},",\n",[91,227,229,232],{"class":93,"line":228},13,[91,230,231],{"class":123},"  \"raise NotImplementedError\"",[91,233,225],{"class":97},[91,235,237,240],{"class":93,"line":236},14,[91,238,239],{"class":123},"  \"if __name__ == .__main__.:\"",[91,241,225],{"class":97},[91,243,245,248],{"class":93,"line":244},15,[91,246,247],{"class":123},"  \"@overload\"",[91,249,225],{"class":97},[91,251,253],{"class":93,"line":252},16,[91,254,114],{"class":97},[256,257],"inline-diagram",{"name":258},"coverage-config-tree",[10,260,261],{},"Three of those settings do most of the work.",[10,263,264,269,270,273],{},[34,265,266],{},[26,267,268],{},"source = [\"src\"]"," measures your package rather than whatever was imported, which means files\nwith no tests at all appear at 0% instead of being silently absent. Measuring the installed package\ninstead makes line numbers point into ",[26,271,272],{},"site-packages",", which is unhelpful in a report.",[10,275,276,281,282,285,286,289],{},[34,277,278],{},[26,279,280],{},"branch = true"," is the difference between a comfortable number and a useful one. A function\nwith ",[26,283,284],{},"if verbose: log(...)"," shows 100% line coverage from a single test that never sets ",[26,287,288],{},"verbose",";\nwith branch coverage it shows the untaken path.",[10,291,292,297,298,301,302,306,307,310],{},[34,293,294],{},[26,295,296],{},"exclude_also"," replaces the older ",[26,299,300],{},"exclude_lines"," and ",[303,304,305],"em",{},"adds"," to the defaults rather than\nreplacing them, which is what you want — the built-in ",[26,308,309],{},"pragma: no cover"," handling stays in place.",[10,312,313],{},"Run it as part of the normal suite:",[82,315,319],{"className":316,"code":317,"language":318,"meta":87,"style":87},"language-bash shiki shiki-themes github-light github-dark","pytest --cov --cov-report=term-missing --cov-report=html\n","bash",[26,320,321],{"__ignoreMap":87},[91,322,323,326,329,332],{"class":93,"line":94},[91,324,325],{"class":101},"pytest",[91,327,328],{"class":135}," --cov",[91,330,331],{"class":135}," --cov-report=term-missing",[91,333,334],{"class":135}," --cov-report=html\n",[14,336,338],{"id":337},"what-the-number-hides-in-a-cli","What the number hides in a CLI",[256,340],{"name":341},"coverage-blind-spots",[10,343,344],{},"Four paths are routinely invisible, and each needs a deliberate test.",[10,346,347,350,351,354,355,358],{},[34,348,349],{},"The console script."," Tests import ",[26,352,353],{},"app"," and invoke it in-process, so the generated shim, the\n",[26,356,357],{},"[project.scripts]"," target and the packaging metadata are never touched. Coverage cannot see this\nat all — the only cover is the installed smoke test in CI.",[10,360,361,364,365,368],{},[34,362,363],{},"The error boundary."," Nothing in a normal suite raises an unexpected exception, so the\n",[26,366,367],{},"except Exception"," clause that maps a bug to exit code 70 is never entered. Make a test raise on\npurpose:",[82,370,374],{"className":371,"code":372,"language":373,"meta":87,"style":87},"language-python shiki shiki-themes github-light github-dark","def test_unexpected_error_exits_70(cli, monkeypatch):\n    monkeypatch.setattr(\"mytool.commands.sync.core.sync_directory\", _boom)\n\n    result = cli.invoke(app, [\"sync\", \"data\"])\n\n    assert result.exit_code == 70\n    assert \"internal error\" in result.stderr\n","python",[26,375,376,388,399,403,426,430,444],{"__ignoreMap":87},[91,377,378,382,385],{"class":93,"line":94},[91,379,381],{"class":380},"szBVR","def",[91,383,384],{"class":101}," test_unexpected_error_exits_70",[91,386,387],{"class":97},"(cli, monkeypatch):\n",[91,389,390,393,396],{"class":93,"line":117},[91,391,392],{"class":97},"    monkeypatch.setattr(",[91,394,395],{"class":123},"\"mytool.commands.sync.core.sync_directory\"",[91,397,398],{"class":97},", _boom)\n",[91,400,401],{"class":93,"line":129},[91,402,167],{"emptyLinePlaceholder":166},[91,404,405,408,411,414,417,420,423],{"class":93,"line":139},[91,406,407],{"class":97},"    result ",[91,409,410],{"class":380},"=",[91,412,413],{"class":97}," cli.invoke(app, [",[91,415,416],{"class":123},"\"sync\"",[91,418,419],{"class":97},", ",[91,421,422],{"class":123},"\"data\"",[91,424,425],{"class":97},"])\n",[91,427,428],{"class":93,"line":152},[91,429,167],{"emptyLinePlaceholder":166},[91,431,432,435,438,441],{"class":93,"line":163},[91,433,434],{"class":380},"    assert",[91,436,437],{"class":97}," result.exit_code ",[91,439,440],{"class":380},"==",[91,442,443],{"class":135}," 70\n",[91,445,446,448,451,454],{"class":93,"line":170},[91,447,434],{"class":380},[91,449,450],{"class":123}," \"internal error\"",[91,452,453],{"class":380}," in",[91,455,456],{"class":97}," result.stderr\n",[10,458,459,462],{},[34,460,461],{},"Lazily imported command modules."," If commands resolve on demand, a suite that only tests three\nof them never imports the rest, and a broken import in a fourth stays hidden. An import-all test\ncovers it in five lines:",[82,464,466],{"className":371,"code":465,"language":373,"meta":87,"style":87},"def test_every_command_module_imports():\n    package = importlib.import_module(\"mytool.commands\")\n    for module in pkgutil.iter_modules(package.__path__):\n        importlib.import_module(f\"mytool.commands.{module.name}\")\n",[26,467,468,478,494,514],{"__ignoreMap":87},[91,469,470,472,475],{"class":93,"line":94},[91,471,381],{"class":380},[91,473,474],{"class":101}," test_every_command_module_imports",[91,476,477],{"class":97},"():\n",[91,479,480,483,485,488,491],{"class":93,"line":117},[91,481,482],{"class":97},"    package ",[91,484,410],{"class":380},[91,486,487],{"class":97}," importlib.import_module(",[91,489,490],{"class":123},"\"mytool.commands\"",[91,492,493],{"class":97},")\n",[91,495,496,499,502,505,508,511],{"class":93,"line":129},[91,497,498],{"class":380},"    for",[91,500,501],{"class":97}," module ",[91,503,504],{"class":380},"in",[91,506,507],{"class":97}," pkgutil.iter_modules(package.",[91,509,510],{"class":135},"__path__",[91,512,513],{"class":97},"):\n",[91,515,516,519,522,525,528,531,534,537],{"class":93,"line":139},[91,517,518],{"class":97},"        importlib.import_module(",[91,520,521],{"class":380},"f",[91,523,524],{"class":123},"\"mytool.commands.",[91,526,527],{"class":135},"{",[91,529,530],{"class":97},"module.name",[91,532,533],{"class":135},"}",[91,535,536],{"class":123},"\"",[91,538,493],{"class":97},[10,540,541,544,545,548,549,552,553,558],{},[34,542,543],{},"The terminal-only branch."," Under the runner, ",[26,546,547],{},"isatty()"," is ",[26,550,551],{},"False",", so the colour, progress and\nprompt paths are never taken. Route the check through one helper and override it in a test, as the\n",[554,555,557],"a",{"href":556},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-interactive-prompts-and-stdin\u002F","prompt guide","\ndescribes.",[14,560,562],{"id":561},"measuring-across-subprocesses","Measuring across subprocesses",[10,564,565,566,569],{},"If any part of your suite spawns a process — a smoke test, a completion test, a ",[26,567,568],{},"subprocess.run","\nof your own entry point — coverage needs to be told, or those lines appear untested.",[82,571,573],{"className":84,"code":572,"language":86,"meta":87,"style":87},"[tool.coverage.run]\nparallel = true\nconcurrency = [\"multiprocessing\"]\n",[26,574,575,591,597],{"__ignoreMap":87},[91,576,577,579,581,583,585,587,589],{"class":93,"line":94},[91,578,98],{"class":97},[91,580,102],{"class":101},[91,582,76],{"class":97},[91,584,68],{"class":101},[91,586,76],{"class":97},[91,588,111],{"class":101},[91,590,114],{"class":97},[91,592,593,595],{"class":93,"line":117},[91,594,142],{"class":97},[91,596,136],{"class":135},[91,598,599,602,605],{"class":93,"line":129},[91,600,601],{"class":97},"concurrency = [",[91,603,604],{"class":123},"\"multiprocessing\"",[91,606,114],{"class":97},[82,608,610],{"className":316,"code":609,"language":318,"meta":87,"style":87},"COVERAGE_PROCESS_START=pyproject.toml pytest --cov\ncoverage combine\ncoverage report\n",[26,611,612,627,634],{"__ignoreMap":87},[91,613,614,617,619,621,624],{"class":93,"line":94},[91,615,616],{"class":97},"COVERAGE_PROCESS_START",[91,618,410],{"class":380},[91,620,75],{"class":123},[91,622,623],{"class":101}," pytest",[91,625,626],{"class":135}," --cov\n",[91,628,629,631],{"class":93,"line":117},[91,630,68],{"class":101},[91,632,633],{"class":123}," combine\n",[91,635,636,638],{"class":93,"line":129},[91,637,68],{"class":101},[91,639,640],{"class":123}," report\n",[10,642,643,644,646,647,650],{},"The ",[26,645,616],{}," variable makes child processes start measuring; ",[26,648,649],{},"coverage combine","\nmerges the resulting data files. It is worth setting up once if you have even a couple of\nsubprocess tests, because otherwise their lines drag the number down and people start ignoring it.",[14,652,654],{"id":653},"turning-a-gap-into-a-test","Turning a gap into a test",[256,656],{"name":657},"coverage-gap-flow",[10,659,660],{},"The workflow that keeps coverage useful is triage rather than accumulation. For each uncovered\nbranch, ask whether a user can reach it.",[10,662,663,664,667],{},"If they can, write the test — and note that the ",[303,665,666],{},"interesting"," gaps are almost always error paths,\nwhich is exactly where CLI bugs live.",[10,669,670],{},"If they cannot, exclude it with a reason:",[82,672,674],{"className":371,"code":673,"language":373,"meta":87,"style":87},"if sys.platform == \"win32\":  # pragma: no cover - exercised on the Windows CI job\n    ...\n",[26,675,676,695],{"__ignoreMap":87},[91,677,678,681,684,686,689,692],{"class":93,"line":94},[91,679,680],{"class":380},"if",[91,682,683],{"class":97}," sys.platform ",[91,685,440],{"class":380},[91,687,688],{"class":123}," \"win32\"",[91,690,691],{"class":97},":  ",[91,693,694],{"class":148},"# pragma: no cover - exercised on the Windows CI job\n",[91,696,697],{"class":93,"line":117},[91,698,699],{"class":135},"    ...\n",[10,701,702,703,706],{},"A bare ",[26,704,705],{},"# pragma: no cover"," with no explanation is how genuinely untested code gets normalised.\nRequiring a reason in review keeps the exclusions honest, and the comment usually reveals whether\nthe branch should have a test after all.",[14,708,710],{"id":709},"setting-a-floor-rather-than-a-target","Setting a floor rather than a target",[82,712,714],{"className":84,"code":713,"language":86,"meta":87,"style":87},"fail_under = 85\n",[26,715,716],{"__ignoreMap":87},[91,717,718,720],{"class":93,"line":94},[91,719,207],{"class":97},[91,721,210],{"class":135},[10,723,724],{},"Pick a number you already clear, and raise it when the figure improves. That turns coverage into\na ratchet: it cannot silently fall, and nobody is under pressure to write meaningless tests to\nreach an aspirational figure.",[10,726,727,728,731,732,225,735,738],{},"What to avoid is a target high enough that people write tests for the number. A test that invokes\na function and asserts nothing raises coverage and proves nothing — worse, it makes the report\nlie. If you want a signal about test ",[303,729,730],{},"quality"," rather than reach, mutation testing (",[26,733,734],{},"mutmut",[26,736,737],{},"cosmic-ray",") answers that question honestly, at a much higher runtime cost.",[10,740,741,742,745],{},"For a pull request, a diff-coverage check is more useful than the absolute number: it asks whether\n",[303,743,744],{},"the lines you changed"," are tested, which is the question a reviewer actually cares about.",[14,747,749],{"id":748},"ux-considerations","UX considerations",[10,751,752],{},"Coverage output is read by people, so its ergonomics matter.",[10,754,755,758,759,762],{},[26,756,757],{},"show_missing = true"," prints the uncovered line numbers next to each file, which is the difference\nbetween a report you act on and a number you glance at. ",[26,760,761],{},"skip_covered = true"," hides fully covered\nfiles so the list is short enough to read. And the HTML report is worth generating locally —\nclicking through to see which branch of a conditional was never taken is far faster than reading\nline numbers.",[10,764,765],{},"In CI, print the terminal report and upload the HTML as an artifact. Posting the percentage as a\npull-request comment tends to produce arguments about the number rather than conversations about\nthe missing tests.",[14,767,769],{"id":768},"testing-the-behaviour","Testing the behaviour",[10,771,772,773,776],{},"Coverage configuration is itself worth one sanity check, because a misconfigured ",[26,774,775],{},"source"," silently\nmeasures nothing:",[82,778,780],{"className":371,"code":779,"language":373,"meta":87,"style":87},"def test_coverage_measures_the_package():\n    config = tomllib.loads(Path(\"pyproject.toml\").read_text())\n    run = config[\"tool\"][\"coverage\"][\"run\"]\n\n    assert run[\"source\"] == [\"src\"]\n    assert run[\"branch\"] is True\n",[26,781,782,791,807,833,837,859],{"__ignoreMap":87},[91,783,784,786,789],{"class":93,"line":94},[91,785,381],{"class":380},[91,787,788],{"class":101}," test_coverage_measures_the_package",[91,790,477],{"class":97},[91,792,793,796,798,801,804],{"class":93,"line":117},[91,794,795],{"class":97},"    config ",[91,797,410],{"class":380},[91,799,800],{"class":97}," tomllib.loads(Path(",[91,802,803],{"class":123},"\"pyproject.toml\"",[91,805,806],{"class":97},").read_text())\n",[91,808,809,812,814,817,820,823,826,828,831],{"class":93,"line":129},[91,810,811],{"class":97},"    run ",[91,813,410],{"class":380},[91,815,816],{"class":97}," config[",[91,818,819],{"class":123},"\"tool\"",[91,821,822],{"class":97},"][",[91,824,825],{"class":123},"\"coverage\"",[91,827,822],{"class":97},[91,829,830],{"class":123},"\"run\"",[91,832,114],{"class":97},[91,834,835],{"class":93,"line":139},[91,836,167],{"emptyLinePlaceholder":166},[91,838,839,841,844,847,850,852,855,857],{"class":93,"line":152},[91,840,434],{"class":380},[91,842,843],{"class":97}," run[",[91,845,846],{"class":123},"\"source\"",[91,848,849],{"class":97},"] ",[91,851,440],{"class":380},[91,853,854],{"class":97}," [",[91,856,124],{"class":123},[91,858,114],{"class":97},[91,860,861,863,865,868,870,873],{"class":93,"line":163},[91,862,434],{"class":380},[91,864,843],{"class":97},[91,866,867],{"class":123},"\"branch\"",[91,869,849],{"class":97},[91,871,872],{"class":380},"is",[91,874,875],{"class":135}," True\n",[10,877,878,879,882],{},"It looks pedantic and it catches the day someone adds an ",[26,880,881],{},"omit"," entry that swallows half the\npackage while chasing a green build.",[14,884,886],{"id":885},"conclusion","Conclusion",[10,888,889],{},"Configure coverage to measure your source with branches on, set a floor you already clear, and use\nthe report to find untested paths rather than to produce a number. Then remember what it cannot\nsee — the installed entry point, lazily imported commands, the terminal-only branches — and cover\nthose deliberately, because they are the paths a user meets first.",[14,891,893],{"id":892},"frequently-asked-questions","Frequently asked questions",[895,896,898],"h3",{"id":897},"what-coverage-percentage-should-a-cli-aim-for","What coverage percentage should a CLI aim for?",[10,900,901],{},"There is no correct figure, but 80–90% is where most well-tested CLI projects land once the error\npaths are covered. Chasing the last few percent usually means testing generated code, defensive\nbranches and platform-specific lines, which costs more than it returns.",[895,903,905],{"id":904},"why-does-my-coverage-drop-when-i-add-lazy-imports","Why does my coverage drop when I add lazy imports?",[10,907,908],{},"Because the modules are no longer imported during collection, so their lines are only measured if a\ntest actually invokes that command. Add the import-all test described above: it restores the\nmeasurement and, more importantly, catches a broken import that lazy loading would otherwise hide\nuntil a user ran that command.",[895,910,912],{"id":911},"should-test-files-be-included-in-the-measurement","Should test files be included in the measurement?",[10,914,915,916,918],{},"No. Measuring tests inflates the number and tells you nothing — a test file is executed by\ndefinition. ",[26,917,268],{}," excludes them automatically, which is another reason to prefer it\nover measuring by import.",[895,920,922],{"id":921},"how-do-i-cover-code-that-only-runs-on-another-platform","How do I cover code that only runs on another platform?",[10,924,925,926,929],{},"Run the suite on that platform in CI and combine the data, or exclude it with a ",[26,927,928],{},"pragma"," naming\nthe job that covers it. What to avoid is excluding it silently, which is how a Windows-only branch\nstays broken for a year.",[895,931,933],{"id":932},"does-coverage-slow-the-test-suite-down","Does coverage slow the test suite down?",[10,935,936,937,939,940,943],{},"Measurably but rarely painfully — expect roughly 10–30% on a Python suite. If it becomes an issue,\nrun coverage in CI and without it locally; ",[26,938,325],{}," with no ",[26,941,942],{},"--cov"," is the fast loop, and the CI\nrun is where the floor is enforced anyway.",[895,945,947],{"id":946},"is-diff-coverage-better-than-a-global-threshold","Is diff coverage better than a global threshold?",[10,949,950,951,954],{},"For reviewing a change, yes. A global floor stops the number falling; diff coverage answers the\nquestion a reviewer actually has, which is whether the lines in ",[303,952,953],{},"this"," pull request are tested.\nRunning both is common: the floor protects the project, the diff check guides the review.",[14,956,958],{"id":957},"related","Related",[19,960,961,968,975,980,987],{},[22,962,963,964],{},"Up: ",[554,965,967],{"href":966},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002F","Testing Python CLI applications",[22,969,970,971],{},"Sideways: ",[554,972,974],{"href":973},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fsnapshot-testing-cli-output\u002F","Snapshot testing CLI output",[22,976,970,977],{},[554,978,979],{"href":556},"Testing interactive prompts and stdin",[22,981,982,983],{},"Related: ",[554,984,986],{"href":985},"\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects\u002F","Pre-commit hooks for CLI projects",[22,988,982,989],{},[554,990,992],{"href":991},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Flazy-loading-subcommands-for-faster-startup\u002F","Lazy loading subcommands for faster startup",[994,995,996],"style",{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}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);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}",{"title":87,"searchDepth":117,"depth":117,"links":998},[999,1000,1001,1002,1003,1004,1005,1006,1007,1008,1009,1017],{"id":16,"depth":117,"text":17},{"id":57,"depth":117,"text":58},{"id":79,"depth":117,"text":80},{"id":337,"depth":117,"text":338},{"id":561,"depth":117,"text":562},{"id":653,"depth":117,"text":654},{"id":709,"depth":117,"text":710},{"id":748,"depth":117,"text":749},{"id":768,"depth":117,"text":769},{"id":885,"depth":117,"text":886},{"id":892,"depth":117,"text":893,"children":1010},[1011,1012,1013,1014,1015,1016],{"id":897,"depth":129,"text":898},{"id":904,"depth":129,"text":905},{"id":911,"depth":129,"text":912},{"id":921,"depth":129,"text":922},{"id":932,"depth":129,"text":933},{"id":946,"depth":129,"text":947},{"id":957,"depth":117,"text":958},"2026-08-01","Configure coverage.py for a Python CLI - branch coverage, subprocess measurement, sensible exclusions, and the code paths the numbers quietly miss.","intermediate",false,"md",{},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmeasuring-cli-test-coverage",{"title":5,"description":1019},"modern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmeasuring-cli-test-coverage\u002Findex",[1028,68,325,1029,730],"testing","ci","0ZYDp78t7y6GUXzaln93tVFDoBthOpyytmzKY2df5rU",[1032,1035,1038,1041,1044,1047,1050,1053,1056,1059,1062,1065,1068,1071,1074,1077,1080,1083,1086,1089,1092,1095,1098,1101,1104,1107,1110,1113,1116,1119,1122,1125,1128,1131,1134,1137,1140,1143,1146,1149,1152,1155,1158,1161,1164,1167,1170,1173,1176,1179,1180,1183,1186,1189,1192,1195,1198,1201,1204,1207,1210,1213,1216,1219,1222,1225,1228,1231,1234,1237,1240,1243,1246,1249,1252,1255,1258,1261,1264,1267,1270,1273,1276],{"path":1033,"title":1034},"\u002Fabout","About Python CLI Toolcraft",{"path":1036,"title":1037},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies","Advanced Argument Validation Strategies",{"path":1039,"title":1040},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fparsing-nested-json-arguments-in-python-clis","Parsing Nested JSON Args in Python CLIs",{"path":1042,"title":1043},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fvalidating-file-and-directory-paths-in-clis","Validating File and Directory Paths in CLIs",{"path":1045,"title":1046},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fadding-examples-and-epilogs-to-help-output","Adding Examples and Epilogs to Help Output",{"path":1048,"title":1049},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fgenerating-man-pages-and-docs-from-a-cli","Generating Man Pages and Docs from a CLI",{"path":1051,"title":1052},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation","CLI Help Output and Documentation",{"path":1054,"title":1055},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fversioning-and-deprecating-cli-flags","Versioning and Deprecating CLI Flags",{"path":1057,"title":1058},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fwriting-help-text-users-actually-read","Writing Help Text Users Actually Read",{"path":1060,"title":1061},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Fchoosing-exit-codes-for-cli-tools","Choosing Exit Codes for CLI Tools",{"path":1063,"title":1064},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Ffriendly-error-messages-and-tracebacks","Friendly Error Messages and Tracebacks",{"path":1066,"title":1067},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Fhandling-keyboard-interrupt-cleanly","Handling Keyboard Interrupt Cleanly",{"path":1069,"title":1070},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes","Error Handling and Exit Codes for CLIs",{"path":1072,"title":1073},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Fconfig-precedence-flags-env-files-defaults","Config Precedence: Flags, Env, Files, Defaults",{"path":1075,"title":1076},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars","Handling Config Files and Env Vars in CLIs",{"path":1078,"title":1079},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Floading-yaml-configs-safely-in-cli-apps","Loading YAML configs safely in CLI apps",{"path":1081,"title":1082},"\u002Fadvanced-input-parsing-user-experience","Advanced Input Parsing for Python CLIs",{"path":1084,"title":1085},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Fadding-progress-bars-and-spinners-to-python-clis","Progress Bars and Spinners for Python CLIs",{"path":1087,"title":1088},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich","Interactive Terminal UI with Rich",{"path":1090,"title":1091},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Frendering-tables-and-json-with-rich","Rendering Tables and JSON with Rich",{"path":1093,"title":1094},"\u002Fadvanced-input-parsing-user-experience\u002Fshell-completion-for-python-clis\u002Fenabling-tab-completion-in-click-and-typer","Enabling Tab Completion in Click and Typer",{"path":1096,"title":1097},"\u002Fadvanced-input-parsing-user-experience\u002Fshell-completion-for-python-clis","Shell Completion for Python CLIs",{"path":1099,"title":1100},"\u002Fadvanced-input-parsing-user-experience\u002Fshell-completion-for-python-clis\u002Finstalling-shell-completion-for-bash-zsh-fish","Installing Shell Completion for bash, zsh, fish",{"path":1102,"title":1103},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fadding-verbose-and-quiet-logging-flags","Adding Verbose and Quiet Logging Flags",{"path":1105,"title":1106},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps","Structured Logging for CLI Apps",{"path":1108,"title":1109},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fstructured-json-logging-in-python-clis","Structured JSON Logging in Python CLIs",{"path":1111,"title":1112},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Fdetecting-tty-and-adapting-output","Detecting a TTY and Adapting Output",{"path":1114,"title":1115},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Femitting-json-output-for-scripting","Emitting JSON Output for Scripting",{"path":1117,"title":1118},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Fhandling-broken-pipe-and-sigpipe","Handling Broken Pipe and SIGPIPE",{"path":1120,"title":1121},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes","Working with stdin, stdout and Pipes",{"path":1123,"title":1124},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Freading-piped-input-in-python-clis","Reading Piped Input in Python CLIs",{"path":1126,"title":1127},"\u002F","Python CLI Toolcraft",{"path":1129,"title":1130},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading","CLI Startup Performance and Lazy Loading",{"path":1132,"title":1133},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Flazy-loading-subcommands-for-faster-startup","Lazy Loading Subcommands for Faster Startup",{"path":1135,"title":1136},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Fprofiling-python-cli-startup-time","Profiling Python CLI Startup Time",{"path":1138,"title":1139},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Freducing-cli-dependency-weight","Reducing CLI Dependency Weight",{"path":1141,"title":1142},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fargparse-subparsers-for-subcommands","argparse Subparsers for Subcommands",{"path":1144,"title":1145},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fargparse-vs-click-vs-typer-comparison","argparse vs Click vs Typer Compared",{"path":1147,"title":1148},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse","Command-Line Parsing with argparse",{"path":1150,"title":1151},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fmigrating-from-argparse-to-typer","Migrating from argparse to Typer",{"path":1153,"title":1154},"\u002Fmodern-python-cli-frameworks-architecture","Python CLI Frameworks and Architecture",{"path":1156,"title":1157},"\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis","Plugin Architectures for Extensible CLIs",{"path":1159,"title":1160},"\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis\u002Fwriting-a-plugin-for-an-existing-cli","Writing a Plugin for an Existing CLI",{"path":1162,"title":1163},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fbest-practices-for-python-cli-entry-points","Best practices for Python CLI entry points",{"path":1165,"title":1166},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fdependency-injection-patterns-for-cli-commands","Dependency Injection Patterns for CLI Commands",{"path":1168,"title":1169},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fhow-to-structure-a-large-python-cli-project","Structuring a Large Python CLI Project",{"path":1171,"title":1172},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis","Structuring Multi-Command Python CLIs",{"path":1174,"title":1175},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fsharing-state-with-click-context-objects","Sharing State with Click Context Objects",{"path":1177,"title":1178},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications","Testing Python CLI Applications",{"path":1024,"title":5},{"path":1181,"title":1182},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmocking-filesystem-and-network-in-cli-tests","Mocking the Filesystem and Network in CLI Tests",{"path":1184,"title":1185},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fsnapshot-testing-cli-output","Snapshot Testing CLI Output",{"path":1187,"title":1188},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-click-commands-with-clirunner","Testing Click Commands with CliRunner",{"path":1190,"title":1191},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-interactive-prompts-and-stdin","Testing Interactive Prompts and stdin",{"path":1193,"title":1194},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Fbuilding-a-cli-with-subcommands-in-click","Building a CLI with subcommands in Click",{"path":1196,"title":1197},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Fconverting-a-click-app-to-typer","Converting a Click App to Typer",{"path":1199,"title":1200},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each","Typer vs Click: When to Use Each",{"path":1202,"title":1203},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Ftyper-callback-functions-explained","Typer callback functions explained",{"path":1205,"title":1206},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter\u002Fcopier-vs-cookiecutter-for-cli-templates","Copier vs Cookiecutter for CLI Templates",{"path":1208,"title":1209},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter","CLI Project Scaffolding with Cookiecutter",{"path":1211,"title":1212},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fbuilding-cross-platform-release-binaries-in-ci","Building Cross-Platform Release Binaries in CI",{"path":1214,"title":1215},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fbundling-a-python-cli-with-pyinstaller","Bundling a Python CLI with PyInstaller",{"path":1217,"title":1218},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fhomebrew-and-scoop-packaging-for-python-clis","Homebrew and Scoop Packaging for Python CLIs",{"path":1220,"title":1221},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries","Distributing CLIs as Standalone Binaries",{"path":1223,"title":1224},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fshipping-a-cli-as-a-zipapp-with-shiv","Shipping a CLI as a Zipapp with shiv",{"path":1226,"title":1227},"\u002Fproject-setup-dependency-management","Project Setup & Dependency Management",{"path":1229,"title":1230},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fautomating-changelogs-with-conventional-commits","Automating Changelogs with Conventional Commits",{"path":1232,"title":1233},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fexposing-version-info-and-build-metadata","Exposing Version Info and Build Metadata",{"path":1235,"title":1236},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs","Managing CLI Versioning & Changelogs",{"path":1238,"title":1239},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fbuilding-wheels-and-sdists-for-python-clis","Building Wheels and sdists for Python CLIs",{"path":1241,"title":1242},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution","Packaging Python CLIs for Distribution",{"path":1244,"title":1245},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Finstalling-and-distributing-clis-with-pipx","Installing and Distributing CLIs with pipx",{"path":1247,"title":1248},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fpublishing-a-python-cli-to-pypi","Publishing a Python CLI to PyPI",{"path":1250,"title":1251},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development","Poetry Workflows for CLI Development",{"path":1253,"title":1254},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development\u002Fpoetry-entry-points-and-scripts-for-clis","Poetry Entry Points and Scripts for CLIs",{"path":1256,"title":1257},"\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects","Pre-commit Hooks for CLI Projects",{"path":1259,"title":1260},"\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects\u002Fsetting-up-pre-commit-for-python-cli-repos","Setting up pre-commit for Python CLI repos",{"path":1262,"title":1263},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management","uv for Python CLI Dependency Management",{"path":1265,"title":1266},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management\u002Fuv-init-vs-poetry-init-for-cli-tools","uv init vs poetry init for CLI tools",{"path":1268,"title":1269},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management\u002Fuv-tool-install-vs-pipx-for-clis","uv tool install vs pipx for CLIs",{"path":1271,"title":1272},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices","Python CLI Env Isolation Best Practices",{"path":1274,"title":1275},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fmanaging-virtual-environments-for-cross-platform-clis","Managing Python CLI Virtual Environments",{"path":1277,"title":1278},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fpinning-the-python-version-for-a-cli","Pinning the Python Version for a CLI",1785614690033]