[{"data":1,"prerenderedAt":2187},["ShallowReactive",2],{"page-\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmocking-filesystem-and-network-in-cli-tests\u002F":3,"content-directory":1940},{"id":4,"title":5,"body":6,"date":1925,"description":1926,"difficulty":1927,"draft":1928,"extension":1929,"meta":1930,"navigation":136,"path":1931,"seo":1932,"stem":1933,"tags":1934,"updated":1925,"__hash__":1939},"content\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmocking-filesystem-and-network-in-cli-tests\u002Findex.md","Mocking the Filesystem and Network in CLI Tests",{"type":7,"value":8,"toc":1902},"minimark",[9,18,23,60,64,79,83,87,98,101,433,436,447,519,523,526,779,782,786,798,975,991,995,998,1207,1356,1359,1369,1373,1376,1469,1479,1483,1486,1496,1585,1592,1596,1599,1753,1756,1760,1763,1767,1776,1791,1795,1802,1810,1816,1820,1830,1834,1840,1844,1858,1862,1898],[10,11,12,13,17],"p",{},"A test that touches the network is a test that fails on a train. A test that writes into the\nrepository is a test that passes once. Isolating a CLI suite is mostly a question of choosing\n",[14,15,16],"em",{},"where"," to substitute something — and the usual instinct, patching the function under test, is\nthe one place that proves nothing.",[19,20,22],"h2",{"id":21},"tldr","TL;DR",[24,25,26,35,47,50,57],"ul",{},[27,28,29,30,34],"li",{},"Push the seam ",[31,32,33],"strong",{},"outwards",": replace the HTTP transport, not your own request-building function.",[27,36,37,38,42,43,46],{},"Use ",[39,40,41],"code",{},"tmp_path"," for real files; a mocked ",[39,44,45],{},"open()"," behaves differently from a real filesystem.",[27,48,49],{},"Inject time and randomness as parameters with sensible defaults instead of patching modules.",[27,51,52,53,56],{},"Prefer a small hand-written fake over ",[39,54,55],{},"MagicMock"," — it fails loudly when the real interface\nchanges.",[27,58,59],{},"Clear your tool's environment variables in a fixture so a developer's shell cannot change\nresults.",[19,61,63],{"id":62},"prerequisites","Prerequisites",[10,65,66,67,70,71,74,75,78],{},"A CLI whose logic lives outside the command functions, pytest, and ",[39,68,69],{},"httpx"," for the network\nexamples (the same pattern applies to ",[39,72,73],{},"requests"," via ",[39,76,77],{},"responses",", and to any client with a\npluggable transport).",[19,80,82],{"id":81},"where-to-put-the-seam","Where to put the seam",[84,85],"inline-diagram",{"name":86},"mock-boundary-stack",[10,88,89,90,93,94,97],{},"The rule of thumb: push the substitution outwards until all of ",[14,91,92],{},"your"," code is inside it. Patching\n",[39,95,96],{},"mytool.core.deploy"," and asserting it was called proves that the command calls a function — which\nwas never in doubt — while leaving the request construction, the error handling and the response\nparsing untested.",[10,99,100],{},"Replacing the transport keeps all of that under test and still never opens a socket:",[102,103,108],"pre",{"className":104,"code":105,"language":106,"meta":107,"style":107},"language-python shiki shiki-themes github-light github-dark","import json\nimport httpx\n\ndef test_deploy_sends_the_expected_request():\n    seen = {}\n\n    def handler(request: httpx.Request) -> httpx.Response:\n        seen[\"method\"] = request.method\n        seen[\"url\"] = str(request.url)\n        seen[\"body\"] = json.loads(request.content)\n        return httpx.Response(201, json={\"id\": \"dep_123\", \"status\": \"queued\"})\n\n    client = httpx.Client(transport=httpx.MockTransport(handler), base_url=\"https:\u002F\u002Fapi.test\")\n    result = core.deploy(client, environment=\"prod\", replicas=3)\n\n    assert seen[\"method\"] == \"POST\"\n    assert seen[\"url\"].endswith(\"\u002Fdeployments\")\n    assert seen[\"body\"] == {\"environment\": \"prod\", \"replicas\": 3}\n    assert result.id == \"dep_123\"\n","python","",[39,109,110,123,131,138,151,163,168,180,198,217,232,278,283,313,344,349,368,385,420],{"__ignoreMap":107},[111,112,115,119],"span",{"class":113,"line":114},"line",1,[111,116,118],{"class":117},"szBVR","import",[111,120,122],{"class":121},"sVt8B"," json\n",[111,124,126,128],{"class":113,"line":125},2,[111,127,118],{"class":117},[111,129,130],{"class":121}," httpx\n",[111,132,134],{"class":113,"line":133},3,[111,135,137],{"emptyLinePlaceholder":136},true,"\n",[111,139,141,144,148],{"class":113,"line":140},4,[111,142,143],{"class":117},"def",[111,145,147],{"class":146},"sScJk"," test_deploy_sends_the_expected_request",[111,149,150],{"class":121},"():\n",[111,152,154,157,160],{"class":113,"line":153},5,[111,155,156],{"class":121},"    seen ",[111,158,159],{"class":117},"=",[111,161,162],{"class":121}," {}\n",[111,164,166],{"class":113,"line":165},6,[111,167,137],{"emptyLinePlaceholder":136},[111,169,171,174,177],{"class":113,"line":170},7,[111,172,173],{"class":117},"    def",[111,175,176],{"class":146}," handler",[111,178,179],{"class":121},"(request: httpx.Request) -> httpx.Response:\n",[111,181,183,186,190,193,195],{"class":113,"line":182},8,[111,184,185],{"class":121},"        seen[",[111,187,189],{"class":188},"sZZnC","\"method\"",[111,191,192],{"class":121},"] ",[111,194,159],{"class":117},[111,196,197],{"class":121}," request.method\n",[111,199,201,203,206,208,210,214],{"class":113,"line":200},9,[111,202,185],{"class":121},[111,204,205],{"class":188},"\"url\"",[111,207,192],{"class":121},[111,209,159],{"class":117},[111,211,213],{"class":212},"sj4cs"," str",[111,215,216],{"class":121},"(request.url)\n",[111,218,220,222,225,227,229],{"class":113,"line":219},10,[111,221,185],{"class":121},[111,223,224],{"class":188},"\"body\"",[111,226,192],{"class":121},[111,228,159],{"class":117},[111,230,231],{"class":121}," json.loads(request.content)\n",[111,233,235,238,241,244,247,251,253,256,259,262,265,267,270,272,275],{"class":113,"line":234},11,[111,236,237],{"class":117},"        return",[111,239,240],{"class":121}," httpx.Response(",[111,242,243],{"class":212},"201",[111,245,246],{"class":121},", ",[111,248,250],{"class":249},"s4XuR","json",[111,252,159],{"class":117},[111,254,255],{"class":121},"{",[111,257,258],{"class":188},"\"id\"",[111,260,261],{"class":121},": ",[111,263,264],{"class":188},"\"dep_123\"",[111,266,246],{"class":121},[111,268,269],{"class":188},"\"status\"",[111,271,261],{"class":121},[111,273,274],{"class":188},"\"queued\"",[111,276,277],{"class":121},"})\n",[111,279,281],{"class":113,"line":280},12,[111,282,137],{"emptyLinePlaceholder":136},[111,284,286,289,291,294,297,299,302,305,307,310],{"class":113,"line":285},13,[111,287,288],{"class":121},"    client ",[111,290,159],{"class":117},[111,292,293],{"class":121}," httpx.Client(",[111,295,296],{"class":249},"transport",[111,298,159],{"class":117},[111,300,301],{"class":121},"httpx.MockTransport(handler), ",[111,303,304],{"class":249},"base_url",[111,306,159],{"class":117},[111,308,309],{"class":188},"\"https:\u002F\u002Fapi.test\"",[111,311,312],{"class":121},")\n",[111,314,316,319,321,324,327,329,332,334,337,339,342],{"class":113,"line":315},14,[111,317,318],{"class":121},"    result ",[111,320,159],{"class":117},[111,322,323],{"class":121}," core.deploy(client, ",[111,325,326],{"class":249},"environment",[111,328,159],{"class":117},[111,330,331],{"class":188},"\"prod\"",[111,333,246],{"class":121},[111,335,336],{"class":249},"replicas",[111,338,159],{"class":117},[111,340,341],{"class":212},"3",[111,343,312],{"class":121},[111,345,347],{"class":113,"line":346},15,[111,348,137],{"emptyLinePlaceholder":136},[111,350,352,355,358,360,362,365],{"class":113,"line":351},16,[111,353,354],{"class":117},"    assert",[111,356,357],{"class":121}," seen[",[111,359,189],{"class":188},[111,361,192],{"class":121},[111,363,364],{"class":117},"==",[111,366,367],{"class":188}," \"POST\"\n",[111,369,371,373,375,377,380,383],{"class":113,"line":370},17,[111,372,354],{"class":117},[111,374,357],{"class":121},[111,376,205],{"class":188},[111,378,379],{"class":121},"].endswith(",[111,381,382],{"class":188},"\"\u002Fdeployments\"",[111,384,312],{"class":121},[111,386,388,390,392,394,396,398,401,404,406,408,410,413,415,417],{"class":113,"line":387},18,[111,389,354],{"class":117},[111,391,357],{"class":121},[111,393,224],{"class":188},[111,395,192],{"class":121},[111,397,364],{"class":117},[111,399,400],{"class":121}," {",[111,402,403],{"class":188},"\"environment\"",[111,405,261],{"class":121},[111,407,331],{"class":188},[111,409,246],{"class":121},[111,411,412],{"class":188},"\"replicas\"",[111,414,261],{"class":121},[111,416,341],{"class":212},[111,418,419],{"class":121},"}\n",[111,421,423,425,428,430],{"class":113,"line":422},19,[111,424,354],{"class":117},[111,426,427],{"class":121}," result.id ",[111,429,364],{"class":117},[111,431,432],{"class":188}," \"dep_123\"\n",[84,434],{"name":435},"filesystem-isolation-matrix",[10,437,438,439,442,443,446],{},"For this to work, the client has to be something the caller can supply. A ",[39,440,441],{},"core"," function that\nconstructs its own ",[39,444,445],{},"httpx.Client"," internally forces you back to patching — so make it a parameter\nwith a default, which costs nothing at the call site:",[102,448,450],{"className":104,"code":449,"language":106,"meta":107,"style":107},"def deploy(client: httpx.Client | None = None, *, environment: str, replicas: int) -> Deployment:\n    client = client or httpx.Client(base_url=settings.api_url)\n    ...\n",[39,451,452,493,514],{"__ignoreMap":107},[111,453,454,456,459,462,465,468,471,473,475,478,481,484,487,490],{"class":113,"line":114},[111,455,143],{"class":117},[111,457,458],{"class":146}," deploy",[111,460,461],{"class":121},"(client: httpx.Client ",[111,463,464],{"class":117},"|",[111,466,467],{"class":212}," None",[111,469,470],{"class":117}," =",[111,472,467],{"class":212},[111,474,246],{"class":121},[111,476,477],{"class":117},"*",[111,479,480],{"class":121},", environment: ",[111,482,483],{"class":212},"str",[111,485,486],{"class":121},", replicas: ",[111,488,489],{"class":212},"int",[111,491,492],{"class":121},") -> Deployment:\n",[111,494,495,497,499,502,505,507,509,511],{"class":113,"line":125},[111,496,288],{"class":121},[111,498,159],{"class":117},[111,500,501],{"class":121}," client ",[111,503,504],{"class":117},"or",[111,506,293],{"class":121},[111,508,304],{"class":249},[111,510,159],{"class":117},[111,512,513],{"class":121},"settings.api_url)\n",[111,515,516],{"class":113,"line":133},[111,517,518],{"class":212},"    ...\n",[19,520,522],{"id":521},"testing-error-paths-without-a-network","Testing error paths without a network",[10,524,525],{},"The valuable network tests are the failures, because that is the code least exercised during\ndevelopment. A transport handler can raise or return anything:",[102,527,529],{"className":104,"code":528,"language":106,"meta":107,"style":107},"import pytest\n\ndef failing_transport(exc: Exception) -> httpx.MockTransport:\n    def handler(request: httpx.Request) -> httpx.Response:\n        raise exc\n    return httpx.MockTransport(handler)\n\ndef test_connection_error_becomes_a_domain_error():\n    client = httpx.Client(transport=failing_transport(httpx.ConnectError(\"no route\")))\n\n    with pytest.raises(RemoteUnavailable) as excinfo:\n        core.deploy(client, environment=\"prod\", replicas=1)\n\n    assert \"deploy API\" in str(excinfo.value)      # the message names what was unreachable\n\n@pytest.mark.parametrize(\"status,expected\", [(401, AuthError), (429, RateLimited), (500, RemoteUnavailable)])\ndef test_http_errors_map_to_domain_errors(status, expected):\n    client = httpx.Client(transport=httpx.MockTransport(lambda r: httpx.Response(status)))\n\n    with pytest.raises(expected):\n        core.deploy(client, environment=\"prod\", replicas=1)\n",[39,530,531,538,542,558,566,574,582,586,595,616,620,634,656,660,679,683,715,725,746,750,758],{"__ignoreMap":107},[111,532,533,535],{"class":113,"line":114},[111,534,118],{"class":117},[111,536,537],{"class":121}," pytest\n",[111,539,540],{"class":113,"line":125},[111,541,137],{"emptyLinePlaceholder":136},[111,543,544,546,549,552,555],{"class":113,"line":133},[111,545,143],{"class":117},[111,547,548],{"class":146}," failing_transport",[111,550,551],{"class":121},"(exc: ",[111,553,554],{"class":212},"Exception",[111,556,557],{"class":121},") -> httpx.MockTransport:\n",[111,559,560,562,564],{"class":113,"line":140},[111,561,173],{"class":117},[111,563,176],{"class":146},[111,565,179],{"class":121},[111,567,568,571],{"class":113,"line":153},[111,569,570],{"class":117},"        raise",[111,572,573],{"class":121}," exc\n",[111,575,576,579],{"class":113,"line":165},[111,577,578],{"class":117},"    return",[111,580,581],{"class":121}," httpx.MockTransport(handler)\n",[111,583,584],{"class":113,"line":170},[111,585,137],{"emptyLinePlaceholder":136},[111,587,588,590,593],{"class":113,"line":182},[111,589,143],{"class":117},[111,591,592],{"class":146}," test_connection_error_becomes_a_domain_error",[111,594,150],{"class":121},[111,596,597,599,601,603,605,607,610,613],{"class":113,"line":200},[111,598,288],{"class":121},[111,600,159],{"class":117},[111,602,293],{"class":121},[111,604,296],{"class":249},[111,606,159],{"class":117},[111,608,609],{"class":121},"failing_transport(httpx.ConnectError(",[111,611,612],{"class":188},"\"no route\"",[111,614,615],{"class":121},")))\n",[111,617,618],{"class":113,"line":219},[111,619,137],{"emptyLinePlaceholder":136},[111,621,622,625,628,631],{"class":113,"line":234},[111,623,624],{"class":117},"    with",[111,626,627],{"class":121}," pytest.raises(RemoteUnavailable) ",[111,629,630],{"class":117},"as",[111,632,633],{"class":121}," excinfo:\n",[111,635,636,639,641,643,645,647,649,651,654],{"class":113,"line":280},[111,637,638],{"class":121},"        core.deploy(client, ",[111,640,326],{"class":249},[111,642,159],{"class":117},[111,644,331],{"class":188},[111,646,246],{"class":121},[111,648,336],{"class":249},[111,650,159],{"class":117},[111,652,653],{"class":212},"1",[111,655,312],{"class":121},[111,657,658],{"class":113,"line":285},[111,659,137],{"emptyLinePlaceholder":136},[111,661,662,664,667,670,672,675],{"class":113,"line":315},[111,663,354],{"class":117},[111,665,666],{"class":188}," \"deploy API\"",[111,668,669],{"class":117}," in",[111,671,213],{"class":212},[111,673,674],{"class":121},"(excinfo.value)      ",[111,676,678],{"class":677},"sJ8bj","# the message names what was unreachable\n",[111,680,681],{"class":113,"line":346},[111,682,137],{"emptyLinePlaceholder":136},[111,684,685,688,691,694,697,700,703,706,709,712],{"class":113,"line":351},[111,686,687],{"class":146},"@pytest.mark.parametrize",[111,689,690],{"class":121},"(",[111,692,693],{"class":188},"\"status,expected\"",[111,695,696],{"class":121},", [(",[111,698,699],{"class":212},"401",[111,701,702],{"class":121},", AuthError), (",[111,704,705],{"class":212},"429",[111,707,708],{"class":121},", RateLimited), (",[111,710,711],{"class":212},"500",[111,713,714],{"class":121},", RemoteUnavailable)])\n",[111,716,717,719,722],{"class":113,"line":370},[111,718,143],{"class":117},[111,720,721],{"class":146}," test_http_errors_map_to_domain_errors",[111,723,724],{"class":121},"(status, expected):\n",[111,726,727,729,731,733,735,737,740,743],{"class":113,"line":387},[111,728,288],{"class":121},[111,730,159],{"class":117},[111,732,293],{"class":121},[111,734,296],{"class":249},[111,736,159],{"class":117},[111,738,739],{"class":121},"httpx.MockTransport(",[111,741,742],{"class":117},"lambda",[111,744,745],{"class":121}," r: httpx.Response(status)))\n",[111,747,748],{"class":113,"line":422},[111,749,137],{"emptyLinePlaceholder":136},[111,751,753,755],{"class":113,"line":752},20,[111,754,624],{"class":117},[111,756,757],{"class":121}," pytest.raises(expected):\n",[111,759,761,763,765,767,769,771,773,775,777],{"class":113,"line":760},21,[111,762,638],{"class":121},[111,764,326],{"class":249},[111,766,159],{"class":117},[111,768,331],{"class":188},[111,770,246],{"class":121},[111,772,336],{"class":249},[111,774,159],{"class":117},[111,776,653],{"class":212},[111,778,312],{"class":121},[10,780,781],{},"That parametrised test is the one that pays for itself. It pins the mapping from HTTP status to\nyour own exception types, which is what determines the exit code and the message a user sees.",[19,783,785],{"id":784},"the-filesystem-use-real-files","The filesystem: use real files",[10,787,788,790,791,793,794,797],{},[39,789,41],{}," gives each test its own directory with absolute paths, cleaned up automatically. It is\nalmost always better than a mocked ",[39,792,45],{},", because the code under test then meets real\nencodings, real permissions and the real ",[39,795,796],{},"FileNotFoundError",".",[102,799,801],{"className":104,"code":800,"language":106,"meta":107,"style":107},"def test_reads_config_from_the_project_directory(tmp_path):\n    (tmp_path \u002F \".mytool.toml\").write_text('region = \"eu-west-1\"\\n', encoding=\"utf-8\")\n\n    settings = load_settings(start=tmp_path)\n\n    assert settings.region == \"eu-west-1\"\n\ndef test_unreadable_config_is_reported_clearly(tmp_path):\n    config = tmp_path \u002F \".mytool.toml\"\n    config.write_text(\"region = [unclosed\\n\")\n\n    with pytest.raises(ConfigError) as excinfo:\n        load_settings(start=tmp_path)\n\n    assert \".mytool.toml\" in str(excinfo.value)     # the message names the file\n",[39,802,803,813,848,852,870,874,886,890,899,914,929,933,944,955,959],{"__ignoreMap":107},[111,804,805,807,810],{"class":113,"line":114},[111,806,143],{"class":117},[111,808,809],{"class":146}," test_reads_config_from_the_project_directory",[111,811,812],{"class":121},"(tmp_path):\n",[111,814,815,818,821,824,827,830,833,836,838,841,843,846],{"class":113,"line":125},[111,816,817],{"class":121},"    (tmp_path ",[111,819,820],{"class":117},"\u002F",[111,822,823],{"class":188}," \".mytool.toml\"",[111,825,826],{"class":121},").write_text(",[111,828,829],{"class":188},"'region = \"eu-west-1\"",[111,831,832],{"class":212},"\\n",[111,834,835],{"class":188},"'",[111,837,246],{"class":121},[111,839,840],{"class":249},"encoding",[111,842,159],{"class":117},[111,844,845],{"class":188},"\"utf-8\"",[111,847,312],{"class":121},[111,849,850],{"class":113,"line":133},[111,851,137],{"emptyLinePlaceholder":136},[111,853,854,857,859,862,865,867],{"class":113,"line":140},[111,855,856],{"class":121},"    settings ",[111,858,159],{"class":117},[111,860,861],{"class":121}," load_settings(",[111,863,864],{"class":249},"start",[111,866,159],{"class":117},[111,868,869],{"class":121},"tmp_path)\n",[111,871,872],{"class":113,"line":153},[111,873,137],{"emptyLinePlaceholder":136},[111,875,876,878,881,883],{"class":113,"line":165},[111,877,354],{"class":117},[111,879,880],{"class":121}," settings.region ",[111,882,364],{"class":117},[111,884,885],{"class":188}," \"eu-west-1\"\n",[111,887,888],{"class":113,"line":170},[111,889,137],{"emptyLinePlaceholder":136},[111,891,892,894,897],{"class":113,"line":182},[111,893,143],{"class":117},[111,895,896],{"class":146}," test_unreadable_config_is_reported_clearly",[111,898,812],{"class":121},[111,900,901,904,906,909,911],{"class":113,"line":200},[111,902,903],{"class":121},"    config ",[111,905,159],{"class":117},[111,907,908],{"class":121}," tmp_path ",[111,910,820],{"class":117},[111,912,913],{"class":188}," \".mytool.toml\"\n",[111,915,916,919,922,924,927],{"class":113,"line":219},[111,917,918],{"class":121},"    config.write_text(",[111,920,921],{"class":188},"\"region = [unclosed",[111,923,832],{"class":212},[111,925,926],{"class":188},"\"",[111,928,312],{"class":121},[111,930,931],{"class":113,"line":234},[111,932,137],{"emptyLinePlaceholder":136},[111,934,935,937,940,942],{"class":113,"line":280},[111,936,624],{"class":117},[111,938,939],{"class":121}," pytest.raises(ConfigError) ",[111,941,630],{"class":117},[111,943,633],{"class":121},[111,945,946,949,951,953],{"class":113,"line":285},[111,947,948],{"class":121},"        load_settings(",[111,950,864],{"class":249},[111,952,159],{"class":117},[111,954,869],{"class":121},[111,956,957],{"class":113,"line":315},[111,958,137],{"emptyLinePlaceholder":136},[111,960,961,963,965,967,969,972],{"class":113,"line":346},[111,962,354],{"class":117},[111,964,823],{"class":188},[111,966,669],{"class":117},[111,968,213],{"class":212},[111,970,971],{"class":121},"(excinfo.value)     ",[111,973,974],{"class":677},"# the message names the file\n",[10,976,977,978,983,984,987,988,797],{},"Two habits keep filesystem tests honest. ",[31,979,980,981],{},"Never write outside ",[39,982,41],{}," — a test that writes\ninto the repository leaves artifacts that later tests come to depend on. And ",[31,985,986],{},"pass the starting\ndirectory in"," rather than relying on the process working directory, so tests can run in parallel\nwithout fighting over ",[39,989,990],{},"chdir",[19,992,994],{"id":993},"time-randomness-and-identifiers","Time, randomness and identifiers",[10,996,997],{},"Anything non-deterministic should be a parameter with a default, not a module-level call. The\ndefault keeps production code readable; the parameter makes the test trivial.",[102,999,1001],{"className":104,"code":1000,"language":106,"meta":107,"style":107},"from datetime import UTC, datetime\nfrom typing import Callable\n\ndef retry(\n    operation: Callable[[], T],\n    *,\n    attempts: int = 3,\n    backoff: float = 1.0,\n    sleep: Callable[[float], None] = time.sleep,\n) -> T:\n    for attempt in range(1, attempts + 1):\n        try:\n            return operation()\n        except Transient:\n            if attempt == attempts:\n                raise\n            sleep(backoff * 2 ** (attempt - 1))\n",[39,1002,1003,1019,1031,1035,1045,1050,1058,1072,1087,1107,1112,1142,1150,1158,1166,1178,1183],{"__ignoreMap":107},[111,1004,1005,1008,1011,1013,1016],{"class":113,"line":114},[111,1006,1007],{"class":117},"from",[111,1009,1010],{"class":121}," datetime ",[111,1012,118],{"class":117},[111,1014,1015],{"class":212}," UTC",[111,1017,1018],{"class":121},", datetime\n",[111,1020,1021,1023,1026,1028],{"class":113,"line":125},[111,1022,1007],{"class":117},[111,1024,1025],{"class":121}," typing ",[111,1027,118],{"class":117},[111,1029,1030],{"class":121}," Callable\n",[111,1032,1033],{"class":113,"line":133},[111,1034,137],{"emptyLinePlaceholder":136},[111,1036,1037,1039,1042],{"class":113,"line":140},[111,1038,143],{"class":117},[111,1040,1041],{"class":146}," retry",[111,1043,1044],{"class":121},"(\n",[111,1046,1047],{"class":113,"line":153},[111,1048,1049],{"class":121},"    operation: Callable[[], T],\n",[111,1051,1052,1055],{"class":113,"line":165},[111,1053,1054],{"class":117},"    *",[111,1056,1057],{"class":121},",\n",[111,1059,1060,1063,1065,1067,1070],{"class":113,"line":170},[111,1061,1062],{"class":121},"    attempts: ",[111,1064,489],{"class":212},[111,1066,470],{"class":117},[111,1068,1069],{"class":212}," 3",[111,1071,1057],{"class":121},[111,1073,1074,1077,1080,1082,1085],{"class":113,"line":182},[111,1075,1076],{"class":121},"    backoff: ",[111,1078,1079],{"class":212},"float",[111,1081,470],{"class":117},[111,1083,1084],{"class":212}," 1.0",[111,1086,1057],{"class":121},[111,1088,1089,1092,1094,1097,1100,1102,1104],{"class":113,"line":200},[111,1090,1091],{"class":121},"    sleep: Callable[[",[111,1093,1079],{"class":212},[111,1095,1096],{"class":121},"], ",[111,1098,1099],{"class":212},"None",[111,1101,192],{"class":121},[111,1103,159],{"class":117},[111,1105,1106],{"class":121}," time.sleep,\n",[111,1108,1109],{"class":113,"line":219},[111,1110,1111],{"class":121},") -> T:\n",[111,1113,1114,1117,1120,1123,1126,1128,1130,1133,1136,1139],{"class":113,"line":234},[111,1115,1116],{"class":117},"    for",[111,1118,1119],{"class":121}," attempt ",[111,1121,1122],{"class":117},"in",[111,1124,1125],{"class":212}," range",[111,1127,690],{"class":121},[111,1129,653],{"class":212},[111,1131,1132],{"class":121},", attempts ",[111,1134,1135],{"class":117},"+",[111,1137,1138],{"class":212}," 1",[111,1140,1141],{"class":121},"):\n",[111,1143,1144,1147],{"class":113,"line":280},[111,1145,1146],{"class":117},"        try",[111,1148,1149],{"class":121},":\n",[111,1151,1152,1155],{"class":113,"line":285},[111,1153,1154],{"class":117},"            return",[111,1156,1157],{"class":121}," operation()\n",[111,1159,1160,1163],{"class":113,"line":315},[111,1161,1162],{"class":117},"        except",[111,1164,1165],{"class":121}," Transient:\n",[111,1167,1168,1171,1173,1175],{"class":113,"line":346},[111,1169,1170],{"class":117},"            if",[111,1172,1119],{"class":121},[111,1174,364],{"class":117},[111,1176,1177],{"class":121}," attempts:\n",[111,1179,1180],{"class":113,"line":351},[111,1181,1182],{"class":117},"                raise\n",[111,1184,1185,1188,1190,1193,1196,1199,1202,1204],{"class":113,"line":370},[111,1186,1187],{"class":121},"            sleep(backoff ",[111,1189,477],{"class":117},[111,1191,1192],{"class":212}," 2",[111,1194,1195],{"class":117}," **",[111,1197,1198],{"class":121}," (attempt ",[111,1200,1201],{"class":117},"-",[111,1203,1138],{"class":212},[111,1205,1206],{"class":121},"))\n",[102,1208,1210],{"className":104,"code":1209,"language":106,"meta":107,"style":107},"def test_backoff_doubles_between_attempts():\n    waits: list[float] = []\n    calls = iter([Transient(), Transient(), \"done\"])\n\n    def operation():\n        item = next(calls)\n        if isinstance(item, Exception):\n            raise item\n        return item\n\n    assert retry(operation, sleep=waits.append) == \"done\"\n    assert waits == [1.0, 2.0]\n",[39,1211,1212,1221,1235,1254,1258,1267,1280,1295,1303,1309,1313,1333],{"__ignoreMap":107},[111,1213,1214,1216,1219],{"class":113,"line":114},[111,1215,143],{"class":117},[111,1217,1218],{"class":146}," test_backoff_doubles_between_attempts",[111,1220,150],{"class":121},[111,1222,1223,1226,1228,1230,1232],{"class":113,"line":125},[111,1224,1225],{"class":121},"    waits: list[",[111,1227,1079],{"class":212},[111,1229,192],{"class":121},[111,1231,159],{"class":117},[111,1233,1234],{"class":121}," []\n",[111,1236,1237,1240,1242,1245,1248,1251],{"class":113,"line":133},[111,1238,1239],{"class":121},"    calls ",[111,1241,159],{"class":117},[111,1243,1244],{"class":212}," iter",[111,1246,1247],{"class":121},"([Transient(), Transient(), ",[111,1249,1250],{"class":188},"\"done\"",[111,1252,1253],{"class":121},"])\n",[111,1255,1256],{"class":113,"line":140},[111,1257,137],{"emptyLinePlaceholder":136},[111,1259,1260,1262,1265],{"class":113,"line":153},[111,1261,173],{"class":117},[111,1263,1264],{"class":146}," operation",[111,1266,150],{"class":121},[111,1268,1269,1272,1274,1277],{"class":113,"line":165},[111,1270,1271],{"class":121},"        item ",[111,1273,159],{"class":117},[111,1275,1276],{"class":212}," next",[111,1278,1279],{"class":121},"(calls)\n",[111,1281,1282,1285,1288,1291,1293],{"class":113,"line":170},[111,1283,1284],{"class":117},"        if",[111,1286,1287],{"class":212}," isinstance",[111,1289,1290],{"class":121},"(item, ",[111,1292,554],{"class":212},[111,1294,1141],{"class":121},[111,1296,1297,1300],{"class":113,"line":182},[111,1298,1299],{"class":117},"            raise",[111,1301,1302],{"class":121}," item\n",[111,1304,1305,1307],{"class":113,"line":200},[111,1306,237],{"class":117},[111,1308,1302],{"class":121},[111,1310,1311],{"class":113,"line":219},[111,1312,137],{"emptyLinePlaceholder":136},[111,1314,1315,1317,1320,1323,1325,1328,1330],{"class":113,"line":234},[111,1316,354],{"class":117},[111,1318,1319],{"class":121}," retry(operation, ",[111,1321,1322],{"class":249},"sleep",[111,1324,159],{"class":117},[111,1326,1327],{"class":121},"waits.append) ",[111,1329,364],{"class":117},[111,1331,1332],{"class":188}," \"done\"\n",[111,1334,1335,1337,1340,1342,1345,1348,1350,1353],{"class":113,"line":280},[111,1336,354],{"class":117},[111,1338,1339],{"class":121}," waits ",[111,1341,364],{"class":117},[111,1343,1344],{"class":121}," [",[111,1346,1347],{"class":212},"1.0",[111,1349,246],{"class":121},[111,1351,1352],{"class":212},"2.0",[111,1354,1355],{"class":121},"]\n",[84,1357],{"name":1358},"fake-clock-flow",[10,1360,1361,1362,1365,1366,797],{},"No patching, no waiting, and the test reads as a statement about the retry policy. The same shape\ncovers ",[39,1363,1364],{},"now: Callable[[], datetime] = lambda: datetime.now(UTC)"," and\n",[39,1367,1368],{},"new_id: Callable[[], str] = lambda: str(uuid4())",[19,1370,1372],{"id":1371},"the-environment","The environment",[10,1374,1375],{},"Environment variables leak between tests and between machines, and the failures are baffling\nbecause nothing in the test mentions them. Two lines in a fixture remove the whole class:",[102,1377,1379],{"className":104,"code":1378,"language":106,"meta":107,"style":107},"@pytest.fixture(autouse=True)\ndef clean_environment(monkeypatch):\n    for key in list(os.environ):\n        if key.startswith(\"MYTOOL_\"):\n            monkeypatch.delenv(key, raising=False)\n    monkeypatch.setenv(\"NO_COLOR\", \"1\")            # stable output everywhere\n",[39,1380,1381,1398,1408,1423,1435,1450],{"__ignoreMap":107},[111,1382,1383,1386,1388,1391,1393,1396],{"class":113,"line":114},[111,1384,1385],{"class":146},"@pytest.fixture",[111,1387,690],{"class":121},[111,1389,1390],{"class":249},"autouse",[111,1392,159],{"class":117},[111,1394,1395],{"class":212},"True",[111,1397,312],{"class":121},[111,1399,1400,1402,1405],{"class":113,"line":125},[111,1401,143],{"class":117},[111,1403,1404],{"class":146}," clean_environment",[111,1406,1407],{"class":121},"(monkeypatch):\n",[111,1409,1410,1412,1415,1417,1420],{"class":113,"line":133},[111,1411,1116],{"class":117},[111,1413,1414],{"class":121}," key ",[111,1416,1122],{"class":117},[111,1418,1419],{"class":212}," list",[111,1421,1422],{"class":121},"(os.environ):\n",[111,1424,1425,1427,1430,1433],{"class":113,"line":140},[111,1426,1284],{"class":117},[111,1428,1429],{"class":121}," key.startswith(",[111,1431,1432],{"class":188},"\"MYTOOL_\"",[111,1434,1141],{"class":121},[111,1436,1437,1440,1443,1445,1448],{"class":113,"line":153},[111,1438,1439],{"class":121},"            monkeypatch.delenv(key, ",[111,1441,1442],{"class":249},"raising",[111,1444,159],{"class":117},[111,1446,1447],{"class":212},"False",[111,1449,312],{"class":121},[111,1451,1452,1455,1458,1460,1463,1466],{"class":113,"line":165},[111,1453,1454],{"class":121},"    monkeypatch.setenv(",[111,1456,1457],{"class":188},"\"NO_COLOR\"",[111,1459,246],{"class":121},[111,1461,1462],{"class":188},"\"1\"",[111,1464,1465],{"class":121},")            ",[111,1467,1468],{"class":677},"# stable output everywhere\n",[10,1470,1471,1474,1475,1478],{},[39,1472,1473],{},"autouse=True"," is justified here: no test wants a developer's exported variables, and forgetting\nto request the fixture is exactly the mistake it exists to prevent. ",[39,1476,1477],{},"monkeypatch"," undoes all of it\nafterwards, including in tests that fail.",[19,1480,1482],{"id":1481},"ux-considerations","UX considerations",[10,1484,1485],{},"Isolation choices show up in the quality of failure messages, which is worth optimising for.",[10,1487,1488,1489,1492,1493,1495],{},"A fake that raises ",[39,1490,1491],{},"AttributeError: 'FakeBucket' object has no attribute 'upload_many'"," tells you\nimmediately that the real interface grew a method. A ",[39,1494,55],{}," in the same position returns\nanother mock, the test passes, and the defect surfaces in production. That difference is the whole\nargument for hand-written fakes:",[102,1497,1499],{"className":104,"code":1498,"language":106,"meta":107,"style":107},"class FakeBucket:\n    def __init__(self) -> None:\n        self.uploaded: list[str] = []\n\n    def upload(self, path, *, retries: int = 3) -> None:\n        self.uploaded.append(str(path))\n",[39,1500,1501,1511,1525,1541,1545,1573],{"__ignoreMap":107},[111,1502,1503,1506,1509],{"class":113,"line":114},[111,1504,1505],{"class":117},"class",[111,1507,1508],{"class":146}," FakeBucket",[111,1510,1149],{"class":121},[111,1512,1513,1515,1518,1521,1523],{"class":113,"line":125},[111,1514,173],{"class":117},[111,1516,1517],{"class":212}," __init__",[111,1519,1520],{"class":121},"(self) -> ",[111,1522,1099],{"class":212},[111,1524,1149],{"class":121},[111,1526,1527,1530,1533,1535,1537,1539],{"class":113,"line":133},[111,1528,1529],{"class":212},"        self",[111,1531,1532],{"class":121},".uploaded: list[",[111,1534,483],{"class":212},[111,1536,192],{"class":121},[111,1538,159],{"class":117},[111,1540,1234],{"class":121},[111,1542,1543],{"class":113,"line":140},[111,1544,137],{"emptyLinePlaceholder":136},[111,1546,1547,1549,1552,1555,1557,1560,1562,1564,1566,1569,1571],{"class":113,"line":153},[111,1548,173],{"class":117},[111,1550,1551],{"class":146}," upload",[111,1553,1554],{"class":121},"(self, path, ",[111,1556,477],{"class":117},[111,1558,1559],{"class":121},", retries: ",[111,1561,489],{"class":212},[111,1563,470],{"class":117},[111,1565,1069],{"class":212},[111,1567,1568],{"class":121},") -> ",[111,1570,1099],{"class":212},[111,1572,1149],{"class":121},[111,1574,1575,1577,1580,1582],{"class":113,"line":165},[111,1576,1529],{"class":212},[111,1578,1579],{"class":121},".uploaded.append(",[111,1581,483],{"class":212},[111,1583,1584],{"class":121},"(path))\n",[10,1586,1587,1588,1591],{},"Ten lines, readable, and assertions run against state (",[39,1589,1590],{},"fake.uploaded == [...]",") rather than\nagainst call sequences — which is both easier to read and less coupled to how the code achieves\nthe result.",[19,1593,1595],{"id":1594},"testing-the-behaviour","Testing the behaviour",[10,1597,1598],{},"Once the seams are in place, the tests that matter become short:",[102,1600,1602],{"className":104,"code":1601,"language":106,"meta":107,"style":107},"def test_sync_uploads_only_changed_files(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 = {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",[39,1603,1604,1613,1629,1645,1655,1676,1680,1697,1701,1731],{"__ignoreMap":107},[111,1605,1606,1608,1611],{"class":113,"line":114},[111,1607,143],{"class":117},[111,1609,1610],{"class":146}," test_sync_uploads_only_changed_files",[111,1612,812],{"class":121},[111,1614,1615,1617,1619,1622,1624,1627],{"class":113,"line":125},[111,1616,817],{"class":121},[111,1618,820],{"class":117},[111,1620,1621],{"class":188}," \"a.txt\"",[111,1623,826],{"class":121},[111,1625,1626],{"class":188},"\"x\"",[111,1628,312],{"class":121},[111,1630,1631,1633,1635,1638,1640,1643],{"class":113,"line":133},[111,1632,817],{"class":121},[111,1634,820],{"class":117},[111,1636,1637],{"class":188}," \"b.txt\"",[111,1639,826],{"class":121},[111,1641,1642],{"class":188},"\"y\"",[111,1644,312],{"class":121},[111,1646,1647,1650,1652],{"class":113,"line":140},[111,1648,1649],{"class":121},"    bucket ",[111,1651,159],{"class":117},[111,1653,1654],{"class":121}," FakeBucket()\n",[111,1656,1657,1660,1662,1664,1666,1669,1671,1673],{"class":113,"line":153},[111,1658,1659],{"class":121},"    bucket.current ",[111,1661,159],{"class":117},[111,1663,400],{"class":121},[111,1665,483],{"class":212},[111,1667,1668],{"class":121},"(tmp_path ",[111,1670,820],{"class":117},[111,1672,1621],{"class":188},[111,1674,1675],{"class":121},")}\n",[111,1677,1678],{"class":113,"line":165},[111,1679,137],{"emptyLinePlaceholder":136},[111,1681,1682,1684,1686,1689,1692,1694],{"class":113,"line":170},[111,1683,318],{"class":121},[111,1685,159],{"class":117},[111,1687,1688],{"class":121}," sync_directory(tmp_path, ",[111,1690,1691],{"class":249},"bucket",[111,1693,159],{"class":117},[111,1695,1696],{"class":121},"bucket)\n",[111,1698,1699],{"class":113,"line":182},[111,1700,137],{"emptyLinePlaceholder":136},[111,1702,1703,1705,1708,1710,1713,1716,1718,1720,1722,1725,1727,1729],{"class":113,"line":200},[111,1704,354],{"class":117},[111,1706,1707],{"class":121}," result ",[111,1709,364],{"class":117},[111,1711,1712],{"class":121}," SyncResult(",[111,1714,1715],{"class":249},"uploaded",[111,1717,159],{"class":117},[111,1719,653],{"class":212},[111,1721,246],{"class":121},[111,1723,1724],{"class":249},"skipped",[111,1726,159],{"class":117},[111,1728,653],{"class":212},[111,1730,312],{"class":121},[111,1732,1733,1735,1738,1740,1742,1744,1746,1748,1750],{"class":113,"line":219},[111,1734,354],{"class":117},[111,1736,1737],{"class":121}," bucket.uploaded ",[111,1739,364],{"class":117},[111,1741,1344],{"class":121},[111,1743,483],{"class":212},[111,1745,1668],{"class":121},[111,1747,820],{"class":117},[111,1749,1637],{"class":188},[111,1751,1752],{"class":121},")]\n",[10,1754,1755],{},"No patching, no network, one real directory, and every assertion is about the rule under test.",[19,1757,1759],{"id":1758},"conclusion","Conclusion",[10,1761,1762],{},"Isolation is a design question more than a testing one. Accept collaborators as parameters, keep\nnon-determinism at the edges, replace the transport rather than your own code, and use real\ntemporary files. The result is a suite that runs offline, in parallel, in any order, and fails for\nreasons that point at the defect.",[19,1764,1766],{"id":1765},"frequently-asked-questions","Frequently asked questions",[1768,1769,1771,1772,1775],"h3",{"id":1770},"is-unittestmockpatch-ever-the-right-tool","Is ",[39,1773,1774],{},"unittest.mock.patch"," ever the right tool?",[10,1777,1778,1779,1782,1783,1786,1787,1790],{},"Yes — when the interaction ",[14,1780,1781],{},"is"," the behaviour under test, such as \"we call ",[39,1784,1785],{},"commit"," exactly once\neven when the loop retries\". For substituting a collaborator, injection or a fake is clearer and\nsurvives refactoring. The practical warning sign is a test with three or more ",[39,1788,1789],{},"patch"," decorators:\nthat usually means the code has no seam and the test is compensating.",[1768,1792,1794],{"id":1793},"how-do-i-test-code-that-shells-out-to-another-program","How do I test code that shells out to another program?",[10,1796,1797,1798,1801],{},"Inject the runner: a ",[39,1799,1800],{},"run: Callable[..., CompletedProcess] = subprocess.run"," parameter lets a test\nsupply a stub that records the argument list and returns a canned result. That covers the part\nworth testing — what command you built — without depending on the other program being installed.",[1768,1803,1805,1806,1809],{"id":1804},"should-i-use-pyfakefs-or-an-in-memory-filesystem","Should I use ",[39,1807,1808],{},"pyfakefs"," or an in-memory filesystem?",[10,1811,1812,1813,1815],{},"Rarely. ",[39,1814,41],{}," is already fast, and a real filesystem behaves the way production does, including\npermissions, symlinks and encoding errors. An in-memory layer is worth considering only when a test\nwould otherwise write tens of thousands of files.",[1768,1817,1819],{"id":1818},"how-do-i-record-real-http-responses-to-replay-later","How do I record real HTTP responses to replay later?",[10,1821,1822,1825,1826,1829],{},[39,1823,1824],{},"vcr.py"," or ",[39,1827,1828],{},"respx"," can capture and replay, which is useful for a complicated third-party API. The\ncost is that the recordings age silently — the API changes and your tests do not notice. Treat\nrecorded cassettes as a supplement to a small number of live integration tests, not a replacement.",[1768,1831,1833],{"id":1832},"what-about-database-access-in-a-cli","What about database access in a CLI?",[10,1835,1836,1837,1839],{},"Same principle: accept a session or connection as a parameter. For SQLite an on-disk database in\n",[39,1838,41],{}," is fast and behaves like production; for a server database, a transaction rolled back\nafter each test is the usual pattern, with a handful of tests running against a real instance in CI.",[1768,1841,1843],{"id":1842},"how-do-i-keep-fakes-in-sync-with-the-real-interface","How do I keep fakes in sync with the real interface?",[10,1845,1846,1847,1850,1851,1853,1854,1857],{},"Have the fake implement the same ",[39,1848,1849],{},"Protocol"," the real collaborator does, and let the type checker\nenforce it. A ",[39,1852,1849],{}," costs a few lines, documents the contract in one place, and turns \"the\nfake has drifted\" from a runtime surprise in production into a ",[39,1855,1856],{},"mypy"," error in the pull request\nthat changed the interface, which is where it is cheapest to notice rather than after a release has gone out with a fake that no longer resembles the thing it stands in for.",[19,1859,1861],{"id":1860},"related","Related",[24,1863,1864,1872,1879,1885,1892],{},[27,1865,1866,1867],{},"Up: ",[1868,1869,1871],"a",{"href":1870},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002F","Testing Python CLI applications",[27,1873,1874,1875],{},"Sideways: ",[1868,1876,1878],{"href":1877},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-click-commands-with-clirunner\u002F","Testing Click commands with CliRunner",[27,1880,1874,1881],{},[1868,1882,1884],{"href":1883},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmeasuring-cli-test-coverage\u002F","Measuring CLI test coverage",[27,1886,1887,1888],{},"Related: ",[1868,1889,1891],{"href":1890},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fsharing-state-with-click-context-objects\u002F","Sharing state with Click context objects",[27,1893,1887,1894],{},[1868,1895,1897],{"href":1896},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002F","Handling configuration files and environment variables",[1899,1900,1901],"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 .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 .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}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 .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":107,"searchDepth":125,"depth":125,"links":1903},[1904,1905,1906,1907,1908,1909,1910,1911,1912,1913,1914,1924],{"id":21,"depth":125,"text":22},{"id":62,"depth":125,"text":63},{"id":81,"depth":125,"text":82},{"id":521,"depth":125,"text":522},{"id":784,"depth":125,"text":785},{"id":993,"depth":125,"text":994},{"id":1371,"depth":125,"text":1372},{"id":1481,"depth":125,"text":1482},{"id":1594,"depth":125,"text":1595},{"id":1758,"depth":125,"text":1759},{"id":1765,"depth":125,"text":1766,"children":1915},[1916,1918,1919,1921,1922,1923],{"id":1770,"depth":133,"text":1917},"Is unittest.mock.patch ever the right tool?",{"id":1793,"depth":133,"text":1794},{"id":1804,"depth":133,"text":1920},"Should I use pyfakefs or an in-memory filesystem?",{"id":1818,"depth":133,"text":1819},{"id":1832,"depth":133,"text":1833},{"id":1842,"depth":133,"text":1843},{"id":1860,"depth":125,"text":1861},"2026-08-01","Isolate Python CLI tests from disk, HTTP, time, and the environment - transport mocks, tmp_path, injected clocks, and fakes that fail loudly.","intermediate",false,"md",{},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmocking-filesystem-and-network-in-cli-tests",{"title":5,"description":1926},"modern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmocking-filesystem-and-network-in-cli-tests\u002Findex",[1935,1936,1937,69,1938],"testing","mocking","pytest","fixtures","DeWMkYlZq1vLo4zdHymFpYVUduVIXkTd9VtZBV8QCzk",[1941,1944,1947,1950,1953,1956,1959,1962,1965,1968,1971,1974,1977,1980,1983,1986,1989,1992,1995,1998,2001,2004,2007,2010,2013,2016,2019,2022,2025,2028,2031,2034,2036,2039,2042,2045,2048,2051,2054,2057,2060,2063,2066,2069,2072,2075,2078,2081,2084,2087,2090,2091,2094,2097,2100,2103,2106,2109,2112,2115,2118,2121,2124,2127,2130,2133,2136,2139,2142,2145,2148,2151,2154,2157,2160,2163,2166,2169,2172,2175,2178,2181,2184],{"path":1942,"title":1943},"\u002Fabout","About Python CLI Toolcraft",{"path":1945,"title":1946},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies","Advanced Argument Validation Strategies",{"path":1948,"title":1949},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fparsing-nested-json-arguments-in-python-clis","Parsing Nested JSON Args in Python CLIs",{"path":1951,"title":1952},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fvalidating-file-and-directory-paths-in-clis","Validating File and Directory Paths in CLIs",{"path":1954,"title":1955},"\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":1957,"title":1958},"\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":1960,"title":1961},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation","CLI Help Output and Documentation",{"path":1963,"title":1964},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fversioning-and-deprecating-cli-flags","Versioning and Deprecating CLI Flags",{"path":1966,"title":1967},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fwriting-help-text-users-actually-read","Writing Help Text Users Actually Read",{"path":1969,"title":1970},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Fchoosing-exit-codes-for-cli-tools","Choosing Exit Codes for CLI Tools",{"path":1972,"title":1973},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Ffriendly-error-messages-and-tracebacks","Friendly Error Messages and Tracebacks",{"path":1975,"title":1976},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Fhandling-keyboard-interrupt-cleanly","Handling Keyboard Interrupt Cleanly",{"path":1978,"title":1979},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes","Error Handling and Exit Codes for CLIs",{"path":1981,"title":1982},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Fconfig-precedence-flags-env-files-defaults","Config Precedence: Flags, Env, Files, Defaults",{"path":1984,"title":1985},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars","Handling Config Files and Env Vars in CLIs",{"path":1987,"title":1988},"\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":1990,"title":1991},"\u002Fadvanced-input-parsing-user-experience","Advanced Input Parsing for Python CLIs",{"path":1993,"title":1994},"\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":1996,"title":1997},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich","Interactive Terminal UI with Rich",{"path":1999,"title":2000},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Frendering-tables-and-json-with-rich","Rendering Tables and JSON with Rich",{"path":2002,"title":2003},"\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":2005,"title":2006},"\u002Fadvanced-input-parsing-user-experience\u002Fshell-completion-for-python-clis","Shell Completion for Python CLIs",{"path":2008,"title":2009},"\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":2011,"title":2012},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fadding-verbose-and-quiet-logging-flags","Adding Verbose and Quiet Logging Flags",{"path":2014,"title":2015},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps","Structured Logging for CLI Apps",{"path":2017,"title":2018},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fstructured-json-logging-in-python-clis","Structured JSON Logging in Python CLIs",{"path":2020,"title":2021},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Fdetecting-tty-and-adapting-output","Detecting a TTY and Adapting Output",{"path":2023,"title":2024},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Femitting-json-output-for-scripting","Emitting JSON Output for Scripting",{"path":2026,"title":2027},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Fhandling-broken-pipe-and-sigpipe","Handling Broken Pipe and SIGPIPE",{"path":2029,"title":2030},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes","Working with stdin, stdout and Pipes",{"path":2032,"title":2033},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Freading-piped-input-in-python-clis","Reading Piped Input in Python CLIs",{"path":820,"title":2035},"Python CLI Toolcraft",{"path":2037,"title":2038},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading","CLI Startup Performance and Lazy Loading",{"path":2040,"title":2041},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Flazy-loading-subcommands-for-faster-startup","Lazy Loading Subcommands for Faster Startup",{"path":2043,"title":2044},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Fprofiling-python-cli-startup-time","Profiling Python CLI Startup Time",{"path":2046,"title":2047},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Freducing-cli-dependency-weight","Reducing CLI Dependency Weight",{"path":2049,"title":2050},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fargparse-subparsers-for-subcommands","argparse Subparsers for Subcommands",{"path":2052,"title":2053},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fargparse-vs-click-vs-typer-comparison","argparse vs Click vs Typer Compared",{"path":2055,"title":2056},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse","Command-Line Parsing with argparse",{"path":2058,"title":2059},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fmigrating-from-argparse-to-typer","Migrating from argparse to Typer",{"path":2061,"title":2062},"\u002Fmodern-python-cli-frameworks-architecture","Python CLI Frameworks and Architecture",{"path":2064,"title":2065},"\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis","Plugin Architectures for Extensible CLIs",{"path":2067,"title":2068},"\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":2070,"title":2071},"\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":2073,"title":2074},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fdependency-injection-patterns-for-cli-commands","Dependency Injection Patterns for CLI Commands",{"path":2076,"title":2077},"\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":2079,"title":2080},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis","Structuring Multi-Command Python CLIs",{"path":2082,"title":2083},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fsharing-state-with-click-context-objects","Sharing State with Click Context Objects",{"path":2085,"title":2086},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications","Testing Python CLI Applications",{"path":2088,"title":2089},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmeasuring-cli-test-coverage","Measuring CLI Test Coverage",{"path":1931,"title":5},{"path":2092,"title":2093},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fsnapshot-testing-cli-output","Snapshot Testing CLI Output",{"path":2095,"title":2096},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-click-commands-with-clirunner","Testing Click Commands with CliRunner",{"path":2098,"title":2099},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-interactive-prompts-and-stdin","Testing Interactive Prompts and stdin",{"path":2101,"title":2102},"\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":2104,"title":2105},"\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":2107,"title":2108},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each","Typer vs Click: When to Use Each",{"path":2110,"title":2111},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Ftyper-callback-functions-explained","Typer callback functions explained",{"path":2113,"title":2114},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter\u002Fcopier-vs-cookiecutter-for-cli-templates","Copier vs Cookiecutter for CLI Templates",{"path":2116,"title":2117},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter","CLI Project Scaffolding with Cookiecutter",{"path":2119,"title":2120},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fbuilding-cross-platform-release-binaries-in-ci","Building Cross-Platform Release Binaries in CI",{"path":2122,"title":2123},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fbundling-a-python-cli-with-pyinstaller","Bundling a Python CLI with PyInstaller",{"path":2125,"title":2126},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fhomebrew-and-scoop-packaging-for-python-clis","Homebrew and Scoop Packaging for Python CLIs",{"path":2128,"title":2129},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries","Distributing CLIs as Standalone Binaries",{"path":2131,"title":2132},"\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":2134,"title":2135},"\u002Fproject-setup-dependency-management","Project Setup & Dependency Management",{"path":2137,"title":2138},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fautomating-changelogs-with-conventional-commits","Automating Changelogs with Conventional Commits",{"path":2140,"title":2141},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fexposing-version-info-and-build-metadata","Exposing Version Info and Build Metadata",{"path":2143,"title":2144},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs","Managing CLI Versioning & Changelogs",{"path":2146,"title":2147},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fbuilding-wheels-and-sdists-for-python-clis","Building Wheels and sdists for Python CLIs",{"path":2149,"title":2150},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution","Packaging Python CLIs for Distribution",{"path":2152,"title":2153},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Finstalling-and-distributing-clis-with-pipx","Installing and Distributing CLIs with pipx",{"path":2155,"title":2156},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fpublishing-a-python-cli-to-pypi","Publishing a Python CLI to PyPI",{"path":2158,"title":2159},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development","Poetry Workflows for CLI Development",{"path":2161,"title":2162},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development\u002Fpoetry-entry-points-and-scripts-for-clis","Poetry Entry Points and Scripts for CLIs",{"path":2164,"title":2165},"\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects","Pre-commit Hooks for CLI Projects",{"path":2167,"title":2168},"\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":2170,"title":2171},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management","uv for Python CLI Dependency Management",{"path":2173,"title":2174},"\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":2176,"title":2177},"\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":2179,"title":2180},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices","Python CLI Env Isolation Best Practices",{"path":2182,"title":2183},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fmanaging-virtual-environments-for-cross-platform-clis","Managing Python CLI Virtual Environments",{"path":2185,"title":2186},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fpinning-the-python-version-for-a-cli","Pinning the Python Version for a CLI",1785614690033]