[{"data":1,"prerenderedAt":2279},["ShallowReactive",2],{"page-\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002F":3,"content-directory":2031},{"id":4,"title":5,"body":6,"date":2016,"description":2017,"difficulty":2018,"draft":2019,"extension":2020,"meta":2021,"navigation":129,"path":2022,"seo":2023,"stem":2024,"tags":2025,"updated":2016,"__hash__":2030},"content\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Findex.md","Working with stdin, stdout and Pipes",{"type":7,"value":8,"toc":1989},"minimark",[9,13,18,61,65,69,72,75,78,81,175,186,190,193,348,362,369,411,415,418,552,563,566,570,583,659,672,676,682,685,728,731,735,738,838,845,852,913,920,968,972,975,1127,1133,1140,1144,1147,1153,1167,1182,1186,1189,1392,1402,1405,1524,1528,1531,1635,1733,1736,1739,1743,1749,1752,1765,1774,1787,1790,1794,1826,1830,1853,1857,1862,1876,1880,1883,1887,1898,1905,1908,1912,1918,1922,1939,1943,1946,1950,1985],[10,11,12],"p",{},"The difference between a script and a tool people build on is usually composition: whether your\ncommand can sit in the middle of a pipeline without corrupting it, hanging, or crashing when the\ndownstream program exits early. All of that comes down to how you treat three streams.",[14,15,17],"h2",{"id":16},"tldr","TL;DR",[19,20,21,29,35,43,54],"ul",{},[22,23,24,28],"li",{},[25,26,27],"strong",{},"stdout is the result."," Anything a pipe should carry goes there and nothing else does.",[22,30,31,34],{},[25,32,33],{},"stderr is narration."," Progress, warnings, errors and prompts — nothing should parse it.",[22,36,37,38,42],{},"Support ",[39,40,41],"code",{},"-"," for reading stdin and writing stdout; it costs a few lines.",[22,44,45,46,49,50,53],{},"Catch ",[39,47,48],{},"BrokenPipeError"," at the boundary and exit quietly — ",[39,51,52],{},"| head"," is not a crash.",[22,55,56,57,60],{},"Check ",[39,58,59],{},"isatty"," before colour, progress bars and prompts.",[62,63],"inline-diagram",{"name":64},"streams-section-map",[14,66,68],{"id":67},"three-streams-three-jobs","Three streams, three jobs",[62,70],{"name":71},"stream-roles-stack",[10,73,74],{},"Almost every pipeline bug in a CLI is something written to the wrong stream. The test is simple: if\npiping your command into another program should carry it, it belongs on stdout. Everything else —\nincluding the progress bar that makes the tool pleasant interactively — belongs on stderr, where it\nstill reaches the user's terminal but never enters the pipeline.",[62,76],{"name":77},"pipeline-position",[10,79,80],{},"In practice that means two output objects created once and imported everywhere:",[82,83,88],"pre",{"className":84,"code":85,"language":86,"meta":87,"style":87},"language-python shiki shiki-themes github-light github-dark","# src\u002Fmytool\u002Fconsole.py\nimport sys\nfrom rich.console import Console\n\nout = Console(file=sys.stdout)        # results\nerr = Console(file=sys.stderr)        # progress, warnings, errors\n","python","",[39,89,90,99,110,124,131,155],{"__ignoreMap":87},[91,92,95],"span",{"class":93,"line":94},"line",1,[91,96,98],{"class":97},"sJ8bj","# src\u002Fmytool\u002Fconsole.py\n",[91,100,102,106],{"class":93,"line":101},2,[91,103,105],{"class":104},"szBVR","import",[91,107,109],{"class":108},"sVt8B"," sys\n",[91,111,113,116,119,121],{"class":93,"line":112},3,[91,114,115],{"class":104},"from",[91,117,118],{"class":108}," rich.console ",[91,120,105],{"class":104},[91,122,123],{"class":108}," Console\n",[91,125,127],{"class":93,"line":126},4,[91,128,130],{"emptyLinePlaceholder":129},true,"\n",[91,132,134,137,140,143,147,149,152],{"class":93,"line":133},5,[91,135,136],{"class":108},"out ",[91,138,139],{"class":104},"=",[91,141,142],{"class":108}," Console(",[91,144,146],{"class":145},"s4XuR","file",[91,148,139],{"class":104},[91,150,151],{"class":108},"sys.stdout)        ",[91,153,154],{"class":97},"# results\n",[91,156,158,161,163,165,167,169,172],{"class":93,"line":157},6,[91,159,160],{"class":108},"err ",[91,162,139],{"class":104},[91,164,142],{"class":108},[91,166,146],{"class":145},[91,168,139],{"class":104},[91,170,171],{"class":108},"sys.stderr)        ",[91,173,174],{"class":97},"# progress, warnings, errors\n",[10,176,177,178,181,182,185],{},"Every ",[39,179,180],{},"print"," in the program then goes through one of them, and the question \"which stream is this\non\" is answered at the call site rather than by grepping for stray ",[39,183,184],{},"print()"," calls later.",[14,187,189],{"id":188},"reading-input-that-may-be-piped","Reading input that may be piped",[10,191,192],{},"A tool that reads stdin unconditionally appears to hang when run with no arguments, because it is\nwaiting for input a person at a prompt has no idea they are meant to type. Check first:",[82,194,196],{"className":84,"code":195,"language":86,"meta":87,"style":87},"import sys\nfrom pathlib import Path\n\ndef read_document(source: Path | None) -> str:\n    \"\"\"Read from SOURCE, or from stdin when SOURCE is '-' or omitted with data piped in.\"\"\"\n    if source is not None and str(source) != \"-\":\n        return source.read_text(encoding=\"utf-8\")\n\n    if sys.stdin.isatty():\n        raise typer.BadParameter(\"no input: pass a file, or pipe a document on stdin\")\n\n    return sys.stdin.read()\n",[39,197,198,204,216,220,248,254,287,307,312,320,334,339],{"__ignoreMap":87},[91,199,200,202],{"class":93,"line":94},[91,201,105],{"class":104},[91,203,109],{"class":108},[91,205,206,208,211,213],{"class":93,"line":101},[91,207,115],{"class":104},[91,209,210],{"class":108}," pathlib ",[91,212,105],{"class":104},[91,214,215],{"class":108}," Path\n",[91,217,218],{"class":93,"line":112},[91,219,130],{"emptyLinePlaceholder":129},[91,221,222,225,229,232,235,239,242,245],{"class":93,"line":126},[91,223,224],{"class":104},"def",[91,226,228],{"class":227},"sScJk"," read_document",[91,230,231],{"class":108},"(source: Path ",[91,233,234],{"class":104},"|",[91,236,238],{"class":237},"sj4cs"," None",[91,240,241],{"class":108},") -> ",[91,243,244],{"class":237},"str",[91,246,247],{"class":108},":\n",[91,249,250],{"class":93,"line":133},[91,251,253],{"class":252},"sZZnC","    \"\"\"Read from SOURCE, or from stdin when SOURCE is '-' or omitted with data piped in.\"\"\"\n",[91,255,256,259,262,265,268,270,273,276,279,282,285],{"class":93,"line":157},[91,257,258],{"class":104},"    if",[91,260,261],{"class":108}," source ",[91,263,264],{"class":104},"is",[91,266,267],{"class":104}," not",[91,269,238],{"class":237},[91,271,272],{"class":104}," and",[91,274,275],{"class":237}," str",[91,277,278],{"class":108},"(source) ",[91,280,281],{"class":104},"!=",[91,283,284],{"class":252}," \"-\"",[91,286,247],{"class":108},[91,288,290,293,296,299,301,304],{"class":93,"line":289},7,[91,291,292],{"class":104},"        return",[91,294,295],{"class":108}," source.read_text(",[91,297,298],{"class":145},"encoding",[91,300,139],{"class":104},[91,302,303],{"class":252},"\"utf-8\"",[91,305,306],{"class":108},")\n",[91,308,310],{"class":93,"line":309},8,[91,311,130],{"emptyLinePlaceholder":129},[91,313,315,317],{"class":93,"line":314},9,[91,316,258],{"class":104},[91,318,319],{"class":108}," sys.stdin.isatty():\n",[91,321,323,326,329,332],{"class":93,"line":322},10,[91,324,325],{"class":104},"        raise",[91,327,328],{"class":108}," typer.BadParameter(",[91,330,331],{"class":252},"\"no input: pass a file, or pipe a document on stdin\"",[91,333,306],{"class":108},[91,335,337],{"class":93,"line":336},11,[91,338,130],{"emptyLinePlaceholder":129},[91,340,342,345],{"class":93,"line":341},12,[91,343,344],{"class":104},"    return",[91,346,347],{"class":108}," sys.stdin.read()\n",[10,349,350,351,353,354,357,358,361],{},"The ",[39,352,41],{}," convention is worth honouring in both directions. ",[39,355,356],{},"mytool apply -"," reads the document from\nstdin; ",[39,359,360],{},"mytool export -o -"," writes results to stdout. Users of Unix tools expect it, and supporting\nit is what lets your command appear anywhere in a pipeline rather than only at the start.",[10,363,364,365,368],{},"One detail that bites across platforms: name the encoding. ",[39,366,367],{},"sys.stdin.read()"," uses the platform\ndefault, which differs between Linux, macOS and Windows and between locales. For text, reconfigure\nexplicitly; for binary data, use the underlying buffer:",[82,370,372],{"className":84,"code":371,"language":86,"meta":87,"style":87},"sys.stdin.reconfigure(encoding=\"utf-8\", errors=\"strict\")\nraw_bytes = sys.stdin.buffer.read()          # when the input is not text at all\n",[39,373,374,398],{"__ignoreMap":87},[91,375,376,379,381,383,385,388,391,393,396],{"class":93,"line":94},[91,377,378],{"class":108},"sys.stdin.reconfigure(",[91,380,298],{"class":145},[91,382,139],{"class":104},[91,384,303],{"class":252},[91,386,387],{"class":108},", ",[91,389,390],{"class":145},"errors",[91,392,139],{"class":104},[91,394,395],{"class":252},"\"strict\"",[91,397,306],{"class":108},[91,399,400,403,405,408],{"class":93,"line":101},[91,401,402],{"class":108},"raw_bytes ",[91,404,139],{"class":104},[91,406,407],{"class":108}," sys.stdin.buffer.read()          ",[91,409,410],{"class":97},"# when the input is not text at all\n",[14,412,414],{"id":413},"output-other-programs-can-parse","Output other programs can parse",[10,416,417],{},"The moment someone wants to script your tool, they will parse whatever you print. Giving them a\nreal format instead means the human-readable output stops being an interface you have to keep\nstable.",[82,419,421],{"className":84,"code":420,"language":86,"meta":87,"style":87},"@app.command()\ndef status(\n    json_output: Annotated[bool, typer.Option(\"--json\", help=\"Emit JSON on stdout.\")] = False,\n) -> None:\n    rows = core.collect_status()\n    if json_output:\n        typer.echo(json.dumps({\"version\": 1, \"data\": [r.as_dict() for r in rows], \"warnings\": []}))\n        return\n    render_table(rows)\n",[39,422,423,431,441,476,485,495,502,542,547],{"__ignoreMap":87},[91,424,425,428],{"class":93,"line":94},[91,426,427],{"class":227},"@app.command",[91,429,430],{"class":108},"()\n",[91,432,433,435,438],{"class":93,"line":101},[91,434,224],{"class":104},[91,436,437],{"class":227}," status",[91,439,440],{"class":108},"(\n",[91,442,443,446,449,452,455,457,460,462,465,468,470,473],{"class":93,"line":112},[91,444,445],{"class":108},"    json_output: Annotated[",[91,447,448],{"class":237},"bool",[91,450,451],{"class":108},", typer.Option(",[91,453,454],{"class":252},"\"--json\"",[91,456,387],{"class":108},[91,458,459],{"class":145},"help",[91,461,139],{"class":104},[91,463,464],{"class":252},"\"Emit JSON on stdout.\"",[91,466,467],{"class":108},")] ",[91,469,139],{"class":104},[91,471,472],{"class":237}," False",[91,474,475],{"class":108},",\n",[91,477,478,480,483],{"class":93,"line":126},[91,479,241],{"class":108},[91,481,482],{"class":237},"None",[91,484,247],{"class":108},[91,486,487,490,492],{"class":93,"line":133},[91,488,489],{"class":108},"    rows ",[91,491,139],{"class":104},[91,493,494],{"class":108}," core.collect_status()\n",[91,496,497,499],{"class":93,"line":157},[91,498,258],{"class":104},[91,500,501],{"class":108}," json_output:\n",[91,503,504,507,510,513,516,518,521,524,527,530,533,536,539],{"class":93,"line":289},[91,505,506],{"class":108},"        typer.echo(json.dumps({",[91,508,509],{"class":252},"\"version\"",[91,511,512],{"class":108},": ",[91,514,515],{"class":237},"1",[91,517,387],{"class":108},[91,519,520],{"class":252},"\"data\"",[91,522,523],{"class":108},": [r.as_dict() ",[91,525,526],{"class":104},"for",[91,528,529],{"class":108}," r ",[91,531,532],{"class":104},"in",[91,534,535],{"class":108}," rows], ",[91,537,538],{"class":252},"\"warnings\"",[91,540,541],{"class":108},": []}))\n",[91,543,544],{"class":93,"line":309},[91,545,546],{"class":104},"        return\n",[91,548,549],{"class":93,"line":314},[91,550,551],{"class":108},"    render_table(rows)\n",[10,553,554,555,558,559,562],{},"Use one envelope for every command. A tool where ",[39,556,557],{},"status --json"," returns a list and ",[39,560,561],{},"list --json","\nreturns an object forces consumers to write per-command parsers, which is exactly the friction the\nflag was meant to remove.",[10,564,565],{},"For long or streaming results, newline-delimited JSON is the better shape: one object per line, no\nenclosing array, so a consumer can start work before your command has finished — and your memory\nstays flat.",[14,567,569],{"id":568},"surviving-a-closed-pipe","Surviving a closed pipe",[10,571,572,575,576,579,580,582],{},[39,573,574],{},"mytool list | head -n 10"," is an ordinary thing to type. When ",[39,577,578],{},"head"," has what it needs it exits and\ncloses the pipe, and your next write raises ",[39,581,48],{},". The default behaviour — a traceback,\nplus a second warning from the interpreter at shutdown — makes a completely normal shell idiom look\nlike a crash.",[82,584,586],{"className":84,"code":585,"language":86,"meta":87,"style":87},"def main() -> None:\n    try:\n        app()\n    except BrokenPipeError:\n        # Redirect stdout so the interpreter's own flush at shutdown cannot fail too.\n        devnull = os.open(os.devnull, os.O_WRONLY)\n        os.dup2(devnull, sys.stdout.fileno())\n        sys.exit(0)\n",[39,587,588,602,609,614,624,629,644,649],{"__ignoreMap":87},[91,589,590,592,595,598,600],{"class":93,"line":94},[91,591,224],{"class":104},[91,593,594],{"class":227}," main",[91,596,597],{"class":108},"() -> ",[91,599,482],{"class":237},[91,601,247],{"class":108},[91,603,604,607],{"class":93,"line":101},[91,605,606],{"class":104},"    try",[91,608,247],{"class":108},[91,610,611],{"class":93,"line":112},[91,612,613],{"class":108},"        app()\n",[91,615,616,619,622],{"class":93,"line":126},[91,617,618],{"class":104},"    except",[91,620,621],{"class":237}," BrokenPipeError",[91,623,247],{"class":108},[91,625,626],{"class":93,"line":133},[91,627,628],{"class":97},"        # Redirect stdout so the interpreter's own flush at shutdown cannot fail too.\n",[91,630,631,634,636,639,642],{"class":93,"line":157},[91,632,633],{"class":108},"        devnull ",[91,635,139],{"class":104},[91,637,638],{"class":108}," os.open(os.devnull, os.",[91,640,641],{"class":237},"O_WRONLY",[91,643,306],{"class":108},[91,645,646],{"class":93,"line":289},[91,647,648],{"class":108},"        os.dup2(devnull, sys.stdout.fileno())\n",[91,650,651,654,657],{"class":93,"line":309},[91,652,653],{"class":108},"        sys.exit(",[91,655,656],{"class":237},"0",[91,658,306],{"class":108},[10,660,661,662,667,668,671],{},"Exit 0: nothing went wrong. The downstream consumer decided it had enough, which is the pipeline\nworking as designed. The ",[663,664,666],"a",{"href":665},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Fhandling-broken-pipe-and-sigpipe\u002F","broken pipe guide","\ncovers the platform differences and why the ",[39,669,670],{},"dup2"," line is needed.",[14,673,675],{"id":674},"behaving-differently-when-nobody-is-watching","Behaving differently when nobody is watching",[10,677,678,679,681],{},"Five behaviours should change when output is not a terminal, and each is one ",[39,680,59],{}," check. Rich\nand Click handle most of them automatically; what they cannot decide is whether a progress bar\nshould become periodic plain lines or disappear entirely, and whether a prompt should fail or use a\ndefault.",[10,683,684],{},"Route the check through one helper so the answer is consistent and testable:",[82,686,688],{"className":84,"code":687,"language":86,"meta":87,"style":87},"def interactive() -> bool:\n    return sys.stdin.isatty() and sys.stderr.isatty() and not os.environ.get(\"CI\")\n",[39,689,690,703],{"__ignoreMap":87},[91,691,692,694,697,699,701],{"class":93,"line":94},[91,693,224],{"class":104},[91,695,696],{"class":227}," interactive",[91,698,597],{"class":108},[91,700,448],{"class":237},[91,702,247],{"class":108},[91,704,705,707,710,713,716,718,720,723,726],{"class":93,"line":101},[91,706,344],{"class":104},[91,708,709],{"class":108}," sys.stdin.isatty() ",[91,711,712],{"class":104},"and",[91,714,715],{"class":108}," sys.stderr.isatty() ",[91,717,712],{"class":104},[91,719,267],{"class":104},[91,721,722],{"class":108}," os.environ.get(",[91,724,725],{"class":252},"\"CI\"",[91,727,306],{"class":108},[62,729],{"name":730},"pipe-safety-checklist",[14,732,734],{"id":733},"streaming-rather-than-collecting","Streaming rather than collecting",[10,736,737],{},"The shape of a command that produces many records decides how it behaves in a pipeline. Collecting\neverything and printing at the end is simpler to write and worse in three ways: memory grows with\nthe input, the downstream program waits for the whole run, and a failure halfway through produces\nnothing at all.",[82,739,741],{"className":84,"code":740,"language":86,"meta":87,"style":87},"# collects — memory grows, nothing appears until the end\ndef export(period: str) -> None:\n    rows = list(core.collect(period))\n    typer.echo(json.dumps([row.as_dict() for row in rows]))\n\n# streams — flat memory, output starts immediately\ndef export(period: str) -> None:\n    for row in core.collect(period):          # a generator\n        typer.echo(json.dumps(row.as_dict()))\n",[39,742,743,748,766,778,793,797,802,818,833],{"__ignoreMap":87},[91,744,745],{"class":93,"line":94},[91,746,747],{"class":97},"# collects — memory grows, nothing appears until the end\n",[91,749,750,752,755,758,760,762,764],{"class":93,"line":101},[91,751,224],{"class":104},[91,753,754],{"class":227}," export",[91,756,757],{"class":108},"(period: ",[91,759,244],{"class":237},[91,761,241],{"class":108},[91,763,482],{"class":237},[91,765,247],{"class":108},[91,767,768,770,772,775],{"class":93,"line":112},[91,769,489],{"class":108},[91,771,139],{"class":104},[91,773,774],{"class":237}," list",[91,776,777],{"class":108},"(core.collect(period))\n",[91,779,780,783,785,788,790],{"class":93,"line":126},[91,781,782],{"class":108},"    typer.echo(json.dumps([row.as_dict() ",[91,784,526],{"class":104},[91,786,787],{"class":108}," row ",[91,789,532],{"class":104},[91,791,792],{"class":108}," rows]))\n",[91,794,795],{"class":93,"line":133},[91,796,130],{"emptyLinePlaceholder":129},[91,798,799],{"class":93,"line":157},[91,800,801],{"class":97},"# streams — flat memory, output starts immediately\n",[91,803,804,806,808,810,812,814,816],{"class":93,"line":289},[91,805,224],{"class":104},[91,807,754],{"class":227},[91,809,757],{"class":108},[91,811,244],{"class":237},[91,813,241],{"class":108},[91,815,482],{"class":237},[91,817,247],{"class":108},[91,819,820,823,825,827,830],{"class":93,"line":309},[91,821,822],{"class":104},"    for",[91,824,787],{"class":108},[91,826,532],{"class":104},[91,828,829],{"class":108}," core.collect(period):          ",[91,831,832],{"class":97},"# a generator\n",[91,834,835],{"class":93,"line":314},[91,836,837],{"class":108},"        typer.echo(json.dumps(row.as_dict()))\n",[10,839,840,841,844],{},"The streaming version needs ",[39,842,843],{},"core.collect"," to be a generator rather than a function returning a\nlist, which is usually a small change and occasionally a design improvement in its own right —\nthe logic layer stops deciding how much to hold in memory, and the caller decides instead.",[10,846,847,848,851],{},"Two details make streaming work properly in practice. ",[25,849,850],{},"Flush on a sensible boundary",": Python\nbuffers stdout when it is not a terminal, which is what you want for throughput, but it means a\nlong-running command produces nothing for a while. If a downstream consumer needs records promptly,\nflush every few hundred lines rather than every line:",[82,853,855],{"className":84,"code":854,"language":86,"meta":87,"style":87},"for index, row in enumerate(core.collect(period), start=1):\n    typer.echo(json.dumps(row.as_dict()))\n    if index % 500 == 0:\n        sys.stdout.flush()\n",[39,856,857,882,887,908],{"__ignoreMap":87},[91,858,859,861,864,866,869,872,875,877,879],{"class":93,"line":94},[91,860,526],{"class":104},[91,862,863],{"class":108}," index, row ",[91,865,532],{"class":104},[91,867,868],{"class":237}," enumerate",[91,870,871],{"class":108},"(core.collect(period), ",[91,873,874],{"class":145},"start",[91,876,139],{"class":104},[91,878,515],{"class":237},[91,880,881],{"class":108},"):\n",[91,883,884],{"class":93,"line":101},[91,885,886],{"class":108},"    typer.echo(json.dumps(row.as_dict()))\n",[91,888,889,891,894,897,900,903,906],{"class":93,"line":112},[91,890,258],{"class":104},[91,892,893],{"class":108}," index ",[91,895,896],{"class":104},"%",[91,898,899],{"class":237}," 500",[91,901,902],{"class":104}," ==",[91,904,905],{"class":237}," 0",[91,907,247],{"class":108},[91,909,910],{"class":93,"line":126},[91,911,912],{"class":108},"        sys.stdout.flush()\n",[10,914,915,916,919],{},"And ",[25,917,918],{},"report progress on stderr",", where it does not enter the pipeline:",[82,921,923],{"className":84,"code":922,"language":86,"meta":87,"style":87},"if index % 1000 == 0:\n    err.print(f\"[dim]{index} rows exported…[\u002F]\")\n",[39,924,925,943],{"__ignoreMap":87},[91,926,927,930,932,934,937,939,941],{"class":93,"line":94},[91,928,929],{"class":104},"if",[91,931,893],{"class":108},[91,933,896],{"class":104},[91,935,936],{"class":237}," 1000",[91,938,902],{"class":104},[91,940,905],{"class":237},[91,942,247],{"class":108},[91,944,945,948,951,954,957,960,963,966],{"class":93,"line":101},[91,946,947],{"class":108},"    err.print(",[91,949,950],{"class":104},"f",[91,952,953],{"class":252},"\"[dim]",[91,955,956],{"class":237},"{",[91,958,959],{"class":108},"index",[91,961,962],{"class":237},"}",[91,964,965],{"class":252}," rows exported…[\u002F]\"",[91,967,306],{"class":108},[14,969,971],{"id":970},"composing-with-the-shell-deliberately","Composing with the shell, deliberately",[10,973,974],{},"A tool that reads stdin and writes stdout gains a whole vocabulary of uses you never wrote code\nfor. It is worth designing for them explicitly, because a few small decisions decide whether these\nwork:",[82,976,980],{"className":977,"code":978,"language":979,"meta":87,"style":87},"language-bash shiki shiki-themes github-light github-dark","mytool export --json | jq '.data[] | select(.size > 1e6)'      # filter\nmytool export --json | mytool import -                          # round-trip\ncat jobs.ndjson | mytool apply - --dry-run                      # bulk apply\nmytool list --json | head -5                                    # sample\nmytool export -o - | gzip > backup.json.gz                      # stream to storage\ndiff \u003C(mytool export --json) \u003C(ssh host mytool export --json)   # compare environments\n","bash",[39,981,982,1004,1026,1049,1068,1093],{"__ignoreMap":87},[91,983,984,987,989,992,995,998,1001],{"class":93,"line":94},[91,985,986],{"class":227},"mytool",[91,988,754],{"class":252},[91,990,991],{"class":237}," --json",[91,993,994],{"class":104}," |",[91,996,997],{"class":227}," jq",[91,999,1000],{"class":252}," '.data[] | select(.size > 1e6)'",[91,1002,1003],{"class":97},"      # filter\n",[91,1005,1006,1008,1010,1012,1014,1017,1020,1023],{"class":93,"line":101},[91,1007,986],{"class":227},[91,1009,754],{"class":252},[91,1011,991],{"class":237},[91,1013,994],{"class":104},[91,1015,1016],{"class":227}," mytool",[91,1018,1019],{"class":252}," import",[91,1021,1022],{"class":252}," -",[91,1024,1025],{"class":97},"                          # round-trip\n",[91,1027,1028,1031,1034,1036,1038,1041,1043,1046],{"class":93,"line":112},[91,1029,1030],{"class":227},"cat",[91,1032,1033],{"class":252}," jobs.ndjson",[91,1035,994],{"class":104},[91,1037,1016],{"class":227},[91,1039,1040],{"class":252}," apply",[91,1042,1022],{"class":252},[91,1044,1045],{"class":237}," --dry-run",[91,1047,1048],{"class":97},"                      # bulk apply\n",[91,1050,1051,1053,1055,1057,1059,1062,1065],{"class":93,"line":126},[91,1052,986],{"class":227},[91,1054,774],{"class":252},[91,1056,991],{"class":237},[91,1058,994],{"class":104},[91,1060,1061],{"class":227}," head",[91,1063,1064],{"class":237}," -5",[91,1066,1067],{"class":97},"                                    # sample\n",[91,1069,1070,1072,1074,1077,1079,1081,1084,1087,1090],{"class":93,"line":133},[91,1071,986],{"class":227},[91,1073,754],{"class":252},[91,1075,1076],{"class":237}," -o",[91,1078,1022],{"class":252},[91,1080,994],{"class":104},[91,1082,1083],{"class":227}," gzip",[91,1085,1086],{"class":104}," >",[91,1088,1089],{"class":252}," backup.json.gz",[91,1091,1092],{"class":97},"                      # stream to storage\n",[91,1094,1095,1098,1101,1103,1106,1109,1112,1114,1117,1120,1122,1124],{"class":93,"line":157},[91,1096,1097],{"class":227},"diff",[91,1099,1100],{"class":252}," \u003C(",[91,1102,986],{"class":227},[91,1104,1105],{"class":252}," export ",[91,1107,1108],{"class":237},"--json",[91,1110,1111],{"class":252},")",[91,1113,1100],{"class":252},[91,1115,1116],{"class":227},"ssh",[91,1118,1119],{"class":252}," host mytool export ",[91,1121,1108],{"class":237},[91,1123,1111],{"class":252},[91,1125,1126],{"class":97},"   # compare environments\n",[10,1128,1129,1130,1132],{},"Four properties make all six possible, and each has already appeared above: results on stdout,\n",[39,1131,41],{}," supported in both directions, a stable JSON shape, and a clean exit when the downstream closes.\nThe last example — process substitution comparing two environments — is the kind of use nobody\nplans for and everybody appreciates.",[10,1134,1135,1136,1139],{},"The round-trip case (",[39,1137,1138],{},"export | import -",") is worth testing explicitly, because it is both a\ngenuinely useful operation and a strong check that your output format contains everything your\ninput format needs.",[14,1141,1143],{"id":1142},"buffering-ordering-and-the-surprises","Buffering, ordering and the surprises",[10,1145,1146],{},"Three behaviours around streams surprise people often enough to be worth stating.",[10,1148,1149,1152],{},[25,1150,1151],{},"stdout and stderr are buffered differently."," stdout is line-buffered on a terminal and block-\nbuffered when redirected; stderr is unbuffered or line-buffered. The visible consequence is that\nwhen both are redirected to the same file, the interleaving does not match the order you wrote\nthem. If ordering matters — a log people read alongside results — send everything to one stream, or\nflush deliberately.",[10,1154,1155,1158,1159,1162,1163,1166],{},[25,1156,1157],{},"Redirection changes behaviour, not just destination."," ",[39,1160,1161],{},"mytool status"," on a terminal and\n",[39,1164,1165],{},"mytool status > out.txt"," produce different bytes if you are colouring output, sizing tables to the\nwindow, or drawing progress. That is correct and intended, but it means a bug report that says\n\"the output is wrong in a file\" is usually about detection rather than about formatting.",[10,1168,1169,1172,1173,1176,1177,1181],{},[25,1170,1171],{},"Exit code and output are separate channels."," A command can print a perfectly good JSON document\nand still exit non-zero — a partial failure, say. Consumers should check the status before parsing,\nand your documentation should say what output accompanies each code. Putting a ",[39,1174,1175],{},"\"status\": \"error\"","\nfield in the JSON ",[1178,1179,1180],"em",{},"instead"," of using the exit code is a common mistake: shell scripts branch on the\nstatus, not on the payload.",[14,1183,1185],{"id":1184},"testing-pipeline-behaviour","Testing pipeline behaviour",[10,1187,1188],{},"All of this is testable in-process, which means it belongs in the ordinary suite rather than in a\nshell script somebody runs by hand.",[82,1190,1192],{"className":84,"code":1191,"language":86,"meta":87,"style":87},"def test_results_go_to_stdout_and_narration_to_stderr(cli):\n    result = cli.invoke(app, [\"export\", \"--json\"])\n\n    assert result.exit_code == 0\n    json.loads(result.stdout)                      # stdout is parseable on its own\n    assert \"exported\" in result.stderr             # progress went elsewhere\n\ndef test_reads_a_document_from_stdin(cli):\n    result = cli.invoke(app, [\"apply\", \"-\"], input='{\"replicas\": 3}')\n\n    assert result.exit_code == 0\n\ndef test_empty_stdin_is_a_clear_error(cli):\n    result = cli.invoke(app, [\"apply\", \"-\"], input=\"\")\n\n    assert result.exit_code == 65\n    assert \"no input\" in result.stderr\n",[39,1193,1194,1204,1224,1228,1242,1250,1266,1270,1279,1308,1312,1322,1326,1336,1362,1367,1379],{"__ignoreMap":87},[91,1195,1196,1198,1201],{"class":93,"line":94},[91,1197,224],{"class":104},[91,1199,1200],{"class":227}," test_results_go_to_stdout_and_narration_to_stderr",[91,1202,1203],{"class":108},"(cli):\n",[91,1205,1206,1209,1211,1214,1217,1219,1221],{"class":93,"line":101},[91,1207,1208],{"class":108},"    result ",[91,1210,139],{"class":104},[91,1212,1213],{"class":108}," cli.invoke(app, [",[91,1215,1216],{"class":252},"\"export\"",[91,1218,387],{"class":108},[91,1220,454],{"class":252},[91,1222,1223],{"class":108},"])\n",[91,1225,1226],{"class":93,"line":112},[91,1227,130],{"emptyLinePlaceholder":129},[91,1229,1230,1233,1236,1239],{"class":93,"line":126},[91,1231,1232],{"class":104},"    assert",[91,1234,1235],{"class":108}," result.exit_code ",[91,1237,1238],{"class":104},"==",[91,1240,1241],{"class":237}," 0\n",[91,1243,1244,1247],{"class":93,"line":133},[91,1245,1246],{"class":108},"    json.loads(result.stdout)                      ",[91,1248,1249],{"class":97},"# stdout is parseable on its own\n",[91,1251,1252,1254,1257,1260,1263],{"class":93,"line":157},[91,1253,1232],{"class":104},[91,1255,1256],{"class":252}," \"exported\"",[91,1258,1259],{"class":104}," in",[91,1261,1262],{"class":108}," result.stderr             ",[91,1264,1265],{"class":97},"# progress went elsewhere\n",[91,1267,1268],{"class":93,"line":289},[91,1269,130],{"emptyLinePlaceholder":129},[91,1271,1272,1274,1277],{"class":93,"line":309},[91,1273,224],{"class":104},[91,1275,1276],{"class":227}," test_reads_a_document_from_stdin",[91,1278,1203],{"class":108},[91,1280,1281,1283,1285,1287,1290,1292,1295,1298,1301,1303,1306],{"class":93,"line":314},[91,1282,1208],{"class":108},[91,1284,139],{"class":104},[91,1286,1213],{"class":108},[91,1288,1289],{"class":252},"\"apply\"",[91,1291,387],{"class":108},[91,1293,1294],{"class":252},"\"-\"",[91,1296,1297],{"class":108},"], ",[91,1299,1300],{"class":145},"input",[91,1302,139],{"class":104},[91,1304,1305],{"class":252},"'{\"replicas\": 3}'",[91,1307,306],{"class":108},[91,1309,1310],{"class":93,"line":322},[91,1311,130],{"emptyLinePlaceholder":129},[91,1313,1314,1316,1318,1320],{"class":93,"line":336},[91,1315,1232],{"class":104},[91,1317,1235],{"class":108},[91,1319,1238],{"class":104},[91,1321,1241],{"class":237},[91,1323,1324],{"class":93,"line":341},[91,1325,130],{"emptyLinePlaceholder":129},[91,1327,1329,1331,1334],{"class":93,"line":1328},13,[91,1330,224],{"class":104},[91,1332,1333],{"class":227}," test_empty_stdin_is_a_clear_error",[91,1335,1203],{"class":108},[91,1337,1339,1341,1343,1345,1347,1349,1351,1353,1355,1357,1360],{"class":93,"line":1338},14,[91,1340,1208],{"class":108},[91,1342,139],{"class":104},[91,1344,1213],{"class":108},[91,1346,1289],{"class":252},[91,1348,387],{"class":108},[91,1350,1294],{"class":252},[91,1352,1297],{"class":108},[91,1354,1300],{"class":145},[91,1356,139],{"class":104},[91,1358,1359],{"class":252},"\"\"",[91,1361,306],{"class":108},[91,1363,1365],{"class":93,"line":1364},15,[91,1366,130],{"emptyLinePlaceholder":129},[91,1368,1370,1372,1374,1376],{"class":93,"line":1369},16,[91,1371,1232],{"class":104},[91,1373,1235],{"class":108},[91,1375,1238],{"class":104},[91,1377,1378],{"class":237}," 65\n",[91,1380,1382,1384,1387,1389],{"class":93,"line":1381},17,[91,1383,1232],{"class":104},[91,1385,1386],{"class":252}," \"no input\"",[91,1388,1259],{"class":104},[91,1390,1391],{"class":108}," result.stderr\n",[10,1393,1394,1395,1398,1399,1401],{},"The first test is the important one and the one people leave out. ",[39,1396,1397],{},"json.loads(result.stdout)"," fails\nloudly the moment anything non-JSON is written to stdout — a stray ",[39,1400,180],{},", a progress line, a\nwarning that went to the wrong stream — which is exactly the regression that breaks every consumer\nat once.",[10,1403,1404],{},"For broken-pipe behaviour, an in-process test is awkward, so use a subprocess in one test:",[82,1406,1408],{"className":84,"code":1407,"language":86,"meta":87,"style":87},"def test_survives_a_closed_downstream():\n    tool = subprocess.Popen([\"mytool\", \"list\"], stdout=subprocess.PIPE)\n    head = subprocess.Popen([\"head\", \"-n\", \"5\"], stdin=tool.stdout, stdout=subprocess.DEVNULL)\n    tool.stdout.close()\n    head.wait()\n\n    assert tool.wait() == 0        # not a crash, not a non-zero status\n",[39,1409,1410,1420,1453,1496,1501,1506,1510],{"__ignoreMap":87},[91,1411,1412,1414,1417],{"class":93,"line":94},[91,1413,224],{"class":104},[91,1415,1416],{"class":227}," test_survives_a_closed_downstream",[91,1418,1419],{"class":108},"():\n",[91,1421,1422,1425,1427,1430,1433,1435,1438,1440,1443,1445,1448,1451],{"class":93,"line":101},[91,1423,1424],{"class":108},"    tool ",[91,1426,139],{"class":104},[91,1428,1429],{"class":108}," subprocess.Popen([",[91,1431,1432],{"class":252},"\"mytool\"",[91,1434,387],{"class":108},[91,1436,1437],{"class":252},"\"list\"",[91,1439,1297],{"class":108},[91,1441,1442],{"class":145},"stdout",[91,1444,139],{"class":104},[91,1446,1447],{"class":108},"subprocess.",[91,1449,1450],{"class":237},"PIPE",[91,1452,306],{"class":108},[91,1454,1455,1458,1460,1462,1465,1467,1470,1472,1475,1477,1480,1482,1485,1487,1489,1491,1494],{"class":93,"line":112},[91,1456,1457],{"class":108},"    head ",[91,1459,139],{"class":104},[91,1461,1429],{"class":108},[91,1463,1464],{"class":252},"\"head\"",[91,1466,387],{"class":108},[91,1468,1469],{"class":252},"\"-n\"",[91,1471,387],{"class":108},[91,1473,1474],{"class":252},"\"5\"",[91,1476,1297],{"class":108},[91,1478,1479],{"class":145},"stdin",[91,1481,139],{"class":104},[91,1483,1484],{"class":108},"tool.stdout, ",[91,1486,1442],{"class":145},[91,1488,139],{"class":104},[91,1490,1447],{"class":108},[91,1492,1493],{"class":237},"DEVNULL",[91,1495,306],{"class":108},[91,1497,1498],{"class":93,"line":126},[91,1499,1500],{"class":108},"    tool.stdout.close()\n",[91,1502,1503],{"class":93,"line":133},[91,1504,1505],{"class":108},"    head.wait()\n",[91,1507,1508],{"class":93,"line":157},[91,1509,130],{"emptyLinePlaceholder":129},[91,1511,1512,1514,1517,1519,1521],{"class":93,"line":289},[91,1513,1232],{"class":104},[91,1515,1516],{"class":108}," tool.wait() ",[91,1518,1238],{"class":104},[91,1520,905],{"class":237},[91,1522,1523],{"class":97},"        # not a crash, not a non-zero status\n",[14,1525,1527],{"id":1526},"designing-a-command-that-filters","Designing a command that filters",[10,1529,1530],{},"The most composable shape a command can have is a filter: read records from stdin, transform them,\nwrite records to stdout. Tools built that way chain with each other and with everything else in the\nshell.",[82,1532,1534],{"className":84,"code":1533,"language":86,"meta":87,"style":87},"@app.command()\ndef filter_failed(\n    source: Annotated[Path, typer.Argument(help=\"NDJSON input, or - for stdin.\")] = Path(\"-\"),\n) -> None:\n    \"\"\"Read deployment records and emit only the failed ones.\"\"\"\n    for line in read_lines(source):\n        record = json.loads(line)\n        if not record.get(\"healthy\", True):\n            typer.echo(json.dumps(record))\n",[39,1535,1536,1542,1551,1575,1583,1588,1600,1610,1630],{"__ignoreMap":87},[91,1537,1538,1540],{"class":93,"line":94},[91,1539,427],{"class":227},[91,1541,430],{"class":108},[91,1543,1544,1546,1549],{"class":93,"line":101},[91,1545,224],{"class":104},[91,1547,1548],{"class":227}," filter_failed",[91,1550,440],{"class":108},[91,1552,1553,1556,1558,1560,1563,1565,1567,1570,1572],{"class":93,"line":112},[91,1554,1555],{"class":108},"    source: Annotated[Path, typer.Argument(",[91,1557,459],{"class":145},[91,1559,139],{"class":104},[91,1561,1562],{"class":252},"\"NDJSON input, or - for stdin.\"",[91,1564,467],{"class":108},[91,1566,139],{"class":104},[91,1568,1569],{"class":108}," Path(",[91,1571,1294],{"class":252},[91,1573,1574],{"class":108},"),\n",[91,1576,1577,1579,1581],{"class":93,"line":126},[91,1578,241],{"class":108},[91,1580,482],{"class":237},[91,1582,247],{"class":108},[91,1584,1585],{"class":93,"line":133},[91,1586,1587],{"class":252},"    \"\"\"Read deployment records and emit only the failed ones.\"\"\"\n",[91,1589,1590,1592,1595,1597],{"class":93,"line":157},[91,1591,822],{"class":104},[91,1593,1594],{"class":108}," line ",[91,1596,532],{"class":104},[91,1598,1599],{"class":108}," read_lines(source):\n",[91,1601,1602,1605,1607],{"class":93,"line":289},[91,1603,1604],{"class":108},"        record ",[91,1606,139],{"class":104},[91,1608,1609],{"class":108}," json.loads(line)\n",[91,1611,1612,1615,1617,1620,1623,1625,1628],{"class":93,"line":309},[91,1613,1614],{"class":104},"        if",[91,1616,267],{"class":104},[91,1618,1619],{"class":108}," record.get(",[91,1621,1622],{"class":252},"\"healthy\"",[91,1624,387],{"class":108},[91,1626,1627],{"class":237},"True",[91,1629,881],{"class":108},[91,1631,1632],{"class":93,"line":314},[91,1633,1634],{"class":108},"            typer.echo(json.dumps(record))\n",[82,1636,1638],{"className":84,"code":1637,"language":86,"meta":87,"style":87},"def read_lines(source: Path) -> Iterator[str]:\n    if str(source) == \"-\":\n        if sys.stdin.isatty():\n            raise typer.BadParameter(\"no input: pipe NDJSON on stdin or pass a file\")\n        yield from sys.stdin\n    else:\n        with source.open(encoding=\"utf-8\") as handle:\n            yield from handle\n",[39,1639,1640,1655,1669,1675,1687,1695,1702,1725],{"__ignoreMap":87},[91,1641,1642,1644,1647,1650,1652],{"class":93,"line":94},[91,1643,224],{"class":104},[91,1645,1646],{"class":227}," read_lines",[91,1648,1649],{"class":108},"(source: Path) -> Iterator[",[91,1651,244],{"class":237},[91,1653,1654],{"class":108},"]:\n",[91,1656,1657,1659,1661,1663,1665,1667],{"class":93,"line":101},[91,1658,258],{"class":104},[91,1660,275],{"class":237},[91,1662,278],{"class":108},[91,1664,1238],{"class":104},[91,1666,284],{"class":252},[91,1668,247],{"class":108},[91,1670,1671,1673],{"class":93,"line":112},[91,1672,1614],{"class":104},[91,1674,319],{"class":108},[91,1676,1677,1680,1682,1685],{"class":93,"line":126},[91,1678,1679],{"class":104},"            raise",[91,1681,328],{"class":108},[91,1683,1684],{"class":252},"\"no input: pipe NDJSON on stdin or pass a file\"",[91,1686,306],{"class":108},[91,1688,1689,1692],{"class":93,"line":133},[91,1690,1691],{"class":104},"        yield from",[91,1693,1694],{"class":108}," sys.stdin\n",[91,1696,1697,1700],{"class":93,"line":157},[91,1698,1699],{"class":104},"    else",[91,1701,247],{"class":108},[91,1703,1704,1707,1710,1712,1714,1716,1719,1722],{"class":93,"line":289},[91,1705,1706],{"class":104},"        with",[91,1708,1709],{"class":108}," source.open(",[91,1711,298],{"class":145},[91,1713,139],{"class":104},[91,1715,303],{"class":252},[91,1717,1718],{"class":108},") ",[91,1720,1721],{"class":104},"as",[91,1723,1724],{"class":108}," handle:\n",[91,1726,1727,1730],{"class":93,"line":309},[91,1728,1729],{"class":104},"            yield from",[91,1731,1732],{"class":108}," handle\n",[10,1734,1735],{},"Four properties make it a good citizen. It defaults to stdin, so it works mid-pipeline with no\narguments. It streams, so memory stays flat and output appears immediately. It emits the same shape\nit consumes, so it can be chained with itself. And it writes nothing but records to stdout, so the\nnext command's parser never chokes.",[10,1737,1738],{},"The same shape covers a surprising range of commands: filtering, enriching, reformatting,\nvalidating. When a command does not fit it — because it needs the whole input to produce one answer,\nfor instance — that is worth noticing rather than forcing, and the honest version reads everything\nand emits one document.",[14,1740,1742],{"id":1741},"what-to-do-when-both-a-file-and-stdin-are-possible","What to do when both a file and stdin are possible",[10,1744,1745,1746,1748],{},"Commands often accept a path ",[1178,1747,712],{}," support stdin, which raises a small design question people\nanswer inconsistently: what happens when neither is given?",[10,1750,1751],{},"Three defensible answers, and the one to avoid:",[10,1753,1754,1757,1758,1761,1762,1764],{},[25,1755,1756],{},"Read stdin when data is waiting, otherwise error."," The most Unix-like: ",[39,1759,1760],{},"mytool apply"," with a\npipe behaves like ",[39,1763,356],{},", and without one it fails immediately with a message naming the\nargument. This is the behaviour of most filters.",[10,1766,1767,1773],{},[25,1768,1769,1770,1772],{},"Require the argument, with ",[39,1771,41],{}," as the explicit stdin form."," Slightly more verbose for users and\nmuch harder to get wrong, because there is no implicit mode. Good for destructive commands, where\n\"it read something I did not mean to give it\" is expensive.",[10,1775,1776,1779,1780,387,1783,1786],{},[25,1777,1778],{},"Default to a conventional file"," (",[39,1781,1782],{},".\u002Fmytool.toml",[39,1784,1785],{},".\u002Fjobs.ndjson","), documented in the help text.\nConvenient for a tool with an obvious project-local input.",[10,1788,1789],{},"What to avoid is blocking on stdin with no indication. A command that hangs at a prompt with no\noutput is indistinguishable from a crash, and the user's next move is Ctrl-C — which, if you have\nnot handled it cleanly, prints a traceback on top of the confusion.",[14,1791,1793],{"id":1792},"where-to-go-next","Where to go next",[19,1795,1796,1806,1813,1819],{},[22,1797,1798,1802,1803,1805],{},[663,1799,1801],{"href":1800},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Freading-piped-input-in-python-clis\u002F","Reading piped input in Python CLIs","\n— stdin detection, the ",[39,1804,41],{}," convention, encodings and streaming large inputs.",[22,1807,1808,1812],{},[663,1809,1811],{"href":1810},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Femitting-json-output-for-scripting\u002F","Emitting JSON output for scripting","\n— envelope design, NDJSON, versioning the shape and testing it.",[22,1814,1815,1818],{},[663,1816,1817],{"href":665},"Handling broken pipe and SIGPIPE","\n— why the traceback appears, and the boundary that removes it.",[22,1820,1821,1825],{},[663,1822,1824],{"href":1823},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Fdetecting-tty-and-adapting-output\u002F","Detecting a TTY and adapting output","\n— colour, progress, prompts and the environment variables worth honouring.",[14,1827,1829],{"id":1828},"key-takeaways","Key takeaways",[19,1831,1832,1835,1840,1843,1846],{},[22,1833,1834],{},"Results go to stdout; everything else goes to stderr, without exception.",[22,1836,37,1837,1839],{},[39,1838,41],{}," in both directions so the tool composes in either position.",[22,1841,1842],{},"One JSON envelope across every command beats a shape per command.",[22,1844,1845],{},"A closed downstream is a normal end, not an error — exit 0, quietly.",[22,1847,1848,1849,1852],{},"One ",[39,1850,1851],{},"interactive()"," helper, consulted everywhere, keeps TTY behaviour consistent.",[14,1854,1856],{"id":1855},"frequently-asked-questions","Frequently asked questions",[1858,1859,1861],"h3",{"id":1860},"how-do-i-know-whether-data-is-being-piped-in","How do I know whether data is being piped in?",[10,1863,1864,1867,1868,1871,1872,1875],{},[39,1865,1866],{},"sys.stdin.isatty()"," returns ",[39,1869,1870],{},"False"," when stdin is a pipe or a redirected file, which is the\npractical signal that data is waiting. It cannot tell you whether that data is ",[1178,1873,1874],{},"empty"," — an upstream\ncommand that produced nothing still gives you a non-terminal stdin — so handle the empty case with\na clear message rather than a parser traceback.",[1858,1877,1879],{"id":1878},"should-errors-ever-go-to-stdout","Should errors ever go to stdout?",[10,1881,1882],{},"No. Even for a command whose whole purpose is producing errors — a linter, say — the machine-readable\nfindings are the result and belong on stdout, while the summary and any diagnostics about the run\nitself belong on stderr. Mixing them means a consumer has to filter your prose out of their data.",[1858,1884,1886],{"id":1885},"what-exit-code-should-i-use-when-the-pipe-closes","What exit code should I use when the pipe closes?",[1888,1889,1891],"ol",{"start":1890},0,[22,1892,1893,1894,1897],{},"Nothing failed: the downstream program decided it had enough output. Returning non-zero makes\n",[39,1895,1896],{},"mytool list | head"," look like a failure to every wrapper script that checks the status.",[1858,1899,1901,1902,1904],{"id":1900},"is-it-worth-supporting-if-my-tool-takes-a-file-argument","Is it worth supporting ",[39,1903,41],{}," if my tool takes a file argument?",[10,1906,1907],{},"Yes, and it is about five lines. Without it, users pipe through a temporary file or process\nsubstitution, both of which are more fragile than the convention every Unix tool already\nimplements.",[1858,1909,1911],{"id":1910},"how-large-an-input-can-i-read-from-stdin","How large an input can I read from stdin?",[10,1913,1914,1915,1917],{},"Whatever you stream. ",[39,1916,367],{}," loads everything into memory; iterating line by line, or\nreading in chunks, keeps usage flat regardless of size. For anything that might be a gigabyte —\na log, an export — stream it, and your tool works on inputs nobody anticipated.",[1858,1919,1921],{"id":1920},"does-any-of-this-work-the-same-way-on-windows","Does any of this work the same way on Windows?",[10,1923,1924,1925,1927,1928,1930,1931,1934,1935,1938],{},"Mostly. ",[39,1926,59],{},", stream redirection and the ",[39,1929,41],{}," convention behave as expected, and PowerShell\npipelines carry text between programs much as a Unix shell does. The differences worth knowing are\nthat there is no ",[39,1932,1933],{},"SIGPIPE"," — a closed downstream surfaces as an ",[39,1936,1937],{},"OSError"," rather than a signal — and\nthat the console encoding has historically not been UTF-8, which is why naming the encoding\nexplicitly matters more there than anywhere else.",[1858,1940,1942],{"id":1941},"should-a-filter-preserve-unknown-fields-it-does-not-understand","Should a filter preserve unknown fields it does not understand?",[10,1944,1945],{},"Yes, wherever it can. A filter that drops fields it did not expect silently breaks every downstream\nconsumer that needed them, and it makes chaining two versions of your own tool lossy. Read into a\nmodel that keeps extras, or operate on the parsed mapping directly and write it back out.",[14,1947,1949],{"id":1948},"related","Related",[19,1951,1952,1959,1966,1972,1979],{},[22,1953,1954,1955],{},"Up: ",[663,1956,1958],{"href":1957},"\u002Fadvanced-input-parsing-user-experience\u002F","Advanced Input Parsing & User Experience",[22,1960,1961,1962],{},"Sideways: ",[663,1963,1965],{"href":1964},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002F","Interactive terminal UI with Rich",[22,1967,1961,1968],{},[663,1969,1971],{"href":1970},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002F","Error handling and exit codes for CLIs",[22,1973,1974,1975],{},"Related: ",[663,1976,1978],{"href":1977},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002F","Structured logging for CLI apps",[22,1980,1974,1981],{},[663,1982,1984],{"href":1983},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002F","CLI help output and documentation",[1986,1987,1988],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}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 .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 .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":87,"searchDepth":101,"depth":101,"links":1990},[1991,1992,1993,1994,1995,1996,1997,1998,1999,2000,2001,2002,2003,2004,2005,2015],{"id":16,"depth":101,"text":17},{"id":67,"depth":101,"text":68},{"id":188,"depth":101,"text":189},{"id":413,"depth":101,"text":414},{"id":568,"depth":101,"text":569},{"id":674,"depth":101,"text":675},{"id":733,"depth":101,"text":734},{"id":970,"depth":101,"text":971},{"id":1142,"depth":101,"text":1143},{"id":1184,"depth":101,"text":1185},{"id":1526,"depth":101,"text":1527},{"id":1741,"depth":101,"text":1742},{"id":1792,"depth":101,"text":1793},{"id":1828,"depth":101,"text":1829},{"id":1855,"depth":101,"text":1856,"children":2006},[2007,2008,2009,2010,2012,2013,2014],{"id":1860,"depth":112,"text":1861},{"id":1878,"depth":112,"text":1879},{"id":1885,"depth":112,"text":1886},{"id":1900,"depth":112,"text":2011},"Is it worth supporting - if my tool takes a file argument?",{"id":1910,"depth":112,"text":1911},{"id":1920,"depth":112,"text":1921},{"id":1941,"depth":112,"text":1942},{"id":1948,"depth":101,"text":1949},"2026-08-01","Make a Python CLI compose - read piped input, keep stdout clean, emit stable JSON, survive broken pipes, and adapt when no terminal is attached.","intermediate",false,"md",{},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes",{"title":5,"description":2017},"advanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Findex",[1479,1442,2026,2027,2028,2029],"pipes","json","tty","unix","BzSJv1IsQ9yzCV-hcsItD4NA1gewAwsqpwGn-a3dd4I",[2032,2035,2038,2041,2044,2047,2050,2053,2056,2059,2062,2065,2068,2071,2074,2077,2080,2083,2086,2089,2092,2095,2098,2101,2104,2107,2110,2113,2116,2119,2120,2123,2126,2129,2132,2135,2138,2141,2144,2147,2150,2153,2156,2159,2162,2165,2168,2171,2174,2177,2180,2183,2186,2189,2192,2195,2198,2201,2204,2207,2210,2213,2216,2219,2222,2225,2228,2231,2234,2237,2240,2243,2246,2249,2252,2255,2258,2261,2264,2267,2270,2273,2276],{"path":2033,"title":2034},"\u002Fabout","About Python CLI Toolcraft",{"path":2036,"title":2037},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies","Advanced Argument Validation Strategies",{"path":2039,"title":2040},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fparsing-nested-json-arguments-in-python-clis","Parsing Nested JSON Args in Python CLIs",{"path":2042,"title":2043},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fvalidating-file-and-directory-paths-in-clis","Validating File and Directory Paths in CLIs",{"path":2045,"title":2046},"\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":2048,"title":2049},"\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":2051,"title":2052},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation","CLI Help Output and Documentation",{"path":2054,"title":2055},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fversioning-and-deprecating-cli-flags","Versioning and Deprecating CLI Flags",{"path":2057,"title":2058},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fwriting-help-text-users-actually-read","Writing Help Text Users Actually Read",{"path":2060,"title":2061},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Fchoosing-exit-codes-for-cli-tools","Choosing Exit Codes for CLI Tools",{"path":2063,"title":2064},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Ffriendly-error-messages-and-tracebacks","Friendly Error Messages and Tracebacks",{"path":2066,"title":2067},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Fhandling-keyboard-interrupt-cleanly","Handling Keyboard Interrupt Cleanly",{"path":2069,"title":2070},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes","Error Handling and Exit Codes for CLIs",{"path":2072,"title":2073},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Fconfig-precedence-flags-env-files-defaults","Config Precedence: Flags, Env, Files, Defaults",{"path":2075,"title":2076},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars","Handling Config Files and Env Vars in CLIs",{"path":2078,"title":2079},"\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":2081,"title":2082},"\u002Fadvanced-input-parsing-user-experience","Advanced Input Parsing for Python CLIs",{"path":2084,"title":2085},"\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":2087,"title":2088},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich","Interactive Terminal UI with Rich",{"path":2090,"title":2091},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Frendering-tables-and-json-with-rich","Rendering Tables and JSON with Rich",{"path":2093,"title":2094},"\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":2096,"title":2097},"\u002Fadvanced-input-parsing-user-experience\u002Fshell-completion-for-python-clis","Shell Completion for Python CLIs",{"path":2099,"title":2100},"\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":2102,"title":2103},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fadding-verbose-and-quiet-logging-flags","Adding Verbose and Quiet Logging Flags",{"path":2105,"title":2106},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps","Structured Logging for CLI Apps",{"path":2108,"title":2109},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fstructured-json-logging-in-python-clis","Structured JSON Logging in Python CLIs",{"path":2111,"title":2112},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Fdetecting-tty-and-adapting-output","Detecting a TTY and Adapting Output",{"path":2114,"title":2115},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Femitting-json-output-for-scripting","Emitting JSON Output for Scripting",{"path":2117,"title":2118},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Fhandling-broken-pipe-and-sigpipe","Handling Broken Pipe and SIGPIPE",{"path":2022,"title":5},{"path":2121,"title":2122},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Freading-piped-input-in-python-clis","Reading Piped Input in Python CLIs",{"path":2124,"title":2125},"\u002F","Python CLI Toolcraft",{"path":2127,"title":2128},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading","CLI Startup Performance and Lazy Loading",{"path":2130,"title":2131},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Flazy-loading-subcommands-for-faster-startup","Lazy Loading Subcommands for Faster Startup",{"path":2133,"title":2134},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Fprofiling-python-cli-startup-time","Profiling Python CLI Startup Time",{"path":2136,"title":2137},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Freducing-cli-dependency-weight","Reducing CLI Dependency Weight",{"path":2139,"title":2140},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fargparse-subparsers-for-subcommands","argparse Subparsers for Subcommands",{"path":2142,"title":2143},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fargparse-vs-click-vs-typer-comparison","argparse vs Click vs Typer Compared",{"path":2145,"title":2146},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse","Command-Line Parsing with argparse",{"path":2148,"title":2149},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fmigrating-from-argparse-to-typer","Migrating from argparse to Typer",{"path":2151,"title":2152},"\u002Fmodern-python-cli-frameworks-architecture","Python CLI Frameworks and Architecture",{"path":2154,"title":2155},"\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis","Plugin Architectures for Extensible CLIs",{"path":2157,"title":2158},"\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":2160,"title":2161},"\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":2163,"title":2164},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fdependency-injection-patterns-for-cli-commands","Dependency Injection Patterns for CLI Commands",{"path":2166,"title":2167},"\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":2169,"title":2170},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis","Structuring Multi-Command Python CLIs",{"path":2172,"title":2173},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fsharing-state-with-click-context-objects","Sharing State with Click Context Objects",{"path":2175,"title":2176},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications","Testing Python CLI Applications",{"path":2178,"title":2179},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmeasuring-cli-test-coverage","Measuring CLI Test Coverage",{"path":2181,"title":2182},"\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":2184,"title":2185},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fsnapshot-testing-cli-output","Snapshot Testing CLI Output",{"path":2187,"title":2188},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-click-commands-with-clirunner","Testing Click Commands with CliRunner",{"path":2190,"title":2191},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-interactive-prompts-and-stdin","Testing Interactive Prompts and stdin",{"path":2193,"title":2194},"\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":2196,"title":2197},"\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":2199,"title":2200},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each","Typer vs Click: When to Use Each",{"path":2202,"title":2203},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Ftyper-callback-functions-explained","Typer callback functions explained",{"path":2205,"title":2206},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter\u002Fcopier-vs-cookiecutter-for-cli-templates","Copier vs Cookiecutter for CLI Templates",{"path":2208,"title":2209},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter","CLI Project Scaffolding with Cookiecutter",{"path":2211,"title":2212},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fbuilding-cross-platform-release-binaries-in-ci","Building Cross-Platform Release Binaries in CI",{"path":2214,"title":2215},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fbundling-a-python-cli-with-pyinstaller","Bundling a Python CLI with PyInstaller",{"path":2217,"title":2218},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fhomebrew-and-scoop-packaging-for-python-clis","Homebrew and Scoop Packaging for Python CLIs",{"path":2220,"title":2221},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries","Distributing CLIs as Standalone Binaries",{"path":2223,"title":2224},"\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":2226,"title":2227},"\u002Fproject-setup-dependency-management","Project Setup & Dependency Management",{"path":2229,"title":2230},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fautomating-changelogs-with-conventional-commits","Automating Changelogs with Conventional Commits",{"path":2232,"title":2233},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fexposing-version-info-and-build-metadata","Exposing Version Info and Build Metadata",{"path":2235,"title":2236},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs","Managing CLI Versioning & Changelogs",{"path":2238,"title":2239},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fbuilding-wheels-and-sdists-for-python-clis","Building Wheels and sdists for Python CLIs",{"path":2241,"title":2242},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution","Packaging Python CLIs for Distribution",{"path":2244,"title":2245},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Finstalling-and-distributing-clis-with-pipx","Installing and Distributing CLIs with pipx",{"path":2247,"title":2248},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fpublishing-a-python-cli-to-pypi","Publishing a Python CLI to PyPI",{"path":2250,"title":2251},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development","Poetry Workflows for CLI Development",{"path":2253,"title":2254},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development\u002Fpoetry-entry-points-and-scripts-for-clis","Poetry Entry Points and Scripts for CLIs",{"path":2256,"title":2257},"\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects","Pre-commit Hooks for CLI Projects",{"path":2259,"title":2260},"\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":2262,"title":2263},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management","uv for Python CLI Dependency Management",{"path":2265,"title":2266},"\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":2268,"title":2269},"\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":2271,"title":2272},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices","Python CLI Env Isolation Best Practices",{"path":2274,"title":2275},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fmanaging-virtual-environments-for-cross-platform-clis","Managing Python CLI Virtual Environments",{"path":2277,"title":2278},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fpinning-the-python-version-for-a-cli","Pinning the Python Version for a CLI",1785614690031]