[{"data":1,"prerenderedAt":2522},["ShallowReactive",2],{"page-\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002F":3,"content-directory":2276},{"id":4,"title":5,"body":6,"date":2260,"description":2261,"difficulty":2262,"draft":2263,"extension":2264,"meta":2265,"navigation":143,"path":2266,"seo":2267,"stem":2268,"tags":2269,"updated":2260,"__hash__":2275},"content\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Findex.md","Testing Python CLI Applications",{"type":7,"value":8,"toc":2233},"minimark",[9,13,16,21,61,65,69,83,86,89,92,95,99,106,278,281,284,290,308,317,320,323,467,471,474,477,486,489,496,502,728,731,738,752,756,759,762,952,955,959,962,1016,1019,1026,1030,1033,1036,1187,1206,1210,1217,1372,1379,1382,1508,1515,1519,1522,1730,1752,1763,1767,1770,1784,1790,1858,1865,1878,1882,1885,1891,1908,1914,1921,1925,1928,1936,1955,1962,2022,2028,2032,2035,2048,2054,2060,2066,2070,2107,2111,2116,2119,2123,2133,2137,2143,2151,2154,2158,2163,2167,2170,2174,2191,2195,2229],[10,11,12],"p",{},"A command-line tool is unusually easy to test badly. Everything it does is observable from\noutside — an exit code, some text on a stream, a file that appeared — which tempts people into\ntesting it entirely through subprocesses. That works, and it produces a suite so slow that\nnobody runs it.",[10,14,15],{},"This section is about the other approach: a small number of invocation-level tests that pin the\ninterface, sitting on top of ordinary unit tests that prove the behaviour.",[17,18,20],"h2",{"id":19},"tldr","TL;DR",[22,23,24,38,48,51,58],"ul",{},[25,26,27,28,32,33,37],"li",{},"Invoke commands with ",[29,30,31],"code",{},"CliRunner"," ",[34,35,36],"strong",{},"in-process",", never through a subprocess.",[25,39,40,41,32,44,47],{},"Assert on ",[29,42,43],{},"exit_code",[34,45,46],{},"first",", output second.",[25,49,50],{},"Keep the interesting assertions in unit tests of plain functions; runner tests should prove\nthat flags map to arguments and that failures produce the documented code.",[25,52,53,54,57],{},"Use ",[29,55,56],{},"tmp_path"," for real files, a mock transport for HTTP, and injected values for time.",[25,59,60],{},"Add exactly one test that installs the built wheel and runs the command.",[62,63],"inline-diagram",{"name":64},"testing-section-map",[17,66,68],{"id":67},"where-the-tests-should-sit","Where the tests should sit",[10,70,71,72,77,78,82],{},"The layering from ",[73,74,76],"a",{"href":75},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002F","structuring multi-command CLIs","\nis what makes a fast suite possible. When the logic lives in plain functions, the tests that cover\nit need no parsing, no runner and no filesystem — and the tests that ",[79,80,81],"em",{},"do"," need a runner become few\nand cheap.",[62,84],{"name":85},"test-pyramid-cli",[10,87,88],{},"The shape is not an aesthetic preference; it falls out of the cost of each kind of test.",[62,90],{"name":91},"test-speed-budget",[10,93,94],{},"Two hundred subprocess tests is a minute of waiting before you learn anything. The same coverage\nthrough unit tests is under a second, and the difference decides whether people run the suite\nbefore pushing or after CI complains.",[17,96,98],{"id":97},"the-runner-in-one-page","The runner, in one page",[10,100,101,102,105],{},"Both Click and Typer ship the same runner; Typer simply re-exports it. It replaces ",[29,103,104],{},"argv",",\ncaptures the streams and calls your application in the current process.",[107,108,113],"pre",{"className":109,"code":110,"language":111,"meta":112,"style":112},"language-python shiki shiki-themes github-light github-dark","from typer.testing import CliRunner      # or: from click.testing import CliRunner\n\nfrom mytool.cli import app\n\nrunner = CliRunner()\n\ndef test_sync_reports_what_it_uploaded(tmp_path):\n    (tmp_path \u002F \"a.txt\").write_text(\"x\")\n\n    result = runner.invoke(app, [\"sync\", str(tmp_path)])\n\n    assert result.exit_code == 0\n    assert \"1 uploaded\" in result.stdout\n","python","",[29,114,115,138,145,158,163,175,180,193,215,220,244,249,264],{"__ignoreMap":112},[116,117,120,124,128,131,134],"span",{"class":118,"line":119},"line",1,[116,121,123],{"class":122},"szBVR","from",[116,125,127],{"class":126},"sVt8B"," typer.testing ",[116,129,130],{"class":122},"import",[116,132,133],{"class":126}," CliRunner      ",[116,135,137],{"class":136},"sJ8bj","# or: from click.testing import CliRunner\n",[116,139,141],{"class":118,"line":140},2,[116,142,144],{"emptyLinePlaceholder":143},true,"\n",[116,146,148,150,153,155],{"class":118,"line":147},3,[116,149,123],{"class":122},[116,151,152],{"class":126}," mytool.cli ",[116,154,130],{"class":122},[116,156,157],{"class":126}," app\n",[116,159,161],{"class":118,"line":160},4,[116,162,144],{"emptyLinePlaceholder":143},[116,164,166,169,172],{"class":118,"line":165},5,[116,167,168],{"class":126},"runner ",[116,170,171],{"class":122},"=",[116,173,174],{"class":126}," CliRunner()\n",[116,176,178],{"class":118,"line":177},6,[116,179,144],{"emptyLinePlaceholder":143},[116,181,183,186,190],{"class":118,"line":182},7,[116,184,185],{"class":122},"def",[116,187,189],{"class":188},"sScJk"," test_sync_reports_what_it_uploaded",[116,191,192],{"class":126},"(tmp_path):\n",[116,194,196,199,202,206,209,212],{"class":118,"line":195},8,[116,197,198],{"class":126},"    (tmp_path ",[116,200,201],{"class":122},"\u002F",[116,203,205],{"class":204},"sZZnC"," \"a.txt\"",[116,207,208],{"class":126},").write_text(",[116,210,211],{"class":204},"\"x\"",[116,213,214],{"class":126},")\n",[116,216,218],{"class":118,"line":217},9,[116,219,144],{"emptyLinePlaceholder":143},[116,221,223,226,228,231,234,237,241],{"class":118,"line":222},10,[116,224,225],{"class":126},"    result ",[116,227,171],{"class":122},[116,229,230],{"class":126}," runner.invoke(app, [",[116,232,233],{"class":204},"\"sync\"",[116,235,236],{"class":126},", ",[116,238,240],{"class":239},"sj4cs","str",[116,242,243],{"class":126},"(tmp_path)])\n",[116,245,247],{"class":118,"line":246},11,[116,248,144],{"emptyLinePlaceholder":143},[116,250,252,255,258,261],{"class":118,"line":251},12,[116,253,254],{"class":122},"    assert",[116,256,257],{"class":126}," result.exit_code ",[116,259,260],{"class":122},"==",[116,262,263],{"class":239}," 0\n",[116,265,267,269,272,275],{"class":118,"line":266},13,[116,268,254],{"class":122},[116,270,271],{"class":204}," \"1 uploaded\"",[116,273,274],{"class":122}," in",[116,276,277],{"class":126}," result.stdout\n",[62,279],{"name":280},"clirunner-anatomy",[10,282,283],{},"Three habits make runner tests useful rather than incidental.",[10,285,286,289],{},[34,287,288],{},"Assert on the exit code first."," A test that only checks output passes cheerfully when the\ncommand failed for an unrelated reason — a missing fixture, a changed default — because the string\nhappened to appear in an error message.",[10,291,292,295,296,299,300,303,304,307],{},[34,293,294],{},"Separate the streams."," Construct the runner with ",[29,297,298],{},"CliRunner(mix_stderr=False)"," and assert\nagainst ",[29,301,302],{},"result.stdout"," and ",[29,305,306],{},"result.stderr"," explicitly. Whether a message went to the right stream\nis a real property that scripts depend on, and mixing them hides regressions.",[10,309,310,32,313,316],{},[34,311,312],{},"Pass real argument lists.",[29,314,315],{},"[\"sync\", \"--dry-run\", str(path)]"," is what a user types. Building the\nlist programmatically from your own constants means a renamed flag updates the test automatically,\nwhich is precisely the failure you wanted the test to catch.",[10,318,319],{},"A short table of invocations is worth writing once per project:",[62,321],{"name":322},"exit-code-test-matrix",[107,324,326],{"className":109,"code":325,"language":111,"meta":112,"style":112},"import pytest\n\n@pytest.mark.parametrize(\"argv,expected\", [\n    ([], 2),                                 # no subcommand: deliberate, documented\n    ([\"--help\"], 0),\n    ([\"--version\"], 0),\n    ([\"sync\"], 2),                           # missing required argument\n    ([\"sync\", \".\u002Fmissing\"], 66),             # documented \"no input\" code\n])\ndef test_documented_exit_codes(argv, expected, tmp_path, monkeypatch):\n    monkeypatch.chdir(tmp_path)\n    assert runner.invoke(app, argv).exit_code == expected\n",[29,327,328,335,339,353,367,384,397,413,435,440,450,455],{"__ignoreMap":112},[116,329,330,332],{"class":118,"line":119},[116,331,130],{"class":122},[116,333,334],{"class":126}," pytest\n",[116,336,337],{"class":118,"line":140},[116,338,144],{"emptyLinePlaceholder":143},[116,340,341,344,347,350],{"class":118,"line":147},[116,342,343],{"class":188},"@pytest.mark.parametrize",[116,345,346],{"class":126},"(",[116,348,349],{"class":204},"\"argv,expected\"",[116,351,352],{"class":126},", [\n",[116,354,355,358,361,364],{"class":118,"line":160},[116,356,357],{"class":126},"    ([], ",[116,359,360],{"class":239},"2",[116,362,363],{"class":126},"),                                 ",[116,365,366],{"class":136},"# no subcommand: deliberate, documented\n",[116,368,369,372,375,378,381],{"class":118,"line":165},[116,370,371],{"class":126},"    ([",[116,373,374],{"class":204},"\"--help\"",[116,376,377],{"class":126},"], ",[116,379,380],{"class":239},"0",[116,382,383],{"class":126},"),\n",[116,385,386,388,391,393,395],{"class":118,"line":177},[116,387,371],{"class":126},[116,389,390],{"class":204},"\"--version\"",[116,392,377],{"class":126},[116,394,380],{"class":239},[116,396,383],{"class":126},[116,398,399,401,403,405,407,410],{"class":118,"line":182},[116,400,371],{"class":126},[116,402,233],{"class":204},[116,404,377],{"class":126},[116,406,360],{"class":239},[116,408,409],{"class":126},"),                           ",[116,411,412],{"class":136},"# missing required argument\n",[116,414,415,417,419,421,424,426,429,432],{"class":118,"line":195},[116,416,371],{"class":126},[116,418,233],{"class":204},[116,420,236],{"class":126},[116,422,423],{"class":204},"\".\u002Fmissing\"",[116,425,377],{"class":126},[116,427,428],{"class":239},"66",[116,430,431],{"class":126},"),             ",[116,433,434],{"class":136},"# documented \"no input\" code\n",[116,436,437],{"class":118,"line":217},[116,438,439],{"class":126},"])\n",[116,441,442,444,447],{"class":118,"line":222},[116,443,185],{"class":122},[116,445,446],{"class":188}," test_documented_exit_codes",[116,448,449],{"class":126},"(argv, expected, tmp_path, monkeypatch):\n",[116,451,452],{"class":118,"line":246},[116,453,454],{"class":126},"    monkeypatch.chdir(tmp_path)\n",[116,456,457,459,462,464],{"class":118,"line":251},[116,458,254],{"class":122},[116,460,461],{"class":126}," runner.invoke(app, argv).exit_code ",[116,463,260],{"class":122},[116,465,466],{"class":126}," expected\n",[17,468,470],{"id":469},"isolating-the-world","Isolating the world",[10,472,473],{},"A test is only fast and repeatable if it does not touch anything shared. Three sources of\nnon-determinism cover almost every flaky CLI test.",[62,475],{"name":476},"test-double-choices",[10,478,479,482,483,485],{},[34,480,481],{},"The filesystem"," is the easy one: ",[29,484,56],{}," gives each test its own directory with absolute\npaths, and pytest removes it afterwards.",[62,487],{"name":488},"tmp-path-layout",[10,490,491,492,495],{},"Prefer a real temporary file over a mocked ",[29,493,494],{},"open()",". The code under test then behaves the way it\nwill in production, including the encoding, the permissions and the error you get when the file is\nmissing.",[10,497,498,501],{},[34,499,500],{},"The network"," should be intercepted at the transport, not at your own functions:",[107,503,505],{"className":109,"code":504,"language":111,"meta":112,"style":112},"import httpx\n\ndef test_deploy_sends_the_right_payload():\n    seen = {}\n\n    def handler(request: httpx.Request) -> httpx.Response:\n        seen[\"url\"] = str(request.url)\n        seen[\"json\"] = json.loads(request.content)\n        return httpx.Response(200, json={\"id\": \"dep_123\"})\n\n    client = httpx.Client(transport=httpx.MockTransport(handler))\n    result = core.deploy(client, environment=\"prod\", replicas=3)\n\n    assert seen[\"url\"].endswith(\"\u002Fdeployments\")\n    assert seen[\"json\"][\"replicas\"] == 3\n    assert result.id == \"dep_123\"\n",[29,506,507,514,518,528,538,542,553,572,586,620,624,642,671,675,693,715],{"__ignoreMap":112},[116,508,509,511],{"class":118,"line":119},[116,510,130],{"class":122},[116,512,513],{"class":126}," httpx\n",[116,515,516],{"class":118,"line":140},[116,517,144],{"emptyLinePlaceholder":143},[116,519,520,522,525],{"class":118,"line":147},[116,521,185],{"class":122},[116,523,524],{"class":188}," test_deploy_sends_the_right_payload",[116,526,527],{"class":126},"():\n",[116,529,530,533,535],{"class":118,"line":160},[116,531,532],{"class":126},"    seen ",[116,534,171],{"class":122},[116,536,537],{"class":126}," {}\n",[116,539,540],{"class":118,"line":165},[116,541,144],{"emptyLinePlaceholder":143},[116,543,544,547,550],{"class":118,"line":177},[116,545,546],{"class":122},"    def",[116,548,549],{"class":188}," handler",[116,551,552],{"class":126},"(request: httpx.Request) -> httpx.Response:\n",[116,554,555,558,561,564,566,569],{"class":118,"line":182},[116,556,557],{"class":126},"        seen[",[116,559,560],{"class":204},"\"url\"",[116,562,563],{"class":126},"] ",[116,565,171],{"class":122},[116,567,568],{"class":239}," str",[116,570,571],{"class":126},"(request.url)\n",[116,573,574,576,579,581,583],{"class":118,"line":195},[116,575,557],{"class":126},[116,577,578],{"class":204},"\"json\"",[116,580,563],{"class":126},[116,582,171],{"class":122},[116,584,585],{"class":126}," json.loads(request.content)\n",[116,587,588,591,594,597,599,603,605,608,611,614,617],{"class":118,"line":217},[116,589,590],{"class":122},"        return",[116,592,593],{"class":126}," httpx.Response(",[116,595,596],{"class":239},"200",[116,598,236],{"class":126},[116,600,602],{"class":601},"s4XuR","json",[116,604,171],{"class":122},[116,606,607],{"class":126},"{",[116,609,610],{"class":204},"\"id\"",[116,612,613],{"class":126},": ",[116,615,616],{"class":204},"\"dep_123\"",[116,618,619],{"class":126},"})\n",[116,621,622],{"class":118,"line":222},[116,623,144],{"emptyLinePlaceholder":143},[116,625,626,629,631,634,637,639],{"class":118,"line":246},[116,627,628],{"class":126},"    client ",[116,630,171],{"class":122},[116,632,633],{"class":126}," httpx.Client(",[116,635,636],{"class":601},"transport",[116,638,171],{"class":122},[116,640,641],{"class":126},"httpx.MockTransport(handler))\n",[116,643,644,646,648,651,654,656,659,661,664,666,669],{"class":118,"line":251},[116,645,225],{"class":126},[116,647,171],{"class":122},[116,649,650],{"class":126}," core.deploy(client, ",[116,652,653],{"class":601},"environment",[116,655,171],{"class":122},[116,657,658],{"class":204},"\"prod\"",[116,660,236],{"class":126},[116,662,663],{"class":601},"replicas",[116,665,171],{"class":122},[116,667,668],{"class":239},"3",[116,670,214],{"class":126},[116,672,673],{"class":118,"line":266},[116,674,144],{"emptyLinePlaceholder":143},[116,676,678,680,683,685,688,691],{"class":118,"line":677},14,[116,679,254],{"class":122},[116,681,682],{"class":126}," seen[",[116,684,560],{"class":204},[116,686,687],{"class":126},"].endswith(",[116,689,690],{"class":204},"\"\u002Fdeployments\"",[116,692,214],{"class":126},[116,694,696,698,700,702,705,708,710,712],{"class":118,"line":695},15,[116,697,254],{"class":122},[116,699,682],{"class":126},[116,701,578],{"class":204},[116,703,704],{"class":126},"][",[116,706,707],{"class":204},"\"replicas\"",[116,709,563],{"class":126},[116,711,260],{"class":122},[116,713,714],{"class":239}," 3\n",[116,716,718,720,723,725],{"class":118,"line":717},16,[116,719,254],{"class":122},[116,721,722],{"class":126}," result.id ",[116,724,260],{"class":122},[116,726,727],{"class":204}," \"dep_123\"\n",[62,729],{"name":730},"network-isolation-flow",[10,732,733,734,737],{},"Patching your own ",[29,735,736],{},"deploy_to_remote"," function would make the test pass while proving nothing about\nthe request you actually build. Patching the transport keeps the request construction and the\nresponse parsing under test, which is where the bugs are.",[10,739,740,743,744,747,748,751],{},[34,741,742],{},"Time and randomness"," are best injected rather than patched. A function that takes ",[29,745,746],{},"now: datetime"," is trivially testable; one that calls ",[29,749,750],{},"datetime.now()"," internally needs monkeypatching in\nevery test that touches it.",[17,753,755],{"id":754},"fixtures-that-compose","Fixtures that compose",[10,757,758],{},"Most of the setup in a CLI suite is shared, and a handful of small fixtures keep individual tests\nto three lines.",[62,760],{"name":761},"fixture-layering",[107,763,765],{"className":109,"code":764,"language":111,"meta":112,"style":112},"# tests\u002Fconftest.py\nimport pytest\nfrom typer.testing import CliRunner\n\nfrom mytool.cli import app\n\n@pytest.fixture\ndef tmp_project(tmp_path):\n    (tmp_path \u002F \".mytool.toml\").write_text('region = \"eu-west-1\"\\nretries = 2\\n')\n    (tmp_path \u002F \"data\").mkdir()\n    (tmp_path \u002F \"data\" \u002F \"a.txt\").write_text(\"x\")\n    return tmp_path\n\n@pytest.fixture\ndef cli(tmp_project, monkeypatch):\n    monkeypatch.chdir(tmp_project)\n    monkeypatch.delenv(\"MYTOOL_RETRIES\", raising=False)   # a developer's shell must not leak in\n    return CliRunner(mix_stderr=False)\n",[29,766,767,772,778,789,793,803,807,812,821,848,860,879,887,891,895,905,910,935],{"__ignoreMap":112},[116,768,769],{"class":118,"line":119},[116,770,771],{"class":136},"# tests\u002Fconftest.py\n",[116,773,774,776],{"class":118,"line":140},[116,775,130],{"class":122},[116,777,334],{"class":126},[116,779,780,782,784,786],{"class":118,"line":147},[116,781,123],{"class":122},[116,783,127],{"class":126},[116,785,130],{"class":122},[116,787,788],{"class":126}," CliRunner\n",[116,790,791],{"class":118,"line":160},[116,792,144],{"emptyLinePlaceholder":143},[116,794,795,797,799,801],{"class":118,"line":165},[116,796,123],{"class":122},[116,798,152],{"class":126},[116,800,130],{"class":122},[116,802,157],{"class":126},[116,804,805],{"class":118,"line":177},[116,806,144],{"emptyLinePlaceholder":143},[116,808,809],{"class":118,"line":182},[116,810,811],{"class":188},"@pytest.fixture\n",[116,813,814,816,819],{"class":118,"line":195},[116,815,185],{"class":122},[116,817,818],{"class":188}," tmp_project",[116,820,192],{"class":126},[116,822,823,825,827,830,832,835,838,841,843,846],{"class":118,"line":217},[116,824,198],{"class":126},[116,826,201],{"class":122},[116,828,829],{"class":204}," \".mytool.toml\"",[116,831,208],{"class":126},[116,833,834],{"class":204},"'region = \"eu-west-1\"",[116,836,837],{"class":239},"\\n",[116,839,840],{"class":204},"retries = 2",[116,842,837],{"class":239},[116,844,845],{"class":204},"'",[116,847,214],{"class":126},[116,849,850,852,854,857],{"class":118,"line":222},[116,851,198],{"class":126},[116,853,201],{"class":122},[116,855,856],{"class":204}," \"data\"",[116,858,859],{"class":126},").mkdir()\n",[116,861,862,864,866,868,871,873,875,877],{"class":118,"line":246},[116,863,198],{"class":126},[116,865,201],{"class":122},[116,867,856],{"class":204},[116,869,870],{"class":122}," \u002F",[116,872,205],{"class":204},[116,874,208],{"class":126},[116,876,211],{"class":204},[116,878,214],{"class":126},[116,880,881,884],{"class":118,"line":251},[116,882,883],{"class":122},"    return",[116,885,886],{"class":126}," tmp_path\n",[116,888,889],{"class":118,"line":266},[116,890,144],{"emptyLinePlaceholder":143},[116,892,893],{"class":118,"line":677},[116,894,811],{"class":188},[116,896,897,899,902],{"class":118,"line":695},[116,898,185],{"class":122},[116,900,901],{"class":188}," cli",[116,903,904],{"class":126},"(tmp_project, monkeypatch):\n",[116,906,907],{"class":118,"line":717},[116,908,909],{"class":126},"    monkeypatch.chdir(tmp_project)\n",[116,911,913,916,919,921,924,926,929,932],{"class":118,"line":912},17,[116,914,915],{"class":126},"    monkeypatch.delenv(",[116,917,918],{"class":204},"\"MYTOOL_RETRIES\"",[116,920,236],{"class":126},[116,922,923],{"class":601},"raising",[116,925,171],{"class":122},[116,927,928],{"class":239},"False",[116,930,931],{"class":126},")   ",[116,933,934],{"class":136},"# a developer's shell must not leak in\n",[116,936,938,940,943,946,948,950],{"class":118,"line":937},18,[116,939,883],{"class":122},[116,941,942],{"class":126}," CliRunner(",[116,944,945],{"class":601},"mix_stderr",[116,947,171],{"class":122},[116,949,928],{"class":239},[116,951,214],{"class":126},[10,953,954],{},"That last line matters more than it looks. A suite that reads real environment variables passes on\nyour machine and fails on someone else's, and the failure is baffling because nothing in the test\nmentions the variable.",[17,956,958],{"id":957},"the-one-test-that-is-worth-a-subprocess","The one test that is worth a subprocess",[10,960,961],{},"Everything above runs in-process, which means none of it exercises the console script, the packaging\nmetadata or the entry point. Exactly one test should:",[107,963,967],{"className":964,"code":965,"language":966,"meta":112,"style":112},"language-bash shiki shiki-themes github-light github-dark","python -m build\npython -m venv \u002Ftmp\u002Fsmoke\n\u002Ftmp\u002Fsmoke\u002Fbin\u002Fpip install dist\u002F*.whl\n\u002Ftmp\u002Fsmoke\u002Fbin\u002Fmytool --version\n","bash",[29,968,969,979,991,1008],{"__ignoreMap":112},[116,970,971,973,976],{"class":118,"line":119},[116,972,111],{"class":188},[116,974,975],{"class":239}," -m",[116,977,978],{"class":204}," build\n",[116,980,981,983,985,988],{"class":118,"line":140},[116,982,111],{"class":188},[116,984,975],{"class":239},[116,986,987],{"class":204}," venv",[116,989,990],{"class":204}," \u002Ftmp\u002Fsmoke\n",[116,992,993,996,999,1002,1005],{"class":118,"line":147},[116,994,995],{"class":188},"\u002Ftmp\u002Fsmoke\u002Fbin\u002Fpip",[116,997,998],{"class":204}," install",[116,1000,1001],{"class":204}," dist\u002F",[116,1003,1004],{"class":239},"*",[116,1006,1007],{"class":204},".whl\n",[116,1009,1010,1013],{"class":118,"line":160},[116,1011,1012],{"class":188},"\u002Ftmp\u002Fsmoke\u002Fbin\u002Fmytool",[116,1014,1015],{"class":239}," --version\n",[62,1017],{"name":1018},"ci-test-stages",[10,1020,1021,1022,1025],{},"It belongs in CI rather than in the pytest suite, and it catches a class of failure nothing else\ncan see: a package missing from the wheel, an unlisted runtime dependency, a typo in\n",[29,1023,1024],{},"[project.scripts]",". Those are precisely the bugs that reach users while a green test suite says\neverything is fine.",[17,1027,1029],{"id":1028},"fakes-mocks-and-what-to-reach-for","Fakes, mocks and what to reach for",[62,1031],{"name":1032},"mock-vs-fake",[10,1034,1035],{},"A small hand-written fake is usually the better tool:",[107,1037,1039],{"className":109,"code":1038,"language":111,"meta":112,"style":112},"class FakeBucket:\n    def __init__(self) -> None:\n        self.uploaded: list[str] = []\n        self.current: set[str] = set()\n\n    def is_current(self, path) -> bool:\n        return str(path) in self.current\n\n    def upload(self, path, *, retries: int = 3) -> None:\n        self.uploaded.append(str(path))\n",[29,1040,1041,1052,1067,1084,1103,1107,1122,1140,1144,1175],{"__ignoreMap":112},[116,1042,1043,1046,1049],{"class":118,"line":119},[116,1044,1045],{"class":122},"class",[116,1047,1048],{"class":188}," FakeBucket",[116,1050,1051],{"class":126},":\n",[116,1053,1054,1056,1059,1062,1065],{"class":118,"line":140},[116,1055,546],{"class":122},[116,1057,1058],{"class":239}," __init__",[116,1060,1061],{"class":126},"(self) -> ",[116,1063,1064],{"class":239},"None",[116,1066,1051],{"class":126},[116,1068,1069,1072,1075,1077,1079,1081],{"class":118,"line":147},[116,1070,1071],{"class":239},"        self",[116,1073,1074],{"class":126},".uploaded: list[",[116,1076,240],{"class":239},[116,1078,563],{"class":126},[116,1080,171],{"class":122},[116,1082,1083],{"class":126}," []\n",[116,1085,1086,1088,1091,1093,1095,1097,1100],{"class":118,"line":160},[116,1087,1071],{"class":239},[116,1089,1090],{"class":126},".current: set[",[116,1092,240],{"class":239},[116,1094,563],{"class":126},[116,1096,171],{"class":122},[116,1098,1099],{"class":239}," set",[116,1101,1102],{"class":126},"()\n",[116,1104,1105],{"class":118,"line":165},[116,1106,144],{"emptyLinePlaceholder":143},[116,1108,1109,1111,1114,1117,1120],{"class":118,"line":177},[116,1110,546],{"class":122},[116,1112,1113],{"class":188}," is_current",[116,1115,1116],{"class":126},"(self, path) -> ",[116,1118,1119],{"class":239},"bool",[116,1121,1051],{"class":126},[116,1123,1124,1126,1128,1131,1134,1137],{"class":118,"line":182},[116,1125,590],{"class":122},[116,1127,568],{"class":239},[116,1129,1130],{"class":126},"(path) ",[116,1132,1133],{"class":122},"in",[116,1135,1136],{"class":239}," self",[116,1138,1139],{"class":126},".current\n",[116,1141,1142],{"class":118,"line":195},[116,1143,144],{"emptyLinePlaceholder":143},[116,1145,1146,1148,1151,1154,1156,1159,1162,1165,1168,1171,1173],{"class":118,"line":217},[116,1147,546],{"class":122},[116,1149,1150],{"class":188}," upload",[116,1152,1153],{"class":126},"(self, path, ",[116,1155,1004],{"class":122},[116,1157,1158],{"class":126},", retries: ",[116,1160,1161],{"class":239},"int",[116,1163,1164],{"class":122}," =",[116,1166,1167],{"class":239}," 3",[116,1169,1170],{"class":126},") -> ",[116,1172,1064],{"class":239},[116,1174,1051],{"class":126},[116,1176,1177,1179,1182,1184],{"class":118,"line":222},[116,1178,1071],{"class":239},[116,1180,1181],{"class":126},".uploaded.append(",[116,1183,240],{"class":239},[116,1185,1186],{"class":126},"(path))\n",[10,1188,1189,1190,1193,1194,1197,1198,1201,1202,1205],{},"It fails loudly with an ",[29,1191,1192],{},"AttributeError"," when the real interface gains a method, which is exactly\nwhen you want to know. A ",[29,1195,1196],{},"MagicMock"," accepts anything, so a renamed method leaves the test green\nand the code broken. Reach for ",[29,1199,1200],{},"unittest.mock"," when the interaction itself is the thing under test\n— \"did we call ",[29,1203,1204],{},"commit"," exactly once\" — and for a fake everywhere else.",[17,1207,1209],{"id":1208},"testing-the-layers-underneath","Testing the layers underneath",[10,1211,1212,1213,1216],{},"Runner tests prove the wiring. Everything about ",[79,1214,1215],{},"behaviour"," belongs one level down, where tests are\nan order of magnitude cheaper and the failures point at the right place.",[107,1218,1220],{"className":109,"code":1219,"language":111,"meta":112,"style":112},"# tests\u002Fcore\u002Ftest_sync.py — no runner, no parsing, no filesystem beyond tmp_path\ndef test_skips_files_that_are_already_current(tmp_path):\n    (tmp_path \u002F \"a.txt\").write_text(\"x\")\n    (tmp_path \u002F \"b.txt\").write_text(\"y\")\n    bucket = FakeBucket()\n    bucket.current.add(str(tmp_path \u002F \"a.txt\"))\n\n    result = sync_directory(tmp_path, bucket=bucket)\n\n    assert result == SyncResult(uploaded=1, skipped=1)\n    assert bucket.uploaded == [str(tmp_path \u002F \"b.txt\")]\n",[29,1221,1222,1227,1236,1250,1266,1276,1293,1297,1314,1318,1349],{"__ignoreMap":112},[116,1223,1224],{"class":118,"line":119},[116,1225,1226],{"class":136},"# tests\u002Fcore\u002Ftest_sync.py — no runner, no parsing, no filesystem beyond tmp_path\n",[116,1228,1229,1231,1234],{"class":118,"line":140},[116,1230,185],{"class":122},[116,1232,1233],{"class":188}," test_skips_files_that_are_already_current",[116,1235,192],{"class":126},[116,1237,1238,1240,1242,1244,1246,1248],{"class":118,"line":147},[116,1239,198],{"class":126},[116,1241,201],{"class":122},[116,1243,205],{"class":204},[116,1245,208],{"class":126},[116,1247,211],{"class":204},[116,1249,214],{"class":126},[116,1251,1252,1254,1256,1259,1261,1264],{"class":118,"line":160},[116,1253,198],{"class":126},[116,1255,201],{"class":122},[116,1257,1258],{"class":204}," \"b.txt\"",[116,1260,208],{"class":126},[116,1262,1263],{"class":204},"\"y\"",[116,1265,214],{"class":126},[116,1267,1268,1271,1273],{"class":118,"line":165},[116,1269,1270],{"class":126},"    bucket ",[116,1272,171],{"class":122},[116,1274,1275],{"class":126}," FakeBucket()\n",[116,1277,1278,1281,1283,1286,1288,1290],{"class":118,"line":177},[116,1279,1280],{"class":126},"    bucket.current.add(",[116,1282,240],{"class":239},[116,1284,1285],{"class":126},"(tmp_path ",[116,1287,201],{"class":122},[116,1289,205],{"class":204},[116,1291,1292],{"class":126},"))\n",[116,1294,1295],{"class":118,"line":182},[116,1296,144],{"emptyLinePlaceholder":143},[116,1298,1299,1301,1303,1306,1309,1311],{"class":118,"line":195},[116,1300,225],{"class":126},[116,1302,171],{"class":122},[116,1304,1305],{"class":126}," sync_directory(tmp_path, ",[116,1307,1308],{"class":601},"bucket",[116,1310,171],{"class":122},[116,1312,1313],{"class":126},"bucket)\n",[116,1315,1316],{"class":118,"line":217},[116,1317,144],{"emptyLinePlaceholder":143},[116,1319,1320,1322,1325,1327,1330,1333,1335,1338,1340,1343,1345,1347],{"class":118,"line":222},[116,1321,254],{"class":122},[116,1323,1324],{"class":126}," result ",[116,1326,260],{"class":122},[116,1328,1329],{"class":126}," SyncResult(",[116,1331,1332],{"class":601},"uploaded",[116,1334,171],{"class":122},[116,1336,1337],{"class":239},"1",[116,1339,236],{"class":126},[116,1341,1342],{"class":601},"skipped",[116,1344,171],{"class":122},[116,1346,1337],{"class":239},[116,1348,214],{"class":126},[116,1350,1351,1353,1356,1358,1361,1363,1365,1367,1369],{"class":118,"line":246},[116,1352,254],{"class":122},[116,1354,1355],{"class":126}," bucket.uploaded ",[116,1357,260],{"class":122},[116,1359,1360],{"class":126}," [",[116,1362,240],{"class":239},[116,1364,1285],{"class":126},[116,1366,201],{"class":122},[116,1368,1258],{"class":204},[116,1370,1371],{"class":126},")]\n",[10,1373,1374,1375,1378],{},"Notice what this test does not need: no ",[29,1376,1377],{},"app",", no argument list, no captured output, no exit code.\nIt states a rule about the domain and fails when that rule breaks. Parametrising it over a dozen\nedge cases — empty directory, nested directories, a symlink, a file that disappears mid-run — costs\nalmost nothing.",[10,1380,1381],{},"The corresponding runner test is then deliberately thin:",[107,1383,1385],{"className":109,"code":1384,"language":111,"meta":112,"style":112},"def test_dry_run_flag_reaches_the_core(cli, monkeypatch):\n    seen = {}\n    monkeypatch.setattr(\n        \"mytool.commands.sync.core.sync_directory\",\n        lambda source, **kwargs: seen.update(kwargs) or SyncResult(0, 0),\n    )\n\n    result = cli.invoke(app, [\"sync\", \"data\", \"--dry-run\"])\n\n    assert result.exit_code == 0\n    assert seen[\"dry_run\"] is True\n",[29,1386,1387,1397,1405,1410,1418,1445,1450,1454,1477,1481,1491],{"__ignoreMap":112},[116,1388,1389,1391,1394],{"class":118,"line":119},[116,1390,185],{"class":122},[116,1392,1393],{"class":188}," test_dry_run_flag_reaches_the_core",[116,1395,1396],{"class":126},"(cli, monkeypatch):\n",[116,1398,1399,1401,1403],{"class":118,"line":140},[116,1400,532],{"class":126},[116,1402,171],{"class":122},[116,1404,537],{"class":126},[116,1406,1407],{"class":118,"line":147},[116,1408,1409],{"class":126},"    monkeypatch.setattr(\n",[116,1411,1412,1415],{"class":118,"line":160},[116,1413,1414],{"class":204},"        \"mytool.commands.sync.core.sync_directory\"",[116,1416,1417],{"class":126},",\n",[116,1419,1420,1423,1426,1429,1432,1435,1437,1439,1441,1443],{"class":118,"line":165},[116,1421,1422],{"class":122},"        lambda",[116,1424,1425],{"class":126}," source, ",[116,1427,1428],{"class":122},"**",[116,1430,1431],{"class":126},"kwargs: seen.update(kwargs) ",[116,1433,1434],{"class":122},"or",[116,1436,1329],{"class":126},[116,1438,380],{"class":239},[116,1440,236],{"class":126},[116,1442,380],{"class":239},[116,1444,383],{"class":126},[116,1446,1447],{"class":118,"line":177},[116,1448,1449],{"class":126},"    )\n",[116,1451,1452],{"class":118,"line":182},[116,1453,144],{"emptyLinePlaceholder":143},[116,1455,1456,1458,1460,1463,1465,1467,1470,1472,1475],{"class":118,"line":195},[116,1457,225],{"class":126},[116,1459,171],{"class":122},[116,1461,1462],{"class":126}," cli.invoke(app, [",[116,1464,233],{"class":204},[116,1466,236],{"class":126},[116,1468,1469],{"class":204},"\"data\"",[116,1471,236],{"class":126},[116,1473,1474],{"class":204},"\"--dry-run\"",[116,1476,439],{"class":126},[116,1478,1479],{"class":118,"line":217},[116,1480,144],{"emptyLinePlaceholder":143},[116,1482,1483,1485,1487,1489],{"class":118,"line":222},[116,1484,254],{"class":122},[116,1486,257],{"class":126},[116,1488,260],{"class":122},[116,1490,263],{"class":239},[116,1492,1493,1495,1497,1500,1502,1505],{"class":118,"line":246},[116,1494,254],{"class":122},[116,1496,682],{"class":126},[116,1498,1499],{"class":204},"\"dry_run\"",[116,1501,563],{"class":126},[116,1503,1504],{"class":122},"is",[116,1506,1507],{"class":239}," True\n",[10,1509,1510,1511,1514],{},"One assertion about the flag, one about the exit code, and nothing about what syncing means. When\nsomeone renames ",[29,1512,1513],{},"--dry-run",", this fails; when someone changes how skipping works, it does not — and\nthat separation is what stops a suite becoming a wall of red for a single change.",[17,1516,1518],{"id":1517},"testing-configuration-and-precedence","Testing configuration and precedence",[10,1520,1521],{},"Configuration is the other place where CLI test suites go wrong, usually by testing precedence\nthrough the command line. Written as a function that takes its inputs, the whole precedence system\nis testable without a runner at all:",[107,1523,1525],{"className":109,"code":1524,"language":111,"meta":112,"style":112},"def test_flag_beats_environment_beats_file():\n    merged = resolve(\n        cli={\"retries\": 5},\n        env={\"retries\": \"9\"},\n        file={\"retries\": 7},\n        defaults={\"retries\": 3},\n    )\n    assert merged[\"retries\"] == 5\n\ndef test_unset_flag_does_not_clobber_the_file():\n    merged = resolve(cli={\"retries\": None}, env={}, file={\"retries\": 7}, defaults={\"retries\": 3})\n    assert merged[\"retries\"] == 7\n",[29,1526,1527,1536,1546,1566,1584,1602,1619,1623,1639,1643,1652,1715],{"__ignoreMap":112},[116,1528,1529,1531,1534],{"class":118,"line":119},[116,1530,185],{"class":122},[116,1532,1533],{"class":188}," test_flag_beats_environment_beats_file",[116,1535,527],{"class":126},[116,1537,1538,1541,1543],{"class":118,"line":140},[116,1539,1540],{"class":126},"    merged ",[116,1542,171],{"class":122},[116,1544,1545],{"class":126}," resolve(\n",[116,1547,1548,1551,1553,1555,1558,1560,1563],{"class":118,"line":147},[116,1549,1550],{"class":601},"        cli",[116,1552,171],{"class":122},[116,1554,607],{"class":126},[116,1556,1557],{"class":204},"\"retries\"",[116,1559,613],{"class":126},[116,1561,1562],{"class":239},"5",[116,1564,1565],{"class":126},"},\n",[116,1567,1568,1571,1573,1575,1577,1579,1582],{"class":118,"line":160},[116,1569,1570],{"class":601},"        env",[116,1572,171],{"class":122},[116,1574,607],{"class":126},[116,1576,1557],{"class":204},[116,1578,613],{"class":126},[116,1580,1581],{"class":204},"\"9\"",[116,1583,1565],{"class":126},[116,1585,1586,1589,1591,1593,1595,1597,1600],{"class":118,"line":165},[116,1587,1588],{"class":601},"        file",[116,1590,171],{"class":122},[116,1592,607],{"class":126},[116,1594,1557],{"class":204},[116,1596,613],{"class":126},[116,1598,1599],{"class":239},"7",[116,1601,1565],{"class":126},[116,1603,1604,1607,1609,1611,1613,1615,1617],{"class":118,"line":177},[116,1605,1606],{"class":601},"        defaults",[116,1608,171],{"class":122},[116,1610,607],{"class":126},[116,1612,1557],{"class":204},[116,1614,613],{"class":126},[116,1616,668],{"class":239},[116,1618,1565],{"class":126},[116,1620,1621],{"class":118,"line":182},[116,1622,1449],{"class":126},[116,1624,1625,1627,1630,1632,1634,1636],{"class":118,"line":195},[116,1626,254],{"class":122},[116,1628,1629],{"class":126}," merged[",[116,1631,1557],{"class":204},[116,1633,563],{"class":126},[116,1635,260],{"class":122},[116,1637,1638],{"class":239}," 5\n",[116,1640,1641],{"class":118,"line":217},[116,1642,144],{"emptyLinePlaceholder":143},[116,1644,1645,1647,1650],{"class":118,"line":222},[116,1646,185],{"class":122},[116,1648,1649],{"class":188}," test_unset_flag_does_not_clobber_the_file",[116,1651,527],{"class":126},[116,1653,1654,1656,1658,1661,1664,1666,1668,1670,1672,1674,1677,1680,1682,1685,1688,1690,1692,1694,1696,1698,1700,1703,1705,1707,1709,1711,1713],{"class":118,"line":246},[116,1655,1540],{"class":126},[116,1657,171],{"class":122},[116,1659,1660],{"class":126}," resolve(",[116,1662,1663],{"class":601},"cli",[116,1665,171],{"class":122},[116,1667,607],{"class":126},[116,1669,1557],{"class":204},[116,1671,613],{"class":126},[116,1673,1064],{"class":239},[116,1675,1676],{"class":126},"}, ",[116,1678,1679],{"class":601},"env",[116,1681,171],{"class":122},[116,1683,1684],{"class":126},"{}, ",[116,1686,1687],{"class":601},"file",[116,1689,171],{"class":122},[116,1691,607],{"class":126},[116,1693,1557],{"class":204},[116,1695,613],{"class":126},[116,1697,1599],{"class":239},[116,1699,1676],{"class":126},[116,1701,1702],{"class":601},"defaults",[116,1704,171],{"class":122},[116,1706,607],{"class":126},[116,1708,1557],{"class":204},[116,1710,613],{"class":126},[116,1712,668],{"class":239},[116,1714,619],{"class":126},[116,1716,1717,1719,1721,1723,1725,1727],{"class":118,"line":251},[116,1718,254],{"class":122},[116,1720,1629],{"class":126},[116,1722,1557],{"class":204},[116,1724,563],{"class":126},[116,1726,260],{"class":122},[116,1728,1729],{"class":239}," 7\n",[10,1731,1732,1733,1736,1737,1740,1741,1743,1744,1747,1748,1751],{},"Then one runner test per ",[79,1734,1735],{},"source"," proves the wiring: that ",[29,1738,1739],{},"--retries"," lands in ",[29,1742,1663],{},", that\n",[29,1745,1746],{},"MYTOOL_RETRIES"," is read, that ",[29,1749,1750],{},"--config"," is honoured. Three tests, rather than a combinatorial\nmatrix run through the CLI.",[10,1753,1754,1755,1758,1759,1762],{},"The trap to avoid is a test that leaves an environment variable set. ",[29,1756,1757],{},"monkeypatch.setenv"," undoes\nitself; ",[29,1760,1761],{},"os.environ[...] = ..."," does not, and the resulting failure appears in an unrelated test\nthat happens to run afterwards.",[17,1764,1766],{"id":1765},"making-failures-readable","Making failures readable",[10,1768,1769],{},"A test suite is a diagnostic tool, and a few habits make the diagnosis immediate.",[10,1771,1772,1775,1776,1779,1780,1783],{},[34,1773,1774],{},"Assert on structured values, not on rendered text",", wherever you have the choice.\n",[29,1777,1778],{},"assert result == SyncResult(uploaded=1, skipped=1)"," prints both sides on failure; ",[29,1781,1782],{},"assert \"1 uploaded\" in output"," prints a wall of captured text and leaves you searching.",[10,1785,1786,1789],{},[34,1787,1788],{},"Give the runner's result a chance to explain itself."," When a runner test fails unexpectedly, the\nexception is on the result object rather than raised:",[107,1791,1793],{"className":109,"code":1792,"language":111,"meta":112,"style":112},"def invoke(cli, argv):\n    result = cli.invoke(app, argv)\n    if result.exception and not isinstance(result.exception, SystemExit):\n        raise result.exception          # surface the real traceback in the test output\n    return result\n",[29,1794,1795,1805,1814,1840,1851],{"__ignoreMap":112},[116,1796,1797,1799,1802],{"class":118,"line":119},[116,1798,185],{"class":122},[116,1800,1801],{"class":188}," invoke",[116,1803,1804],{"class":126},"(cli, argv):\n",[116,1806,1807,1809,1811],{"class":118,"line":140},[116,1808,225],{"class":126},[116,1810,171],{"class":122},[116,1812,1813],{"class":126}," cli.invoke(app, argv)\n",[116,1815,1816,1819,1822,1825,1828,1831,1834,1837],{"class":118,"line":147},[116,1817,1818],{"class":122},"    if",[116,1820,1821],{"class":126}," result.exception ",[116,1823,1824],{"class":122},"and",[116,1826,1827],{"class":122}," not",[116,1829,1830],{"class":239}," isinstance",[116,1832,1833],{"class":126},"(result.exception, ",[116,1835,1836],{"class":239},"SystemExit",[116,1838,1839],{"class":126},"):\n",[116,1841,1842,1845,1848],{"class":118,"line":160},[116,1843,1844],{"class":122},"        raise",[116,1846,1847],{"class":126}," result.exception          ",[116,1849,1850],{"class":136},"# surface the real traceback in the test output\n",[116,1852,1853,1855],{"class":118,"line":165},[116,1854,883],{"class":122},[116,1856,1857],{"class":126}," result\n",[10,1859,1860,1861,1864],{},"That three-line helper turns \"exit code was 1, expected 0\" into the actual ",[29,1862,1863],{},"KeyError"," with its\ntraceback, which is usually the difference between a minute and twenty.",[10,1866,1867,32,1870,1873,1874,1877],{},[34,1868,1869],{},"Name tests after the rule, not the function.",[29,1871,1872],{},"test_missing_config_file_is_a_usage_error"," tells\nyou what broke from the summary line alone; ",[29,1875,1876],{},"test_main_2"," requires opening the file.",[17,1879,1881],{"id":1880},"what-to-do-about-slow-suites","What to do about slow suites",[10,1883,1884],{},"If a CLI suite is slow, the cause is nearly always one of three things, and each has a direct fix.",[10,1886,1887,1890],{},[34,1888,1889],{},"Subprocess tests."," Replace them with runner tests, keeping one packaged smoke test in CI. This\nis usually the entire problem.",[10,1892,1893,32,1896,1899,1900,1903,1904,1907],{},[34,1894,1895],{},"Heavy imports at collection time.",[29,1897,1898],{},"pytest"," imports every test module before running anything,\nso a module-level ",[29,1901,1902],{},"import pandas"," in a test file costs on every run, including ",[29,1905,1906],{},"pytest -k something_else",". Import inside the test, or inside a fixture.",[10,1909,1910,1913],{},[34,1911,1912],{},"Real sleeps and timeouts."," A retry test that waits two seconds three times is six seconds of\nnothing. Inject the sleep function, or pass a zero backoff in tests — the retry logic is what you\nare testing, not the waiting.",[10,1915,1916,1917,1920],{},"Measure before optimising: ",[29,1918,1919],{},"pytest --durations=10"," prints the ten slowest tests, and the answer is\nusually obvious from that list alone.",[17,1922,1924],{"id":1923},"a-suite-worth-copying","A suite worth copying",[10,1926,1927],{},"For a tool of any size, the following shape covers the ground without ceremony:",[107,1929,1934],{"className":1930,"code":1932,"language":1933},[1931],"language-text","tests\u002F\n  conftest.py                 # tmp_project, cli, fake collaborators\n  core\u002F\n    test_sync.py              # the rules, parametrised\n    test_config.py            # precedence, coercion, error messages\n  commands\u002F\n    test_sync_cmd.py          # flags, defaults, exit codes\n    test_status_cmd.py\n  test_cli_contract.py        # the invocation table: --help, --version, usage errors\n","text",[29,1935,1932],{"__ignoreMap":112},[10,1937,1938,1939,1942,1943,1946,1947,1950,1951,1954],{},"Four properties are doing the work. The tree ",[34,1940,1941],{},"mirrors the source",", so finding a file's tests is\nmechanical. Rules live in ",[29,1944,1945],{},"core\u002F"," tests where they are cheap to parametrise. Command tests are\n",[34,1948,1949],{},"thin"," and there is roughly one per command. And ",[29,1952,1953],{},"test_cli_contract.py"," holds the table of\ninvocations that must keep working — the closest thing a CLI has to a public API test.",[10,1956,1957,1958,1961],{},"Add to that a ",[29,1959,1960],{},"pyproject.toml"," section that makes the defaults sensible:",[107,1963,1967],{"className":1964,"code":1965,"language":1966,"meta":112,"style":112},"language-toml shiki shiki-themes github-light github-dark","[tool.pytest.ini_options]\naddopts = \"-q --strict-markers --strict-config\"\ntestpaths = [\"tests\"]\nfilterwarnings = [\"error\"]        # a new DeprecationWarning fails the build, deliberately\n","toml",[29,1968,1969,1990,1998,2008],{"__ignoreMap":112},[116,1970,1971,1974,1977,1980,1982,1984,1987],{"class":118,"line":119},[116,1972,1973],{"class":126},"[",[116,1975,1976],{"class":188},"tool",[116,1978,1979],{"class":126},".",[116,1981,1898],{"class":188},[116,1983,1979],{"class":126},[116,1985,1986],{"class":188},"ini_options",[116,1988,1989],{"class":126},"]\n",[116,1991,1992,1995],{"class":118,"line":140},[116,1993,1994],{"class":126},"addopts = ",[116,1996,1997],{"class":204},"\"-q --strict-markers --strict-config\"\n",[116,1999,2000,2003,2006],{"class":118,"line":147},[116,2001,2002],{"class":126},"testpaths = [",[116,2004,2005],{"class":204},"\"tests\"",[116,2007,1989],{"class":126},[116,2009,2010,2013,2016,2019],{"class":118,"line":160},[116,2011,2012],{"class":126},"filterwarnings = [",[116,2014,2015],{"class":204},"\"error\"",[116,2017,2018],{"class":126},"]        ",[116,2020,2021],{"class":136},"# a new DeprecationWarning fails the build, deliberately\n",[10,2023,2024,2027],{},[29,2025,2026],{},"filterwarnings = [\"error\"]"," is the one people hesitate over and then keep. It turns a library\ndeprecation into a failing test on the day it appears, rather than a surprise when that library\nremoves the feature two releases later.",[17,2029,2031],{"id":2030},"common-failure-modes","Common failure modes",[10,2033,2034],{},"Four patterns account for most of the trouble people report with CLI test suites.",[10,2036,2037,2040,2041,2043,2044,2047],{},[34,2038,2039],{},"Tests that depend on each other."," A test writes a config file into the repository, and a later\ntest reads it. The fix is ",[29,2042,56],{}," everywhere and no writes outside it — plus running with\n",[29,2045,2046],{},"pytest -p no:randomly"," off, so ordering problems surface rather than hide.",[10,2049,2050,2053],{},[34,2051,2052],{},"Assertions against whole help screens."," Any change to a description breaks a dozen tests. Assert\nthat a flag name appears, not that the screen matches; if you genuinely need the whole screen\npinned, use a snapshot so the diff is reviewable.",[10,2055,2056,2059],{},[34,2057,2058],{},"Mocking too close to the test."," Patching the function directly under test means the assertion\nproves nothing. Patch at a boundary — the transport, the clock, the filesystem — and let your own\ncode run.",[10,2061,2062,2065],{},[34,2063,2064],{},"No test for the failure path."," Most CLI bugs that reach users are in error handling, because it\nis the code least exercised during development. One test per documented exit code is a small\ninvestment that pays out repeatedly.",[17,2067,2069],{"id":2068},"where-to-go-next","Where to go next",[22,2071,2072,2079,2086,2093,2100],{},[25,2073,2074,2078],{},[73,2075,2077],{"href":2076},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-click-commands-with-clirunner\u002F","Testing Click commands with CliRunner","\n— the runner in depth: isolated filesystems, streams, exceptions and exit codes.",[25,2080,2081,2085],{},[73,2082,2084],{"href":2083},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fsnapshot-testing-cli-output\u002F","Snapshot testing CLI output","\n— pin help screens and formatted output so drift shows up as a diff.",[25,2087,2088,2092],{},[73,2089,2091],{"href":2090},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmocking-filesystem-and-network-in-cli-tests\u002F","Mocking the filesystem and network in CLI tests","\n— isolation patterns that keep tests honest.",[25,2094,2095,2099],{},[73,2096,2098],{"href":2097},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-interactive-prompts-and-stdin\u002F","Testing interactive prompts and stdin","\n— supplying input, and testing the non-interactive path.",[25,2101,2102,2106],{},[73,2103,2105],{"href":2104},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmeasuring-cli-test-coverage\u002F","Measuring CLI test coverage","\n— configuring coverage for a CLI, and the paths it silently misses.",[17,2108,2110],{"id":2109},"frequently-asked-questions","Frequently asked questions",[2112,2113,2115],"h3",{"id":2114},"should-i-test-through-a-subprocess-to-be-realistic","Should I test through a subprocess to be realistic?",[10,2117,2118],{},"Once, in CI, against the installed wheel — and nowhere else. A subprocess costs interpreter\nstart-up per test, hides tracebacks behind captured output, and tests the same code the runner\ndoes. The realism it adds is entirely about packaging, which is why one test is enough.",[2112,2120,2122],{"id":2121},"how-do-i-test-a-command-that-writes-files","How do I test a command that writes files?",[10,2124,2125,2126,2128,2129,2132],{},"Give it a ",[29,2127,56],{}," and assert on what appeared. ",[29,2130,2131],{},"runner.isolated_filesystem()"," is an alternative\nfor commands that write relative paths, since it changes the working directory for the duration of\nthe block. Either way, never let a test write into the repository — a suite that leaves artifacts\nbehind eventually depends on them.",[2112,2134,2136],{"id":2135},"my-test-passes-locally-and-fails-in-ci-where-do-i-look-first","My test passes locally and fails in CI. Where do I look first?",[10,2138,2139,2140,2142],{},"The environment and the working directory. A developer machine has exported variables, a populated\ncache and a config file in the home directory; CI has none of those. Clearing your tool's variables\nin a fixture and changing into ",[29,2141,56],{}," removes both classes of difference in two lines.",[2112,2144,2146,2147,2150],{"id":2145},"do-i-need-to-test-help-output","Do I need to test ",[29,2148,2149],{},"--help"," output?",[10,2152,2153],{},"Test that it exits 0 and mentions each command, which catches a broken registration. Testing the\nexact text is what snapshot tests are for, and only worth doing if the help output is documentation\nyou have committed to keeping stable.",[2112,2155,2157],{"id":2156},"how-many-tests-should-a-command-have","How many tests should a command have?",[10,2159,2160,2161,1979],{},"Usually one or two at the runner level — the happy path and the documented failure — plus whatever\nthe underlying function needs at the unit level. If a command needs six runner tests, that is a\nstrong signal it is holding logic that belongs in ",[29,2162,1945],{},[2112,2164,2166],{"id":2165},"what-about-testing-on-windows","What about testing on Windows?",[10,2168,2169],{},"Add one Windows job to the CI matrix rather than special-casing paths in tests. The failures it\nsurfaces are real — path separators in expected output, a console encoding that cannot render a\ncharacter, a temporary file left open — and none of them are visible from Linux.",[2112,2171,2173],{"id":2172},"can-i-run-the-suite-against-several-python-versions-locally","Can I run the suite against several Python versions locally?",[10,2175,2176,303,2179,2182,2183,2186,2187,2190],{},[29,2177,2178],{},"uv run --python 3.11 pytest",[29,2180,2181],{},"uv run --python 3.13 pytest"," cover it without a separate tool,\nand ",[29,2184,2185],{},"tox"," or ",[29,2188,2189],{},"nox"," remain good options if you want one command to drive the whole matrix. Locally,\nrunning the ends of your supported range is usually enough — the middle versions rarely surface a\ndistinct failure.",[17,2192,2194],{"id":2193},"related","Related",[22,2196,2197,2204,2210,2216,2223],{},[25,2198,2199,2200],{},"Up: ",[73,2201,2203],{"href":2202},"\u002Fmodern-python-cli-frameworks-architecture\u002F","Python CLI Frameworks and Architecture",[25,2205,2206,2207],{},"Sideways: ",[73,2208,2209],{"href":75},"Structuring multi-command Python CLIs",[25,2211,2206,2212],{},[73,2213,2215],{"href":2214},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002F","Typer vs Click: when to use each",[25,2217,2218,2219],{},"Related: ",[73,2220,2222],{"href":2221},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002F","Error handling and exit codes for CLIs",[25,2224,2218,2225],{},[73,2226,2228],{"href":2227},"\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects\u002F","Pre-commit hooks for CLI projects",[2230,2231,2232],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}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 .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 .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":112,"searchDepth":140,"depth":140,"links":2234},[2235,2236,2237,2238,2239,2240,2241,2242,2243,2244,2245,2246,2247,2248,2249,2259],{"id":19,"depth":140,"text":20},{"id":67,"depth":140,"text":68},{"id":97,"depth":140,"text":98},{"id":469,"depth":140,"text":470},{"id":754,"depth":140,"text":755},{"id":957,"depth":140,"text":958},{"id":1028,"depth":140,"text":1029},{"id":1208,"depth":140,"text":1209},{"id":1517,"depth":140,"text":1518},{"id":1765,"depth":140,"text":1766},{"id":1880,"depth":140,"text":1881},{"id":1923,"depth":140,"text":1924},{"id":2030,"depth":140,"text":2031},{"id":2068,"depth":140,"text":2069},{"id":2109,"depth":140,"text":2110,"children":2250},[2251,2252,2253,2254,2256,2257,2258],{"id":2114,"depth":147,"text":2115},{"id":2121,"depth":147,"text":2122},{"id":2135,"depth":147,"text":2136},{"id":2145,"depth":147,"text":2255},"Do I need to test --help output?",{"id":2156,"depth":147,"text":2157},{"id":2165,"depth":147,"text":2166},{"id":2172,"depth":147,"text":2173},{"id":2193,"depth":140,"text":2194},"2026-08-01","Test Python command-line tools with CliRunner, pytest fixtures, snapshots, and isolation - fast suites that cover flags, exit codes, and output.","intermediate",false,"md",{},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications",{"title":5,"description":2261},"modern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Findex",[2270,1898,2271,2272,2273,2274],"testing","clirunner","click","typer","coverage","RtAhiB8pXytzbZvVl313abfaRFbbufnF2EHIb8M3Rlk",[2277,2280,2283,2286,2289,2292,2295,2298,2301,2304,2307,2310,2313,2316,2319,2322,2325,2328,2331,2334,2337,2340,2343,2346,2349,2352,2355,2358,2361,2364,2367,2370,2372,2375,2378,2381,2384,2387,2390,2393,2396,2398,2401,2404,2407,2410,2413,2416,2419,2420,2423,2426,2429,2432,2435,2438,2441,2444,2447,2450,2453,2456,2459,2462,2465,2468,2471,2474,2477,2480,2483,2486,2489,2492,2495,2498,2501,2504,2507,2510,2513,2516,2519],{"path":2278,"title":2279},"\u002Fabout","About Python CLI Toolcraft",{"path":2281,"title":2282},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies","Advanced Argument Validation Strategies",{"path":2284,"title":2285},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fparsing-nested-json-arguments-in-python-clis","Parsing Nested JSON Args in Python CLIs",{"path":2287,"title":2288},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fvalidating-file-and-directory-paths-in-clis","Validating File and Directory Paths in CLIs",{"path":2290,"title":2291},"\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":2293,"title":2294},"\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":2296,"title":2297},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation","CLI Help Output and Documentation",{"path":2299,"title":2300},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fversioning-and-deprecating-cli-flags","Versioning and Deprecating CLI Flags",{"path":2302,"title":2303},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fwriting-help-text-users-actually-read","Writing Help Text Users Actually Read",{"path":2305,"title":2306},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Fchoosing-exit-codes-for-cli-tools","Choosing Exit Codes for CLI Tools",{"path":2308,"title":2309},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Ffriendly-error-messages-and-tracebacks","Friendly Error Messages and Tracebacks",{"path":2311,"title":2312},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Fhandling-keyboard-interrupt-cleanly","Handling Keyboard Interrupt Cleanly",{"path":2314,"title":2315},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes","Error Handling and Exit Codes for CLIs",{"path":2317,"title":2318},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Fconfig-precedence-flags-env-files-defaults","Config Precedence: Flags, Env, Files, Defaults",{"path":2320,"title":2321},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars","Handling Config Files and Env Vars in CLIs",{"path":2323,"title":2324},"\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":2326,"title":2327},"\u002Fadvanced-input-parsing-user-experience","Advanced Input Parsing for Python CLIs",{"path":2329,"title":2330},"\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":2332,"title":2333},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich","Interactive Terminal UI with Rich",{"path":2335,"title":2336},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Frendering-tables-and-json-with-rich","Rendering Tables and JSON with Rich",{"path":2338,"title":2339},"\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":2341,"title":2342},"\u002Fadvanced-input-parsing-user-experience\u002Fshell-completion-for-python-clis","Shell Completion for Python CLIs",{"path":2344,"title":2345},"\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":2347,"title":2348},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fadding-verbose-and-quiet-logging-flags","Adding Verbose and Quiet Logging Flags",{"path":2350,"title":2351},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps","Structured Logging for CLI Apps",{"path":2353,"title":2354},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fstructured-json-logging-in-python-clis","Structured JSON Logging in Python CLIs",{"path":2356,"title":2357},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Fdetecting-tty-and-adapting-output","Detecting a TTY and Adapting Output",{"path":2359,"title":2360},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Femitting-json-output-for-scripting","Emitting JSON Output for Scripting",{"path":2362,"title":2363},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Fhandling-broken-pipe-and-sigpipe","Handling Broken Pipe and SIGPIPE",{"path":2365,"title":2366},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes","Working with stdin, stdout and Pipes",{"path":2368,"title":2369},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Freading-piped-input-in-python-clis","Reading Piped Input in Python CLIs",{"path":201,"title":2371},"Python CLI Toolcraft",{"path":2373,"title":2374},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading","CLI Startup Performance and Lazy Loading",{"path":2376,"title":2377},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Flazy-loading-subcommands-for-faster-startup","Lazy Loading Subcommands for Faster Startup",{"path":2379,"title":2380},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Fprofiling-python-cli-startup-time","Profiling Python CLI Startup Time",{"path":2382,"title":2383},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Freducing-cli-dependency-weight","Reducing CLI Dependency Weight",{"path":2385,"title":2386},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fargparse-subparsers-for-subcommands","argparse Subparsers for Subcommands",{"path":2388,"title":2389},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fargparse-vs-click-vs-typer-comparison","argparse vs Click vs Typer Compared",{"path":2391,"title":2392},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse","Command-Line Parsing with argparse",{"path":2394,"title":2395},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fmigrating-from-argparse-to-typer","Migrating from argparse to Typer",{"path":2397,"title":2203},"\u002Fmodern-python-cli-frameworks-architecture",{"path":2399,"title":2400},"\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis","Plugin Architectures for Extensible CLIs",{"path":2402,"title":2403},"\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":2405,"title":2406},"\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":2408,"title":2409},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fdependency-injection-patterns-for-cli-commands","Dependency Injection Patterns for CLI Commands",{"path":2411,"title":2412},"\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":2414,"title":2415},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis","Structuring Multi-Command Python CLIs",{"path":2417,"title":2418},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fsharing-state-with-click-context-objects","Sharing State with Click Context Objects",{"path":2266,"title":5},{"path":2421,"title":2422},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmeasuring-cli-test-coverage","Measuring CLI Test Coverage",{"path":2424,"title":2425},"\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":2427,"title":2428},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fsnapshot-testing-cli-output","Snapshot Testing CLI Output",{"path":2430,"title":2431},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-click-commands-with-clirunner","Testing Click Commands with CliRunner",{"path":2433,"title":2434},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-interactive-prompts-and-stdin","Testing Interactive Prompts and stdin",{"path":2436,"title":2437},"\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":2439,"title":2440},"\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":2442,"title":2443},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each","Typer vs Click: When to Use Each",{"path":2445,"title":2446},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Ftyper-callback-functions-explained","Typer callback functions explained",{"path":2448,"title":2449},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter\u002Fcopier-vs-cookiecutter-for-cli-templates","Copier vs Cookiecutter for CLI Templates",{"path":2451,"title":2452},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter","CLI Project Scaffolding with Cookiecutter",{"path":2454,"title":2455},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fbuilding-cross-platform-release-binaries-in-ci","Building Cross-Platform Release Binaries in CI",{"path":2457,"title":2458},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fbundling-a-python-cli-with-pyinstaller","Bundling a Python CLI with PyInstaller",{"path":2460,"title":2461},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fhomebrew-and-scoop-packaging-for-python-clis","Homebrew and Scoop Packaging for Python CLIs",{"path":2463,"title":2464},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries","Distributing CLIs as Standalone Binaries",{"path":2466,"title":2467},"\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":2469,"title":2470},"\u002Fproject-setup-dependency-management","Project Setup & Dependency Management",{"path":2472,"title":2473},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fautomating-changelogs-with-conventional-commits","Automating Changelogs with Conventional Commits",{"path":2475,"title":2476},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fexposing-version-info-and-build-metadata","Exposing Version Info and Build Metadata",{"path":2478,"title":2479},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs","Managing CLI Versioning & Changelogs",{"path":2481,"title":2482},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fbuilding-wheels-and-sdists-for-python-clis","Building Wheels and sdists for Python CLIs",{"path":2484,"title":2485},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution","Packaging Python CLIs for Distribution",{"path":2487,"title":2488},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Finstalling-and-distributing-clis-with-pipx","Installing and Distributing CLIs with pipx",{"path":2490,"title":2491},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fpublishing-a-python-cli-to-pypi","Publishing a Python CLI to PyPI",{"path":2493,"title":2494},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development","Poetry Workflows for CLI Development",{"path":2496,"title":2497},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development\u002Fpoetry-entry-points-and-scripts-for-clis","Poetry Entry Points and Scripts for CLIs",{"path":2499,"title":2500},"\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects","Pre-commit Hooks for CLI Projects",{"path":2502,"title":2503},"\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":2505,"title":2506},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management","uv for Python CLI Dependency Management",{"path":2508,"title":2509},"\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":2511,"title":2512},"\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":2514,"title":2515},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices","Python CLI Env Isolation Best Practices",{"path":2517,"title":2518},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fmanaging-virtual-environments-for-cross-platform-clis","Managing Python CLI Virtual Environments",{"path":2520,"title":2521},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fpinning-the-python-version-for-a-cli","Pinning the Python Version for a CLI",1785614690033]