[{"data":1,"prerenderedAt":26666},["ShallowReactive",2],{"page-\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fbest-practices-for-python-cli-entry-points\u002F":3,"content-directory":1334},{"id":4,"title":5,"body":6,"date":1320,"description":1321,"difficulty":1322,"draft":1323,"extension":1324,"meta":1325,"navigation":183,"path":1326,"seo":1327,"stem":1328,"tags":1329,"updated":1320,"__hash__":1333},"content\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fbest-practices-for-python-cli-entry-points\u002Findex.md","Best practices for Python CLI entry points",{"type":7,"value":8,"toc":1306},"minimark",[9,35,40,101,108,115,125,243,261,292,296,314,366,393,399,402,453,467,474,629,632,696,707,714,723,752,772,776,793,837,843,847,876,1000,1006,1012,1163,1178,1182,1202,1240,1262,1274,1278,1302],[10,11,12,13,17,18,21,22,26,27,30,31,34],"p",{},"When you ",[14,15,16],"code",{},"pip install"," a tool and then type ",[14,19,20],{},"weatherctl"," at the shell, something has to bridge the gap between that bare command and the Python function it runs. That bridge is a ",[23,24,25],"em",{},"console-script entry point",", declared in ",[14,28,29],{},"pyproject.toml",". Get the declaration right and your CLI installs cleanly into any environment — venv, pipx, CI, a colleague's machine — with the command landing on ",[14,32,33],{},"PATH"," automatically. Get it wrong and you ship a package nobody can launch.",[36,37,39],"h2",{"id":38},"tldr","TL;DR",[41,42,43,51,62,68,80,89],"ul",{},[44,45,46,47,50],"li",{},"A console-script entry point maps a command name to a ",[14,48,49],{},"module:callable"," target.",[44,52,53,54,57,58,61],{},"Declare it under PEP 621's ",[14,55,56],{},"[project.scripts]"," table: ",[14,59,60],{},"weatherctl = \"weatherctl.cli:main\"",".",[44,63,64,65,67],{},"Installation generates a small executable wrapper on ",[14,66,33],{}," that imports the module and calls the function.",[44,69,70,71,75,76,79],{},"The target callable ",[72,73,74],"strong",{},"must accept no required arguments"," — it reads ",[14,77,78],{},"sys.argv"," itself (or your framework does).",[44,81,82,85,86,61],{},[14,83,84],{},"python -m weatherctl"," is the zero-install alternative; it runs ",[14,87,88],{},"weatherctl\u002F__main__.py",[44,90,91,92,95,96,61],{},"Read entry points at runtime with ",[14,93,94],{},"importlib.metadata.entry_points(group=...)"," — the foundation for ",[97,98,100],"a",{"href":99},"\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis\u002F","plugin systems",[10,102,103],{},[104,105],"img",{"alt":106,"src":107},"How a console-script entry point resolves: pyproject.toml project.scripts to an installed console script on PATH to the target callable myapp.cli:main()","\u002Fillustrations\u002Fentry-point-resolution.svg",[36,109,111,112,114],{"id":110},"the-projectscripts-table","The ",[14,113,56],{}," table",[10,116,117,118,120,121,124],{},"PEP 621 standardized project metadata in ",[14,119,29],{},", and console scripts live in a dedicated table. The key is the command name users will type; the value is a ",[14,122,123],{},"package.module:function"," string pointing at the callable to invoke.",[126,127,132],"pre",{"className":128,"code":129,"language":130,"meta":131,"style":131},"language-toml shiki shiki-themes github-light github-dark","[project]\nname = \"weatherctl\"\nversion = \"1.2.0\"\nrequires-python = \">=3.10\"\n\n[project.scripts]\nweatherctl = \"weatherctl.cli:main\"\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n","toml","",[14,133,134,150,160,169,178,185,199,208,213,223,234],{"__ignoreMap":131},[135,136,139,143,147],"span",{"class":137,"line":138},"line",1,[135,140,142],{"class":141},"sVt8B","[",[135,144,146],{"class":145},"sScJk","project",[135,148,149],{"class":141},"]\n",[135,151,153,156],{"class":137,"line":152},2,[135,154,155],{"class":141},"name = ",[135,157,159],{"class":158},"sZZnC","\"weatherctl\"\n",[135,161,163,166],{"class":137,"line":162},3,[135,164,165],{"class":141},"version = ",[135,167,168],{"class":158},"\"1.2.0\"\n",[135,170,172,175],{"class":137,"line":171},4,[135,173,174],{"class":141},"requires-python = ",[135,176,177],{"class":158},"\">=3.10\"\n",[135,179,181],{"class":137,"line":180},5,[135,182,184],{"emptyLinePlaceholder":183},true,"\n",[135,186,188,190,192,194,197],{"class":137,"line":187},6,[135,189,142],{"class":141},[135,191,146],{"class":145},[135,193,61],{"class":141},[135,195,196],{"class":145},"scripts",[135,198,149],{"class":141},[135,200,202,205],{"class":137,"line":201},7,[135,203,204],{"class":141},"weatherctl = ",[135,206,207],{"class":158},"\"weatherctl.cli:main\"\n",[135,209,211],{"class":137,"line":210},8,[135,212,184],{"emptyLinePlaceholder":183},[135,214,216,218,221],{"class":137,"line":215},9,[135,217,142],{"class":141},[135,219,220],{"class":145},"build-system",[135,222,149],{"class":141},[135,224,226,229,232],{"class":137,"line":225},10,[135,227,228],{"class":141},"requires = [",[135,230,231],{"class":158},"\"hatchling\"",[135,233,149],{"class":141},[135,235,237,240],{"class":137,"line":236},11,[135,238,239],{"class":141},"build-backend = ",[135,241,242],{"class":158},"\"hatchling.build\"\n",[10,244,245,246,248,249,252,253,255,256,260],{},"This block is build-backend agnostic: hatchling, setuptools (≥61), flit, and pdm all read ",[14,247,56],{}," the same way because it is part of the interoperable standard, not a backend extension. If you use Poetry, the modern ",[14,250,251],{},"poetry-core"," backend reads ",[14,254,56],{}," too — see ",[97,257,259],{"href":258},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development\u002F","Poetry workflows for CLI development"," for the full Poetry setup. You can declare multiple commands; each line adds another executable:",[126,262,264],{"className":128,"code":263,"language":130,"meta":131,"style":131},"[project.scripts]\nweatherctl = \"weatherctl.cli:main\"\nweatherctl-admin = \"weatherctl.admin:main\"\n",[14,265,266,278,284],{"__ignoreMap":131},[135,267,268,270,272,274,276],{"class":137,"line":138},[135,269,142],{"class":141},[135,271,146],{"class":145},[135,273,61],{"class":141},[135,275,196],{"class":145},[135,277,149],{"class":141},[135,279,280,282],{"class":137,"line":152},[135,281,204],{"class":141},[135,283,207],{"class":158},[135,285,286,289],{"class":137,"line":162},[135,287,288],{"class":141},"weatherctl-admin = ",[135,290,291],{"class":158},"\"weatherctl.admin:main\"\n",[36,293,295],{"id":294},"how-installation-creates-the-wrapper","How installation creates the wrapper",[10,297,298,299,301,302,305,306,309,310,313],{},"When the package is installed, the build backend reads ",[14,300,56],{}," and writes a tiny launcher script into the environment's ",[14,303,304],{},"bin\u002F"," directory (",[14,307,308],{},"Scripts\\"," on Windows). On POSIX systems it is a short Python file with a shebang pointing at the environment's interpreter; on Windows it is a generated ",[14,311,312],{},".exe"," shim. Both do the same thing — roughly:",[126,315,319],{"className":316,"code":317,"language":318,"meta":131,"style":131},"language-python shiki shiki-themes github-light github-dark","import sys\nfrom weatherctl.cli import main\nif __name__ == \"__main__\":\n    sys.exit(main())\n","python",[14,320,321,330,343,361],{"__ignoreMap":131},[135,322,323,327],{"class":137,"line":138},[135,324,326],{"class":325},"szBVR","import",[135,328,329],{"class":141}," sys\n",[135,331,332,335,338,340],{"class":137,"line":152},[135,333,334],{"class":325},"from",[135,336,337],{"class":141}," weatherctl.cli ",[135,339,326],{"class":325},[135,341,342],{"class":141}," main\n",[135,344,345,348,352,355,358],{"class":137,"line":162},[135,346,347],{"class":325},"if",[135,349,351],{"class":350},"sj4cs"," __name__",[135,353,354],{"class":325}," ==",[135,356,357],{"class":158}," \"__main__\"",[135,359,360],{"class":141},":\n",[135,362,363],{"class":137,"line":171},[135,364,365],{"class":141},"    sys.exit(main())\n",[10,367,368,369,371,372,374,375,377,378,384,385,388,389,392],{},"Because that script lives in the same ",[14,370,304],{}," directory as the interpreter, activating the venv (or installing via pipx, which manages ",[14,373,33],{}," for you) makes ",[14,376,20],{}," directly callable. The wrapper also captures the function's ",[72,379,380,381],{},"return value and passes it to ",[14,382,383],{},"sys.exit()",", which is why returning an ",[14,386,387],{},"int"," status code from ",[14,390,391],{},"main()"," is the clean way to set the process exit code.",[36,394,111,396,398],{"id":395},"the-modulecallable-resolution-rules",[14,397,49],{}," resolution rules",[10,400,401],{},"The value string has two halves separated by a colon: an import path on the left, an attribute lookup on the right.",[41,403,404,424,438],{},[44,405,406,409,410,412,413,416,417,420,421,423],{},[72,407,408],{},"Left of the colon"," is a dotted module path. Installation resolves it the same way ",[14,411,326],{}," does, against the installed package — ",[14,414,415],{},"weatherctl.cli"," means \"the ",[14,418,419],{},"cli"," module inside the ",[14,422,20],{}," package.\"",[44,425,426,429,430,433,434,437],{},[72,427,428],{},"Right of the colon"," is the attribute to fetch from that module. It is usually a function, but any callable works (a class with ",[14,431,432],{},"__call__",", a ",[14,435,436],{},"click.Group",", a Typer app object).",[44,439,440,441,444,445,448,449,452],{},"You can drill into nested attributes with dots: ",[14,442,443],{},"weatherctl.cli:app.run"," fetches ",[14,446,447],{},"app",", then ",[14,450,451],{},"run"," from it.",[10,454,455,456,459,460,463,464,466],{},"The target is resolved lazily — only when the command runs, not at install time. A typo in the path therefore surfaces as an ",[14,457,458],{},"ImportError","\u002F",[14,461,462],{},"AttributeError"," the first time someone runs the command, not during ",[14,465,16],{},". Test the actual installed command in CI, not just the import.",[10,468,469,470,473],{},"Here is a target callable that satisfies the contract. Save it as ",[14,471,472],{},"weatherctl\u002Fcli.py",":",[126,475,477],{"className":316,"code":476,"language":318,"meta":131,"style":131},"import sys\n\ndef main() -> int:\n    \"\"\"The console-script target: takes no required arguments.\"\"\"\n    args = sys.argv[1:]\n    if not args:\n        print(\"weatherctl: no city given\")\n        return 1\n    print(f\"weatherctl: forecast for {args[0]}\")\n    return 0\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n",[14,478,479,485,489,504,509,526,537,551,559,592,600,604,617],{"__ignoreMap":131},[135,480,481,483],{"class":137,"line":138},[135,482,326],{"class":325},[135,484,329],{"class":141},[135,486,487],{"class":137,"line":152},[135,488,184],{"emptyLinePlaceholder":183},[135,490,491,494,497,500,502],{"class":137,"line":162},[135,492,493],{"class":325},"def",[135,495,496],{"class":145}," main",[135,498,499],{"class":141},"() -> ",[135,501,387],{"class":350},[135,503,360],{"class":141},[135,505,506],{"class":137,"line":171},[135,507,508],{"class":158},"    \"\"\"The console-script target: takes no required arguments.\"\"\"\n",[135,510,511,514,517,520,523],{"class":137,"line":180},[135,512,513],{"class":141},"    args ",[135,515,516],{"class":325},"=",[135,518,519],{"class":141}," sys.argv[",[135,521,522],{"class":350},"1",[135,524,525],{"class":141},":]\n",[135,527,528,531,534],{"class":137,"line":187},[135,529,530],{"class":325},"    if",[135,532,533],{"class":325}," not",[135,535,536],{"class":141}," args:\n",[135,538,539,542,545,548],{"class":137,"line":201},[135,540,541],{"class":350},"        print",[135,543,544],{"class":141},"(",[135,546,547],{"class":158},"\"weatherctl: no city given\"",[135,549,550],{"class":141},")\n",[135,552,553,556],{"class":137,"line":210},[135,554,555],{"class":325},"        return",[135,557,558],{"class":350}," 1\n",[135,560,561,564,566,569,572,575,578,581,584,587,590],{"class":137,"line":215},[135,562,563],{"class":350},"    print",[135,565,544],{"class":141},[135,567,568],{"class":325},"f",[135,570,571],{"class":158},"\"weatherctl: forecast for ",[135,573,574],{"class":350},"{",[135,576,577],{"class":141},"args[",[135,579,580],{"class":350},"0",[135,582,583],{"class":141},"]",[135,585,586],{"class":350},"}",[135,588,589],{"class":158},"\"",[135,591,550],{"class":141},[135,593,594,597],{"class":137,"line":225},[135,595,596],{"class":325},"    return",[135,598,599],{"class":350}," 0\n",[135,601,602],{"class":137,"line":236},[135,603,184],{"emptyLinePlaceholder":183},[135,605,607,609,611,613,615],{"class":137,"line":606},12,[135,608,347],{"class":325},[135,610,351],{"class":350},[135,612,354],{"class":325},[135,614,357],{"class":158},[135,616,360],{"class":141},[135,618,620,623,626],{"class":137,"line":619},13,[135,621,622],{"class":325},"    raise",[135,624,625],{"class":350}," SystemExit",[135,627,628],{"class":141},"(main())\n",[10,630,631],{},"Running it directly behaves exactly as the installed wrapper would:",[126,633,637],{"className":634,"code":635,"language":636,"meta":131,"style":131},"language-bash shiki shiki-themes github-light github-dark","$ python cli.py Berlin\nweatherctl: forecast for Berlin    # exit 0\n$ python cli.py\nweatherctl: no city given          # exit 1\n","bash",[14,638,639,653,671,680],{"__ignoreMap":131},[135,640,641,644,647,650],{"class":137,"line":138},[135,642,643],{"class":145},"$",[135,645,646],{"class":158}," python",[135,648,649],{"class":158}," cli.py",[135,651,652],{"class":158}," Berlin\n",[135,654,655,658,661,664,667],{"class":137,"line":152},[135,656,657],{"class":145},"weatherctl:",[135,659,660],{"class":158}," forecast",[135,662,663],{"class":158}," for",[135,665,666],{"class":158}," Berlin",[135,668,670],{"class":669},"sJ8bj","    # exit 0\n",[135,672,673,675,677],{"class":137,"line":162},[135,674,643],{"class":145},[135,676,646],{"class":158},[135,678,679],{"class":158}," cli.py\n",[135,681,682,684,687,690,693],{"class":137,"line":171},[135,683,657],{"class":145},[135,685,686],{"class":158}," no",[135,688,689],{"class":158}," city",[135,691,692],{"class":158}," given",[135,694,695],{"class":669},"          # exit 1\n",[10,697,698,699,701,702,706],{},"In practice you rarely parse ",[14,700,78],{}," by hand — you hand off to a framework. With Click, the target points at the group object, which is itself callable; see ",[97,703,705],{"href":704},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Fbuilding-a-cli-with-subcommands-in-click\u002F","Building a CLI with subcommands in Click"," for the multi-command pattern. The entry-point contract is the same regardless: a no-argument callable.",[36,708,710,713],{"id":709},"python-m-package-as-an-alternative",[14,711,712],{},"python -m package"," as an alternative",[10,715,716,717,448,720,722],{},"Every entry point can be paralleled by a module runner. If your package contains a ",[14,718,719],{},"__main__.py",[14,721,84],{}," executes it:",[126,724,726],{"className":316,"code":725,"language":318,"meta":131,"style":131},"# weatherctl\u002F__main__.py\nfrom weatherctl.cli import main\nraise SystemExit(main())\n",[14,727,728,733,743],{"__ignoreMap":131},[135,729,730],{"class":137,"line":138},[135,731,732],{"class":669},"# weatherctl\u002F__main__.py\n",[135,734,735,737,739,741],{"class":137,"line":152},[135,736,334],{"class":325},[135,738,337],{"class":141},[135,740,326],{"class":325},[135,742,342],{"class":141},[135,744,745,748,750],{"class":137,"line":162},[135,746,747],{"class":325},"raise",[135,749,625],{"class":350},[135,751,628],{"class":141},[10,753,754,755,757,758,761,762,765,766,768,769,771],{},"This route needs no installed wrapper and no ",[14,756,33],{}," entry — it works from a source checkout, inside Docker before the package is \"installed,\" and pins execution to a specific interpreter (",[14,759,760],{},"python3.12 -m weatherctl","). Offering both a console script ",[23,763,764],{},"and"," ",[14,767,719],{}," is a low-cost belt-and-braces move: the script for everyday use, the module form for reproducible or ambiguous-",[14,770,33],{}," situations.",[36,773,775],{"id":774},"plugingroup-entry-points","Plugin\u002Fgroup entry points",[10,777,778,780,781,784,785,788,789,792],{},[14,779,56],{}," is sugar over a more general mechanism: entry points belong to named ",[23,782,783],{},"groups",", and ",[14,786,787],{},"console_scripts"," is just one well-known group the installer treats specially. You can publish your own groups so that ",[23,790,791],{},"other"," packages can register extensions against your CLI.",[126,794,796],{"className":128,"code":795,"language":130,"meta":131,"style":131},"[project.entry-points.\"weatherctl.plugins\"]\ncelsius = \"weatherctl_celsius.plugin:register\"\nfahrenheit = \"weatherctl_fahrenheit.plugin:register\"\n",[14,797,798,821,829],{"__ignoreMap":131},[135,799,800,802,804,806,809,811,814,816,819],{"class":137,"line":138},[135,801,142],{"class":141},[135,803,146],{"class":145},[135,805,61],{"class":141},[135,807,808],{"class":145},"entry-points",[135,810,61],{"class":141},[135,812,813],{"class":145},"\"weatherctl",[135,815,61],{"class":141},[135,817,818],{"class":145},"plugins\"",[135,820,149],{"class":141},[135,822,823,826],{"class":137,"line":152},[135,824,825],{"class":141},"celsius = ",[135,827,828],{"class":158},"\"weatherctl_celsius.plugin:register\"\n",[135,830,831,834],{"class":137,"line":162},[135,832,833],{"class":141},"fahrenheit = ",[135,835,836],{"class":158},"\"weatherctl_fahrenheit.plugin:register\"\n",[10,838,839,840,61],{},"A third-party package adds the same table for its own name, and your application discovers all of them at runtime — no central registry, no import-time coupling. This is the backbone of an extensible CLI; the full pattern (loading, validation, version skew) lives in ",[97,841,842],{"href":99},"Plugin architectures for extensible CLIs",[36,844,846],{"id":845},"reading-entry-points-at-runtime","Reading entry points at runtime",[10,848,849,850,853,854,857,858,861,862,861,865,868,869,872,873,875],{},"To discover registered plugins, query the group by name with ",[14,851,852],{},"importlib.metadata"," from the standard library. Each ",[14,855,856],{},"EntryPoint"," exposes ",[14,859,860],{},".name",", ",[14,863,864],{},".value",[14,866,867],{},".group",", and a ",[14,870,871],{},".load()"," method that performs the ",[14,874,49],{}," import and returns the object:",[126,877,879],{"className":316,"code":878,"language":318,"meta":131,"style":131},"from importlib.metadata import entry_points\n\n# Your own plugin group — returns an EntryPoints collection.\nplugins = entry_points(group=\"weatherctl.plugins\")\nfor ep in plugins:\n    register = ep.load()   # imports the module, returns the callable\n    register(app)          # hand your app object to the plugin\n\n# Console scripts use the special \"console_scripts\" group.\nfor ep in entry_points(group=\"console_scripts\"):\n    print(ep.name, \"->\", ep.value)\n",[14,880,881,893,897,902,923,937,950,958,962,967,987],{"__ignoreMap":131},[135,882,883,885,888,890],{"class":137,"line":138},[135,884,334],{"class":325},[135,886,887],{"class":141}," importlib.metadata ",[135,889,326],{"class":325},[135,891,892],{"class":141}," entry_points\n",[135,894,895],{"class":137,"line":152},[135,896,184],{"emptyLinePlaceholder":183},[135,898,899],{"class":137,"line":162},[135,900,901],{"class":669},"# Your own plugin group — returns an EntryPoints collection.\n",[135,903,904,907,909,912,916,918,921],{"class":137,"line":171},[135,905,906],{"class":141},"plugins ",[135,908,516],{"class":325},[135,910,911],{"class":141}," entry_points(",[135,913,915],{"class":914},"s4XuR","group",[135,917,516],{"class":325},[135,919,920],{"class":158},"\"weatherctl.plugins\"",[135,922,550],{"class":141},[135,924,925,928,931,934],{"class":137,"line":180},[135,926,927],{"class":325},"for",[135,929,930],{"class":141}," ep ",[135,932,933],{"class":325},"in",[135,935,936],{"class":141}," plugins:\n",[135,938,939,942,944,947],{"class":137,"line":187},[135,940,941],{"class":141},"    register ",[135,943,516],{"class":325},[135,945,946],{"class":141}," ep.load()   ",[135,948,949],{"class":669},"# imports the module, returns the callable\n",[135,951,952,955],{"class":137,"line":201},[135,953,954],{"class":141},"    register(app)          ",[135,956,957],{"class":669},"# hand your app object to the plugin\n",[135,959,960],{"class":137,"line":210},[135,961,184],{"emptyLinePlaceholder":183},[135,963,964],{"class":137,"line":215},[135,965,966],{"class":669},"# Console scripts use the special \"console_scripts\" group.\n",[135,968,969,971,973,975,977,979,981,984],{"class":137,"line":225},[135,970,927],{"class":325},[135,972,930],{"class":141},[135,974,933],{"class":325},[135,976,911],{"class":141},[135,978,915],{"class":914},[135,980,516],{"class":325},[135,982,983],{"class":158},"\"console_scripts\"",[135,985,986],{"class":141},"):\n",[135,988,989,991,994,997],{"class":137,"line":236},[135,990,563],{"class":350},[135,992,993],{"class":141},"(ep.name, ",[135,995,996],{"class":158},"\"->\"",[135,998,999],{"class":141},", ep.value)\n",[10,1001,111,1002,1005],{},[14,1003,1004],{},"entry_points(group=...)"," keyword form is the modern, fast API (Python 3.10+). It avoids loading every distribution's metadata when you only care about one group. The collection returned is lazy and filterable, so iterating a single group is cheap even in a crowded environment.",[10,1007,1008,1009,1011],{},"Validated against the real interpreter, listing the ",[14,1010,787],{}," group and loading one entry shows the round-trip working:",[126,1013,1015],{"className":316,"code":1014,"language":318,"meta":131,"style":131},">>> from importlib.metadata import entry_points\n>>> scripts = list(entry_points(group=\"console_scripts\"))\n>>> [ep.name for ep in scripts]\n['markdown-it', 'py.test', 'pytest', 'pygmentize', 'typer']\n>>> ep = scripts[0]\n>>> ep.name, ep.value, ep.group\n('markdown-it', 'markdown_it.cli.parse:main', 'console_scripts')\n>>> fn = ep.load(); callable(fn)\nTrue\n",[14,1016,1017,1031,1055,1071,1100,1115,1122,1140,1158],{"__ignoreMap":131},[135,1018,1019,1022,1025,1027,1029],{"class":137,"line":138},[135,1020,1021],{"class":325},">>>",[135,1023,1024],{"class":325}," from",[135,1026,887],{"class":141},[135,1028,326],{"class":325},[135,1030,892],{"class":141},[135,1032,1033,1035,1038,1040,1043,1046,1048,1050,1052],{"class":137,"line":152},[135,1034,1021],{"class":325},[135,1036,1037],{"class":141}," scripts ",[135,1039,516],{"class":325},[135,1041,1042],{"class":350}," list",[135,1044,1045],{"class":141},"(entry_points(",[135,1047,915],{"class":914},[135,1049,516],{"class":325},[135,1051,983],{"class":158},[135,1053,1054],{"class":141},"))\n",[135,1056,1057,1059,1062,1064,1066,1068],{"class":137,"line":162},[135,1058,1021],{"class":325},[135,1060,1061],{"class":141}," [ep.name ",[135,1063,927],{"class":325},[135,1065,930],{"class":141},[135,1067,933],{"class":325},[135,1069,1070],{"class":141}," scripts]\n",[135,1072,1073,1075,1078,1080,1083,1085,1088,1090,1093,1095,1098],{"class":137,"line":171},[135,1074,142],{"class":141},[135,1076,1077],{"class":158},"'markdown-it'",[135,1079,861],{"class":141},[135,1081,1082],{"class":158},"'py.test'",[135,1084,861],{"class":141},[135,1086,1087],{"class":158},"'pytest'",[135,1089,861],{"class":141},[135,1091,1092],{"class":158},"'pygmentize'",[135,1094,861],{"class":141},[135,1096,1097],{"class":158},"'typer'",[135,1099,149],{"class":141},[135,1101,1102,1104,1106,1108,1111,1113],{"class":137,"line":180},[135,1103,1021],{"class":325},[135,1105,930],{"class":141},[135,1107,516],{"class":325},[135,1109,1110],{"class":141}," scripts[",[135,1112,580],{"class":350},[135,1114,149],{"class":141},[135,1116,1117,1119],{"class":137,"line":187},[135,1118,1021],{"class":325},[135,1120,1121],{"class":141}," ep.name, ep.value, ep.group\n",[135,1123,1124,1126,1128,1130,1133,1135,1138],{"class":137,"line":201},[135,1125,544],{"class":141},[135,1127,1077],{"class":158},[135,1129,861],{"class":141},[135,1131,1132],{"class":158},"'markdown_it.cli.parse:main'",[135,1134,861],{"class":141},[135,1136,1137],{"class":158},"'console_scripts'",[135,1139,550],{"class":141},[135,1141,1142,1144,1147,1149,1152,1155],{"class":137,"line":210},[135,1143,1021],{"class":325},[135,1145,1146],{"class":141}," fn ",[135,1148,516],{"class":325},[135,1150,1151],{"class":141}," ep.load(); ",[135,1153,1154],{"class":350},"callable",[135,1156,1157],{"class":141},"(fn)\n",[135,1159,1160],{"class":137,"line":215},[135,1161,1162],{"class":350},"True\n",[10,1164,1165,1168,1169,1171,1172,784,1174,1177],{},[14,1166,1167],{},"ep.value"," is the verbatim ",[14,1170,49],{}," string you wrote in ",[14,1173,29],{},[14,1175,1176],{},"ep.load()"," returns the imported callable — confirming the on-disk metadata is exactly your declaration.",[36,1179,1181],{"id":1180},"common-pitfalls","Common pitfalls",[10,1183,1184,1187,1188,1190,1191,1194,1195,1198,1199,1201],{},[72,1185,1186],{},"The target must take no required arguments."," The generated wrapper calls ",[14,1189,391],{}," with nothing. If your function signature is ",[14,1192,1193],{},"def main(config_path)",", the command crashes with a ",[14,1196,1197],{},"TypeError"," the moment it runs. Read inputs from ",[14,1200,78],{},", environment, or let Click\u002FTyper parse them — never from positional parameters on the target.",[10,1203,1204,1207,1208,1211,1212,1215,1216,1219,1220,1223,1224,1226,1227,1230,1231,1234,1235,1239],{},[72,1205,1206],{},"src layout discovery."," If you use the recommended ",[14,1209,1210],{},"src\u002F"," layout, the package lives at ",[14,1213,1214],{},"src\u002Fweatherctl\u002F",", but the entry-point value is still ",[14,1217,1218],{},"weatherctl.cli:main"," — the value references the ",[23,1221,1222],{},"importable"," name, not the on-disk path. Make sure the backend is configured to find it (hatchling auto-detects ",[14,1225,1210],{},"; setuptools may need ",[14,1228,1229],{},"[tool.setuptools.packages.find]"," with ",[14,1232,1233],{},"where = [\"src\"]","). A missing discovery config produces a package that installs but whose entry point fails to import. The ",[97,1236,1238],{"href":1237},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fhow-to-structure-a-large-python-cli-project\u002F","large-project structure guide"," covers the layout in depth.",[10,1241,1242,765,1245,1248,1249,1252,1253,1255,1256,1258,1259,1261],{},[72,1243,1244],{},"Editable installs need a re-install when scripts change.",[14,1246,1247],{},"pip install -e ."," links your source so code edits take effect immediately — but the ",[23,1250,1251],{},"wrapper script itself"," is generated at install time. If you add, rename, or remove a ",[14,1254,56],{}," entry, the existing wrapper won't update; re-run ",[14,1257,1247],{}," to regenerate it. Likewise, entry-point metadata for a new plugin group only becomes visible after the providing package is (re)installed, because ",[14,1260,852],{}," reads the distribution's recorded metadata, not your live source.",[10,1263,1264,1267,1268,1270,1271,1273],{},[72,1265,1266],{},"Don't put logic in the wrapper."," Keep ",[14,1269,391],{}," thin and importable. Tests should import and call ",[14,1272,391],{}," (or your Click\u002FTyper object) directly; only the end-to-end smoke test should shell out to the installed command.",[36,1275,1277],{"id":1276},"related","Related",[41,1279,1280,1287,1292,1297],{},[44,1281,1282,1286],{},[97,1283,1285],{"href":1284},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002F","Structuring multi-command Python CLIs"," — the sub-hub for this topic",[44,1288,1289,1291],{},[97,1290,842],{"href":99}," — group entry points in depth",[44,1293,1294,1296],{},[97,1295,259],{"href":258}," — declaring scripts under Poetry",[44,1298,1299,1301],{},[97,1300,705],{"href":704}," — the callable your entry point targets",[1303,1304,1305],"style",{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":131,"searchDepth":152,"depth":152,"links":1307},[1308,1309,1311,1312,1314,1316,1317,1318,1319],{"id":38,"depth":152,"text":39},{"id":110,"depth":152,"text":1310},"The [project.scripts] table",{"id":294,"depth":152,"text":295},{"id":395,"depth":152,"text":1313},"The module:callable resolution rules",{"id":709,"depth":152,"text":1315},"python -m package as an alternative",{"id":774,"depth":152,"text":775},{"id":845,"depth":152,"text":846},{"id":1180,"depth":152,"text":1181},{"id":1276,"depth":152,"text":1277},"2026-06-17","Configure Python CLI entry points in pyproject.toml using PEP 621 standards for deterministic cross-environment installation and execution.","intermediate",false,"md",{},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fbest-practices-for-python-cli-entry-points",{"title":5,"description":1321},"modern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fbest-practices-for-python-cli-entry-points\u002Findex",[808,1330,1331,1332],"pyproject","pep621","console-scripts","CJQrnF4hHD-7DWXz_0qEiXt0BiBvfnsL1w-1CnIrzE0",[1335,1465,2487,4626,5775,6839,6976,8409,8980,9002,9149,10783,11706,13077,13547,15348,15967,17352,19051,19247,20829,22104,22389,23712,24270,25111,25377],{"id":1336,"title":1337,"body":1338,"date":1320,"description":1456,"difficulty":1457,"draft":1323,"extension":1324,"meta":1458,"navigation":183,"path":1459,"seo":1460,"stem":1461,"tags":1462,"updated":1320,"__hash__":1464},"content\u002Fabout.md","About Python CLI Toolcraft",{"type":7,"value":1339,"toc":1450},[1340,1343,1346,1350,1353,1356,1360,1363,1369,1373,1376,1409,1413,1416,1439,1442,1445],[10,1341,1342],{},"Python CLI Toolcraft is a practical, production-focused reference for engineers who build command-line tools in Python. The goal is simple: help you move from a one-off script to a robust, packaged, distributable application — without wading through outdated advice or theory you'll never apply.",[10,1344,1345],{},"Most Python CLI tutorials stop at \"here's how to read an argument.\" Real tools have to handle messy input, ship to other machines, survive code review, and keep working six months later. This site fills that gap with guidance you can run, adapt, and put into production.",[36,1347,1349],{"id":1348},"what-this-site-is","What this site is",[10,1351,1352],{},"Python CLI Toolcraft is a content-driven reference site for building, testing, packaging, and distributing modern Python command-line applications. It exists to help you architect maintainable CLIs, use today's tooling effectively, and solve the real-world problems that come up once a tool leaves your laptop — cross-platform distribution, configuration, validation, and a usable terminal experience.",[10,1354,1355],{},"Every article aims to be something you'd actually keep open in a tab while you work, not a blog post you skim once and forget.",[36,1357,1359],{"id":1358},"who-its-for","Who it's for",[10,1361,1362],{},"The primary audience is Python developers building internal tools, plus DevOps and data engineers who live in the terminal and the hobbyists automating their own workflows. If you write scripts that other people (or future-you) depend on, this is for you.",[10,1364,1365,1366,1368],{},"A second audience is intermediate developers making the jump from loose scripts to properly packaged CLI apps — the point where questions about entry points, dependency management, and project structure suddenly matter. If you've ever had a script you wished you could ",[14,1367,16],{},", you're in the right place.",[36,1370,1372],{"id":1371},"editorial-principles","Editorial principles",[10,1374,1375],{},"A few commitments shape everything published here:",[41,1377,1378,1384,1397,1403],{},[44,1379,1380,1383],{},[72,1381,1382],{},"Production-ready patterns over theory."," We favor the approach you'd defend in a code review over the one that's merely clever.",[44,1385,1386,1389,1390,1392,1393,1396],{},[72,1387,1388],{},"The modern ecosystem only."," Examples use current tooling — uv, Poetry, Typer, Click, Rich, and pytest — with ",[14,1391,29],{},", type hints, and current APIs. No ",[14,1394,1395],{},"setup.py","-era or Python 2 advice.",[44,1398,1399,1402],{},[72,1400,1401],{},"Runnable, validated examples."," Code is meant to be copy-pasted and run, not pieced together. Snippets are checked against a real interpreter before they're published, and versions are pinned where behavior depends on them.",[44,1404,1405,1408],{},[72,1406,1407],{},"Respect for your time."," Articles lead with the answer and explain the trade-offs after, so you can get unblocked fast and go deeper when you want to.",[36,1410,1412],{"id":1411},"how-the-content-is-organized","How the content is organized",[10,1414,1415],{},"The material is grouped into three tracks that roughly follow the life of a CLI project — from first scaffold to polished, distributable tool.",[41,1417,1418,1425,1432],{},[44,1419,1420,1424],{},[97,1421,1423],{"href":1422},"\u002Fproject-setup-dependency-management\u002F","Project Setup & Dependency Management"," covers the foundation: scaffolding new projects, managing dependencies and virtual environments, versioning, and getting your tool packaged and distributed so others can install it.",[44,1426,1427,1431],{},[97,1428,1430],{"href":1429},"\u002Fmodern-python-cli-frameworks-architecture\u002F","Modern Python CLI Frameworks & Architecture"," is about structure: choosing between Typer and Click, designing multi-command interfaces, organizing larger codebases, and building extensible, plugin-friendly architectures that hold up as your tool grows.",[44,1433,1434,1438],{},[97,1435,1437],{"href":1436},"\u002Fadvanced-input-parsing-user-experience\u002F","Advanced Input Parsing & User Experience"," focuses on the part users actually touch: validating arguments, parsing complex input, loading configuration from files and environment variables, and building rich, interactive terminal interfaces with progress bars, spinners, and clear output.",[10,1440,1441],{},"Start wherever your current problem lives. Each section links down to its detailed guides, and individual articles cross-reference related topics so you can follow a thread without getting lost.",[10,1443,1444],{},"If you build Python tools that run in a terminal, the aim is for this site to make each one a little more reliable, a little easier to maintain, and a lot nicer to use.",[10,1446,1447],{},[97,1448,1449],{"href":459},"Back home",{"title":131,"searchDepth":152,"depth":152,"links":1451},[1452,1453,1454,1455],{"id":1348,"depth":152,"text":1349},{"id":1358,"depth":152,"text":1359},{"id":1371,"depth":152,"text":1372},{"id":1411,"depth":152,"text":1412},"Learn what Python CLI Toolcraft covers — packaging, architecture, validation, and terminal UX guidance for engineers building production Python CLI tools.","beginner",{},"\u002Fabout",{"title":1337,"description":1456},"about",[1461,1463],"overview","gKzuvQfqZ2ASgpCiFnetkzJ8Qn1u45yPErMSEvwwnr4",{"id":1466,"title":1467,"body":1468,"date":1320,"description":2475,"difficulty":1322,"draft":1323,"extension":1324,"meta":2476,"navigation":183,"path":2477,"seo":2478,"stem":2479,"tags":2480,"updated":1320,"__hash__":2486},"content\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Findex.md","Advanced Argument Validation Strategies",{"type":7,"value":1469,"toc":2467},[1470,1477,1479,1517,1523,1527,1537,1544,1946,1953,1957,1960,2014,2027,2031,2045,2171,2205,2209,2223,2430,2441,2443,2464],[10,1471,1472,1473,1476],{},"Robust validation turns brittle scripts into resilient tools. The core principle is simple: validate everything at the boundary, ",[23,1474,1475],{},"before"," a single line of business logic runs, so your command body can assume it is working with clean, typed data. This hub walks through schema-driven validation with Pydantic v2, custom validators and callbacks in Typer and Click, layering checks from type to range to cross-field, and converting validation failures into clean CLI errors with the right exit codes.",[36,1478,39],{"id":38},[41,1480,1481,1484,1491,1506],{},[44,1482,1483],{},"Validate at the boundary: parse raw strings into a typed, validated model first; the rest of the command trusts that model.",[44,1485,1486,1487,1490],{},"Use a Pydantic v2 ",[14,1488,1489],{},"BaseModel"," as the single source of truth for shape, types, ranges, and cross-field rules.",[44,1492,1493,1494,1497,1498,1501,1502,1505],{},"Wire it in with a Typer\u002FClick callback or a custom ",[14,1495,1496],{},"ParamType",", catch ",[14,1499,1500],{},"ValidationError",", and re-raise as a ",[14,1503,1504],{},"click.BadParameter"," so the user gets exit code 2 and a usage message.",[44,1507,1508,1509,1512,1513,1516],{},"Layer your checks: type coercion, then per-field constraints (",[14,1510,1511],{},"Field(ge=..., le=...)","), then cross-field invariants (",[14,1514,1515],{},"@model_validator",").",[10,1518,1519],{},[104,1520],{"alt":1521,"src":1522},"Funnel diagram: raw input passes through a type check, then range\u002Fformat, then cross-field rules into a valid typed object; any failing stage branches to a BadParameter error with exit code 2.","\u002Fillustrations\u002Fvalidation-layers.svg",[36,1524,1526],{"id":1525},"validate-at-the-boundary","Validate at the boundary",[10,1528,1529,1530,1532,1533,1536],{},"The most common validation mistake is scattering ",[14,1531,347],{}," checks through the command body. By the time you discover that ",[14,1534,1535],{},"--replicas"," is negative, you may have already opened a connection or written a file. Instead, treat the argument layer as a gate: nothing untrusted gets past it.",[10,1538,1539,1540,1543],{},"A Pydantic v2 model is the cleanest way to express that gate. It captures the ",[23,1541,1542],{},"entire"," contract — field names, types, bounds, defaults, and relationships — in one declarative place:",[126,1545,1547],{"className":316,"code":1546,"language":318,"meta":131,"style":131},"from typing import Annotated\nfrom pydantic import BaseModel, Field, field_validator, model_validator\n\nclass Resources(BaseModel):\n    cpu: Annotated[int, Field(ge=1, le=64)]\n    memory_mb: Annotated[int, Field(ge=128)]\n\nclass DeployConfig(BaseModel):\n    name: Annotated[str, Field(min_length=1, max_length=63)]\n    replicas: Annotated[int, Field(ge=1, le=100)]\n    resources: Resources\n    canary_percent: Annotated[int, Field(ge=0, le=100)] = 0\n\n    @field_validator(\"name\")\n    @classmethod\n    def name_is_dns_safe(cls, v: str) -> str:\n        if not all(c.isalnum() or c == \"-\" for c in v):\n            raise ValueError(\"name must contain only alphanumerics and hyphens\")\n        return v.lower()\n\n    @model_validator(mode=\"after\")\n    def canary_needs_replicas(self) -> \"DeployConfig\":\n        if self.canary_percent > 0 and self.replicas \u003C 2:\n            raise ValueError(\"canary_percent requires at least 2 replicas\")\n        return self\n",[14,1548,1549,1561,1573,1577,1591,1621,1639,1643,1656,1685,1711,1716,1746,1750,1763,1772,1793,1828,1844,1852,1857,1875,1891,1924,1938],{"__ignoreMap":131},[135,1550,1551,1553,1556,1558],{"class":137,"line":138},[135,1552,334],{"class":325},[135,1554,1555],{"class":141}," typing ",[135,1557,326],{"class":325},[135,1559,1560],{"class":141}," Annotated\n",[135,1562,1563,1565,1568,1570],{"class":137,"line":152},[135,1564,334],{"class":325},[135,1566,1567],{"class":141}," pydantic ",[135,1569,326],{"class":325},[135,1571,1572],{"class":141}," BaseModel, Field, field_validator, model_validator\n",[135,1574,1575],{"class":137,"line":162},[135,1576,184],{"emptyLinePlaceholder":183},[135,1578,1579,1582,1585,1587,1589],{"class":137,"line":171},[135,1580,1581],{"class":325},"class",[135,1583,1584],{"class":145}," Resources",[135,1586,544],{"class":141},[135,1588,1489],{"class":145},[135,1590,986],{"class":141},[135,1592,1593,1596,1598,1601,1604,1606,1608,1610,1613,1615,1618],{"class":137,"line":180},[135,1594,1595],{"class":141},"    cpu: Annotated[",[135,1597,387],{"class":350},[135,1599,1600],{"class":141},", Field(",[135,1602,1603],{"class":914},"ge",[135,1605,516],{"class":325},[135,1607,522],{"class":350},[135,1609,861],{"class":141},[135,1611,1612],{"class":914},"le",[135,1614,516],{"class":325},[135,1616,1617],{"class":350},"64",[135,1619,1620],{"class":141},")]\n",[135,1622,1623,1626,1628,1630,1632,1634,1637],{"class":137,"line":187},[135,1624,1625],{"class":141},"    memory_mb: Annotated[",[135,1627,387],{"class":350},[135,1629,1600],{"class":141},[135,1631,1603],{"class":914},[135,1633,516],{"class":325},[135,1635,1636],{"class":350},"128",[135,1638,1620],{"class":141},[135,1640,1641],{"class":137,"line":201},[135,1642,184],{"emptyLinePlaceholder":183},[135,1644,1645,1647,1650,1652,1654],{"class":137,"line":210},[135,1646,1581],{"class":325},[135,1648,1649],{"class":145}," DeployConfig",[135,1651,544],{"class":141},[135,1653,1489],{"class":145},[135,1655,986],{"class":141},[135,1657,1658,1661,1664,1666,1669,1671,1673,1675,1678,1680,1683],{"class":137,"line":215},[135,1659,1660],{"class":141},"    name: Annotated[",[135,1662,1663],{"class":350},"str",[135,1665,1600],{"class":141},[135,1667,1668],{"class":914},"min_length",[135,1670,516],{"class":325},[135,1672,522],{"class":350},[135,1674,861],{"class":141},[135,1676,1677],{"class":914},"max_length",[135,1679,516],{"class":325},[135,1681,1682],{"class":350},"63",[135,1684,1620],{"class":141},[135,1686,1687,1690,1692,1694,1696,1698,1700,1702,1704,1706,1709],{"class":137,"line":225},[135,1688,1689],{"class":141},"    replicas: Annotated[",[135,1691,387],{"class":350},[135,1693,1600],{"class":141},[135,1695,1603],{"class":914},[135,1697,516],{"class":325},[135,1699,522],{"class":350},[135,1701,861],{"class":141},[135,1703,1612],{"class":914},[135,1705,516],{"class":325},[135,1707,1708],{"class":350},"100",[135,1710,1620],{"class":141},[135,1712,1713],{"class":137,"line":236},[135,1714,1715],{"class":141},"    resources: Resources\n",[135,1717,1718,1721,1723,1725,1727,1729,1731,1733,1735,1737,1739,1742,1744],{"class":137,"line":606},[135,1719,1720],{"class":141},"    canary_percent: Annotated[",[135,1722,387],{"class":350},[135,1724,1600],{"class":141},[135,1726,1603],{"class":914},[135,1728,516],{"class":325},[135,1730,580],{"class":350},[135,1732,861],{"class":141},[135,1734,1612],{"class":914},[135,1736,516],{"class":325},[135,1738,1708],{"class":350},[135,1740,1741],{"class":141},")] ",[135,1743,516],{"class":325},[135,1745,599],{"class":350},[135,1747,1748],{"class":137,"line":619},[135,1749,184],{"emptyLinePlaceholder":183},[135,1751,1753,1756,1758,1761],{"class":137,"line":1752},14,[135,1754,1755],{"class":145},"    @field_validator",[135,1757,544],{"class":141},[135,1759,1760],{"class":158},"\"name\"",[135,1762,550],{"class":141},[135,1764,1766,1769],{"class":137,"line":1765},15,[135,1767,1768],{"class":145},"    @",[135,1770,1771],{"class":350},"classmethod\n",[135,1773,1775,1778,1781,1784,1786,1789,1791],{"class":137,"line":1774},16,[135,1776,1777],{"class":325},"    def",[135,1779,1780],{"class":145}," name_is_dns_safe",[135,1782,1783],{"class":141},"(cls, v: ",[135,1785,1663],{"class":350},[135,1787,1788],{"class":141},") -> ",[135,1790,1663],{"class":350},[135,1792,360],{"class":141},[135,1794,1796,1799,1801,1804,1807,1810,1813,1816,1819,1821,1823,1825],{"class":137,"line":1795},17,[135,1797,1798],{"class":325},"        if",[135,1800,533],{"class":325},[135,1802,1803],{"class":350}," all",[135,1805,1806],{"class":141},"(c.isalnum() ",[135,1808,1809],{"class":325},"or",[135,1811,1812],{"class":141}," c ",[135,1814,1815],{"class":325},"==",[135,1817,1818],{"class":158}," \"-\"",[135,1820,663],{"class":325},[135,1822,1812],{"class":141},[135,1824,933],{"class":325},[135,1826,1827],{"class":141}," v):\n",[135,1829,1831,1834,1837,1839,1842],{"class":137,"line":1830},18,[135,1832,1833],{"class":325},"            raise",[135,1835,1836],{"class":350}," ValueError",[135,1838,544],{"class":141},[135,1840,1841],{"class":158},"\"name must contain only alphanumerics and hyphens\"",[135,1843,550],{"class":141},[135,1845,1847,1849],{"class":137,"line":1846},19,[135,1848,555],{"class":325},[135,1850,1851],{"class":141}," v.lower()\n",[135,1853,1855],{"class":137,"line":1854},20,[135,1856,184],{"emptyLinePlaceholder":183},[135,1858,1860,1863,1865,1868,1870,1873],{"class":137,"line":1859},21,[135,1861,1862],{"class":145},"    @model_validator",[135,1864,544],{"class":141},[135,1866,1867],{"class":914},"mode",[135,1869,516],{"class":325},[135,1871,1872],{"class":158},"\"after\"",[135,1874,550],{"class":141},[135,1876,1878,1880,1883,1886,1889],{"class":137,"line":1877},22,[135,1879,1777],{"class":325},[135,1881,1882],{"class":145}," canary_needs_replicas",[135,1884,1885],{"class":141},"(self) -> ",[135,1887,1888],{"class":158},"\"DeployConfig\"",[135,1890,360],{"class":141},[135,1892,1894,1896,1899,1902,1905,1908,1911,1913,1916,1919,1922],{"class":137,"line":1893},23,[135,1895,1798],{"class":325},[135,1897,1898],{"class":350}," self",[135,1900,1901],{"class":141},".canary_percent ",[135,1903,1904],{"class":325},">",[135,1906,1907],{"class":350}," 0",[135,1909,1910],{"class":325}," and",[135,1912,1898],{"class":350},[135,1914,1915],{"class":141},".replicas ",[135,1917,1918],{"class":325},"\u003C",[135,1920,1921],{"class":350}," 2",[135,1923,360],{"class":141},[135,1925,1927,1929,1931,1933,1936],{"class":137,"line":1926},24,[135,1928,1833],{"class":325},[135,1930,1836],{"class":350},[135,1932,544],{"class":141},[135,1934,1935],{"class":158},"\"canary_percent requires at least 2 replicas\"",[135,1937,550],{"class":141},[135,1939,1941,1943],{"class":137,"line":1940},25,[135,1942,555],{"class":325},[135,1944,1945],{"class":350}," self\n",[10,1947,1948,1949,1952],{},"Call ",[14,1950,1951],{},"DeployConfig.model_validate(data)"," once, and every layer fires in order.",[36,1954,1956],{"id":1955},"layered-validation-type-range-cross-field","Layered validation: type, range, cross-field",[10,1958,1959],{},"Good validation is layered, and Pydantic runs the layers for you in a predictable sequence:",[1961,1962,1963,1984,2005],"ol",{},[44,1964,1965,1968,1969,1972,1973,1976,1977,1979,1980,1983],{},[72,1966,1967],{},"Type coercion"," — Pydantic parses ",[14,1970,1971],{},"\"4\""," into ",[14,1974,1975],{},"4"," for an ",[14,1978,387],{}," field, or rejects ",[14,1981,1982],{},"\"four\"",". This is the cheapest, broadest layer.",[44,1985,1986,1989,1990,1993,1994,1997,1998,2001,2002,2004],{},[72,1987,1988],{},"Per-field constraints"," — ",[14,1991,1992],{},"Field(ge=1, le=64)"," and ",[14,1995,1996],{},"field_validator"," enforce bounds and shape on individual values. The ",[14,1999,2000],{},"name_is_dns_safe"," validator both checks ",[23,2003,764],{}," normalizes (lowercasing), which is a useful trick: validators can return a cleaned value.",[44,2006,2007,1989,2010,2013],{},[72,2008,2009],{},"Cross-field invariants",[14,2011,2012],{},"@model_validator(mode=\"after\")"," sees the fully built object, so it can assert relationships between fields, like \"canary deployments need at least two replicas.\" These rules are impossible to express on a single field.",[10,2015,2016,2017,2020,2021,2024,2025,61],{},"Layering matters because earlier layers protect later ones: a ",[14,2018,2019],{},"model_validator"," never has to guard against ",[14,2022,2023],{},"replicas"," being a string, because the type layer already guaranteed it is an ",[14,2026,387],{},[36,2028,2030],{"id":2029},"wiring-validators-into-typer-and-click","Wiring validators into Typer and Click",[10,2032,2033,2034,433,2037,2040,2041,2044],{},"In ",[72,2035,2036],{},"Typer",[14,2038,2039],{},"callback"," on an option runs your parsing function. Raise ",[14,2042,2043],{},"typer.BadParameter"," to produce a clean usage error:",[126,2046,2048],{"className":316,"code":2047,"language":318,"meta":131,"style":131},"import typer\nfrom pydantic import ValidationError\n\ndef parse_replicas(value: int) -> int:\n    if value > 50:\n        raise typer.BadParameter(\"replicas above 50 require sign-off\")\n    return value\n\n@app.command()\ndef deploy(replicas: int = typer.Option(..., callback=parse_replicas)):\n    ...\n",[14,2049,2050,2057,2068,2072,2090,2104,2117,2124,2128,2136,2166],{"__ignoreMap":131},[135,2051,2052,2054],{"class":137,"line":138},[135,2053,326],{"class":325},[135,2055,2056],{"class":141}," typer\n",[135,2058,2059,2061,2063,2065],{"class":137,"line":152},[135,2060,334],{"class":325},[135,2062,1567],{"class":141},[135,2064,326],{"class":325},[135,2066,2067],{"class":141}," ValidationError\n",[135,2069,2070],{"class":137,"line":162},[135,2071,184],{"emptyLinePlaceholder":183},[135,2073,2074,2076,2079,2082,2084,2086,2088],{"class":137,"line":171},[135,2075,493],{"class":325},[135,2077,2078],{"class":145}," parse_replicas",[135,2080,2081],{"class":141},"(value: ",[135,2083,387],{"class":350},[135,2085,1788],{"class":141},[135,2087,387],{"class":350},[135,2089,360],{"class":141},[135,2091,2092,2094,2097,2099,2102],{"class":137,"line":180},[135,2093,530],{"class":325},[135,2095,2096],{"class":141}," value ",[135,2098,1904],{"class":325},[135,2100,2101],{"class":350}," 50",[135,2103,360],{"class":141},[135,2105,2106,2109,2112,2115],{"class":137,"line":187},[135,2107,2108],{"class":325},"        raise",[135,2110,2111],{"class":141}," typer.BadParameter(",[135,2113,2114],{"class":158},"\"replicas above 50 require sign-off\"",[135,2116,550],{"class":141},[135,2118,2119,2121],{"class":137,"line":201},[135,2120,596],{"class":325},[135,2122,2123],{"class":141}," value\n",[135,2125,2126],{"class":137,"line":210},[135,2127,184],{"emptyLinePlaceholder":183},[135,2129,2130,2133],{"class":137,"line":215},[135,2131,2132],{"class":145},"@app.command",[135,2134,2135],{"class":141},"()\n",[135,2137,2138,2140,2143,2146,2148,2151,2154,2157,2159,2161,2163],{"class":137,"line":225},[135,2139,493],{"class":325},[135,2141,2142],{"class":145}," deploy",[135,2144,2145],{"class":141},"(replicas: ",[135,2147,387],{"class":350},[135,2149,2150],{"class":325}," =",[135,2152,2153],{"class":141}," typer.Option(",[135,2155,2156],{"class":350},"...",[135,2158,861],{"class":141},[135,2160,2039],{"class":914},[135,2162,516],{"class":325},[135,2164,2165],{"class":141},"parse_replicas)):\n",[135,2167,2168],{"class":137,"line":236},[135,2169,2170],{"class":350},"    ...\n",[10,2172,2033,2173,2176,2177,2180,2181,2184,2185,2188,2189,2193,2194,2196,2197,2200,2201,2204],{},[72,2174,2175],{},"Click",", subclass ",[14,2178,2179],{},"click.ParamType"," and override ",[14,2182,2183],{},"convert","; call ",[14,2186,2187],{},"self.fail(...)"," on bad input. That is the natural home for parsing structured payloads — covered in depth in ",[97,2190,2192],{"href":2191},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fparsing-nested-json-arguments-in-python-clis\u002F","parsing nested JSON args in Python CLIs",", which builds a ",[14,2195,1496],{}," that runs ",[14,2198,2199],{},"json.loads"," and then ",[14,2202,2203],{},"model_validate"," in one step.",[36,2206,2208],{"id":2207},"clean-errors-and-correct-exit-codes","Clean errors and correct exit codes",[10,2210,2211,2212,2214,2215,2218,2219,2222],{},"A validation failure should never surface as a raw Python traceback. The Pydantic ",[14,2213,1500],{}," carries a structured ",[14,2216,2217],{},".errors()"," list — turn it into a tidy, field-addressed message and route it through Click's error machinery so the process exits with status ",[14,2220,2221],{},"2"," (the conventional \"usage error\" code):",[126,2224,2226],{"className":316,"code":2225,"language":318,"meta":131,"style":131},"from pydantic import ValidationError\nimport click\n\ndef format_errors(exc: ValidationError) -> str:\n    lines = []\n    for err in exc.errors():\n        loc = \".\".join(str(p) for p in err[\"loc\"]) or \"(root)\"\n        lines.append(f\"  {loc}: {err['msg']}\")\n    return \"validation failed:\\n\" + \"\\n\".join(lines)\n\n# inside a ParamType.convert or a callback:\ntry:\n    return DeployConfig.model_validate(data)\nexcept ValidationError as exc:\n    raise click.BadParameter(format_errors(exc))\n",[14,2227,2228,2238,2245,2249,2263,2273,2286,2325,2361,2386,2390,2395,2402,2409,2423],{"__ignoreMap":131},[135,2229,2230,2232,2234,2236],{"class":137,"line":138},[135,2231,334],{"class":325},[135,2233,1567],{"class":141},[135,2235,326],{"class":325},[135,2237,2067],{"class":141},[135,2239,2240,2242],{"class":137,"line":152},[135,2241,326],{"class":325},[135,2243,2244],{"class":141}," click\n",[135,2246,2247],{"class":137,"line":162},[135,2248,184],{"emptyLinePlaceholder":183},[135,2250,2251,2253,2256,2259,2261],{"class":137,"line":171},[135,2252,493],{"class":325},[135,2254,2255],{"class":145}," format_errors",[135,2257,2258],{"class":141},"(exc: ValidationError) -> ",[135,2260,1663],{"class":350},[135,2262,360],{"class":141},[135,2264,2265,2268,2270],{"class":137,"line":180},[135,2266,2267],{"class":141},"    lines ",[135,2269,516],{"class":325},[135,2271,2272],{"class":141}," []\n",[135,2274,2275,2278,2281,2283],{"class":137,"line":187},[135,2276,2277],{"class":325},"    for",[135,2279,2280],{"class":141}," err ",[135,2282,933],{"class":325},[135,2284,2285],{"class":141}," exc.errors():\n",[135,2287,2288,2291,2293,2296,2299,2301,2304,2306,2309,2311,2314,2317,2320,2322],{"class":137,"line":201},[135,2289,2290],{"class":141},"        loc ",[135,2292,516],{"class":325},[135,2294,2295],{"class":158}," \".\"",[135,2297,2298],{"class":141},".join(",[135,2300,1663],{"class":350},[135,2302,2303],{"class":141},"(p) ",[135,2305,927],{"class":325},[135,2307,2308],{"class":141}," p ",[135,2310,933],{"class":325},[135,2312,2313],{"class":141}," err[",[135,2315,2316],{"class":158},"\"loc\"",[135,2318,2319],{"class":141},"]) ",[135,2321,1809],{"class":325},[135,2323,2324],{"class":158}," \"(root)\"\n",[135,2326,2327,2330,2332,2335,2337,2340,2342,2345,2347,2350,2353,2355,2357,2359],{"class":137,"line":210},[135,2328,2329],{"class":141},"        lines.append(",[135,2331,568],{"class":325},[135,2333,2334],{"class":158},"\"  ",[135,2336,574],{"class":350},[135,2338,2339],{"class":141},"loc",[135,2341,586],{"class":350},[135,2343,2344],{"class":158},": ",[135,2346,574],{"class":350},[135,2348,2349],{"class":141},"err[",[135,2351,2352],{"class":158},"'msg'",[135,2354,583],{"class":141},[135,2356,586],{"class":350},[135,2358,589],{"class":158},[135,2360,550],{"class":141},[135,2362,2363,2365,2368,2371,2373,2376,2379,2381,2383],{"class":137,"line":215},[135,2364,596],{"class":325},[135,2366,2367],{"class":158}," \"validation failed:",[135,2369,2370],{"class":350},"\\n",[135,2372,589],{"class":158},[135,2374,2375],{"class":325}," +",[135,2377,2378],{"class":158}," \"",[135,2380,2370],{"class":350},[135,2382,589],{"class":158},[135,2384,2385],{"class":141},".join(lines)\n",[135,2387,2388],{"class":137,"line":225},[135,2389,184],{"emptyLinePlaceholder":183},[135,2391,2392],{"class":137,"line":236},[135,2393,2394],{"class":669},"# inside a ParamType.convert or a callback:\n",[135,2396,2397,2400],{"class":137,"line":606},[135,2398,2399],{"class":325},"try",[135,2401,360],{"class":141},[135,2403,2404,2406],{"class":137,"line":619},[135,2405,596],{"class":325},[135,2407,2408],{"class":141}," DeployConfig.model_validate(data)\n",[135,2410,2411,2414,2417,2420],{"class":137,"line":1752},[135,2412,2413],{"class":325},"except",[135,2415,2416],{"class":141}," ValidationError ",[135,2418,2419],{"class":325},"as",[135,2421,2422],{"class":141}," exc:\n",[135,2424,2425,2427],{"class":137,"line":1765},[135,2426,622],{"class":325},[135,2428,2429],{"class":141}," click.BadParameter(format_errors(exc))\n",[10,2431,2432,2433,2436,2437,2440],{},"Now a bad ",[14,2434,2435],{},"cpu"," produces ",[14,2438,2439],{},"resources.cpu: Input should be less than or equal to 64"," and an exit code that scripts and CI can detect — not a stack trace.",[36,2442,1277],{"id":1276},[41,2444,2445,2451,2457],{},[44,2446,2447,2448],{},"Up: ",[97,2449,2450],{"href":1436},"Advanced Input Parsing for Python CLIs",[44,2452,2453,2454],{},"Down: ",[97,2455,2456],{"href":2191},"Parsing nested JSON args in Python CLIs",[44,2458,2459,2460],{},"Sideways: ",[97,2461,2463],{"href":2462},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002F","Handling config files and env vars in CLIs",[1303,2465,2466],{},"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 .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 pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}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":131,"searchDepth":152,"depth":152,"links":2468},[2469,2470,2471,2472,2473,2474],{"id":38,"depth":152,"text":39},{"id":1525,"depth":152,"text":1526},{"id":1955,"depth":152,"text":1956},{"id":2029,"depth":152,"text":2030},{"id":2207,"depth":152,"text":2208},{"id":1276,"depth":152,"text":1277},"Enforce strict argument validation in Python CLIs using Pydantic v2, Typer, and custom validators for schema-driven pre-execution data integrity.",{},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies",{"title":1467,"description":2475},"advanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Findex",[2481,2482,2483,2484,2485],"validation","pydantic","typer","click","error-handling","6jQEIly4SUOj1D1Fi7Crm_0WgmvH8FOuyMVzatuno3s",{"id":2488,"title":2489,"body":2490,"date":1320,"description":4616,"difficulty":4617,"draft":1323,"extension":1324,"meta":4618,"navigation":183,"path":4619,"seo":4620,"stem":4621,"tags":4622,"updated":1320,"__hash__":4625},"content\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fparsing-nested-json-arguments-in-python-clis\u002Findex.md","Parsing Nested JSON Args in Python CLIs",{"type":7,"value":2491,"toc":4607},[2492,2518,2520,2557,2563,2567,2570,2949,2955,2959,2979,3774,3791,3795,3805,3867,3874,3878,3885,4488,4509,4513,4584,4586,4604],[10,2493,2494,2495,861,2498,861,2501,861,2504,2507,2508,2510,2511,2513,2514,2517],{},"Some CLI inputs are genuinely structured: a deploy spec with nested resource limits, a filter object with arrays of conditions, an env map. Flattening those into a dozen flags (",[14,2496,2497],{},"--cpu",[14,2499,2500],{},"--mem",[14,2502,2503],{},"--env-key",[14,2505,2506],{},"--env-value","...) gets unwieldy fast. The pragmatic alternative is to accept a single JSON argument and validate it. This article builds a Click custom ",[14,2509,1496],{}," that calls ",[14,2512,2199],{}," and then validates the result against a Pydantic v2 model, handles ",[14,2515,2516],{},"@file"," and stdin input so you stay shell-safe, and reports errors clearly with the right exit code.",[36,2519,39],{"id":38},[41,2521,2522,2528,2535,2545],{},[44,2523,2524,2525,61],{},"Accept nested input as one JSON string and parse it with a Click ",[14,2526,2527],{},"ParamType.convert",[44,2529,2530,2531,2534],{},"Validate the parsed object with ",[14,2532,2533],{},"Model.model_validate(data)"," so a single type owns shape, ranges, and cross-field rules.",[44,2536,2537,2538,1993,2541,2544],{},"Support ",[14,2539,2540],{},"@file.json",[14,2542,2543],{},"@-"," (stdin) conventions to dodge shell-quoting nightmares for big payloads.",[44,2546,2547,2548,2550,2551,2553,2554,2556],{},"On bad JSON or a ",[14,2549,1500],{},", call ",[14,2552,2187],{}," so Click exits with status ",[14,2555,2221],{}," and prints a usage error — never a traceback.",[10,2558,2559],{},[104,2560],{"alt":2561,"src":2562},"Flow diagram: a JSON --config string runs through json.loads into a Python dict, then Pydantic model_validate into a validated nested object; a failure branches to a ValidationError on resources.cpu with exit code 2.","\u002Fillustrations\u002Fnested-json-parsing.svg",[36,2564,2566],{"id":2565},"define-the-schema-first","Define the schema first",[10,2568,2569],{},"Start with the data contract. A Pydantic v2 model is the single source of truth — nesting, types, bounds, and invariants all live here:",[126,2571,2573],{"className":316,"code":2572,"language":318,"meta":131,"style":131},"# models.py\nfrom __future__ import annotations\nfrom typing import Annotated\nfrom pydantic import BaseModel, Field, field_validator, model_validator\n\nclass Resources(BaseModel):\n    cpu: Annotated[int, Field(ge=1, le=64)]\n    memory_mb: Annotated[int, Field(ge=128)]\n\nclass DeployConfig(BaseModel):\n    name: Annotated[str, Field(min_length=1, max_length=63)]\n    replicas: Annotated[int, Field(ge=1, le=100)]\n    resources: Resources\n    env: dict[str, str] = Field(default_factory=dict)\n    canary_percent: Annotated[int, Field(ge=0, le=100)] = 0\n\n    @field_validator(\"name\")\n    @classmethod\n    def name_is_dns_safe(cls, v: str) -> str:\n        if not all(c.isalnum() or c == \"-\" for c in v):\n            raise ValueError(\"name must contain only alphanumerics and hyphens\")\n        return v.lower()\n\n    @model_validator(mode=\"after\")\n    def canary_needs_replicas(self) -> \"DeployConfig\":\n        if self.canary_percent > 0 and self.replicas \u003C 2:\n            raise ValueError(\"canary_percent requires at least 2 replicas\")\n        return self\n",[14,2574,2575,2580,2593,2603,2613,2617,2629,2653,2669,2673,2685,2709,2733,2737,2766,2794,2798,2808,2814,2830,2856,2868,2874,2878,2892,2904,2929,2942],{"__ignoreMap":131},[135,2576,2577],{"class":137,"line":138},[135,2578,2579],{"class":669},"# models.py\n",[135,2581,2582,2584,2587,2590],{"class":137,"line":152},[135,2583,334],{"class":325},[135,2585,2586],{"class":350}," __future__",[135,2588,2589],{"class":325}," import",[135,2591,2592],{"class":141}," annotations\n",[135,2594,2595,2597,2599,2601],{"class":137,"line":162},[135,2596,334],{"class":325},[135,2598,1555],{"class":141},[135,2600,326],{"class":325},[135,2602,1560],{"class":141},[135,2604,2605,2607,2609,2611],{"class":137,"line":171},[135,2606,334],{"class":325},[135,2608,1567],{"class":141},[135,2610,326],{"class":325},[135,2612,1572],{"class":141},[135,2614,2615],{"class":137,"line":180},[135,2616,184],{"emptyLinePlaceholder":183},[135,2618,2619,2621,2623,2625,2627],{"class":137,"line":187},[135,2620,1581],{"class":325},[135,2622,1584],{"class":145},[135,2624,544],{"class":141},[135,2626,1489],{"class":145},[135,2628,986],{"class":141},[135,2630,2631,2633,2635,2637,2639,2641,2643,2645,2647,2649,2651],{"class":137,"line":201},[135,2632,1595],{"class":141},[135,2634,387],{"class":350},[135,2636,1600],{"class":141},[135,2638,1603],{"class":914},[135,2640,516],{"class":325},[135,2642,522],{"class":350},[135,2644,861],{"class":141},[135,2646,1612],{"class":914},[135,2648,516],{"class":325},[135,2650,1617],{"class":350},[135,2652,1620],{"class":141},[135,2654,2655,2657,2659,2661,2663,2665,2667],{"class":137,"line":210},[135,2656,1625],{"class":141},[135,2658,387],{"class":350},[135,2660,1600],{"class":141},[135,2662,1603],{"class":914},[135,2664,516],{"class":325},[135,2666,1636],{"class":350},[135,2668,1620],{"class":141},[135,2670,2671],{"class":137,"line":215},[135,2672,184],{"emptyLinePlaceholder":183},[135,2674,2675,2677,2679,2681,2683],{"class":137,"line":225},[135,2676,1581],{"class":325},[135,2678,1649],{"class":145},[135,2680,544],{"class":141},[135,2682,1489],{"class":145},[135,2684,986],{"class":141},[135,2686,2687,2689,2691,2693,2695,2697,2699,2701,2703,2705,2707],{"class":137,"line":236},[135,2688,1660],{"class":141},[135,2690,1663],{"class":350},[135,2692,1600],{"class":141},[135,2694,1668],{"class":914},[135,2696,516],{"class":325},[135,2698,522],{"class":350},[135,2700,861],{"class":141},[135,2702,1677],{"class":914},[135,2704,516],{"class":325},[135,2706,1682],{"class":350},[135,2708,1620],{"class":141},[135,2710,2711,2713,2715,2717,2719,2721,2723,2725,2727,2729,2731],{"class":137,"line":606},[135,2712,1689],{"class":141},[135,2714,387],{"class":350},[135,2716,1600],{"class":141},[135,2718,1603],{"class":914},[135,2720,516],{"class":325},[135,2722,522],{"class":350},[135,2724,861],{"class":141},[135,2726,1612],{"class":914},[135,2728,516],{"class":325},[135,2730,1708],{"class":350},[135,2732,1620],{"class":141},[135,2734,2735],{"class":137,"line":619},[135,2736,1715],{"class":141},[135,2738,2739,2742,2744,2746,2748,2751,2753,2756,2759,2761,2764],{"class":137,"line":1752},[135,2740,2741],{"class":141},"    env: dict[",[135,2743,1663],{"class":350},[135,2745,861],{"class":141},[135,2747,1663],{"class":350},[135,2749,2750],{"class":141},"] ",[135,2752,516],{"class":325},[135,2754,2755],{"class":141}," Field(",[135,2757,2758],{"class":914},"default_factory",[135,2760,516],{"class":325},[135,2762,2763],{"class":350},"dict",[135,2765,550],{"class":141},[135,2767,2768,2770,2772,2774,2776,2778,2780,2782,2784,2786,2788,2790,2792],{"class":137,"line":1765},[135,2769,1720],{"class":141},[135,2771,387],{"class":350},[135,2773,1600],{"class":141},[135,2775,1603],{"class":914},[135,2777,516],{"class":325},[135,2779,580],{"class":350},[135,2781,861],{"class":141},[135,2783,1612],{"class":914},[135,2785,516],{"class":325},[135,2787,1708],{"class":350},[135,2789,1741],{"class":141},[135,2791,516],{"class":325},[135,2793,599],{"class":350},[135,2795,2796],{"class":137,"line":1774},[135,2797,184],{"emptyLinePlaceholder":183},[135,2799,2800,2802,2804,2806],{"class":137,"line":1795},[135,2801,1755],{"class":145},[135,2803,544],{"class":141},[135,2805,1760],{"class":158},[135,2807,550],{"class":141},[135,2809,2810,2812],{"class":137,"line":1830},[135,2811,1768],{"class":145},[135,2813,1771],{"class":350},[135,2815,2816,2818,2820,2822,2824,2826,2828],{"class":137,"line":1846},[135,2817,1777],{"class":325},[135,2819,1780],{"class":145},[135,2821,1783],{"class":141},[135,2823,1663],{"class":350},[135,2825,1788],{"class":141},[135,2827,1663],{"class":350},[135,2829,360],{"class":141},[135,2831,2832,2834,2836,2838,2840,2842,2844,2846,2848,2850,2852,2854],{"class":137,"line":1854},[135,2833,1798],{"class":325},[135,2835,533],{"class":325},[135,2837,1803],{"class":350},[135,2839,1806],{"class":141},[135,2841,1809],{"class":325},[135,2843,1812],{"class":141},[135,2845,1815],{"class":325},[135,2847,1818],{"class":158},[135,2849,663],{"class":325},[135,2851,1812],{"class":141},[135,2853,933],{"class":325},[135,2855,1827],{"class":141},[135,2857,2858,2860,2862,2864,2866],{"class":137,"line":1859},[135,2859,1833],{"class":325},[135,2861,1836],{"class":350},[135,2863,544],{"class":141},[135,2865,1841],{"class":158},[135,2867,550],{"class":141},[135,2869,2870,2872],{"class":137,"line":1877},[135,2871,555],{"class":325},[135,2873,1851],{"class":141},[135,2875,2876],{"class":137,"line":1893},[135,2877,184],{"emptyLinePlaceholder":183},[135,2879,2880,2882,2884,2886,2888,2890],{"class":137,"line":1926},[135,2881,1862],{"class":145},[135,2883,544],{"class":141},[135,2885,1867],{"class":914},[135,2887,516],{"class":325},[135,2889,1872],{"class":158},[135,2891,550],{"class":141},[135,2893,2894,2896,2898,2900,2902],{"class":137,"line":1940},[135,2895,1777],{"class":325},[135,2897,1882],{"class":145},[135,2899,1885],{"class":141},[135,2901,1888],{"class":158},[135,2903,360],{"class":141},[135,2905,2907,2909,2911,2913,2915,2917,2919,2921,2923,2925,2927],{"class":137,"line":2906},26,[135,2908,1798],{"class":325},[135,2910,1898],{"class":350},[135,2912,1901],{"class":141},[135,2914,1904],{"class":325},[135,2916,1907],{"class":350},[135,2918,1910],{"class":325},[135,2920,1898],{"class":350},[135,2922,1915],{"class":141},[135,2924,1918],{"class":325},[135,2926,1921],{"class":350},[135,2928,360],{"class":141},[135,2930,2932,2934,2936,2938,2940],{"class":137,"line":2931},27,[135,2933,1833],{"class":325},[135,2935,1836],{"class":350},[135,2937,544],{"class":141},[135,2939,1935],{"class":158},[135,2941,550],{"class":141},[135,2943,2945,2947],{"class":137,"line":2944},28,[135,2946,555],{"class":325},[135,2948,1945],{"class":350},[10,2950,111,2951,2954],{},[14,2952,2953],{},"Resources"," submodel makes the nesting explicit, and Pydantic recurses into it automatically when you validate the parent.",[36,2956,2958],{"id":2957},"a-click-paramtype-that-parses-and-validates","A Click ParamType that parses and validates",[10,2960,2961,2962,2964,2965,2968,2969,448,2971,2973,2974,1993,2976,2978],{},"A custom ",[14,2963,2179],{}," is the right seam. Click calls ",[14,2966,2967],{},"convert()"," once per argument; we do the ",[14,2970,2199],{},[14,2972,2203],{},", then hand back a fully typed object. The same type also handles ",[14,2975,2540],{},[14,2977,2543],{}," for stdin:",[126,2980,2982],{"className":316,"code":2981,"language":318,"meta":131,"style":131},"# cli.py\nfrom __future__ import annotations\nimport json\nimport sys\nimport click\nfrom pydantic import BaseModel, ValidationError\nfrom models import DeployConfig\n\nclass JSONModel(click.ParamType):\n    \"\"\"Load JSON from a string, @file, or @- (stdin), then validate via Pydantic.\"\"\"\n    name = \"json\"\n\n    def __init__(self, model: type[BaseModel]) -> None:\n        self.model = model\n\n    def convert(self, value, param, ctx):\n        if isinstance(value, self.model):   # already converted (e.g. a default)\n            return value\n        raw = self._read(value, param, ctx)\n        try:\n            data = json.loads(raw)\n        except json.JSONDecodeError as exc:\n            self.fail(\n                f\"invalid JSON: {exc.msg} (line {exc.lineno}, column {exc.colno})\",\n                param, ctx,\n            )\n        try:\n            return self.model.model_validate(data)\n        except ValidationError as exc:\n            self.fail(self._format(exc), param, ctx)\n\n    def _read(self, value: str, param, ctx) -> str:\n        if value == \"@-\":\n            return sys.stdin.read()\n        if value.startswith(\"@\"):\n            path = value[1:]\n            try:\n                with open(path, \"r\", encoding=\"utf-8\") as fh:\n                    return fh.read()\n            except OSError as exc:\n                self.fail(f\"cannot read {path!r}: {exc.strerror}\", param, ctx)\n        return value\n\n    @staticmethod\n    def _format(exc: ValidationError) -> str:\n        lines = []\n        for err in exc.errors():\n            loc = \".\".join(str(p) for p in err[\"loc\"]) or \"(root)\"\n            lines.append(f\"  {loc}: {err['msg']}\")\n        return \"validation failed:\\n\" + \"\\n\".join(lines)\n\n\n@click.command()\n@click.option(\"--config\", type=JSONModel(DeployConfig), required=True,\n              help=\"Deploy config as inline JSON, @file.json, or @- for stdin.\")\ndef deploy(config: DeployConfig) -> None:\n    click.echo(\n        f\"deploying {config.name} x{config.replicas} \"\n        f\"(cpu={config.resources.cpu}, mem={config.resources.memory_mb}MB)\"\n    )\n\nif __name__ == \"__main__\":\n    deploy()\n",[14,2983,2984,2989,2999,3006,3012,3018,3029,3041,3045,3062,3067,3077,3081,3096,3109,3113,3123,3142,3149,3161,3168,3178,3190,3198,3239,3244,3249,3255,3264,3275,3288,3293,3313,3327,3335,3348,3363,3371,3404,3413,3427,3464,3471,3476,3484,3498,3508,3520,3552,3584,3605,3610,3615,3623,3654,3667,3681,3687,3716,3744,3750,3755,3768],{"__ignoreMap":131},[135,2985,2986],{"class":137,"line":138},[135,2987,2988],{"class":669},"# cli.py\n",[135,2990,2991,2993,2995,2997],{"class":137,"line":152},[135,2992,334],{"class":325},[135,2994,2586],{"class":350},[135,2996,2589],{"class":325},[135,2998,2592],{"class":141},[135,3000,3001,3003],{"class":137,"line":162},[135,3002,326],{"class":325},[135,3004,3005],{"class":141}," json\n",[135,3007,3008,3010],{"class":137,"line":171},[135,3009,326],{"class":325},[135,3011,329],{"class":141},[135,3013,3014,3016],{"class":137,"line":180},[135,3015,326],{"class":325},[135,3017,2244],{"class":141},[135,3019,3020,3022,3024,3026],{"class":137,"line":187},[135,3021,334],{"class":325},[135,3023,1567],{"class":141},[135,3025,326],{"class":325},[135,3027,3028],{"class":141}," BaseModel, ValidationError\n",[135,3030,3031,3033,3036,3038],{"class":137,"line":201},[135,3032,334],{"class":325},[135,3034,3035],{"class":141}," models ",[135,3037,326],{"class":325},[135,3039,3040],{"class":141}," DeployConfig\n",[135,3042,3043],{"class":137,"line":210},[135,3044,184],{"emptyLinePlaceholder":183},[135,3046,3047,3049,3052,3054,3056,3058,3060],{"class":137,"line":215},[135,3048,1581],{"class":325},[135,3050,3051],{"class":145}," JSONModel",[135,3053,544],{"class":141},[135,3055,2484],{"class":145},[135,3057,61],{"class":141},[135,3059,1496],{"class":145},[135,3061,986],{"class":141},[135,3063,3064],{"class":137,"line":225},[135,3065,3066],{"class":158},"    \"\"\"Load JSON from a string, @file, or @- (stdin), then validate via Pydantic.\"\"\"\n",[135,3068,3069,3072,3074],{"class":137,"line":236},[135,3070,3071],{"class":141},"    name ",[135,3073,516],{"class":325},[135,3075,3076],{"class":158}," \"json\"\n",[135,3078,3079],{"class":137,"line":606},[135,3080,184],{"emptyLinePlaceholder":183},[135,3082,3083,3085,3088,3091,3094],{"class":137,"line":619},[135,3084,1777],{"class":325},[135,3086,3087],{"class":350}," __init__",[135,3089,3090],{"class":141},"(self, model: type[BaseModel]) -> ",[135,3092,3093],{"class":350},"None",[135,3095,360],{"class":141},[135,3097,3098,3101,3104,3106],{"class":137,"line":1752},[135,3099,3100],{"class":350},"        self",[135,3102,3103],{"class":141},".model ",[135,3105,516],{"class":325},[135,3107,3108],{"class":141}," model\n",[135,3110,3111],{"class":137,"line":1765},[135,3112,184],{"emptyLinePlaceholder":183},[135,3114,3115,3117,3120],{"class":137,"line":1774},[135,3116,1777],{"class":325},[135,3118,3119],{"class":145}," convert",[135,3121,3122],{"class":141},"(self, value, param, ctx):\n",[135,3124,3125,3127,3130,3133,3136,3139],{"class":137,"line":1795},[135,3126,1798],{"class":325},[135,3128,3129],{"class":350}," isinstance",[135,3131,3132],{"class":141},"(value, ",[135,3134,3135],{"class":350},"self",[135,3137,3138],{"class":141},".model):   ",[135,3140,3141],{"class":669},"# already converted (e.g. a default)\n",[135,3143,3144,3147],{"class":137,"line":1830},[135,3145,3146],{"class":325},"            return",[135,3148,2123],{"class":141},[135,3150,3151,3154,3156,3158],{"class":137,"line":1846},[135,3152,3153],{"class":141},"        raw ",[135,3155,516],{"class":325},[135,3157,1898],{"class":350},[135,3159,3160],{"class":141},"._read(value, param, ctx)\n",[135,3162,3163,3166],{"class":137,"line":1854},[135,3164,3165],{"class":325},"        try",[135,3167,360],{"class":141},[135,3169,3170,3173,3175],{"class":137,"line":1859},[135,3171,3172],{"class":141},"            data ",[135,3174,516],{"class":325},[135,3176,3177],{"class":141}," json.loads(raw)\n",[135,3179,3180,3183,3186,3188],{"class":137,"line":1877},[135,3181,3182],{"class":325},"        except",[135,3184,3185],{"class":141}," json.JSONDecodeError ",[135,3187,2419],{"class":325},[135,3189,2422],{"class":141},[135,3191,3192,3195],{"class":137,"line":1893},[135,3193,3194],{"class":350},"            self",[135,3196,3197],{"class":141},".fail(\n",[135,3199,3200,3203,3206,3208,3211,3213,3216,3218,3221,3223,3226,3228,3231,3233,3236],{"class":137,"line":1926},[135,3201,3202],{"class":325},"                f",[135,3204,3205],{"class":158},"\"invalid JSON: ",[135,3207,574],{"class":350},[135,3209,3210],{"class":141},"exc.msg",[135,3212,586],{"class":350},[135,3214,3215],{"class":158}," (line ",[135,3217,574],{"class":350},[135,3219,3220],{"class":141},"exc.lineno",[135,3222,586],{"class":350},[135,3224,3225],{"class":158},", column ",[135,3227,574],{"class":350},[135,3229,3230],{"class":141},"exc.colno",[135,3232,586],{"class":350},[135,3234,3235],{"class":158},")\"",[135,3237,3238],{"class":141},",\n",[135,3240,3241],{"class":137,"line":1940},[135,3242,3243],{"class":141},"                param, ctx,\n",[135,3245,3246],{"class":137,"line":2906},[135,3247,3248],{"class":141},"            )\n",[135,3250,3251,3253],{"class":137,"line":2931},[135,3252,3165],{"class":325},[135,3254,360],{"class":141},[135,3256,3257,3259,3261],{"class":137,"line":2944},[135,3258,3146],{"class":325},[135,3260,1898],{"class":350},[135,3262,3263],{"class":141},".model.model_validate(data)\n",[135,3265,3267,3269,3271,3273],{"class":137,"line":3266},29,[135,3268,3182],{"class":325},[135,3270,2416],{"class":141},[135,3272,2419],{"class":325},[135,3274,2422],{"class":141},[135,3276,3278,3280,3283,3285],{"class":137,"line":3277},30,[135,3279,3194],{"class":350},[135,3281,3282],{"class":141},".fail(",[135,3284,3135],{"class":350},[135,3286,3287],{"class":141},"._format(exc), param, ctx)\n",[135,3289,3291],{"class":137,"line":3290},31,[135,3292,184],{"emptyLinePlaceholder":183},[135,3294,3296,3298,3301,3304,3306,3309,3311],{"class":137,"line":3295},32,[135,3297,1777],{"class":325},[135,3299,3300],{"class":145}," _read",[135,3302,3303],{"class":141},"(self, value: ",[135,3305,1663],{"class":350},[135,3307,3308],{"class":141},", param, ctx) -> ",[135,3310,1663],{"class":350},[135,3312,360],{"class":141},[135,3314,3316,3318,3320,3322,3325],{"class":137,"line":3315},33,[135,3317,1798],{"class":325},[135,3319,2096],{"class":141},[135,3321,1815],{"class":325},[135,3323,3324],{"class":158}," \"@-\"",[135,3326,360],{"class":141},[135,3328,3330,3332],{"class":137,"line":3329},34,[135,3331,3146],{"class":325},[135,3333,3334],{"class":141}," sys.stdin.read()\n",[135,3336,3338,3340,3343,3346],{"class":137,"line":3337},35,[135,3339,1798],{"class":325},[135,3341,3342],{"class":141}," value.startswith(",[135,3344,3345],{"class":158},"\"@\"",[135,3347,986],{"class":141},[135,3349,3351,3354,3356,3359,3361],{"class":137,"line":3350},36,[135,3352,3353],{"class":141},"            path ",[135,3355,516],{"class":325},[135,3357,3358],{"class":141}," value[",[135,3360,522],{"class":350},[135,3362,525],{"class":141},[135,3364,3366,3369],{"class":137,"line":3365},37,[135,3367,3368],{"class":325},"            try",[135,3370,360],{"class":141},[135,3372,3374,3377,3380,3383,3386,3388,3391,3393,3396,3399,3401],{"class":137,"line":3373},38,[135,3375,3376],{"class":325},"                with",[135,3378,3379],{"class":350}," open",[135,3381,3382],{"class":141},"(path, ",[135,3384,3385],{"class":158},"\"r\"",[135,3387,861],{"class":141},[135,3389,3390],{"class":914},"encoding",[135,3392,516],{"class":325},[135,3394,3395],{"class":158},"\"utf-8\"",[135,3397,3398],{"class":141},") ",[135,3400,2419],{"class":325},[135,3402,3403],{"class":141}," fh:\n",[135,3405,3407,3410],{"class":137,"line":3406},39,[135,3408,3409],{"class":325},"                    return",[135,3411,3412],{"class":141}," fh.read()\n",[135,3414,3416,3419,3422,3425],{"class":137,"line":3415},40,[135,3417,3418],{"class":325},"            except",[135,3420,3421],{"class":350}," OSError",[135,3423,3424],{"class":325}," as",[135,3426,2422],{"class":141},[135,3428,3430,3433,3435,3437,3440,3442,3445,3448,3450,3452,3454,3457,3459,3461],{"class":137,"line":3429},41,[135,3431,3432],{"class":350},"                self",[135,3434,3282],{"class":141},[135,3436,568],{"class":325},[135,3438,3439],{"class":158},"\"cannot read ",[135,3441,574],{"class":350},[135,3443,3444],{"class":141},"path",[135,3446,3447],{"class":325},"!r",[135,3449,586],{"class":350},[135,3451,2344],{"class":158},[135,3453,574],{"class":350},[135,3455,3456],{"class":141},"exc.strerror",[135,3458,586],{"class":350},[135,3460,589],{"class":158},[135,3462,3463],{"class":141},", param, ctx)\n",[135,3465,3467,3469],{"class":137,"line":3466},42,[135,3468,555],{"class":325},[135,3470,2123],{"class":141},[135,3472,3474],{"class":137,"line":3473},43,[135,3475,184],{"emptyLinePlaceholder":183},[135,3477,3479,3481],{"class":137,"line":3478},44,[135,3480,1768],{"class":145},[135,3482,3483],{"class":350},"staticmethod\n",[135,3485,3487,3489,3492,3494,3496],{"class":137,"line":3486},45,[135,3488,1777],{"class":325},[135,3490,3491],{"class":145}," _format",[135,3493,2258],{"class":141},[135,3495,1663],{"class":350},[135,3497,360],{"class":141},[135,3499,3501,3504,3506],{"class":137,"line":3500},46,[135,3502,3503],{"class":141},"        lines ",[135,3505,516],{"class":325},[135,3507,2272],{"class":141},[135,3509,3511,3514,3516,3518],{"class":137,"line":3510},47,[135,3512,3513],{"class":325},"        for",[135,3515,2280],{"class":141},[135,3517,933],{"class":325},[135,3519,2285],{"class":141},[135,3521,3523,3526,3528,3530,3532,3534,3536,3538,3540,3542,3544,3546,3548,3550],{"class":137,"line":3522},48,[135,3524,3525],{"class":141},"            loc ",[135,3527,516],{"class":325},[135,3529,2295],{"class":158},[135,3531,2298],{"class":141},[135,3533,1663],{"class":350},[135,3535,2303],{"class":141},[135,3537,927],{"class":325},[135,3539,2308],{"class":141},[135,3541,933],{"class":325},[135,3543,2313],{"class":141},[135,3545,2316],{"class":158},[135,3547,2319],{"class":141},[135,3549,1809],{"class":325},[135,3551,2324],{"class":158},[135,3553,3555,3558,3560,3562,3564,3566,3568,3570,3572,3574,3576,3578,3580,3582],{"class":137,"line":3554},49,[135,3556,3557],{"class":141},"            lines.append(",[135,3559,568],{"class":325},[135,3561,2334],{"class":158},[135,3563,574],{"class":350},[135,3565,2339],{"class":141},[135,3567,586],{"class":350},[135,3569,2344],{"class":158},[135,3571,574],{"class":350},[135,3573,2349],{"class":141},[135,3575,2352],{"class":158},[135,3577,583],{"class":141},[135,3579,586],{"class":350},[135,3581,589],{"class":158},[135,3583,550],{"class":141},[135,3585,3587,3589,3591,3593,3595,3597,3599,3601,3603],{"class":137,"line":3586},50,[135,3588,555],{"class":325},[135,3590,2367],{"class":158},[135,3592,2370],{"class":350},[135,3594,589],{"class":158},[135,3596,2375],{"class":325},[135,3598,2378],{"class":158},[135,3600,2370],{"class":350},[135,3602,589],{"class":158},[135,3604,2385],{"class":141},[135,3606,3608],{"class":137,"line":3607},51,[135,3609,184],{"emptyLinePlaceholder":183},[135,3611,3613],{"class":137,"line":3612},52,[135,3614,184],{"emptyLinePlaceholder":183},[135,3616,3618,3621],{"class":137,"line":3617},53,[135,3619,3620],{"class":145},"@click.command",[135,3622,2135],{"class":141},[135,3624,3626,3629,3631,3634,3636,3639,3641,3644,3647,3649,3652],{"class":137,"line":3625},54,[135,3627,3628],{"class":145},"@click.option",[135,3630,544],{"class":141},[135,3632,3633],{"class":158},"\"--config\"",[135,3635,861],{"class":141},[135,3637,3638],{"class":914},"type",[135,3640,516],{"class":325},[135,3642,3643],{"class":141},"JSONModel(DeployConfig), ",[135,3645,3646],{"class":914},"required",[135,3648,516],{"class":325},[135,3650,3651],{"class":350},"True",[135,3653,3238],{"class":141},[135,3655,3657,3660,3662,3665],{"class":137,"line":3656},55,[135,3658,3659],{"class":914},"              help",[135,3661,516],{"class":325},[135,3663,3664],{"class":158},"\"Deploy config as inline JSON, @file.json, or @- for stdin.\"",[135,3666,550],{"class":141},[135,3668,3670,3672,3674,3677,3679],{"class":137,"line":3669},56,[135,3671,493],{"class":325},[135,3673,2142],{"class":145},[135,3675,3676],{"class":141},"(config: DeployConfig) -> ",[135,3678,3093],{"class":350},[135,3680,360],{"class":141},[135,3682,3684],{"class":137,"line":3683},57,[135,3685,3686],{"class":141},"    click.echo(\n",[135,3688,3690,3693,3696,3698,3701,3703,3706,3708,3711,3713],{"class":137,"line":3689},58,[135,3691,3692],{"class":325},"        f",[135,3694,3695],{"class":158},"\"deploying ",[135,3697,574],{"class":350},[135,3699,3700],{"class":141},"config.name",[135,3702,586],{"class":350},[135,3704,3705],{"class":158}," x",[135,3707,574],{"class":350},[135,3709,3710],{"class":141},"config.replicas",[135,3712,586],{"class":350},[135,3714,3715],{"class":158}," \"\n",[135,3717,3719,3721,3724,3726,3729,3731,3734,3736,3739,3741],{"class":137,"line":3718},59,[135,3720,3692],{"class":325},[135,3722,3723],{"class":158},"\"(cpu=",[135,3725,574],{"class":350},[135,3727,3728],{"class":141},"config.resources.cpu",[135,3730,586],{"class":350},[135,3732,3733],{"class":158},", mem=",[135,3735,574],{"class":350},[135,3737,3738],{"class":141},"config.resources.memory_mb",[135,3740,586],{"class":350},[135,3742,3743],{"class":158},"MB)\"\n",[135,3745,3747],{"class":137,"line":3746},60,[135,3748,3749],{"class":141},"    )\n",[135,3751,3753],{"class":137,"line":3752},61,[135,3754,184],{"emptyLinePlaceholder":183},[135,3756,3758,3760,3762,3764,3766],{"class":137,"line":3757},62,[135,3759,347],{"class":325},[135,3761,351],{"class":350},[135,3763,354],{"class":325},[135,3765,357],{"class":158},[135,3767,360],{"class":141},[135,3769,3771],{"class":137,"line":3770},63,[135,3772,3773],{"class":141},"    deploy()\n",[10,3775,3776,3777,3780,3781,3783,3784,3786,3787,3790],{},"Calling ",[14,3778,3779],{},"self.fail()"," is the key detail: it raises ",[14,3782,1504],{}," under the hood, which Click renders as a usage error and exits with status ",[14,3785,2221],{},". The command body receives a validated ",[14,3788,3789],{},"DeployConfig"," and nothing else.",[36,3792,3794],{"id":3793},"shell-safe-input-quoting-files-and-stdin","Shell-safe input: quoting, files, and stdin",[10,3796,3797,3798,861,3800,861,3802,3804],{},"The hardest part of passing JSON on the command line is not Python — it's the shell. JSON is full of characters your shell wants to interpret: double quotes, spaces, ",[14,3799,574],{},[14,3801,586],{},[14,3803,643],{},", and backticks. A few rules keep you sane:",[41,3806,3807,3824,3837],{},[44,3808,3809,3812,3813,3816,3817,3820,3821,3823],{},[72,3810,3811],{},"Single-quote the whole payload."," In bash\u002Fzsh, ",[14,3814,3815],{},"'...'"," is literal, so ",[14,3818,3819],{},"--config '{\"name\": \"api\", \"replicas\": 3}'"," passes through untouched. Double quotes would let the shell expand ",[14,3822,643],{}," and backticks inside the JSON.",[44,3825,3826,3829,3830,3833,3834,61],{},[72,3827,3828],{},"For dynamic payloads, never build the string by hand."," If you are launching the CLI from another Python program, build the args as a list and use ",[14,3831,3832],{},"subprocess.run([...], shell=False)"," so there is no shell to quote for at all. If you must produce a shell command string, run each piece through ",[14,3835,3836],{},"shlex.quote()",[44,3838,3839,3842,3843,1993,3845,3847,3848,3851,3852,3855,3856,3859,3860,1993,3863,3866],{},[72,3840,3841],{},"For anything non-trivial, read from a file or stdin."," That is exactly what the ",[14,3844,2540],{},[14,3846,2543],{}," conventions above are for — they sidestep quoting entirely. ",[14,3849,3850],{},"--config @spec.json"," reads the file; ",[14,3853,3854],{},"cat spec.json | mytool deploy --config @-"," pipes it in. The leading ",[14,3857,3858],{},"@"," mirrors the convention ",[14,3861,3862],{},"curl",[14,3864,3865],{},"jq"," use, so it will feel familiar.",[10,3868,3869,3870,3873],{},"The file\u002Fstdin path also scales: a 4 KB nested config is miserable to inline but trivial as ",[14,3871,3872],{},"@spec.json",", and it keeps secrets out of your shell history.",[36,3875,3877],{"id":3876},"verifying-it-with-clicktesting","Verifying it with click.testing",[10,3879,3880,3881,3884],{},"Validation code earns its keep only if you test both the happy path and the failures. ",[14,3882,3883],{},"CliRunner"," invokes the command in-process and captures the exit code and output:",[126,3886,3888],{"className":316,"code":3887,"language":318,"meta":131,"style":131},"# test_cli.py\nimport json\nimport pytest\nfrom click.testing import CliRunner\nfrom pydantic import ValidationError\nfrom cli import deploy\nfrom models import DeployConfig\n\nVALID = {\n    \"name\": \"API-Gateway\",\n    \"replicas\": 3,\n    \"resources\": {\"cpu\": 4, \"memory_mb\": 512},\n    \"env\": {\"LOG_LEVEL\": \"info\"},\n    \"canary_percent\": 25,\n}\n\ndef test_model_lowercases_name():\n    assert DeployConfig.model_validate(VALID).name == \"api-gateway\"\n\ndef test_cross_field_rule():\n    with pytest.raises(ValidationError) as exc:\n        DeployConfig.model_validate({**VALID, \"replicas\": 1, \"canary_percent\": 10})\n    assert \"at least 2 replicas\" in str(exc.value)\n\ndef test_cli_valid_inline():\n    result = CliRunner().invoke(deploy, [\"--config\", json.dumps(VALID)])\n    assert result.exit_code == 0\n    assert \"deploying api-gateway x3\" in result.output\n\ndef test_cli_invalid_json():\n    result = CliRunner().invoke(deploy, [\"--config\", \"{not json}\"])\n    assert result.exit_code == 2\n    assert \"invalid JSON\" in result.output\n\ndef test_cli_validation_error():\n    bad = json.dumps({**VALID, \"resources\": {\"cpu\": 0, \"memory_mb\": 1}})\n    result = CliRunner().invoke(deploy, [\"--config\", bad])\n    assert result.exit_code == 2\n    assert \"resources.cpu\" in result.output\n\ndef test_cli_from_file(tmp_path):\n    p = tmp_path \u002F \"cfg.json\"\n    p.write_text(json.dumps(VALID), encoding=\"utf-8\")\n    result = CliRunner().invoke(deploy, [\"--config\", f\"@{p}\"])\n    assert result.exit_code == 0\n\ndef test_cli_from_stdin():\n    result = CliRunner().invoke(deploy, [\"--config\", \"@-\"], input=json.dumps(VALID))\n    assert result.exit_code == 0\n",[14,3889,3890,3895,3901,3908,3920,3930,3942,3952,3956,3966,3978,3990,4018,4035,4047,4052,4056,4066,4084,4088,4097,4109,4141,4157,4161,4170,4190,4201,4213,4217,4226,4244,4255,4266,4270,4279,4317,4330,4340,4351,4355,4365,4380,4398,4425,4435,4439,4448,4478],{"__ignoreMap":131},[135,3891,3892],{"class":137,"line":138},[135,3893,3894],{"class":669},"# test_cli.py\n",[135,3896,3897,3899],{"class":137,"line":152},[135,3898,326],{"class":325},[135,3900,3005],{"class":141},[135,3902,3903,3905],{"class":137,"line":162},[135,3904,326],{"class":325},[135,3906,3907],{"class":141}," pytest\n",[135,3909,3910,3912,3915,3917],{"class":137,"line":171},[135,3911,334],{"class":325},[135,3913,3914],{"class":141}," click.testing ",[135,3916,326],{"class":325},[135,3918,3919],{"class":141}," CliRunner\n",[135,3921,3922,3924,3926,3928],{"class":137,"line":180},[135,3923,334],{"class":325},[135,3925,1567],{"class":141},[135,3927,326],{"class":325},[135,3929,2067],{"class":141},[135,3931,3932,3934,3937,3939],{"class":137,"line":187},[135,3933,334],{"class":325},[135,3935,3936],{"class":141}," cli ",[135,3938,326],{"class":325},[135,3940,3941],{"class":141}," deploy\n",[135,3943,3944,3946,3948,3950],{"class":137,"line":201},[135,3945,334],{"class":325},[135,3947,3035],{"class":141},[135,3949,326],{"class":325},[135,3951,3040],{"class":141},[135,3953,3954],{"class":137,"line":210},[135,3955,184],{"emptyLinePlaceholder":183},[135,3957,3958,3961,3963],{"class":137,"line":215},[135,3959,3960],{"class":350},"VALID",[135,3962,2150],{"class":325},[135,3964,3965],{"class":141}," {\n",[135,3967,3968,3971,3973,3976],{"class":137,"line":225},[135,3969,3970],{"class":158},"    \"name\"",[135,3972,2344],{"class":141},[135,3974,3975],{"class":158},"\"API-Gateway\"",[135,3977,3238],{"class":141},[135,3979,3980,3983,3985,3988],{"class":137,"line":236},[135,3981,3982],{"class":158},"    \"replicas\"",[135,3984,2344],{"class":141},[135,3986,3987],{"class":350},"3",[135,3989,3238],{"class":141},[135,3991,3992,3995,3998,4001,4003,4005,4007,4010,4012,4015],{"class":137,"line":606},[135,3993,3994],{"class":158},"    \"resources\"",[135,3996,3997],{"class":141},": {",[135,3999,4000],{"class":158},"\"cpu\"",[135,4002,2344],{"class":141},[135,4004,1975],{"class":350},[135,4006,861],{"class":141},[135,4008,4009],{"class":158},"\"memory_mb\"",[135,4011,2344],{"class":141},[135,4013,4014],{"class":350},"512",[135,4016,4017],{"class":141},"},\n",[135,4019,4020,4023,4025,4028,4030,4033],{"class":137,"line":619},[135,4021,4022],{"class":158},"    \"env\"",[135,4024,3997],{"class":141},[135,4026,4027],{"class":158},"\"LOG_LEVEL\"",[135,4029,2344],{"class":141},[135,4031,4032],{"class":158},"\"info\"",[135,4034,4017],{"class":141},[135,4036,4037,4040,4042,4045],{"class":137,"line":1752},[135,4038,4039],{"class":158},"    \"canary_percent\"",[135,4041,2344],{"class":141},[135,4043,4044],{"class":350},"25",[135,4046,3238],{"class":141},[135,4048,4049],{"class":137,"line":1765},[135,4050,4051],{"class":141},"}\n",[135,4053,4054],{"class":137,"line":1774},[135,4055,184],{"emptyLinePlaceholder":183},[135,4057,4058,4060,4063],{"class":137,"line":1795},[135,4059,493],{"class":325},[135,4061,4062],{"class":145}," test_model_lowercases_name",[135,4064,4065],{"class":141},"():\n",[135,4067,4068,4071,4074,4076,4079,4081],{"class":137,"line":1830},[135,4069,4070],{"class":325},"    assert",[135,4072,4073],{"class":141}," DeployConfig.model_validate(",[135,4075,3960],{"class":350},[135,4077,4078],{"class":141},").name ",[135,4080,1815],{"class":325},[135,4082,4083],{"class":158}," \"api-gateway\"\n",[135,4085,4086],{"class":137,"line":1846},[135,4087,184],{"emptyLinePlaceholder":183},[135,4089,4090,4092,4095],{"class":137,"line":1854},[135,4091,493],{"class":325},[135,4093,4094],{"class":145}," test_cross_field_rule",[135,4096,4065],{"class":141},[135,4098,4099,4102,4105,4107],{"class":137,"line":1859},[135,4100,4101],{"class":325},"    with",[135,4103,4104],{"class":141}," pytest.raises(ValidationError) ",[135,4106,2419],{"class":325},[135,4108,2422],{"class":141},[135,4110,4111,4114,4117,4119,4121,4124,4126,4128,4130,4133,4135,4138],{"class":137,"line":1877},[135,4112,4113],{"class":141},"        DeployConfig.model_validate({",[135,4115,4116],{"class":325},"**",[135,4118,3960],{"class":350},[135,4120,861],{"class":141},[135,4122,4123],{"class":158},"\"replicas\"",[135,4125,2344],{"class":141},[135,4127,522],{"class":350},[135,4129,861],{"class":141},[135,4131,4132],{"class":158},"\"canary_percent\"",[135,4134,2344],{"class":141},[135,4136,4137],{"class":350},"10",[135,4139,4140],{"class":141},"})\n",[135,4142,4143,4145,4148,4151,4154],{"class":137,"line":1893},[135,4144,4070],{"class":325},[135,4146,4147],{"class":158}," \"at least 2 replicas\"",[135,4149,4150],{"class":325}," in",[135,4152,4153],{"class":350}," str",[135,4155,4156],{"class":141},"(exc.value)\n",[135,4158,4159],{"class":137,"line":1926},[135,4160,184],{"emptyLinePlaceholder":183},[135,4162,4163,4165,4168],{"class":137,"line":1940},[135,4164,493],{"class":325},[135,4166,4167],{"class":145}," test_cli_valid_inline",[135,4169,4065],{"class":141},[135,4171,4172,4175,4177,4180,4182,4185,4187],{"class":137,"line":2906},[135,4173,4174],{"class":141},"    result ",[135,4176,516],{"class":325},[135,4178,4179],{"class":141}," CliRunner().invoke(deploy, [",[135,4181,3633],{"class":158},[135,4183,4184],{"class":141},", json.dumps(",[135,4186,3960],{"class":350},[135,4188,4189],{"class":141},")])\n",[135,4191,4192,4194,4197,4199],{"class":137,"line":2931},[135,4193,4070],{"class":325},[135,4195,4196],{"class":141}," result.exit_code ",[135,4198,1815],{"class":325},[135,4200,599],{"class":350},[135,4202,4203,4205,4208,4210],{"class":137,"line":2944},[135,4204,4070],{"class":325},[135,4206,4207],{"class":158}," \"deploying api-gateway x3\"",[135,4209,4150],{"class":325},[135,4211,4212],{"class":141}," result.output\n",[135,4214,4215],{"class":137,"line":3266},[135,4216,184],{"emptyLinePlaceholder":183},[135,4218,4219,4221,4224],{"class":137,"line":3277},[135,4220,493],{"class":325},[135,4222,4223],{"class":145}," test_cli_invalid_json",[135,4225,4065],{"class":141},[135,4227,4228,4230,4232,4234,4236,4238,4241],{"class":137,"line":3290},[135,4229,4174],{"class":141},[135,4231,516],{"class":325},[135,4233,4179],{"class":141},[135,4235,3633],{"class":158},[135,4237,861],{"class":141},[135,4239,4240],{"class":158},"\"{not json}\"",[135,4242,4243],{"class":141},"])\n",[135,4245,4246,4248,4250,4252],{"class":137,"line":3295},[135,4247,4070],{"class":325},[135,4249,4196],{"class":141},[135,4251,1815],{"class":325},[135,4253,4254],{"class":350}," 2\n",[135,4256,4257,4259,4262,4264],{"class":137,"line":3315},[135,4258,4070],{"class":325},[135,4260,4261],{"class":158}," \"invalid JSON\"",[135,4263,4150],{"class":325},[135,4265,4212],{"class":141},[135,4267,4268],{"class":137,"line":3329},[135,4269,184],{"emptyLinePlaceholder":183},[135,4271,4272,4274,4277],{"class":137,"line":3337},[135,4273,493],{"class":325},[135,4275,4276],{"class":145}," test_cli_validation_error",[135,4278,4065],{"class":141},[135,4280,4281,4284,4286,4289,4291,4293,4295,4298,4300,4302,4304,4306,4308,4310,4312,4314],{"class":137,"line":3350},[135,4282,4283],{"class":141},"    bad ",[135,4285,516],{"class":325},[135,4287,4288],{"class":141}," json.dumps({",[135,4290,4116],{"class":325},[135,4292,3960],{"class":350},[135,4294,861],{"class":141},[135,4296,4297],{"class":158},"\"resources\"",[135,4299,3997],{"class":141},[135,4301,4000],{"class":158},[135,4303,2344],{"class":141},[135,4305,580],{"class":350},[135,4307,861],{"class":141},[135,4309,4009],{"class":158},[135,4311,2344],{"class":141},[135,4313,522],{"class":350},[135,4315,4316],{"class":141},"}})\n",[135,4318,4319,4321,4323,4325,4327],{"class":137,"line":3365},[135,4320,4174],{"class":141},[135,4322,516],{"class":325},[135,4324,4179],{"class":141},[135,4326,3633],{"class":158},[135,4328,4329],{"class":141},", bad])\n",[135,4331,4332,4334,4336,4338],{"class":137,"line":3373},[135,4333,4070],{"class":325},[135,4335,4196],{"class":141},[135,4337,1815],{"class":325},[135,4339,4254],{"class":350},[135,4341,4342,4344,4347,4349],{"class":137,"line":3406},[135,4343,4070],{"class":325},[135,4345,4346],{"class":158}," \"resources.cpu\"",[135,4348,4150],{"class":325},[135,4350,4212],{"class":141},[135,4352,4353],{"class":137,"line":3415},[135,4354,184],{"emptyLinePlaceholder":183},[135,4356,4357,4359,4362],{"class":137,"line":3429},[135,4358,493],{"class":325},[135,4360,4361],{"class":145}," test_cli_from_file",[135,4363,4364],{"class":141},"(tmp_path):\n",[135,4366,4367,4370,4372,4375,4377],{"class":137,"line":3466},[135,4368,4369],{"class":141},"    p ",[135,4371,516],{"class":325},[135,4373,4374],{"class":141}," tmp_path ",[135,4376,459],{"class":325},[135,4378,4379],{"class":158}," \"cfg.json\"\n",[135,4381,4382,4385,4387,4390,4392,4394,4396],{"class":137,"line":3473},[135,4383,4384],{"class":141},"    p.write_text(json.dumps(",[135,4386,3960],{"class":350},[135,4388,4389],{"class":141},"), ",[135,4391,3390],{"class":914},[135,4393,516],{"class":325},[135,4395,3395],{"class":158},[135,4397,550],{"class":141},[135,4399,4400,4402,4404,4406,4408,4410,4412,4415,4417,4419,4421,4423],{"class":137,"line":3478},[135,4401,4174],{"class":141},[135,4403,516],{"class":325},[135,4405,4179],{"class":141},[135,4407,3633],{"class":158},[135,4409,861],{"class":141},[135,4411,568],{"class":325},[135,4413,4414],{"class":158},"\"@",[135,4416,574],{"class":350},[135,4418,10],{"class":141},[135,4420,586],{"class":350},[135,4422,589],{"class":158},[135,4424,4243],{"class":141},[135,4426,4427,4429,4431,4433],{"class":137,"line":3486},[135,4428,4070],{"class":325},[135,4430,4196],{"class":141},[135,4432,1815],{"class":325},[135,4434,599],{"class":350},[135,4436,4437],{"class":137,"line":3500},[135,4438,184],{"emptyLinePlaceholder":183},[135,4440,4441,4443,4446],{"class":137,"line":3510},[135,4442,493],{"class":325},[135,4444,4445],{"class":145}," test_cli_from_stdin",[135,4447,4065],{"class":141},[135,4449,4450,4452,4454,4456,4458,4460,4463,4466,4469,4471,4474,4476],{"class":137,"line":3522},[135,4451,4174],{"class":141},[135,4453,516],{"class":325},[135,4455,4179],{"class":141},[135,4457,3633],{"class":158},[135,4459,861],{"class":141},[135,4461,4462],{"class":158},"\"@-\"",[135,4464,4465],{"class":141},"], ",[135,4467,4468],{"class":914},"input",[135,4470,516],{"class":325},[135,4472,4473],{"class":141},"json.dumps(",[135,4475,3960],{"class":350},[135,4477,1054],{"class":141},[135,4479,4480,4482,4484,4486],{"class":137,"line":3554},[135,4481,4070],{"class":325},[135,4483,4196],{"class":141},[135,4485,1815],{"class":325},[135,4487,599],{"class":350},[10,4489,4490,4491,4494,4495,4498,4499,4501,4502,4505,4506,61],{},"Run it with ",[14,4492,4493],{},"pytest -q",". Note how ",[14,4496,4497],{},"test_cli_validation_error"," asserts both the exit code ",[23,4500,764],{}," the field path ",[14,4503,4504],{},"resources.cpu"," — that nested location string is what makes the error actually useful, and it comes straight from Pydantic's ",[14,4507,4508],{},"err[\"loc\"]",[36,4510,4512],{"id":4511},"production-notes","Production notes",[41,4514,4515,4527,4544,4569],{},[44,4516,4517,765,4520,4522,4523,4526],{},[72,4518,4519],{},"Depth limits.",[14,4521,2199],{}," will happily parse deeply nested input. If the JSON comes from an untrusted source, cap the payload size before parsing (",[14,4524,4525],{},"len(raw) \u003C MAX",") — Python's parser is recursive and pathological nesting can exhaust the stack.",[44,4528,4529,4535,4536,4539,4540,4543],{},[72,4530,4531,4534],{},[14,4532,4533],{},"extra"," fields."," By default Pydantic ignores unknown keys. Set ",[14,4537,4538],{},"model_config = ConfigDict(extra=\"forbid\")"," if a typo'd key like ",[14,4541,4542],{},"\"replcas\""," should be a hard error rather than silently dropped — usually the right call for a CLI.",[44,4545,4546,4551,4552,4554,4555,4558,4559,4561,4562,4565,4566,61],{},[72,4547,4548,4550],{},[14,4549,3858],{}," collisions."," If a literal value could legitimately begin with ",[14,4553,3858],{},", document the convention and offer an explicit ",[14,4556,4557],{},"--config-file"," option as an alternative, the way ",[14,4560,3862],{}," distinguishes ",[14,4563,4564],{},"-d"," from ",[14,4567,4568],{},"-d @file",[44,4570,4571,765,4574,4576,4577,4580,4581,4583],{},[72,4572,4573],{},"Cross-platform stdin.",[14,4575,2543],{}," works the same on Windows, but pipe behavior in ",[14,4578,4579],{},"cmd.exe"," differs from PowerShell; prefer ",[14,4582,2540],{}," in cross-platform docs and CI.",[36,4585,1277],{"id":1276},[41,4587,4588,4594,4598],{},[44,4589,2447,4590],{},[97,4591,4593],{"href":4592},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002F","Advanced argument validation strategies",[44,4595,2447,4596],{},[97,4597,2450],{"href":1436},[44,4599,2459,4600],{},[97,4601,4603],{"href":4602},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Floading-yaml-configs-safely-in-cli-apps\u002F","Loading YAML configs safely in CLI apps",[1303,4605,4606],{},"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 .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}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 .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":131,"searchDepth":152,"depth":152,"links":4608},[4609,4610,4611,4612,4613,4614,4615],{"id":38,"depth":152,"text":39},{"id":2565,"depth":152,"text":2566},{"id":2957,"depth":152,"text":2958},{"id":3793,"depth":152,"text":3794},{"id":3876,"depth":152,"text":3877},{"id":4511,"depth":152,"text":4512},{"id":1276,"depth":152,"text":1277},"Parse and validate nested JSON arguments in Python CLIs using Click custom types, Pydantic models, and shell-safe encoding for complex config objects.","advanced",{},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fparsing-nested-json-arguments-in-python-clis",{"title":2489,"description":4616},"advanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fparsing-nested-json-arguments-in-python-clis\u002Findex",[4623,2484,2482,2481,4624],"json","shell","OKg-AbUFbdLu4-0xPIJ-E496-I_VZfTFza3c16_K2RA",{"id":4627,"title":4628,"body":4629,"date":1320,"description":5765,"difficulty":1322,"draft":1323,"extension":1324,"meta":5766,"navigation":183,"path":5767,"seo":5768,"stem":5769,"tags":5770,"updated":1320,"__hash__":5774},"content\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Findex.md","Handling Config Files and Env Vars in CLIs",{"type":7,"value":4630,"toc":5756},[4631,4634,4636,4679,4683,4689,4692,4793,4797,4814,4975,4979,4986,5562,5591,5599,5614,5618,5639,5667,5677,5679,5737,5739,5753],[10,4632,4633],{},"A real CLI reads settings from several places at once: a command-line flag, an environment variable, a project config file checked into the repo, a user config in your home directory, and hard-coded defaults. The hard part is not reading any one source — it is deciding which wins when two of them disagree. This page gives you a single, deterministic precedence chain and a runnable merge function that resolves it.",[36,4635,39],{"id":38},[41,4637,4638,4644,4657,4664],{},[44,4639,4640,4641,61],{},"Fix the precedence once and document it: ",[72,4642,4643],{},"CLI flags > env vars > project config > user config > defaults",[44,4645,4646,4647,4649,4650,4656],{},"Merge low-to-high into one plain ",[14,4648,2763],{},", then validate the result with a single ",[97,4651,4655],{"href":4652,"rel":4653},"https:\u002F\u002Fdocs.pydantic.dev\u002Flatest\u002F",[4654],"nofollow","Pydantic v2"," model so types and unknown keys are checked in one place.",[44,4658,4659,4660,4663],{},"Put user config under the XDG base directory (",[14,4661,4662],{},"~\u002F.config\u002F\u003Capp>\u002Fconfig.yaml","), and look for a project config in the current tree.",[44,4665,4666,4667,4670,4671,861,4674,4670,4677,61],{},"Coerce strings (env vars are always strings) by letting Pydantic do the work — ",[14,4668,4669],{},"\"5432\""," becomes ",[14,4672,4673],{},"5432",[14,4675,4676],{},"\"true\"",[14,4678,3651],{},[36,4680,4682],{"id":4681},"the-precedence-chain","The precedence chain",[10,4684,4685],{},[104,4686],{"alt":4687,"src":4688},"Configuration precedence from highest to lowest — CLI flags, environment variables, project config file, user config file, then defaults; each source is consulted left to right and the first to define a value wins.","\u002Fillustrations\u002Fconfig-precedence.svg",[10,4690,4691],{},"The single most important decision is the order. Higher-priority sources overwrite lower ones key by key. The rule of thumb: the closer a value is to the moment of invocation, the more it should win. A flag you typed this second beats an env var in your shell, which beats a file someone committed last month, which beats a file in your home directory, which beats the built-in default.",[4693,4694,4695,4714],"table",{},[4696,4697,4698],"thead",{},[4699,4700,4701,4705,4708,4711],"tr",{},[4702,4703,4704],"th",{},"Priority",[4702,4706,4707],{},"Source",[4702,4709,4710],{},"Example",[4702,4712,4713],{},"Why it wins",[4715,4716,4717,4734,4749,4764,4779],"tbody",{},[4699,4718,4719,4723,4726,4731],{},[4720,4721,4722],"td",{},"1 (highest)",[4720,4724,4725],{},"CLI flag",[4720,4727,4728],{},[14,4729,4730],{},"--port 9000",[4720,4732,4733],{},"Explicit, this invocation",[4699,4735,4736,4738,4741,4746],{},[4720,4737,2221],{},[4720,4739,4740],{},"Env var",[4720,4742,4743],{},[14,4744,4745],{},"MYCLI_PORT=9000",[4720,4747,4748],{},"Session\u002Fdeploy scoped",[4699,4750,4751,4753,4756,4761],{},[4720,4752,3987],{},[4720,4754,4755],{},"Project config",[4720,4757,4758],{},[14,4759,4760],{},".\u002Fmyapp.yaml",[4720,4762,4763],{},"Per-repo, shared with team",[4699,4765,4766,4768,4771,4776],{},[4720,4767,1975],{},[4720,4769,4770],{},"User config",[4720,4772,4773],{},[14,4774,4775],{},"~\u002F.config\u002Fmyapp\u002Fconfig.yaml",[4720,4777,4778],{},"Per-machine preference",[4699,4780,4781,4784,4787,4790],{},[4720,4782,4783],{},"5 (lowest)",[4720,4785,4786],{},"Defaults",[4720,4788,4789],{},"code constants",[4720,4791,4792],{},"Fallback",[36,4794,4796],{"id":4795},"where-config-files-live","Where config files live",[10,4798,4799,4800,4805,4806,4809,4810,4813],{},"Don't invent paths. On Linux and macOS, follow the ",[97,4801,4804],{"href":4802,"rel":4803},"https:\u002F\u002Fspecifications.freedesktop.org\u002Fbasedir-spec\u002Flatest\u002F",[4654],"XDG Base Directory spec",": user config lives in ",[14,4807,4808],{},"$XDG_CONFIG_HOME"," (default ",[14,4811,4812],{},"~\u002F.config","). The project config is whatever file you find walking up from the working directory. A small resolver keeps this honest:",[126,4815,4817],{"className":316,"code":4816,"language":318,"meta":131,"style":131},"from __future__ import annotations\nfrom pathlib import Path\nimport os\n\nAPP = \"myapp\"\n\ndef user_config_path() -> Path:\n    base = os.environ.get(\"XDG_CONFIG_HOME\") or str(Path.home() \u002F \".config\")\n    return Path(base) \u002F APP \u002F \"config.yaml\"\n\ndef project_config_path(start: Path | None = None) -> Path:\n    return (start or Path.cwd()) \u002F f\"{APP}.yaml\"\n",[14,4818,4819,4829,4841,4848,4852,4862,4866,4876,4905,4923,4927,4950],{"__ignoreMap":131},[135,4820,4821,4823,4825,4827],{"class":137,"line":138},[135,4822,334],{"class":325},[135,4824,2586],{"class":350},[135,4826,2589],{"class":325},[135,4828,2592],{"class":141},[135,4830,4831,4833,4836,4838],{"class":137,"line":152},[135,4832,334],{"class":325},[135,4834,4835],{"class":141}," pathlib ",[135,4837,326],{"class":325},[135,4839,4840],{"class":141}," Path\n",[135,4842,4843,4845],{"class":137,"line":162},[135,4844,326],{"class":325},[135,4846,4847],{"class":141}," os\n",[135,4849,4850],{"class":137,"line":171},[135,4851,184],{"emptyLinePlaceholder":183},[135,4853,4854,4857,4859],{"class":137,"line":180},[135,4855,4856],{"class":350},"APP",[135,4858,2150],{"class":325},[135,4860,4861],{"class":158}," \"myapp\"\n",[135,4863,4864],{"class":137,"line":187},[135,4865,184],{"emptyLinePlaceholder":183},[135,4867,4868,4870,4873],{"class":137,"line":201},[135,4869,493],{"class":325},[135,4871,4872],{"class":145}," user_config_path",[135,4874,4875],{"class":141},"() -> Path:\n",[135,4877,4878,4881,4883,4886,4889,4891,4893,4895,4898,4900,4903],{"class":137,"line":210},[135,4879,4880],{"class":141},"    base ",[135,4882,516],{"class":325},[135,4884,4885],{"class":141}," os.environ.get(",[135,4887,4888],{"class":158},"\"XDG_CONFIG_HOME\"",[135,4890,3398],{"class":141},[135,4892,1809],{"class":325},[135,4894,4153],{"class":350},[135,4896,4897],{"class":141},"(Path.home() ",[135,4899,459],{"class":325},[135,4901,4902],{"class":158}," \".config\"",[135,4904,550],{"class":141},[135,4906,4907,4909,4912,4914,4917,4920],{"class":137,"line":215},[135,4908,596],{"class":325},[135,4910,4911],{"class":141}," Path(base) ",[135,4913,459],{"class":325},[135,4915,4916],{"class":350}," APP",[135,4918,4919],{"class":325}," \u002F",[135,4921,4922],{"class":158}," \"config.yaml\"\n",[135,4924,4925],{"class":137,"line":225},[135,4926,184],{"emptyLinePlaceholder":183},[135,4928,4929,4931,4934,4937,4940,4943,4945,4947],{"class":137,"line":236},[135,4930,493],{"class":325},[135,4932,4933],{"class":145}," project_config_path",[135,4935,4936],{"class":141},"(start: Path ",[135,4938,4939],{"class":325},"|",[135,4941,4942],{"class":350}," None",[135,4944,2150],{"class":325},[135,4946,4942],{"class":350},[135,4948,4949],{"class":141},") -> Path:\n",[135,4951,4952,4954,4957,4959,4962,4964,4967,4969,4972],{"class":137,"line":606},[135,4953,596],{"class":325},[135,4955,4956],{"class":141}," (start ",[135,4958,1809],{"class":325},[135,4960,4961],{"class":141}," Path.cwd()) ",[135,4963,459],{"class":325},[135,4965,4966],{"class":325}," f",[135,4968,589],{"class":158},[135,4970,4971],{"class":350},"{APP}",[135,4973,4974],{"class":158},".yaml\"\n",[36,4976,4978],{"id":4977},"a-runnable-merge-function","A runnable merge function",[10,4980,4981,4982,4985],{},"Merge each source into one dict in priority order (lowest first so higher overwrites), then validate once. Keeping validation at the ",[23,4983,4984],{},"end"," means every source — file, env, or flag — is checked against the same schema and coerced to the same types. This snippet runs as-is:",[126,4987,4989],{"className":316,"code":4988,"language":318,"meta":131,"style":131},"from __future__ import annotations\nfrom pathlib import Path\nimport yaml\nfrom pydantic import BaseModel, ConfigDict, ValidationError\n\n\nclass AppConfig(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n    host: str = \"localhost\"\n    port: int = 8000\n    timeout: int = 10\n    verbose: bool = False\n\n\nDEFAULTS = {\"host\": \"localhost\", \"port\": 8000, \"timeout\": 10, \"verbose\": False}\nENV_MAP = {\n    \"MYCLI_HOST\": \"host\",\n    \"MYCLI_PORT\": \"port\",\n    \"MYCLI_TIMEOUT\": \"timeout\",\n    \"MYCLI_VERBOSE\": \"verbose\",\n}\n\n\ndef _read_yaml(path: Path) -> dict:\n    if not path.is_file():\n        return {}\n    data = yaml.safe_load(path.read_text(encoding=\"utf-8\"))\n    if data is None:\n        return {}\n    if not isinstance(data, dict):\n        raise ValueError(f\"{path}: top-level YAML must be a mapping\")\n    return data\n\n\ndef _from_env(environ: dict) -> dict:\n    return {key: environ[name] for name, key in ENV_MAP.items() if name in environ}\n\n\ndef merge_config(user_file: Path, project_file: Path,\n                 environ: dict, cli_flags: dict) -> AppConfig:\n    \"\"\"Precedence (low -> high): defaults \u003C user \u003C project \u003C env \u003C CLI.\"\"\"\n    merged: dict = {}\n    merged.update(DEFAULTS)\n    merged.update(_read_yaml(user_file))\n    merged.update(_read_yaml(project_file))\n    merged.update(_from_env(environ))\n    merged.update({k: v for k, v in cli_flags.items() if v is not None})\n    try:\n        return AppConfig.model_validate(merged)  # coerces \"3333\" -> 3333, \"true\" -> True\n    except ValidationError as exc:\n        raise SystemExit(f\"Bad merged config: {exc}\")\n",[14,4990,4991,5001,5011,5018,5029,5033,5037,5050,5069,5081,5093,5105,5118,5122,5126,5175,5184,5195,5206,5217,5228,5232,5236,5240,5254,5263,5270,5288,5302,5308,5323,5346,5353,5357,5361,5379,5409,5413,5417,5427,5442,5447,5458,5467,5472,5477,5482,5510,5517,5527,5538],{"__ignoreMap":131},[135,4992,4993,4995,4997,4999],{"class":137,"line":138},[135,4994,334],{"class":325},[135,4996,2586],{"class":350},[135,4998,2589],{"class":325},[135,5000,2592],{"class":141},[135,5002,5003,5005,5007,5009],{"class":137,"line":152},[135,5004,334],{"class":325},[135,5006,4835],{"class":141},[135,5008,326],{"class":325},[135,5010,4840],{"class":141},[135,5012,5013,5015],{"class":137,"line":162},[135,5014,326],{"class":325},[135,5016,5017],{"class":141}," yaml\n",[135,5019,5020,5022,5024,5026],{"class":137,"line":171},[135,5021,334],{"class":325},[135,5023,1567],{"class":141},[135,5025,326],{"class":325},[135,5027,5028],{"class":141}," BaseModel, ConfigDict, ValidationError\n",[135,5030,5031],{"class":137,"line":180},[135,5032,184],{"emptyLinePlaceholder":183},[135,5034,5035],{"class":137,"line":187},[135,5036,184],{"emptyLinePlaceholder":183},[135,5038,5039,5041,5044,5046,5048],{"class":137,"line":201},[135,5040,1581],{"class":325},[135,5042,5043],{"class":145}," AppConfig",[135,5045,544],{"class":141},[135,5047,1489],{"class":145},[135,5049,986],{"class":141},[135,5051,5052,5055,5057,5060,5062,5064,5067],{"class":137,"line":210},[135,5053,5054],{"class":141},"    model_config ",[135,5056,516],{"class":325},[135,5058,5059],{"class":141}," ConfigDict(",[135,5061,4533],{"class":914},[135,5063,516],{"class":325},[135,5065,5066],{"class":158},"\"forbid\"",[135,5068,550],{"class":141},[135,5070,5071,5074,5076,5078],{"class":137,"line":215},[135,5072,5073],{"class":141},"    host: ",[135,5075,1663],{"class":350},[135,5077,2150],{"class":325},[135,5079,5080],{"class":158}," \"localhost\"\n",[135,5082,5083,5086,5088,5090],{"class":137,"line":225},[135,5084,5085],{"class":141},"    port: ",[135,5087,387],{"class":350},[135,5089,2150],{"class":325},[135,5091,5092],{"class":350}," 8000\n",[135,5094,5095,5098,5100,5102],{"class":137,"line":236},[135,5096,5097],{"class":141},"    timeout: ",[135,5099,387],{"class":350},[135,5101,2150],{"class":325},[135,5103,5104],{"class":350}," 10\n",[135,5106,5107,5110,5113,5115],{"class":137,"line":606},[135,5108,5109],{"class":141},"    verbose: ",[135,5111,5112],{"class":350},"bool",[135,5114,2150],{"class":325},[135,5116,5117],{"class":350}," False\n",[135,5119,5120],{"class":137,"line":619},[135,5121,184],{"emptyLinePlaceholder":183},[135,5123,5124],{"class":137,"line":1752},[135,5125,184],{"emptyLinePlaceholder":183},[135,5127,5128,5131,5133,5136,5139,5141,5144,5146,5149,5151,5154,5156,5159,5161,5163,5165,5168,5170,5173],{"class":137,"line":1765},[135,5129,5130],{"class":350},"DEFAULTS",[135,5132,2150],{"class":325},[135,5134,5135],{"class":141}," {",[135,5137,5138],{"class":158},"\"host\"",[135,5140,2344],{"class":141},[135,5142,5143],{"class":158},"\"localhost\"",[135,5145,861],{"class":141},[135,5147,5148],{"class":158},"\"port\"",[135,5150,2344],{"class":141},[135,5152,5153],{"class":350},"8000",[135,5155,861],{"class":141},[135,5157,5158],{"class":158},"\"timeout\"",[135,5160,2344],{"class":141},[135,5162,4137],{"class":350},[135,5164,861],{"class":141},[135,5166,5167],{"class":158},"\"verbose\"",[135,5169,2344],{"class":141},[135,5171,5172],{"class":350},"False",[135,5174,4051],{"class":141},[135,5176,5177,5180,5182],{"class":137,"line":1774},[135,5178,5179],{"class":350},"ENV_MAP",[135,5181,2150],{"class":325},[135,5183,3965],{"class":141},[135,5185,5186,5189,5191,5193],{"class":137,"line":1795},[135,5187,5188],{"class":158},"    \"MYCLI_HOST\"",[135,5190,2344],{"class":141},[135,5192,5138],{"class":158},[135,5194,3238],{"class":141},[135,5196,5197,5200,5202,5204],{"class":137,"line":1830},[135,5198,5199],{"class":158},"    \"MYCLI_PORT\"",[135,5201,2344],{"class":141},[135,5203,5148],{"class":158},[135,5205,3238],{"class":141},[135,5207,5208,5211,5213,5215],{"class":137,"line":1846},[135,5209,5210],{"class":158},"    \"MYCLI_TIMEOUT\"",[135,5212,2344],{"class":141},[135,5214,5158],{"class":158},[135,5216,3238],{"class":141},[135,5218,5219,5222,5224,5226],{"class":137,"line":1854},[135,5220,5221],{"class":158},"    \"MYCLI_VERBOSE\"",[135,5223,2344],{"class":141},[135,5225,5167],{"class":158},[135,5227,3238],{"class":141},[135,5229,5230],{"class":137,"line":1859},[135,5231,4051],{"class":141},[135,5233,5234],{"class":137,"line":1877},[135,5235,184],{"emptyLinePlaceholder":183},[135,5237,5238],{"class":137,"line":1893},[135,5239,184],{"emptyLinePlaceholder":183},[135,5241,5242,5244,5247,5250,5252],{"class":137,"line":1926},[135,5243,493],{"class":325},[135,5245,5246],{"class":145}," _read_yaml",[135,5248,5249],{"class":141},"(path: Path) -> ",[135,5251,2763],{"class":350},[135,5253,360],{"class":141},[135,5255,5256,5258,5260],{"class":137,"line":1940},[135,5257,530],{"class":325},[135,5259,533],{"class":325},[135,5261,5262],{"class":141}," path.is_file():\n",[135,5264,5265,5267],{"class":137,"line":2906},[135,5266,555],{"class":325},[135,5268,5269],{"class":141}," {}\n",[135,5271,5272,5275,5277,5280,5282,5284,5286],{"class":137,"line":2931},[135,5273,5274],{"class":141},"    data ",[135,5276,516],{"class":325},[135,5278,5279],{"class":141}," yaml.safe_load(path.read_text(",[135,5281,3390],{"class":914},[135,5283,516],{"class":325},[135,5285,3395],{"class":158},[135,5287,1054],{"class":141},[135,5289,5290,5292,5295,5298,5300],{"class":137,"line":2944},[135,5291,530],{"class":325},[135,5293,5294],{"class":141}," data ",[135,5296,5297],{"class":325},"is",[135,5299,4942],{"class":350},[135,5301,360],{"class":141},[135,5303,5304,5306],{"class":137,"line":3266},[135,5305,555],{"class":325},[135,5307,5269],{"class":141},[135,5309,5310,5312,5314,5316,5319,5321],{"class":137,"line":3277},[135,5311,530],{"class":325},[135,5313,533],{"class":325},[135,5315,3129],{"class":350},[135,5317,5318],{"class":141},"(data, ",[135,5320,2763],{"class":350},[135,5322,986],{"class":141},[135,5324,5325,5327,5329,5331,5333,5335,5337,5339,5341,5344],{"class":137,"line":3290},[135,5326,2108],{"class":325},[135,5328,1836],{"class":350},[135,5330,544],{"class":141},[135,5332,568],{"class":325},[135,5334,589],{"class":158},[135,5336,574],{"class":350},[135,5338,3444],{"class":141},[135,5340,586],{"class":350},[135,5342,5343],{"class":158},": top-level YAML must be a mapping\"",[135,5345,550],{"class":141},[135,5347,5348,5350],{"class":137,"line":3295},[135,5349,596],{"class":325},[135,5351,5352],{"class":141}," data\n",[135,5354,5355],{"class":137,"line":3315},[135,5356,184],{"emptyLinePlaceholder":183},[135,5358,5359],{"class":137,"line":3329},[135,5360,184],{"emptyLinePlaceholder":183},[135,5362,5363,5365,5368,5371,5373,5375,5377],{"class":137,"line":3337},[135,5364,493],{"class":325},[135,5366,5367],{"class":145}," _from_env",[135,5369,5370],{"class":141},"(environ: ",[135,5372,2763],{"class":350},[135,5374,1788],{"class":141},[135,5376,2763],{"class":350},[135,5378,360],{"class":141},[135,5380,5381,5383,5386,5388,5391,5393,5396,5399,5401,5404,5406],{"class":137,"line":3350},[135,5382,596],{"class":325},[135,5384,5385],{"class":141}," {key: environ[name] ",[135,5387,927],{"class":325},[135,5389,5390],{"class":141}," name, key ",[135,5392,933],{"class":325},[135,5394,5395],{"class":350}," ENV_MAP",[135,5397,5398],{"class":141},".items() ",[135,5400,347],{"class":325},[135,5402,5403],{"class":141}," name ",[135,5405,933],{"class":325},[135,5407,5408],{"class":141}," environ}\n",[135,5410,5411],{"class":137,"line":3365},[135,5412,184],{"emptyLinePlaceholder":183},[135,5414,5415],{"class":137,"line":3373},[135,5416,184],{"emptyLinePlaceholder":183},[135,5418,5419,5421,5424],{"class":137,"line":3406},[135,5420,493],{"class":325},[135,5422,5423],{"class":145}," merge_config",[135,5425,5426],{"class":141},"(user_file: Path, project_file: Path,\n",[135,5428,5429,5432,5434,5437,5439],{"class":137,"line":3415},[135,5430,5431],{"class":141},"                 environ: ",[135,5433,2763],{"class":350},[135,5435,5436],{"class":141},", cli_flags: ",[135,5438,2763],{"class":350},[135,5440,5441],{"class":141},") -> AppConfig:\n",[135,5443,5444],{"class":137,"line":3429},[135,5445,5446],{"class":158},"    \"\"\"Precedence (low -> high): defaults \u003C user \u003C project \u003C env \u003C CLI.\"\"\"\n",[135,5448,5449,5452,5454,5456],{"class":137,"line":3466},[135,5450,5451],{"class":141},"    merged: ",[135,5453,2763],{"class":350},[135,5455,2150],{"class":325},[135,5457,5269],{"class":141},[135,5459,5460,5463,5465],{"class":137,"line":3473},[135,5461,5462],{"class":141},"    merged.update(",[135,5464,5130],{"class":350},[135,5466,550],{"class":141},[135,5468,5469],{"class":137,"line":3478},[135,5470,5471],{"class":141},"    merged.update(_read_yaml(user_file))\n",[135,5473,5474],{"class":137,"line":3486},[135,5475,5476],{"class":141},"    merged.update(_read_yaml(project_file))\n",[135,5478,5479],{"class":137,"line":3500},[135,5480,5481],{"class":141},"    merged.update(_from_env(environ))\n",[135,5483,5484,5487,5489,5492,5494,5497,5499,5502,5504,5506,5508],{"class":137,"line":3510},[135,5485,5486],{"class":141},"    merged.update({k: v ",[135,5488,927],{"class":325},[135,5490,5491],{"class":141}," k, v ",[135,5493,933],{"class":325},[135,5495,5496],{"class":141}," cli_flags.items() ",[135,5498,347],{"class":325},[135,5500,5501],{"class":141}," v ",[135,5503,5297],{"class":325},[135,5505,533],{"class":325},[135,5507,4942],{"class":350},[135,5509,4140],{"class":141},[135,5511,5512,5515],{"class":137,"line":3522},[135,5513,5514],{"class":325},"    try",[135,5516,360],{"class":141},[135,5518,5519,5521,5524],{"class":137,"line":3554},[135,5520,555],{"class":325},[135,5522,5523],{"class":141}," AppConfig.model_validate(merged)  ",[135,5525,5526],{"class":669},"# coerces \"3333\" -> 3333, \"true\" -> True\n",[135,5528,5529,5532,5534,5536],{"class":137,"line":3586},[135,5530,5531],{"class":325},"    except",[135,5533,2416],{"class":141},[135,5535,2419],{"class":325},[135,5537,2422],{"class":141},[135,5539,5540,5542,5544,5546,5548,5551,5553,5556,5558,5560],{"class":137,"line":3607},[135,5541,2108],{"class":325},[135,5543,625],{"class":350},[135,5545,544],{"class":141},[135,5547,568],{"class":325},[135,5549,5550],{"class":158},"\"Bad merged config: ",[135,5552,574],{"class":350},[135,5554,5555],{"class":141},"exc",[135,5557,586],{"class":350},[135,5559,589],{"class":158},[135,5561,550],{"class":141},[10,5563,5564,5565,861,5568,861,5571,5574,5575,861,5577,5579,5580,861,5583,5586,5587,5590],{},"Run it with a user file (",[14,5566,5567],{},"host",[14,5569,5570],{},"port",[14,5572,5573],{},"timeout","), a project file (",[14,5576,5567],{},[14,5578,5570],{},"), env vars (",[14,5581,5582],{},"MYCLI_PORT=3333",[14,5584,5585],{},"MYCLI_VERBOSE=true","), and a single ",[14,5588,5589],{},"--host flag-host"," flag, and you get:",[126,5592,5597],{"className":5593,"code":5595,"language":5596,"meta":131},[5594],"language-text","Final config: {'host': 'flag-host', 'port': 3333, 'timeout': 99, 'verbose': True}\n","text",[14,5598,5595],{"__ignoreMap":131},[10,5600,5601,5603,5604,5606,5607,5609,5610,5613],{},[14,5602,5567],{}," came from the flag, ",[14,5605,5570],{}," from the env (overriding both files), ",[14,5608,5573],{}," from the user file (no higher source set it), and ",[14,5611,5612],{},"verbose"," from the env — exactly the precedence table above.",[36,5615,5617],{"id":5616},"why-merge-then-validate","Why merge-then-validate",[10,5619,5620,5621,5624,5625,5627,5628,5631,5632,5635,5636,5638],{},"Two patterns compete here. You ",[23,5622,5623],{},"could"," validate each source separately and then merge typed objects, but that forces every source to be complete and duplicates the schema. The merge-then-validate approach treats every layer as a partial ",[14,5626,2763],{},", lets ",[14,5629,5630],{},"dict.update"," express precedence with zero ceremony, and runs ",[72,5633,5634],{},"one"," schema check on the final result. That single check is where type coercion and unknown-key rejection happen — see ",[97,5637,4593],{"href":4592}," for the validation patterns this leans on.",[10,5640,5641,5642,5644,5645,5648,5649,5651,5652,784,5654,459,5656,5659,5660,5662,5663,5666],{},"The one subtlety is type coercion. Environment variables are always strings, so ",[14,5643,5582],{}," arrives as ",[14,5646,5647],{},"\"3333\"",". Pydantic v2 coerces it to ",[14,5650,387],{}," during ",[14,5653,2203],{},[14,5655,4676],{},[14,5657,5658],{},"\"false\""," to ",[14,5661,5112],{},". Because coercion happens after the merge, you never have to parse types by hand per-source. Set ",[14,5664,5665],{},"extra=\"forbid\""," so a typo'd key fails loudly instead of being silently ignored.",[5668,5669,5670],"blockquote",{},[10,5671,5672,5673,5676],{},"We deliberately don't use ",[14,5674,5675],{},"pydantic-settings"," here. Building the merge by hand keeps the precedence explicit and testable, and avoids a dependency you may not want in a small CLI.",[36,5678,4512],{"id":4511},[41,5680,5681,5689,5699,5723],{},[44,5682,5683,765,5686,5688],{},[72,5684,5685],{},"Deep merge for nested config.",[14,5687,5630],{}," is shallow — a nested table in the project file replaces the whole nested table from the user file. If you need per-key merging inside nested mappings, recurse.",[44,5690,5691,5694,5695,5698],{},[72,5692,5693],{},"Boolean env vars."," Pydantic accepts ",[14,5696,5697],{},"true\u002Ffalse\u002F1\u002F0\u002Fyes\u002Fno"," for bools. Document which strings your users should set.",[44,5700,5701,5704,5705,5708,5709,1993,5712,5715,5716,5719,5720,61],{},[72,5702,5703],{},"Testing precedence."," Make ",[14,5706,5707],{},"merge_config"," take ",[14,5710,5711],{},"environ",[14,5713,5714],{},"cli_flags"," as arguments (as above) rather than reading ",[14,5717,5718],{},"os.environ"," directly — that makes precedence trivial to unit-test with ",[14,5721,5722],{},"pytest.mark.parametrize",[44,5724,5725,5728,5729,5732,5733,5736],{},[72,5726,5727],{},"TOML too."," The pattern is identical for TOML; swap ",[14,5730,5731],{},"yaml.safe_load"," for ",[14,5734,5735],{},"tomllib.load"," (stdlib since 3.11). The merge and validation layers don't change.",[36,5738,1277],{"id":1276},[41,5740,5741,5745,5749],{},[44,5742,2447,5743],{},[97,5744,2450],{"href":1436},[44,5746,2453,5747],{},[97,5748,4603],{"href":4602},[44,5750,2459,5751],{},[97,5752,4593],{"href":4592},[1303,5754,5755],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":131,"searchDepth":152,"depth":152,"links":5757},[5758,5759,5760,5761,5762,5763,5764],{"id":38,"depth":152,"text":39},{"id":4681,"depth":152,"text":4682},{"id":4795,"depth":152,"text":4796},{"id":4977,"depth":152,"text":4978},{"id":5616,"depth":152,"text":5617},{"id":4511,"depth":152,"text":4512},{"id":1276,"depth":152,"text":1277},"Implement a deterministic config hierarchy in Python CLIs — merge env vars, dotfiles, and YAML\u002FTOML configs with strict type safety and clear precedence rules.",{},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars",{"title":4628,"description":5765},"advanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Findex",[5771,5772,2482,5773],"config","env-vars","precedence","S2up6uHBNh-WjKNX--5pdZTAH1ra46Z9HpLbg9xoVaM",{"id":5776,"title":4603,"body":5777,"date":1320,"description":6831,"difficulty":1322,"draft":1323,"extension":1324,"meta":6832,"navigation":183,"path":6833,"seo":6834,"stem":6835,"tags":6836,"updated":1320,"__hash__":6838},"content\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Floading-yaml-configs-safely-in-cli-apps\u002Findex.md",{"type":7,"value":5778,"toc":6821},[5779,5789,5791,5840,5846,5857,5871,5895,5919,5939,6018,6024,6028,6042,6486,6492,6566,6572,6578,6582,6585,6635,6649,6672,6675,6681,6684,6688,6691,6738,6748,6750,6802,6804,6818],[10,5780,5781,5782,5785,5786,5788],{},"YAML is the default config format for most CLIs because it is readable and supports nesting. But the default way to parse it in Python — ",[14,5783,5784],{},"yaml.load()"," — can execute arbitrary code embedded in the file. If your CLI reads a config that a user, a CI system, or a teammate can write, parsing it unsafely turns a config file into a remote-code-execution vector. This page shows the one-line fix and the validation layer that turns a raw ",[14,5787,2763],{}," into a checked, typed config object.",[36,5790,39],{"id":38},[41,5792,5793,5810,5820,5834],{},[44,5794,5795,5796,5799,5800,861,5802,5805,5806,5809],{},"Always call ",[14,5797,5798],{},"yaml.safe_load()",". Never ",[14,5801,5784],{},[14,5803,5804],{},"yaml.FullLoader",", or ",[14,5807,5808],{},"yaml.UnsafeLoader"," on files you didn't generate.",[44,5811,5812,5815,5816,5819],{},[14,5813,5814],{},"safe_load"," refuses the YAML tags (",[14,5817,5818],{},"!!python\u002Fobject\u002F...",") that construct arbitrary Python objects — so a malicious file can't run code through your parser.",[44,5821,5822,5823,5826,5827,5830,5831,5833],{},"A parsed dict is still untrusted ",[23,5824,5825],{},"data",": validate it against a ",[97,5828,4655],{"href":4652,"rel":5829},[4654]," model with ",[14,5832,5665],{}," to catch wrong types, missing required keys, and typo'd extra keys.",[44,5835,5836,5837,5839],{},"Turn Pydantic's ",[14,5838,1500],{}," into a short, line-oriented message so the user knows exactly what to fix.",[10,5841,5842],{},[104,5843],{"alt":5844,"src":5845},"Two paths from config.yaml: safe_load yields a plain dict for Pydantic validation, while yaml.load\u002FFullLoader can construct arbitrary objects and risks code execution.","\u002Fillustrations\u002Fyaml-safe-vs-unsafe.svg",[36,5847,5849,5850,5852,5853,5856],{"id":5848},"why-safe_load-and-what-load-actually-does","Why ",[14,5851,5814],{},", and what ",[14,5854,5855],{},"load"," actually does",[10,5858,5859,5860,5863,5864,765,5867,5870],{},"PyYAML's full loader implements YAML tags that construct native Python objects. The tag ",[14,5861,5862],{},"!!python\u002Fobject\u002Fapply:os.system"," tells the loader to ",[23,5865,5866],{},"call",[14,5868,5869],{},"os.system"," with the given argument while parsing. So this is a valid YAML document:",[126,5872,5876],{"className":5873,"code":5874,"language":5875,"meta":131,"style":131},"language-yaml shiki shiki-themes github-light github-dark","# DO NOT load this with yaml.load()\n!!python\u002Fobject\u002Fapply:os.system ['echo pwned']\n","yaml",[14,5877,5878,5883],{"__ignoreMap":131},[135,5879,5880],{"class":137,"line":138},[135,5881,5882],{"class":669},"# DO NOT load this with yaml.load()\n",[135,5884,5885,5887,5890,5893],{"class":137,"line":152},[135,5886,5862],{"class":325},[135,5888,5889],{"class":141}," [",[135,5891,5892],{"class":158},"'echo pwned'",[135,5894,149],{"class":141},[10,5896,5897,5900,5901,459,5904,5907,5908,5910,5911,5914,5915,5918],{},[14,5898,5899],{},"yaml.load(text)"," — and ",[14,5902,5903],{},"FullLoader",[14,5905,5906],{},"UnsafeLoader"," — will execute that ",[14,5909,5869],{}," call. The attacker doesn't need your code to do anything; the parse step itself is the exploit. This is the YAML analogue of ",[14,5912,5913],{},"pickle.loads"," on untrusted input. CVE history is full of CLIs that shipped ",[14,5916,5917],{},"yaml.load"," on user config.",[10,5920,5921,5923,5924,5927,5928,5930,5931,5934,5935,5938],{},[14,5922,5798],{}," uses ",[14,5925,5926],{},"SafeLoader",", which only constructs standard scalars and containers — strings, ints, floats, bools, ",[14,5929,3093],{},", lists, and dicts. Encounter a ",[14,5932,5933],{},"!!python\u002F..."," tag and it raises ",[14,5936,5937],{},"ConstructorError"," instead of executing anything. Here is the difference, runnable:",[126,5940,5942],{"className":316,"code":5941,"language":318,"meta":131,"style":131},"import yaml\n\ndanger = \"!!python\u002Fobject\u002Fapply:os.system ['echo pwned']\\n\"\ntry:\n    yaml.safe_load(danger)\nexcept yaml.YAMLError as e:\n    print(\"safe_load refused unsafe tag:\", type(e).__name__)\n# -> safe_load refused unsafe tag: ConstructorError\n",[14,5943,5944,5950,5954,5969,5975,5980,5992,6013],{"__ignoreMap":131},[135,5945,5946,5948],{"class":137,"line":138},[135,5947,326],{"class":325},[135,5949,5017],{"class":141},[135,5951,5952],{"class":137,"line":152},[135,5953,184],{"emptyLinePlaceholder":183},[135,5955,5956,5959,5961,5964,5966],{"class":137,"line":162},[135,5957,5958],{"class":141},"danger ",[135,5960,516],{"class":325},[135,5962,5963],{"class":158}," \"!!python\u002Fobject\u002Fapply:os.system ['echo pwned']",[135,5965,2370],{"class":350},[135,5967,5968],{"class":158},"\"\n",[135,5970,5971,5973],{"class":137,"line":171},[135,5972,2399],{"class":325},[135,5974,360],{"class":141},[135,5976,5977],{"class":137,"line":180},[135,5978,5979],{"class":141},"    yaml.safe_load(danger)\n",[135,5981,5982,5984,5987,5989],{"class":137,"line":187},[135,5983,2413],{"class":325},[135,5985,5986],{"class":141}," yaml.YAMLError ",[135,5988,2419],{"class":325},[135,5990,5991],{"class":141}," e:\n",[135,5993,5994,5996,5998,6001,6003,6005,6008,6011],{"class":137,"line":201},[135,5995,563],{"class":350},[135,5997,544],{"class":141},[135,5999,6000],{"class":158},"\"safe_load refused unsafe tag:\"",[135,6002,861],{"class":141},[135,6004,3638],{"class":350},[135,6006,6007],{"class":141},"(e).",[135,6009,6010],{"class":350},"__name__",[135,6012,550],{"class":141},[135,6014,6015],{"class":137,"line":210},[135,6016,6017],{"class":669},"# -> safe_load refused unsafe tag: ConstructorError\n",[10,6019,6020,6021,6023],{},"The rule is absolute: for any file outside your own build process, use ",[14,6022,5814],{},". There is no flag combination that makes the unsafe loaders acceptable for user-supplied YAML.",[36,6025,6027],{"id":6026},"loading-and-validating-the-complete-pattern","Loading and validating: the complete pattern",[10,6029,6030,6031,6033,6034,6037,6038,6041],{},"Parsing safely gets you a ",[14,6032,2763],{},", but that dict is still arbitrary: it might be missing a required key, have ",[14,6035,6036],{},"port: \"five thousand\"",", or contain a typo like ",[14,6039,6040],{},"prot:",". Push it through a schema. This program runs end-to-end and is the pattern you should copy:",[126,6043,6045],{"className":316,"code":6044,"language":318,"meta":131,"style":131},"from __future__ import annotations\nfrom pathlib import Path\nimport yaml\nfrom pydantic import BaseModel, ConfigDict, ValidationError\n\n\nclass Settings(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")  # reject unknown keys\n    host: str                                   # required\n    port: int = 5432\n    timeout: int = 10\n    retries: int = 3\n    verbose: bool = False\n    tags: list[str] = []\n\n\ndef load_yaml(path: Path) -> dict:\n    text = path.read_text(encoding=\"utf-8\")\n    data = yaml.safe_load(text)            # SAFE: never yaml.load\n    if data is None:                       # empty file -> empty config\n        return {}\n    if not isinstance(data, dict):\n        raise ValueError(\n            f\"{path}: top-level YAML must be a mapping, got {type(data).__name__}\"\n        )\n    return data\n\n\ndef load_settings(path: Path) -> Settings:\n    raw = load_yaml(path)\n    try:\n        return Settings.model_validate(raw)\n    except ValidationError as exc:\n        lines = [f\"Invalid configuration in {path}:\"]\n        for err in exc.errors():\n            loc = \".\".join(str(p) for p in err[\"loc\"]) or \"(root)\"\n            lines.append(f\"  - {loc}: {err['msg']}\")\n        raise SystemExit(\"\\n\".join(lines))\n",[14,6046,6047,6057,6067,6073,6083,6087,6091,6104,6124,6133,6144,6154,6166,6176,6189,6193,6197,6210,6228,6240,6256,6262,6276,6285,6312,6317,6323,6327,6331,6341,6351,6357,6364,6374,6398,6408,6438,6469],{"__ignoreMap":131},[135,6048,6049,6051,6053,6055],{"class":137,"line":138},[135,6050,334],{"class":325},[135,6052,2586],{"class":350},[135,6054,2589],{"class":325},[135,6056,2592],{"class":141},[135,6058,6059,6061,6063,6065],{"class":137,"line":152},[135,6060,334],{"class":325},[135,6062,4835],{"class":141},[135,6064,326],{"class":325},[135,6066,4840],{"class":141},[135,6068,6069,6071],{"class":137,"line":162},[135,6070,326],{"class":325},[135,6072,5017],{"class":141},[135,6074,6075,6077,6079,6081],{"class":137,"line":171},[135,6076,334],{"class":325},[135,6078,1567],{"class":141},[135,6080,326],{"class":325},[135,6082,5028],{"class":141},[135,6084,6085],{"class":137,"line":180},[135,6086,184],{"emptyLinePlaceholder":183},[135,6088,6089],{"class":137,"line":187},[135,6090,184],{"emptyLinePlaceholder":183},[135,6092,6093,6095,6098,6100,6102],{"class":137,"line":201},[135,6094,1581],{"class":325},[135,6096,6097],{"class":145}," Settings",[135,6099,544],{"class":141},[135,6101,1489],{"class":145},[135,6103,986],{"class":141},[135,6105,6106,6108,6110,6112,6114,6116,6118,6121],{"class":137,"line":210},[135,6107,5054],{"class":141},[135,6109,516],{"class":325},[135,6111,5059],{"class":141},[135,6113,4533],{"class":914},[135,6115,516],{"class":325},[135,6117,5066],{"class":158},[135,6119,6120],{"class":141},")  ",[135,6122,6123],{"class":669},"# reject unknown keys\n",[135,6125,6126,6128,6130],{"class":137,"line":215},[135,6127,5073],{"class":141},[135,6129,1663],{"class":350},[135,6131,6132],{"class":669},"                                   # required\n",[135,6134,6135,6137,6139,6141],{"class":137,"line":225},[135,6136,5085],{"class":141},[135,6138,387],{"class":350},[135,6140,2150],{"class":325},[135,6142,6143],{"class":350}," 5432\n",[135,6145,6146,6148,6150,6152],{"class":137,"line":236},[135,6147,5097],{"class":141},[135,6149,387],{"class":350},[135,6151,2150],{"class":325},[135,6153,5104],{"class":350},[135,6155,6156,6159,6161,6163],{"class":137,"line":606},[135,6157,6158],{"class":141},"    retries: ",[135,6160,387],{"class":350},[135,6162,2150],{"class":325},[135,6164,6165],{"class":350}," 3\n",[135,6167,6168,6170,6172,6174],{"class":137,"line":619},[135,6169,5109],{"class":141},[135,6171,5112],{"class":350},[135,6173,2150],{"class":325},[135,6175,5117],{"class":350},[135,6177,6178,6181,6183,6185,6187],{"class":137,"line":1752},[135,6179,6180],{"class":141},"    tags: list[",[135,6182,1663],{"class":350},[135,6184,2750],{"class":141},[135,6186,516],{"class":325},[135,6188,2272],{"class":141},[135,6190,6191],{"class":137,"line":1765},[135,6192,184],{"emptyLinePlaceholder":183},[135,6194,6195],{"class":137,"line":1774},[135,6196,184],{"emptyLinePlaceholder":183},[135,6198,6199,6201,6204,6206,6208],{"class":137,"line":1795},[135,6200,493],{"class":325},[135,6202,6203],{"class":145}," load_yaml",[135,6205,5249],{"class":141},[135,6207,2763],{"class":350},[135,6209,360],{"class":141},[135,6211,6212,6215,6217,6220,6222,6224,6226],{"class":137,"line":1830},[135,6213,6214],{"class":141},"    text ",[135,6216,516],{"class":325},[135,6218,6219],{"class":141}," path.read_text(",[135,6221,3390],{"class":914},[135,6223,516],{"class":325},[135,6225,3395],{"class":158},[135,6227,550],{"class":141},[135,6229,6230,6232,6234,6237],{"class":137,"line":1846},[135,6231,5274],{"class":141},[135,6233,516],{"class":325},[135,6235,6236],{"class":141}," yaml.safe_load(text)            ",[135,6238,6239],{"class":669},"# SAFE: never yaml.load\n",[135,6241,6242,6244,6246,6248,6250,6253],{"class":137,"line":1854},[135,6243,530],{"class":325},[135,6245,5294],{"class":141},[135,6247,5297],{"class":325},[135,6249,4942],{"class":350},[135,6251,6252],{"class":141},":                       ",[135,6254,6255],{"class":669},"# empty file -> empty config\n",[135,6257,6258,6260],{"class":137,"line":1859},[135,6259,555],{"class":325},[135,6261,5269],{"class":141},[135,6263,6264,6266,6268,6270,6272,6274],{"class":137,"line":1877},[135,6265,530],{"class":325},[135,6267,533],{"class":325},[135,6269,3129],{"class":350},[135,6271,5318],{"class":141},[135,6273,2763],{"class":350},[135,6275,986],{"class":141},[135,6277,6278,6280,6282],{"class":137,"line":1893},[135,6279,2108],{"class":325},[135,6281,1836],{"class":350},[135,6283,6284],{"class":141},"(\n",[135,6286,6287,6290,6292,6294,6296,6298,6301,6304,6307,6310],{"class":137,"line":1926},[135,6288,6289],{"class":325},"            f",[135,6291,589],{"class":158},[135,6293,574],{"class":350},[135,6295,3444],{"class":141},[135,6297,586],{"class":350},[135,6299,6300],{"class":158},": top-level YAML must be a mapping, got ",[135,6302,6303],{"class":350},"{type",[135,6305,6306],{"class":141},"(data).",[135,6308,6309],{"class":350},"__name__}",[135,6311,5968],{"class":158},[135,6313,6314],{"class":137,"line":1940},[135,6315,6316],{"class":141},"        )\n",[135,6318,6319,6321],{"class":137,"line":2906},[135,6320,596],{"class":325},[135,6322,5352],{"class":141},[135,6324,6325],{"class":137,"line":2931},[135,6326,184],{"emptyLinePlaceholder":183},[135,6328,6329],{"class":137,"line":2944},[135,6330,184],{"emptyLinePlaceholder":183},[135,6332,6333,6335,6338],{"class":137,"line":3266},[135,6334,493],{"class":325},[135,6336,6337],{"class":145}," load_settings",[135,6339,6340],{"class":141},"(path: Path) -> Settings:\n",[135,6342,6343,6346,6348],{"class":137,"line":3277},[135,6344,6345],{"class":141},"    raw ",[135,6347,516],{"class":325},[135,6349,6350],{"class":141}," load_yaml(path)\n",[135,6352,6353,6355],{"class":137,"line":3290},[135,6354,5514],{"class":325},[135,6356,360],{"class":141},[135,6358,6359,6361],{"class":137,"line":3295},[135,6360,555],{"class":325},[135,6362,6363],{"class":141}," Settings.model_validate(raw)\n",[135,6365,6366,6368,6370,6372],{"class":137,"line":3315},[135,6367,5531],{"class":325},[135,6369,2416],{"class":141},[135,6371,2419],{"class":325},[135,6373,2422],{"class":141},[135,6375,6376,6378,6380,6382,6384,6387,6389,6391,6393,6396],{"class":137,"line":3329},[135,6377,3503],{"class":141},[135,6379,516],{"class":325},[135,6381,5889],{"class":141},[135,6383,568],{"class":325},[135,6385,6386],{"class":158},"\"Invalid configuration in ",[135,6388,574],{"class":350},[135,6390,3444],{"class":141},[135,6392,586],{"class":350},[135,6394,6395],{"class":158},":\"",[135,6397,149],{"class":141},[135,6399,6400,6402,6404,6406],{"class":137,"line":3337},[135,6401,3513],{"class":325},[135,6403,2280],{"class":141},[135,6405,933],{"class":325},[135,6407,2285],{"class":141},[135,6409,6410,6412,6414,6416,6418,6420,6422,6424,6426,6428,6430,6432,6434,6436],{"class":137,"line":3350},[135,6411,3525],{"class":141},[135,6413,516],{"class":325},[135,6415,2295],{"class":158},[135,6417,2298],{"class":141},[135,6419,1663],{"class":350},[135,6421,2303],{"class":141},[135,6423,927],{"class":325},[135,6425,2308],{"class":141},[135,6427,933],{"class":325},[135,6429,2313],{"class":141},[135,6431,2316],{"class":158},[135,6433,2319],{"class":141},[135,6435,1809],{"class":325},[135,6437,2324],{"class":158},[135,6439,6440,6442,6444,6447,6449,6451,6453,6455,6457,6459,6461,6463,6465,6467],{"class":137,"line":3365},[135,6441,3557],{"class":141},[135,6443,568],{"class":325},[135,6445,6446],{"class":158},"\"  - ",[135,6448,574],{"class":350},[135,6450,2339],{"class":141},[135,6452,586],{"class":350},[135,6454,2344],{"class":158},[135,6456,574],{"class":350},[135,6458,2349],{"class":141},[135,6460,2352],{"class":158},[135,6462,583],{"class":141},[135,6464,586],{"class":350},[135,6466,589],{"class":158},[135,6468,550],{"class":141},[135,6470,6471,6473,6475,6477,6479,6481,6483],{"class":137,"line":3373},[135,6472,2108],{"class":325},[135,6474,625],{"class":350},[135,6476,544],{"class":141},[135,6478,589],{"class":158},[135,6480,2370],{"class":350},[135,6482,589],{"class":158},[135,6484,6485],{"class":141},".join(lines))\n",[10,6487,6488,6489,473],{},"Given this ",[14,6490,6491],{},"sample.yaml",[126,6493,6495],{"className":5873,"code":6494,"language":5875,"meta":131,"style":131},"host: db.internal\nport: 5432\ntimeout: 30\nretries: 3\nverbose: true\ntags:\n  - prod\n  - east\n",[14,6496,6497,6507,6516,6525,6535,6544,6551,6559],{"__ignoreMap":131},[135,6498,6499,6502,6504],{"class":137,"line":138},[135,6500,5567],{"class":6501},"s9eBZ",[135,6503,2344],{"class":141},[135,6505,6506],{"class":158},"db.internal\n",[135,6508,6509,6511,6513],{"class":137,"line":152},[135,6510,5570],{"class":6501},[135,6512,2344],{"class":141},[135,6514,6515],{"class":350},"5432\n",[135,6517,6518,6520,6522],{"class":137,"line":162},[135,6519,5573],{"class":6501},[135,6521,2344],{"class":141},[135,6523,6524],{"class":350},"30\n",[135,6526,6527,6530,6532],{"class":137,"line":171},[135,6528,6529],{"class":6501},"retries",[135,6531,2344],{"class":141},[135,6533,6534],{"class":350},"3\n",[135,6536,6537,6539,6541],{"class":137,"line":180},[135,6538,5612],{"class":6501},[135,6540,2344],{"class":141},[135,6542,6543],{"class":350},"true\n",[135,6545,6546,6549],{"class":137,"line":187},[135,6547,6548],{"class":6501},"tags",[135,6550,360],{"class":141},[135,6552,6553,6556],{"class":137,"line":201},[135,6554,6555],{"class":141},"  - ",[135,6557,6558],{"class":158},"prod\n",[135,6560,6561,6563],{"class":137,"line":210},[135,6562,6555],{"class":141},[135,6564,6565],{"class":158},"east\n",[10,6567,6568,6571],{},[14,6569,6570],{},"load_settings(Path(\"sample.yaml\"))"," returns a fully typed object:",[126,6573,6576],{"className":6574,"code":6575,"language":5596,"meta":131},[5594],"Loaded & validated: {'host': 'db.internal', 'port': 5432, 'timeout': 30,\n 'retries': 3, 'verbose': True, 'tags': ['prod', 'east']}\n",[14,6577,6575],{"__ignoreMap":131},[36,6579,6581],{"id":6580},"handling-missing-extra-and-wrong-keys","Handling missing, extra, and wrong keys",[10,6583,6584],{},"The schema decides three failure modes, and each should produce a clear message rather than a stack trace:",[41,6586,6587,6598,6613],{},[44,6588,6589,1989,6592,6594,6595,61],{},[72,6590,6591],{},"Missing required key",[14,6593,5567],{}," has no default, so a file without it fails validation with ",[14,6596,6597],{},"Field required",[44,6599,6600,1989,6603,6605,6606,6609,6610,6612],{},[72,6601,6602],{},"Extra\u002Ftypo'd key",[14,6604,5665],{}," rejects unknown keys. A file with ",[14,6607,6608],{},"prot: 5432"," (typo for ",[14,6611,5570],{},") fails instead of silently dropping the value. This is the single most valuable guard: silent extra-key drops are how users spend an hour wondering why their setting \"isn't working.\"",[44,6614,6615,1989,6618,6621,6622,6624,6625,6628,6629,6631,6632,6634],{},[72,6616,6617],{},"Wrong type",[14,6619,6620],{},"port: \"abc\""," fails because it can't coerce to ",[14,6623,387],{},". (Note Pydantic ",[23,6626,6627],{},"will"," coerce ",[14,6630,4669],{}," → ",[14,6633,4673],{},", which is what you want for values that arrive as strings.)",[10,6636,111,6637,6640,6641,6644,6645,6648],{},[14,6638,6639],{},"load_settings"," error handler above flattens ",[14,6642,6643],{},"ValidationError.errors()"," into one line per problem. Feed it a ",[14,6646,6647],{},"bad.yaml"," containing a typo:",[126,6650,6652],{"className":5873,"code":6651,"language":5875,"meta":131,"style":131},"host: x\nprot: 5432\n",[14,6653,6654,6663],{"__ignoreMap":131},[135,6655,6656,6658,6660],{"class":137,"line":138},[135,6657,5567],{"class":6501},[135,6659,2344],{"class":141},[135,6661,6662],{"class":158},"x\n",[135,6664,6665,6668,6670],{"class":137,"line":152},[135,6666,6667],{"class":6501},"prot",[135,6669,2344],{"class":141},[135,6671,6515],{"class":350},[10,6673,6674],{},"and you get an actionable message, then a non-zero exit:",[126,6676,6679],{"className":6677,"code":6678,"language":5596,"meta":131},[5594],"Invalid configuration in bad.yaml:\n  - prot: Extra inputs are not permitted\n",[14,6680,6678],{"__ignoreMap":131},[10,6682,6683],{},"That is the difference between a CLI a user can debug and one that dumps a 30-line Pydantic traceback at them.",[36,6685,6687],{"id":6686},"loading-from-the-right-path","Loading from the right path",[10,6689,6690],{},"Read config from a predictable, documented location, not the current directory by accident. Resolve the path explicitly and handle \"file absent\" as \"use defaults,\" not as an error:",[126,6692,6694],{"className":316,"code":6693,"language":318,"meta":131,"style":131},"def load_config(path: Path) -> Settings:\n    if not path.is_file():\n        return Settings(host=\"localhost\")  # documented fallback\n    return load_settings(path)\n",[14,6695,6696,6705,6713,6731],{"__ignoreMap":131},[135,6697,6698,6700,6703],{"class":137,"line":138},[135,6699,493],{"class":325},[135,6701,6702],{"class":145}," load_config",[135,6704,6340],{"class":141},[135,6706,6707,6709,6711],{"class":137,"line":152},[135,6708,530],{"class":325},[135,6710,533],{"class":325},[135,6712,5262],{"class":141},[135,6714,6715,6717,6720,6722,6724,6726,6728],{"class":137,"line":162},[135,6716,555],{"class":325},[135,6718,6719],{"class":141}," Settings(",[135,6721,5567],{"class":914},[135,6723,516],{"class":325},[135,6725,5143],{"class":158},[135,6727,6120],{"class":141},[135,6729,6730],{"class":669},"# documented fallback\n",[135,6732,6733,6735],{"class":137,"line":171},[135,6734,596],{"class":325},[135,6736,6737],{"class":141}," load_settings(path)\n",[10,6739,6740,6741,6744,6745,6747],{},"For where these paths should be — user config under ",[14,6742,6743],{},"~\u002F.config\u002F\u003Capp>\u002F",", project config in the repo — and how to combine a YAML file with environment variables and flags, see the precedence rules in the parent hub. The merge there feeds the same ",[14,6746,5814],{}," + Pydantic pipeline you built on this page.",[36,6749,4512],{"id":4511},[41,6751,6752,6771,6780,6796],{},[44,6753,6754,6759,6760,6763,6764,6766,6767,6770],{},[72,6755,6756,6758],{},[14,6757,5814],{}," only, every time."," Wrap parsing in one helper (like ",[14,6761,6762],{},"load_yaml"," above) so no caller can ever reach for ",[14,6765,5917],{},". A grep for ",[14,6768,6769],{},"yaml.load("," in CI catches regressions.",[44,6772,6773,6776,6777,6779],{},[72,6774,6775],{},"Audit your plugins."," A plugin that parses its own YAML with ",[14,6778,5917],{}," reopens the hole you closed. Hold third-party config readers to the same rule.",[44,6781,6782,6789,6790,4565,6793,6795],{},[72,6783,6784,6785,6788],{},"Catch ",[14,6786,6787],{},"yaml.YAMLError"," too."," Malformed YAML (bad indentation, duplicate keys) raises ",[14,6791,6792],{},"YAMLError",[14,6794,5814],{}," before validation runs. Catch it and report the file and line.",[44,6797,6798,6801],{},[72,6799,6800],{},"Don't trust nesting depth."," Deeply nested or huge YAML can be a denial-of-service vector. For files from fully untrusted sources, cap file size before parsing.",[36,6803,1277],{"id":1276},[41,6805,6806,6810,6814],{},[44,6807,2447,6808],{},[97,6809,2463],{"href":2462},[44,6811,2447,6812],{},[97,6813,2450],{"href":1436},[44,6815,2459,6816],{},[97,6817,4593],{"href":4592},[1303,6819,6820],{},"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 .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}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 .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":131,"searchDepth":152,"depth":152,"links":6822},[6823,6824,6826,6827,6828,6829,6830],{"id":38,"depth":152,"text":39},{"id":5848,"depth":152,"text":6825},"Why safe_load, and what load actually does",{"id":6026,"depth":152,"text":6027},{"id":6580,"depth":152,"text":6581},{"id":6686,"depth":152,"text":6687},{"id":4511,"depth":152,"text":4512},{"id":1276,"depth":152,"text":1277},"Load and validate YAML configuration files safely in Python CLI apps using pyyaml with Pydantic schema validation and secure load() best practices.",{},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Floading-yaml-configs-safely-in-cli-apps",{"title":4603,"description":6831},"advanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Floading-yaml-configs-safely-in-cli-apps\u002Findex",[5875,5771,6837,2482],"security","DmllFEJFFQY1poPfAA60JAIK_KF9OoJqKs9YkvLcjZY",{"id":6840,"title":2450,"body":6841,"date":1320,"description":6966,"difficulty":1322,"draft":1323,"extension":1324,"meta":6967,"navigation":183,"path":6968,"seo":6969,"stem":6970,"tags":6971,"updated":1320,"__hash__":6975},"content\u002Fadvanced-input-parsing-user-experience\u002Findex.md",{"type":7,"value":6842,"toc":6957},[6843,6846,6853,6859,6863,6868,6881,6885,6897,6901,6917,6921,6944,6948],[10,6844,6845],{},"A CLI lives or dies on the boundary where untrusted input meets your code. This track\ncovers that boundary in both directions: validating and merging everything the user\nthrows at your tool — flags, environment variables, config files — and rendering output\nthat reads clearly to humans while staying parseable for automation.",[10,6847,6848,6849,6852],{},"These patterns sit on top of a sound ",[97,6850,6851],{"href":1429},"architecture",".\nHere the focus is reliability and developer experience at the edges.",[10,6854,6855],{},[104,6856],{"alt":6857,"src":6858},"The CLI input pipeline: raw args, env vars, and config files are parsed, validated, merged, run, and rendered with Rich.","\u002Fillustrations\u002Finput-pipeline.svg",[36,6860,6862],{"id":6861},"what-this-track-covers","What this track covers",[6864,6865,6867],"h3",{"id":6866},"validating-arguments","Validating arguments",[41,6869,6870],{},[44,6871,6872,6876,6877,6880],{},[72,6873,6874],{},[97,6875,4593],{"href":4592},"\n— enforce schema-driven, pre-execution data integrity with Pydantic v2, Typer, and\ncustom validators. Then handle the hard case:\n",[97,6878,6879],{"href":2191},"parsing nested JSON arguments in Python CLIs","\nwith Click custom types and shell-safe encoding.",[6864,6882,6884],{"id":6883},"merging-configuration","Merging configuration",[41,6886,6887],{},[44,6888,6889,6893,6894,61],{},[72,6890,6891],{},[97,6892,2463],{"href":2462},"\n— build a deterministic precedence chain (flags > env > file > defaults) with strict\ntype safety. Includes the security-sensitive case of\n",[97,6895,6896],{"href":4602},"loading YAML configs safely in CLI apps",[6864,6898,6900],{"id":6899},"terminal-user-experience","Terminal user experience",[41,6902,6903],{},[44,6904,6905,6911,6912,6916],{},[72,6906,6907],{},[97,6908,6910],{"href":6909},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002F","Interactive terminal UI with Rich","\n— tables, panels, prompts, and live-updating output that elevate a CLI from functional\nto polished. Start with\n",[97,6913,6915],{"href":6914},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Fadding-progress-bars-and-spinners-to-python-clis\u002F","progress bars and spinners for Python CLIs","\nfor both deterministic and indeterminate work.",[36,6918,6920],{"id":6919},"a-recommended-path","A recommended path",[1961,6922,6923,6930,6937],{},[44,6924,6925,6926,6929],{},"Validate ",[72,6927,6928],{},"arguments"," at the boundary so bad input fails fast and clearly.",[44,6931,6932,6933,6936],{},"Define a ",[72,6934,6935],{},"configuration precedence"," chain your users can reason about.",[44,6938,6939,6940,6943],{},"Layer on ",[72,6941,6942],{},"Rich-based output"," to make results readable without breaking pipes.",[36,6945,6947],{"id":6946},"related-tracks","Related tracks",[10,6949,6950,6951,6953,6954,6956],{},"Set up the project in\n",[97,6952,1423],{"href":1422}," and design\nthe command surface in\n",[97,6955,1430],{"href":1429},"\nbefore polishing the input and output layers here.",{"title":131,"searchDepth":152,"depth":152,"links":6958},[6959,6964,6965],{"id":6861,"depth":152,"text":6862,"children":6960},[6961,6962,6963],{"id":6866,"depth":162,"text":6867},{"id":6883,"depth":162,"text":6884},{"id":6899,"depth":162,"text":6900},{"id":6919,"depth":152,"text":6920},{"id":6946,"depth":152,"text":6947},"Master argument validation, config file precedence, env var handling, and Rich-based terminal rendering for reliable Python CLI input pipelines.",{},"\u002Fadvanced-input-parsing-user-experience",{"title":2450,"description":6966},"advanced-input-parsing-user-experience\u002Findex",[2481,6972,5772,6973,6974],"configuration","rich","terminal-ux","9aci2CDsQG7nxaU_6J0N48ASx_YeeNCgCNn0wyidgYc",{"id":6977,"title":6978,"body":6979,"date":1320,"description":8399,"difficulty":1322,"draft":1323,"extension":1324,"meta":8400,"navigation":183,"path":8401,"seo":8402,"stem":8403,"tags":8404,"updated":1320,"__hash__":8408},"content\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Fadding-progress-bars-and-spinners-to-python-clis\u002Findex.md","Progress Bars and Spinners for Python CLIs",{"type":7,"value":6980,"toc":8388},[6981,6991,6993,7057,7063,7067,7073,7179,7210,7213,7269,7279,7283,7296,7487,7499,7503,7514,7690,7699,7703,7709,7887,7914,7918,7927,7934,8054,8082,8086,8097,8103,8288,8309,8311,8366,8368,8385],[10,6982,6983,6984,6987,6988,61],{},"A CLI that sits silent for thirty seconds looks frozen; users hit Ctrl-C and file bug reports. The fix is feedback: a progress bar when you know how much work there is, a spinner when you don't. Rich's ",[14,6985,6986],{},"rich.progress.Progress"," gives you both, plus multiple concurrent task bars and fully custom columns — and it does the hard part (re-rendering a region of the terminal without scrolling) correctly. This guide shows the deterministic case, the indeterminate case, several tasks at once, and the one rule that keeps your display from turning to garbage: route every line of output through the same ",[14,6989,6990],{},"Console",[36,6992,39],{"id":38},[41,6994,6995,7007,7021,7034,7054],{},[44,6996,6997,765,7000,448,7003,7006],{},[72,6998,6999],{},"Known total → deterministic bar.",[14,7001,7002],{},"progress.add_task(\"...\", total=N)",[14,7004,7005],{},"progress.advance(task)"," as work completes.",[44,7008,7009,7012,7013,7016,7017,7020],{},[72,7010,7011],{},"Unknown total → spinner."," Pass ",[14,7014,7015],{},"total=None","; add a ",[14,7018,7019],{},"SpinnerColumn()",". Update the total later if you learn it.",[44,7022,7023,7026,7027,7030,7031,61],{},[72,7024,7025],{},"Many tasks → many bars."," Call ",[14,7028,7029],{},"add_task"," more than once; loop until ",[14,7032,7033],{},"progress.finished",[44,7035,7036,7046,7047,7050,7051,7053],{},[72,7037,7038,7039,7042,7043,61],{},"Never ",[14,7040,7041],{},"print()"," inside a live ",[14,7044,7045],{},"Progress"," Use ",[14,7048,7049],{},"progress.console.log(...)"," (or the same ",[14,7052,6990],{}," you passed in) so logs appear above the bar instead of shredding it.",[44,7055,7056],{},"When output is redirected, Rich auto-detects the non-TTY and emits plain refreshes instead of animations.",[10,7058,7059],{},[104,7060],{"alt":7061,"src":7062},"Anatomy of a progress display: a determinate bar made of a description segment, a bar filled to about 60%, a 60% readout, and an elapsed time of 0:00:12; below it an indeterminate spinner labelled \"working…\" with the note total=None.","\u002Fillustrations\u002Fprogress-bar-anatomy.svg",[36,7064,7066],{"id":7065},"deterministic-progress-a-known-total","Deterministic progress: a known total",[10,7068,7069,7070,7072],{},"When you can count the work ahead of time — files to copy, rows to import, URLs to fetch — use a determinate bar. ",[14,7071,7045],{}," is a context manager: entering it starts a background refresh, exiting it stops cleanly and leaves the final frame on screen.",[126,7074,7076],{"className":316,"code":7075,"language":318,"meta":131,"style":131},"import time\nfrom rich.progress import Progress\n\nwith Progress() as progress:\n    task = progress.add_task(\"Downloading...\", total=20)\n    for _ in range(20):\n        time.sleep(0.001)          # stand-in for real work\n        progress.advance(task)     # or progress.update(task, advance=1)\n",[14,7077,7078,7085,7097,7101,7114,7139,7157,7171],{"__ignoreMap":131},[135,7079,7080,7082],{"class":137,"line":138},[135,7081,326],{"class":325},[135,7083,7084],{"class":141}," time\n",[135,7086,7087,7089,7092,7094],{"class":137,"line":152},[135,7088,334],{"class":325},[135,7090,7091],{"class":141}," rich.progress ",[135,7093,326],{"class":325},[135,7095,7096],{"class":141}," Progress\n",[135,7098,7099],{"class":137,"line":162},[135,7100,184],{"emptyLinePlaceholder":183},[135,7102,7103,7106,7109,7111],{"class":137,"line":171},[135,7104,7105],{"class":325},"with",[135,7107,7108],{"class":141}," Progress() ",[135,7110,2419],{"class":325},[135,7112,7113],{"class":141}," progress:\n",[135,7115,7116,7119,7121,7124,7127,7129,7132,7134,7137],{"class":137,"line":180},[135,7117,7118],{"class":141},"    task ",[135,7120,516],{"class":325},[135,7122,7123],{"class":141}," progress.add_task(",[135,7125,7126],{"class":158},"\"Downloading...\"",[135,7128,861],{"class":141},[135,7130,7131],{"class":914},"total",[135,7133,516],{"class":325},[135,7135,7136],{"class":350},"20",[135,7138,550],{"class":141},[135,7140,7141,7143,7146,7148,7151,7153,7155],{"class":137,"line":187},[135,7142,2277],{"class":325},[135,7144,7145],{"class":141}," _ ",[135,7147,933],{"class":325},[135,7149,7150],{"class":350}," range",[135,7152,544],{"class":141},[135,7154,7136],{"class":350},[135,7156,986],{"class":141},[135,7158,7159,7162,7165,7168],{"class":137,"line":201},[135,7160,7161],{"class":141},"        time.sleep(",[135,7163,7164],{"class":350},"0.001",[135,7166,7167],{"class":141},")          ",[135,7169,7170],{"class":669},"# stand-in for real work\n",[135,7172,7173,7176],{"class":137,"line":210},[135,7174,7175],{"class":141},"        progress.advance(task)     ",[135,7177,7178],{"class":669},"# or progress.update(task, advance=1)\n",[10,7180,7181,7183,7184,7187,7188,459,7191,7194,7195,7197,7198,7201,7202,7205,7206,7209],{},[14,7182,7029],{}," returns a ",[14,7185,7186],{},"TaskID"," you pass back to ",[14,7189,7190],{},"advance",[14,7192,7193],{},"update",". The default ",[14,7196,7045],{}," columns give you a description, a bar, a percentage, and an ETA. ",[14,7199,7200],{},"advance(task)"," bumps completion by one; ",[14,7203,7204],{},"update(task, advance=n)"," bumps by ",[14,7207,7208],{},"n"," and can change other fields at the same time.",[10,7211,7212],{},"For the common \"do work over an iterable\" case, Rich offers a one-liner that wraps all of the above:",[126,7214,7216],{"className":316,"code":7215,"language":318,"meta":131,"style":131},"from rich.progress import track\n\nfor item in track(range(20), description=\"Processing...\"):\n    do_work(item)\n",[14,7217,7218,7229,7233,7264],{"__ignoreMap":131},[135,7219,7220,7222,7224,7226],{"class":137,"line":138},[135,7221,334],{"class":325},[135,7223,7091],{"class":141},[135,7225,326],{"class":325},[135,7227,7228],{"class":141}," track\n",[135,7230,7231],{"class":137,"line":152},[135,7232,184],{"emptyLinePlaceholder":183},[135,7234,7235,7237,7240,7242,7245,7248,7250,7252,7254,7257,7259,7262],{"class":137,"line":162},[135,7236,927],{"class":325},[135,7238,7239],{"class":141}," item ",[135,7241,933],{"class":325},[135,7243,7244],{"class":141}," track(",[135,7246,7247],{"class":350},"range",[135,7249,544],{"class":141},[135,7251,7136],{"class":350},[135,7253,4389],{"class":141},[135,7255,7256],{"class":914},"description",[135,7258,516],{"class":325},[135,7260,7261],{"class":158},"\"Processing...\"",[135,7263,986],{"class":141},[135,7265,7266],{"class":137,"line":171},[135,7267,7268],{"class":141},"    do_work(item)\n",[10,7270,7271,7272,7275,7276,7278],{},"Reach for ",[14,7273,7274],{},"track()"," for a single loop; reach for ",[14,7277,7045],{}," directly when you need custom columns, multiple bars, or to log alongside the bar.",[36,7280,7282],{"id":7281},"indeterminate-work-spinners","Indeterminate work: spinners",[10,7284,7285,7286,7288,7289,7291,7292,7295],{},"Sometimes you genuinely don't know the total — you're waiting on a remote server, scanning a directory of unknown size, or blocked on a subprocess. Pass ",[14,7287,7015],{}," to get an indeterminate task, and add a ",[14,7290,7019],{}," so there's visible motion. A nice pattern is to start indeterminate and ",[23,7293,7294],{},"switch"," to a real bar once the total becomes known.",[126,7297,7299],{"className":316,"code":7298,"language":318,"meta":131,"style":131},"import time\nfrom rich.progress import Progress, SpinnerColumn, TextColumn\n\nwith Progress(\n    SpinnerColumn(),\n    TextColumn(\"[progress.description]{task.description}\"),\n    transient=True,   # erase the spinner once the block exits\n) as progress:\n    task = progress.add_task(\"Connecting to server...\", total=None)\n    for _ in range(10):\n        time.sleep(0.001)\n        progress.update(task)        # keeps the spinner alive\n\n    # We learned the work size — promote to a determinate bar.\n    progress.update(task, total=5, completed=0)\n    for _ in range(5):\n        time.sleep(0.001)\n        progress.advance(task)\n",[14,7300,7301,7307,7318,7322,7329,7334,7350,7365,7373,7394,7410,7418,7426,7430,7435,7458,7474,7482],{"__ignoreMap":131},[135,7302,7303,7305],{"class":137,"line":138},[135,7304,326],{"class":325},[135,7306,7084],{"class":141},[135,7308,7309,7311,7313,7315],{"class":137,"line":152},[135,7310,334],{"class":325},[135,7312,7091],{"class":141},[135,7314,326],{"class":325},[135,7316,7317],{"class":141}," Progress, SpinnerColumn, TextColumn\n",[135,7319,7320],{"class":137,"line":162},[135,7321,184],{"emptyLinePlaceholder":183},[135,7323,7324,7326],{"class":137,"line":171},[135,7325,7105],{"class":325},[135,7327,7328],{"class":141}," Progress(\n",[135,7330,7331],{"class":137,"line":180},[135,7332,7333],{"class":141},"    SpinnerColumn(),\n",[135,7335,7336,7339,7342,7345,7347],{"class":137,"line":187},[135,7337,7338],{"class":141},"    TextColumn(",[135,7340,7341],{"class":158},"\"[progress.description]",[135,7343,7344],{"class":350},"{task.description}",[135,7346,589],{"class":158},[135,7348,7349],{"class":141},"),\n",[135,7351,7352,7355,7357,7359,7362],{"class":137,"line":201},[135,7353,7354],{"class":914},"    transient",[135,7356,516],{"class":325},[135,7358,3651],{"class":350},[135,7360,7361],{"class":141},",   ",[135,7363,7364],{"class":669},"# erase the spinner once the block exits\n",[135,7366,7367,7369,7371],{"class":137,"line":210},[135,7368,3398],{"class":141},[135,7370,2419],{"class":325},[135,7372,7113],{"class":141},[135,7374,7375,7377,7379,7381,7384,7386,7388,7390,7392],{"class":137,"line":215},[135,7376,7118],{"class":141},[135,7378,516],{"class":325},[135,7380,7123],{"class":141},[135,7382,7383],{"class":158},"\"Connecting to server...\"",[135,7385,861],{"class":141},[135,7387,7131],{"class":914},[135,7389,516],{"class":325},[135,7391,3093],{"class":350},[135,7393,550],{"class":141},[135,7395,7396,7398,7400,7402,7404,7406,7408],{"class":137,"line":225},[135,7397,2277],{"class":325},[135,7399,7145],{"class":141},[135,7401,933],{"class":325},[135,7403,7150],{"class":350},[135,7405,544],{"class":141},[135,7407,4137],{"class":350},[135,7409,986],{"class":141},[135,7411,7412,7414,7416],{"class":137,"line":236},[135,7413,7161],{"class":141},[135,7415,7164],{"class":350},[135,7417,550],{"class":141},[135,7419,7420,7423],{"class":137,"line":606},[135,7421,7422],{"class":141},"        progress.update(task)        ",[135,7424,7425],{"class":669},"# keeps the spinner alive\n",[135,7427,7428],{"class":137,"line":619},[135,7429,184],{"emptyLinePlaceholder":183},[135,7431,7432],{"class":137,"line":1752},[135,7433,7434],{"class":669},"    # We learned the work size — promote to a determinate bar.\n",[135,7436,7437,7440,7442,7444,7447,7449,7452,7454,7456],{"class":137,"line":1765},[135,7438,7439],{"class":141},"    progress.update(task, ",[135,7441,7131],{"class":914},[135,7443,516],{"class":325},[135,7445,7446],{"class":350},"5",[135,7448,861],{"class":141},[135,7450,7451],{"class":914},"completed",[135,7453,516],{"class":325},[135,7455,580],{"class":350},[135,7457,550],{"class":141},[135,7459,7460,7462,7464,7466,7468,7470,7472],{"class":137,"line":1774},[135,7461,2277],{"class":325},[135,7463,7145],{"class":141},[135,7465,933],{"class":325},[135,7467,7150],{"class":350},[135,7469,544],{"class":141},[135,7471,7446],{"class":350},[135,7473,986],{"class":141},[135,7475,7476,7478,7480],{"class":137,"line":1795},[135,7477,7161],{"class":141},[135,7479,7164],{"class":350},[135,7481,550],{"class":141},[135,7483,7484],{"class":137,"line":1830},[135,7485,7486],{"class":141},"        progress.advance(task)\n",[10,7488,7489,7492,7493,7495,7496,7498],{},[14,7490,7491],{},"transient=True"," removes the progress display when the ",[14,7494,7105],{}," block ends, which is ideal for a transient \"connecting…\" message you don't want cluttering the final output. Drop it (the default ",[14,7497,5172],{},") when you want the completed bar to remain on screen as a record.",[36,7500,7502],{"id":7501},"multiple-concurrent-tasks","Multiple concurrent tasks",[10,7504,7505,7507,7508,7510,7511,7513],{},[14,7506,7045],{}," can track any number of tasks, each with its own bar, stacked vertically and refreshed together. Add several tasks and advance them as their respective work progresses. ",[14,7509,7033],{}," is ",[14,7512,3651],{}," once every determinate task reaches its total — a clean loop condition.",[126,7515,7517],{"className":316,"code":7516,"language":318,"meta":131,"style":131},"import time\nfrom rich.progress import Progress, BarColumn, TextColumn, TaskProgressColumn\n\nwith Progress(\n    TextColumn(\"[bold]{task.description}\"),\n    BarColumn(),\n    TaskProgressColumn(),\n) as progress:\n    alpha = progress.add_task(\"alpha\", total=8)\n    beta = progress.add_task(\"beta\", total=12)\n    gamma = progress.add_task(\"gamma\", total=4)\n    while not progress.finished:\n        time.sleep(0.001)\n        progress.advance(alpha, 1)\n        progress.advance(beta, 2)\n        progress.advance(gamma, 1)\n",[14,7518,7519,7525,7536,7540,7546,7559,7564,7569,7577,7600,7623,7645,7655,7663,7672,7681],{"__ignoreMap":131},[135,7520,7521,7523],{"class":137,"line":138},[135,7522,326],{"class":325},[135,7524,7084],{"class":141},[135,7526,7527,7529,7531,7533],{"class":137,"line":152},[135,7528,334],{"class":325},[135,7530,7091],{"class":141},[135,7532,326],{"class":325},[135,7534,7535],{"class":141}," Progress, BarColumn, TextColumn, TaskProgressColumn\n",[135,7537,7538],{"class":137,"line":162},[135,7539,184],{"emptyLinePlaceholder":183},[135,7541,7542,7544],{"class":137,"line":171},[135,7543,7105],{"class":325},[135,7545,7328],{"class":141},[135,7547,7548,7550,7553,7555,7557],{"class":137,"line":180},[135,7549,7338],{"class":141},[135,7551,7552],{"class":158},"\"[bold]",[135,7554,7344],{"class":350},[135,7556,589],{"class":158},[135,7558,7349],{"class":141},[135,7560,7561],{"class":137,"line":187},[135,7562,7563],{"class":141},"    BarColumn(),\n",[135,7565,7566],{"class":137,"line":201},[135,7567,7568],{"class":141},"    TaskProgressColumn(),\n",[135,7570,7571,7573,7575],{"class":137,"line":210},[135,7572,3398],{"class":141},[135,7574,2419],{"class":325},[135,7576,7113],{"class":141},[135,7578,7579,7582,7584,7586,7589,7591,7593,7595,7598],{"class":137,"line":215},[135,7580,7581],{"class":141},"    alpha ",[135,7583,516],{"class":325},[135,7585,7123],{"class":141},[135,7587,7588],{"class":158},"\"alpha\"",[135,7590,861],{"class":141},[135,7592,7131],{"class":914},[135,7594,516],{"class":325},[135,7596,7597],{"class":350},"8",[135,7599,550],{"class":141},[135,7601,7602,7605,7607,7609,7612,7614,7616,7618,7621],{"class":137,"line":225},[135,7603,7604],{"class":141},"    beta ",[135,7606,516],{"class":325},[135,7608,7123],{"class":141},[135,7610,7611],{"class":158},"\"beta\"",[135,7613,861],{"class":141},[135,7615,7131],{"class":914},[135,7617,516],{"class":325},[135,7619,7620],{"class":350},"12",[135,7622,550],{"class":141},[135,7624,7625,7628,7630,7632,7635,7637,7639,7641,7643],{"class":137,"line":236},[135,7626,7627],{"class":141},"    gamma ",[135,7629,516],{"class":325},[135,7631,7123],{"class":141},[135,7633,7634],{"class":158},"\"gamma\"",[135,7636,861],{"class":141},[135,7638,7131],{"class":914},[135,7640,516],{"class":325},[135,7642,1975],{"class":350},[135,7644,550],{"class":141},[135,7646,7647,7650,7652],{"class":137,"line":606},[135,7648,7649],{"class":325},"    while",[135,7651,533],{"class":325},[135,7653,7654],{"class":141}," progress.finished:\n",[135,7656,7657,7659,7661],{"class":137,"line":619},[135,7658,7161],{"class":141},[135,7660,7164],{"class":350},[135,7662,550],{"class":141},[135,7664,7665,7668,7670],{"class":137,"line":1752},[135,7666,7667],{"class":141},"        progress.advance(alpha, ",[135,7669,522],{"class":350},[135,7671,550],{"class":141},[135,7673,7674,7677,7679],{"class":137,"line":1765},[135,7675,7676],{"class":141},"        progress.advance(beta, ",[135,7678,2221],{"class":350},[135,7680,550],{"class":141},[135,7682,7683,7686,7688],{"class":137,"line":1774},[135,7684,7685],{"class":141},"        progress.advance(gamma, ",[135,7687,522],{"class":350},[135,7689,550],{"class":141},[10,7691,7692,7693,7695,7696,7698],{},"This is the foundation for things like concurrent downloads: spawn a thread per file, each calling ",[14,7694,7190],{}," on its own task ID. The ",[14,7697,7045],{}," instance is safe to update from worker threads because the rendering happens on its own refresh thread.",[36,7700,7702],{"id":7701},"custom-columns","Custom columns",[10,7704,7705,7706,7708],{},"The default layout is fine, but you'll often want to show throughput, an \"M of N\" counter, or elapsed time. Build the ",[14,7707,7045],{}," with an explicit list of columns; each is a small renderable that reads from the task.",[126,7710,7712],{"className":316,"code":7711,"language":318,"meta":131,"style":131},"import time\nfrom rich.progress import (\n    Progress,\n    TextColumn,\n    BarColumn,\n    MofNCompleteColumn,\n    TimeElapsedColumn,\n)\n\ncolumns = (\n    TextColumn(\"[bold blue]{task.description}\"),\n    BarColumn(bar_width=None),       # expand to fill available width\n    MofNCompleteColumn(),            # e.g. \"7\u002F10\"\n    TimeElapsedColumn(),\n)\nwith Progress(*columns) as progress:\n    task = progress.add_task(\"Processing files\", total=10)\n    for _ in range(10):\n        time.sleep(0.001)\n        progress.advance(task)\n",[14,7713,7714,7720,7731,7736,7741,7746,7751,7756,7760,7764,7773,7786,7804,7812,7817,7821,7838,7859,7875,7883],{"__ignoreMap":131},[135,7715,7716,7718],{"class":137,"line":138},[135,7717,326],{"class":325},[135,7719,7084],{"class":141},[135,7721,7722,7724,7726,7728],{"class":137,"line":152},[135,7723,334],{"class":325},[135,7725,7091],{"class":141},[135,7727,326],{"class":325},[135,7729,7730],{"class":141}," (\n",[135,7732,7733],{"class":137,"line":162},[135,7734,7735],{"class":141},"    Progress,\n",[135,7737,7738],{"class":137,"line":171},[135,7739,7740],{"class":141},"    TextColumn,\n",[135,7742,7743],{"class":137,"line":180},[135,7744,7745],{"class":141},"    BarColumn,\n",[135,7747,7748],{"class":137,"line":187},[135,7749,7750],{"class":141},"    MofNCompleteColumn,\n",[135,7752,7753],{"class":137,"line":201},[135,7754,7755],{"class":141},"    TimeElapsedColumn,\n",[135,7757,7758],{"class":137,"line":210},[135,7759,550],{"class":141},[135,7761,7762],{"class":137,"line":215},[135,7763,184],{"emptyLinePlaceholder":183},[135,7765,7766,7769,7771],{"class":137,"line":225},[135,7767,7768],{"class":141},"columns ",[135,7770,516],{"class":325},[135,7772,7730],{"class":141},[135,7774,7775,7777,7780,7782,7784],{"class":137,"line":236},[135,7776,7338],{"class":141},[135,7778,7779],{"class":158},"\"[bold blue]",[135,7781,7344],{"class":350},[135,7783,589],{"class":158},[135,7785,7349],{"class":141},[135,7787,7788,7791,7794,7796,7798,7801],{"class":137,"line":606},[135,7789,7790],{"class":141},"    BarColumn(",[135,7792,7793],{"class":914},"bar_width",[135,7795,516],{"class":325},[135,7797,3093],{"class":350},[135,7799,7800],{"class":141},"),       ",[135,7802,7803],{"class":669},"# expand to fill available width\n",[135,7805,7806,7809],{"class":137,"line":619},[135,7807,7808],{"class":141},"    MofNCompleteColumn(),            ",[135,7810,7811],{"class":669},"# e.g. \"7\u002F10\"\n",[135,7813,7814],{"class":137,"line":1752},[135,7815,7816],{"class":141},"    TimeElapsedColumn(),\n",[135,7818,7819],{"class":137,"line":1765},[135,7820,550],{"class":141},[135,7822,7823,7825,7828,7831,7834,7836],{"class":137,"line":1774},[135,7824,7105],{"class":325},[135,7826,7827],{"class":141}," Progress(",[135,7829,7830],{"class":325},"*",[135,7832,7833],{"class":141},"columns) ",[135,7835,2419],{"class":325},[135,7837,7113],{"class":141},[135,7839,7840,7842,7844,7846,7849,7851,7853,7855,7857],{"class":137,"line":1795},[135,7841,7118],{"class":141},[135,7843,516],{"class":325},[135,7845,7123],{"class":141},[135,7847,7848],{"class":158},"\"Processing files\"",[135,7850,861],{"class":141},[135,7852,7131],{"class":914},[135,7854,516],{"class":325},[135,7856,4137],{"class":350},[135,7858,550],{"class":141},[135,7860,7861,7863,7865,7867,7869,7871,7873],{"class":137,"line":1830},[135,7862,2277],{"class":325},[135,7864,7145],{"class":141},[135,7866,933],{"class":325},[135,7868,7150],{"class":350},[135,7870,544],{"class":141},[135,7872,4137],{"class":350},[135,7874,986],{"class":141},[135,7876,7877,7879,7881],{"class":137,"line":1846},[135,7878,7161],{"class":141},[135,7880,7164],{"class":350},[135,7882,550],{"class":141},[135,7884,7885],{"class":137,"line":1854},[135,7886,7486],{"class":141},[10,7888,7889,7890,861,7893,861,7896,784,7899,7902,7903,5659,7906,7909,7910,7913],{},"Useful built-ins include ",[14,7891,7892],{},"DownloadColumn",[14,7894,7895],{},"TransferSpeedColumn",[14,7897,7898],{},"TimeRemainingColumn",[14,7900,7901],{},"SpinnerColumn",". You can also pass arbitrary ",[14,7904,7905],{},"fields",[14,7907,7908],{},"add_task(..., foo=\"bar\")"," and reference them in a ",[14,7911,7912],{},"TextColumn(\"{task.fields[foo]}\")"," for fully custom readouts.",[36,7915,7917],{"id":7916},"the-cardinal-rule-log-through-the-same-console","The cardinal rule: log through the same Console",[10,7919,7920,7921,7923,7924,7926],{},"This is where most progress-bar code breaks. While ",[14,7922,7045],{}," is live, it owns a region of the terminal and repaints it on a timer. If you call the built-in ",[14,7925,7041],{}," in that window, your text lands in the middle of the bar and the next repaint smears it across the screen.",[10,7928,7929,7930,7933],{},"The fix: write through the progress display's own console, which knows to scroll your line ",[23,7931,7932],{},"above"," the live region.",[126,7935,7937],{"className":316,"code":7936,"language":318,"meta":131,"style":131},"import time\nfrom rich.progress import Progress, TextColumn, BarColumn\n\nwith Progress(TextColumn(\"{task.description}\"), BarColumn()) as progress:\n    task = progress.add_task(\"Processing files\", total=10)\n    for i in range(10):\n        time.sleep(0.001)\n        progress.console.log(f\"handled item {i}\")   # safe — appears above the bar\n        progress.advance(task)\n",[14,7938,7939,7945,7956,7960,7980,8000,8017,8025,8050],{"__ignoreMap":131},[135,7940,7941,7943],{"class":137,"line":138},[135,7942,326],{"class":325},[135,7944,7084],{"class":141},[135,7946,7947,7949,7951,7953],{"class":137,"line":152},[135,7948,334],{"class":325},[135,7950,7091],{"class":141},[135,7952,326],{"class":325},[135,7954,7955],{"class":141}," Progress, TextColumn, BarColumn\n",[135,7957,7958],{"class":137,"line":162},[135,7959,184],{"emptyLinePlaceholder":183},[135,7961,7962,7964,7967,7969,7971,7973,7976,7978],{"class":137,"line":171},[135,7963,7105],{"class":325},[135,7965,7966],{"class":141}," Progress(TextColumn(",[135,7968,589],{"class":158},[135,7970,7344],{"class":350},[135,7972,589],{"class":158},[135,7974,7975],{"class":141},"), BarColumn()) ",[135,7977,2419],{"class":325},[135,7979,7113],{"class":141},[135,7981,7982,7984,7986,7988,7990,7992,7994,7996,7998],{"class":137,"line":180},[135,7983,7118],{"class":141},[135,7985,516],{"class":325},[135,7987,7123],{"class":141},[135,7989,7848],{"class":158},[135,7991,861],{"class":141},[135,7993,7131],{"class":914},[135,7995,516],{"class":325},[135,7997,4137],{"class":350},[135,7999,550],{"class":141},[135,8001,8002,8004,8007,8009,8011,8013,8015],{"class":137,"line":187},[135,8003,2277],{"class":325},[135,8005,8006],{"class":141}," i ",[135,8008,933],{"class":325},[135,8010,7150],{"class":350},[135,8012,544],{"class":141},[135,8014,4137],{"class":350},[135,8016,986],{"class":141},[135,8018,8019,8021,8023],{"class":137,"line":201},[135,8020,7161],{"class":141},[135,8022,7164],{"class":350},[135,8024,550],{"class":141},[135,8026,8027,8030,8032,8035,8037,8040,8042,8044,8047],{"class":137,"line":210},[135,8028,8029],{"class":141},"        progress.console.log(",[135,8031,568],{"class":325},[135,8033,8034],{"class":158},"\"handled item ",[135,8036,574],{"class":350},[135,8038,8039],{"class":141},"i",[135,8041,586],{"class":350},[135,8043,589],{"class":158},[135,8045,8046],{"class":141},")   ",[135,8048,8049],{"class":669},"# safe — appears above the bar\n",[135,8051,8052],{"class":137,"line":215},[135,8053,7486],{"class":141},[10,8055,8056,8057,8059,8060,8062,8063,8066,8067,8073,8074,8077,8078,8081],{},"If you already created a shared ",[14,8058,6990],{}," (recommended — see ",[97,8061,6910],{"href":6909},"), pass it in with ",[14,8064,8065],{},"Progress(..., console=my_console)"," and log through that same object everywhere. The rule generalises: ",[72,8068,8069,8070,8072],{},"inside a live Rich display, only that display's ",[14,8071,6990],{}," may write to the terminal."," Configure your ",[14,8075,8076],{},"logging"," handler with Rich's ",[14,8079,8080],{},"RichHandler"," bound to the same console and even library log output behaves.",[36,8083,8085],{"id":8084},"behaviour-when-output-is-redirected","Behaviour when output is redirected",[10,8087,8088,8089,8092,8093,8096],{},"Animations only make sense on a real terminal. When your CLI is piped into a file, ",[14,8090,8091],{},"grep",", or a CI log, you do not want thousands of cursor-control sequences written to disk. Rich detects this automatically via ",[14,8094,8095],{},"Console.is_terminal",": on a non-TTY it disables the live animation and emits a plain, periodic text update instead, so the captured log stays readable.",[10,8098,8099,8100,8102],{},"You can verify and test this without an actual terminal by giving the ",[14,8101,6990],{}," a file buffer:",[126,8104,8106],{"className":316,"code":8105,"language":318,"meta":131,"style":131},"import io\nfrom rich.console import Console\nfrom rich.progress import Progress, TextColumn, BarColumn\n\nbuf = io.StringIO()\nconsole = Console(file=buf, force_terminal=False, width=80)\nprint(\"is_terminal:\", console.is_terminal)   # False -> degraded, plain output\nwith Progress(TextColumn(\"{task.description}\"), BarColumn(), console=console) as p:\n    task = p.add_task(\"export\", total=3)\n    for _ in range(3):\n        p.advance(task)\nassert \"export\" in buf.getvalue()\n",[14,8107,8108,8115,8127,8137,8141,8151,8188,8204,8232,8254,8270,8275],{"__ignoreMap":131},[135,8109,8110,8112],{"class":137,"line":138},[135,8111,326],{"class":325},[135,8113,8114],{"class":141}," io\n",[135,8116,8117,8119,8122,8124],{"class":137,"line":152},[135,8118,334],{"class":325},[135,8120,8121],{"class":141}," rich.console ",[135,8123,326],{"class":325},[135,8125,8126],{"class":141}," Console\n",[135,8128,8129,8131,8133,8135],{"class":137,"line":162},[135,8130,334],{"class":325},[135,8132,7091],{"class":141},[135,8134,326],{"class":325},[135,8136,7955],{"class":141},[135,8138,8139],{"class":137,"line":171},[135,8140,184],{"emptyLinePlaceholder":183},[135,8142,8143,8146,8148],{"class":137,"line":180},[135,8144,8145],{"class":141},"buf ",[135,8147,516],{"class":325},[135,8149,8150],{"class":141}," io.StringIO()\n",[135,8152,8153,8156,8158,8161,8164,8166,8169,8172,8174,8176,8178,8181,8183,8186],{"class":137,"line":187},[135,8154,8155],{"class":141},"console ",[135,8157,516],{"class":325},[135,8159,8160],{"class":141}," Console(",[135,8162,8163],{"class":914},"file",[135,8165,516],{"class":325},[135,8167,8168],{"class":141},"buf, ",[135,8170,8171],{"class":914},"force_terminal",[135,8173,516],{"class":325},[135,8175,5172],{"class":350},[135,8177,861],{"class":141},[135,8179,8180],{"class":914},"width",[135,8182,516],{"class":325},[135,8184,8185],{"class":350},"80",[135,8187,550],{"class":141},[135,8189,8190,8193,8195,8198,8201],{"class":137,"line":201},[135,8191,8192],{"class":350},"print",[135,8194,544],{"class":141},[135,8196,8197],{"class":158},"\"is_terminal:\"",[135,8199,8200],{"class":141},", console.is_terminal)   ",[135,8202,8203],{"class":669},"# False -> degraded, plain output\n",[135,8205,8206,8208,8210,8212,8214,8216,8219,8222,8224,8227,8229],{"class":137,"line":210},[135,8207,7105],{"class":325},[135,8209,7966],{"class":141},[135,8211,589],{"class":158},[135,8213,7344],{"class":350},[135,8215,589],{"class":158},[135,8217,8218],{"class":141},"), BarColumn(), ",[135,8220,8221],{"class":914},"console",[135,8223,516],{"class":325},[135,8225,8226],{"class":141},"console) ",[135,8228,2419],{"class":325},[135,8230,8231],{"class":141}," p:\n",[135,8233,8234,8236,8238,8241,8244,8246,8248,8250,8252],{"class":137,"line":215},[135,8235,7118],{"class":141},[135,8237,516],{"class":325},[135,8239,8240],{"class":141}," p.add_task(",[135,8242,8243],{"class":158},"\"export\"",[135,8245,861],{"class":141},[135,8247,7131],{"class":914},[135,8249,516],{"class":325},[135,8251,3987],{"class":350},[135,8253,550],{"class":141},[135,8255,8256,8258,8260,8262,8264,8266,8268],{"class":137,"line":225},[135,8257,2277],{"class":325},[135,8259,7145],{"class":141},[135,8261,933],{"class":325},[135,8263,7150],{"class":350},[135,8265,544],{"class":141},[135,8267,3987],{"class":350},[135,8269,986],{"class":141},[135,8271,8272],{"class":137,"line":236},[135,8273,8274],{"class":141},"        p.advance(task)\n",[135,8276,8277,8280,8283,8285],{"class":137,"line":606},[135,8278,8279],{"class":325},"assert",[135,8281,8282],{"class":158}," \"export\"",[135,8284,4150],{"class":325},[135,8286,8287],{"class":141}," buf.getvalue()\n",[10,8289,8290,8291,8294,8295,459,8298,8300,8301,8304,8305,8308],{},"This same trick is what makes progress code testable: render into a ",[14,8292,8293],{},"StringIO",", then assert on the captured text. Use ",[14,8296,8297],{},"force_terminal=True",[14,8299,5172],{}," to pin the behaviour you want under test or in CI, and reach for ",[14,8302,8303],{},"Console(record=True)"," plus ",[14,8306,8307],{},"console.export_text()"," when you want a snapshot of the final frame.",[36,8310,4512],{"id":4511},[41,8312,8313,8322,8332,8350,8356],{},[44,8314,8315,765,8318,8321],{},[72,8316,8317],{},"Refresh rate.",[14,8319,8320],{},"Progress(refresh_per_second=10)"," controls repaint frequency. The default is sensible; lower it if many tasks make the terminal flicker, and note Rich throttles repaints regardless so a tight loop won't peg your CPU on rendering.",[44,8323,8324,8327,8328,8331],{},[72,8325,8326],{},"Threads, not async-by-default."," Updates from worker threads are safe. For ",[14,8329,8330],{},"asyncio",", advance the task from your coroutines; the refresh thread still handles drawing.",[44,8333,8334,8337,8338,8340,8341,8343,8344,8346,8347,8349],{},[72,8335,8336],{},"Don't over-advance."," Calling ",[14,8339,7190],{}," past ",[14,8342,7131],{}," is harmless (it clamps), but a wrong ",[14,8345,7131],{}," makes the ETA lie. Set the total accurately up front, or use ",[14,8348,7015],{}," until you know it.",[44,8351,8352,8355],{},[72,8353,8354],{},"Windows."," Rich works in the modern Windows Terminal and recent PowerShell\u002Fconhost. On very old consoles, fall back to plain output — which the non-TTY detection above already handles for redirected streams.",[44,8357,8358,8361,8362,8365],{},[72,8359,8360],{},"Pin Rich."," Column names and a few defaults have shifted across major versions; pin ",[14,8363,8364],{},"rich>=13"," and test against the version you ship.",[36,8367,1277],{"id":1276},[41,8369,8370,8375,8380],{},[44,8371,8372,8374],{},[97,8373,6910],{"href":6909}," — the sub-hub: Console, tables, panels, and prompts.",[44,8376,8377,8379],{},[97,8378,2450],{"href":1436}," — the parent pillar on input and UX.",[44,8381,8382,8384],{},[97,8383,4593],{"href":4592}," — validate the inputs that drive these long-running tasks.",[1303,8386,8387],{},"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 .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":131,"searchDepth":152,"depth":152,"links":8389},[8390,8391,8392,8393,8394,8395,8396,8397,8398],{"id":38,"depth":152,"text":39},{"id":7065,"depth":152,"text":7066},{"id":7281,"depth":152,"text":7282},{"id":7501,"depth":152,"text":7502},{"id":7701,"depth":152,"text":7702},{"id":7916,"depth":152,"text":7917},{"id":8084,"depth":152,"text":8085},{"id":4511,"depth":152,"text":4512},{"id":1276,"depth":152,"text":1277},"Add Rich progress bars and spinners to Python CLIs for deterministic and indeterminate tasks, with live output and multi-task progress tracking.",{},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Fadding-progress-bars-and-spinners-to-python-clis",{"title":6978,"description":8399},"advanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Fadding-progress-bars-and-spinners-to-python-clis\u002Findex",[6973,8405,8406,419,8407],"progress","spinners","output","RtwUlGY4Tf8LXhr08fh37VQsHoxbxTrjrHzKj0VBfT8",{"id":8410,"title":8411,"body":8412,"date":1320,"description":8972,"difficulty":1457,"draft":1323,"extension":1324,"meta":8973,"navigation":183,"path":8974,"seo":8975,"stem":8976,"tags":8977,"updated":1320,"__hash__":8979},"content\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Findex.md","Interactive Terminal UI with Rich",{"type":7,"value":8413,"toc":8963},[8414,8422,8424,8468,8474,8478,8489,8554,8563,8567,8575,8712,8715,8719,8736,8806,8819,8823,8833,8841,8845,8851,8867,8924,8938,8940,8960],[10,8415,8416,8421],{},[97,8417,8420],{"href":8418,"rel":8419},"https:\u002F\u002Fgithub.com\u002FTextualize\u002Frich",[4654],"Rich"," turns a plain Python CLI into something readable: coloured text, aligned tables, bordered panels, progress bars, and prompts — all from one library with no terminal-escape-code wrangling on your part. This hub surveys the pieces Rich gives you for building a friendly terminal UI, and points you to the deeper guides for each. It is aimed at anyone who has a working CLI and wants the output to stop looking like a 1990s log dump.",[36,8423,39],{"id":38},[41,8425,8426,8436,8457],{},[44,8427,8428,8429,8431,8432,8435],{},"Everything starts with a ",[14,8430,6990],{},". Create one, call ",[14,8433,8434],{},"console.print(...)",", and you get markup, colour, and word-wrapping for free.",[44,8437,8438,8439,861,8442,8445,8446,459,8449,784,8452,5732,8454,61],{},"Rich ships ready-made building blocks: ",[14,8440,8441],{},"Table",[14,8443,8444],{},"Panel",", styled text, ",[14,8447,8448],{},"Prompt",[14,8450,8451],{},"Confirm",[14,8453,7045],{},[97,8455,8456],{"href":6914},"progress bars and spinners",[44,8458,8459,8460,8463,8464,8467],{},"Rich detects when output is ",[72,8461,8462],{},"not"," a terminal (piped, redirected, in CI) and degrades gracefully — it drops control codes and respects ",[14,8465,8466],{},"NO_COLOR"," so your tool stays scriptable.",[10,8469,8470],{},[104,8471],{"alt":8472,"src":8473},"The Rich toolkit: a central Rich Console hub surrounded by its building blocks — Tables, Panels, Progress, Prompts, styled text\u002Fmarkup, and Live display — with a note that it degrades gracefully when piped.","\u002Fillustrations\u002Frich-toolkit.svg",[36,8475,8477],{"id":8476},"the-console-one-object-for-all-output","The Console: one object for all output",[10,8479,111,8480,8482,8483,1230,8485,8488],{},[14,8481,6990],{}," is the entry point for everything Rich draws. Replace ",[14,8484,7041],{},[14,8486,8487],{},"console.print()"," and you immediately get console markup, automatic word-wrap to the terminal width, and theme-aware colour.",[126,8490,8492],{"className":316,"code":8491,"language":318,"meta":131,"style":131},"from rich.console import Console\n\nconsole = Console()\nconsole.print(\"[bold green]✓ deploy succeeded[\u002F]  in 4.2s\")\nconsole.print({\"region\": \"eu-west-1\", \"replicas\": 3})  # pretty-prints structures\n",[14,8493,8494,8504,8508,8517,8527],{"__ignoreMap":131},[135,8495,8496,8498,8500,8502],{"class":137,"line":138},[135,8497,334],{"class":325},[135,8499,8121],{"class":141},[135,8501,326],{"class":325},[135,8503,8126],{"class":141},[135,8505,8506],{"class":137,"line":152},[135,8507,184],{"emptyLinePlaceholder":183},[135,8509,8510,8512,8514],{"class":137,"line":162},[135,8511,8155],{"class":141},[135,8513,516],{"class":325},[135,8515,8516],{"class":141}," Console()\n",[135,8518,8519,8522,8525],{"class":137,"line":171},[135,8520,8521],{"class":141},"console.print(",[135,8523,8524],{"class":158},"\"[bold green]✓ deploy succeeded[\u002F]  in 4.2s\"",[135,8526,550],{"class":141},[135,8528,8529,8532,8535,8537,8540,8542,8544,8546,8548,8551],{"class":137,"line":180},[135,8530,8531],{"class":141},"console.print({",[135,8533,8534],{"class":158},"\"region\"",[135,8536,2344],{"class":141},[135,8538,8539],{"class":158},"\"eu-west-1\"",[135,8541,861],{"class":141},[135,8543,4123],{"class":158},[135,8545,2344],{"class":141},[135,8547,3987],{"class":350},[135,8549,8550],{"class":141},"})  ",[135,8552,8553],{"class":669},"# pretty-prints structures\n",[10,8555,8556,8557,8559,8560,8562],{},"Create the ",[14,8558,6990],{}," once and pass it around (or stash it on a small app object) rather than constructing a new one per call. That single instance is also what keeps progress bars, prompts, and log lines from fighting over the cursor — a theme that comes up again on the ",[97,8561,8456],{"href":6914}," page.",[36,8564,8566],{"id":8565},"tables-and-panels-for-structured-output","Tables and panels for structured output",[10,8568,8569,8570,8572,8573,61],{},"When your command returns rows — servers, files, test results — reach for ",[14,8571,8441],{},". For a framed summary or a callout, use ",[14,8574,8444],{},[126,8576,8578],{"className":316,"code":8577,"language":318,"meta":131,"style":131},"from rich.table import Table\nfrom rich.panel import Panel\n\ntable = Table(title=\"Servers\")\ntable.add_column(\"Host\")\ntable.add_column(\"Status\", style=\"green\")\ntable.add_row(\"web-01\", \"up\")\ntable.add_row(\"web-02\", \"down\")\nconsole.print(table)\n\nconsole.print(Panel(\"All systems nominal\", title=\"Status\"))\n",[14,8579,8580,8592,8604,8608,8628,8638,8656,8671,8685,8690,8694],{"__ignoreMap":131},[135,8581,8582,8584,8587,8589],{"class":137,"line":138},[135,8583,334],{"class":325},[135,8585,8586],{"class":141}," rich.table ",[135,8588,326],{"class":325},[135,8590,8591],{"class":141}," Table\n",[135,8593,8594,8596,8599,8601],{"class":137,"line":152},[135,8595,334],{"class":325},[135,8597,8598],{"class":141}," rich.panel ",[135,8600,326],{"class":325},[135,8602,8603],{"class":141}," Panel\n",[135,8605,8606],{"class":137,"line":162},[135,8607,184],{"emptyLinePlaceholder":183},[135,8609,8610,8613,8615,8618,8621,8623,8626],{"class":137,"line":171},[135,8611,8612],{"class":141},"table ",[135,8614,516],{"class":325},[135,8616,8617],{"class":141}," Table(",[135,8619,8620],{"class":914},"title",[135,8622,516],{"class":325},[135,8624,8625],{"class":158},"\"Servers\"",[135,8627,550],{"class":141},[135,8629,8630,8633,8636],{"class":137,"line":180},[135,8631,8632],{"class":141},"table.add_column(",[135,8634,8635],{"class":158},"\"Host\"",[135,8637,550],{"class":141},[135,8639,8640,8642,8645,8647,8649,8651,8654],{"class":137,"line":187},[135,8641,8632],{"class":141},[135,8643,8644],{"class":158},"\"Status\"",[135,8646,861],{"class":141},[135,8648,1303],{"class":914},[135,8650,516],{"class":325},[135,8652,8653],{"class":158},"\"green\"",[135,8655,550],{"class":141},[135,8657,8658,8661,8664,8666,8669],{"class":137,"line":201},[135,8659,8660],{"class":141},"table.add_row(",[135,8662,8663],{"class":158},"\"web-01\"",[135,8665,861],{"class":141},[135,8667,8668],{"class":158},"\"up\"",[135,8670,550],{"class":141},[135,8672,8673,8675,8678,8680,8683],{"class":137,"line":210},[135,8674,8660],{"class":141},[135,8676,8677],{"class":158},"\"web-02\"",[135,8679,861],{"class":141},[135,8681,8682],{"class":158},"\"down\"",[135,8684,550],{"class":141},[135,8686,8687],{"class":137,"line":215},[135,8688,8689],{"class":141},"console.print(table)\n",[135,8691,8692],{"class":137,"line":225},[135,8693,184],{"emptyLinePlaceholder":183},[135,8695,8696,8699,8702,8704,8706,8708,8710],{"class":137,"line":236},[135,8697,8698],{"class":141},"console.print(Panel(",[135,8700,8701],{"class":158},"\"All systems nominal\"",[135,8703,861],{"class":141},[135,8705,8620],{"class":914},[135,8707,516],{"class":325},[135,8709,8644],{"class":158},[135,8711,1054],{"class":141},[10,8713,8714],{},"Tables handle column widths, alignment, and wrapping automatically, so you never hand-pad strings again.",[36,8716,8718],{"id":8717},"styled-text-and-prompts","Styled text and prompts",[10,8720,8721,8722,861,8725,861,8728,8731,8732,8735],{},"Rich markup (",[14,8723,8724],{},"[bold]",[14,8726,8727],{},"[red]",[14,8729,8730],{},"[link=…]",") inlines styling without escape codes. For interactive input, ",[14,8733,8734],{},"rich.prompt"," gives you typed, validated prompts:",[126,8737,8739],{"className":316,"code":8738,"language":318,"meta":131,"style":131},"from rich.prompt import Prompt, Confirm\n\nname = Prompt.ask(\"Project name\", default=\"my-cli\")\nif Confirm.ask(\"Initialise a git repo?\", default=True):\n    ...\n",[14,8740,8741,8753,8757,8782,8802],{"__ignoreMap":131},[135,8742,8743,8745,8748,8750],{"class":137,"line":138},[135,8744,334],{"class":325},[135,8746,8747],{"class":141}," rich.prompt ",[135,8749,326],{"class":325},[135,8751,8752],{"class":141}," Prompt, Confirm\n",[135,8754,8755],{"class":137,"line":152},[135,8756,184],{"emptyLinePlaceholder":183},[135,8758,8759,8762,8764,8767,8770,8772,8775,8777,8780],{"class":137,"line":162},[135,8760,8761],{"class":141},"name ",[135,8763,516],{"class":325},[135,8765,8766],{"class":141}," Prompt.ask(",[135,8768,8769],{"class":158},"\"Project name\"",[135,8771,861],{"class":141},[135,8773,8774],{"class":914},"default",[135,8776,516],{"class":325},[135,8778,8779],{"class":158},"\"my-cli\"",[135,8781,550],{"class":141},[135,8783,8784,8786,8789,8792,8794,8796,8798,8800],{"class":137,"line":171},[135,8785,347],{"class":325},[135,8787,8788],{"class":141}," Confirm.ask(",[135,8790,8791],{"class":158},"\"Initialise a git repo?\"",[135,8793,861],{"class":141},[135,8795,8774],{"class":914},[135,8797,516],{"class":325},[135,8799,3651],{"class":350},[135,8801,986],{"class":141},[135,8803,8804],{"class":137,"line":180},[135,8805,2170],{"class":350},[10,8807,8808,8811,8812,8815,8816,61],{},[14,8809,8810],{},"Prompt.ask"," supports ",[14,8813,8814],{},"choices=[...]",", default values, and password masking. It is a quick win for interactive flows, though for anything driven by flags or files you will still lean on argument parsing and ",[97,8817,8818],{"href":2462},"config files and env vars",[36,8820,8822],{"id":8821},"progress-spinners-and-live-output","Progress, spinners, and live output",[10,8824,8825,8826,8828,8829,8832],{},"Long-running work deserves feedback. Rich's ",[14,8827,7045],{}," covers deterministic tasks (a known total — copying N files), indeterminate work (a spinner while you wait on a network call), and several concurrent task bars at once. ",[14,8830,8831],{},"Live"," lets you re-render a region of the screen in place for dashboards. This is the single biggest UX upgrade for most CLIs, so it has its own deep dive:",[41,8834,8835],{},[44,8836,8837,8840],{},[97,8838,8839],{"href":6914},"Progress bars and spinners for Python CLIs"," — deterministic vs. indeterminate tasks, custom columns, multiple bars, and how to log without corrupting the display.",[36,8842,8844],{"id":8843},"degrade-gracefully-when-output-is-not-a-terminal","Degrade gracefully when output is not a terminal",[10,8846,8847,8848,8850],{},"This is the rule that separates a polished CLI from a broken one: your tool will be piped into ",[14,8849,8091],{},", redirected to a file, and run in CI. Rich handles this for you, but only if you let it own the output.",[10,8852,8853,8855,8856,8859,8860,8862,8863,8866],{},[14,8854,6990],{}," auto-detects whether it is attached to a TTY via ",[14,8857,8858],{},"console.is_terminal",". When it is not (a pipe, a file, a CI log), Rich suppresses animations and colour and emits plain text, and it honours the ",[14,8861,8466],{}," environment variable. Check ",[14,8864,8865],{},"is_terminal"," yourself when you want an explicit fallback:",[126,8868,8870],{"className":316,"code":8869,"language":318,"meta":131,"style":131},"if console.is_terminal:\n    console.print(table)          # full styled output\nelse:\n    for row in rows:\n        console.print(\"\\t\".join(row))  # script-friendly plain text\n",[14,8871,8872,8879,8887,8894,8906],{"__ignoreMap":131},[135,8873,8874,8876],{"class":137,"line":138},[135,8875,347],{"class":325},[135,8877,8878],{"class":141}," console.is_terminal:\n",[135,8880,8881,8884],{"class":137,"line":152},[135,8882,8883],{"class":141},"    console.print(table)          ",[135,8885,8886],{"class":669},"# full styled output\n",[135,8888,8889,8892],{"class":137,"line":162},[135,8890,8891],{"class":325},"else",[135,8893,360],{"class":141},[135,8895,8896,8898,8901,8903],{"class":137,"line":171},[135,8897,2277],{"class":325},[135,8899,8900],{"class":141}," row ",[135,8902,933],{"class":325},[135,8904,8905],{"class":141}," rows:\n",[135,8907,8908,8911,8913,8916,8918,8921],{"class":137,"line":180},[135,8909,8910],{"class":141},"        console.print(",[135,8912,589],{"class":158},[135,8914,8915],{"class":350},"\\t",[135,8917,589],{"class":158},[135,8919,8920],{"class":141},".join(row))  ",[135,8922,8923],{"class":669},"# script-friendly plain text\n",[10,8925,8926,8927,459,8929,8931,8932,8934,8935,8937],{},"Set ",[14,8928,8297],{},[14,8930,5172],{}," on the ",[14,8933,6990],{}," to override detection in tests or CI, and use ",[14,8936,8303],{}," to capture rendered output for regression tests.",[36,8939,1277],{"id":1276},[41,8941,8942,8947,8955],{},[44,8943,8944,8946],{},[97,8945,2450],{"href":1436}," — the parent pillar covering input and UX.",[44,8948,8949,8951,8952,61],{},[97,8950,8839],{"href":6914}," — the deep dive on ",[14,8953,8954],{},"rich.progress",[44,8956,8957,8959],{},[97,8958,2463],{"href":2462}," — feed your Rich-powered commands their settings.",[1303,8961,8962],{},"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 .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":131,"searchDepth":152,"depth":152,"links":8964},[8965,8966,8967,8968,8969,8970,8971],{"id":38,"depth":152,"text":39},{"id":8476,"depth":152,"text":8477},{"id":8565,"depth":152,"text":8566},{"id":8717,"depth":152,"text":8718},{"id":8821,"depth":152,"text":8822},{"id":8843,"depth":152,"text":8844},{"id":1276,"depth":152,"text":1277},"Build interactive Python CLI interfaces with the Rich library — tables, progress bars, panels, prompts, and live-updating terminal output.",{},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich",{"title":8411,"description":8972},"advanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Findex",[6973,8978,419,8407],"terminal-ui","Ps52RIvHn0m0oo8yRUAXMdW2Hu0GrUv23voSId-zODs",{"id":8981,"title":8982,"body":8983,"date":8993,"description":8994,"difficulty":8993,"draft":1323,"extension":1324,"meta":8995,"navigation":183,"path":459,"seo":8996,"stem":9000,"tags":8993,"updated":8993,"__hash__":9001},"content\u002Findex.md","Python CLI Toolcraft",{"type":7,"value":8984,"toc":8991},[8985],[10,8986,8987,8988,61],{},"Need more context? Visit the ",[97,8989,8990],{"href":1459},"about page",{"title":131,"searchDepth":152,"depth":152,"links":8992},[],null,"[object Object]",{},{"title":8982,"description":8997},{"Build, test, package, and ship modern Python command-line applications":8998},{" Three tracks":8999},"project setup, CLI architecture, and input validation & terminal UX.","index","ACflfxT97RN9VMqKVW0nljlUmLWvaeKI3rQ5d-0HnUw",{"id":9003,"title":9004,"body":9005,"date":1320,"description":9140,"difficulty":1322,"draft":1323,"extension":1324,"meta":9141,"navigation":183,"path":9142,"seo":9143,"stem":9144,"tags":9145,"updated":1320,"__hash__":9148},"content\u002Fmodern-python-cli-frameworks-architecture\u002Findex.md","Python CLI Frameworks and Architecture",{"type":7,"value":9006,"toc":9131},[9007,9010,9017,9023,9025,9029,9048,9052,9073,9077,9089,9091,9121,9123],[10,9008,9009],{},"A script becomes a tool the moment other people depend on it. This track is about the\ndecisions that make that transition survivable: which framework to commit to, how to\nlay out a command tree that stays testable as it grows, and how to let features ship\nindependently through plugins instead of accreting into one unmaintainable file.",[10,9011,9012,9013,9016],{},"These guides assume your ",[97,9014,9015],{"href":1422},"project foundation"," is\nalready in place. Here we focus on the shape of the code itself.",[10,9018,9019],{},[104,9020],{"alt":9021,"src":9022},"Layered CLI architecture: a terminal feeds a Typer\u002FClick command and parsing layer, which calls down into business logic and services, then an output and formatting layer, with results flowing back up.","\u002Fillustrations\u002Fcli-architecture-layers.svg",[36,9024,6862],{"id":6861},[6864,9026,9028],{"id":9027},"picking-a-framework","Picking a framework",[41,9030,9031],{},[44,9032,9033,9039,9040,9043,9044,61],{},[72,9034,9035],{},[97,9036,9038],{"href":9037},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002F","Typer vs Click: when to use each","\n— type-hint inference versus explicit decorator control, test ergonomics, and the\nproject traits that point to one framework over the other. Go deeper with\n",[97,9041,9042],{"href":704},"building a CLI with subcommands in Click","\nand ",[97,9045,9047],{"href":9046},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Ftyper-callback-functions-explained\u002F","Typer callback functions explained",[6864,9049,9051],{"id":9050},"structuring-the-command-tree","Structuring the command tree",[41,9053,9054],{},[44,9055,9056,9060,9061,9064,9065,9067,9068,9072],{},[72,9057,9058],{},[97,9059,1285],{"href":1284},"\n— separate parsing, business logic, and output so commands stay testable and the tree\nstays navigable. Then scale up with\n",[97,9062,9063],{"href":1237},"how to structure a large Python CLI project","\nusing the ",[14,9066,1210],{}," layout, and lock in\n",[97,9069,9071],{"href":9070},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fbest-practices-for-python-cli-entry-points\u002F","best practices for Python CLI entry points","\nvia PEP 621.",[6864,9074,9076],{"id":9075},"extensibility","Extensibility",[41,9078,9079],{},[44,9080,9081,9085,9086,9088],{},[72,9082,9083],{},[97,9084,842],{"href":99},"\n— design plugin systems with entry points, ",[14,9087,852],{},", and protocol\ninterfaces so teams can ship features against a stable API without touching the core.",[36,9090,6920],{"id":6919},[1961,9092,9093,9100,9107,9114],{},[44,9094,9095,9096,9099],{},"Decide between ",[72,9097,9098],{},"Typer and Click"," before you write the second command.",[44,9101,9102,9103,9106],{},"Establish ",[72,9104,9105],{},"multi-command structure"," with clean layer separation early.",[44,9108,9109,9110,9113],{},"Standardize ",[72,9111,9112],{},"entry points"," so installs behave the same everywhere.",[44,9115,9116,9117,9120],{},"Reach for a ",[72,9118,9119],{},"plugin architecture"," only once the command set outgrows one team.",[36,9122,6947],{"id":6946},[10,9124,9125,9126,9128,9129,61],{},"Build the foundation first in\n",[97,9127,1423],{"href":1422},", then make\nthe resulting commands a pleasure to use in\n",[97,9130,1437],{"href":1436},{"title":131,"searchDepth":152,"depth":152,"links":9132},[9133,9138,9139],{"id":6861,"depth":152,"text":6862,"children":9134},[9135,9136,9137],{"id":9027,"depth":162,"text":9028},{"id":9050,"depth":162,"text":9051},{"id":9075,"depth":162,"text":9076},{"id":6919,"depth":152,"text":6920},{"id":6946,"depth":152,"text":6947},"Choose between Typer and Click, structure multi-command CLIs, and design extensible plugin systems for scalable Python command-line applications.",{},"\u002Fmodern-python-cli-frameworks-architecture",{"title":9004,"description":9140},"modern-python-cli-frameworks-architecture\u002Findex",[6851,2483,2484,9146,9147],"plugins","command-structure","mItOLJfCyRCW0flOvRbFXBiFQMHFv-ga4gnAaGJ6zBI",{"id":9150,"title":9151,"body":9152,"date":1320,"description":10775,"difficulty":4617,"draft":1323,"extension":1324,"meta":10776,"navigation":183,"path":10777,"seo":10778,"stem":10779,"tags":10780,"updated":1320,"__hash__":10782},"content\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis\u002Findex.md","Plugin Architectures for Extensible CLIs",{"type":7,"value":9153,"toc":10764},[9154,9161,9163,9204,9210,9214,9229,9235,9241,9245,9248,9332,9369,9373,9388,9463,9468,9545,9564,9568,9585,9676,9704,9708,9715,10498,10501,10507,10520,10524,10527,10546,10577,10580,10681,10685,10698,10731,10734,10736,10761],[10,9155,9156,9157,9160],{},"Once your CLI has more than a handful of teams depending on it, every new feature request becomes a merge into the core. A plugin architecture breaks that bottleneck: third parties (or other teams in your org) ship their own commands as separate, independently versioned packages that snap into your tool at runtime. This article shows how to build one with Python's native entry-points mechanism, a ",[14,9158,9159],{},"typing.Protocol"," contract, and a loader that isolates failures so a single broken plugin can't take down the whole CLI.",[36,9162,39],{"id":38},[41,9164,9165,9177,9184,9190],{},[44,9166,9167,9168,9170,9171,9173,9174,61],{},"Declare plugins as ",[72,9169,9112],{}," in each plugin package's ",[14,9172,29],{}," under ",[14,9175,9176],{},"[project.entry-points.\"\u003Cyour.group>\"]",[44,9178,9179,9180,9183],{},"Discover them at runtime with ",[14,9181,9182],{},"importlib.metadata.entry_points(group=\"\u003Cyour.group>\")"," — no scanning, no import-by-string-name hacks.",[44,9185,9186,9187,9189],{},"Define the contract with a ",[14,9188,9159],{}," so plugins depend on an interface, not your internals.",[44,9191,9192,9193,9195,9196,9199,9200,9203],{},"Wrap each plugin's load ",[23,9194,764],{}," registration in ",[14,9197,9198],{},"try\u002Fexcept",", and gate on a declared ",[14,9201,9202],{},"api_version",". One bad plugin should log and skip, never crash.",[10,9205,9206],{},[104,9207],{"alt":9208,"src":9209},"Plugins via entry points: a CLI core with a stable API uses importlib.metadata.entry_points(group=...) to build a plugin registry that three plugins plug into; one failing plugin is isolated on error while the core keeps running.","\u002Fillustrations\u002Fplugin-architecture.svg",[36,9211,9213],{"id":9212},"why-plugins-and-what-stable-core-api-means","Why plugins, and what \"stable core API\" means",[10,9215,9216,9217,9220,9221,9224,9225,9228],{},"The point of a plugin system is ",[72,9218,9219],{},"independent shipping",". Your core CLI exposes a small, frozen surface — a way to register commands and maybe a shared context object — and everything else is built against it. A data-science team ships ",[14,9222,9223],{},"mycli-plugin-pandas","; the SRE team ships ",[14,9226,9227],{},"mycli-plugin-deploy",". Neither needs a PR into your repo, neither blocks the other's release, and your core stays small.",[10,9230,9231,9232,9234],{},"That only works if the contract between core and plugin is genuinely stable. If plugins reach into your internal modules, every refactor breaks the ecosystem. So the core API is whatever you promise ",[23,9233,8462],{}," to break: the entry-point group name, the protocol methods plugins implement, and the type of any context you pass in. Treat it like a public API with semantic versioning.",[10,9236,9237,9238,9240],{},"If you haven't yet structured the core CLI itself, read ",[97,9239,1285],{"href":1284}," first — the plugin layer sits on top of a clean command tree.",[36,9242,9244],{"id":9243},"the-entry-points-mechanism-in-pyprojecttoml","The entry-points mechanism in pyproject.toml",[10,9246,9247],{},"Entry points are metadata that a package advertises at install time. Python's packaging tooling records them, and any other process can query them by group. A plugin package declares its plugin like this:",[126,9249,9251],{"className":128,"code":9250,"language":130,"meta":131,"style":131},"# pyproject.toml of a plugin package, e.g. \"mycli-plugin-greet\"\n[project]\nname = \"mycli-plugin-greet\"\nversion = \"0.2.0\"\ndependencies = [\"mycli-core>=1.4,\u003C2\"]   # depend on the stable core API\n\n# The group name is YOUR namespace — pick something unique to your CLI.\n[project.entry-points.\"mycli.plugins\"]\ngreet = \"mycli_plugin_greet:GreetPlugin\"\n",[14,9252,9253,9258,9266,9273,9280,9294,9298,9303,9324],{"__ignoreMap":131},[135,9254,9255],{"class":137,"line":138},[135,9256,9257],{"class":669},"# pyproject.toml of a plugin package, e.g. \"mycli-plugin-greet\"\n",[135,9259,9260,9262,9264],{"class":137,"line":152},[135,9261,142],{"class":141},[135,9263,146],{"class":145},[135,9265,149],{"class":141},[135,9267,9268,9270],{"class":137,"line":162},[135,9269,155],{"class":141},[135,9271,9272],{"class":158},"\"mycli-plugin-greet\"\n",[135,9274,9275,9277],{"class":137,"line":171},[135,9276,165],{"class":141},[135,9278,9279],{"class":158},"\"0.2.0\"\n",[135,9281,9282,9285,9288,9291],{"class":137,"line":180},[135,9283,9284],{"class":141},"dependencies = [",[135,9286,9287],{"class":158},"\"mycli-core>=1.4,\u003C2\"",[135,9289,9290],{"class":141},"]   ",[135,9292,9293],{"class":669},"# depend on the stable core API\n",[135,9295,9296],{"class":137,"line":187},[135,9297,184],{"emptyLinePlaceholder":183},[135,9299,9300],{"class":137,"line":201},[135,9301,9302],{"class":669},"# The group name is YOUR namespace — pick something unique to your CLI.\n",[135,9304,9305,9307,9309,9311,9313,9315,9318,9320,9322],{"class":137,"line":210},[135,9306,142],{"class":141},[135,9308,146],{"class":145},[135,9310,61],{"class":141},[135,9312,808],{"class":145},[135,9314,61],{"class":141},[135,9316,9317],{"class":145},"\"mycli",[135,9319,61],{"class":141},[135,9321,818],{"class":145},[135,9323,149],{"class":141},[135,9325,9326,9329],{"class":137,"line":215},[135,9327,9328],{"class":141},"greet = ",[135,9330,9331],{"class":158},"\"mycli_plugin_greet:GreetPlugin\"\n",[10,9333,9334,9335,9338,9339,9342,9343,9346,9347,9350,9351,9354,9355,9358,9359,9362,9363,9366,9367,61],{},"The group ",[14,9336,9337],{},"\"mycli.plugins\""," is the rendezvous point: your core CLI will look it up by exactly this string. The key (",[14,9340,9341],{},"greet",") is the plugin's advertised name; the value (",[14,9344,9345],{},"mycli_plugin_greet:GreetPlugin",") is an ",[14,9348,9349],{},"import.path:object"," reference. The ",[14,9352,9353],{},":object"," part can point at a class, a factory function, or anything callable\u002Floadable. Note the ",[14,9356,9357],{},"dependencies"," line — the plugin pins the ",[23,9360,9361],{},"core"," package's version range, which is how compatibility gets enforced at install time before your runtime checks even run. This is the same ",[14,9364,9365],{},"[project.entry-points]"," table that powers console scripts; for the mechanics of that side, see ",[97,9368,5],{"href":9070},[36,9370,9372],{"id":9371},"discovering-plugins-at-runtime","Discovering plugins at runtime",[10,9374,9375,9376,9379,9380,9383,9384,9387],{},"On the core side, discovery is a single standard-library call. Since Python 3.10, ",[14,9377,9378],{},"entry_points()"," accepts a ",[14,9381,9382],{},"group="," keyword and returns a filtered ",[14,9385,9386],{},"EntryPoints"," collection:",[126,9389,9391],{"className":316,"code":9390,"language":318,"meta":131,"style":131},"from importlib.metadata import entry_points\n\ndef discover(group: str = \"mycli.plugins\"):\n    for ep in entry_points(group=group):\n        plugin_cls = ep.load()   # imports the module and resolves the object\n        yield ep.name, plugin_cls()\n",[14,9392,9393,9403,9407,9426,9443,9455],{"__ignoreMap":131},[135,9394,9395,9397,9399,9401],{"class":137,"line":138},[135,9396,334],{"class":325},[135,9398,887],{"class":141},[135,9400,326],{"class":325},[135,9402,892],{"class":141},[135,9404,9405],{"class":137,"line":152},[135,9406,184],{"emptyLinePlaceholder":183},[135,9408,9409,9411,9414,9417,9419,9421,9424],{"class":137,"line":162},[135,9410,493],{"class":325},[135,9412,9413],{"class":145}," discover",[135,9415,9416],{"class":141},"(group: ",[135,9418,1663],{"class":350},[135,9420,2150],{"class":325},[135,9422,9423],{"class":158}," \"mycli.plugins\"",[135,9425,986],{"class":141},[135,9427,9428,9430,9432,9434,9436,9438,9440],{"class":137,"line":171},[135,9429,2277],{"class":325},[135,9431,930],{"class":141},[135,9433,933],{"class":325},[135,9435,911],{"class":141},[135,9437,915],{"class":914},[135,9439,516],{"class":325},[135,9441,9442],{"class":141},"group):\n",[135,9444,9445,9448,9450,9452],{"class":137,"line":180},[135,9446,9447],{"class":141},"        plugin_cls ",[135,9449,516],{"class":325},[135,9451,946],{"class":141},[135,9453,9454],{"class":669},"# imports the module and resolves the object\n",[135,9456,9457,9460],{"class":137,"line":187},[135,9458,9459],{"class":325},"        yield",[135,9461,9462],{"class":141}," ep.name, plugin_cls()\n",[10,9464,9465,9467],{},[14,9466,1176],{}," performs the import lazily — nothing in a plugin package is imported until you ask for it. That keeps startup fast and means an uninstalled or broken module only fails when you actually try to load it (which is where error isolation comes in). The call shape is exactly what we validated:",[126,9469,9471],{"className":316,"code":9470,"language":318,"meta":131,"style":131},">>> from importlib.metadata import entry_points\n>>> eps = entry_points(group=\"mycli.plugins\")\n>>> type(eps).__name__\n'EntryPoints'\n>>> [ep.name for ep in eps]   # empty until plugin packages are installed\n[]\n",[14,9472,9473,9485,9504,9517,9522,9540],{"__ignoreMap":131},[135,9474,9475,9477,9479,9481,9483],{"class":137,"line":138},[135,9476,1021],{"class":325},[135,9478,1024],{"class":325},[135,9480,887],{"class":141},[135,9482,326],{"class":325},[135,9484,892],{"class":141},[135,9486,9487,9489,9492,9494,9496,9498,9500,9502],{"class":137,"line":152},[135,9488,1021],{"class":325},[135,9490,9491],{"class":141}," eps ",[135,9493,516],{"class":325},[135,9495,911],{"class":141},[135,9497,915],{"class":914},[135,9499,516],{"class":325},[135,9501,9337],{"class":158},[135,9503,550],{"class":141},[135,9505,9506,9508,9511,9514],{"class":137,"line":162},[135,9507,1021],{"class":325},[135,9509,9510],{"class":350}," type",[135,9512,9513],{"class":141},"(eps).",[135,9515,9516],{"class":350},"__name__\n",[135,9518,9519],{"class":137,"line":171},[135,9520,9521],{"class":158},"'EntryPoints'\n",[135,9523,9524,9526,9528,9530,9532,9534,9537],{"class":137,"line":180},[135,9525,1021],{"class":325},[135,9527,1061],{"class":141},[135,9529,927],{"class":325},[135,9531,930],{"class":141},[135,9533,933],{"class":325},[135,9535,9536],{"class":141}," eps]   ",[135,9538,9539],{"class":669},"# empty until plugin packages are installed\n",[135,9541,9542],{"class":137,"line":187},[135,9543,9544],{"class":141},"[]\n",[5668,9546,9547],{},[10,9548,9549,9550,9552,9553,9555,9556,9559,9560,9563],{},"Compatibility note: on Python 3.9 the ",[14,9551,9382],{}," keyword does not exist — you call ",[14,9554,9378],{}," with no args and index the returned dict with ",[14,9557,9558],{},"eps.get(\"mycli.plugins\", [])",". If you support 3.9, branch on ",[14,9561,9562],{},"sys.version_info",". From 3.10 onward the keyword form above is canonical.",[36,9565,9567],{"id":9566},"defining-the-plugin-contract-with-protocol","Defining the plugin contract with Protocol",[10,9569,9570,9571,9573,9574,9577,9578,9581,9582,61],{},"Rather than a base class plugins must subclass, use a ",[14,9572,9159],{},". Structural typing means a plugin only has to ",[23,9575,9576],{},"have the right shape"," — it never imports your class, which keeps coupling minimal and makes plugins testable in isolation. Mark it ",[14,9579,9580],{},"@runtime_checkable"," so the loader can verify the shape with ",[14,9583,9584],{},"isinstance",[126,9586,9588],{"className":316,"code":9587,"language":318,"meta":131,"style":131},"from typing import Protocol, runtime_checkable\nimport typer\n\nAPI_VERSION = 1\n\n@runtime_checkable\nclass CLIPlugin(Protocol):\n    name: str\n    api_version: int\n    def register(self, app: typer.Typer) -> None: ...\n",[14,9589,9590,9601,9607,9611,9620,9624,9629,9643,9651,9659],{"__ignoreMap":131},[135,9591,9592,9594,9596,9598],{"class":137,"line":138},[135,9593,334],{"class":325},[135,9595,1555],{"class":141},[135,9597,326],{"class":325},[135,9599,9600],{"class":141}," Protocol, runtime_checkable\n",[135,9602,9603,9605],{"class":137,"line":152},[135,9604,326],{"class":325},[135,9606,2056],{"class":141},[135,9608,9609],{"class":137,"line":162},[135,9610,184],{"emptyLinePlaceholder":183},[135,9612,9613,9616,9618],{"class":137,"line":171},[135,9614,9615],{"class":350},"API_VERSION",[135,9617,2150],{"class":325},[135,9619,558],{"class":350},[135,9621,9622],{"class":137,"line":180},[135,9623,184],{"emptyLinePlaceholder":183},[135,9625,9626],{"class":137,"line":187},[135,9627,9628],{"class":145},"@runtime_checkable\n",[135,9630,9631,9633,9636,9638,9641],{"class":137,"line":201},[135,9632,1581],{"class":325},[135,9634,9635],{"class":145}," CLIPlugin",[135,9637,544],{"class":141},[135,9639,9640],{"class":145},"Protocol",[135,9642,986],{"class":141},[135,9644,9645,9648],{"class":137,"line":210},[135,9646,9647],{"class":141},"    name: ",[135,9649,9650],{"class":350},"str\n",[135,9652,9653,9656],{"class":137,"line":215},[135,9654,9655],{"class":141},"    api_version: ",[135,9657,9658],{"class":350},"int\n",[135,9660,9661,9663,9666,9669,9671,9673],{"class":137,"line":225},[135,9662,1777],{"class":325},[135,9664,9665],{"class":145}," register",[135,9667,9668],{"class":141},"(self, app: typer.Typer) -> ",[135,9670,3093],{"class":350},[135,9672,2344],{"class":141},[135,9674,9675],{"class":350},"...\n",[10,9677,9678,9679,9682,9683,9685,9686,9689,9690,9693,9694,459,9697,9700,9701,9703],{},"Three things make up the stable contract here: an identifying ",[14,9680,9681],{},"name",", a declared ",[14,9684,9202],{}," the loader can gate on, and a single ",[14,9687,9688],{},"register(app)"," method that receives the Typer (or Click) application and wires in commands. Plugins never touch your internals — they only call ",[14,9691,9692],{},"app.command()",". This is also where the Typer-vs-Click choice matters: Typer's decorator-based registration is ergonomic for plugin authors, while Click gives you ",[14,9695,9696],{},"add_command",[14,9698,9699],{},"Group"," objects that are easier to compose programmatically. See ",[97,9702,9038],{"href":9037}," for the trade-off.",[36,9705,9707],{"id":9706},"loading-and-registering-into-a-typer-app-with-isolation","Loading and registering into a Typer app — with isolation",[10,9709,9710,9711,9714],{},"Here is the complete, validated loader. It checks the protocol, gates on version, and isolates registration failures. In production the candidate list comes from ",[14,9712,9713],{},"discover()"," above; in this self-contained example we hand it the objects directly so it runs with no installed packages.",[126,9716,9718],{"className":316,"code":9717,"language":318,"meta":131,"style":131},"from __future__ import annotations\nfrom typing import Protocol, runtime_checkable\nimport typer\n\n@runtime_checkable\nclass CLIPlugin(Protocol):\n    name: str\n    api_version: int\n    def register(self, app: typer.Typer) -> None: ...\n\nAPI_VERSION = 1\n\nclass GreetPlugin:\n    name = \"greet\"\n    api_version = 1\n    def register(self, app: typer.Typer) -> None:\n        @app.command()\n        def greet(who: str = \"world\") -> None:\n            print(f\"hello, {who}\")\n\nclass StatsPlugin:\n    name = \"stats\"\n    api_version = 1\n    def register(self, app: typer.Typer) -> None:\n        @app.command()\n        def stats(n: int) -> None:\n            print(f\"sum 0..{n} = {sum(range(n + 1))}\")\n\nclass BrokenPlugin:            # raises during register\n    name = \"broken\"\n    api_version = 1\n    def register(self, app: typer.Typer) -> None:\n        raise RuntimeError(\"boom during register\")\n\nclass StalePlugin:             # built for an incompatible API version\n    name = \"stale\"\n    api_version = 99\n    def register(self, app: typer.Typer) -> None:\n        @app.command()\n        def stale() -> None:\n            print(\"should never load\")\n\n# In production: DISCOVERED = [obj for _, obj in discover(\"mycli.plugins\")]\nDISCOVERED = [GreetPlugin(), StatsPlugin(), BrokenPlugin(), StalePlugin()]\n\ndef load_plugins(app: typer.Typer, candidates) -> list[str]:\n    loaded: list[str] = []\n    for plugin in candidates:\n        if not isinstance(plugin, CLIPlugin):                 # contract check\n            print(f\"[skip] {plugin!r}: does not satisfy CLIPlugin protocol\")\n            continue\n        if plugin.api_version != API_VERSION:                 # version\u002Fcompat gate\n            print(f\"[skip] {plugin.name}: api_version {plugin.api_version} != {API_VERSION}\")\n            continue\n        try:                                                  # error isolation\n            plugin.register(app)\n        except Exception as exc:                              # noqa: BLE001\n            print(f\"[error] {plugin.name}: failed to register: {exc}\")\n            continue\n        loaded.append(plugin.name)\n        print(f\"[ok] loaded plugin: {plugin.name}\")\n    return loaded\n\nif __name__ == \"__main__\":\n    app = typer.Typer()\n    loaded = load_plugins(app, DISCOVERED)\n    print(\"ACTIVE PLUGINS:\", loaded)\n    print(\"COMMANDS:\", sorted(c.callback.__name__ for c in app.registered_commands))\n",[14,9719,9720,9730,9740,9746,9750,9754,9766,9772,9778,9792,9796,9804,9808,9817,9826,9835,9847,9854,9878,9901,9905,9914,9923,9931,9943,9949,9967,10012,10016,10029,10038,10046,10058,10072,10076,10089,10098,10107,10119,10125,10138,10149,10153,10158,10168,10172,10187,10200,10212,10226,10251,10256,10275,10312,10316,10326,10331,10346,10376,10380,10385,10406,10413,10417,10430,10441,10456,10469],{"__ignoreMap":131},[135,9721,9722,9724,9726,9728],{"class":137,"line":138},[135,9723,334],{"class":325},[135,9725,2586],{"class":350},[135,9727,2589],{"class":325},[135,9729,2592],{"class":141},[135,9731,9732,9734,9736,9738],{"class":137,"line":152},[135,9733,334],{"class":325},[135,9735,1555],{"class":141},[135,9737,326],{"class":325},[135,9739,9600],{"class":141},[135,9741,9742,9744],{"class":137,"line":162},[135,9743,326],{"class":325},[135,9745,2056],{"class":141},[135,9747,9748],{"class":137,"line":171},[135,9749,184],{"emptyLinePlaceholder":183},[135,9751,9752],{"class":137,"line":180},[135,9753,9628],{"class":145},[135,9755,9756,9758,9760,9762,9764],{"class":137,"line":187},[135,9757,1581],{"class":325},[135,9759,9635],{"class":145},[135,9761,544],{"class":141},[135,9763,9640],{"class":145},[135,9765,986],{"class":141},[135,9767,9768,9770],{"class":137,"line":201},[135,9769,9647],{"class":141},[135,9771,9650],{"class":350},[135,9773,9774,9776],{"class":137,"line":210},[135,9775,9655],{"class":141},[135,9777,9658],{"class":350},[135,9779,9780,9782,9784,9786,9788,9790],{"class":137,"line":215},[135,9781,1777],{"class":325},[135,9783,9665],{"class":145},[135,9785,9668],{"class":141},[135,9787,3093],{"class":350},[135,9789,2344],{"class":141},[135,9791,9675],{"class":350},[135,9793,9794],{"class":137,"line":225},[135,9795,184],{"emptyLinePlaceholder":183},[135,9797,9798,9800,9802],{"class":137,"line":236},[135,9799,9615],{"class":350},[135,9801,2150],{"class":325},[135,9803,558],{"class":350},[135,9805,9806],{"class":137,"line":606},[135,9807,184],{"emptyLinePlaceholder":183},[135,9809,9810,9812,9815],{"class":137,"line":619},[135,9811,1581],{"class":325},[135,9813,9814],{"class":145}," GreetPlugin",[135,9816,360],{"class":141},[135,9818,9819,9821,9823],{"class":137,"line":1752},[135,9820,3071],{"class":141},[135,9822,516],{"class":325},[135,9824,9825],{"class":158}," \"greet\"\n",[135,9827,9828,9831,9833],{"class":137,"line":1765},[135,9829,9830],{"class":141},"    api_version ",[135,9832,516],{"class":325},[135,9834,558],{"class":350},[135,9836,9837,9839,9841,9843,9845],{"class":137,"line":1774},[135,9838,1777],{"class":325},[135,9840,9665],{"class":145},[135,9842,9668],{"class":141},[135,9844,3093],{"class":350},[135,9846,360],{"class":141},[135,9848,9849,9852],{"class":137,"line":1795},[135,9850,9851],{"class":145},"        @app.command",[135,9853,2135],{"class":141},[135,9855,9856,9859,9862,9865,9867,9869,9872,9874,9876],{"class":137,"line":1830},[135,9857,9858],{"class":325},"        def",[135,9860,9861],{"class":145}," greet",[135,9863,9864],{"class":141},"(who: ",[135,9866,1663],{"class":350},[135,9868,2150],{"class":325},[135,9870,9871],{"class":158}," \"world\"",[135,9873,1788],{"class":141},[135,9875,3093],{"class":350},[135,9877,360],{"class":141},[135,9879,9880,9883,9885,9887,9890,9892,9895,9897,9899],{"class":137,"line":1846},[135,9881,9882],{"class":350},"            print",[135,9884,544],{"class":141},[135,9886,568],{"class":325},[135,9888,9889],{"class":158},"\"hello, ",[135,9891,574],{"class":350},[135,9893,9894],{"class":141},"who",[135,9896,586],{"class":350},[135,9898,589],{"class":158},[135,9900,550],{"class":141},[135,9902,9903],{"class":137,"line":1854},[135,9904,184],{"emptyLinePlaceholder":183},[135,9906,9907,9909,9912],{"class":137,"line":1859},[135,9908,1581],{"class":325},[135,9910,9911],{"class":145}," StatsPlugin",[135,9913,360],{"class":141},[135,9915,9916,9918,9920],{"class":137,"line":1877},[135,9917,3071],{"class":141},[135,9919,516],{"class":325},[135,9921,9922],{"class":158}," \"stats\"\n",[135,9924,9925,9927,9929],{"class":137,"line":1893},[135,9926,9830],{"class":141},[135,9928,516],{"class":325},[135,9930,558],{"class":350},[135,9932,9933,9935,9937,9939,9941],{"class":137,"line":1926},[135,9934,1777],{"class":325},[135,9936,9665],{"class":145},[135,9938,9668],{"class":141},[135,9940,3093],{"class":350},[135,9942,360],{"class":141},[135,9944,9945,9947],{"class":137,"line":1940},[135,9946,9851],{"class":145},[135,9948,2135],{"class":141},[135,9950,9951,9953,9956,9959,9961,9963,9965],{"class":137,"line":2906},[135,9952,9858],{"class":325},[135,9954,9955],{"class":145}," stats",[135,9957,9958],{"class":141},"(n: ",[135,9960,387],{"class":350},[135,9962,1788],{"class":141},[135,9964,3093],{"class":350},[135,9966,360],{"class":141},[135,9968,9969,9971,9973,9975,9978,9980,9982,9984,9987,9990,9992,9994,9997,10000,10003,10006,10008,10010],{"class":137,"line":2931},[135,9970,9882],{"class":350},[135,9972,544],{"class":141},[135,9974,568],{"class":325},[135,9976,9977],{"class":158},"\"sum 0..",[135,9979,574],{"class":350},[135,9981,7208],{"class":141},[135,9983,586],{"class":350},[135,9985,9986],{"class":158}," = ",[135,9988,9989],{"class":350},"{sum",[135,9991,544],{"class":141},[135,9993,7247],{"class":350},[135,9995,9996],{"class":141},"(n ",[135,9998,9999],{"class":325},"+",[135,10001,10002],{"class":350}," 1",[135,10004,10005],{"class":141},"))",[135,10007,586],{"class":350},[135,10009,589],{"class":158},[135,10011,550],{"class":141},[135,10013,10014],{"class":137,"line":2944},[135,10015,184],{"emptyLinePlaceholder":183},[135,10017,10018,10020,10023,10026],{"class":137,"line":3266},[135,10019,1581],{"class":325},[135,10021,10022],{"class":145}," BrokenPlugin",[135,10024,10025],{"class":141},":            ",[135,10027,10028],{"class":669},"# raises during register\n",[135,10030,10031,10033,10035],{"class":137,"line":3277},[135,10032,3071],{"class":141},[135,10034,516],{"class":325},[135,10036,10037],{"class":158}," \"broken\"\n",[135,10039,10040,10042,10044],{"class":137,"line":3290},[135,10041,9830],{"class":141},[135,10043,516],{"class":325},[135,10045,558],{"class":350},[135,10047,10048,10050,10052,10054,10056],{"class":137,"line":3295},[135,10049,1777],{"class":325},[135,10051,9665],{"class":145},[135,10053,9668],{"class":141},[135,10055,3093],{"class":350},[135,10057,360],{"class":141},[135,10059,10060,10062,10065,10067,10070],{"class":137,"line":3315},[135,10061,2108],{"class":325},[135,10063,10064],{"class":350}," RuntimeError",[135,10066,544],{"class":141},[135,10068,10069],{"class":158},"\"boom during register\"",[135,10071,550],{"class":141},[135,10073,10074],{"class":137,"line":3329},[135,10075,184],{"emptyLinePlaceholder":183},[135,10077,10078,10080,10083,10086],{"class":137,"line":3337},[135,10079,1581],{"class":325},[135,10081,10082],{"class":145}," StalePlugin",[135,10084,10085],{"class":141},":             ",[135,10087,10088],{"class":669},"# built for an incompatible API version\n",[135,10090,10091,10093,10095],{"class":137,"line":3350},[135,10092,3071],{"class":141},[135,10094,516],{"class":325},[135,10096,10097],{"class":158}," \"stale\"\n",[135,10099,10100,10102,10104],{"class":137,"line":3365},[135,10101,9830],{"class":141},[135,10103,516],{"class":325},[135,10105,10106],{"class":350}," 99\n",[135,10108,10109,10111,10113,10115,10117],{"class":137,"line":3373},[135,10110,1777],{"class":325},[135,10112,9665],{"class":145},[135,10114,9668],{"class":141},[135,10116,3093],{"class":350},[135,10118,360],{"class":141},[135,10120,10121,10123],{"class":137,"line":3406},[135,10122,9851],{"class":145},[135,10124,2135],{"class":141},[135,10126,10127,10129,10132,10134,10136],{"class":137,"line":3415},[135,10128,9858],{"class":325},[135,10130,10131],{"class":145}," stale",[135,10133,499],{"class":141},[135,10135,3093],{"class":350},[135,10137,360],{"class":141},[135,10139,10140,10142,10144,10147],{"class":137,"line":3429},[135,10141,9882],{"class":350},[135,10143,544],{"class":141},[135,10145,10146],{"class":158},"\"should never load\"",[135,10148,550],{"class":141},[135,10150,10151],{"class":137,"line":3466},[135,10152,184],{"emptyLinePlaceholder":183},[135,10154,10155],{"class":137,"line":3473},[135,10156,10157],{"class":669},"# In production: DISCOVERED = [obj for _, obj in discover(\"mycli.plugins\")]\n",[135,10159,10160,10163,10165],{"class":137,"line":3478},[135,10161,10162],{"class":350},"DISCOVERED",[135,10164,2150],{"class":325},[135,10166,10167],{"class":141}," [GreetPlugin(), StatsPlugin(), BrokenPlugin(), StalePlugin()]\n",[135,10169,10170],{"class":137,"line":3486},[135,10171,184],{"emptyLinePlaceholder":183},[135,10173,10174,10176,10179,10182,10184],{"class":137,"line":3500},[135,10175,493],{"class":325},[135,10177,10178],{"class":145}," load_plugins",[135,10180,10181],{"class":141},"(app: typer.Typer, candidates) -> list[",[135,10183,1663],{"class":350},[135,10185,10186],{"class":141},"]:\n",[135,10188,10189,10192,10194,10196,10198],{"class":137,"line":3510},[135,10190,10191],{"class":141},"    loaded: list[",[135,10193,1663],{"class":350},[135,10195,2750],{"class":141},[135,10197,516],{"class":325},[135,10199,2272],{"class":141},[135,10201,10202,10204,10207,10209],{"class":137,"line":3522},[135,10203,2277],{"class":325},[135,10205,10206],{"class":141}," plugin ",[135,10208,933],{"class":325},[135,10210,10211],{"class":141}," candidates:\n",[135,10213,10214,10216,10218,10220,10223],{"class":137,"line":3554},[135,10215,1798],{"class":325},[135,10217,533],{"class":325},[135,10219,3129],{"class":350},[135,10221,10222],{"class":141},"(plugin, CLIPlugin):                 ",[135,10224,10225],{"class":669},"# contract check\n",[135,10227,10228,10230,10232,10234,10237,10239,10242,10244,10246,10249],{"class":137,"line":3586},[135,10229,9882],{"class":350},[135,10231,544],{"class":141},[135,10233,568],{"class":325},[135,10235,10236],{"class":158},"\"[skip] ",[135,10238,574],{"class":350},[135,10240,10241],{"class":141},"plugin",[135,10243,3447],{"class":325},[135,10245,586],{"class":350},[135,10247,10248],{"class":158},": does not satisfy CLIPlugin protocol\"",[135,10250,550],{"class":141},[135,10252,10253],{"class":137,"line":3607},[135,10254,10255],{"class":325},"            continue\n",[135,10257,10258,10260,10263,10266,10269,10272],{"class":137,"line":3612},[135,10259,1798],{"class":325},[135,10261,10262],{"class":141}," plugin.api_version ",[135,10264,10265],{"class":325},"!=",[135,10267,10268],{"class":350}," API_VERSION",[135,10270,10271],{"class":141},":                 ",[135,10273,10274],{"class":669},"# version\u002Fcompat gate\n",[135,10276,10277,10279,10281,10283,10285,10287,10290,10292,10295,10297,10300,10302,10305,10308,10310],{"class":137,"line":3617},[135,10278,9882],{"class":350},[135,10280,544],{"class":141},[135,10282,568],{"class":325},[135,10284,10236],{"class":158},[135,10286,574],{"class":350},[135,10288,10289],{"class":141},"plugin.name",[135,10291,586],{"class":350},[135,10293,10294],{"class":158},": api_version ",[135,10296,574],{"class":350},[135,10298,10299],{"class":141},"plugin.api_version",[135,10301,586],{"class":350},[135,10303,10304],{"class":158}," != ",[135,10306,10307],{"class":350},"{API_VERSION}",[135,10309,589],{"class":158},[135,10311,550],{"class":141},[135,10313,10314],{"class":137,"line":3625},[135,10315,10255],{"class":325},[135,10317,10318,10320,10323],{"class":137,"line":3656},[135,10319,3165],{"class":325},[135,10321,10322],{"class":141},":                                                  ",[135,10324,10325],{"class":669},"# error isolation\n",[135,10327,10328],{"class":137,"line":3669},[135,10329,10330],{"class":141},"            plugin.register(app)\n",[135,10332,10333,10335,10338,10340,10343],{"class":137,"line":3683},[135,10334,3182],{"class":325},[135,10336,10337],{"class":350}," Exception",[135,10339,3424],{"class":325},[135,10341,10342],{"class":141}," exc:                              ",[135,10344,10345],{"class":669},"# noqa: BLE001\n",[135,10347,10348,10350,10352,10354,10357,10359,10361,10363,10366,10368,10370,10372,10374],{"class":137,"line":3689},[135,10349,9882],{"class":350},[135,10351,544],{"class":141},[135,10353,568],{"class":325},[135,10355,10356],{"class":158},"\"[error] ",[135,10358,574],{"class":350},[135,10360,10289],{"class":141},[135,10362,586],{"class":350},[135,10364,10365],{"class":158},": failed to register: ",[135,10367,574],{"class":350},[135,10369,5555],{"class":141},[135,10371,586],{"class":350},[135,10373,589],{"class":158},[135,10375,550],{"class":141},[135,10377,10378],{"class":137,"line":3718},[135,10379,10255],{"class":325},[135,10381,10382],{"class":137,"line":3746},[135,10383,10384],{"class":141},"        loaded.append(plugin.name)\n",[135,10386,10387,10389,10391,10393,10396,10398,10400,10402,10404],{"class":137,"line":3752},[135,10388,541],{"class":350},[135,10390,544],{"class":141},[135,10392,568],{"class":325},[135,10394,10395],{"class":158},"\"[ok] loaded plugin: ",[135,10397,574],{"class":350},[135,10399,10289],{"class":141},[135,10401,586],{"class":350},[135,10403,589],{"class":158},[135,10405,550],{"class":141},[135,10407,10408,10410],{"class":137,"line":3757},[135,10409,596],{"class":325},[135,10411,10412],{"class":141}," loaded\n",[135,10414,10415],{"class":137,"line":3770},[135,10416,184],{"emptyLinePlaceholder":183},[135,10418,10420,10422,10424,10426,10428],{"class":137,"line":10419},64,[135,10421,347],{"class":325},[135,10423,351],{"class":350},[135,10425,354],{"class":325},[135,10427,357],{"class":158},[135,10429,360],{"class":141},[135,10431,10433,10436,10438],{"class":137,"line":10432},65,[135,10434,10435],{"class":141},"    app ",[135,10437,516],{"class":325},[135,10439,10440],{"class":141}," typer.Typer()\n",[135,10442,10444,10447,10449,10452,10454],{"class":137,"line":10443},66,[135,10445,10446],{"class":141},"    loaded ",[135,10448,516],{"class":325},[135,10450,10451],{"class":141}," load_plugins(app, ",[135,10453,10162],{"class":350},[135,10455,550],{"class":141},[135,10457,10459,10461,10463,10466],{"class":137,"line":10458},67,[135,10460,563],{"class":350},[135,10462,544],{"class":141},[135,10464,10465],{"class":158},"\"ACTIVE PLUGINS:\"",[135,10467,10468],{"class":141},", loaded)\n",[135,10470,10472,10474,10476,10479,10481,10484,10487,10489,10491,10493,10495],{"class":137,"line":10471},68,[135,10473,563],{"class":350},[135,10475,544],{"class":141},[135,10477,10478],{"class":158},"\"COMMANDS:\"",[135,10480,861],{"class":141},[135,10482,10483],{"class":350},"sorted",[135,10485,10486],{"class":141},"(c.callback.",[135,10488,6010],{"class":350},[135,10490,663],{"class":325},[135,10492,1812],{"class":141},[135,10494,933],{"class":325},[135,10496,10497],{"class":141}," app.registered_commands))\n",[10,10499,10500],{},"Running this against Typer 0.26 \u002F Click 8.4 on Python 3.14 produces:",[126,10502,10505],{"className":10503,"code":10504,"language":5596,"meta":131},[5594],"[ok] loaded plugin: greet\n[ok] loaded plugin: stats\n[error] broken: failed to register: boom during register\n[skip] stale: api_version 99 != 1\nACTIVE PLUGINS: ['greet', 'stats']\nCOMMANDS: ['greet', 'stats']\n",[14,10506,10504],{"__ignoreMap":131},[10,10508,10509,10510,10512,10513,10516,10517,61],{},"The two good plugins load, the broken one is caught and logged, the version-mismatched one is skipped — and the CLI keeps running with a coherent command set. For Click, the shape is identical: swap the parameter type to ",[14,10511,436],{}," and have ",[14,10514,10515],{},"register"," call ",[14,10518,10519],{},"app.add_command(some_command)",[36,10521,10523],{"id":10522},"version-compatibility-and-error-isolation","Version, compatibility, and error isolation",[10,10525,10526],{},"Two failure modes dominate plugin systems, and both are visible above.",[10,10528,10529,10532,10533,10535,10536,10539,10540,10542,10543,10545],{},[72,10530,10531],{},"Compatibility drift."," When you change the core API, old plugins built against the previous contract may call methods that no longer exist or pass the wrong context shape. The defenses layer up: the plugin's ",[14,10534,29],{}," pins ",[14,10537,10538],{},"mycli-core>=1.4,\u003C2",", so a major bump won't even install together; and the runtime ",[14,10541,9202],{}," gate refuses anything that slips through. Bump ",[14,10544,9615],{}," only on breaking changes, and treat it like the major component of a semver contract.",[10,10547,10548,10551,10552,10555,10556,10558,10559,10562,10563,10565,10566,10569,10570,10573,10574,10576],{},[72,10549,10550],{},"Crash propagation."," The cardinal rule: ",[23,10553,10554],{},"one bad plugin must never crash the CLI."," Discovery (",[14,10557,1176],{},") and registration (",[14,10560,10561],{},"plugin.register(app)",") are the two points where arbitrary third-party code runs, so both belong inside ",[14,10564,9198],{},". Catching broad ",[14,10567,10568],{},"Exception"," is the correct call here even though linters flag it — you genuinely cannot predict what a third-party plugin will raise, and the goal is graceful degradation. Log the failure (with a real logger and a ",[14,10571,10572],{},"--debug","-gated traceback in production rather than a bare ",[14,10575,8192],{},"), skip the plugin, and carry on.",[10,10578,10579],{},"A robust loader wraps discovery the same way:",[126,10581,10583],{"className":316,"code":10582,"language":318,"meta":131,"style":131},"def discover(group=\"mycli.plugins\"):\n    from importlib.metadata import entry_points\n    for ep in entry_points(group=group):\n        try:\n            yield ep.name, ep.load()()\n        except Exception as exc:        # bad import, missing dep, syntax error\n            logging.getLogger(__name__).warning(\"plugin %s failed to load: %s\", ep.name, exc)\n",[14,10584,10585,10600,10611,10627,10633,10641,10655],{"__ignoreMap":131},[135,10586,10587,10589,10591,10594,10596,10598],{"class":137,"line":138},[135,10588,493],{"class":325},[135,10590,9413],{"class":145},[135,10592,10593],{"class":141},"(group",[135,10595,516],{"class":325},[135,10597,9337],{"class":158},[135,10599,986],{"class":141},[135,10601,10602,10605,10607,10609],{"class":137,"line":152},[135,10603,10604],{"class":325},"    from",[135,10606,887],{"class":141},[135,10608,326],{"class":325},[135,10610,892],{"class":141},[135,10612,10613,10615,10617,10619,10621,10623,10625],{"class":137,"line":162},[135,10614,2277],{"class":325},[135,10616,930],{"class":141},[135,10618,933],{"class":325},[135,10620,911],{"class":141},[135,10622,915],{"class":914},[135,10624,516],{"class":325},[135,10626,9442],{"class":141},[135,10628,10629,10631],{"class":137,"line":171},[135,10630,3165],{"class":325},[135,10632,360],{"class":141},[135,10634,10635,10638],{"class":137,"line":180},[135,10636,10637],{"class":325},"            yield",[135,10639,10640],{"class":141}," ep.name, ep.load()()\n",[135,10642,10643,10645,10647,10649,10652],{"class":137,"line":187},[135,10644,3182],{"class":325},[135,10646,10337],{"class":350},[135,10648,3424],{"class":325},[135,10650,10651],{"class":141}," exc:        ",[135,10653,10654],{"class":669},"# bad import, missing dep, syntax error\n",[135,10656,10657,10660,10662,10665,10668,10671,10674,10676,10678],{"class":137,"line":201},[135,10658,10659],{"class":141},"            logging.getLogger(",[135,10661,6010],{"class":350},[135,10663,10664],{"class":141},").warning(",[135,10666,10667],{"class":158},"\"plugin ",[135,10669,10670],{"class":350},"%s",[135,10672,10673],{"class":158}," failed to load: ",[135,10675,10670],{"class":350},[135,10677,589],{"class":158},[135,10679,10680],{"class":141},", ep.name, exc)\n",[36,10682,10684],{"id":10683},"security-considerations","Security considerations",[10,10686,10687,10688,10691,10692,10694,10695,10697],{},"Entry-point plugins are ",[72,10689,10690],{},"arbitrary code that runs in your process with your user's privileges."," There is no sandbox. Installing a plugin is exactly as dangerous as ",[14,10693,16],{}," of any package — the moment ",[14,10696,1176],{}," imports the module, its top-level code executes. Treat the plugin ecosystem as a supply-chain surface:",[41,10699,10700,10707,10718,10721],{},[44,10701,10702,10703,10706],{},"Don't auto-install plugins. Let users opt in explicitly, and surface which plugins are active (a ",[14,10704,10705],{},"mycli plugins list"," command that prints names, versions, and distribution origins builds trust).",[44,10708,10709,10710,10713,10714,10717],{},"Pin and audit. Plugins are dependencies; lock them and review them like any other. A typosquatted ",[14,10711,10712],{},"mycli-plugin-greet"," vs ",[14,10715,10716],{},"mycli_plugin_greet"," is a real attack vector.",[44,10719,10720],{},"Be wary of confused-deputy escalation: if your CLI runs with elevated rights (a deploy tool, say), every loaded plugin inherits them. Document this loudly.",[44,10722,10723,10724,10727,10728,10730],{},"A version gate is a ",[23,10725,10726],{},"compatibility"," control, not a ",[23,10729,6837],{}," one — it stops accidental breakage, not malicious code. Don't conflate the two.",[10,10732,10733],{},"For most internal tools the right posture is: plugins are trusted because you control the install, and the loader's job is robustness (isolation, versioning), not defense against hostile code. If you ever need to load genuinely untrusted plugins, an in-process entry-point system is the wrong tool — reach for subprocess isolation or a real sandbox.",[36,10735,1277],{"id":1276},[41,10737,10738,10743,10748,10756],{},[44,10739,10740,10742],{},[97,10741,1430],{"href":1429}," — the pillar this fits into.",[44,10744,10745,10747],{},[97,10746,1285],{"href":1284}," — the command tree your plugins extend.",[44,10749,10750,10752,10753,10755],{},[97,10751,5],{"href":9070}," — the ",[14,10754,9365],{}," mechanics in depth.",[44,10757,10758,10760],{},[97,10759,9038],{"href":9037}," — choosing the framework your plugins register against.",[1303,10762,10763],{},"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 .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}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":131,"searchDepth":152,"depth":152,"links":10765},[10766,10767,10768,10769,10770,10771,10772,10773,10774],{"id":38,"depth":152,"text":39},{"id":9212,"depth":152,"text":9213},{"id":9243,"depth":152,"text":9244},{"id":9371,"depth":152,"text":9372},{"id":9566,"depth":152,"text":9567},{"id":9706,"depth":152,"text":9707},{"id":10522,"depth":152,"text":10523},{"id":10683,"depth":152,"text":10684},{"id":1276,"depth":152,"text":1277},"Design Python CLI plugin systems using entry points, importlib.metadata, and protocol interfaces for independent feature shipping with a stable API.",{},"\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis",{"title":9151,"description":10775},"modern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis\u002Findex",[9146,808,10781,9075],"importlib","7i3ulCVJM4Zso0JegBAe0KgDXjNsyjUKJhHj0KqIg8Y",{"id":4,"title":5,"body":10784,"date":1320,"description":1321,"difficulty":1322,"draft":1323,"extension":1324,"meta":11703,"navigation":183,"path":1326,"seo":11704,"stem":1328,"tags":11705,"updated":1320,"__hash__":1333},{"type":7,"value":10785,"toc":11692},[10786,10798,10800,10834,10838,10842,10848,10926,10936,10964,10966,10976,11012,11028,11032,11034,11064,11072,11076,11196,11198,11244,11250,11254,11260,11286,11298,11300,11310,11346,11350,11352,11368,11468,11472,11476,11604,11614,11616,11628,11648,11662,11670,11672,11690],[10,10787,12,10788,17,10790,21,10792,26,10794,30,10796,34],{},[14,10789,16],{},[14,10791,20],{},[23,10793,25],{},[14,10795,29],{},[14,10797,33],{},[36,10799,39],{"id":38},[41,10801,10802,10806,10812,10816,10822,10828],{},[44,10803,46,10804,50],{},[14,10805,49],{},[44,10807,53,10808,57,10810,61],{},[14,10809,56],{},[14,10811,60],{},[44,10813,64,10814,67],{},[14,10815,33],{},[44,10817,70,10818,75,10820,79],{},[72,10819,74],{},[14,10821,78],{},[44,10823,10824,85,10826,61],{},[14,10825,84],{},[14,10827,88],{},[44,10829,91,10830,95,10832,61],{},[14,10831,94],{},[97,10833,100],{"href":99},[10,10835,10836],{},[104,10837],{"alt":106,"src":107},[36,10839,111,10840,114],{"id":110},[14,10841,56],{},[10,10843,117,10844,120,10846,124],{},[14,10845,29],{},[14,10847,123],{},[126,10849,10850],{"className":128,"code":129,"language":130,"meta":131,"style":131},[14,10851,10852,10860,10866,10872,10878,10882,10894,10900,10904,10912,10920],{"__ignoreMap":131},[135,10853,10854,10856,10858],{"class":137,"line":138},[135,10855,142],{"class":141},[135,10857,146],{"class":145},[135,10859,149],{"class":141},[135,10861,10862,10864],{"class":137,"line":152},[135,10863,155],{"class":141},[135,10865,159],{"class":158},[135,10867,10868,10870],{"class":137,"line":162},[135,10869,165],{"class":141},[135,10871,168],{"class":158},[135,10873,10874,10876],{"class":137,"line":171},[135,10875,174],{"class":141},[135,10877,177],{"class":158},[135,10879,10880],{"class":137,"line":180},[135,10881,184],{"emptyLinePlaceholder":183},[135,10883,10884,10886,10888,10890,10892],{"class":137,"line":187},[135,10885,142],{"class":141},[135,10887,146],{"class":145},[135,10889,61],{"class":141},[135,10891,196],{"class":145},[135,10893,149],{"class":141},[135,10895,10896,10898],{"class":137,"line":201},[135,10897,204],{"class":141},[135,10899,207],{"class":158},[135,10901,10902],{"class":137,"line":210},[135,10903,184],{"emptyLinePlaceholder":183},[135,10905,10906,10908,10910],{"class":137,"line":215},[135,10907,142],{"class":141},[135,10909,220],{"class":145},[135,10911,149],{"class":141},[135,10913,10914,10916,10918],{"class":137,"line":225},[135,10915,228],{"class":141},[135,10917,231],{"class":158},[135,10919,149],{"class":141},[135,10921,10922,10924],{"class":137,"line":236},[135,10923,239],{"class":141},[135,10925,242],{"class":158},[10,10927,245,10928,248,10930,252,10932,255,10934,260],{},[14,10929,56],{},[14,10931,251],{},[14,10933,56],{},[97,10935,259],{"href":258},[126,10937,10938],{"className":128,"code":263,"language":130,"meta":131,"style":131},[14,10939,10940,10952,10958],{"__ignoreMap":131},[135,10941,10942,10944,10946,10948,10950],{"class":137,"line":138},[135,10943,142],{"class":141},[135,10945,146],{"class":145},[135,10947,61],{"class":141},[135,10949,196],{"class":145},[135,10951,149],{"class":141},[135,10953,10954,10956],{"class":137,"line":152},[135,10955,204],{"class":141},[135,10957,207],{"class":158},[135,10959,10960,10962],{"class":137,"line":162},[135,10961,288],{"class":141},[135,10963,291],{"class":158},[36,10965,295],{"id":294},[10,10967,298,10968,301,10970,305,10972,309,10974,313],{},[14,10969,56],{},[14,10971,304],{},[14,10973,308],{},[14,10975,312],{},[126,10977,10978],{"className":316,"code":317,"language":318,"meta":131,"style":131},[14,10979,10980,10986,10996,11008],{"__ignoreMap":131},[135,10981,10982,10984],{"class":137,"line":138},[135,10983,326],{"class":325},[135,10985,329],{"class":141},[135,10987,10988,10990,10992,10994],{"class":137,"line":152},[135,10989,334],{"class":325},[135,10991,337],{"class":141},[135,10993,326],{"class":325},[135,10995,342],{"class":141},[135,10997,10998,11000,11002,11004,11006],{"class":137,"line":162},[135,10999,347],{"class":325},[135,11001,351],{"class":350},[135,11003,354],{"class":325},[135,11005,357],{"class":158},[135,11007,360],{"class":141},[135,11009,11010],{"class":137,"line":171},[135,11011,365],{"class":141},[10,11013,368,11014,371,11016,374,11018,377,11020,384,11024,388,11026,392],{},[14,11015,304],{},[14,11017,33],{},[14,11019,20],{},[72,11021,380,11022],{},[14,11023,383],{},[14,11025,387],{},[14,11027,391],{},[36,11029,111,11030,398],{"id":395},[14,11031,49],{},[10,11033,401],{},[41,11035,11036,11048,11056],{},[44,11037,11038,409,11040,412,11042,416,11044,420,11046,423],{},[72,11039,408],{},[14,11041,326],{},[14,11043,415],{},[14,11045,419],{},[14,11047,20],{},[44,11049,11050,429,11052,433,11054,437],{},[72,11051,428],{},[14,11053,432],{},[14,11055,436],{},[44,11057,440,11058,444,11060,448,11062,452],{},[14,11059,443],{},[14,11061,447],{},[14,11063,451],{},[10,11065,455,11066,459,11068,463,11070,466],{},[14,11067,458],{},[14,11069,462],{},[14,11071,16],{},[10,11073,469,11074,473],{},[14,11075,472],{},[126,11077,11078],{"className":316,"code":476,"language":318,"meta":131,"style":131},[14,11079,11080,11086,11090,11102,11106,11118,11126,11136,11142,11166,11172,11176,11188],{"__ignoreMap":131},[135,11081,11082,11084],{"class":137,"line":138},[135,11083,326],{"class":325},[135,11085,329],{"class":141},[135,11087,11088],{"class":137,"line":152},[135,11089,184],{"emptyLinePlaceholder":183},[135,11091,11092,11094,11096,11098,11100],{"class":137,"line":162},[135,11093,493],{"class":325},[135,11095,496],{"class":145},[135,11097,499],{"class":141},[135,11099,387],{"class":350},[135,11101,360],{"class":141},[135,11103,11104],{"class":137,"line":171},[135,11105,508],{"class":158},[135,11107,11108,11110,11112,11114,11116],{"class":137,"line":180},[135,11109,513],{"class":141},[135,11111,516],{"class":325},[135,11113,519],{"class":141},[135,11115,522],{"class":350},[135,11117,525],{"class":141},[135,11119,11120,11122,11124],{"class":137,"line":187},[135,11121,530],{"class":325},[135,11123,533],{"class":325},[135,11125,536],{"class":141},[135,11127,11128,11130,11132,11134],{"class":137,"line":201},[135,11129,541],{"class":350},[135,11131,544],{"class":141},[135,11133,547],{"class":158},[135,11135,550],{"class":141},[135,11137,11138,11140],{"class":137,"line":210},[135,11139,555],{"class":325},[135,11141,558],{"class":350},[135,11143,11144,11146,11148,11150,11152,11154,11156,11158,11160,11162,11164],{"class":137,"line":215},[135,11145,563],{"class":350},[135,11147,544],{"class":141},[135,11149,568],{"class":325},[135,11151,571],{"class":158},[135,11153,574],{"class":350},[135,11155,577],{"class":141},[135,11157,580],{"class":350},[135,11159,583],{"class":141},[135,11161,586],{"class":350},[135,11163,589],{"class":158},[135,11165,550],{"class":141},[135,11167,11168,11170],{"class":137,"line":225},[135,11169,596],{"class":325},[135,11171,599],{"class":350},[135,11173,11174],{"class":137,"line":236},[135,11175,184],{"emptyLinePlaceholder":183},[135,11177,11178,11180,11182,11184,11186],{"class":137,"line":606},[135,11179,347],{"class":325},[135,11181,351],{"class":350},[135,11183,354],{"class":325},[135,11185,357],{"class":158},[135,11187,360],{"class":141},[135,11189,11190,11192,11194],{"class":137,"line":619},[135,11191,622],{"class":325},[135,11193,625],{"class":350},[135,11195,628],{"class":141},[10,11197,631],{},[126,11199,11200],{"className":634,"code":635,"language":636,"meta":131,"style":131},[14,11201,11202,11212,11224,11232],{"__ignoreMap":131},[135,11203,11204,11206,11208,11210],{"class":137,"line":138},[135,11205,643],{"class":145},[135,11207,646],{"class":158},[135,11209,649],{"class":158},[135,11211,652],{"class":158},[135,11213,11214,11216,11218,11220,11222],{"class":137,"line":152},[135,11215,657],{"class":145},[135,11217,660],{"class":158},[135,11219,663],{"class":158},[135,11221,666],{"class":158},[135,11223,670],{"class":669},[135,11225,11226,11228,11230],{"class":137,"line":162},[135,11227,643],{"class":145},[135,11229,646],{"class":158},[135,11231,679],{"class":158},[135,11233,11234,11236,11238,11240,11242],{"class":137,"line":171},[135,11235,657],{"class":145},[135,11237,686],{"class":158},[135,11239,689],{"class":158},[135,11241,692],{"class":158},[135,11243,695],{"class":669},[10,11245,698,11246,701,11248,706],{},[14,11247,78],{},[97,11249,705],{"href":704},[36,11251,11252,713],{"id":709},[14,11253,712],{},[10,11255,716,11256,448,11258,722],{},[14,11257,719],{},[14,11259,84],{},[126,11261,11262],{"className":316,"code":725,"language":318,"meta":131,"style":131},[14,11263,11264,11268,11278],{"__ignoreMap":131},[135,11265,11266],{"class":137,"line":138},[135,11267,732],{"class":669},[135,11269,11270,11272,11274,11276],{"class":137,"line":152},[135,11271,334],{"class":325},[135,11273,337],{"class":141},[135,11275,326],{"class":325},[135,11277,342],{"class":141},[135,11279,11280,11282,11284],{"class":137,"line":162},[135,11281,747],{"class":325},[135,11283,625],{"class":350},[135,11285,628],{"class":141},[10,11287,754,11288,757,11290,761,11292,765,11294,768,11296,771],{},[14,11289,33],{},[14,11291,760],{},[23,11293,764],{},[14,11295,719],{},[14,11297,33],{},[36,11299,775],{"id":774},[10,11301,11302,780,11304,784,11306,788,11308,792],{},[14,11303,56],{},[23,11305,783],{},[14,11307,787],{},[23,11309,791],{},[126,11311,11312],{"className":128,"code":795,"language":130,"meta":131,"style":131},[14,11313,11314,11334,11340],{"__ignoreMap":131},[135,11315,11316,11318,11320,11322,11324,11326,11328,11330,11332],{"class":137,"line":138},[135,11317,142],{"class":141},[135,11319,146],{"class":145},[135,11321,61],{"class":141},[135,11323,808],{"class":145},[135,11325,61],{"class":141},[135,11327,813],{"class":145},[135,11329,61],{"class":141},[135,11331,818],{"class":145},[135,11333,149],{"class":141},[135,11335,11336,11338],{"class":137,"line":152},[135,11337,825],{"class":141},[135,11339,828],{"class":158},[135,11341,11342,11344],{"class":137,"line":162},[135,11343,833],{"class":141},[135,11345,836],{"class":158},[10,11347,839,11348,61],{},[97,11349,842],{"href":99},[36,11351,846],{"id":845},[10,11353,849,11354,853,11356,857,11358,861,11360,861,11362,868,11364,872,11366,875],{},[14,11355,852],{},[14,11357,856],{},[14,11359,860],{},[14,11361,864],{},[14,11363,867],{},[14,11365,871],{},[14,11367,49],{},[126,11369,11370],{"className":316,"code":878,"language":318,"meta":131,"style":131},[14,11371,11372,11382,11386,11390,11406,11416,11426,11432,11436,11440,11458],{"__ignoreMap":131},[135,11373,11374,11376,11378,11380],{"class":137,"line":138},[135,11375,334],{"class":325},[135,11377,887],{"class":141},[135,11379,326],{"class":325},[135,11381,892],{"class":141},[135,11383,11384],{"class":137,"line":152},[135,11385,184],{"emptyLinePlaceholder":183},[135,11387,11388],{"class":137,"line":162},[135,11389,901],{"class":669},[135,11391,11392,11394,11396,11398,11400,11402,11404],{"class":137,"line":171},[135,11393,906],{"class":141},[135,11395,516],{"class":325},[135,11397,911],{"class":141},[135,11399,915],{"class":914},[135,11401,516],{"class":325},[135,11403,920],{"class":158},[135,11405,550],{"class":141},[135,11407,11408,11410,11412,11414],{"class":137,"line":180},[135,11409,927],{"class":325},[135,11411,930],{"class":141},[135,11413,933],{"class":325},[135,11415,936],{"class":141},[135,11417,11418,11420,11422,11424],{"class":137,"line":187},[135,11419,941],{"class":141},[135,11421,516],{"class":325},[135,11423,946],{"class":141},[135,11425,949],{"class":669},[135,11427,11428,11430],{"class":137,"line":201},[135,11429,954],{"class":141},[135,11431,957],{"class":669},[135,11433,11434],{"class":137,"line":210},[135,11435,184],{"emptyLinePlaceholder":183},[135,11437,11438],{"class":137,"line":215},[135,11439,966],{"class":669},[135,11441,11442,11444,11446,11448,11450,11452,11454,11456],{"class":137,"line":225},[135,11443,927],{"class":325},[135,11445,930],{"class":141},[135,11447,933],{"class":325},[135,11449,911],{"class":141},[135,11451,915],{"class":914},[135,11453,516],{"class":325},[135,11455,983],{"class":158},[135,11457,986],{"class":141},[135,11459,11460,11462,11464,11466],{"class":137,"line":236},[135,11461,563],{"class":350},[135,11463,993],{"class":141},[135,11465,996],{"class":158},[135,11467,999],{"class":141},[10,11469,111,11470,1005],{},[14,11471,1004],{},[10,11473,1008,11474,1011],{},[14,11475,787],{},[126,11477,11478],{"className":316,"code":1014,"language":318,"meta":131,"style":131},[14,11479,11480,11492,11512,11526,11550,11564,11570,11586,11600],{"__ignoreMap":131},[135,11481,11482,11484,11486,11488,11490],{"class":137,"line":138},[135,11483,1021],{"class":325},[135,11485,1024],{"class":325},[135,11487,887],{"class":141},[135,11489,326],{"class":325},[135,11491,892],{"class":141},[135,11493,11494,11496,11498,11500,11502,11504,11506,11508,11510],{"class":137,"line":152},[135,11495,1021],{"class":325},[135,11497,1037],{"class":141},[135,11499,516],{"class":325},[135,11501,1042],{"class":350},[135,11503,1045],{"class":141},[135,11505,915],{"class":914},[135,11507,516],{"class":325},[135,11509,983],{"class":158},[135,11511,1054],{"class":141},[135,11513,11514,11516,11518,11520,11522,11524],{"class":137,"line":162},[135,11515,1021],{"class":325},[135,11517,1061],{"class":141},[135,11519,927],{"class":325},[135,11521,930],{"class":141},[135,11523,933],{"class":325},[135,11525,1070],{"class":141},[135,11527,11528,11530,11532,11534,11536,11538,11540,11542,11544,11546,11548],{"class":137,"line":171},[135,11529,142],{"class":141},[135,11531,1077],{"class":158},[135,11533,861],{"class":141},[135,11535,1082],{"class":158},[135,11537,861],{"class":141},[135,11539,1087],{"class":158},[135,11541,861],{"class":141},[135,11543,1092],{"class":158},[135,11545,861],{"class":141},[135,11547,1097],{"class":158},[135,11549,149],{"class":141},[135,11551,11552,11554,11556,11558,11560,11562],{"class":137,"line":180},[135,11553,1021],{"class":325},[135,11555,930],{"class":141},[135,11557,516],{"class":325},[135,11559,1110],{"class":141},[135,11561,580],{"class":350},[135,11563,149],{"class":141},[135,11565,11566,11568],{"class":137,"line":187},[135,11567,1021],{"class":325},[135,11569,1121],{"class":141},[135,11571,11572,11574,11576,11578,11580,11582,11584],{"class":137,"line":201},[135,11573,544],{"class":141},[135,11575,1077],{"class":158},[135,11577,861],{"class":141},[135,11579,1132],{"class":158},[135,11581,861],{"class":141},[135,11583,1137],{"class":158},[135,11585,550],{"class":141},[135,11587,11588,11590,11592,11594,11596,11598],{"class":137,"line":210},[135,11589,1021],{"class":325},[135,11591,1146],{"class":141},[135,11593,516],{"class":325},[135,11595,1151],{"class":141},[135,11597,1154],{"class":350},[135,11599,1157],{"class":141},[135,11601,11602],{"class":137,"line":215},[135,11603,1162],{"class":350},[10,11605,11606,1168,11608,1171,11610,784,11612,1177],{},[14,11607,1167],{},[14,11609,49],{},[14,11611,29],{},[14,11613,1176],{},[36,11615,1181],{"id":1180},[10,11617,11618,1187,11620,1190,11622,1194,11624,1198,11626,1201],{},[72,11619,1186],{},[14,11621,391],{},[14,11623,1193],{},[14,11625,1197],{},[14,11627,78],{},[10,11629,11630,1207,11632,1211,11634,1215,11636,1219,11638,1223,11640,1226,11642,1230,11644,1234,11646,1239],{},[72,11631,1206],{},[14,11633,1210],{},[14,11635,1214],{},[14,11637,1218],{},[23,11639,1222],{},[14,11641,1210],{},[14,11643,1229],{},[14,11645,1233],{},[97,11647,1238],{"href":1237},[10,11649,11650,765,11652,1248,11654,1252,11656,1255,11658,1258,11660,1261],{},[72,11651,1244],{},[14,11653,1247],{},[23,11655,1251],{},[14,11657,56],{},[14,11659,1247],{},[14,11661,852],{},[10,11663,11664,1267,11666,1270,11668,1273],{},[72,11665,1266],{},[14,11667,391],{},[14,11669,391],{},[36,11671,1277],{"id":1276},[41,11673,11674,11678,11682,11686],{},[44,11675,11676,1286],{},[97,11677,1285],{"href":1284},[44,11679,11680,1291],{},[97,11681,842],{"href":99},[44,11683,11684,1296],{},[97,11685,259],{"href":258},[44,11687,11688,1301],{},[97,11689,705],{"href":704},[1303,11691,1305],{},{"title":131,"searchDepth":152,"depth":152,"links":11693},[11694,11695,11696,11697,11698,11699,11700,11701,11702],{"id":38,"depth":152,"text":39},{"id":110,"depth":152,"text":1310},{"id":294,"depth":152,"text":295},{"id":395,"depth":152,"text":1313},{"id":709,"depth":152,"text":1315},{"id":774,"depth":152,"text":775},{"id":845,"depth":152,"text":846},{"id":1180,"depth":152,"text":1181},{"id":1276,"depth":152,"text":1277},{},{"title":5,"description":1321},[808,1330,1331,1332],{"id":11707,"title":11708,"body":11709,"date":1320,"description":13066,"difficulty":4617,"draft":1323,"extension":1324,"meta":13067,"navigation":183,"path":13068,"seo":13069,"stem":13070,"tags":13071,"updated":1320,"__hash__":13076},"content\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fhow-to-structure-a-large-python-cli-project\u002Findex.md","Structuring a Large Python CLI Project",{"type":7,"value":11710,"toc":13057},[11711,11721,11723,11783,11789,11793,11803,11809,11845,11851,11986,11990,12000,12180,12189,12282,12292,12438,12442,12454,12636,12639,12645,12649,12672,12678,12979,13005,13009,13025,13034,13036,13054],[10,11712,11713,11714,11717,11718,11720],{},"A CLI that started as a single ",[14,11715,11716],{},"main.py"," and grew to forty subcommands needs more than a tidy desk — it needs a layout that keeps import paths predictable, startup fast, and tests fast. This article gives a concrete, opinionated structure for a large Python CLI: the ",[14,11719,1210],{}," layout, layered packages, namespace packages for plugins, and lazy command loading so a tool with dozens of commands still starts in milliseconds.",[36,11722,39],{"id":38},[41,11724,11725,11741,11756,11762,11776],{},[44,11726,11727,11728,11733,11734,11736,11737,11740],{},"Use the ",[72,11729,11730,11732],{},[14,11731,1210],{}," layout"," — your package lives under ",[14,11735,1210],{},", not at the repo root — so tests run against the ",[23,11738,11739],{},"installed"," package and can't accidentally import from the working directory.",[44,11742,11743,11744,11747,11748,11751,11752,11755],{},"Organize by layer, not by feature dump: ",[14,11745,11746],{},"commands\u002F"," (thin parsing wrappers), ",[14,11749,11750],{},"core\u002F"," (business logic), ",[14,11753,11754],{},"io\u002F"," (formatting).",[44,11757,11758,11761],{},[72,11759,11760],{},"Lazy-load commands"," so importing the root group doesn't import every subcommand's dependencies — this is what keeps startup time flat as the command count grows.",[44,11763,11764,11765,11768,11769,861,11771,11773,11774,61],{},"Put tests in a top-level ",[14,11766,11767],{},"tests\u002F"," directory: fast unit tests against ",[14,11770,11750],{},[14,11772,3883],{}," smoke tests against ",[14,11775,11746],{},[44,11777,11778,11779,11782],{},"Use ",[72,11780,11781],{},"namespace packages"," when you want third parties (or separate internal repos) to contribute subcommands.",[10,11784,11785],{},[104,11786],{"alt":11787,"src":11788},"Directory tree for a large CLI using the src\u002F layout, with commands, core, and io layers and a top-level tests directory","\u002Fillustrations\u002Fsrc-layout-tree.svg",[36,11790,11792],{"id":11791},"the-src-layout-and-why","The src\u002F layout, and why",[10,11794,11795,11796,11799,11800,11802],{},"The single most important structural decision is putting your importable package ",[23,11797,11798],{},"inside"," a ",[14,11801,1210],{}," directory rather than at the repository root:",[126,11804,11807],{"className":11805,"code":11806,"language":5596,"meta":131},[5594],"my-cli\u002F\n├── pyproject.toml\n├── src\u002F\n│   └── reporter\u002F\n│       ├── __init__.py\n│       ├── cli.py\n│       ├── commands\u002F\n│       ├── core\u002F\n│       └── io\u002F\n└── tests\u002F\n    ├── test_sales.py\n    └── test_report.py\n",[14,11808,11806],{"__ignoreMap":131},[10,11810,11811,11812,11814,11815,11818,11819,11822,11823,11826,11827,11829,11830,11833,11834,11836,11837,11840,11841,11844],{},"Without ",[14,11813,1210],{},", the repository root is on ",[14,11816,11817],{},"sys.path"," whenever you run anything from it, so ",[14,11820,11821],{},"import reporter"," finds the ",[23,11824,11825],{},"source tree"," directly — even if you forgot to install the package, even if your packaging config is broken. Tests pass locally and fail in CI or for users. The ",[14,11828,1210],{}," layout removes the root from the import path: the only way to import ",[14,11831,11832],{},"reporter"," is to install it (",[14,11835,1247],{},"), which means your tests exercise the same thing your users get. It catches missing ",[14,11838,11839],{},"__init__.py"," files, files left out of the wheel, and ",[14,11842,11843],{},"MANIFEST"," gaps before release.",[10,11846,11847,11848,11850],{},"The matching ",[14,11849,29],{}," is short:",[126,11852,11854],{"className":128,"code":11853,"language":130,"meta":131,"style":131},"[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"reporter\"\nversion = \"0.1.0\"\nrequires-python = \">=3.10\"\ndependencies = [\"click>=8.1\"]\n\n[project.scripts]\nreporter = \"reporter.cli:cli\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src\u002Freporter\"]\n",[14,11855,11856,11864,11872,11878,11882,11890,11897,11904,11910,11919,11923,11935,11943,11947,11976],{"__ignoreMap":131},[135,11857,11858,11860,11862],{"class":137,"line":138},[135,11859,142],{"class":141},[135,11861,220],{"class":145},[135,11863,149],{"class":141},[135,11865,11866,11868,11870],{"class":137,"line":152},[135,11867,228],{"class":141},[135,11869,231],{"class":158},[135,11871,149],{"class":141},[135,11873,11874,11876],{"class":137,"line":162},[135,11875,239],{"class":141},[135,11877,242],{"class":158},[135,11879,11880],{"class":137,"line":171},[135,11881,184],{"emptyLinePlaceholder":183},[135,11883,11884,11886,11888],{"class":137,"line":180},[135,11885,142],{"class":141},[135,11887,146],{"class":145},[135,11889,149],{"class":141},[135,11891,11892,11894],{"class":137,"line":187},[135,11893,155],{"class":141},[135,11895,11896],{"class":158},"\"reporter\"\n",[135,11898,11899,11901],{"class":137,"line":201},[135,11900,165],{"class":141},[135,11902,11903],{"class":158},"\"0.1.0\"\n",[135,11905,11906,11908],{"class":137,"line":210},[135,11907,174],{"class":141},[135,11909,177],{"class":158},[135,11911,11912,11914,11917],{"class":137,"line":215},[135,11913,9284],{"class":141},[135,11915,11916],{"class":158},"\"click>=8.1\"",[135,11918,149],{"class":141},[135,11920,11921],{"class":137,"line":225},[135,11922,184],{"emptyLinePlaceholder":183},[135,11924,11925,11927,11929,11931,11933],{"class":137,"line":236},[135,11926,142],{"class":141},[135,11928,146],{"class":145},[135,11930,61],{"class":141},[135,11932,196],{"class":145},[135,11934,149],{"class":141},[135,11936,11937,11940],{"class":137,"line":606},[135,11938,11939],{"class":141},"reporter = ",[135,11941,11942],{"class":158},"\"reporter.cli:cli\"\n",[135,11944,11945],{"class":137,"line":619},[135,11946,184],{"emptyLinePlaceholder":183},[135,11948,11949,11951,11954,11956,11959,11961,11964,11966,11969,11971,11974],{"class":137,"line":1752},[135,11950,142],{"class":141},[135,11952,11953],{"class":145},"tool",[135,11955,61],{"class":141},[135,11957,11958],{"class":145},"hatch",[135,11960,61],{"class":141},[135,11962,11963],{"class":145},"build",[135,11965,61],{"class":141},[135,11967,11968],{"class":145},"targets",[135,11970,61],{"class":141},[135,11972,11973],{"class":145},"wheel",[135,11975,149],{"class":141},[135,11977,11978,11981,11984],{"class":137,"line":1765},[135,11979,11980],{"class":141},"packages = [",[135,11982,11983],{"class":158},"\"src\u002Freporter\"",[135,11985,149],{"class":141},[36,11987,11989],{"id":11988},"organize-by-layer","Organize by layer",[10,11991,11992,11993,11996,11997,11999],{},"Resist the urge to make one giant ",[14,11994,11995],{},"commands.py",". Split responsibilities into packages so the import graph mirrors the architecture. The ",[14,11998,11750],{}," service layer is pure Python with no framework imports — it's where the work happens:",[126,12001,12003],{"className":316,"code":12002,"language":318,"meta":131,"style":131},"# src\u002Freporter\u002Fcore\u002Fsales.py\nfrom __future__ import annotations\nfrom dataclasses import dataclass\n\n@dataclass(frozen=True)\nclass SalesSummary:\n    total: float\n    count: int\n\n    @property\n    def average(self) -> float:\n        return self.total \u002F self.count if self.count else 0.0\n\ndef summarize(amounts: list[float]) -> SalesSummary:\n    \"\"\"Pure business logic: no I\u002FO, no framework objects.\"\"\"\n    return SalesSummary(total=sum(amounts), count=len(amounts))\n",[14,12004,12005,12010,12020,12032,12036,12052,12061,12069,12076,12080,12087,12101,12128,12132,12147,12152],{"__ignoreMap":131},[135,12006,12007],{"class":137,"line":138},[135,12008,12009],{"class":669},"# src\u002Freporter\u002Fcore\u002Fsales.py\n",[135,12011,12012,12014,12016,12018],{"class":137,"line":152},[135,12013,334],{"class":325},[135,12015,2586],{"class":350},[135,12017,2589],{"class":325},[135,12019,2592],{"class":141},[135,12021,12022,12024,12027,12029],{"class":137,"line":162},[135,12023,334],{"class":325},[135,12025,12026],{"class":141}," dataclasses ",[135,12028,326],{"class":325},[135,12030,12031],{"class":141}," dataclass\n",[135,12033,12034],{"class":137,"line":171},[135,12035,184],{"emptyLinePlaceholder":183},[135,12037,12038,12041,12043,12046,12048,12050],{"class":137,"line":180},[135,12039,12040],{"class":145},"@dataclass",[135,12042,544],{"class":141},[135,12044,12045],{"class":914},"frozen",[135,12047,516],{"class":325},[135,12049,3651],{"class":350},[135,12051,550],{"class":141},[135,12053,12054,12056,12059],{"class":137,"line":187},[135,12055,1581],{"class":325},[135,12057,12058],{"class":145}," SalesSummary",[135,12060,360],{"class":141},[135,12062,12063,12066],{"class":137,"line":201},[135,12064,12065],{"class":141},"    total: ",[135,12067,12068],{"class":350},"float\n",[135,12070,12071,12074],{"class":137,"line":210},[135,12072,12073],{"class":141},"    count: ",[135,12075,9658],{"class":350},[135,12077,12078],{"class":137,"line":215},[135,12079,184],{"emptyLinePlaceholder":183},[135,12081,12082,12084],{"class":137,"line":225},[135,12083,1768],{"class":145},[135,12085,12086],{"class":350},"property\n",[135,12088,12089,12091,12094,12096,12099],{"class":137,"line":236},[135,12090,1777],{"class":325},[135,12092,12093],{"class":145}," average",[135,12095,1885],{"class":141},[135,12097,12098],{"class":350},"float",[135,12100,360],{"class":141},[135,12102,12103,12105,12107,12110,12112,12114,12117,12119,12121,12123,12125],{"class":137,"line":606},[135,12104,555],{"class":325},[135,12106,1898],{"class":350},[135,12108,12109],{"class":141},".total ",[135,12111,459],{"class":325},[135,12113,1898],{"class":350},[135,12115,12116],{"class":141},".count ",[135,12118,347],{"class":325},[135,12120,1898],{"class":350},[135,12122,12116],{"class":141},[135,12124,8891],{"class":325},[135,12126,12127],{"class":350}," 0.0\n",[135,12129,12130],{"class":137,"line":619},[135,12131,184],{"emptyLinePlaceholder":183},[135,12133,12134,12136,12139,12142,12144],{"class":137,"line":1752},[135,12135,493],{"class":325},[135,12137,12138],{"class":145}," summarize",[135,12140,12141],{"class":141},"(amounts: list[",[135,12143,12098],{"class":350},[135,12145,12146],{"class":141},"]) -> SalesSummary:\n",[135,12148,12149],{"class":137,"line":1765},[135,12150,12151],{"class":158},"    \"\"\"Pure business logic: no I\u002FO, no framework objects.\"\"\"\n",[135,12153,12154,12156,12159,12161,12163,12166,12169,12172,12174,12177],{"class":137,"line":1774},[135,12155,596],{"class":325},[135,12157,12158],{"class":141}," SalesSummary(",[135,12160,7131],{"class":914},[135,12162,516],{"class":325},[135,12164,12165],{"class":350},"sum",[135,12167,12168],{"class":141},"(amounts), ",[135,12170,12171],{"class":914},"count",[135,12173,516],{"class":325},[135,12175,12176],{"class":350},"len",[135,12178,12179],{"class":141},"(amounts))\n",[10,12181,111,12182,12184,12185,12188],{},[14,12183,11754],{}," layer turns service results into output, and only this layer changes when you add ",[14,12186,12187],{},"--format json"," or a Rich table:",[126,12190,12192],{"className":316,"code":12191,"language":318,"meta":131,"style":131},"# src\u002Freporter\u002Fio\u002Frender.py\nfrom __future__ import annotations\nfrom reporter.core.sales import SalesSummary\n\ndef render_summary(summary: SalesSummary) -> str:\n    return f\"count={summary.count} total={summary.total:.2f} avg={summary.average:.2f}\"\n",[14,12193,12194,12199,12209,12221,12225,12239],{"__ignoreMap":131},[135,12195,12196],{"class":137,"line":138},[135,12197,12198],{"class":669},"# src\u002Freporter\u002Fio\u002Frender.py\n",[135,12200,12201,12203,12205,12207],{"class":137,"line":152},[135,12202,334],{"class":325},[135,12204,2586],{"class":350},[135,12206,2589],{"class":325},[135,12208,2592],{"class":141},[135,12210,12211,12213,12216,12218],{"class":137,"line":162},[135,12212,334],{"class":325},[135,12214,12215],{"class":141}," reporter.core.sales ",[135,12217,326],{"class":325},[135,12219,12220],{"class":141}," SalesSummary\n",[135,12222,12223],{"class":137,"line":171},[135,12224,184],{"emptyLinePlaceholder":183},[135,12226,12227,12229,12232,12235,12237],{"class":137,"line":180},[135,12228,493],{"class":325},[135,12230,12231],{"class":145}," render_summary",[135,12233,12234],{"class":141},"(summary: SalesSummary) -> ",[135,12236,1663],{"class":350},[135,12238,360],{"class":141},[135,12240,12241,12243,12245,12248,12250,12253,12255,12258,12260,12263,12266,12268,12271,12273,12276,12278,12280],{"class":137,"line":187},[135,12242,596],{"class":325},[135,12244,4966],{"class":325},[135,12246,12247],{"class":158},"\"count=",[135,12249,574],{"class":350},[135,12251,12252],{"class":141},"summary.count",[135,12254,586],{"class":350},[135,12256,12257],{"class":158}," total=",[135,12259,574],{"class":350},[135,12261,12262],{"class":141},"summary.total",[135,12264,12265],{"class":325},":.2f",[135,12267,586],{"class":350},[135,12269,12270],{"class":158}," avg=",[135,12272,574],{"class":350},[135,12274,12275],{"class":141},"summary.average",[135,12277,12265],{"class":325},[135,12279,586],{"class":350},[135,12281,5968],{"class":158},[10,12283,111,12284,12286,12287,12289,12290,473],{},[14,12285,11746],{}," layer is thin glue — parse, delegate to ",[14,12288,11750],{},", render with ",[14,12291,11754],{},[126,12293,12295],{"className":316,"code":12294,"language":318,"meta":131,"style":131},"# src\u002Freporter\u002Fcommands\u002Freport.py\nfrom __future__ import annotations\nimport click\nfrom reporter.core.sales import summarize\nfrom reporter.io.render import render_summary\n\n@click.command(name=\"report\")\n@click.argument(\"amounts\", nargs=-1, type=float)\ndef report_command(amounts: tuple[float, ...]) -> None:\n    \"\"\"Summarize AMOUNTS. Thin wrapper: parse -> service -> render.\"\"\"\n    summary = summarize(list(amounts))\n    click.echo(render_summary(summary))\n",[14,12296,12297,12302,12312,12318,12329,12341,12345,12360,12390,12413,12418,12433],{"__ignoreMap":131},[135,12298,12299],{"class":137,"line":138},[135,12300,12301],{"class":669},"# src\u002Freporter\u002Fcommands\u002Freport.py\n",[135,12303,12304,12306,12308,12310],{"class":137,"line":152},[135,12305,334],{"class":325},[135,12307,2586],{"class":350},[135,12309,2589],{"class":325},[135,12311,2592],{"class":141},[135,12313,12314,12316],{"class":137,"line":162},[135,12315,326],{"class":325},[135,12317,2244],{"class":141},[135,12319,12320,12322,12324,12326],{"class":137,"line":171},[135,12321,334],{"class":325},[135,12323,12215],{"class":141},[135,12325,326],{"class":325},[135,12327,12328],{"class":141}," summarize\n",[135,12330,12331,12333,12336,12338],{"class":137,"line":180},[135,12332,334],{"class":325},[135,12334,12335],{"class":141}," reporter.io.render ",[135,12337,326],{"class":325},[135,12339,12340],{"class":141}," render_summary\n",[135,12342,12343],{"class":137,"line":187},[135,12344,184],{"emptyLinePlaceholder":183},[135,12346,12347,12349,12351,12353,12355,12358],{"class":137,"line":201},[135,12348,3620],{"class":145},[135,12350,544],{"class":141},[135,12352,9681],{"class":914},[135,12354,516],{"class":325},[135,12356,12357],{"class":158},"\"report\"",[135,12359,550],{"class":141},[135,12361,12362,12365,12367,12370,12372,12375,12378,12380,12382,12384,12386,12388],{"class":137,"line":210},[135,12363,12364],{"class":145},"@click.argument",[135,12366,544],{"class":141},[135,12368,12369],{"class":158},"\"amounts\"",[135,12371,861],{"class":141},[135,12373,12374],{"class":914},"nargs",[135,12376,12377],{"class":325},"=-",[135,12379,522],{"class":350},[135,12381,861],{"class":141},[135,12383,3638],{"class":914},[135,12385,516],{"class":325},[135,12387,12098],{"class":350},[135,12389,550],{"class":141},[135,12391,12392,12394,12397,12400,12402,12404,12406,12409,12411],{"class":137,"line":215},[135,12393,493],{"class":325},[135,12395,12396],{"class":145}," report_command",[135,12398,12399],{"class":141},"(amounts: tuple[",[135,12401,12098],{"class":350},[135,12403,861],{"class":141},[135,12405,2156],{"class":350},[135,12407,12408],{"class":141},"]) -> ",[135,12410,3093],{"class":350},[135,12412,360],{"class":141},[135,12414,12415],{"class":137,"line":225},[135,12416,12417],{"class":158},"    \"\"\"Summarize AMOUNTS. Thin wrapper: parse -> service -> render.\"\"\"\n",[135,12419,12420,12423,12425,12428,12431],{"class":137,"line":236},[135,12421,12422],{"class":141},"    summary ",[135,12424,516],{"class":325},[135,12426,12427],{"class":141}," summarize(",[135,12429,12430],{"class":350},"list",[135,12432,12179],{"class":141},[135,12434,12435],{"class":137,"line":606},[135,12436,12437],{"class":141},"    click.echo(render_summary(summary))\n",[36,12439,12441],{"id":12440},"where-tests-live","Where tests live",[10,12443,12444,12445,12447,12448,12450,12451,12453],{},"Tests sit in a top-level ",[14,12446,11767],{}," directory, outside ",[14,12449,1210],{},", so they aren't packaged into the wheel. Mirror the layering: most tests target ",[14,12452,11750],{}," as plain function calls, and a thin smoke test per command verifies the wiring. Both run in-process — no subprocess — so the suite stays fast even with dozens of commands.",[126,12455,12457],{"className":316,"code":12456,"language":318,"meta":131,"style":131},"# tests\u002Ftest_report.py\nfrom click.testing import CliRunner\nfrom reporter.cli import cli\nfrom reporter.core.sales import summarize\n\ndef test_service_pure() -> None:\n    s = summarize([10.0, 20.0, 30.0])\n    assert s.total == 60.0 and s.count == 3 and s.average == 20.0\n\ndef test_report_command() -> None:\n    result = CliRunner().invoke(cli, [\"report\", \"10\", \"20\", \"30\"])\n    assert result.exit_code == 0\n    assert \"count=3 total=60.00 avg=20.00\" in result.output\n",[14,12458,12459,12464,12474,12486,12496,12500,12513,12538,12570,12574,12587,12615,12625],{"__ignoreMap":131},[135,12460,12461],{"class":137,"line":138},[135,12462,12463],{"class":669},"# tests\u002Ftest_report.py\n",[135,12465,12466,12468,12470,12472],{"class":137,"line":152},[135,12467,334],{"class":325},[135,12469,3914],{"class":141},[135,12471,326],{"class":325},[135,12473,3919],{"class":141},[135,12475,12476,12478,12481,12483],{"class":137,"line":162},[135,12477,334],{"class":325},[135,12479,12480],{"class":141}," reporter.cli ",[135,12482,326],{"class":325},[135,12484,12485],{"class":141}," cli\n",[135,12487,12488,12490,12492,12494],{"class":137,"line":171},[135,12489,334],{"class":325},[135,12491,12215],{"class":141},[135,12493,326],{"class":325},[135,12495,12328],{"class":141},[135,12497,12498],{"class":137,"line":180},[135,12499,184],{"emptyLinePlaceholder":183},[135,12501,12502,12504,12507,12509,12511],{"class":137,"line":187},[135,12503,493],{"class":325},[135,12505,12506],{"class":145}," test_service_pure",[135,12508,499],{"class":141},[135,12510,3093],{"class":350},[135,12512,360],{"class":141},[135,12514,12515,12518,12520,12523,12526,12528,12531,12533,12536],{"class":137,"line":201},[135,12516,12517],{"class":141},"    s ",[135,12519,516],{"class":325},[135,12521,12522],{"class":141}," summarize([",[135,12524,12525],{"class":350},"10.0",[135,12527,861],{"class":141},[135,12529,12530],{"class":350},"20.0",[135,12532,861],{"class":141},[135,12534,12535],{"class":350},"30.0",[135,12537,4243],{"class":141},[135,12539,12540,12542,12545,12547,12550,12552,12555,12557,12560,12562,12565,12567],{"class":137,"line":210},[135,12541,4070],{"class":325},[135,12543,12544],{"class":141}," s.total ",[135,12546,1815],{"class":325},[135,12548,12549],{"class":350}," 60.0",[135,12551,1910],{"class":325},[135,12553,12554],{"class":141}," s.count ",[135,12556,1815],{"class":325},[135,12558,12559],{"class":350}," 3",[135,12561,1910],{"class":325},[135,12563,12564],{"class":141}," s.average ",[135,12566,1815],{"class":325},[135,12568,12569],{"class":350}," 20.0\n",[135,12571,12572],{"class":137,"line":215},[135,12573,184],{"emptyLinePlaceholder":183},[135,12575,12576,12578,12581,12583,12585],{"class":137,"line":225},[135,12577,493],{"class":325},[135,12579,12580],{"class":145}," test_report_command",[135,12582,499],{"class":141},[135,12584,3093],{"class":350},[135,12586,360],{"class":141},[135,12588,12589,12591,12593,12596,12598,12600,12603,12605,12608,12610,12613],{"class":137,"line":236},[135,12590,4174],{"class":141},[135,12592,516],{"class":325},[135,12594,12595],{"class":141}," CliRunner().invoke(cli, [",[135,12597,12357],{"class":158},[135,12599,861],{"class":141},[135,12601,12602],{"class":158},"\"10\"",[135,12604,861],{"class":141},[135,12606,12607],{"class":158},"\"20\"",[135,12609,861],{"class":141},[135,12611,12612],{"class":158},"\"30\"",[135,12614,4243],{"class":141},[135,12616,12617,12619,12621,12623],{"class":137,"line":606},[135,12618,4070],{"class":325},[135,12620,4196],{"class":141},[135,12622,1815],{"class":325},[135,12624,599],{"class":350},[135,12626,12627,12629,12632,12634],{"class":137,"line":619},[135,12628,4070],{"class":325},[135,12630,12631],{"class":158}," \"count=3 total=60.00 avg=20.00\"",[135,12633,4150],{"class":325},[135,12635,4212],{"class":141},[10,12637,12638],{},"Running this against an editable install:",[126,12640,12643],{"className":12641,"code":12642,"language":5596,"meta":131},[5594],"$ pytest -q\n..                                                                       [100%]\n2 passed in 0.01s\n",[14,12644,12642],{"__ignoreMap":131},[36,12646,12648],{"id":12647},"lazy-command-loading-for-startup-time","Lazy command loading for startup time",[10,12650,12651,12652,12655,12656,12659,12660,12663,12664,12667,12668,12671],{},"Here's the problem that bites large CLIs: if ",[14,12653,12654],{},"cli.py"," imports every command module at the top, then ",[23,12657,12658],{},"any"," invocation — even ",[14,12661,12662],{},"mycli --help"," — imports the dependencies of ",[23,12665,12666],{},"every"," command. If one command imports pandas and another imports boto3, your ",[14,12669,12670],{},"--version"," flag now pays for both. Startup time creeps up as the command count grows.",[10,12673,12674,12675,12677],{},"The fix is a lazy ",[14,12676,9699],{}," that defers importing a command module until that command is actually invoked. The group knows the import path for each command as a string and resolves it on demand:",[126,12679,12681],{"className":316,"code":12680,"language":318,"meta":131,"style":131},"# src\u002Freporter\u002Fcli.py\nfrom __future__ import annotations\nimport importlib\nimport click\n\nclass LazyGroup(click.Group):\n    \"\"\"Defer importing command modules until a command is invoked.\"\"\"\n    def __init__(self, *args, lazy_subcommands=None, **kwargs):\n        super().__init__(*args, **kwargs)\n        self._lazy = lazy_subcommands or {}\n\n    def list_commands(self, ctx):\n        return sorted([*super().list_commands(ctx), *self._lazy])\n\n    def get_command(self, ctx, name):\n        if name in self._lazy:\n            module_path, attr = self._lazy[name].rsplit(\":\", 1)\n            mod = importlib.import_module(module_path)\n            return getattr(mod, attr)\n        return super().get_command(ctx, name)\n\n@click.group(cls=LazyGroup, lazy_subcommands={\n    \"report\": \"reporter.commands.report:report_command\",\n})\ndef cli() -> None:\n    \"\"\"reporter: example layered CLI.\"\"\"\n",[14,12682,12683,12688,12698,12705,12711,12715,12732,12737,12762,12785,12801,12805,12815,12840,12844,12854,12867,12888,12898,12908,12918,12922,12945,12957,12961,12974],{"__ignoreMap":131},[135,12684,12685],{"class":137,"line":138},[135,12686,12687],{"class":669},"# src\u002Freporter\u002Fcli.py\n",[135,12689,12690,12692,12694,12696],{"class":137,"line":152},[135,12691,334],{"class":325},[135,12693,2586],{"class":350},[135,12695,2589],{"class":325},[135,12697,2592],{"class":141},[135,12699,12700,12702],{"class":137,"line":162},[135,12701,326],{"class":325},[135,12703,12704],{"class":141}," importlib\n",[135,12706,12707,12709],{"class":137,"line":171},[135,12708,326],{"class":325},[135,12710,2244],{"class":141},[135,12712,12713],{"class":137,"line":180},[135,12714,184],{"emptyLinePlaceholder":183},[135,12716,12717,12719,12722,12724,12726,12728,12730],{"class":137,"line":187},[135,12718,1581],{"class":325},[135,12720,12721],{"class":145}," LazyGroup",[135,12723,544],{"class":141},[135,12725,2484],{"class":145},[135,12727,61],{"class":141},[135,12729,9699],{"class":145},[135,12731,986],{"class":141},[135,12733,12734],{"class":137,"line":201},[135,12735,12736],{"class":158},"    \"\"\"Defer importing command modules until a command is invoked.\"\"\"\n",[135,12738,12739,12741,12743,12746,12748,12751,12753,12755,12757,12759],{"class":137,"line":210},[135,12740,1777],{"class":325},[135,12742,3087],{"class":350},[135,12744,12745],{"class":141},"(self, ",[135,12747,7830],{"class":325},[135,12749,12750],{"class":141},"args, lazy_subcommands",[135,12752,516],{"class":325},[135,12754,3093],{"class":350},[135,12756,861],{"class":141},[135,12758,4116],{"class":325},[135,12760,12761],{"class":141},"kwargs):\n",[135,12763,12764,12767,12770,12773,12775,12777,12780,12782],{"class":137,"line":215},[135,12765,12766],{"class":350},"        super",[135,12768,12769],{"class":141},"().",[135,12771,12772],{"class":350},"__init__",[135,12774,544],{"class":141},[135,12776,7830],{"class":325},[135,12778,12779],{"class":141},"args, ",[135,12781,4116],{"class":325},[135,12783,12784],{"class":141},"kwargs)\n",[135,12786,12787,12789,12792,12794,12797,12799],{"class":137,"line":225},[135,12788,3100],{"class":350},[135,12790,12791],{"class":141},"._lazy ",[135,12793,516],{"class":325},[135,12795,12796],{"class":141}," lazy_subcommands ",[135,12798,1809],{"class":325},[135,12800,5269],{"class":141},[135,12802,12803],{"class":137,"line":236},[135,12804,184],{"emptyLinePlaceholder":183},[135,12806,12807,12809,12812],{"class":137,"line":606},[135,12808,1777],{"class":325},[135,12810,12811],{"class":145}," list_commands",[135,12813,12814],{"class":141},"(self, ctx):\n",[135,12816,12817,12819,12822,12825,12827,12830,12833,12835,12837],{"class":137,"line":619},[135,12818,555],{"class":325},[135,12820,12821],{"class":350}," sorted",[135,12823,12824],{"class":141},"([",[135,12826,7830],{"class":325},[135,12828,12829],{"class":350},"super",[135,12831,12832],{"class":141},"().list_commands(ctx), ",[135,12834,7830],{"class":325},[135,12836,3135],{"class":350},[135,12838,12839],{"class":141},"._lazy])\n",[135,12841,12842],{"class":137,"line":1752},[135,12843,184],{"emptyLinePlaceholder":183},[135,12845,12846,12848,12851],{"class":137,"line":1765},[135,12847,1777],{"class":325},[135,12849,12850],{"class":145}," get_command",[135,12852,12853],{"class":141},"(self, ctx, name):\n",[135,12855,12856,12858,12860,12862,12864],{"class":137,"line":1774},[135,12857,1798],{"class":325},[135,12859,5403],{"class":141},[135,12861,933],{"class":325},[135,12863,1898],{"class":350},[135,12865,12866],{"class":141},"._lazy:\n",[135,12868,12869,12872,12874,12876,12879,12882,12884,12886],{"class":137,"line":1795},[135,12870,12871],{"class":141},"            module_path, attr ",[135,12873,516],{"class":325},[135,12875,1898],{"class":350},[135,12877,12878],{"class":141},"._lazy[name].rsplit(",[135,12880,12881],{"class":158},"\":\"",[135,12883,861],{"class":141},[135,12885,522],{"class":350},[135,12887,550],{"class":141},[135,12889,12890,12893,12895],{"class":137,"line":1830},[135,12891,12892],{"class":141},"            mod ",[135,12894,516],{"class":325},[135,12896,12897],{"class":141}," importlib.import_module(module_path)\n",[135,12899,12900,12902,12905],{"class":137,"line":1846},[135,12901,3146],{"class":325},[135,12903,12904],{"class":350}," getattr",[135,12906,12907],{"class":141},"(mod, attr)\n",[135,12909,12910,12912,12915],{"class":137,"line":1854},[135,12911,555],{"class":325},[135,12913,12914],{"class":350}," super",[135,12916,12917],{"class":141},"().get_command(ctx, name)\n",[135,12919,12920],{"class":137,"line":1859},[135,12921,184],{"emptyLinePlaceholder":183},[135,12923,12924,12927,12929,12932,12934,12937,12940,12942],{"class":137,"line":1877},[135,12925,12926],{"class":145},"@click.group",[135,12928,544],{"class":141},[135,12930,12931],{"class":914},"cls",[135,12933,516],{"class":325},[135,12935,12936],{"class":141},"LazyGroup, ",[135,12938,12939],{"class":914},"lazy_subcommands",[135,12941,516],{"class":325},[135,12943,12944],{"class":141},"{\n",[135,12946,12947,12950,12952,12955],{"class":137,"line":1893},[135,12948,12949],{"class":158},"    \"report\"",[135,12951,2344],{"class":141},[135,12953,12954],{"class":158},"\"reporter.commands.report:report_command\"",[135,12956,3238],{"class":141},[135,12958,12959],{"class":137,"line":1926},[135,12960,4140],{"class":141},[135,12962,12963,12965,12968,12970,12972],{"class":137,"line":1940},[135,12964,493],{"class":325},[135,12966,12967],{"class":145}," cli",[135,12969,499],{"class":141},[135,12971,3093],{"class":350},[135,12973,360],{"class":141},[135,12975,12976],{"class":137,"line":2906},[135,12977,12978],{"class":158},"    \"\"\"reporter: example layered CLI.\"\"\"\n",[10,12980,12981,12984,12985,12988,12989,12992,12993,12996,12997,13000,13001,13004],{},[14,12982,12983],{},"list_commands"," makes ",[14,12986,12987],{},"report"," show up in ",[14,12990,12991],{},"--help"," without importing ",[14,12994,12995],{},"reporter.commands.report","; ",[14,12998,12999],{},"get_command"," imports it only when the user actually runs ",[14,13002,13003],{},"reporter report",". With dozens of commands, only the one being run — and its dependencies — gets loaded. The mapping is a plain dict, so it's also the natural place to register commands discovered from plugins.",[36,13006,13008],{"id":13007},"namespace-packages-and-scaling-to-dozens-of-commands","Namespace packages and scaling to dozens of commands",[10,13010,13011,13012,13014,13015,13018,13019,13021,13022,61],{},"When you want a command tree spread across multiple distributions — a core package plus optional plugin packages, or separate internal repos — reach for ",[72,13013,11781],{}," (PEP 420). Multiple installed distributions can contribute modules under a shared package name like ",[14,13016,13017],{},"reporter.commands.*"," without any of them shipping an ",[14,13020,11839],{}," for the namespace, and without one \"owning\" the directory. Combined with entry-point discovery, that lets a plugin register its command into the lazy mapping at install time. The deep dive on this lives in ",[97,13023,13024],{"href":99},"plugin architectures for extensible CLIs",[10,13026,13027,13028,13030,13031,13033],{},"At the scale of dozens of commands, three habits keep the project healthy: one module per command under ",[14,13029,11746],{}," (never a grab-bag file), all real logic in ",[14,13032,11750],{}," where it's unit-testable without the framework, and lazy loading so the import cost of a rarely-used command never slows the common path. The directory shape stays identical from five commands to fifty — contributors always know exactly where a new command, its logic, and its tests belong.",[36,13035,1277],{"id":1276},[41,13037,13038,13042,13046,13050],{},[44,13039,2447,13040],{},[97,13041,1285],{"href":1284},[44,13043,2447,13044],{},[97,13045,1430],{"href":1429},[44,13047,2459,13048],{},[97,13049,5],{"href":9070},[44,13051,2459,13052],{},[97,13053,842],{"href":99},[1303,13055,13056],{},"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 .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}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}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}",{"title":131,"searchDepth":152,"depth":152,"links":13058},[13059,13060,13061,13062,13063,13064,13065],{"id":38,"depth":152,"text":39},{"id":11791,"depth":152,"text":11792},{"id":11988,"depth":152,"text":11989},{"id":12440,"depth":152,"text":12441},{"id":12647,"depth":152,"text":12648},{"id":13007,"depth":152,"text":13008},{"id":1276,"depth":152,"text":1277},"Use the src\u002F layout, namespace packages, and layered modules to structure large Python CLI projects for testability and long-term maintainability.",{},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fhow-to-structure-a-large-python-cli-project",{"title":11708,"description":13066},"modern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fhow-to-structure-a-large-python-cli-project\u002Findex",[6851,13072,13073,13074,13075],"src-layout","packaging","testing","structure","La82CcS-CPkKVdl4d_n9-F5NwzDl3xfjIp-OOCChGiA",{"id":13078,"title":13079,"body":13080,"date":1320,"description":13540,"difficulty":1322,"draft":1323,"extension":1324,"meta":13541,"navigation":183,"path":13542,"seo":13543,"stem":13544,"tags":13545,"updated":1320,"__hash__":13546},"content\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Findex.md","Structuring Multi-Command Python CLIs",{"type":7,"value":13081,"toc":13532},[13082,13085,13087,13127,13133,13137,13140,13174,13177,13300,13310,13314,13317,13320,13389,13395,13399,13405,13411,13417,13475,13483,13487,13517,13519,13529],[10,13083,13084],{},"A multi-command CLI grows messy fast when the command functions do everything: parse flags, talk to the database, format a table, and print it. The fix is a layered architecture — keep the parsing layer (Click or Typer) thin and push the real work into a service layer it calls. This hub explains the layered model and routes you to the deep dives on project layout and entry points.",[36,13086,39],{"id":38},[41,13088,13089,13104,13111,13117],{},[44,13090,13091,13092,13095,13096,13099,13100,13103],{},"Split every command into three layers: ",[72,13093,13094],{},"parsing"," (the framework), ",[72,13097,13098],{},"business logic"," (a plain-Python service layer), and ",[72,13101,13102],{},"output\u002Fformatting"," (rendering).",[44,13105,13106,13107,13110],{},"Command functions stay ",[23,13108,13109],{},"thin"," — they parse arguments, call a service function, and hand the result to a renderer. No business logic in the command body.",[44,13112,13113,13114,13116],{},"This separation is what makes commands testable: you unit-test the service with plain values and the command with ",[14,13115,3883],{},", never spinning up a subprocess.",[44,13118,13119,13120,861,13122,861,13124,13126],{},"A predictable package layout (",[14,13121,11746],{},[14,13123,11750],{},[14,13125,11754],{},") keeps a tree of dozens of commands navigable.",[10,13128,13129],{},[104,13130],{"alt":13131,"src":13132},"Three stacked CLI layers — parsing (command defs), business logic (services), and output\u002Fformatting — with a downward call arrow and an upward result arrow; each layer testable in isolation.","\u002Fillustrations\u002Fcli-layers.svg",[36,13134,13136],{"id":13135},"the-layered-model","The layered model",[10,13138,13139],{},"Think of each command as a pipeline with three responsibilities, owned by three different modules:",[1961,13141,13142,13159,13165],{},[44,13143,13144,13147,13148,13151,13152,13155,13156,61],{},[72,13145,13146],{},"Parsing \u002F command layer"," — Click or Typer turns ",[14,13149,13150],{},"argv"," into typed Python values. This is the only layer that knows about decorators, ",[14,13153,13154],{},"Context",", exit codes, and ",[14,13157,13158],{},"echo",[44,13160,13161,13164],{},[72,13162,13163],{},"Business logic \u002F service layer"," — plain functions and classes that take ordinary arguments and return ordinary data. No framework imports here. This is where the actual work lives.",[44,13166,13167,13170,13171,13173],{},[72,13168,13169],{},"Output \u002F formatting layer"," — turns the service's return value into text, a table, or JSON. Swapping ",[14,13172,12187],{}," for a Rich table touches only this layer.",[10,13175,13176],{},"The discipline is simple: a command function should read like a three-line summary of itself — parse, delegate, render.",[126,13178,13180],{"className":316,"code":13179,"language":318,"meta":131,"style":131},"import click\nfrom reporter.core.sales import summarize\nfrom reporter.io.render import render_summary\n\n@click.command(name=\"report\")\n@click.argument(\"amounts\", nargs=-1, type=float)\ndef report_command(amounts: tuple[float, ...]) -> None:\n    \"\"\"Summarize AMOUNTS. Thin wrapper: parse -> service -> render.\"\"\"\n    summary = summarize(list(amounts))      # business logic\n    click.echo(render_summary(summary))     # formatting\n",[14,13181,13182,13188,13198,13208,13212,13226,13252,13272,13276,13292],{"__ignoreMap":131},[135,13183,13184,13186],{"class":137,"line":138},[135,13185,326],{"class":325},[135,13187,2244],{"class":141},[135,13189,13190,13192,13194,13196],{"class":137,"line":152},[135,13191,334],{"class":325},[135,13193,12215],{"class":141},[135,13195,326],{"class":325},[135,13197,12328],{"class":141},[135,13199,13200,13202,13204,13206],{"class":137,"line":162},[135,13201,334],{"class":325},[135,13203,12335],{"class":141},[135,13205,326],{"class":325},[135,13207,12340],{"class":141},[135,13209,13210],{"class":137,"line":171},[135,13211,184],{"emptyLinePlaceholder":183},[135,13213,13214,13216,13218,13220,13222,13224],{"class":137,"line":180},[135,13215,3620],{"class":145},[135,13217,544],{"class":141},[135,13219,9681],{"class":914},[135,13221,516],{"class":325},[135,13223,12357],{"class":158},[135,13225,550],{"class":141},[135,13227,13228,13230,13232,13234,13236,13238,13240,13242,13244,13246,13248,13250],{"class":137,"line":187},[135,13229,12364],{"class":145},[135,13231,544],{"class":141},[135,13233,12369],{"class":158},[135,13235,861],{"class":141},[135,13237,12374],{"class":914},[135,13239,12377],{"class":325},[135,13241,522],{"class":350},[135,13243,861],{"class":141},[135,13245,3638],{"class":914},[135,13247,516],{"class":325},[135,13249,12098],{"class":350},[135,13251,550],{"class":141},[135,13253,13254,13256,13258,13260,13262,13264,13266,13268,13270],{"class":137,"line":201},[135,13255,493],{"class":325},[135,13257,12396],{"class":145},[135,13259,12399],{"class":141},[135,13261,12098],{"class":350},[135,13263,861],{"class":141},[135,13265,2156],{"class":350},[135,13267,12408],{"class":141},[135,13269,3093],{"class":350},[135,13271,360],{"class":141},[135,13273,13274],{"class":137,"line":210},[135,13275,12417],{"class":158},[135,13277,13278,13280,13282,13284,13286,13289],{"class":137,"line":215},[135,13279,12422],{"class":141},[135,13281,516],{"class":325},[135,13283,12427],{"class":141},[135,13285,12430],{"class":350},[135,13287,13288],{"class":141},"(amounts))      ",[135,13290,13291],{"class":669},"# business logic\n",[135,13293,13294,13297],{"class":137,"line":225},[135,13295,13296],{"class":141},"    click.echo(render_summary(summary))     ",[135,13298,13299],{"class":669},"# formatting\n",[10,13301,13302,13305,13306,13309],{},[14,13303,13304],{},"summarize"," knows nothing about Click; ",[14,13307,13308],{},"render_summary"," knows nothing about argument parsing. Each can change independently.",[36,13311,13313],{"id":13312},"why-this-separation-makes-commands-testable","Why this separation makes commands testable",[10,13315,13316],{},"When business logic lives inside the command body, the only way to exercise it is through the framework — you build an argument list, invoke the runner, and assert on stdout. That works, but it couples every logic test to flag names and output formatting, and it's slow to reason about.",[10,13318,13319],{},"Pull the logic into a service layer and most of your tests become plain function calls:",[126,13321,13323],{"className":316,"code":13322,"language":318,"meta":131,"style":131},"from reporter.core.sales import summarize\n\ndef test_service_pure() -> None:\n    s = summarize([10.0, 20.0, 30.0])\n    assert s.total == 60.0 and s.average == 20.0\n",[14,13324,13325,13335,13339,13351,13371],{"__ignoreMap":131},[135,13326,13327,13329,13331,13333],{"class":137,"line":138},[135,13328,334],{"class":325},[135,13330,12215],{"class":141},[135,13332,326],{"class":325},[135,13334,12328],{"class":141},[135,13336,13337],{"class":137,"line":152},[135,13338,184],{"emptyLinePlaceholder":183},[135,13340,13341,13343,13345,13347,13349],{"class":137,"line":162},[135,13342,493],{"class":325},[135,13344,12506],{"class":145},[135,13346,499],{"class":141},[135,13348,3093],{"class":350},[135,13350,360],{"class":141},[135,13352,13353,13355,13357,13359,13361,13363,13365,13367,13369],{"class":137,"line":171},[135,13354,12517],{"class":141},[135,13356,516],{"class":325},[135,13358,12522],{"class":141},[135,13360,12525],{"class":350},[135,13362,861],{"class":141},[135,13364,12530],{"class":350},[135,13366,861],{"class":141},[135,13368,12535],{"class":350},[135,13370,4243],{"class":141},[135,13372,13373,13375,13377,13379,13381,13383,13385,13387],{"class":137,"line":180},[135,13374,4070],{"class":325},[135,13376,12544],{"class":141},[135,13378,1815],{"class":325},[135,13380,12549],{"class":350},[135,13382,1910],{"class":325},[135,13384,12564],{"class":141},[135,13386,1815],{"class":325},[135,13388,12569],{"class":350},[10,13390,13391,13392,13394],{},"You still write a thin smoke test per command with ",[14,13393,3883],{}," to confirm the wiring — that flags map to the right service call and the exit code is right — but the bulk of your coverage targets pure functions that are trivial to test. This is the single biggest payoff of the layered model.",[36,13396,13398],{"id":13397},"a-recommended-package-layout","A recommended package layout",[10,13400,13401,13402,13404],{},"A ",[14,13403,1210],{}," layout with one package per layer scales cleanly:",[126,13406,13409],{"className":13407,"code":13408,"language":5596,"meta":131},[5594],"src\u002Freporter\u002F\n├── cli.py              # root group, wires subcommands together\n├── commands\u002F           # one thin module per command (parsing layer)\n│   ├── report.py\n│   └── deploy.py\n├── core\u002F               # business logic — no framework imports\n│   └── sales.py\n└── io\u002F                 # formatting \u002F rendering layer\n    └── render.py\ntests\u002F\n├── test_sales.py       # fast unit tests against core\u002F\n└── test_report.py      # CliRunner smoke tests against commands\u002F\n",[14,13410,13408],{"__ignoreMap":131},[10,13412,13413,13414,13416],{},"The root ",[14,13415,12654],{}," does nothing but assemble the tree:",[126,13418,13420],{"className":316,"code":13419,"language":318,"meta":131,"style":131},"import click\nfrom reporter.commands.report import report_command\n\n@click.group()\ndef cli() -> None:\n    \"\"\"reporter: example layered CLI.\"\"\"\n\ncli.add_command(report_command)\n",[14,13421,13422,13428,13440,13444,13450,13462,13466,13470],{"__ignoreMap":131},[135,13423,13424,13426],{"class":137,"line":138},[135,13425,326],{"class":325},[135,13427,2244],{"class":141},[135,13429,13430,13432,13435,13437],{"class":137,"line":152},[135,13431,334],{"class":325},[135,13433,13434],{"class":141}," reporter.commands.report ",[135,13436,326],{"class":325},[135,13438,13439],{"class":141}," report_command\n",[135,13441,13442],{"class":137,"line":162},[135,13443,184],{"emptyLinePlaceholder":183},[135,13445,13446,13448],{"class":137,"line":171},[135,13447,12926],{"class":145},[135,13449,2135],{"class":141},[135,13451,13452,13454,13456,13458,13460],{"class":137,"line":180},[135,13453,493],{"class":325},[135,13455,12967],{"class":145},[135,13457,499],{"class":141},[135,13459,3093],{"class":350},[135,13461,360],{"class":141},[135,13463,13464],{"class":137,"line":187},[135,13465,12978],{"class":158},[135,13467,13468],{"class":137,"line":201},[135,13469,184],{"emptyLinePlaceholder":183},[135,13471,13472],{"class":137,"line":210},[135,13473,13474],{"class":141},"cli.add_command(report_command)\n",[10,13476,13477,13478,1993,13480,13482],{},"As the tool grows, you add files under ",[14,13479,11746],{},[14,13481,11750],{}," — the shape of the project never changes, so contributors always know where a new command goes.",[36,13484,13486],{"id":13485},"go-deeper","Go deeper",[41,13488,13489,13500],{},[44,13490,13491,13496,13497,13499],{},[72,13492,13493],{},[97,13494,13495],{"href":1237},"Structuring a large Python CLI project","\n— the ",[14,13498,1210],{}," layout in depth, namespace packages, lazy command loading for startup time, and scaling to dozens of commands.",[44,13501,13502,13506,13507,13509,13510,13512,13513,13516],{},[72,13503,13504],{},[97,13505,5],{"href":9070},"\n— wiring ",[14,13508,56],{},", a clean ",[14,13511,391],{},", and the ",[14,13514,13515],{},"python -m"," fallback.",[36,13518,1277],{"id":1276},[41,13520,13521,13525],{},[44,13522,2447,13523],{},[97,13524,1430],{"href":1429},[44,13526,2459,13527],{},[97,13528,842],{"href":99},[1303,13530,13531],{},"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 .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":131,"searchDepth":152,"depth":152,"links":13533},[13534,13535,13536,13537,13538,13539],{"id":38,"depth":152,"text":39},{"id":13135,"depth":152,"text":13136},{"id":13312,"depth":152,"text":13313},{"id":13397,"depth":152,"text":13398},{"id":13485,"depth":152,"text":13486},{"id":1276,"depth":152,"text":1277},"Structure multi-command Python CLIs with clear separation of parsing, business logic, and output layers for testable, maintainable command trees.",{},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis",{"title":13079,"description":13540},"modern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Findex",[6851,2484,2483,13074,13075],"S6pfrQQcDj1p5aAjLofdyor1Yo6-ZgXnEIch9LbRQaM",{"id":13548,"title":705,"body":13549,"date":1320,"description":15338,"difficulty":1322,"draft":1323,"extension":1324,"meta":15339,"navigation":183,"path":15340,"seo":15341,"stem":15342,"tags":15343,"updated":1320,"__hash__":15347},"content\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Fbuilding-a-cli-with-subcommands-in-click\u002Findex.md",{"type":7,"value":13550,"toc":15325},[13551,13571,13573,13617,13623,13627,13633,13866,13877,13881,13890,14061,14064,14097,14101,14110,14275,14292,14296,14303,14439,14451,14455,14458,14572,14578,14582,14593,14618,14633,14637,14643,14914,14933,14937,14943,15236,15251,15253,15304,15306,15322],[10,13552,13553,13554,861,13557,861,13560,13563,13564,13566,13567,13570],{},"The moment a CLI grows past one job — ",[14,13555,13556],{},"git commit",[14,13558,13559],{},"git push",[14,13561,13562],{},"git remote add"," — you\nneed subcommands. In Click, subcommands come from ",[72,13565,783],{},": a group is a command whose\njob is to dispatch to child commands. This guide builds a small but realistic ",[14,13568,13569],{},"widget","\nCLI with a top-level group, nested groups, shared state, and tests, then covers the\nproduction concerns that bite once the tree gets large.",[36,13572,39],{"id":38},[41,13574,13575,13588,13607,13614],{},[44,13576,13577,13578,13581,13582,1993,13585,61],{},"Turn a function into a dispatcher with ",[14,13579,13580],{},"@click.group()",", then attach children with\n",[14,13583,13584],{},"@cli.command()",[14,13586,13587],{},"@cli.group()",[44,13589,13590,13591,13594,13595,13598,13599,13602,13603,13606],{},"Share state from parent to child through ",[14,13592,13593],{},"ctx.obj",", using ",[14,13596,13597],{},"ctx.ensure_object(dict)"," in\nthe group and ",[14,13600,13601],{},"@click.pass_context"," (or ",[14,13604,13605],{},"@click.pass_obj",") in the children.",[44,13608,13609,13610,13613],{},"Test the whole tree in-process with ",[14,13611,13612],{},"click.testing.CliRunner"," — no subprocess needed.",[44,13615,13616],{},"For large CLIs, load subcommands lazily so startup stays fast.",[10,13618,13619],{},[104,13620],{"alt":13621,"src":13622},"A Click command tree: the root cli group has children create, list, and the nested remote group; remote in turn contains add and remove. Groups are drawn with an accent border, leaf commands with a plain border.","\u002Fillustrations\u002Fclick-command-tree.svg",[36,13624,13626],{"id":13625},"a-group-is-just-a-command-that-dispatches","A group is just a command that dispatches",[10,13628,13629,13630,13632],{},"Start with the root. A group function runs ",[23,13631,1475],{}," the chosen subcommand, which makes it\nthe natural place to parse global options and set up shared state.",[126,13634,13636],{"className":316,"code":13635,"language":318,"meta":131,"style":131},"# widget\u002Fcli.py\nimport click\n\n@click.group()\n@click.version_option(version=\"1.0.0\", prog_name=\"widget\")\n@click.option(\"-v\", \"--verbose\", is_flag=True, help=\"Enable verbose output.\")\n@click.option(\n    \"--config\",\n    type=click.Path(exists=True, dir_okay=False, path_type=str),\n    help=\"Path to a config file.\",\n)\n@click.pass_context\ndef cli(ctx: click.Context, verbose: bool, config: str | None) -> None:\n    \"\"\"widget — manage widgets from the command line.\"\"\"\n    # ensure_object guarantees ctx.obj exists even when a child is invoked directly.\n    ctx.ensure_object(dict)\n    ctx.obj[\"verbose\"] = verbose\n    ctx.obj[\"config\"] = config\n",[14,13637,13638,13643,13649,13653,13659,13686,13721,13727,13734,13771,13783,13787,13792,13819,13824,13829,13838,13852],{"__ignoreMap":131},[135,13639,13640],{"class":137,"line":138},[135,13641,13642],{"class":669},"# widget\u002Fcli.py\n",[135,13644,13645,13647],{"class":137,"line":152},[135,13646,326],{"class":325},[135,13648,2244],{"class":141},[135,13650,13651],{"class":137,"line":162},[135,13652,184],{"emptyLinePlaceholder":183},[135,13654,13655,13657],{"class":137,"line":171},[135,13656,12926],{"class":145},[135,13658,2135],{"class":141},[135,13660,13661,13664,13666,13669,13671,13674,13676,13679,13681,13684],{"class":137,"line":180},[135,13662,13663],{"class":145},"@click.version_option",[135,13665,544],{"class":141},[135,13667,13668],{"class":914},"version",[135,13670,516],{"class":325},[135,13672,13673],{"class":158},"\"1.0.0\"",[135,13675,861],{"class":141},[135,13677,13678],{"class":914},"prog_name",[135,13680,516],{"class":325},[135,13682,13683],{"class":158},"\"widget\"",[135,13685,550],{"class":141},[135,13687,13688,13690,13692,13695,13697,13700,13702,13705,13707,13709,13711,13714,13716,13719],{"class":137,"line":187},[135,13689,3628],{"class":145},[135,13691,544],{"class":141},[135,13693,13694],{"class":158},"\"-v\"",[135,13696,861],{"class":141},[135,13698,13699],{"class":158},"\"--verbose\"",[135,13701,861],{"class":141},[135,13703,13704],{"class":914},"is_flag",[135,13706,516],{"class":325},[135,13708,3651],{"class":350},[135,13710,861],{"class":141},[135,13712,13713],{"class":914},"help",[135,13715,516],{"class":325},[135,13717,13718],{"class":158},"\"Enable verbose output.\"",[135,13720,550],{"class":141},[135,13722,13723,13725],{"class":137,"line":201},[135,13724,3628],{"class":145},[135,13726,6284],{"class":141},[135,13728,13729,13732],{"class":137,"line":210},[135,13730,13731],{"class":158},"    \"--config\"",[135,13733,3238],{"class":141},[135,13735,13736,13739,13741,13744,13747,13749,13751,13753,13756,13758,13760,13762,13765,13767,13769],{"class":137,"line":215},[135,13737,13738],{"class":914},"    type",[135,13740,516],{"class":325},[135,13742,13743],{"class":141},"click.Path(",[135,13745,13746],{"class":914},"exists",[135,13748,516],{"class":325},[135,13750,3651],{"class":350},[135,13752,861],{"class":141},[135,13754,13755],{"class":914},"dir_okay",[135,13757,516],{"class":325},[135,13759,5172],{"class":350},[135,13761,861],{"class":141},[135,13763,13764],{"class":914},"path_type",[135,13766,516],{"class":325},[135,13768,1663],{"class":350},[135,13770,7349],{"class":141},[135,13772,13773,13776,13778,13781],{"class":137,"line":225},[135,13774,13775],{"class":914},"    help",[135,13777,516],{"class":325},[135,13779,13780],{"class":158},"\"Path to a config file.\"",[135,13782,3238],{"class":141},[135,13784,13785],{"class":137,"line":236},[135,13786,550],{"class":141},[135,13788,13789],{"class":137,"line":606},[135,13790,13791],{"class":145},"@click.pass_context\n",[135,13793,13794,13796,13798,13801,13803,13806,13808,13811,13813,13815,13817],{"class":137,"line":619},[135,13795,493],{"class":325},[135,13797,12967],{"class":145},[135,13799,13800],{"class":141},"(ctx: click.Context, verbose: ",[135,13802,5112],{"class":350},[135,13804,13805],{"class":141},", config: ",[135,13807,1663],{"class":350},[135,13809,13810],{"class":325}," |",[135,13812,4942],{"class":350},[135,13814,1788],{"class":141},[135,13816,3093],{"class":350},[135,13818,360],{"class":141},[135,13820,13821],{"class":137,"line":1752},[135,13822,13823],{"class":158},"    \"\"\"widget — manage widgets from the command line.\"\"\"\n",[135,13825,13826],{"class":137,"line":1765},[135,13827,13828],{"class":669},"    # ensure_object guarantees ctx.obj exists even when a child is invoked directly.\n",[135,13830,13831,13834,13836],{"class":137,"line":1774},[135,13832,13833],{"class":141},"    ctx.ensure_object(",[135,13835,2763],{"class":350},[135,13837,550],{"class":141},[135,13839,13840,13843,13845,13847,13849],{"class":137,"line":1795},[135,13841,13842],{"class":141},"    ctx.obj[",[135,13844,5167],{"class":158},[135,13846,2750],{"class":141},[135,13848,516],{"class":325},[135,13850,13851],{"class":141}," verbose\n",[135,13853,13854,13856,13859,13861,13863],{"class":137,"line":1830},[135,13855,13842],{"class":141},[135,13857,13858],{"class":158},"\"config\"",[135,13860,2750],{"class":141},[135,13862,516],{"class":325},[135,13864,13865],{"class":141}," config\n",[10,13867,13868,13870,13871,13873,13874,13876],{},[14,13869,13663],{}," adds a ",[14,13872,12670],{}," flag for free. ",[14,13875,13597],{}," creates\na shared dictionary that every child command can read.",[36,13878,13880],{"id":13879},"attaching-commands","Attaching commands",[10,13882,13883,13884,13886,13887,13889],{},"Each subcommand is a normal Click command attached to the group with ",[14,13885,13584],{},". Use\n",[14,13888,13601],{}," when a command needs the shared object:",[126,13891,13893],{"className":316,"code":13892,"language":318,"meta":131,"style":131},"@cli.command()\n@click.argument(\"name\")\n@click.pass_context\ndef create(ctx: click.Context, name: str) -> None:\n    \"\"\"Create a new widget called NAME.\"\"\"\n    if ctx.obj[\"verbose\"]:\n        click.echo(f\"[verbose] config={ctx.obj['config']}\")\n    click.echo(f\"Created widget {name!r}\")\n\n@cli.command(name=\"list\")\ndef list_widgets() -> None:\n    \"\"\"List all widgets.\"\"\"\n    for name in (\"widget-a\", \"widget-b\"):\n        click.echo(name)\n",[14,13894,13895,13902,13912,13916,13934,13939,13950,13976,13998,14002,14017,14030,14035,14056],{"__ignoreMap":131},[135,13896,13897,13900],{"class":137,"line":138},[135,13898,13899],{"class":145},"@cli.command",[135,13901,2135],{"class":141},[135,13903,13904,13906,13908,13910],{"class":137,"line":152},[135,13905,12364],{"class":145},[135,13907,544],{"class":141},[135,13909,1760],{"class":158},[135,13911,550],{"class":141},[135,13913,13914],{"class":137,"line":162},[135,13915,13791],{"class":145},[135,13917,13918,13920,13923,13926,13928,13930,13932],{"class":137,"line":171},[135,13919,493],{"class":325},[135,13921,13922],{"class":145}," create",[135,13924,13925],{"class":141},"(ctx: click.Context, name: ",[135,13927,1663],{"class":350},[135,13929,1788],{"class":141},[135,13931,3093],{"class":350},[135,13933,360],{"class":141},[135,13935,13936],{"class":137,"line":180},[135,13937,13938],{"class":158},"    \"\"\"Create a new widget called NAME.\"\"\"\n",[135,13940,13941,13943,13946,13948],{"class":137,"line":187},[135,13942,530],{"class":325},[135,13944,13945],{"class":141}," ctx.obj[",[135,13947,5167],{"class":158},[135,13949,10186],{"class":141},[135,13951,13952,13955,13957,13960,13962,13965,13968,13970,13972,13974],{"class":137,"line":201},[135,13953,13954],{"class":141},"        click.echo(",[135,13956,568],{"class":325},[135,13958,13959],{"class":158},"\"[verbose] config=",[135,13961,574],{"class":350},[135,13963,13964],{"class":141},"ctx.obj[",[135,13966,13967],{"class":158},"'config'",[135,13969,583],{"class":141},[135,13971,586],{"class":350},[135,13973,589],{"class":158},[135,13975,550],{"class":141},[135,13977,13978,13981,13983,13986,13988,13990,13992,13994,13996],{"class":137,"line":210},[135,13979,13980],{"class":141},"    click.echo(",[135,13982,568],{"class":325},[135,13984,13985],{"class":158},"\"Created widget ",[135,13987,574],{"class":350},[135,13989,9681],{"class":141},[135,13991,3447],{"class":325},[135,13993,586],{"class":350},[135,13995,589],{"class":158},[135,13997,550],{"class":141},[135,13999,14000],{"class":137,"line":215},[135,14001,184],{"emptyLinePlaceholder":183},[135,14003,14004,14006,14008,14010,14012,14015],{"class":137,"line":225},[135,14005,13899],{"class":145},[135,14007,544],{"class":141},[135,14009,9681],{"class":914},[135,14011,516],{"class":325},[135,14013,14014],{"class":158},"\"list\"",[135,14016,550],{"class":141},[135,14018,14019,14021,14024,14026,14028],{"class":137,"line":236},[135,14020,493],{"class":325},[135,14022,14023],{"class":145}," list_widgets",[135,14025,499],{"class":141},[135,14027,3093],{"class":350},[135,14029,360],{"class":141},[135,14031,14032],{"class":137,"line":606},[135,14033,14034],{"class":158},"    \"\"\"List all widgets.\"\"\"\n",[135,14036,14037,14039,14041,14043,14046,14049,14051,14054],{"class":137,"line":619},[135,14038,2277],{"class":325},[135,14040,5403],{"class":141},[135,14042,933],{"class":325},[135,14044,14045],{"class":141}," (",[135,14047,14048],{"class":158},"\"widget-a\"",[135,14050,861],{"class":141},[135,14052,14053],{"class":158},"\"widget-b\"",[135,14055,986],{"class":141},[135,14057,14058],{"class":137,"line":1752},[135,14059,14060],{"class":141},"        click.echo(name)\n",[10,14062,14063],{},"Two details worth calling out:",[41,14065,14066,14086],{},[44,14067,14068,14069,14072,14073,14075,14076,14079,14081,14082,14085],{},"The function is named ",[14,14070,14071],{},"list_widgets"," but the command is ",[14,14074,12430],{},". Naming the ",[23,14077,14078],{},"function",[14,14080,12430],{}," would shadow the builtin, so pass ",[14,14083,14084],{},"name=\"list\""," and keep a safe Python name.",[44,14087,14088,14089,14092,14093,14096],{},"Click derives the command name from the function name by replacing underscores with\nhyphens, so ",[14,14090,14091],{},"def remove_all()"," becomes the ",[14,14094,14095],{},"remove-all"," command automatically.",[36,14098,14100],{"id":14099},"nesting-groups","Nesting groups",[10,14102,14103,14104,14107,14108,473],{},"A group can contain other groups, giving you ",[14,14105,14106],{},"widget remote add ...",". Attach a child group\nwith ",[14,14109,13587],{},[126,14111,14113],{"className":316,"code":14112,"language":318,"meta":131,"style":131},"@cli.group()\ndef remote() -> None:\n    \"\"\"Manage remote widget registries.\"\"\"\n\n@remote.command()\n@click.argument(\"url\")\ndef add(url: str) -> None:\n    \"\"\"Register a remote registry at URL.\"\"\"\n    click.echo(f\"Added remote {url}\")\n\n@remote.command(name=\"remove\")\n@click.argument(\"url\")\ndef remove_remote(url: str) -> None:\n    \"\"\"Remove the remote registry at URL.\"\"\"\n    click.echo(f\"Removed remote {url}\")\n",[14,14114,14115,14122,14135,14140,14144,14151,14162,14180,14185,14205,14209,14224,14234,14251,14256],{"__ignoreMap":131},[135,14116,14117,14120],{"class":137,"line":138},[135,14118,14119],{"class":145},"@cli.group",[135,14121,2135],{"class":141},[135,14123,14124,14126,14129,14131,14133],{"class":137,"line":152},[135,14125,493],{"class":325},[135,14127,14128],{"class":145}," remote",[135,14130,499],{"class":141},[135,14132,3093],{"class":350},[135,14134,360],{"class":141},[135,14136,14137],{"class":137,"line":162},[135,14138,14139],{"class":158},"    \"\"\"Manage remote widget registries.\"\"\"\n",[135,14141,14142],{"class":137,"line":171},[135,14143,184],{"emptyLinePlaceholder":183},[135,14145,14146,14149],{"class":137,"line":180},[135,14147,14148],{"class":145},"@remote.command",[135,14150,2135],{"class":141},[135,14152,14153,14155,14157,14160],{"class":137,"line":187},[135,14154,12364],{"class":145},[135,14156,544],{"class":141},[135,14158,14159],{"class":158},"\"url\"",[135,14161,550],{"class":141},[135,14163,14164,14166,14169,14172,14174,14176,14178],{"class":137,"line":201},[135,14165,493],{"class":325},[135,14167,14168],{"class":145}," add",[135,14170,14171],{"class":141},"(url: ",[135,14173,1663],{"class":350},[135,14175,1788],{"class":141},[135,14177,3093],{"class":350},[135,14179,360],{"class":141},[135,14181,14182],{"class":137,"line":210},[135,14183,14184],{"class":158},"    \"\"\"Register a remote registry at URL.\"\"\"\n",[135,14186,14187,14189,14191,14194,14196,14199,14201,14203],{"class":137,"line":215},[135,14188,13980],{"class":141},[135,14190,568],{"class":325},[135,14192,14193],{"class":158},"\"Added remote ",[135,14195,574],{"class":350},[135,14197,14198],{"class":141},"url",[135,14200,586],{"class":350},[135,14202,589],{"class":158},[135,14204,550],{"class":141},[135,14206,14207],{"class":137,"line":225},[135,14208,184],{"emptyLinePlaceholder":183},[135,14210,14211,14213,14215,14217,14219,14222],{"class":137,"line":236},[135,14212,14148],{"class":145},[135,14214,544],{"class":141},[135,14216,9681],{"class":914},[135,14218,516],{"class":325},[135,14220,14221],{"class":158},"\"remove\"",[135,14223,550],{"class":141},[135,14225,14226,14228,14230,14232],{"class":137,"line":606},[135,14227,12364],{"class":145},[135,14229,544],{"class":141},[135,14231,14159],{"class":158},[135,14233,550],{"class":141},[135,14235,14236,14238,14241,14243,14245,14247,14249],{"class":137,"line":619},[135,14237,493],{"class":325},[135,14239,14240],{"class":145}," remove_remote",[135,14242,14171],{"class":141},[135,14244,1663],{"class":350},[135,14246,1788],{"class":141},[135,14248,3093],{"class":350},[135,14250,360],{"class":141},[135,14252,14253],{"class":137,"line":1752},[135,14254,14255],{"class":158},"    \"\"\"Remove the remote registry at URL.\"\"\"\n",[135,14257,14258,14260,14262,14265,14267,14269,14271,14273],{"class":137,"line":1765},[135,14259,13980],{"class":141},[135,14261,568],{"class":325},[135,14263,14264],{"class":158},"\"Removed remote ",[135,14266,574],{"class":350},[135,14268,14198],{"class":141},[135,14270,586],{"class":350},[135,14272,589],{"class":158},[135,14274,550],{"class":141},[10,14276,14277,14278,14281,14282,1993,14285,784,14288,14291],{},"Now ",[14,14279,14280],{},"widget remote --help"," lists ",[14,14283,14284],{},"add",[14,14286,14287],{},"remove",[14,14289,14290],{},"widget remote add https:\u002F\u002F...","\nruns the nested command. Nesting can go arbitrarily deep, but two levels is usually the\npractical ceiling for usability.",[36,14293,14295],{"id":14294},"sharing-state-cleanly","Sharing state cleanly",[10,14297,14298,14299,14302],{},"Reaching into ",[14,14300,14301],{},"ctx.obj[\"verbose\"]"," everywhere is fragile — a typo'd key fails at runtime.\nFor anything beyond a flag or two, store a typed object instead of a bare dict:",[126,14304,14306],{"className":316,"code":14305,"language":318,"meta":131,"style":131},"from dataclasses import dataclass\n\n@dataclass\nclass AppState:\n    verbose: bool\n    config: str | None\n\n# In the group body, store a typed object instead of a dict:\n#   ctx.obj = AppState(verbose=verbose, config=config)\n#\n# In a command, pass_obj injects ctx.obj directly:\n@cli.command()\n@click.pass_obj\ndef status(state: AppState) -> None:\n    \"\"\"Show resolved configuration.\"\"\"\n    click.echo(f\"verbose={state.verbose} config={state.config}\")\n",[14,14307,14308,14318,14322,14327,14336,14343,14355,14359,14364,14369,14374,14379,14385,14390,14404,14409],{"__ignoreMap":131},[135,14309,14310,14312,14314,14316],{"class":137,"line":138},[135,14311,334],{"class":325},[135,14313,12026],{"class":141},[135,14315,326],{"class":325},[135,14317,12031],{"class":141},[135,14319,14320],{"class":137,"line":152},[135,14321,184],{"emptyLinePlaceholder":183},[135,14323,14324],{"class":137,"line":162},[135,14325,14326],{"class":145},"@dataclass\n",[135,14328,14329,14331,14334],{"class":137,"line":171},[135,14330,1581],{"class":325},[135,14332,14333],{"class":145}," AppState",[135,14335,360],{"class":141},[135,14337,14338,14340],{"class":137,"line":180},[135,14339,5109],{"class":141},[135,14341,14342],{"class":350},"bool\n",[135,14344,14345,14348,14350,14352],{"class":137,"line":187},[135,14346,14347],{"class":141},"    config: ",[135,14349,1663],{"class":350},[135,14351,13810],{"class":325},[135,14353,14354],{"class":350}," None\n",[135,14356,14357],{"class":137,"line":201},[135,14358,184],{"emptyLinePlaceholder":183},[135,14360,14361],{"class":137,"line":210},[135,14362,14363],{"class":669},"# In the group body, store a typed object instead of a dict:\n",[135,14365,14366],{"class":137,"line":215},[135,14367,14368],{"class":669},"#   ctx.obj = AppState(verbose=verbose, config=config)\n",[135,14370,14371],{"class":137,"line":225},[135,14372,14373],{"class":669},"#\n",[135,14375,14376],{"class":137,"line":236},[135,14377,14378],{"class":669},"# In a command, pass_obj injects ctx.obj directly:\n",[135,14380,14381,14383],{"class":137,"line":606},[135,14382,13899],{"class":145},[135,14384,2135],{"class":141},[135,14386,14387],{"class":137,"line":619},[135,14388,14389],{"class":145},"@click.pass_obj\n",[135,14391,14392,14394,14397,14400,14402],{"class":137,"line":1752},[135,14393,493],{"class":325},[135,14395,14396],{"class":145}," status",[135,14398,14399],{"class":141},"(state: AppState) -> ",[135,14401,3093],{"class":350},[135,14403,360],{"class":141},[135,14405,14406],{"class":137,"line":1765},[135,14407,14408],{"class":158},"    \"\"\"Show resolved configuration.\"\"\"\n",[135,14410,14411,14413,14415,14418,14420,14423,14425,14428,14430,14433,14435,14437],{"class":137,"line":1774},[135,14412,13980],{"class":141},[135,14414,568],{"class":325},[135,14416,14417],{"class":158},"\"verbose=",[135,14419,574],{"class":350},[135,14421,14422],{"class":141},"state.verbose",[135,14424,586],{"class":350},[135,14426,14427],{"class":158}," config=",[135,14429,574],{"class":350},[135,14431,14432],{"class":141},"state.config",[135,14434,586],{"class":350},[135,14436,589],{"class":158},[135,14438,550],{"class":141},[10,14440,14441,14443,14444,14446,14447,14450],{},[14,14442,13605],{}," hands the command ",[14,14445,13593],{}," directly, so you get a typed ",[14,14448,14449],{},"AppState"," and\neditor autocompletion instead of stringly-typed dictionary access.",[36,14452,14454],{"id":14453},"middleware-style-behavior","Middleware-style behavior",[10,14456,14457],{},"Because the group body runs before every subcommand, it doubles as middleware: configure\nlogging, open a shared connection, or enforce preconditions in one place.",[126,14459,14461],{"className":316,"code":14460,"language":318,"meta":131,"style":131},"import logging\n\n@click.group()\n@click.option(\"-v\", \"--verbose\", is_flag=True)\n@click.pass_context\ndef cli(ctx: click.Context, verbose: bool) -> None:\n    logging.basicConfig(level=logging.DEBUG if verbose else logging.WARNING)\n    ctx.ensure_object(dict)\n    # Register cleanup that runs after the subcommand completes, even on error.\n    ctx.call_on_close(logging.shutdown)\n",[14,14462,14463,14470,14474,14480,14502,14506,14522,14554,14562,14567],{"__ignoreMap":131},[135,14464,14465,14467],{"class":137,"line":138},[135,14466,326],{"class":325},[135,14468,14469],{"class":141}," logging\n",[135,14471,14472],{"class":137,"line":152},[135,14473,184],{"emptyLinePlaceholder":183},[135,14475,14476,14478],{"class":137,"line":162},[135,14477,12926],{"class":145},[135,14479,2135],{"class":141},[135,14481,14482,14484,14486,14488,14490,14492,14494,14496,14498,14500],{"class":137,"line":171},[135,14483,3628],{"class":145},[135,14485,544],{"class":141},[135,14487,13694],{"class":158},[135,14489,861],{"class":141},[135,14491,13699],{"class":158},[135,14493,861],{"class":141},[135,14495,13704],{"class":914},[135,14497,516],{"class":325},[135,14499,3651],{"class":350},[135,14501,550],{"class":141},[135,14503,14504],{"class":137,"line":180},[135,14505,13791],{"class":145},[135,14507,14508,14510,14512,14514,14516,14518,14520],{"class":137,"line":187},[135,14509,493],{"class":325},[135,14511,12967],{"class":145},[135,14513,13800],{"class":141},[135,14515,5112],{"class":350},[135,14517,1788],{"class":141},[135,14519,3093],{"class":350},[135,14521,360],{"class":141},[135,14523,14524,14527,14530,14532,14535,14538,14541,14544,14546,14549,14552],{"class":137,"line":201},[135,14525,14526],{"class":141},"    logging.basicConfig(",[135,14528,14529],{"class":914},"level",[135,14531,516],{"class":325},[135,14533,14534],{"class":141},"logging.",[135,14536,14537],{"class":350},"DEBUG",[135,14539,14540],{"class":325}," if",[135,14542,14543],{"class":141}," verbose ",[135,14545,8891],{"class":325},[135,14547,14548],{"class":141}," logging.",[135,14550,14551],{"class":350},"WARNING",[135,14553,550],{"class":141},[135,14555,14556,14558,14560],{"class":137,"line":210},[135,14557,13833],{"class":141},[135,14559,2763],{"class":350},[135,14561,550],{"class":141},[135,14563,14564],{"class":137,"line":215},[135,14565,14566],{"class":669},"    # Register cleanup that runs after the subcommand completes, even on error.\n",[135,14568,14569],{"class":137,"line":225},[135,14570,14571],{"class":141},"    ctx.call_on_close(logging.shutdown)\n",[10,14573,14574,14577],{},[14,14575,14576],{},"ctx.call_on_close()"," registers teardown that fires when the context exits — the right hook\nfor closing files, flushing buffers, or releasing connections opened in the group.",[36,14579,14581],{"id":14580},"wiring-the-entry-point","Wiring the entry point",[10,14583,14584,14585,14587,14588,14590,14591,473],{},"Expose the root group as a console script in ",[14,14586,29],{}," so users get a ",[14,14589,13569],{},"\ncommand on ",[14,14592,33],{},[126,14594,14596],{"className":128,"code":14595,"language":130,"meta":131,"style":131},"[project.scripts]\nwidget = \"widget.cli:cli\"\n",[14,14597,14598,14610],{"__ignoreMap":131},[135,14599,14600,14602,14604,14606,14608],{"class":137,"line":138},[135,14601,142],{"class":141},[135,14603,146],{"class":145},[135,14605,61],{"class":141},[135,14607,196],{"class":145},[135,14609,149],{"class":141},[135,14611,14612,14615],{"class":137,"line":152},[135,14613,14614],{"class":141},"widget = ",[135,14616,14617],{"class":158},"\"widget.cli:cli\"\n",[10,14619,14620,14621,13602,14623,4389,14626,14629,14630,14632],{},"After ",[14,14622,1247],{},[14,14624,14625],{},"uv pip install -e .",[14,14627,14628],{},"widget create gadget"," runs your root\ngroup. See ",[97,14631,9071],{"href":9070},"\nfor the full PEP 621 treatment.",[36,14634,14636],{"id":14635},"testing-the-command-tree","Testing the command tree",[10,14638,14639,14640,14642],{},"Click's ",[14,14641,3883],{}," invokes commands in-process and captures output and exit code, so tests\nare fast and need no subprocess. Test the root, the nested group, and the failure paths:",[126,14644,14646],{"className":316,"code":14645,"language":318,"meta":131,"style":131},"# tests\u002Ftest_cli.py\nfrom click.testing import CliRunner\nfrom widget.cli import cli\n\ndef test_create_succeeds() -> None:\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"create\", \"gadget\"])\n    assert result.exit_code == 0\n    assert \"Created widget 'gadget'\" in result.output\n\ndef test_global_flag_reaches_subcommand() -> None:\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"--verbose\", \"create\", \"gadget\"])\n    assert \"[verbose]\" in result.output\n\ndef test_nested_group() -> None:\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"remote\", \"add\", \"https:\u002F\u002Fexample.com\"])\n    assert result.exit_code == 0\n    assert \"Added remote https:\u002F\u002Fexample.com\" in result.output\n\ndef test_unknown_command_exits_nonzero() -> None:\n    runner = CliRunner()\n    result = runner.invoke(cli, [\"frobnicate\"])\n    assert result.exit_code != 0\n",[14,14647,14648,14653,14663,14674,14678,14691,14701,14720,14730,14741,14745,14758,14766,14786,14797,14801,14814,14822,14845,14855,14866,14870,14883,14891,14904],{"__ignoreMap":131},[135,14649,14650],{"class":137,"line":138},[135,14651,14652],{"class":669},"# tests\u002Ftest_cli.py\n",[135,14654,14655,14657,14659,14661],{"class":137,"line":152},[135,14656,334],{"class":325},[135,14658,3914],{"class":141},[135,14660,326],{"class":325},[135,14662,3919],{"class":141},[135,14664,14665,14667,14670,14672],{"class":137,"line":162},[135,14666,334],{"class":325},[135,14668,14669],{"class":141}," widget.cli ",[135,14671,326],{"class":325},[135,14673,12485],{"class":141},[135,14675,14676],{"class":137,"line":171},[135,14677,184],{"emptyLinePlaceholder":183},[135,14679,14680,14682,14685,14687,14689],{"class":137,"line":180},[135,14681,493],{"class":325},[135,14683,14684],{"class":145}," test_create_succeeds",[135,14686,499],{"class":141},[135,14688,3093],{"class":350},[135,14690,360],{"class":141},[135,14692,14693,14696,14698],{"class":137,"line":187},[135,14694,14695],{"class":141},"    runner ",[135,14697,516],{"class":325},[135,14699,14700],{"class":141}," CliRunner()\n",[135,14702,14703,14705,14707,14710,14713,14715,14718],{"class":137,"line":201},[135,14704,4174],{"class":141},[135,14706,516],{"class":325},[135,14708,14709],{"class":141}," runner.invoke(cli, [",[135,14711,14712],{"class":158},"\"create\"",[135,14714,861],{"class":141},[135,14716,14717],{"class":158},"\"gadget\"",[135,14719,4243],{"class":141},[135,14721,14722,14724,14726,14728],{"class":137,"line":210},[135,14723,4070],{"class":325},[135,14725,4196],{"class":141},[135,14727,1815],{"class":325},[135,14729,599],{"class":350},[135,14731,14732,14734,14737,14739],{"class":137,"line":215},[135,14733,4070],{"class":325},[135,14735,14736],{"class":158}," \"Created widget 'gadget'\"",[135,14738,4150],{"class":325},[135,14740,4212],{"class":141},[135,14742,14743],{"class":137,"line":225},[135,14744,184],{"emptyLinePlaceholder":183},[135,14746,14747,14749,14752,14754,14756],{"class":137,"line":236},[135,14748,493],{"class":325},[135,14750,14751],{"class":145}," test_global_flag_reaches_subcommand",[135,14753,499],{"class":141},[135,14755,3093],{"class":350},[135,14757,360],{"class":141},[135,14759,14760,14762,14764],{"class":137,"line":606},[135,14761,14695],{"class":141},[135,14763,516],{"class":325},[135,14765,14700],{"class":141},[135,14767,14768,14770,14772,14774,14776,14778,14780,14782,14784],{"class":137,"line":619},[135,14769,4174],{"class":141},[135,14771,516],{"class":325},[135,14773,14709],{"class":141},[135,14775,13699],{"class":158},[135,14777,861],{"class":141},[135,14779,14712],{"class":158},[135,14781,861],{"class":141},[135,14783,14717],{"class":158},[135,14785,4243],{"class":141},[135,14787,14788,14790,14793,14795],{"class":137,"line":1752},[135,14789,4070],{"class":325},[135,14791,14792],{"class":158}," \"[verbose]\"",[135,14794,4150],{"class":325},[135,14796,4212],{"class":141},[135,14798,14799],{"class":137,"line":1765},[135,14800,184],{"emptyLinePlaceholder":183},[135,14802,14803,14805,14808,14810,14812],{"class":137,"line":1774},[135,14804,493],{"class":325},[135,14806,14807],{"class":145}," test_nested_group",[135,14809,499],{"class":141},[135,14811,3093],{"class":350},[135,14813,360],{"class":141},[135,14815,14816,14818,14820],{"class":137,"line":1795},[135,14817,14695],{"class":141},[135,14819,516],{"class":325},[135,14821,14700],{"class":141},[135,14823,14824,14826,14828,14830,14833,14835,14838,14840,14843],{"class":137,"line":1830},[135,14825,4174],{"class":141},[135,14827,516],{"class":325},[135,14829,14709],{"class":141},[135,14831,14832],{"class":158},"\"remote\"",[135,14834,861],{"class":141},[135,14836,14837],{"class":158},"\"add\"",[135,14839,861],{"class":141},[135,14841,14842],{"class":158},"\"https:\u002F\u002Fexample.com\"",[135,14844,4243],{"class":141},[135,14846,14847,14849,14851,14853],{"class":137,"line":1846},[135,14848,4070],{"class":325},[135,14850,4196],{"class":141},[135,14852,1815],{"class":325},[135,14854,599],{"class":350},[135,14856,14857,14859,14862,14864],{"class":137,"line":1854},[135,14858,4070],{"class":325},[135,14860,14861],{"class":158}," \"Added remote https:\u002F\u002Fexample.com\"",[135,14863,4150],{"class":325},[135,14865,4212],{"class":141},[135,14867,14868],{"class":137,"line":1859},[135,14869,184],{"emptyLinePlaceholder":183},[135,14871,14872,14874,14877,14879,14881],{"class":137,"line":1877},[135,14873,493],{"class":325},[135,14875,14876],{"class":145}," test_unknown_command_exits_nonzero",[135,14878,499],{"class":141},[135,14880,3093],{"class":350},[135,14882,360],{"class":141},[135,14884,14885,14887,14889],{"class":137,"line":1893},[135,14886,14695],{"class":141},[135,14888,516],{"class":325},[135,14890,14700],{"class":141},[135,14892,14893,14895,14897,14899,14902],{"class":137,"line":1926},[135,14894,4174],{"class":141},[135,14896,516],{"class":325},[135,14898,14709],{"class":141},[135,14900,14901],{"class":158},"\"frobnicate\"",[135,14903,4243],{"class":141},[135,14905,14906,14908,14910,14912],{"class":137,"line":1940},[135,14907,4070],{"class":325},[135,14909,4196],{"class":141},[135,14911,10265],{"class":325},[135,14913,599],{"class":350},[10,14915,14916,14917,14920,14921,14924,14925,14928,14929,14932],{},"Note that ",[14,14918,14919],{},"--verbose"," is a ",[23,14922,14923],{},"global"," flag on the group, so it goes before the subcommand:\n",[14,14926,14927],{},"widget --verbose create gadget",", not ",[14,14930,14931],{},"widget create --verbose gadget",". Getting flag\nplacement right is one of the most common surprises with grouped CLIs — surface it in your\nhelp text and tests.",[36,14934,14936],{"id":14935},"keeping-startup-fast-with-lazy-loading","Keeping startup fast with lazy loading",[10,14938,14939,14940,14942],{},"Every ",[14,14941,326],{}," at module load runs when the CLI starts, even for commands the user didn't\ncall. For a tool with dozens of subcommands and heavy dependencies, that's a visible delay.\nClick's recommended fix is a group that imports children on demand:",[126,14944,14946],{"className":316,"code":14945,"language":318,"meta":131,"style":131},"import importlib\nimport click\n\nclass LazyGroup(click.Group):\n    \"\"\"A group that imports subcommands only when they're actually invoked.\"\"\"\n\n    def __init__(self, *args, lazy_subcommands: dict[str, str] | None = None, **kwargs):\n        super().__init__(*args, **kwargs)\n        # Map command name -> \"module.path:attribute\"\n        self.lazy_subcommands = lazy_subcommands or {}\n\n    def list_commands(self, ctx: click.Context) -> list[str]:\n        return sorted([*super().list_commands(ctx), *self.lazy_subcommands])\n\n    def get_command(self, ctx: click.Context, cmd_name: str):\n        if cmd_name in self.lazy_subcommands:\n            module_path, attr = self.lazy_subcommands[cmd_name].split(\":\", 1)\n            module = importlib.import_module(module_path)\n            return getattr(module, attr)\n        return super().get_command(ctx, cmd_name)\n\n@click.group(\n    cls=LazyGroup,\n    lazy_subcommands={\"report\": \"widget.commands.report:report\"},\n)\ndef cli() -> None:\n    \"\"\"widget with lazily loaded subcommands.\"\"\"\n",[14,14947,14948,14954,14960,14964,14980,14985,14989,15024,15042,15047,15062,15066,15079,15100,15104,15117,15131,15150,15159,15168,15177,15181,15187,15197,15215,15219,15231],{"__ignoreMap":131},[135,14949,14950,14952],{"class":137,"line":138},[135,14951,326],{"class":325},[135,14953,12704],{"class":141},[135,14955,14956,14958],{"class":137,"line":152},[135,14957,326],{"class":325},[135,14959,2244],{"class":141},[135,14961,14962],{"class":137,"line":162},[135,14963,184],{"emptyLinePlaceholder":183},[135,14965,14966,14968,14970,14972,14974,14976,14978],{"class":137,"line":171},[135,14967,1581],{"class":325},[135,14969,12721],{"class":145},[135,14971,544],{"class":141},[135,14973,2484],{"class":145},[135,14975,61],{"class":141},[135,14977,9699],{"class":145},[135,14979,986],{"class":141},[135,14981,14982],{"class":137,"line":180},[135,14983,14984],{"class":158},"    \"\"\"A group that imports subcommands only when they're actually invoked.\"\"\"\n",[135,14986,14987],{"class":137,"line":187},[135,14988,184],{"emptyLinePlaceholder":183},[135,14990,14991,14993,14995,14997,14999,15002,15004,15006,15008,15010,15012,15014,15016,15018,15020,15022],{"class":137,"line":201},[135,14992,1777],{"class":325},[135,14994,3087],{"class":350},[135,14996,12745],{"class":141},[135,14998,7830],{"class":325},[135,15000,15001],{"class":141},"args, lazy_subcommands: dict[",[135,15003,1663],{"class":350},[135,15005,861],{"class":141},[135,15007,1663],{"class":350},[135,15009,2750],{"class":141},[135,15011,4939],{"class":325},[135,15013,4942],{"class":350},[135,15015,2150],{"class":325},[135,15017,4942],{"class":350},[135,15019,861],{"class":141},[135,15021,4116],{"class":325},[135,15023,12761],{"class":141},[135,15025,15026,15028,15030,15032,15034,15036,15038,15040],{"class":137,"line":210},[135,15027,12766],{"class":350},[135,15029,12769],{"class":141},[135,15031,12772],{"class":350},[135,15033,544],{"class":141},[135,15035,7830],{"class":325},[135,15037,12779],{"class":141},[135,15039,4116],{"class":325},[135,15041,12784],{"class":141},[135,15043,15044],{"class":137,"line":215},[135,15045,15046],{"class":669},"        # Map command name -> \"module.path:attribute\"\n",[135,15048,15049,15051,15054,15056,15058,15060],{"class":137,"line":225},[135,15050,3100],{"class":350},[135,15052,15053],{"class":141},".lazy_subcommands ",[135,15055,516],{"class":325},[135,15057,12796],{"class":141},[135,15059,1809],{"class":325},[135,15061,5269],{"class":141},[135,15063,15064],{"class":137,"line":236},[135,15065,184],{"emptyLinePlaceholder":183},[135,15067,15068,15070,15072,15075,15077],{"class":137,"line":606},[135,15069,1777],{"class":325},[135,15071,12811],{"class":145},[135,15073,15074],{"class":141},"(self, ctx: click.Context) -> list[",[135,15076,1663],{"class":350},[135,15078,10186],{"class":141},[135,15080,15081,15083,15085,15087,15089,15091,15093,15095,15097],{"class":137,"line":619},[135,15082,555],{"class":325},[135,15084,12821],{"class":350},[135,15086,12824],{"class":141},[135,15088,7830],{"class":325},[135,15090,12829],{"class":350},[135,15092,12832],{"class":141},[135,15094,7830],{"class":325},[135,15096,3135],{"class":350},[135,15098,15099],{"class":141},".lazy_subcommands])\n",[135,15101,15102],{"class":137,"line":1752},[135,15103,184],{"emptyLinePlaceholder":183},[135,15105,15106,15108,15110,15113,15115],{"class":137,"line":1765},[135,15107,1777],{"class":325},[135,15109,12850],{"class":145},[135,15111,15112],{"class":141},"(self, ctx: click.Context, cmd_name: ",[135,15114,1663],{"class":350},[135,15116,986],{"class":141},[135,15118,15119,15121,15124,15126,15128],{"class":137,"line":1774},[135,15120,1798],{"class":325},[135,15122,15123],{"class":141}," cmd_name ",[135,15125,933],{"class":325},[135,15127,1898],{"class":350},[135,15129,15130],{"class":141},".lazy_subcommands:\n",[135,15132,15133,15135,15137,15139,15142,15144,15146,15148],{"class":137,"line":1795},[135,15134,12871],{"class":141},[135,15136,516],{"class":325},[135,15138,1898],{"class":350},[135,15140,15141],{"class":141},".lazy_subcommands[cmd_name].split(",[135,15143,12881],{"class":158},[135,15145,861],{"class":141},[135,15147,522],{"class":350},[135,15149,550],{"class":141},[135,15151,15152,15155,15157],{"class":137,"line":1830},[135,15153,15154],{"class":141},"            module ",[135,15156,516],{"class":325},[135,15158,12897],{"class":141},[135,15160,15161,15163,15165],{"class":137,"line":1846},[135,15162,3146],{"class":325},[135,15164,12904],{"class":350},[135,15166,15167],{"class":141},"(module, attr)\n",[135,15169,15170,15172,15174],{"class":137,"line":1854},[135,15171,555],{"class":325},[135,15173,12914],{"class":350},[135,15175,15176],{"class":141},"().get_command(ctx, cmd_name)\n",[135,15178,15179],{"class":137,"line":1859},[135,15180,184],{"emptyLinePlaceholder":183},[135,15182,15183,15185],{"class":137,"line":1877},[135,15184,12926],{"class":145},[135,15186,6284],{"class":141},[135,15188,15189,15192,15194],{"class":137,"line":1893},[135,15190,15191],{"class":914},"    cls",[135,15193,516],{"class":325},[135,15195,15196],{"class":141},"LazyGroup,\n",[135,15198,15199,15202,15204,15206,15208,15210,15213],{"class":137,"line":1926},[135,15200,15201],{"class":914},"    lazy_subcommands",[135,15203,516],{"class":325},[135,15205,574],{"class":141},[135,15207,12357],{"class":158},[135,15209,2344],{"class":141},[135,15211,15212],{"class":158},"\"widget.commands.report:report\"",[135,15214,4017],{"class":141},[135,15216,15217],{"class":137,"line":1940},[135,15218,550],{"class":141},[135,15220,15221,15223,15225,15227,15229],{"class":137,"line":2906},[135,15222,493],{"class":325},[135,15224,12967],{"class":145},[135,15226,499],{"class":141},[135,15228,3093],{"class":350},[135,15230,360],{"class":141},[135,15232,15233],{"class":137,"line":2931},[135,15234,15235],{"class":158},"    \"\"\"widget with lazily loaded subcommands.\"\"\"\n",[10,15237,15238,15239,15242,15243,15246,15247,15250],{},"The heavy ",[14,15240,15241],{},"widget.commands.report"," module is imported only when the user runs\n",[14,15244,15245],{},"widget report",", so ",[14,15248,15249],{},"widget --help"," and unrelated commands stay instant.",[36,15252,4512],{"id":4511},[41,15254,15255,15268,15283,15289],{},[44,15256,15257,15260,15261,15264,15265,15267],{},[72,15258,15259],{},"Exit codes:"," raise ",[14,15262,15263],{},"click.ClickException"," (or subclasses) for expected errors — Click\nprints the message to stderr and exits non-zero. Reserve bare ",[14,15266,747],{}," for bugs.",[44,15269,15270,15275,15276,15279,15280,15282],{},[72,15271,15272,15273],{},"Don't call ",[14,15274,383],{}," inside commands; raise ",[14,15277,15278],{},"click.exceptions.Exit(code)"," so\n",[14,15281,3883],{}," can capture the code in tests.",[44,15284,15285,15288],{},[72,15286,15287],{},"Help text is your UI:"," every group and command should have a one-line docstring; it\nbecomes the help summary. Document where global flags go.",[44,15290,15291,15294,15295,1993,15298,15301,15302,61],{},[72,15292,15293],{},"Cross-platform:"," use ",[14,15296,15297],{},"click.Path(path_type=pathlib.Path)",[14,15299,15300],{},"click.echo()"," (which\nhandles encoding and strips colors when output is piped) instead of ",[14,15303,7041],{},[36,15305,1277],{"id":1276},[41,15307,15308,15312,15317],{},[44,15309,2447,15310],{},[97,15311,9038],{"href":9037},[44,15313,15314,15315],{},"Sibling: ",[97,15316,9047],{"href":9046},[44,15318,15319,15320],{},"Next: ",[97,15321,1285],{"href":1284},[1303,15323,15324],{},"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 .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":131,"searchDepth":152,"depth":152,"links":15326},[15327,15328,15329,15330,15331,15332,15333,15334,15335,15336,15337],{"id":38,"depth":152,"text":39},{"id":13625,"depth":152,"text":13626},{"id":13879,"depth":152,"text":13880},{"id":14099,"depth":152,"text":14100},{"id":14294,"depth":152,"text":14295},{"id":14453,"depth":152,"text":14454},{"id":14580,"depth":152,"text":14581},{"id":14635,"depth":152,"text":14636},{"id":14935,"depth":152,"text":14936},{"id":4511,"depth":152,"text":4512},{"id":1276,"depth":152,"text":1277},"Build a multi-command Python CLI using Click group() and nested command decorators with shared context, middleware, and testable command trees.",{},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Fbuilding-a-cli-with-subcommands-in-click",{"title":705,"description":15338},"modern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Fbuilding-a-cli-with-subcommands-in-click\u002Findex",[2484,15344,15345,15346,13074],"subcommands","command-groups","context","DEQLW94vmLvpcK6mvow11A-QVY_xGn1ecBw6bheurSo",{"id":15349,"title":15350,"body":15351,"date":1320,"description":15958,"difficulty":1322,"draft":1323,"extension":1324,"meta":15959,"navigation":183,"path":15960,"seo":15961,"stem":15962,"tags":15963,"updated":1320,"__hash__":15966},"content\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Findex.md","Typer vs Click: When to Use Each",{"type":7,"value":15352,"toc":15949},[15353,15360,15362,15382,15388,15392,15399,15526,15536,15665,15674,15678,15770,15774,15798,15802,15808,15901,15903,15929,15931,15946],[10,15354,15355,15356,15359],{},"Typer and Click are the two frameworks most Python teams choose between in 2026, and the\ndecision matters less than newcomers fear: ",[72,15357,15358],{},"Typer is built on top of Click",", so picking\none doesn't lock you out of the other's concepts. The real question is how much you want\nthe framework to infer from your type hints versus how much you want to spell out by hand.",[36,15361,39],{"id":38},[41,15363,15364,15370,15376],{},[44,15365,15366,15369],{},[72,15367,15368],{},"Choose Typer"," when you want type hints to drive the interface, you're starting fresh,\nand you value minimal boilerplate and automatic help from function signatures.",[44,15371,15372,15375],{},[72,15373,15374],{},"Choose Click"," when you need fine-grained control over parsing, you're maintaining an\nexisting Click codebase, or you depend on the broad ecosystem of Click extensions.",[44,15377,15378,15379,15381],{},"You can always drop down to Click primitives from inside a Typer app, because a Typer\ncommand ",[23,15380,5297],{}," a Click command underneath.",[10,15383,15384],{},[104,15385],{"alt":15386,"src":15387},"Decision diagram: how you want to define the interface — from type hints leads to Typer (new projects, less boilerplate); explicit decorators or maximum control leads to Click (existing Click code, fine control).","\u002Fillustrations\u002Ftyper-vs-click-decision.svg",[36,15389,15391],{"id":15390},"the-core-difference","The core difference",[10,15393,15394,15395,15398],{},"Click is ",[72,15396,15397],{},"decorator-driven and explicit",". You declare each option and argument with a\ndecorator, and nothing is inferred:",[126,15400,15402],{"className":316,"code":15401,"language":318,"meta":131,"style":131},"import click\n\n@click.command()\n@click.option(\"--count\", default=1, type=int, help=\"Number of greetings.\")\n@click.argument(\"name\")\ndef hello(count: int, name: str) -> None:\n    \"\"\"Greet NAME COUNT times.\"\"\"\n    for _ in range(count):\n        click.echo(f\"Hello {name}\")\n",[14,15403,15404,15410,15414,15420,15456,15466,15489,15494,15507],{"__ignoreMap":131},[135,15405,15406,15408],{"class":137,"line":138},[135,15407,326],{"class":325},[135,15409,2244],{"class":141},[135,15411,15412],{"class":137,"line":152},[135,15413,184],{"emptyLinePlaceholder":183},[135,15415,15416,15418],{"class":137,"line":162},[135,15417,3620],{"class":145},[135,15419,2135],{"class":141},[135,15421,15422,15424,15426,15429,15431,15433,15435,15437,15439,15441,15443,15445,15447,15449,15451,15454],{"class":137,"line":171},[135,15423,3628],{"class":145},[135,15425,544],{"class":141},[135,15427,15428],{"class":158},"\"--count\"",[135,15430,861],{"class":141},[135,15432,8774],{"class":914},[135,15434,516],{"class":325},[135,15436,522],{"class":350},[135,15438,861],{"class":141},[135,15440,3638],{"class":914},[135,15442,516],{"class":325},[135,15444,387],{"class":350},[135,15446,861],{"class":141},[135,15448,13713],{"class":914},[135,15450,516],{"class":325},[135,15452,15453],{"class":158},"\"Number of greetings.\"",[135,15455,550],{"class":141},[135,15457,15458,15460,15462,15464],{"class":137,"line":180},[135,15459,12364],{"class":145},[135,15461,544],{"class":141},[135,15463,1760],{"class":158},[135,15465,550],{"class":141},[135,15467,15468,15470,15473,15476,15478,15481,15483,15485,15487],{"class":137,"line":187},[135,15469,493],{"class":325},[135,15471,15472],{"class":145}," hello",[135,15474,15475],{"class":141},"(count: ",[135,15477,387],{"class":350},[135,15479,15480],{"class":141},", name: ",[135,15482,1663],{"class":350},[135,15484,1788],{"class":141},[135,15486,3093],{"class":350},[135,15488,360],{"class":141},[135,15490,15491],{"class":137,"line":201},[135,15492,15493],{"class":158},"    \"\"\"Greet NAME COUNT times.\"\"\"\n",[135,15495,15496,15498,15500,15502,15504],{"class":137,"line":210},[135,15497,2277],{"class":325},[135,15499,7145],{"class":141},[135,15501,933],{"class":325},[135,15503,7150],{"class":350},[135,15505,15506],{"class":141},"(count):\n",[135,15508,15509,15511,15513,15516,15518,15520,15522,15524],{"class":137,"line":215},[135,15510,13954],{"class":141},[135,15512,568],{"class":325},[135,15514,15515],{"class":158},"\"Hello ",[135,15517,574],{"class":350},[135,15519,9681],{"class":141},[135,15521,586],{"class":350},[135,15523,589],{"class":158},[135,15525,550],{"class":141},[10,15527,15528,15529,15532,15533,473],{},"Typer is ",[72,15530,15531],{},"type-hint-driven",". The same command infers types, defaults, and help text\nfrom the function signature using ",[14,15534,15535],{},"Annotated",[126,15537,15539],{"className":316,"code":15538,"language":318,"meta":131,"style":131},"from typing import Annotated\nimport typer\n\ndef hello(\n    name: str,\n    count: Annotated[int, typer.Option(help=\"Number of greetings.\")] = 1,\n) -> None:\n    \"\"\"Greet NAME COUNT times.\"\"\"\n    for _ in range(count):\n        typer.echo(f\"Hello {name}\")\n\nif __name__ == \"__main__\":\n    typer.run(hello)\n",[14,15540,15541,15551,15557,15561,15569,15577,15601,15609,15613,15625,15644,15648,15660],{"__ignoreMap":131},[135,15542,15543,15545,15547,15549],{"class":137,"line":138},[135,15544,334],{"class":325},[135,15546,1555],{"class":141},[135,15548,326],{"class":325},[135,15550,1560],{"class":141},[135,15552,15553,15555],{"class":137,"line":152},[135,15554,326],{"class":325},[135,15556,2056],{"class":141},[135,15558,15559],{"class":137,"line":162},[135,15560,184],{"emptyLinePlaceholder":183},[135,15562,15563,15565,15567],{"class":137,"line":171},[135,15564,493],{"class":325},[135,15566,15472],{"class":145},[135,15568,6284],{"class":141},[135,15570,15571,15573,15575],{"class":137,"line":180},[135,15572,9647],{"class":141},[135,15574,1663],{"class":350},[135,15576,3238],{"class":141},[135,15578,15579,15582,15584,15587,15589,15591,15593,15595,15597,15599],{"class":137,"line":187},[135,15580,15581],{"class":141},"    count: Annotated[",[135,15583,387],{"class":350},[135,15585,15586],{"class":141},", typer.Option(",[135,15588,13713],{"class":914},[135,15590,516],{"class":325},[135,15592,15453],{"class":158},[135,15594,1741],{"class":141},[135,15596,516],{"class":325},[135,15598,10002],{"class":350},[135,15600,3238],{"class":141},[135,15602,15603,15605,15607],{"class":137,"line":201},[135,15604,1788],{"class":141},[135,15606,3093],{"class":350},[135,15608,360],{"class":141},[135,15610,15611],{"class":137,"line":210},[135,15612,15493],{"class":158},[135,15614,15615,15617,15619,15621,15623],{"class":137,"line":215},[135,15616,2277],{"class":325},[135,15618,7145],{"class":141},[135,15620,933],{"class":325},[135,15622,7150],{"class":350},[135,15624,15506],{"class":141},[135,15626,15627,15630,15632,15634,15636,15638,15640,15642],{"class":137,"line":225},[135,15628,15629],{"class":141},"        typer.echo(",[135,15631,568],{"class":325},[135,15633,15515],{"class":158},[135,15635,574],{"class":350},[135,15637,9681],{"class":141},[135,15639,586],{"class":350},[135,15641,589],{"class":158},[135,15643,550],{"class":141},[135,15645,15646],{"class":137,"line":236},[135,15647,184],{"emptyLinePlaceholder":183},[135,15649,15650,15652,15654,15656,15658],{"class":137,"line":606},[135,15651,347],{"class":325},[135,15653,351],{"class":350},[135,15655,354],{"class":325},[135,15657,357],{"class":158},[135,15659,360],{"class":141},[135,15661,15662],{"class":137,"line":619},[135,15663,15664],{"class":141},"    typer.run(hello)\n",[10,15666,15667,15668,15670,15671,15673],{},"Both produce equivalent ",[14,15669,12991],{}," output and the same ",[14,15672,387],{}," coercion. Typer reads the\nparameter kind (positional → argument, keyword with default → option) and the annotation;\nClick wants you to say it outright.",[36,15675,15677],{"id":15676},"decision-factors","Decision factors",[4693,15679,15680,15691],{},[4696,15681,15682],{},[4699,15683,15684,15687,15689],{},[4702,15685,15686],{},"Factor",[4702,15688,2036],{},[4702,15690,2175],{},[4715,15692,15693,15704,15715,15726,15737,15748,15759],{},[4699,15694,15695,15698,15701],{},[4720,15696,15697],{},"Interface definition",[4720,15699,15700],{},"Inferred from type hints",[4720,15702,15703],{},"Explicit decorators",[4699,15705,15706,15709,15712],{},[4720,15707,15708],{},"Boilerplate",[4720,15710,15711],{},"Lower",[4720,15713,15714],{},"Higher, but very transparent",[4699,15716,15717,15720,15723],{},[4720,15718,15719],{},"Learning curve",[4720,15721,15722],{},"Gentle if you already write type hints",[4720,15724,15725],{},"Gentle if you think in decorators",[4699,15727,15728,15731,15734],{},[4720,15729,15730],{},"Control over edge-case parsing",[4720,15732,15733],{},"Good; drop to Click when needed",[4720,15735,15736],{},"Maximum",[4699,15738,15739,15742,15745],{},[4720,15740,15741],{},"Ecosystem \u002F plugins",[4720,15743,15744],{},"Inherits Click's",[4720,15746,15747],{},"Largest, most mature",[4699,15749,15750,15753,15756],{},[4720,15751,15752],{},"Shell completion",[4720,15754,15755],{},"Built in, minimal setup",[4720,15757,15758],{},"Available, more manual",[4699,15760,15761,15764,15767],{},[4720,15762,15763],{},"Best for",[4720,15765,15766],{},"New projects, type-hinted codebases",[4720,15768,15769],{},"Existing Click code, fine control",[36,15771,15773],{"id":15772},"when-the-choice-is-already-made-for-you","When the choice is already made for you",[41,15775,15776,15782,15792],{},[44,15777,15778,15781],{},[72,15779,15780],{},"Inheriting a Click codebase?"," Stay on Click. Mixing frameworks for the sake of it\nadds cognitive load without payoff.",[44,15783,15784,15787,15788,15791],{},[72,15785,15786],{},"Heavy use of a Click plugin"," (e.g. ",[14,15789,15790],{},"click-plugins",", framework-specific groups)? Click\nkeeps that path frictionless.",[44,15793,15794,15797],{},[72,15795,15796],{},"Greenfield tool with type hints everywhere?"," Typer removes boilerplate and keeps the\nsignature as the single source of truth.",[36,15799,15801],{"id":15800},"test-ergonomics","Test ergonomics",[10,15803,15804,15805,15807],{},"Both frameworks ship a ",[14,15806,3883],{}," that invokes commands in-process and captures output and\nexit codes — no subprocess required. The APIs are nearly identical because Typer reuses\nClick's testing machinery, so your testing strategy doesn't change with the framework:",[126,15809,15811],{"className":316,"code":15810,"language":318,"meta":131,"style":131},"from click.testing import CliRunner  # Typer: from typer.testing import CliRunner\n\ndef test_hello() -> None:\n    runner = CliRunner()\n    result = runner.invoke(hello, [\"World\", \"--count\", \"2\"])\n    assert result.exit_code == 0\n    assert result.output.count(\"Hello World\") == 2\n",[14,15812,15813,15827,15831,15844,15852,15875,15885],{"__ignoreMap":131},[135,15814,15815,15817,15819,15821,15824],{"class":137,"line":138},[135,15816,334],{"class":325},[135,15818,3914],{"class":141},[135,15820,326],{"class":325},[135,15822,15823],{"class":141}," CliRunner  ",[135,15825,15826],{"class":669},"# Typer: from typer.testing import CliRunner\n",[135,15828,15829],{"class":137,"line":152},[135,15830,184],{"emptyLinePlaceholder":183},[135,15832,15833,15835,15838,15840,15842],{"class":137,"line":162},[135,15834,493],{"class":325},[135,15836,15837],{"class":145}," test_hello",[135,15839,499],{"class":141},[135,15841,3093],{"class":350},[135,15843,360],{"class":141},[135,15845,15846,15848,15850],{"class":137,"line":171},[135,15847,14695],{"class":141},[135,15849,516],{"class":325},[135,15851,14700],{"class":141},[135,15853,15854,15856,15858,15861,15864,15866,15868,15870,15873],{"class":137,"line":180},[135,15855,4174],{"class":141},[135,15857,516],{"class":325},[135,15859,15860],{"class":141}," runner.invoke(hello, [",[135,15862,15863],{"class":158},"\"World\"",[135,15865,861],{"class":141},[135,15867,15428],{"class":158},[135,15869,861],{"class":141},[135,15871,15872],{"class":158},"\"2\"",[135,15874,4243],{"class":141},[135,15876,15877,15879,15881,15883],{"class":137,"line":187},[135,15878,4070],{"class":325},[135,15880,4196],{"class":141},[135,15882,1815],{"class":325},[135,15884,599],{"class":350},[135,15886,15887,15889,15892,15895,15897,15899],{"class":137,"line":201},[135,15888,4070],{"class":325},[135,15890,15891],{"class":141}," result.output.count(",[135,15893,15894],{"class":158},"\"Hello World\"",[135,15896,3398],{"class":141},[135,15898,1815],{"class":325},[135,15900,4254],{"class":350},[36,15902,13486],{"id":13485},[41,15904,15905,15916],{},[44,15906,15907,15911,15912,15915],{},[72,15908,15909],{},[97,15910,705],{"href":704},"\n— ",[14,15913,15914],{},"group()",", nested commands, shared context, and middleware-style behavior.",[44,15917,15918,15922,15923,15925,15926,61],{},[72,15919,15920],{},[97,15921,9047],{"href":9046},"\n— shared options, ",[14,15924,12670],{}," flags, and pre-command hooks with ",[14,15927,15928],{},"invoke_without_command",[36,15930,1277],{"id":1276},[41,15932,15933,15937,15941],{},[44,15934,2447,15935],{},[97,15936,1430],{"href":1429},[44,15938,15319,15939],{},[97,15940,1285],{"href":1284},[44,15942,15943,15944],{},"Then: ",[97,15945,842],{"href":99},[1303,15947,15948],{},"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 .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":131,"searchDepth":152,"depth":152,"links":15950},[15951,15952,15953,15954,15955,15956,15957],{"id":38,"depth":152,"text":39},{"id":15390,"depth":152,"text":15391},{"id":15676,"depth":152,"text":15677},{"id":15772,"depth":152,"text":15773},{"id":15800,"depth":152,"text":15801},{"id":13485,"depth":152,"text":13486},{"id":1276,"depth":152,"text":1277},"Compare Typer and Click for Python CLIs — type-hint inference vs decorator control, test ergonomics, and when each framework best fits your project.",{},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each",{"title":15350,"description":15958},"modern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Findex",[2483,2484,15964,15965],"frameworks","comparison","ESzOEz9YN8bhggRKAcQgd3cspYvuZgL33dWT7GlRneE",{"id":15968,"title":9047,"body":15969,"date":1320,"description":17343,"difficulty":1322,"draft":1323,"extension":1324,"meta":17344,"navigation":183,"path":17345,"seo":17346,"stem":17347,"tags":17348,"updated":1320,"__hash__":17351},"content\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Ftyper-callback-functions-explained\u002Findex.md",{"type":7,"value":15970,"toc":17330},[15971,15986,15988,16033,16039,16043,16051,16271,16290,16297,16307,16512,16522,16526,16536,16617,16629,16633,16642,16792,16806,16810,16813,16844,16850,16854,16876,17088,17107,17111,17151,17155,17161,17311,17313,17327],[10,15972,15973,15974,15976,15977,861,15979,15982,15983,15985],{},"In Typer, a ",[72,15975,2039],{}," is the function that runs before any subcommand — the equivalent\nof Click's group body. It's where you declare global options (",[14,15978,14919],{},[14,15980,15981],{},"--config","),\nimplement an eager ",[14,15984,12670],{}," flag, set up shared state, and enforce preconditions. This\nguide explains the three kinds of callback you'll actually use and the patterns that keep\nthem correct.",[36,15987,39],{"id":38},[41,15989,15990,16001,16017,16027],{},[44,15991,111,15992,14045,15995,15998,15999,61],{},[72,15993,15994],{},"app callback",[14,15996,15997],{},"@app.callback()",") runs before every subcommand — put global\noptions and setup there, and store shared state on ",[14,16000,13593],{},[44,16002,16003,16004,16006,16007,1230,16010,16013,16014,61],{},"For a flag that should run and exit immediately (like ",[14,16005,12670],{},"), use an ",[72,16008,16009],{},"eager option\ncallback",[14,16011,16012],{},"is_eager=True"," that raises ",[14,16015,16016],{},"typer.Exit()",[44,16018,16019,16020,14045,16023,16026],{},"Use a ",[72,16021,16022],{},"parameter callback",[14,16024,16025],{},"typer.Option(callback=...)",") for per-option validation.",[44,16028,8926,16029,16032],{},[14,16030,16031],{},"invoke_without_command=True"," when the app should do something even with no\nsubcommand.",[10,16034,16035],{},[104,16036],{"alt":16037,"src":16038},"Typer callback order of operations: eager param callbacks (which can exit early), then the app callback, then the chosen command's param callbacks, then the command body.","\u002Fillustrations\u002Ftyper-callback-order.svg",[36,16040,16042],{"id":16041},"the-app-callback-global-options-and-setup","The app callback: global options and setup",[10,16044,16045,16046,16048,16049,473],{},"Add a callback to a multi-command app with ",[14,16047,15997],{},". Its parameters become global\noptions that appear before the subcommand. Store anything subcommands need on ",[14,16050,13593],{},[126,16052,16054],{"className":316,"code":16053,"language":318,"meta":131,"style":131},"# widget\u002Fmain.py\nfrom typing import Annotated\nimport typer\n\napp = typer.Typer()\n\n@app.callback()\ndef main(\n    ctx: typer.Context,\n    verbose: Annotated[bool, typer.Option(\"--verbose\", \"-v\", help=\"Enable verbose logging.\")] = False,\n) -> None:\n    \"\"\"widget — manage widgets from the command line.\"\"\"\n    # ctx.obj is the shared bag passed down to every subcommand.\n    ctx.obj = {\"verbose\": verbose}\n\n@app.command()\ndef create(ctx: typer.Context, name: str) -> None:\n    \"\"\"Create a widget called NAME.\"\"\"\n    if ctx.obj[\"verbose\"]:\n        typer.echo(\"[verbose] creating widget\")\n    typer.echo(f\"Created widget {name!r}\")\n\nif __name__ == \"__main__\":\n    app()\n",[14,16055,16056,16061,16071,16077,16081,16090,16094,16101,16109,16114,16147,16155,16159,16164,16178,16182,16188,16205,16210,16220,16229,16250,16254,16266],{"__ignoreMap":131},[135,16057,16058],{"class":137,"line":138},[135,16059,16060],{"class":669},"# widget\u002Fmain.py\n",[135,16062,16063,16065,16067,16069],{"class":137,"line":152},[135,16064,334],{"class":325},[135,16066,1555],{"class":141},[135,16068,326],{"class":325},[135,16070,1560],{"class":141},[135,16072,16073,16075],{"class":137,"line":162},[135,16074,326],{"class":325},[135,16076,2056],{"class":141},[135,16078,16079],{"class":137,"line":171},[135,16080,184],{"emptyLinePlaceholder":183},[135,16082,16083,16086,16088],{"class":137,"line":180},[135,16084,16085],{"class":141},"app ",[135,16087,516],{"class":325},[135,16089,10440],{"class":141},[135,16091,16092],{"class":137,"line":187},[135,16093,184],{"emptyLinePlaceholder":183},[135,16095,16096,16099],{"class":137,"line":201},[135,16097,16098],{"class":145},"@app.callback",[135,16100,2135],{"class":141},[135,16102,16103,16105,16107],{"class":137,"line":210},[135,16104,493],{"class":325},[135,16106,496],{"class":145},[135,16108,6284],{"class":141},[135,16110,16111],{"class":137,"line":215},[135,16112,16113],{"class":141},"    ctx: typer.Context,\n",[135,16115,16116,16119,16121,16123,16125,16127,16129,16131,16133,16135,16138,16140,16142,16145],{"class":137,"line":225},[135,16117,16118],{"class":141},"    verbose: Annotated[",[135,16120,5112],{"class":350},[135,16122,15586],{"class":141},[135,16124,13699],{"class":158},[135,16126,861],{"class":141},[135,16128,13694],{"class":158},[135,16130,861],{"class":141},[135,16132,13713],{"class":914},[135,16134,516],{"class":325},[135,16136,16137],{"class":158},"\"Enable verbose logging.\"",[135,16139,1741],{"class":141},[135,16141,516],{"class":325},[135,16143,16144],{"class":350}," False",[135,16146,3238],{"class":141},[135,16148,16149,16151,16153],{"class":137,"line":236},[135,16150,1788],{"class":141},[135,16152,3093],{"class":350},[135,16154,360],{"class":141},[135,16156,16157],{"class":137,"line":606},[135,16158,13823],{"class":158},[135,16160,16161],{"class":137,"line":619},[135,16162,16163],{"class":669},"    # ctx.obj is the shared bag passed down to every subcommand.\n",[135,16165,16166,16169,16171,16173,16175],{"class":137,"line":1752},[135,16167,16168],{"class":141},"    ctx.obj ",[135,16170,516],{"class":325},[135,16172,5135],{"class":141},[135,16174,5167],{"class":158},[135,16176,16177],{"class":141},": verbose}\n",[135,16179,16180],{"class":137,"line":1765},[135,16181,184],{"emptyLinePlaceholder":183},[135,16183,16184,16186],{"class":137,"line":1774},[135,16185,2132],{"class":145},[135,16187,2135],{"class":141},[135,16189,16190,16192,16194,16197,16199,16201,16203],{"class":137,"line":1795},[135,16191,493],{"class":325},[135,16193,13922],{"class":145},[135,16195,16196],{"class":141},"(ctx: typer.Context, name: ",[135,16198,1663],{"class":350},[135,16200,1788],{"class":141},[135,16202,3093],{"class":350},[135,16204,360],{"class":141},[135,16206,16207],{"class":137,"line":1830},[135,16208,16209],{"class":158},"    \"\"\"Create a widget called NAME.\"\"\"\n",[135,16211,16212,16214,16216,16218],{"class":137,"line":1846},[135,16213,530],{"class":325},[135,16215,13945],{"class":141},[135,16217,5167],{"class":158},[135,16219,10186],{"class":141},[135,16221,16222,16224,16227],{"class":137,"line":1854},[135,16223,15629],{"class":141},[135,16225,16226],{"class":158},"\"[verbose] creating widget\"",[135,16228,550],{"class":141},[135,16230,16231,16234,16236,16238,16240,16242,16244,16246,16248],{"class":137,"line":1859},[135,16232,16233],{"class":141},"    typer.echo(",[135,16235,568],{"class":325},[135,16237,13985],{"class":158},[135,16239,574],{"class":350},[135,16241,9681],{"class":141},[135,16243,3447],{"class":325},[135,16245,586],{"class":350},[135,16247,589],{"class":158},[135,16249,550],{"class":141},[135,16251,16252],{"class":137,"line":1877},[135,16253,184],{"emptyLinePlaceholder":183},[135,16255,16256,16258,16260,16262,16264],{"class":137,"line":1893},[135,16257,347],{"class":325},[135,16259,351],{"class":350},[135,16261,354],{"class":325},[135,16263,357],{"class":158},[135,16265,360],{"class":141},[135,16267,16268],{"class":137,"line":1926},[135,16269,16270],{"class":141},"    app()\n",[10,16272,16273,16274,16276,16277,16279,16280,16282,16283,16286,16287,16289],{},"Run it as ",[14,16275,14927],{},". The callback runs first, stashes ",[14,16278,5612],{}," on\n",[14,16281,13593],{},", and then ",[14,16284,16285],{},"create"," reads it. The global option goes ",[23,16288,1475],{}," the subcommand,\nexactly like Click groups.",[36,16291,16293,16294,16296],{"id":16292},"the-eager-version-flag","The eager ",[14,16295,12670],{}," flag",[10,16298,13401,16299,16301,16302,16304,16305,473],{},[14,16300,12670],{}," flag should print and exit before Typer validates anything else — even before\nrequired arguments on a subcommand. That's what ",[14,16303,16012],{}," is for: eager parameters are\nprocessed first, regardless of their position. Pair it with a callback that raises\n",[14,16306,16016],{},[126,16308,16310],{"className":316,"code":16309,"language":318,"meta":131,"style":131},"from typing import Annotated, Optional\nimport typer\n\n__version__ = \"1.2.0\"\napp = typer.Typer()\n\ndef version_callback(value: bool) -> None:\n    if value:\n        typer.echo(f\"widget {__version__}\")\n        raise typer.Exit()  # stops processing immediately, exit code 0\n\n@app.callback()\ndef main(\n    version: Annotated[\n        Optional[bool],\n        typer.Option(\n            \"--version\",\n            callback=version_callback,\n            is_eager=True,\n            help=\"Show the version and exit.\",\n        ),\n    ] = None,\n) -> None:\n    \"\"\"widget — manage widgets.\"\"\"\n",[14,16311,16312,16323,16329,16333,16343,16351,16355,16372,16379,16395,16405,16409,16415,16423,16428,16438,16443,16450,16460,16471,16483,16488,16499,16507],{"__ignoreMap":131},[135,16313,16314,16316,16318,16320],{"class":137,"line":138},[135,16315,334],{"class":325},[135,16317,1555],{"class":141},[135,16319,326],{"class":325},[135,16321,16322],{"class":141}," Annotated, Optional\n",[135,16324,16325,16327],{"class":137,"line":152},[135,16326,326],{"class":325},[135,16328,2056],{"class":141},[135,16330,16331],{"class":137,"line":162},[135,16332,184],{"emptyLinePlaceholder":183},[135,16334,16335,16338,16340],{"class":137,"line":171},[135,16336,16337],{"class":350},"__version__",[135,16339,2150],{"class":325},[135,16341,16342],{"class":158}," \"1.2.0\"\n",[135,16344,16345,16347,16349],{"class":137,"line":180},[135,16346,16085],{"class":141},[135,16348,516],{"class":325},[135,16350,10440],{"class":141},[135,16352,16353],{"class":137,"line":187},[135,16354,184],{"emptyLinePlaceholder":183},[135,16356,16357,16359,16362,16364,16366,16368,16370],{"class":137,"line":201},[135,16358,493],{"class":325},[135,16360,16361],{"class":145}," version_callback",[135,16363,2081],{"class":141},[135,16365,5112],{"class":350},[135,16367,1788],{"class":141},[135,16369,3093],{"class":350},[135,16371,360],{"class":141},[135,16373,16374,16376],{"class":137,"line":210},[135,16375,530],{"class":325},[135,16377,16378],{"class":141}," value:\n",[135,16380,16381,16383,16385,16388,16391,16393],{"class":137,"line":215},[135,16382,15629],{"class":141},[135,16384,568],{"class":325},[135,16386,16387],{"class":158},"\"widget ",[135,16389,16390],{"class":350},"{__version__}",[135,16392,589],{"class":158},[135,16394,550],{"class":141},[135,16396,16397,16399,16402],{"class":137,"line":225},[135,16398,2108],{"class":325},[135,16400,16401],{"class":141}," typer.Exit()  ",[135,16403,16404],{"class":669},"# stops processing immediately, exit code 0\n",[135,16406,16407],{"class":137,"line":236},[135,16408,184],{"emptyLinePlaceholder":183},[135,16410,16411,16413],{"class":137,"line":606},[135,16412,16098],{"class":145},[135,16414,2135],{"class":141},[135,16416,16417,16419,16421],{"class":137,"line":619},[135,16418,493],{"class":325},[135,16420,496],{"class":145},[135,16422,6284],{"class":141},[135,16424,16425],{"class":137,"line":1752},[135,16426,16427],{"class":141},"    version: Annotated[\n",[135,16429,16430,16433,16435],{"class":137,"line":1765},[135,16431,16432],{"class":141},"        Optional[",[135,16434,5112],{"class":350},[135,16436,16437],{"class":141},"],\n",[135,16439,16440],{"class":137,"line":1774},[135,16441,16442],{"class":141},"        typer.Option(\n",[135,16444,16445,16448],{"class":137,"line":1795},[135,16446,16447],{"class":158},"            \"--version\"",[135,16449,3238],{"class":141},[135,16451,16452,16455,16457],{"class":137,"line":1830},[135,16453,16454],{"class":914},"            callback",[135,16456,516],{"class":325},[135,16458,16459],{"class":141},"version_callback,\n",[135,16461,16462,16465,16467,16469],{"class":137,"line":1846},[135,16463,16464],{"class":914},"            is_eager",[135,16466,516],{"class":325},[135,16468,3651],{"class":350},[135,16470,3238],{"class":141},[135,16472,16473,16476,16478,16481],{"class":137,"line":1854},[135,16474,16475],{"class":914},"            help",[135,16477,516],{"class":325},[135,16479,16480],{"class":158},"\"Show the version and exit.\"",[135,16482,3238],{"class":141},[135,16484,16485],{"class":137,"line":1859},[135,16486,16487],{"class":141},"        ),\n",[135,16489,16490,16493,16495,16497],{"class":137,"line":1877},[135,16491,16492],{"class":141},"    ] ",[135,16494,516],{"class":325},[135,16496,4942],{"class":350},[135,16498,3238],{"class":141},[135,16500,16501,16503,16505],{"class":137,"line":1893},[135,16502,1788],{"class":141},[135,16504,3093],{"class":350},[135,16506,360],{"class":141},[135,16508,16509],{"class":137,"line":1926},[135,16510,16511],{"class":158},"    \"\"\"widget — manage widgets.\"\"\"\n",[10,16513,16514,16515,16518,16519,16521],{},"Because the option is eager, ",[14,16516,16517],{},"widget --version"," prints the version and exits cleanly even\nthough no subcommand was given. Without ",[14,16520,16012],{},", Typer might complain about a\nmissing command first.",[36,16523,16525],{"id":16524},"running-without-a-subcommand","Running without a subcommand",[10,16527,16528,16529,16531,16532,16535],{},"By default a multi-command Typer app requires a subcommand. Set\n",[14,16530,16031],{}," to let the callback run on its own — useful for showing a\ndefault status view or custom help. Check ",[14,16533,16534],{},"ctx.invoked_subcommand"," to tell the cases apart:",[126,16537,16539],{"className":316,"code":16538,"language":318,"meta":131,"style":131},"import typer\n\napp = typer.Typer(invoke_without_command=True)\n\n@app.callback()\ndef main(ctx: typer.Context) -> None:\n    \"\"\"widget — manage widgets.\"\"\"\n    if ctx.invoked_subcommand is None:\n        typer.echo(\"No command given. Run 'widget --help' for usage.\")\n",[14,16540,16541,16547,16551,16568,16572,16578,16591,16595,16608],{"__ignoreMap":131},[135,16542,16543,16545],{"class":137,"line":138},[135,16544,326],{"class":325},[135,16546,2056],{"class":141},[135,16548,16549],{"class":137,"line":152},[135,16550,184],{"emptyLinePlaceholder":183},[135,16552,16553,16555,16557,16560,16562,16564,16566],{"class":137,"line":162},[135,16554,16085],{"class":141},[135,16556,516],{"class":325},[135,16558,16559],{"class":141}," typer.Typer(",[135,16561,15928],{"class":914},[135,16563,516],{"class":325},[135,16565,3651],{"class":350},[135,16567,550],{"class":141},[135,16569,16570],{"class":137,"line":171},[135,16571,184],{"emptyLinePlaceholder":183},[135,16573,16574,16576],{"class":137,"line":180},[135,16575,16098],{"class":145},[135,16577,2135],{"class":141},[135,16579,16580,16582,16584,16587,16589],{"class":137,"line":187},[135,16581,493],{"class":325},[135,16583,496],{"class":145},[135,16585,16586],{"class":141},"(ctx: typer.Context) -> ",[135,16588,3093],{"class":350},[135,16590,360],{"class":141},[135,16592,16593],{"class":137,"line":201},[135,16594,16511],{"class":158},[135,16596,16597,16599,16602,16604,16606],{"class":137,"line":210},[135,16598,530],{"class":325},[135,16600,16601],{"class":141}," ctx.invoked_subcommand ",[135,16603,5297],{"class":325},[135,16605,4942],{"class":350},[135,16607,360],{"class":141},[135,16609,16610,16612,16615],{"class":137,"line":215},[135,16611,15629],{"class":141},[135,16613,16614],{"class":158},"\"No command given. Run 'widget --help' for usage.\"",[135,16616,550],{"class":141},[10,16618,14277,16619,16621,16622,16625,16626,16628],{},[14,16620,13569],{}," with no arguments runs the callback body, while ",[14,16623,16624],{},"widget create ..."," skips the\n",[14,16627,347],{}," branch and dispatches normally.",[36,16630,16632],{"id":16631},"parameter-callbacks-for-validation","Parameter callbacks for validation",[10,16634,16635,16636,16638,16639,16641],{},"Distinct from the app callback, a ",[72,16637,16022],{}," validates a single option or\nargument. Return the (optionally transformed) value, or raise ",[14,16640,2043],{}," to fail\nwith a clean error message and a non-zero exit code:",[126,16643,16645],{"className":316,"code":16644,"language":318,"meta":131,"style":131},"from typing import Annotated\nimport typer\n\napp = typer.Typer()\n\ndef validate_name(value: str) -> str:\n    if not value.isidentifier():\n        raise typer.BadParameter(\"name must be a valid Python identifier\")\n    return value.lower()  # callbacks can normalize, not just validate\n\n@app.command()\ndef create(\n    name: Annotated[str, typer.Argument(callback=validate_name)],\n) -> None:\n    \"\"\"Create a widget.\"\"\"\n    typer.echo(f\"Created {name}\")\n",[14,16646,16647,16657,16663,16667,16675,16679,16696,16705,16716,16726,16730,16736,16744,16760,16768,16773],{"__ignoreMap":131},[135,16648,16649,16651,16653,16655],{"class":137,"line":138},[135,16650,334],{"class":325},[135,16652,1555],{"class":141},[135,16654,326],{"class":325},[135,16656,1560],{"class":141},[135,16658,16659,16661],{"class":137,"line":152},[135,16660,326],{"class":325},[135,16662,2056],{"class":141},[135,16664,16665],{"class":137,"line":162},[135,16666,184],{"emptyLinePlaceholder":183},[135,16668,16669,16671,16673],{"class":137,"line":171},[135,16670,16085],{"class":141},[135,16672,516],{"class":325},[135,16674,10440],{"class":141},[135,16676,16677],{"class":137,"line":180},[135,16678,184],{"emptyLinePlaceholder":183},[135,16680,16681,16683,16686,16688,16690,16692,16694],{"class":137,"line":187},[135,16682,493],{"class":325},[135,16684,16685],{"class":145}," validate_name",[135,16687,2081],{"class":141},[135,16689,1663],{"class":350},[135,16691,1788],{"class":141},[135,16693,1663],{"class":350},[135,16695,360],{"class":141},[135,16697,16698,16700,16702],{"class":137,"line":201},[135,16699,530],{"class":325},[135,16701,533],{"class":325},[135,16703,16704],{"class":141}," value.isidentifier():\n",[135,16706,16707,16709,16711,16714],{"class":137,"line":210},[135,16708,2108],{"class":325},[135,16710,2111],{"class":141},[135,16712,16713],{"class":158},"\"name must be a valid Python identifier\"",[135,16715,550],{"class":141},[135,16717,16718,16720,16723],{"class":137,"line":215},[135,16719,596],{"class":325},[135,16721,16722],{"class":141}," value.lower()  ",[135,16724,16725],{"class":669},"# callbacks can normalize, not just validate\n",[135,16727,16728],{"class":137,"line":225},[135,16729,184],{"emptyLinePlaceholder":183},[135,16731,16732,16734],{"class":137,"line":236},[135,16733,2132],{"class":145},[135,16735,2135],{"class":141},[135,16737,16738,16740,16742],{"class":137,"line":606},[135,16739,493],{"class":325},[135,16741,13922],{"class":145},[135,16743,6284],{"class":141},[135,16745,16746,16748,16750,16753,16755,16757],{"class":137,"line":619},[135,16747,1660],{"class":141},[135,16749,1663],{"class":350},[135,16751,16752],{"class":141},", typer.Argument(",[135,16754,2039],{"class":914},[135,16756,516],{"class":325},[135,16758,16759],{"class":141},"validate_name)],\n",[135,16761,16762,16764,16766],{"class":137,"line":1752},[135,16763,1788],{"class":141},[135,16765,3093],{"class":350},[135,16767,360],{"class":141},[135,16769,16770],{"class":137,"line":1765},[135,16771,16772],{"class":158},"    \"\"\"Create a widget.\"\"\"\n",[135,16774,16775,16777,16779,16782,16784,16786,16788,16790],{"class":137,"line":1774},[135,16776,16233],{"class":141},[135,16778,568],{"class":325},[135,16780,16781],{"class":158},"\"Created ",[135,16783,574],{"class":350},[135,16785,9681],{"class":141},[135,16787,586],{"class":350},[135,16789,589],{"class":158},[135,16791,550],{"class":141},[10,16793,16794,16797,16798,16801,16802,16805],{},[14,16795,16796],{},"widget create \"not valid\""," exits non-zero with a clear message; ",[14,16799,16800],{},"widget create Gadget","\nstores the normalized ",[14,16803,16804],{},"gadget",". Keep these callbacks pure and fast — they run during\nparsing, before your command logic.",[36,16807,16809],{"id":16808},"order-of-operations","Order of operations",[10,16811,16812],{},"When a command runs, Typer processes things in this order:",[1961,16814,16815,16824,16831,16838],{},[44,16816,16817,16820,16821,16823],{},[72,16818,16819],{},"Eager"," parameter callbacks (e.g. ",[14,16822,12670],{},"), which may exit early.",[44,16825,111,16826,14045,16828,16830],{},[72,16827,15994],{},[14,16829,15997],{},"), including its own parameter callbacks.",[44,16832,16833,16834,16837],{},"Non-eager ",[72,16835,16836],{},"parameter callbacks"," for the chosen subcommand.",[44,16839,111,16840,16843],{},[72,16841,16842],{},"subcommand"," body.",[10,16845,16846,16847,16849],{},"Knowing this order explains why ",[14,16848,12670],{}," works without a subcommand and why validation\nin a parameter callback fires before your command code.",[36,16851,16853],{"id":16852},"callbacks-on-sub-apps","Callbacks on sub-apps",[10,16855,16856,16857,16860,16861,16864,16865,16868,16869,16872,16873,16875],{},"Large CLIs split commands across multiple Typer apps and compose them with\n",[14,16858,16859],{},"add_typer()",". Each sub-app gets its ",[72,16862,16863],{},"own"," callback, which runs after the parent's and\nbefore that sub-app's commands — so ",[14,16866,16867],{},"widget remote add"," runs the root callback, then the\n",[14,16870,16871],{},"remote"," callback, then ",[14,16874,14284],{},". This is how you scope setup (and global options) to a branch\nof the command tree:",[126,16877,16879],{"className":316,"code":16878,"language":318,"meta":131,"style":131},"import typer\n\napp = typer.Typer()\nremote_app = typer.Typer()\napp.add_typer(remote_app, name=\"remote\")\n\n@app.callback()\ndef main(ctx: typer.Context) -> None:\n    \"\"\"widget — manage widgets.\"\"\"\n    ctx.obj = {\"scope\": \"root\"}\n\n@remote_app.callback()\ndef remote_main(ctx: typer.Context, registry: str = \"default\") -> None:\n    \"\"\"Manage remote registries.\"\"\"\n    # Extend the parent's shared state instead of replacing it.\n    ctx.obj[\"registry\"] = registry\n\n@remote_app.command()\ndef add(ctx: typer.Context, url: str) -> None:\n    \"\"\"Register URL with the selected registry.\"\"\"\n    typer.echo(f\"Added {url} to {ctx.obj['registry']}\")\n",[14,16880,16881,16887,16891,16899,16908,16921,16925,16931,16943,16947,16965,16969,16976,16999,17004,17009,17023,17027,17034,17051,17056],{"__ignoreMap":131},[135,16882,16883,16885],{"class":137,"line":138},[135,16884,326],{"class":325},[135,16886,2056],{"class":141},[135,16888,16889],{"class":137,"line":152},[135,16890,184],{"emptyLinePlaceholder":183},[135,16892,16893,16895,16897],{"class":137,"line":162},[135,16894,16085],{"class":141},[135,16896,516],{"class":325},[135,16898,10440],{"class":141},[135,16900,16901,16904,16906],{"class":137,"line":171},[135,16902,16903],{"class":141},"remote_app ",[135,16905,516],{"class":325},[135,16907,10440],{"class":141},[135,16909,16910,16913,16915,16917,16919],{"class":137,"line":180},[135,16911,16912],{"class":141},"app.add_typer(remote_app, ",[135,16914,9681],{"class":914},[135,16916,516],{"class":325},[135,16918,14832],{"class":158},[135,16920,550],{"class":141},[135,16922,16923],{"class":137,"line":187},[135,16924,184],{"emptyLinePlaceholder":183},[135,16926,16927,16929],{"class":137,"line":201},[135,16928,16098],{"class":145},[135,16930,2135],{"class":141},[135,16932,16933,16935,16937,16939,16941],{"class":137,"line":210},[135,16934,493],{"class":325},[135,16936,496],{"class":145},[135,16938,16586],{"class":141},[135,16940,3093],{"class":350},[135,16942,360],{"class":141},[135,16944,16945],{"class":137,"line":215},[135,16946,16511],{"class":158},[135,16948,16949,16951,16953,16955,16958,16960,16963],{"class":137,"line":225},[135,16950,16168],{"class":141},[135,16952,516],{"class":325},[135,16954,5135],{"class":141},[135,16956,16957],{"class":158},"\"scope\"",[135,16959,2344],{"class":141},[135,16961,16962],{"class":158},"\"root\"",[135,16964,4051],{"class":141},[135,16966,16967],{"class":137,"line":236},[135,16968,184],{"emptyLinePlaceholder":183},[135,16970,16971,16974],{"class":137,"line":606},[135,16972,16973],{"class":145},"@remote_app.callback",[135,16975,2135],{"class":141},[135,16977,16978,16980,16983,16986,16988,16990,16993,16995,16997],{"class":137,"line":619},[135,16979,493],{"class":325},[135,16981,16982],{"class":145}," remote_main",[135,16984,16985],{"class":141},"(ctx: typer.Context, registry: ",[135,16987,1663],{"class":350},[135,16989,2150],{"class":325},[135,16991,16992],{"class":158}," \"default\"",[135,16994,1788],{"class":141},[135,16996,3093],{"class":350},[135,16998,360],{"class":141},[135,17000,17001],{"class":137,"line":1752},[135,17002,17003],{"class":158},"    \"\"\"Manage remote registries.\"\"\"\n",[135,17005,17006],{"class":137,"line":1765},[135,17007,17008],{"class":669},"    # Extend the parent's shared state instead of replacing it.\n",[135,17010,17011,17013,17016,17018,17020],{"class":137,"line":1774},[135,17012,13842],{"class":141},[135,17014,17015],{"class":158},"\"registry\"",[135,17017,2750],{"class":141},[135,17019,516],{"class":325},[135,17021,17022],{"class":141}," registry\n",[135,17024,17025],{"class":137,"line":1795},[135,17026,184],{"emptyLinePlaceholder":183},[135,17028,17029,17032],{"class":137,"line":1830},[135,17030,17031],{"class":145},"@remote_app.command",[135,17033,2135],{"class":141},[135,17035,17036,17038,17040,17043,17045,17047,17049],{"class":137,"line":1846},[135,17037,493],{"class":325},[135,17039,14168],{"class":145},[135,17041,17042],{"class":141},"(ctx: typer.Context, url: ",[135,17044,1663],{"class":350},[135,17046,1788],{"class":141},[135,17048,3093],{"class":350},[135,17050,360],{"class":141},[135,17052,17053],{"class":137,"line":1854},[135,17054,17055],{"class":158},"    \"\"\"Register URL with the selected registry.\"\"\"\n",[135,17057,17058,17060,17062,17065,17067,17069,17071,17073,17075,17077,17080,17082,17084,17086],{"class":137,"line":1859},[135,17059,16233],{"class":141},[135,17061,568],{"class":325},[135,17063,17064],{"class":158},"\"Added ",[135,17066,574],{"class":350},[135,17068,14198],{"class":141},[135,17070,586],{"class":350},[135,17072,5659],{"class":158},[135,17074,574],{"class":350},[135,17076,13964],{"class":141},[135,17078,17079],{"class":158},"'registry'",[135,17081,583],{"class":141},[135,17083,586],{"class":350},[135,17085,589],{"class":158},[135,17087,550],{"class":141},[10,17089,17090,17091,17094,17095,17098,17099,17101,17102,17104,17105,61],{},"Run ",[14,17092,17093],{},"widget remote --registry staging add https:\u002F\u002Fexample.com",". The ",[14,17096,17097],{},"--registry"," option is\ndeclared on the ",[14,17100,16871],{}," callback, so it's scoped to the ",[14,17103,16871],{}," branch and doesn't clutter\nunrelated commands. This mirrors how nested Click groups carry their own options — see\n",[97,17106,9042],{"href":704},[36,17108,17110],{"id":17109},"state-management-best-practices","State management best practices",[41,17112,17113,17125,17134,17145],{},[44,17114,17115,17121,17122,17124],{},[72,17116,17117,17118,17120],{},"Prefer ",[14,17119,13593],{}," over module-level globals."," Globals make commands order-dependent and\nhard to test; ",[14,17123,13593],{}," scopes state to a single invocation.",[44,17126,17127,17133],{},[72,17128,17129,17130],{},"For async code, reach for ",[14,17131,17132],{},"contextvars.ContextVar"," rather than module globals, so\nper-task state stays isolated across concurrent tasks.",[44,17135,17136,17139,17140,1993,17142,17144],{},[72,17137,17138],{},"Keep callbacks lightweight."," Defer blocking I\u002FO (network, large file reads) to the\ncommand body so ",[14,17141,12991],{},[14,17143,12670],{}," stay instant.",[44,17146,17147,17150],{},[72,17148,17149],{},"Validate in parameter callbacks, orchestrate in the app callback."," Mixing the two\nmakes failures harder to attribute.",[36,17152,17154],{"id":17153},"testing-callbacks","Testing callbacks",[10,17156,11778,17157,17160],{},[14,17158,17159],{},"typer.testing.CliRunner"," — the same in-process pattern as Click — to assert that global\nflags, the version flag, and validation all behave:",[126,17162,17164],{"className":316,"code":17163,"language":318,"meta":131,"style":131},"from typer.testing import CliRunner\nfrom widget.main import app\n\nrunner = CliRunner()\n\ndef test_version_flag_exits_zero() -> None:\n    result = runner.invoke(app, [\"--version\"])\n    assert result.exit_code == 0\n    assert \"widget\" in result.output\n\ndef test_verbose_reaches_command() -> None:\n    result = runner.invoke(app, [\"--verbose\", \"create\", \"gadget\"])\n    assert result.exit_code == 0\n    assert \"[verbose]\" in result.output\n",[14,17165,17166,17177,17189,17193,17202,17206,17219,17233,17243,17254,17258,17271,17291,17301],{"__ignoreMap":131},[135,17167,17168,17170,17173,17175],{"class":137,"line":138},[135,17169,334],{"class":325},[135,17171,17172],{"class":141}," typer.testing ",[135,17174,326],{"class":325},[135,17176,3919],{"class":141},[135,17178,17179,17181,17184,17186],{"class":137,"line":152},[135,17180,334],{"class":325},[135,17182,17183],{"class":141}," widget.main ",[135,17185,326],{"class":325},[135,17187,17188],{"class":141}," app\n",[135,17190,17191],{"class":137,"line":162},[135,17192,184],{"emptyLinePlaceholder":183},[135,17194,17195,17198,17200],{"class":137,"line":171},[135,17196,17197],{"class":141},"runner ",[135,17199,516],{"class":325},[135,17201,14700],{"class":141},[135,17203,17204],{"class":137,"line":180},[135,17205,184],{"emptyLinePlaceholder":183},[135,17207,17208,17210,17213,17215,17217],{"class":137,"line":187},[135,17209,493],{"class":325},[135,17211,17212],{"class":145}," test_version_flag_exits_zero",[135,17214,499],{"class":141},[135,17216,3093],{"class":350},[135,17218,360],{"class":141},[135,17220,17221,17223,17225,17228,17231],{"class":137,"line":201},[135,17222,4174],{"class":141},[135,17224,516],{"class":325},[135,17226,17227],{"class":141}," runner.invoke(app, [",[135,17229,17230],{"class":158},"\"--version\"",[135,17232,4243],{"class":141},[135,17234,17235,17237,17239,17241],{"class":137,"line":210},[135,17236,4070],{"class":325},[135,17238,4196],{"class":141},[135,17240,1815],{"class":325},[135,17242,599],{"class":350},[135,17244,17245,17247,17250,17252],{"class":137,"line":215},[135,17246,4070],{"class":325},[135,17248,17249],{"class":158}," \"widget\"",[135,17251,4150],{"class":325},[135,17253,4212],{"class":141},[135,17255,17256],{"class":137,"line":225},[135,17257,184],{"emptyLinePlaceholder":183},[135,17259,17260,17262,17265,17267,17269],{"class":137,"line":236},[135,17261,493],{"class":325},[135,17263,17264],{"class":145}," test_verbose_reaches_command",[135,17266,499],{"class":141},[135,17268,3093],{"class":350},[135,17270,360],{"class":141},[135,17272,17273,17275,17277,17279,17281,17283,17285,17287,17289],{"class":137,"line":606},[135,17274,4174],{"class":141},[135,17276,516],{"class":325},[135,17278,17227],{"class":141},[135,17280,13699],{"class":158},[135,17282,861],{"class":141},[135,17284,14712],{"class":158},[135,17286,861],{"class":141},[135,17288,14717],{"class":158},[135,17290,4243],{"class":141},[135,17292,17293,17295,17297,17299],{"class":137,"line":619},[135,17294,4070],{"class":325},[135,17296,4196],{"class":141},[135,17298,1815],{"class":325},[135,17300,599],{"class":350},[135,17302,17303,17305,17307,17309],{"class":137,"line":1752},[135,17304,4070],{"class":325},[135,17306,14792],{"class":158},[135,17308,4150],{"class":325},[135,17310,4212],{"class":141},[36,17312,1277],{"id":1276},[41,17314,17315,17319,17323],{},[44,17316,2447,17317],{},[97,17318,9038],{"href":9037},[44,17320,15314,17321],{},[97,17322,705],{"href":704},[44,17324,15319,17325],{},[97,17326,1285],{"href":1284},[1303,17328,17329],{},"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 .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}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);}",{"title":131,"searchDepth":152,"depth":152,"links":17331},[17332,17333,17334,17336,17337,17338,17339,17340,17341,17342],{"id":38,"depth":152,"text":39},{"id":16041,"depth":152,"text":16042},{"id":16292,"depth":152,"text":17335},"The eager --version flag",{"id":16524,"depth":152,"text":16525},{"id":16631,"depth":152,"text":16632},{"id":16808,"depth":152,"text":16809},{"id":16852,"depth":152,"text":16853},{"id":17109,"depth":152,"text":17110},{"id":17153,"depth":152,"text":17154},{"id":1276,"depth":152,"text":1277},"Understand Typer callback functions for shared options, version flags, and pre-command hooks in Python CLIs using invoke_without_command patterns.",{},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Ftyper-callback-functions-explained",{"title":9047,"description":17343},"modern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Ftyper-callback-functions-explained\u002Findex",[2483,17349,17350,15346,2481],"callbacks","version-flag","A8RLZT6Xyi1dia-UabgEl5qPJtosW9IqObso3Y2nXMk",{"id":17353,"title":17354,"body":17355,"date":1320,"description":19042,"difficulty":1322,"draft":1323,"extension":1324,"meta":19043,"navigation":183,"path":19044,"seo":19045,"stem":19046,"tags":19047,"updated":1320,"__hash__":19050},"content\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter\u002Findex.md","CLI Project Scaffolding with Cookiecutter",{"type":7,"value":17356,"toc":19028},[17357,17368,17370,17424,17430,17434,17448,17454,17457,17461,17478,17484,17509,17515,17520,17692,17713,17727,17733,17736,18004,18043,18050,18176,18180,18199,18204,18305,18311,18713,18723,18727,18730,18813,18816,18867,18871,18881,18902,18918,18920,18997,18999,19025],[10,17358,17359,17360,433,17362,17364,17365,17367],{},"Every new Python CLI starts with the same forgettable chores: a ",[14,17361,29],{},[14,17363,1210],{}," directory, an entry point wired into ",[14,17366,56],{},", a test folder, a license, and a CI file. Do this by hand once and you have a project; do it ten times across a team and you have ten subtly different projects. Cookiecutter turns that boilerplate into a parameterized template you generate in one command, so every CLI your team ships starts from the same correct skeleton.",[36,17369,39],{"id":38},[41,17371,17372,17389,17396,17407,17417],{},[44,17373,17374,17375,17380,17381,17384,17385,17388],{},"Cookiecutter renders a directory tree from ",[97,17376,17379],{"href":17377,"rel":17378},"https:\u002F\u002Fjinja.palletsprojects.com\u002F",[4654],"Jinja2"," templates. Variables come from a ",[14,17382,17383],{},"cookiecutter.json"," at the template root; the rendered project lives under a ",[14,17386,17387],{},"{{cookiecutter.project_slug}}\u002F"," directory.",[44,17390,17391,17392,17395],{},"Generate a project with ",[14,17393,17394],{},"cookiecutter gh:your-org\u002Fcli-template"," (or a local path). Cookiecutter prompts for each variable, then renders the tree.",[44,17397,17398,17399,17401,17402,1230,17404,17406],{},"Put the real structure in the template: ",[14,17400,1210],{}," layout, a ",[14,17403,29],{},[14,17405,56],{},", tests, and CI.",[44,17408,11778,17409,17412,17413,17416],{},[14,17410,17411],{},"hooks\u002Fpre_gen_project.py"," to validate answers and ",[14,17414,17415],{},"hooks\u002Fpost_gen_project.py"," to delete unused files, init git, or fail fast on bad input.",[44,17418,17419,17420,17423],{},"The win is ",[23,17421,17422],{},"team consistency"," — one reviewed template means every CLI has the same layout, the same entry-point convention, and the same lint config.",[10,17425,17426],{},[104,17427],{"alt":17428,"src":17429},"Cookiecutter renders a template directory with {{cookiecutter.project_slug}}\u002F and templated file names into a fully generated project tree.","\u002Fillustrations\u002Fcookiecutter-template-to-project.svg",[36,17431,17433],{"id":17432},"what-cookiecutter-is-and-when-to-reach-for-it","What Cookiecutter is, and when to reach for it",[10,17435,17436,17437,17440,17441,17444,17445,17447],{},"Cookiecutter (",[14,17438,17439],{},"pip install cookiecutter",", or run it once with ",[14,17442,17443],{},"uvx cookiecutter",") is a project generator. You point it at a template — a local directory, a Git URL, or a zip — and it asks you the questions defined in that template's ",[14,17446,17383],{},", then writes out a fully rendered project with your answers substituted in.",[10,17449,17450,17451,17453],{},"Use it when you create new CLIs often enough that the setup tax is real, or when more than one person needs to produce projects that look the same. A single developer scaffolding one tool a year does not need it. A platform team that owns a dozen internal CLIs — each needing the same logging setup, the same ",[14,17452,12670],{}," flag, the same release workflow — gets compounding value: fix the template once and every future project inherits the fix.",[10,17455,17456],{},"The alternative most people reach for first is \"copy the last project and delete the bits I don't need.\" That works until the copied project drifts, carries stale dependencies, or leaks a hardcoded package name into three files you forgot to rename. A template makes the rename a variable.",[36,17458,17460],{"id":17459},"template-directory-layout","Template directory layout",[10,17462,17463,17464,17467,17468,17470,17471,17474,17475,17477],{},"A Cookiecutter template is itself a directory. The one rule that matters: the project being generated lives inside a directory whose name is a Jinja2 expression, conventionally ",[14,17465,17466],{},"{{cookiecutter.project_slug}}",". Everything outside that directory (the ",[14,17469,17383],{},", the ",[14,17472,17473],{},"hooks\u002F",") is template machinery and is ",[23,17476,8462],{}," copied into the output.",[126,17479,17482],{"className":17480,"code":17481,"language":5596,"meta":131},[5594],"cli-template\u002F\n├── cookiecutter.json\n├── hooks\u002F\n│   ├── pre_gen_project.py\n│   └── post_gen_project.py\n└── {{cookiecutter.project_slug}}\u002F\n    ├── pyproject.toml\n    ├── README.md\n    ├── .pre-commit-config.yaml\n    ├── src\u002F\n    │   └── {{cookiecutter.package_name}}\u002F\n    │       ├── __init__.py\n    │       ├── __main__.py\n    │       └── cli.py\n    └── tests\u002F\n        └── test_cli.py\n",[14,17483,17481],{"__ignoreMap":131},[10,17485,17486,17487,17490,17491,17493,17494,17497,17498,17501,17502,17504,17505,17508],{},"Notice the nested ",[14,17488,17489],{},"{{cookiecutter.package_name}}\u002F"," directory under ",[14,17492,1210],{},". Cookiecutter templates both file ",[23,17495,17496],{},"contents"," and file\u002Fdirectory ",[23,17499,17500],{},"names",", so the import package gets named correctly without any post-processing. This is what makes the ",[14,17503,1210],{}," layout work cleanly: the rendered tree is ",[14,17506,17507],{},"src\u002Fyour_package\u002F"," with a name derived from the answers, not a fixed string you have to find-and-replace.",[36,17510,17512,17513],{"id":17511},"a-realistic-cookiecutterjson","A realistic ",[14,17514,17383],{},[10,17516,111,17517,17519],{},[14,17518,17383],{}," defines variables and their defaults. Order matters — earlier answers can feed later defaults through Jinja2 expressions. List values become a \"choose one\" menu. The leading-underscore keys are reserved settings, not prompts.",[126,17521,17524],{"className":17522,"code":17523,"language":4623,"meta":131,"style":131},"language-json shiki shiki-themes github-light github-dark","{\n  \"project_name\": \"My CLI Tool\",\n  \"project_slug\": \"{{ cookiecutter.project_name.lower().replace(' ', '-') }}\",\n  \"package_name\": \"{{ cookiecutter.project_slug.replace('-', '_') }}\",\n  \"command_name\": \"{{ cookiecutter.project_slug }}\",\n  \"author_name\": \"Your Name\",\n  \"author_email\": \"you@example.com\",\n  \"python_version\": \"3.12\",\n  \"cli_framework\": [\"typer\", \"click\", \"argparse\"],\n  \"license\": [\"MIT\", \"Apache-2.0\", \"Proprietary\"],\n  \"use_precommit\": [\"yes\", \"no\"],\n  \"_copy_without_render\": [\".github\u002Fworkflows\u002F*.yml\"]\n}\n",[14,17525,17526,17530,17542,17554,17566,17578,17590,17602,17614,17637,17659,17676,17688],{"__ignoreMap":131},[135,17527,17528],{"class":137,"line":138},[135,17529,12944],{"class":141},[135,17531,17532,17535,17537,17540],{"class":137,"line":152},[135,17533,17534],{"class":350},"  \"project_name\"",[135,17536,2344],{"class":141},[135,17538,17539],{"class":158},"\"My CLI Tool\"",[135,17541,3238],{"class":141},[135,17543,17544,17547,17549,17552],{"class":137,"line":162},[135,17545,17546],{"class":350},"  \"project_slug\"",[135,17548,2344],{"class":141},[135,17550,17551],{"class":158},"\"{{ cookiecutter.project_name.lower().replace(' ', '-') }}\"",[135,17553,3238],{"class":141},[135,17555,17556,17559,17561,17564],{"class":137,"line":171},[135,17557,17558],{"class":350},"  \"package_name\"",[135,17560,2344],{"class":141},[135,17562,17563],{"class":158},"\"{{ cookiecutter.project_slug.replace('-', '_') }}\"",[135,17565,3238],{"class":141},[135,17567,17568,17571,17573,17576],{"class":137,"line":180},[135,17569,17570],{"class":350},"  \"command_name\"",[135,17572,2344],{"class":141},[135,17574,17575],{"class":158},"\"{{ cookiecutter.project_slug }}\"",[135,17577,3238],{"class":141},[135,17579,17580,17583,17585,17588],{"class":137,"line":187},[135,17581,17582],{"class":350},"  \"author_name\"",[135,17584,2344],{"class":141},[135,17586,17587],{"class":158},"\"Your Name\"",[135,17589,3238],{"class":141},[135,17591,17592,17595,17597,17600],{"class":137,"line":201},[135,17593,17594],{"class":350},"  \"author_email\"",[135,17596,2344],{"class":141},[135,17598,17599],{"class":158},"\"you@example.com\"",[135,17601,3238],{"class":141},[135,17603,17604,17607,17609,17612],{"class":137,"line":210},[135,17605,17606],{"class":350},"  \"python_version\"",[135,17608,2344],{"class":141},[135,17610,17611],{"class":158},"\"3.12\"",[135,17613,3238],{"class":141},[135,17615,17616,17619,17622,17625,17627,17630,17632,17635],{"class":137,"line":215},[135,17617,17618],{"class":350},"  \"cli_framework\"",[135,17620,17621],{"class":141},": [",[135,17623,17624],{"class":158},"\"typer\"",[135,17626,861],{"class":141},[135,17628,17629],{"class":158},"\"click\"",[135,17631,861],{"class":141},[135,17633,17634],{"class":158},"\"argparse\"",[135,17636,16437],{"class":141},[135,17638,17639,17642,17644,17647,17649,17652,17654,17657],{"class":137,"line":225},[135,17640,17641],{"class":350},"  \"license\"",[135,17643,17621],{"class":141},[135,17645,17646],{"class":158},"\"MIT\"",[135,17648,861],{"class":141},[135,17650,17651],{"class":158},"\"Apache-2.0\"",[135,17653,861],{"class":141},[135,17655,17656],{"class":158},"\"Proprietary\"",[135,17658,16437],{"class":141},[135,17660,17661,17664,17666,17669,17671,17674],{"class":137,"line":236},[135,17662,17663],{"class":350},"  \"use_precommit\"",[135,17665,17621],{"class":141},[135,17667,17668],{"class":158},"\"yes\"",[135,17670,861],{"class":141},[135,17672,17673],{"class":158},"\"no\"",[135,17675,16437],{"class":141},[135,17677,17678,17681,17683,17686],{"class":137,"line":606},[135,17679,17680],{"class":350},"  \"_copy_without_render\"",[135,17682,17621],{"class":141},[135,17684,17685],{"class":158},"\".github\u002Fworkflows\u002F*.yml\"",[135,17687,149],{"class":141},[135,17689,17690],{"class":137,"line":619},[135,17691,4051],{"class":141},[10,17693,17694,17695,17698,17699,1972,17701,17704,17705,17708,17709,17712],{},"Two derivations carry the load. ",[14,17696,17697],{},"project_slug"," turns ",[14,17700,17539],{},[14,17702,17703],{},"my-cli-tool"," for the directory and the distribution name, and ",[14,17706,17707],{},"package_name"," turns that into ",[14,17710,17711],{},"my_cli_tool"," — a valid Python identifier for the import package. Getting these right in the template means the rendered project's distribution name and import name follow the standard convention (dashes in the dist name, underscores in the module) without the author thinking about it.",[10,17714,17715,17718,17719,17722,17723,17726],{},[14,17716,17717],{},"_copy_without_render"," is a safety hatch: GitHub Actions files use ",[14,17720,17721],{},"${{ ... }}"," syntax that collides with Jinja2's ",[14,17724,17725],{},"{{ ... }}",", so you tell Cookiecutter to copy those paths verbatim instead of trying to render them.",[36,17728,17730,17731],{"id":17729},"the-generated-pyprojecttoml","The generated ",[14,17732,29],{},[10,17734,17735],{},"This is the heart of the template — the file that makes the output a real, installable CLI. The Jinja2 expressions are resolved at generation time against the answers above.",[126,17737,17739],{"className":128,"code":17738,"language":130,"meta":131,"style":131},"[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"{{ cookiecutter.project_slug }}\"\nversion = \"0.1.0\"\ndescription = \"{{ cookiecutter.project_name }} — a command-line tool.\"\nauthors = [{ name = \"{{ cookiecutter.author_name }}\", email = \"{{ cookiecutter.author_email }}\" }]\nrequires-python = \">={{ cookiecutter.python_version }}\"\nreadme = \"README.md\"\nlicense = { text = \"{{ cookiecutter.license }}\" }\ndependencies = [\n{%- if cookiecutter.cli_framework == \"typer\" %}\n  \"typer>=0.12\",\n{%- elif cookiecutter.cli_framework == \"click\" %}\n  \"click>=8.1\",\n{%- endif %}\n]\n\n[project.scripts]\n{{ cookiecutter.command_name }} = \"{{ cookiecutter.package_name }}.cli:app\"\n\n[project.optional-dependencies]\ndev = [\"pytest>=8.0\", \"ruff>=0.5\"]\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src\u002F{{ cookiecutter.package_name }}\"]\n\n[tool.ruff]\ntarget-version = \"py{{ cookiecutter.python_version.replace('.', '') }}\"\nsrc = [\"src\", \"tests\"]\n",[14,17740,17741,17749,17757,17763,17767,17775,17782,17788,17796,17813,17820,17828,17839,17844,17849,17855,17860,17865,17870,17874,17878,17890,17895,17899,17912,17927,17931,17955,17964,17968,17981,17989],{"__ignoreMap":131},[135,17742,17743,17745,17747],{"class":137,"line":138},[135,17744,142],{"class":141},[135,17746,220],{"class":145},[135,17748,149],{"class":141},[135,17750,17751,17753,17755],{"class":137,"line":152},[135,17752,228],{"class":141},[135,17754,231],{"class":158},[135,17756,149],{"class":141},[135,17758,17759,17761],{"class":137,"line":162},[135,17760,239],{"class":141},[135,17762,242],{"class":158},[135,17764,17765],{"class":137,"line":171},[135,17766,184],{"emptyLinePlaceholder":183},[135,17768,17769,17771,17773],{"class":137,"line":180},[135,17770,142],{"class":141},[135,17772,146],{"class":145},[135,17774,149],{"class":141},[135,17776,17777,17779],{"class":137,"line":187},[135,17778,155],{"class":141},[135,17780,17781],{"class":158},"\"{{ cookiecutter.project_slug }}\"\n",[135,17783,17784,17786],{"class":137,"line":201},[135,17785,165],{"class":141},[135,17787,11903],{"class":158},[135,17789,17790,17793],{"class":137,"line":210},[135,17791,17792],{"class":141},"description = ",[135,17794,17795],{"class":158},"\"{{ cookiecutter.project_name }} — a command-line tool.\"\n",[135,17797,17798,17801,17804,17807,17810],{"class":137,"line":215},[135,17799,17800],{"class":141},"authors = [{ name = ",[135,17802,17803],{"class":158},"\"{{ cookiecutter.author_name }}\"",[135,17805,17806],{"class":141},", email = ",[135,17808,17809],{"class":158},"\"{{ cookiecutter.author_email }}\"",[135,17811,17812],{"class":141}," }]\n",[135,17814,17815,17817],{"class":137,"line":225},[135,17816,174],{"class":141},[135,17818,17819],{"class":158},"\">={{ cookiecutter.python_version }}\"\n",[135,17821,17822,17825],{"class":137,"line":236},[135,17823,17824],{"class":141},"readme = ",[135,17826,17827],{"class":158},"\"README.md\"\n",[135,17829,17830,17833,17836],{"class":137,"line":606},[135,17831,17832],{"class":141},"license = { text = ",[135,17834,17835],{"class":158},"\"{{ cookiecutter.license }}\"",[135,17837,17838],{"class":141}," }\n",[135,17840,17841],{"class":137,"line":619},[135,17842,17843],{"class":141},"dependencies = [\n",[135,17845,17846],{"class":137,"line":1752},[135,17847,17848],{"class":141},"{%- if cookiecutter.cli_framework == \"typer\" %}\n",[135,17850,17851],{"class":137,"line":1765},[135,17852,17854],{"class":17853},"s7hpK","  \"typer>=0.12\",\n",[135,17856,17857],{"class":137,"line":1774},[135,17858,17859],{"class":17853},"{%- elif cookiecutter.cli_framework == \"click\" %}\n",[135,17861,17862],{"class":137,"line":1795},[135,17863,17864],{"class":17853},"  \"click>=8.1\",\n",[135,17866,17867],{"class":137,"line":1830},[135,17868,17869],{"class":17853},"{%- endif %}\n",[135,17871,17872],{"class":137,"line":1846},[135,17873,149],{"class":141},[135,17875,17876],{"class":137,"line":1854},[135,17877,184],{"emptyLinePlaceholder":183},[135,17879,17880,17882,17884,17886,17888],{"class":137,"line":1859},[135,17881,142],{"class":141},[135,17883,146],{"class":145},[135,17885,61],{"class":141},[135,17887,196],{"class":145},[135,17889,149],{"class":141},[135,17891,17892],{"class":137,"line":1877},[135,17893,17894],{"class":17853},"{{ cookiecutter.command_name }} = \"{{ cookiecutter.package_name }}.cli:app\"\n",[135,17896,17897],{"class":137,"line":1893},[135,17898,184],{"emptyLinePlaceholder":183},[135,17900,17901,17903,17905,17907,17910],{"class":137,"line":1926},[135,17902,142],{"class":141},[135,17904,146],{"class":145},[135,17906,61],{"class":141},[135,17908,17909],{"class":145},"optional-dependencies",[135,17911,149],{"class":141},[135,17913,17914,17917,17920,17922,17925],{"class":137,"line":1940},[135,17915,17916],{"class":141},"dev = [",[135,17918,17919],{"class":158},"\"pytest>=8.0\"",[135,17921,861],{"class":141},[135,17923,17924],{"class":158},"\"ruff>=0.5\"",[135,17926,149],{"class":141},[135,17928,17929],{"class":137,"line":2906},[135,17930,184],{"emptyLinePlaceholder":183},[135,17932,17933,17935,17937,17939,17941,17943,17945,17947,17949,17951,17953],{"class":137,"line":2931},[135,17934,142],{"class":141},[135,17936,11953],{"class":145},[135,17938,61],{"class":141},[135,17940,11958],{"class":145},[135,17942,61],{"class":141},[135,17944,11963],{"class":145},[135,17946,61],{"class":141},[135,17948,11968],{"class":145},[135,17950,61],{"class":141},[135,17952,11973],{"class":145},[135,17954,149],{"class":141},[135,17956,17957,17959,17962],{"class":137,"line":2944},[135,17958,11980],{"class":141},[135,17960,17961],{"class":158},"\"src\u002F{{ cookiecutter.package_name }}\"",[135,17963,149],{"class":141},[135,17965,17966],{"class":137,"line":3266},[135,17967,184],{"emptyLinePlaceholder":183},[135,17969,17970,17972,17974,17976,17979],{"class":137,"line":3277},[135,17971,142],{"class":141},[135,17973,11953],{"class":145},[135,17975,61],{"class":141},[135,17977,17978],{"class":145},"ruff",[135,17980,149],{"class":141},[135,17982,17983,17986],{"class":137,"line":3290},[135,17984,17985],{"class":141},"target-version = ",[135,17987,17988],{"class":158},"\"py{{ cookiecutter.python_version.replace('.', '') }}\"\n",[135,17990,17991,17994,17997,17999,18002],{"class":137,"line":3295},[135,17992,17993],{"class":141},"src = [",[135,17995,17996],{"class":158},"\"src\"",[135,17998,861],{"class":141},[135,18000,18001],{"class":158},"\"tests\"",[135,18003,149],{"class":141},[10,18005,111,18006,18008,18009,18012,18013,18015,18016,18019,18020,18023,18024,18027,18028,18031,18032,18034,18035,18037,18038,18040,18041,61],{},[14,18007,56],{}," line is what installs the ",[14,18010,18011],{},"mytool"," command onto the user's ",[14,18014,33],{},". It points at ",[14,18017,18018],{},"package.cli:app"," — the framework's callable in the rendered module. The ",[14,18021,18022],{},"{%- if %}"," block adds the framework dependency that matches the chosen ",[14,18025,18026],{},"cli_framework",", and only that one. The ",[14,18029,18030],{},"[tool.hatch.build.targets.wheel]"," section tells the build backend that the package lives under ",[14,18033,1210],{},", which is required for the ",[14,18036,1210],{}," layout to package correctly. For the deeper rationale on choosing ",[14,18039,447],{}," versus a function and registering it as a console script, see ",[97,18042,9071],{"href":9070},[10,18044,18045,18046,18049],{},"The rendered ",[14,18047,18048],{},"src\u002F{{cookiecutter.package_name}}\u002Fcli.py"," is the matching minimal entry point:",[126,18051,18053],{"className":316,"code":18052,"language":318,"meta":131,"style":131},"import typer\n\napp = typer.Typer(help=\"{{ cookiecutter.project_name }}\")\n\n\n@app.command()\ndef hello(name: str = \"world\") -> None:\n    \"\"\"Print a greeting.\"\"\"\n    typer.echo(f\"Hello, {name}!\")\n\n\nif __name__ == \"__main__\":\n    app()\n",[14,18054,18055,18061,18065,18092,18096,18100,18106,18127,18132,18152,18156,18160,18172],{"__ignoreMap":131},[135,18056,18057,18059],{"class":137,"line":138},[135,18058,326],{"class":325},[135,18060,2056],{"class":141},[135,18062,18063],{"class":137,"line":152},[135,18064,184],{"emptyLinePlaceholder":183},[135,18066,18067,18069,18071,18073,18075,18077,18079,18082,18085,18088,18090],{"class":137,"line":162},[135,18068,16085],{"class":141},[135,18070,516],{"class":325},[135,18072,16559],{"class":141},[135,18074,13713],{"class":914},[135,18076,516],{"class":325},[135,18078,589],{"class":158},[135,18080,18081],{"class":350},"{{",[135,18083,18084],{"class":158}," cookiecutter.project_name ",[135,18086,18087],{"class":350},"}}",[135,18089,589],{"class":158},[135,18091,550],{"class":141},[135,18093,18094],{"class":137,"line":171},[135,18095,184],{"emptyLinePlaceholder":183},[135,18097,18098],{"class":137,"line":180},[135,18099,184],{"emptyLinePlaceholder":183},[135,18101,18102,18104],{"class":137,"line":187},[135,18103,2132],{"class":145},[135,18105,2135],{"class":141},[135,18107,18108,18110,18112,18115,18117,18119,18121,18123,18125],{"class":137,"line":201},[135,18109,493],{"class":325},[135,18111,15472],{"class":145},[135,18113,18114],{"class":141},"(name: ",[135,18116,1663],{"class":350},[135,18118,2150],{"class":325},[135,18120,9871],{"class":158},[135,18122,1788],{"class":141},[135,18124,3093],{"class":350},[135,18126,360],{"class":141},[135,18128,18129],{"class":137,"line":210},[135,18130,18131],{"class":158},"    \"\"\"Print a greeting.\"\"\"\n",[135,18133,18134,18136,18138,18141,18143,18145,18147,18150],{"class":137,"line":215},[135,18135,16233],{"class":141},[135,18137,568],{"class":325},[135,18139,18140],{"class":158},"\"Hello, ",[135,18142,574],{"class":350},[135,18144,9681],{"class":141},[135,18146,586],{"class":350},[135,18148,18149],{"class":158},"!\"",[135,18151,550],{"class":141},[135,18153,18154],{"class":137,"line":225},[135,18155,184],{"emptyLinePlaceholder":183},[135,18157,18158],{"class":137,"line":236},[135,18159,184],{"emptyLinePlaceholder":183},[135,18161,18162,18164,18166,18168,18170],{"class":137,"line":606},[135,18163,347],{"class":325},[135,18165,351],{"class":350},[135,18167,354],{"class":325},[135,18169,357],{"class":158},[135,18171,360],{"class":141},[135,18173,18174],{"class":137,"line":619},[135,18175,16270],{"class":141},[36,18177,18179],{"id":18178},"pre-and-post-generation-hooks","Pre- and post-generation hooks",[10,18181,18182,18183,17094,18185,18188,18189,18191,18192,18188,18195,18198],{},"Hooks are ordinary Python scripts in ",[14,18184,17473],{},[14,18186,18187],{},"pre_gen_project.py"," runs ",[23,18190,1475],{}," rendering (in a temp context) and is the place to reject invalid answers; ",[14,18193,18194],{},"post_gen_project.py",[23,18196,18197],{},"after"," rendering, with the current working directory set to the root of the freshly generated project. The post hook is where you delete files the chosen options don't need, initialize git, or print next-steps.",[10,18200,13401,18201,18203],{},[14,18202,18187],{}," validation guard — exit non-zero and Cookiecutter aborts the whole generation, leaving nothing behind:",[126,18205,18207],{"className":316,"code":18206,"language":318,"meta":131,"style":131},"import re\nimport sys\n\nPACKAGE_NAME = \"{{ cookiecutter.package_name }}\"\n\nif not re.match(r\"^[a-z][a-z0-9_]+$\", PACKAGE_NAME):\n    print(f\"ERROR: '{PACKAGE_NAME}' is not a valid Python package name.\")\n    sys.exit(1)\n",[14,18208,18209,18216,18222,18226,18244,18248,18277,18296],{"__ignoreMap":131},[135,18210,18211,18213],{"class":137,"line":138},[135,18212,326],{"class":325},[135,18214,18215],{"class":141}," re\n",[135,18217,18218,18220],{"class":137,"line":152},[135,18219,326],{"class":325},[135,18221,329],{"class":141},[135,18223,18224],{"class":137,"line":162},[135,18225,184],{"emptyLinePlaceholder":183},[135,18227,18228,18231,18233,18235,18237,18240,18242],{"class":137,"line":171},[135,18229,18230],{"class":350},"PACKAGE_NAME",[135,18232,2150],{"class":325},[135,18234,2378],{"class":158},[135,18236,18081],{"class":350},[135,18238,18239],{"class":158}," cookiecutter.package_name ",[135,18241,18087],{"class":350},[135,18243,5968],{"class":158},[135,18245,18246],{"class":137,"line":180},[135,18247,184],{"emptyLinePlaceholder":183},[135,18249,18250,18252,18254,18257,18260,18262,18265,18267,18269,18271,18273,18275],{"class":137,"line":187},[135,18251,347],{"class":325},[135,18253,533],{"class":325},[135,18255,18256],{"class":141}," re.match(",[135,18258,18259],{"class":325},"r",[135,18261,589],{"class":158},[135,18263,18264],{"class":350},"^[a-z][a-z0-9_]",[135,18266,9999],{"class":325},[135,18268,643],{"class":350},[135,18270,589],{"class":158},[135,18272,861],{"class":141},[135,18274,18230],{"class":350},[135,18276,986],{"class":141},[135,18278,18279,18281,18283,18285,18288,18291,18294],{"class":137,"line":201},[135,18280,563],{"class":350},[135,18282,544],{"class":141},[135,18284,568],{"class":325},[135,18286,18287],{"class":158},"\"ERROR: '",[135,18289,18290],{"class":350},"{PACKAGE_NAME}",[135,18292,18293],{"class":158},"' is not a valid Python package name.\"",[135,18295,550],{"class":141},[135,18297,18298,18301,18303],{"class":137,"line":210},[135,18299,18300],{"class":141},"    sys.exit(",[135,18302,522],{"class":350},[135,18304,550],{"class":141},[10,18306,18307,18308,18310],{},"The post hook does the cleanup and git init. This is real, runnable Python — Cookiecutter substitutes the ",[14,18309,17725],{}," literal before executing it, so the conditional reads a plain string at runtime:",[126,18312,18314],{"className":316,"code":18313,"language":318,"meta":131,"style":131},"\"\"\"Post-generation hook: runs from the root of the freshly rendered project.\"\"\"\n\nfrom __future__ import annotations\n\nimport shutil\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nPROJECT_ROOT = Path.cwd()\n\n# Cookiecutter renders this literal into the conditional below at generation time.\nUSE_PRECOMMIT = \"{{ cookiecutter.use_precommit }}\" == \"yes\"\n\n\ndef remove_unused_paths() -> None:\n    \"\"\"Delete optional files the chosen options don't need.\"\"\"\n    if not USE_PRECOMMIT:\n        config = PROJECT_ROOT \u002F \".pre-commit-config.yaml\"\n        config.unlink(missing_ok=True)\n\n\ndef init_git_repo() -> None:\n    \"\"\"Initialize a git repo if git is available; never fail the generation.\"\"\"\n    if shutil.which(\"git\") is None:\n        print(\"git not found on PATH — skipping repo init\", file=sys.stderr)\n        return\n    try:\n        subprocess.run([\"git\", \"init\", \"--quiet\"], cwd=PROJECT_ROOT, check=True)\n    except subprocess.CalledProcessError as exc:\n        print(f\"git init failed ({exc.returncode}) — continuing\", file=sys.stderr)\n\n\ndef main() -> int:\n    remove_unused_paths()\n    init_git_repo()\n    print(f\"Scaffolded project in {PROJECT_ROOT}\")\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n",[14,18315,18316,18321,18325,18335,18339,18346,18353,18359,18369,18373,18383,18387,18392,18415,18419,18423,18436,18441,18452,18467,18481,18485,18489,18502,18507,18525,18543,18548,18554,18591,18602,18631,18635,18639,18651,18656,18661,18679,18685,18689,18693,18705],{"__ignoreMap":131},[135,18317,18318],{"class":137,"line":138},[135,18319,18320],{"class":158},"\"\"\"Post-generation hook: runs from the root of the freshly rendered project.\"\"\"\n",[135,18322,18323],{"class":137,"line":152},[135,18324,184],{"emptyLinePlaceholder":183},[135,18326,18327,18329,18331,18333],{"class":137,"line":162},[135,18328,334],{"class":325},[135,18330,2586],{"class":350},[135,18332,2589],{"class":325},[135,18334,2592],{"class":141},[135,18336,18337],{"class":137,"line":171},[135,18338,184],{"emptyLinePlaceholder":183},[135,18340,18341,18343],{"class":137,"line":180},[135,18342,326],{"class":325},[135,18344,18345],{"class":141}," shutil\n",[135,18347,18348,18350],{"class":137,"line":187},[135,18349,326],{"class":325},[135,18351,18352],{"class":141}," subprocess\n",[135,18354,18355,18357],{"class":137,"line":201},[135,18356,326],{"class":325},[135,18358,329],{"class":141},[135,18360,18361,18363,18365,18367],{"class":137,"line":210},[135,18362,334],{"class":325},[135,18364,4835],{"class":141},[135,18366,326],{"class":325},[135,18368,4840],{"class":141},[135,18370,18371],{"class":137,"line":215},[135,18372,184],{"emptyLinePlaceholder":183},[135,18374,18375,18378,18380],{"class":137,"line":225},[135,18376,18377],{"class":350},"PROJECT_ROOT",[135,18379,2150],{"class":325},[135,18381,18382],{"class":141}," Path.cwd()\n",[135,18384,18385],{"class":137,"line":236},[135,18386,184],{"emptyLinePlaceholder":183},[135,18388,18389],{"class":137,"line":606},[135,18390,18391],{"class":669},"# Cookiecutter renders this literal into the conditional below at generation time.\n",[135,18393,18394,18397,18399,18401,18403,18406,18408,18410,18412],{"class":137,"line":619},[135,18395,18396],{"class":350},"USE_PRECOMMIT",[135,18398,2150],{"class":325},[135,18400,2378],{"class":158},[135,18402,18081],{"class":350},[135,18404,18405],{"class":158}," cookiecutter.use_precommit ",[135,18407,18087],{"class":350},[135,18409,589],{"class":158},[135,18411,354],{"class":325},[135,18413,18414],{"class":158}," \"yes\"\n",[135,18416,18417],{"class":137,"line":1752},[135,18418,184],{"emptyLinePlaceholder":183},[135,18420,18421],{"class":137,"line":1765},[135,18422,184],{"emptyLinePlaceholder":183},[135,18424,18425,18427,18430,18432,18434],{"class":137,"line":1774},[135,18426,493],{"class":325},[135,18428,18429],{"class":145}," remove_unused_paths",[135,18431,499],{"class":141},[135,18433,3093],{"class":350},[135,18435,360],{"class":141},[135,18437,18438],{"class":137,"line":1795},[135,18439,18440],{"class":158},"    \"\"\"Delete optional files the chosen options don't need.\"\"\"\n",[135,18442,18443,18445,18447,18450],{"class":137,"line":1830},[135,18444,530],{"class":325},[135,18446,533],{"class":325},[135,18448,18449],{"class":350}," USE_PRECOMMIT",[135,18451,360],{"class":141},[135,18453,18454,18457,18459,18462,18464],{"class":137,"line":1846},[135,18455,18456],{"class":141},"        config ",[135,18458,516],{"class":325},[135,18460,18461],{"class":350}," PROJECT_ROOT",[135,18463,4919],{"class":325},[135,18465,18466],{"class":158}," \".pre-commit-config.yaml\"\n",[135,18468,18469,18472,18475,18477,18479],{"class":137,"line":1854},[135,18470,18471],{"class":141},"        config.unlink(",[135,18473,18474],{"class":914},"missing_ok",[135,18476,516],{"class":325},[135,18478,3651],{"class":350},[135,18480,550],{"class":141},[135,18482,18483],{"class":137,"line":1859},[135,18484,184],{"emptyLinePlaceholder":183},[135,18486,18487],{"class":137,"line":1877},[135,18488,184],{"emptyLinePlaceholder":183},[135,18490,18491,18493,18496,18498,18500],{"class":137,"line":1893},[135,18492,493],{"class":325},[135,18494,18495],{"class":145}," init_git_repo",[135,18497,499],{"class":141},[135,18499,3093],{"class":350},[135,18501,360],{"class":141},[135,18503,18504],{"class":137,"line":1926},[135,18505,18506],{"class":158},"    \"\"\"Initialize a git repo if git is available; never fail the generation.\"\"\"\n",[135,18508,18509,18511,18514,18517,18519,18521,18523],{"class":137,"line":1940},[135,18510,530],{"class":325},[135,18512,18513],{"class":141}," shutil.which(",[135,18515,18516],{"class":158},"\"git\"",[135,18518,3398],{"class":141},[135,18520,5297],{"class":325},[135,18522,4942],{"class":350},[135,18524,360],{"class":141},[135,18526,18527,18529,18531,18534,18536,18538,18540],{"class":137,"line":2906},[135,18528,541],{"class":350},[135,18530,544],{"class":141},[135,18532,18533],{"class":158},"\"git not found on PATH — skipping repo init\"",[135,18535,861],{"class":141},[135,18537,8163],{"class":914},[135,18539,516],{"class":325},[135,18541,18542],{"class":141},"sys.stderr)\n",[135,18544,18545],{"class":137,"line":2931},[135,18546,18547],{"class":325},"        return\n",[135,18549,18550,18552],{"class":137,"line":2944},[135,18551,5514],{"class":325},[135,18553,360],{"class":141},[135,18555,18556,18559,18561,18563,18566,18568,18571,18573,18576,18578,18580,18582,18585,18587,18589],{"class":137,"line":3266},[135,18557,18558],{"class":141},"        subprocess.run([",[135,18560,18516],{"class":158},[135,18562,861],{"class":141},[135,18564,18565],{"class":158},"\"init\"",[135,18567,861],{"class":141},[135,18569,18570],{"class":158},"\"--quiet\"",[135,18572,4465],{"class":141},[135,18574,18575],{"class":914},"cwd",[135,18577,516],{"class":325},[135,18579,18377],{"class":350},[135,18581,861],{"class":141},[135,18583,18584],{"class":914},"check",[135,18586,516],{"class":325},[135,18588,3651],{"class":350},[135,18590,550],{"class":141},[135,18592,18593,18595,18598,18600],{"class":137,"line":3277},[135,18594,5531],{"class":325},[135,18596,18597],{"class":141}," subprocess.CalledProcessError ",[135,18599,2419],{"class":325},[135,18601,2422],{"class":141},[135,18603,18604,18606,18608,18610,18613,18615,18618,18620,18623,18625,18627,18629],{"class":137,"line":3290},[135,18605,541],{"class":350},[135,18607,544],{"class":141},[135,18609,568],{"class":325},[135,18611,18612],{"class":158},"\"git init failed (",[135,18614,574],{"class":350},[135,18616,18617],{"class":141},"exc.returncode",[135,18619,586],{"class":350},[135,18621,18622],{"class":158},") — continuing\"",[135,18624,861],{"class":141},[135,18626,8163],{"class":914},[135,18628,516],{"class":325},[135,18630,18542],{"class":141},[135,18632,18633],{"class":137,"line":3295},[135,18634,184],{"emptyLinePlaceholder":183},[135,18636,18637],{"class":137,"line":3315},[135,18638,184],{"emptyLinePlaceholder":183},[135,18640,18641,18643,18645,18647,18649],{"class":137,"line":3329},[135,18642,493],{"class":325},[135,18644,496],{"class":145},[135,18646,499],{"class":141},[135,18648,387],{"class":350},[135,18650,360],{"class":141},[135,18652,18653],{"class":137,"line":3337},[135,18654,18655],{"class":141},"    remove_unused_paths()\n",[135,18657,18658],{"class":137,"line":3350},[135,18659,18660],{"class":141},"    init_git_repo()\n",[135,18662,18663,18665,18667,18669,18672,18675,18677],{"class":137,"line":3365},[135,18664,563],{"class":350},[135,18666,544],{"class":141},[135,18668,568],{"class":325},[135,18670,18671],{"class":158},"\"Scaffolded project in ",[135,18673,18674],{"class":350},"{PROJECT_ROOT}",[135,18676,589],{"class":158},[135,18678,550],{"class":141},[135,18680,18681,18683],{"class":137,"line":3373},[135,18682,596],{"class":325},[135,18684,599],{"class":350},[135,18686,18687],{"class":137,"line":3406},[135,18688,184],{"emptyLinePlaceholder":183},[135,18690,18691],{"class":137,"line":3415},[135,18692,184],{"emptyLinePlaceholder":183},[135,18694,18695,18697,18699,18701,18703],{"class":137,"line":3429},[135,18696,347],{"class":325},[135,18698,351],{"class":350},[135,18700,354],{"class":325},[135,18702,357],{"class":158},[135,18704,360],{"class":141},[135,18706,18707,18709,18711],{"class":137,"line":3466},[135,18708,622],{"class":325},[135,18710,625],{"class":350},[135,18712,628],{"class":141},[10,18714,18715,18716,1230,18719,18722],{},"The pattern worth copying: cleanup is driven by ",[14,18717,18718],{},"pathlib",[14,18720,18721],{},"missing_ok=True"," so a second run never crashes, and anything that touches the outside world (git) degrades gracefully instead of aborting a generation that already succeeded. A post hook that raises on a missing optional file is the most common way to leave a half-generated project on disk.",[36,18724,18726],{"id":18725},"generating-a-project","Generating a project",[10,18728,18729],{},"With the template published — or sitting in a local directory — generation is one command:",[126,18731,18733],{"className":634,"code":18732,"language":636,"meta":131,"style":131},"# From a Git host (GitHub shortcut):\ncookiecutter gh:your-org\u002Fcli-template\n\n# From a local checkout:\ncookiecutter .\u002Fcli-template\n\n# Run without installing it globally:\nuvx cookiecutter gh:your-org\u002Fcli-template\n\n# Skip the prompts in CI, overriding only what you need:\ncookiecutter gh:your-org\u002Fcli-template --no-input \\\n  project_name=\"Deploy Bot\" cli_framework=click\n",[14,18734,18735,18740,18748,18752,18757,18764,18768,18773,18783,18787,18792,18805],{"__ignoreMap":131},[135,18736,18737],{"class":137,"line":138},[135,18738,18739],{"class":669},"# From a Git host (GitHub shortcut):\n",[135,18741,18742,18745],{"class":137,"line":152},[135,18743,18744],{"class":145},"cookiecutter",[135,18746,18747],{"class":158}," gh:your-org\u002Fcli-template\n",[135,18749,18750],{"class":137,"line":162},[135,18751,184],{"emptyLinePlaceholder":183},[135,18753,18754],{"class":137,"line":171},[135,18755,18756],{"class":669},"# From a local checkout:\n",[135,18758,18759,18761],{"class":137,"line":180},[135,18760,18744],{"class":145},[135,18762,18763],{"class":158}," .\u002Fcli-template\n",[135,18765,18766],{"class":137,"line":187},[135,18767,184],{"emptyLinePlaceholder":183},[135,18769,18770],{"class":137,"line":201},[135,18771,18772],{"class":669},"# Run without installing it globally:\n",[135,18774,18775,18778,18781],{"class":137,"line":210},[135,18776,18777],{"class":145},"uvx",[135,18779,18780],{"class":158}," cookiecutter",[135,18782,18747],{"class":158},[135,18784,18785],{"class":137,"line":215},[135,18786,184],{"emptyLinePlaceholder":183},[135,18788,18789],{"class":137,"line":225},[135,18790,18791],{"class":669},"# Skip the prompts in CI, overriding only what you need:\n",[135,18793,18794,18796,18799,18802],{"class":137,"line":236},[135,18795,18744],{"class":145},[135,18797,18798],{"class":158}," gh:your-org\u002Fcli-template",[135,18800,18801],{"class":350}," --no-input",[135,18803,18804],{"class":350}," \\\n",[135,18806,18807,18810],{"class":137,"line":606},[135,18808,18809],{"class":158},"  project_name=\"Deploy Bot\"",[135,18811,18812],{"class":158}," cli_framework=click\n",[10,18814,18815],{},"After generation, the rendered project is ready to install in editable mode and run:",[126,18817,18819],{"className":634,"code":18818,"language":636,"meta":131,"style":131},"cd deploy-bot\nuv venv && uv pip install -e \".[dev]\"\ndeploy-bot hello --name team\n",[14,18820,18821,18829,18854],{"__ignoreMap":131},[135,18822,18823,18826],{"class":137,"line":138},[135,18824,18825],{"class":350},"cd",[135,18827,18828],{"class":158}," deploy-bot\n",[135,18830,18831,18834,18837,18840,18842,18845,18848,18851],{"class":137,"line":152},[135,18832,18833],{"class":145},"uv",[135,18835,18836],{"class":158}," venv",[135,18838,18839],{"class":141}," && ",[135,18841,18833],{"class":145},[135,18843,18844],{"class":158}," pip",[135,18846,18847],{"class":158}," install",[135,18849,18850],{"class":350}," -e",[135,18852,18853],{"class":158}," \".[dev]\"\n",[135,18855,18856,18859,18861,18864],{"class":137,"line":162},[135,18857,18858],{"class":145},"deploy-bot",[135,18860,15472],{"class":158},[135,18862,18863],{"class":350}," --name",[135,18865,18866],{"class":158}," team\n",[36,18868,18870],{"id":18869},"why-this-works-and-the-trade-offs","Why this works (and the trade-offs)",[10,18872,18873,18874,18876,18877,18880],{},"The value of Cookiecutter is centralization. The structural decisions — ",[14,18875,1210],{}," layout, where the entry point lives, which build backend, how lint is configured — get made ",[23,18878,18879],{},"once",", in a template that a senior engineer reviews, instead of re-litigated in every new repo. When you bump the minimum Python version or switch build backends, you change the template and every project generated afterward is correct.",[10,18882,18883,18884,18887,18888,1993,18893,18898,18899,18901],{},"The trade-off is that Cookiecutter is a ",[23,18885,18886],{},"one-shot"," generator: it scaffolds a project at time zero and then walks away. Once a developer runs it, their project is theirs — there is no link back to the template, so improvements you make later do not flow into already-generated projects. Tools like ",[97,18889,18892],{"href":18890,"rel":18891},"https:\u002F\u002Fcruft.github.io\u002Fcruft\u002F",[4654],"Cruft",[97,18894,18897],{"href":18895,"rel":18896},"https:\u002F\u002Fcopier.readthedocs.io\u002F",[4654],"Copier"," exist specifically to add that update path. If \"keep 40 services in sync with the template\" is your actual problem, evaluate Copier instead; if \"stop people hand-rolling ",[14,18900,29],{},"\" is the problem, Cookiecutter is the right size.",[10,18903,18904,18905,18907,18908,18910,18911,10713,18914,18917],{},"The second trade-off is templating noise. A ",[14,18906,29],{}," full of ",[14,18909,18022],{}," blocks is harder to read and easy to break — a misplaced whitespace-control marker (",[14,18912,18913],{},"{%-",[14,18915,18916],{},"{%",") silently mangles your output. Keep conditionals shallow; if a template grows three levels of nested logic, that is a sign you want two templates, not one heroic one.",[36,18919,4512],{"id":4511},[41,18921,18922,18940,18954,18967,18985],{},[44,18923,18924,18927,18928,18936,18937,18939],{},[72,18925,18926],{},"Test the template, not just the output."," Cookiecutter has a ",[97,18929,18932,18933],{"href":18930,"rel":18931},"https:\u002F\u002Fpytest-cookies.readthedocs.io\u002F",[4654],"pytest plugin, ",[14,18934,18935],{},"pytest-cookies",", that bakes the template into a temp directory inside a test. Add a CI job that bakes the project, then runs ",[14,18938,14625],{}," and the generated test suite inside it. A template that generates a project that does not install is worse than no template.",[44,18941,18942,18947,18948,18950,18951,18953],{},[72,18943,18944,18946],{},[14,18945,17717],{}," for collision-prone files."," Any file that legitimately contains ",[14,18949,18081],{}," or ",[14,18952,18916],{}," — GitHub Actions workflows, some YAML config, Jinja-templated app files — must be listed there, or Cookiecutter will try to render it and fail or corrupt it.",[44,18955,18956,18959,18960,18962,18963,18966],{},[72,18957,18958],{},"Pin the framework versions in the template."," The generated ",[14,18961,29],{}," above uses floors like ",[14,18964,18965],{},"typer>=0.12",". That gives new projects a known-good baseline; let each project's lockfile pin the exact versions.",[44,18968,18969,18972,18973,861,18975,861,18978,861,18981,18984],{},[72,18970,18971],{},"Hooks run with the interpreter that runs Cookiecutter."," They are not isolated in the project's venv. Keep them to the standard library (",[14,18974,18718],{},[14,18976,18977],{},"subprocess",[14,18979,18980],{},"re",[14,18982,18983],{},"shutil",") so they work regardless of what the generated project depends on.",[44,18986,18987,7046,18990,1993,18993,18996],{},[72,18988,18989],{},"Idempotent cleanup.",[14,18991,18992],{},"unlink(missing_ok=True)",[14,18994,18995],{},"shutil.rmtree(..., ignore_errors=True)"," in post hooks so re-runs and partial states do not crash.",[36,18998,1277],{"id":1276},[41,19000,19001,19006,19013,19018],{},[44,19002,19003,19005],{},[97,19004,1423],{"href":1422}," — the parent hub for everything that happens before your first command runs.",[44,19007,19008,19012],{},[97,19009,19011],{"href":19010},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management\u002F","uv for Python CLI dependency management"," — install the scaffolded project and lock its dependencies.",[44,19014,19015,19017],{},[97,19016,259],{"href":258}," — an alternative dependency and build workflow you can template instead of hatchling.",[44,19019,19020,10752,19022,19024],{},[97,19021,5],{"href":9070},[14,19023,56],{}," convention the template bakes in.",[1303,19026,19027],{},"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 .s7hpK, html code.shiki .s7hpK{--shiki-default:#B31D28;--shiki-default-font-style:italic;--shiki-dark:#FDAEB7;--shiki-dark-font-style:italic}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":131,"searchDepth":152,"depth":152,"links":19029},[19030,19031,19032,19033,19035,19037,19038,19039,19040,19041],{"id":38,"depth":152,"text":39},{"id":17432,"depth":152,"text":17433},{"id":17459,"depth":152,"text":17460},{"id":17511,"depth":152,"text":19034},"A realistic cookiecutter.json",{"id":17729,"depth":152,"text":19036},"The generated pyproject.toml",{"id":18178,"depth":152,"text":18179},{"id":18725,"depth":152,"text":18726},{"id":18869,"depth":152,"text":18870},{"id":4511,"depth":152,"text":4512},{"id":1276,"depth":152,"text":1277},"Generate production-ready Python CLI project scaffolds with Cookiecutter templates — enforce pyproject.toml structure, src layout, and team consistency.",{},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter",{"title":17354,"description":19042},"project-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter\u002Findex",[18744,19048,19049,1330],"scaffolding","project-templates","x5HG4emei0CsczgoqiZdgSdxvgmEUZY56Dlg--7BDY4",{"id":19052,"title":1423,"body":19053,"date":1320,"description":19238,"difficulty":1457,"draft":1323,"extension":1324,"meta":19239,"navigation":183,"path":19240,"seo":19241,"stem":19242,"tags":19243,"updated":1320,"__hash__":19246},"content\u002Fproject-setup-dependency-management\u002Findex.md",{"type":7,"value":19054,"toc":19229},[19055,19061,19064,19070,19072,19076,19099,19103,19128,19132,19181,19183,19219,19221],[10,19056,19057,19058,19060],{},"Every reliable Python CLI starts with a foundation you rarely think about again: a\nclean ",[14,19059,29],{},", a reproducible dependency lockfile, an isolated environment,\nand a release process that won't surprise you at 2 a.m. This track walks you from an\nempty directory to a packaged tool that installs the same way on every machine.",[10,19062,19063],{},"If you're starting a brand-new CLI, read the guides in the order below. If you're\nhardening an existing project, jump straight to the topic you're fighting with today.",[10,19065,19066],{},[104,19067],{"alt":19068,"src":19069},"Project setup pipeline: Scaffold, Dependencies, Isolation, Quality gates, Version & release — from an empty directory to a shipped CLI.","\u002Fillustrations\u002Fproject-setup-pipeline.svg",[36,19071,6862],{"id":6861},[6864,19073,19075],{"id":19074},"choosing-and-driving-your-toolchain","Choosing and driving your toolchain",[41,19077,19078,19092],{},[44,19079,19080,19084,19085,19087,19088,61],{},[72,19081,19082],{},[97,19083,19011],{"href":19010},"\n— fast lockfile resolution, environment creation, and PEP 621 ",[14,19086,29],{},"\nworkflows with the tool that has become the default for new projects. Includes a\nhead-to-head: ",[97,19089,19091],{"href":19090},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management\u002Fuv-init-vs-poetry-init-for-cli-tools\u002F","uv init vs poetry init for CLI tools",[44,19093,19094,19098],{},[72,19095,19096],{},[97,19097,259],{"href":258},"\n— lockfile management, script entry points, dependency groups, and publishing to\nPyPI when your team is already standardized on Poetry.",[6864,19100,19102],{"id":19101},"isolation-and-reproducibility","Isolation and reproducibility",[41,19104,19105],{},[44,19106,19107,19113,19114,861,19117,784,19119,19122,19123,19127],{},[72,19108,19109],{},[97,19110,19112],{"href":19111},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002F","Python CLI environment isolation best practices","\n— use ",[14,19115,19116],{},"venv",[14,19118,18833],{},[14,19120,19121],{},"pyenv"," to keep dependencies conflict-free and execution\nreproducible across development and CI. Then go deeper on\n",[97,19124,19126],{"href":19125},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fmanaging-virtual-environments-for-cross-platform-clis\u002F","managing virtual environments for cross-platform CLIs","\nacross Linux, macOS, and Windows.",[6864,19129,19131],{"id":19130},"scaffolding-quality-gates-and-releases","Scaffolding, quality gates, and releases",[41,19133,19134,19143,19165],{},[44,19135,19136,19142],{},[72,19137,19138],{},[97,19139,19141],{"href":19140},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter\u002F","CLI project scaffolding with Cookiecutter","\n— generate production-ready project skeletons so every new tool starts with the same\nlayout, packaging, and conventions.",[44,19144,19145,19151,19152,861,19154,784,19157,19160,19161,61],{},[72,19146,19147],{},[97,19148,19150],{"href":19149},"\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects\u002F","Pre-commit hooks for CLI projects","\n— wire up ",[14,19153,17978],{},[14,19155,19156],{},"mypy",[14,19158,19159],{},"pytest"," gates that run before every commit, with a\nstep-by-step ",[97,19162,19164],{"href":19163},"\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects\u002Fsetting-up-pre-commit-for-python-cli-repos\u002F","setup guide for Python CLI repos",[44,19166,19167,19173,19174,19177,19178,61],{},[72,19168,19169],{},[97,19170,19172],{"href":19171},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002F","Managing CLI versioning & changelogs","\n— semantic versioning, ",[14,19175,19176],{},"bump-my-version",", and automated changelog generation from\nConventional Commits with ",[14,19179,19180],{},"git-cliff",[36,19182,6920],{"id":6919},[1961,19184,19185,19191,19198,19205,19212],{},[44,19186,19187,19188,19190],{},"Pick a dependency manager — start with ",[72,19189,18833],{}," unless your team already runs Poetry.",[44,19192,19193,19194,19197],{},"Lock down ",[72,19195,19196],{},"environment isolation"," so builds are reproducible.",[44,19199,19200,19201,19204],{},"Capture the layout in a ",[72,19202,19203],{},"Cookiecutter"," template so the next project is free.",[44,19206,19207,19208,19211],{},"Add ",[72,19209,19210],{},"pre-commit"," gates before the codebase grows.",[44,19213,19214,19215,19218],{},"Automate ",[72,19216,19217],{},"versioning and changelogs"," before your first release.",[36,19220,6947],{"id":6946},[10,19222,19223,19224,19226,19227,61],{},"Once the foundation is in place, move on to designing the command surface in\n",[97,19225,1430],{"href":1429},",\nand polishing how your tool reads input and talks back in\n",[97,19228,1437],{"href":1436},{"title":131,"searchDepth":152,"depth":152,"links":19230},[19231,19236,19237],{"id":6861,"depth":152,"text":6862,"children":19232},[19233,19234,19235],{"id":19074,"depth":162,"text":19075},{"id":19101,"depth":162,"text":19102},{"id":19130,"depth":162,"text":19131},{"id":6919,"depth":152,"text":6920},{"id":6946,"depth":152,"text":6947},"Set up Python CLI projects with pyproject.toml, uv or Poetry, virtual environments, versioning strategies, and automated release workflows.",{},"\u002Fproject-setup-dependency-management",{"title":1423,"description":19238},"project-setup-dependency-management\u002Findex",[19244,9357,13073,18833,19245],"project-setup","poetry","_uBZ7vnm9LHq8XPp6kKFdo-CaHtWysg7FQ1ADKVHJ0c",{"id":19248,"title":19249,"body":19250,"date":1320,"description":20819,"difficulty":1322,"draft":1323,"extension":1324,"meta":20820,"navigation":183,"path":20821,"seo":20822,"stem":20823,"tags":20824,"updated":1320,"__hash__":20828},"content\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Findex.md","Managing CLI Versioning & Changelogs",{"type":7,"value":19251,"toc":20806},[19252,19273,19275,19323,19329,19333,19348,19368,19381,19385,19401,19407,19506,19520,19610,19620,19639,19645,19654,19837,19840,19909,19917,19921,19940,20058,20068,20183,20189,20192,20210,20274,20295,20299,20315,20564,20577,20651,20664,20666,20675,20701,20708,20710,20784,20786,20804],[10,19253,19254,19255,19257,19258,19261,19262,19264,19265,19267,19268,19270,19271,61],{},"Every CLI a human installs eventually answers one question first: \"what version is this?\" Get versioning wrong and you ship a tool whose ",[14,19256,12670],{}," lies, a ",[14,19259,19260],{},"CHANGELOG"," nobody trusts, and a release process held together by manual find-and-replace. This article shows the modern, single-sourced approach: define the version once in ",[14,19263,29],{},", read it at runtime with ",[14,19266,852],{},", bump it with ",[14,19269,19176],{},", and generate the changelog automatically from Conventional Commits using ",[14,19272,19180],{},[36,19274,39],{"id":38},[41,19276,19277,19289,19298,19307],{},[44,19278,19279,19282,19283,19285,19286,61],{},[72,19280,19281],{},"Single-source the version",": put it only in ",[14,19284,29],{},"; read it at runtime with ",[14,19287,19288],{},"from importlib.metadata import version",[44,19290,19291,14045,19294,19297],{},[72,19292,19293],{},"Follow SemVer",[14,19295,19296],{},"MAJOR.MINOR.PATCH",") — for CLIs the \"public API\" is your flags, subcommands, output format, and exit codes.",[44,19299,19300,1230,19303,19306],{},[72,19301,19302],{},"Bump consistently",[14,19304,19305],{},"bump-my-version bump patch|minor|major",", which rewrites the version and tags the commit.",[44,19308,19309,19312,19313,19318,19319,19322],{},[72,19310,19311],{},"Automate the changelog",": write ",[97,19314,19317],{"href":19315,"rel":19316},"https:\u002F\u002Fwww.conventionalcommits.org\u002F",[4654],"Conventional Commits",", then run ",[14,19320,19321],{},"git cliff -o CHANGELOG.md"," to generate it from history.",[10,19324,19325],{},[104,19326],{"alt":19327,"src":19328},"Diagram: one version in pyproject.toml flows through the build to package metadata, is read at runtime by importlib.metadata.version() for mytool --version, while Conventional Commits feed git-cliff to produce CHANGELOG.md.","\u002Fillustrations\u002Fversion-single-source.svg",[36,19330,19332],{"id":19331},"semantic-versioning-for-a-cli","Semantic versioning for a CLI",[10,19334,19335,19340,19341,19343,19344,19347],{},[97,19336,19339],{"href":19337,"rel":19338},"https:\u002F\u002Fsemver.org\u002F",[4654],"Semantic Versioning"," reads ",[14,19342,19296],{},". The discipline is in knowing what counts as a breaking change for a ",[23,19345,19346],{},"command-line"," tool — it is not just importable Python symbols. Your public contract is everything a user or a script can depend on:",[41,19349,19350,19356,19362],{},[44,19351,19352,19355],{},[72,19353,19354],{},"MAJOR"," — you removed or renamed a flag\u002Fsubcommand, changed a default, altered machine-readable output (JSON schema, column order), or changed exit codes. Anything that breaks an existing invocation.",[44,19357,19358,19361],{},[72,19359,19360],{},"MINOR"," — you added a new subcommand, a new optional flag, or new output fields, in a backward-compatible way.",[44,19363,19364,19367],{},[72,19365,19366],{},"PATCH"," — bug fixes that don't change the documented behavior.",[10,19369,19370,19371,19373,19374,459,19377,19380],{},"The implication: treat ",[14,19372,12991],{}," text loosely, but treat flag names, exit codes, and the shape of any ",[14,19375,19376],{},"--json",[14,19378,19379],{},"--output"," payload as a versioned API. Scripts in someone's CI pipeline parse that output; renaming a JSON key is a major bump even though the code \"still works.\"",[36,19382,19384],{"id":19383},"single-sourcing-the-version","Single-sourcing the version",[10,19386,19387,19388,19391,19392,19394,19395,19398,19399,61],{},"The cardinal rule is ",[72,19389,19390],{},"one source of truth",". Declare the version in ",[14,19393,29],{}," and nowhere else — no hardcoded ",[14,19396,19397],{},"__version__ = \"1.2.0\""," string that drifts out of sync with the package metadata. At runtime, read it back from the installed distribution's metadata with ",[14,19400,852],{},[10,19402,19403,19404,19406],{},"Here's the package metadata in ",[14,19405,29],{}," (PEP 621):",[126,19408,19410],{"className":128,"code":19409,"language":130,"meta":131,"style":131},"[project]\nname = \"mytool\"\nversion = \"0.3.0\"\ndescription = \"An example CLI\"\nrequires-python = \">=3.10\"\ndependencies = [\"typer>=0.12\"]\n\n[project.scripts]\nmytool = \"mytool.cli:app\"\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n",[14,19411,19412,19420,19427,19434,19441,19447,19456,19460,19472,19480,19484,19492,19500],{"__ignoreMap":131},[135,19413,19414,19416,19418],{"class":137,"line":138},[135,19415,142],{"class":141},[135,19417,146],{"class":145},[135,19419,149],{"class":141},[135,19421,19422,19424],{"class":137,"line":152},[135,19423,155],{"class":141},[135,19425,19426],{"class":158},"\"mytool\"\n",[135,19428,19429,19431],{"class":137,"line":162},[135,19430,165],{"class":141},[135,19432,19433],{"class":158},"\"0.3.0\"\n",[135,19435,19436,19438],{"class":137,"line":171},[135,19437,17792],{"class":141},[135,19439,19440],{"class":158},"\"An example CLI\"\n",[135,19442,19443,19445],{"class":137,"line":180},[135,19444,174],{"class":141},[135,19446,177],{"class":158},[135,19448,19449,19451,19454],{"class":137,"line":187},[135,19450,9284],{"class":141},[135,19452,19453],{"class":158},"\"typer>=0.12\"",[135,19455,149],{"class":141},[135,19457,19458],{"class":137,"line":201},[135,19459,184],{"emptyLinePlaceholder":183},[135,19461,19462,19464,19466,19468,19470],{"class":137,"line":210},[135,19463,142],{"class":141},[135,19465,146],{"class":145},[135,19467,61],{"class":141},[135,19469,196],{"class":145},[135,19471,149],{"class":141},[135,19473,19474,19477],{"class":137,"line":215},[135,19475,19476],{"class":141},"mytool = ",[135,19478,19479],{"class":158},"\"mytool.cli:app\"\n",[135,19481,19482],{"class":137,"line":225},[135,19483,184],{"emptyLinePlaceholder":183},[135,19485,19486,19488,19490],{"class":137,"line":236},[135,19487,142],{"class":141},[135,19489,220],{"class":145},[135,19491,149],{"class":141},[135,19493,19494,19496,19498],{"class":137,"line":606},[135,19495,228],{"class":141},[135,19497,231],{"class":158},[135,19499,149],{"class":141},[135,19501,19502,19504],{"class":137,"line":619},[135,19503,239],{"class":141},[135,19505,242],{"class":158},[10,19507,19508,19509,19512,19513,19516,19517,19519],{},"Now read that single value at runtime. The modern pattern (Python 3.8+, no third-party dependency) uses ",[14,19510,19511],{},"importlib.metadata.version"," with a ",[14,19514,19515],{},"PackageNotFoundError"," fallback so the tool still runs from a source checkout that was never ",[14,19518,16],{},"ed:",[126,19521,19523],{"className":316,"code":19522,"language":318,"meta":131,"style":131},"# mytool\u002F_version.py\nfrom importlib.metadata import version, PackageNotFoundError\n\n__all__ = [\"__version__\"]\n\ntry:\n    # Resolves the version recorded in installed metadata at runtime.\n    __version__ = version(\"mytool\")\nexcept PackageNotFoundError:\n    # Running from a source checkout that was never installed.\n    __version__ = \"0.0.0+unknown\"\n",[14,19524,19525,19530,19541,19545,19559,19563,19569,19574,19589,19596,19601],{"__ignoreMap":131},[135,19526,19527],{"class":137,"line":138},[135,19528,19529],{"class":669},"# mytool\u002F_version.py\n",[135,19531,19532,19534,19536,19538],{"class":137,"line":152},[135,19533,334],{"class":325},[135,19535,887],{"class":141},[135,19537,326],{"class":325},[135,19539,19540],{"class":141}," version, PackageNotFoundError\n",[135,19542,19543],{"class":137,"line":162},[135,19544,184],{"emptyLinePlaceholder":183},[135,19546,19547,19550,19552,19554,19557],{"class":137,"line":171},[135,19548,19549],{"class":350},"__all__",[135,19551,2150],{"class":325},[135,19553,5889],{"class":141},[135,19555,19556],{"class":158},"\"__version__\"",[135,19558,149],{"class":141},[135,19560,19561],{"class":137,"line":180},[135,19562,184],{"emptyLinePlaceholder":183},[135,19564,19565,19567],{"class":137,"line":187},[135,19566,2399],{"class":325},[135,19568,360],{"class":141},[135,19570,19571],{"class":137,"line":201},[135,19572,19573],{"class":669},"    # Resolves the version recorded in installed metadata at runtime.\n",[135,19575,19576,19579,19581,19584,19587],{"class":137,"line":210},[135,19577,19578],{"class":350},"    __version__",[135,19580,2150],{"class":325},[135,19582,19583],{"class":141}," version(",[135,19585,19586],{"class":158},"\"mytool\"",[135,19588,550],{"class":141},[135,19590,19591,19593],{"class":137,"line":215},[135,19592,2413],{"class":325},[135,19594,19595],{"class":141}," PackageNotFoundError:\n",[135,19597,19598],{"class":137,"line":225},[135,19599,19600],{"class":669},"    # Running from a source checkout that was never installed.\n",[135,19602,19603,19605,19607],{"class":137,"line":236},[135,19604,19578],{"class":350},[135,19606,2150],{"class":325},[135,19608,19609],{"class":158}," \"0.0.0+unknown\"\n",[10,19611,19612,19613,19616,19617,19619],{},"This is validated below. With the package installed, ",[14,19614,19615],{},"version(\"mytool\")"," returns exactly what's in ",[14,19618,29],{},"; from a bare checkout it falls back gracefully instead of crashing.",[5668,19621,19622],{},[10,19623,19624,19625,19627,19628,19631,19632,19635,19636,61],{},"Note the distribution name (",[14,19626,19586],{},", matching ",[14,19629,19630],{},"[project].name",") may differ from the import package name. Pass the ",[23,19633,19634],{},"distribution"," name to ",[14,19637,19638],{},"version()",[36,19640,19642,19643],{"id":19641},"exposing-version","Exposing ",[14,19644,12670],{},[10,19646,19647,19648,19650,19651,19653],{},"Wire ",[14,19649,16337],{}," into your entry point. In Typer, use an eager callback so ",[14,19652,12670],{}," short-circuits before any command runs:",[126,19655,19657],{"className":316,"code":19656,"language":318,"meta":131,"style":131},"# mytool\u002Fcli.py\nimport typer\nfrom mytool._version import __version__\n\napp = typer.Typer()\n\n\ndef _version_callback(value: bool) -> None:\n    if value:\n        typer.echo(f\"mytool {__version__}\")\n        raise typer.Exit()\n\n\n@app.callback()\ndef main(\n    version: bool = typer.Option(\n        None, \"--version\", callback=_version_callback, is_eager=True,\n        help=\"Show the version and exit.\",\n    ),\n) -> None:\n    \"\"\"mytool — an example CLI.\"\"\"\n",[14,19658,19659,19664,19670,19682,19686,19694,19698,19702,19719,19725,19740,19747,19751,19755,19761,19769,19781,19808,19819,19824,19832],{"__ignoreMap":131},[135,19660,19661],{"class":137,"line":138},[135,19662,19663],{"class":669},"# mytool\u002Fcli.py\n",[135,19665,19666,19668],{"class":137,"line":152},[135,19667,326],{"class":325},[135,19669,2056],{"class":141},[135,19671,19672,19674,19677,19679],{"class":137,"line":162},[135,19673,334],{"class":325},[135,19675,19676],{"class":141}," mytool._version ",[135,19678,326],{"class":325},[135,19680,19681],{"class":350}," __version__\n",[135,19683,19684],{"class":137,"line":171},[135,19685,184],{"emptyLinePlaceholder":183},[135,19687,19688,19690,19692],{"class":137,"line":180},[135,19689,16085],{"class":141},[135,19691,516],{"class":325},[135,19693,10440],{"class":141},[135,19695,19696],{"class":137,"line":187},[135,19697,184],{"emptyLinePlaceholder":183},[135,19699,19700],{"class":137,"line":201},[135,19701,184],{"emptyLinePlaceholder":183},[135,19703,19704,19706,19709,19711,19713,19715,19717],{"class":137,"line":210},[135,19705,493],{"class":325},[135,19707,19708],{"class":145}," _version_callback",[135,19710,2081],{"class":141},[135,19712,5112],{"class":350},[135,19714,1788],{"class":141},[135,19716,3093],{"class":350},[135,19718,360],{"class":141},[135,19720,19721,19723],{"class":137,"line":215},[135,19722,530],{"class":325},[135,19724,16378],{"class":141},[135,19726,19727,19729,19731,19734,19736,19738],{"class":137,"line":225},[135,19728,15629],{"class":141},[135,19730,568],{"class":325},[135,19732,19733],{"class":158},"\"mytool ",[135,19735,16390],{"class":350},[135,19737,589],{"class":158},[135,19739,550],{"class":141},[135,19741,19742,19744],{"class":137,"line":236},[135,19743,2108],{"class":325},[135,19745,19746],{"class":141}," typer.Exit()\n",[135,19748,19749],{"class":137,"line":606},[135,19750,184],{"emptyLinePlaceholder":183},[135,19752,19753],{"class":137,"line":619},[135,19754,184],{"emptyLinePlaceholder":183},[135,19756,19757,19759],{"class":137,"line":1752},[135,19758,16098],{"class":145},[135,19760,2135],{"class":141},[135,19762,19763,19765,19767],{"class":137,"line":1765},[135,19764,493],{"class":325},[135,19766,496],{"class":145},[135,19768,6284],{"class":141},[135,19770,19771,19774,19776,19778],{"class":137,"line":1774},[135,19772,19773],{"class":141},"    version: ",[135,19775,5112],{"class":350},[135,19777,2150],{"class":325},[135,19779,19780],{"class":141}," typer.Option(\n",[135,19782,19783,19786,19788,19790,19792,19794,19796,19799,19802,19804,19806],{"class":137,"line":1795},[135,19784,19785],{"class":350},"        None",[135,19787,861],{"class":141},[135,19789,17230],{"class":158},[135,19791,861],{"class":141},[135,19793,2039],{"class":914},[135,19795,516],{"class":325},[135,19797,19798],{"class":141},"_version_callback, ",[135,19800,19801],{"class":914},"is_eager",[135,19803,516],{"class":325},[135,19805,3651],{"class":350},[135,19807,3238],{"class":141},[135,19809,19810,19813,19815,19817],{"class":137,"line":1830},[135,19811,19812],{"class":914},"        help",[135,19814,516],{"class":325},[135,19816,16480],{"class":158},[135,19818,3238],{"class":141},[135,19820,19821],{"class":137,"line":1846},[135,19822,19823],{"class":141},"    ),\n",[135,19825,19826,19828,19830],{"class":137,"line":1854},[135,19827,1788],{"class":141},[135,19829,3093],{"class":350},[135,19831,360],{"class":141},[135,19833,19834],{"class":137,"line":1859},[135,19835,19836],{"class":158},"    \"\"\"mytool — an example CLI.\"\"\"\n",[10,19838,19839],{},"The Click equivalent is the built-in decorator, which reads metadata for you:",[126,19841,19843],{"className":316,"code":19842,"language":318,"meta":131,"style":131},"import click\nfrom mytool._version import __version__\n\n@click.group()\n@click.version_option(version=__version__, prog_name=\"mytool\")\ndef cli() -> None:\n    ...\n",[14,19844,19845,19851,19861,19865,19871,19893,19905],{"__ignoreMap":131},[135,19846,19847,19849],{"class":137,"line":138},[135,19848,326],{"class":325},[135,19850,2244],{"class":141},[135,19852,19853,19855,19857,19859],{"class":137,"line":152},[135,19854,334],{"class":325},[135,19856,19676],{"class":141},[135,19858,326],{"class":325},[135,19860,19681],{"class":350},[135,19862,19863],{"class":137,"line":162},[135,19864,184],{"emptyLinePlaceholder":183},[135,19866,19867,19869],{"class":137,"line":171},[135,19868,12926],{"class":145},[135,19870,2135],{"class":141},[135,19872,19873,19875,19877,19879,19881,19883,19885,19887,19889,19891],{"class":137,"line":180},[135,19874,13663],{"class":145},[135,19876,544],{"class":141},[135,19878,13668],{"class":914},[135,19880,516],{"class":325},[135,19882,16337],{"class":350},[135,19884,861],{"class":141},[135,19886,13678],{"class":914},[135,19888,516],{"class":325},[135,19890,19586],{"class":158},[135,19892,550],{"class":141},[135,19894,19895,19897,19899,19901,19903],{"class":137,"line":187},[135,19896,493],{"class":325},[135,19898,12967],{"class":145},[135,19900,499],{"class":141},[135,19902,3093],{"class":350},[135,19904,360],{"class":141},[135,19906,19907],{"class":137,"line":201},[135,19908,2170],{"class":350},[10,19910,19911,19912,19914,19915,61],{},"For more on structuring the callable behind ",[14,19913,56],{},", see ",[97,19916,9071],{"href":9070},[36,19918,19920],{"id":19919},"bumping-the-version-with-bump-my-version","Bumping the version with bump-my-version",[10,19922,19923,19924,19926,19927,19933,19934,19937,19938,473],{},"Editing ",[14,19925,29],{}," by hand on every release invites typos and forgotten git tags. ",[97,19928,19931],{"href":19929,"rel":19930},"https:\u002F\u002Fgithub.com\u002Fcallowayproject\u002Fbump-my-version",[4654],[14,19932,19176],{}," (the maintained successor to ",[14,19935,19936],{},"bump2version",") automates the rewrite, the commit, and the tag in one command. Configure it inside ",[14,19939,29],{},[126,19941,19943],{"className":128,"code":19942,"language":130,"meta":131,"style":131},"[tool.bumpversion]\ncurrent_version = \"0.3.0\"\nallow_dirty = false\ncommit = true\nmessage = \"chore(release): bump version {current_version} -> {new_version}\"\ntag = true\ntag_name = \"v{new_version}\"\ntag_message = \"Release v{new_version}\"\n\n[[tool.bumpversion.files]]\nfilename = \"pyproject.toml\"\nsearch = 'version = \"{current_version}\"'\nreplace = 'version = \"{new_version}\"'\n",[14,19944,19945,19958,19965,19973,19980,19988,19995,20003,20011,20015,20034,20042,20050],{"__ignoreMap":131},[135,19946,19947,19949,19951,19953,19956],{"class":137,"line":138},[135,19948,142],{"class":141},[135,19950,11953],{"class":145},[135,19952,61],{"class":141},[135,19954,19955],{"class":145},"bumpversion",[135,19957,149],{"class":141},[135,19959,19960,19963],{"class":137,"line":152},[135,19961,19962],{"class":141},"current_version = ",[135,19964,19433],{"class":158},[135,19966,19967,19970],{"class":137,"line":162},[135,19968,19969],{"class":141},"allow_dirty = ",[135,19971,19972],{"class":350},"false\n",[135,19974,19975,19978],{"class":137,"line":171},[135,19976,19977],{"class":141},"commit = ",[135,19979,6543],{"class":350},[135,19981,19982,19985],{"class":137,"line":180},[135,19983,19984],{"class":141},"message = ",[135,19986,19987],{"class":158},"\"chore(release): bump version {current_version} -> {new_version}\"\n",[135,19989,19990,19993],{"class":137,"line":187},[135,19991,19992],{"class":141},"tag = ",[135,19994,6543],{"class":350},[135,19996,19997,20000],{"class":137,"line":201},[135,19998,19999],{"class":141},"tag_name = ",[135,20001,20002],{"class":158},"\"v{new_version}\"\n",[135,20004,20005,20008],{"class":137,"line":210},[135,20006,20007],{"class":141},"tag_message = ",[135,20009,20010],{"class":158},"\"Release v{new_version}\"\n",[135,20012,20013],{"class":137,"line":215},[135,20014,184],{"emptyLinePlaceholder":183},[135,20016,20017,20020,20022,20024,20026,20028,20031],{"class":137,"line":225},[135,20018,20019],{"class":141},"[[",[135,20021,11953],{"class":145},[135,20023,61],{"class":141},[135,20025,19955],{"class":145},[135,20027,61],{"class":141},[135,20029,20030],{"class":145},"files",[135,20032,20033],{"class":141},"]]\n",[135,20035,20036,20039],{"class":137,"line":236},[135,20037,20038],{"class":141},"filename = ",[135,20040,20041],{"class":158},"\"pyproject.toml\"\n",[135,20043,20044,20047],{"class":137,"line":606},[135,20045,20046],{"class":141},"search = ",[135,20048,20049],{"class":158},"'version = \"{current_version}\"'\n",[135,20051,20052,20055],{"class":137,"line":619},[135,20053,20054],{"class":141},"replace = ",[135,20056,20057],{"class":158},"'version = \"{new_version}\"'\n",[10,20059,20060,20061,20063,20064,20067],{},"Because the version lives only in ",[14,20062,29],{},", that single ",[14,20065,20066],{},"[[tool.bumpversion.files]]"," block is all you need — no second file to keep in sync. Driving it:",[126,20069,20071],{"className":634,"code":20070,"language":636,"meta":131,"style":131},"# Install once (kept out of your runtime deps; a dev\u002Ftool dependency):\nuv tool install bump-my-version\n\n# Preview without writing anything:\nbump-my-version bump --dry-run --verbose patch\n\n# 0.3.0 -> 0.3.1, commit, and tag v0.3.1:\nbump-my-version bump patch\n\n# 0.3.1 -> 0.4.0:\nbump-my-version bump minor\n\n# 0.4.0 -> 1.0.0:\nbump-my-version bump major\n\ngit push --follow-tags\n",[14,20072,20073,20078,20090,20094,20099,20115,20119,20124,20132,20136,20141,20150,20154,20159,20168,20172],{"__ignoreMap":131},[135,20074,20075],{"class":137,"line":138},[135,20076,20077],{"class":669},"# Install once (kept out of your runtime deps; a dev\u002Ftool dependency):\n",[135,20079,20080,20082,20085,20087],{"class":137,"line":152},[135,20081,18833],{"class":145},[135,20083,20084],{"class":158}," tool",[135,20086,18847],{"class":158},[135,20088,20089],{"class":158}," bump-my-version\n",[135,20091,20092],{"class":137,"line":162},[135,20093,184],{"emptyLinePlaceholder":183},[135,20095,20096],{"class":137,"line":171},[135,20097,20098],{"class":669},"# Preview without writing anything:\n",[135,20100,20101,20103,20106,20109,20112],{"class":137,"line":180},[135,20102,19176],{"class":145},[135,20104,20105],{"class":158}," bump",[135,20107,20108],{"class":350}," --dry-run",[135,20110,20111],{"class":350}," --verbose",[135,20113,20114],{"class":158}," patch\n",[135,20116,20117],{"class":137,"line":187},[135,20118,184],{"emptyLinePlaceholder":183},[135,20120,20121],{"class":137,"line":201},[135,20122,20123],{"class":669},"# 0.3.0 -> 0.3.1, commit, and tag v0.3.1:\n",[135,20125,20126,20128,20130],{"class":137,"line":210},[135,20127,19176],{"class":145},[135,20129,20105],{"class":158},[135,20131,20114],{"class":158},[135,20133,20134],{"class":137,"line":215},[135,20135,184],{"emptyLinePlaceholder":183},[135,20137,20138],{"class":137,"line":225},[135,20139,20140],{"class":669},"# 0.3.1 -> 0.4.0:\n",[135,20142,20143,20145,20147],{"class":137,"line":236},[135,20144,19176],{"class":145},[135,20146,20105],{"class":158},[135,20148,20149],{"class":158}," minor\n",[135,20151,20152],{"class":137,"line":606},[135,20153,184],{"emptyLinePlaceholder":183},[135,20155,20156],{"class":137,"line":619},[135,20157,20158],{"class":669},"# 0.4.0 -> 1.0.0:\n",[135,20160,20161,20163,20165],{"class":137,"line":1752},[135,20162,19176],{"class":145},[135,20164,20105],{"class":158},[135,20166,20167],{"class":158}," major\n",[135,20169,20170],{"class":137,"line":1765},[135,20171,184],{"emptyLinePlaceholder":183},[135,20173,20174,20177,20180],{"class":137,"line":1774},[135,20175,20176],{"class":145},"git",[135,20178,20179],{"class":158}," push",[135,20181,20182],{"class":350}," --follow-tags\n",[10,20184,111,20185,20188],{},[14,20186,20187],{},"--dry-run --verbose"," preview is your safety net: it prints the exact diff and the tag it would create before you commit to anything.",[36,20190,19317],{"id":20191},"conventional-commits",[10,20193,20194,20195,20198,20199,20202,20203,18950,20206,20209],{},"Automated changelogs need machine-readable history. ",[97,20196,19317],{"href":19315,"rel":20197},[4654]," is a lightweight convention for commit subjects: ",[14,20200,20201],{},"\u003Ctype>(\u003Cscope>): \u003Cdescription>",", with a ",[14,20204,20205],{},"!",[14,20207,20208],{},"BREAKING CHANGE:"," footer for incompatible changes.",[126,20211,20213],{"className":634,"code":20212,"language":636,"meta":131,"style":131},"feat(parser): add --json output to the list command\nfix(auth): respect HTTPS_PROXY when refreshing tokens\ndocs(readme): document the new --json flag\nrefactor(core): extract retry logic into a helper\nfeat(api)!: rename --target to --destination   # breaking -> MAJOR\n",[14,20214,20215,20223,20231,20239,20247],{"__ignoreMap":131},[135,20216,20217,20220],{"class":137,"line":138},[135,20218,20219],{"class":145},"feat(parser",[135,20221,20222],{"class":141},"): add --json output to the list command\n",[135,20224,20225,20228],{"class":137,"line":152},[135,20226,20227],{"class":145},"fix(auth",[135,20229,20230],{"class":141},"): respect HTTPS_PROXY when refreshing tokens\n",[135,20232,20233,20236],{"class":137,"line":162},[135,20234,20235],{"class":145},"docs(readme",[135,20237,20238],{"class":141},"): document the new --json flag\n",[135,20240,20241,20244],{"class":137,"line":171},[135,20242,20243],{"class":145},"refactor(core",[135,20245,20246],{"class":141},"): extract retry logic into a helper\n",[135,20248,20249,20252,20255,20257,20259,20262,20265,20268,20271],{"class":137,"line":180},[135,20250,20251],{"class":145},"feat(api",[135,20253,20254],{"class":141},")",[135,20256,20205],{"class":325},[135,20258,473],{"class":350},[135,20260,20261],{"class":158}," rename",[135,20263,20264],{"class":350}," --target",[135,20266,20267],{"class":158}," to",[135,20269,20270],{"class":350}," --destination",[135,20272,20273],{"class":669},"   # breaking -> MAJOR\n",[10,20275,20276,20277,20280,20281,20284,20285,459,20287,20290,20291,20294],{},"The payoff is twofold. First, the types map directly onto SemVer: ",[14,20278,20279],{},"fix"," → PATCH, ",[14,20282,20283],{},"feat"," → MINOR, anything with ",[14,20286,20205],{},[14,20288,20289],{},"BREAKING CHANGE"," → MAJOR. Second, a tool can group these into changelog sections without you writing release notes by hand. Enforce the format in CI or with a ",[97,20292,20293],{"href":19149},"pre-commit hook"," so a single non-conforming commit doesn't poison the generated log.",[36,20296,20298],{"id":20297},"automated-changelogs-with-git-cliff","Automated changelogs with git-cliff",[10,20300,20301,20307,20308,20311,20312,473],{},[97,20302,20305],{"href":20303,"rel":20304},"https:\u002F\u002Fgit-cliff.org\u002F",[4654],[14,20306,19180],{}," parses your Conventional Commits and renders a ",[14,20309,20310],{},"CHANGELOG.md",". It's a single Rust binary configured by a ",[14,20313,20314],{},"cliff.toml",[126,20316,20318],{"className":128,"code":20317,"language":130,"meta":131,"style":131},"[changelog]\nheader = \"# Changelog\\n\\nAll notable changes to this project are documented here.\\n\"\nbody = \"\"\"\n{% for group, commits in commits | group_by(attribute=\"group\") %}\n### {{ group | upper_first }}\n{% for commit in commits %}\n- {{ commit.message | upper_first }}\\\n{% endfor %}\n{% endfor %}\n\"\"\"\ntrim = true\n\n[git]\nconventional_commits = true\nfilter_unconventional = true\ncommit_parsers = [\n  { message = \"^feat\", group = \"Features\" },\n  { message = \"^fix\", group = \"Bug Fixes\" },\n  { message = \"^docs\", group = \"Documentation\" },\n  { message = \"^perf\", group = \"Performance\" },\n  { message = \"^refactor\", group = \"Refactor\" },\n  { body = \".*security\", group = \"Security\" },\n  { message = \"^chore\\\\(release\\\\)\", skip = true },\n  { message = \"^chore\", skip = true },\n]\ntag_pattern = \"v[0-9]*\"\n",[14,20319,20320,20329,20347,20355,20360,20365,20370,20375,20380,20384,20388,20395,20399,20407,20414,20421,20426,20443,20457,20471,20485,20499,20514,20539,20552,20556],{"__ignoreMap":131},[135,20321,20322,20324,20327],{"class":137,"line":138},[135,20323,142],{"class":141},[135,20325,20326],{"class":145},"changelog",[135,20328,149],{"class":141},[135,20330,20331,20334,20337,20340,20343,20345],{"class":137,"line":152},[135,20332,20333],{"class":141},"header = ",[135,20335,20336],{"class":158},"\"# Changelog",[135,20338,20339],{"class":350},"\\n\\n",[135,20341,20342],{"class":158},"All notable changes to this project are documented here.",[135,20344,2370],{"class":350},[135,20346,5968],{"class":158},[135,20348,20349,20352],{"class":137,"line":162},[135,20350,20351],{"class":141},"body = ",[135,20353,20354],{"class":158},"\"\"\"\n",[135,20356,20357],{"class":137,"line":171},[135,20358,20359],{"class":158},"{% for group, commits in commits | group_by(attribute=\"group\") %}\n",[135,20361,20362],{"class":137,"line":180},[135,20363,20364],{"class":158},"### {{ group | upper_first }}\n",[135,20366,20367],{"class":137,"line":187},[135,20368,20369],{"class":158},"{% for commit in commits %}\n",[135,20371,20372],{"class":137,"line":201},[135,20373,20374],{"class":158},"- {{ commit.message | upper_first }}\\\n",[135,20376,20377],{"class":137,"line":210},[135,20378,20379],{"class":158},"{% endfor %}\n",[135,20381,20382],{"class":137,"line":215},[135,20383,20379],{"class":158},[135,20385,20386],{"class":137,"line":225},[135,20387,20354],{"class":158},[135,20389,20390,20393],{"class":137,"line":236},[135,20391,20392],{"class":141},"trim = ",[135,20394,6543],{"class":350},[135,20396,20397],{"class":137,"line":606},[135,20398,184],{"emptyLinePlaceholder":183},[135,20400,20401,20403,20405],{"class":137,"line":619},[135,20402,142],{"class":141},[135,20404,20176],{"class":145},[135,20406,149],{"class":141},[135,20408,20409,20412],{"class":137,"line":1752},[135,20410,20411],{"class":141},"conventional_commits = ",[135,20413,6543],{"class":350},[135,20415,20416,20419],{"class":137,"line":1765},[135,20417,20418],{"class":141},"filter_unconventional = ",[135,20420,6543],{"class":350},[135,20422,20423],{"class":137,"line":1774},[135,20424,20425],{"class":141},"commit_parsers = [\n",[135,20427,20428,20431,20434,20437,20440],{"class":137,"line":1795},[135,20429,20430],{"class":141},"  { message = ",[135,20432,20433],{"class":158},"\"^feat\"",[135,20435,20436],{"class":141},", group = ",[135,20438,20439],{"class":158},"\"Features\"",[135,20441,20442],{"class":141}," },\n",[135,20444,20445,20447,20450,20452,20455],{"class":137,"line":1830},[135,20446,20430],{"class":141},[135,20448,20449],{"class":158},"\"^fix\"",[135,20451,20436],{"class":141},[135,20453,20454],{"class":158},"\"Bug Fixes\"",[135,20456,20442],{"class":141},[135,20458,20459,20461,20464,20466,20469],{"class":137,"line":1846},[135,20460,20430],{"class":141},[135,20462,20463],{"class":158},"\"^docs\"",[135,20465,20436],{"class":141},[135,20467,20468],{"class":158},"\"Documentation\"",[135,20470,20442],{"class":141},[135,20472,20473,20475,20478,20480,20483],{"class":137,"line":1854},[135,20474,20430],{"class":141},[135,20476,20477],{"class":158},"\"^perf\"",[135,20479,20436],{"class":141},[135,20481,20482],{"class":158},"\"Performance\"",[135,20484,20442],{"class":141},[135,20486,20487,20489,20492,20494,20497],{"class":137,"line":1859},[135,20488,20430],{"class":141},[135,20490,20491],{"class":158},"\"^refactor\"",[135,20493,20436],{"class":141},[135,20495,20496],{"class":158},"\"Refactor\"",[135,20498,20442],{"class":141},[135,20500,20501,20504,20507,20509,20512],{"class":137,"line":1877},[135,20502,20503],{"class":141},"  { body = ",[135,20505,20506],{"class":158},"\".*security\"",[135,20508,20436],{"class":141},[135,20510,20511],{"class":158},"\"Security\"",[135,20513,20442],{"class":141},[135,20515,20516,20518,20521,20524,20527,20529,20531,20534,20537],{"class":137,"line":1893},[135,20517,20430],{"class":141},[135,20519,20520],{"class":158},"\"^chore",[135,20522,20523],{"class":350},"\\\\",[135,20525,20526],{"class":158},"(release",[135,20528,20523],{"class":350},[135,20530,3235],{"class":158},[135,20532,20533],{"class":141},", skip = ",[135,20535,20536],{"class":350},"true",[135,20538,20442],{"class":141},[135,20540,20541,20543,20546,20548,20550],{"class":137,"line":1926},[135,20542,20430],{"class":141},[135,20544,20545],{"class":158},"\"^chore\"",[135,20547,20533],{"class":141},[135,20549,20536],{"class":350},[135,20551,20442],{"class":141},[135,20553,20554],{"class":137,"line":1940},[135,20555,149],{"class":141},[135,20557,20558,20561],{"class":137,"line":2906},[135,20559,20560],{"class":141},"tag_pattern = ",[135,20562,20563],{"class":158},"\"v[0-9]*\"\n",[10,20565,20566,20567,2344,20570,20573,20574,20576],{},"Note the ",[14,20568,20569],{},"commit_parsers",[14,20571,20572],{},"chore(release)"," commits (the ones ",[14,20575,19176],{}," creates) are skipped so your changelog isn't cluttered with \"bump version\" noise. Generate and regenerate it:",[126,20578,20580],{"className":634,"code":20579,"language":636,"meta":131,"style":131},"uv tool install git-cliff\n\n# Render the full history into CHANGELOG.md:\ngit cliff -o CHANGELOG.md\n\n# Only the unreleased commits, e.g. to paste into a draft release:\ngit cliff --unreleased\n\n# Render notes for a specific tag range (handy in CI on tag push):\ngit cliff v0.3.0..v0.4.0\n",[14,20581,20582,20593,20597,20602,20615,20619,20624,20633,20637,20642],{"__ignoreMap":131},[135,20583,20584,20586,20588,20590],{"class":137,"line":138},[135,20585,18833],{"class":145},[135,20587,20084],{"class":158},[135,20589,18847],{"class":158},[135,20591,20592],{"class":158}," git-cliff\n",[135,20594,20595],{"class":137,"line":152},[135,20596,184],{"emptyLinePlaceholder":183},[135,20598,20599],{"class":137,"line":162},[135,20600,20601],{"class":669},"# Render the full history into CHANGELOG.md:\n",[135,20603,20604,20606,20609,20612],{"class":137,"line":171},[135,20605,20176],{"class":145},[135,20607,20608],{"class":158}," cliff",[135,20610,20611],{"class":350}," -o",[135,20613,20614],{"class":158}," CHANGELOG.md\n",[135,20616,20617],{"class":137,"line":180},[135,20618,184],{"emptyLinePlaceholder":183},[135,20620,20621],{"class":137,"line":187},[135,20622,20623],{"class":669},"# Only the unreleased commits, e.g. to paste into a draft release:\n",[135,20625,20626,20628,20630],{"class":137,"line":201},[135,20627,20176],{"class":145},[135,20629,20608],{"class":158},[135,20631,20632],{"class":350}," --unreleased\n",[135,20634,20635],{"class":137,"line":210},[135,20636,184],{"emptyLinePlaceholder":183},[135,20638,20639],{"class":137,"line":215},[135,20640,20641],{"class":669},"# Render notes for a specific tag range (handy in CI on tag push):\n",[135,20643,20644,20646,20648],{"class":137,"line":225},[135,20645,20176],{"class":145},[135,20647,20608],{"class":158},[135,20649,20650],{"class":158}," v0.3.0..v0.4.0\n",[10,20652,20653,20654,20656,20657,20660,20661,20663],{},"The clean release flow combines all three tools: write Conventional Commits as you work, run ",[14,20655,19321],{}," and commit it, then ",[14,20658,20659],{},"bump-my-version bump minor"," to tag the release. In CI, you can run ",[14,20662,19180],{}," on tag push to auto-attach release notes.",[36,20665,18870],{"id":18869},[10,20667,20668,20669,20671,20672,20674],{},"Single-sourcing removes the most common bug class entirely: a ",[14,20670,12670],{}," that disagrees with the published package. The version exists in exactly one place, the build backend stamps it into the wheel metadata, and ",[14,20673,852],{}," reads it back. There is nothing to keep in sync.",[10,20676,20677,20678,20680,20681,20684,20685,20687,20688,20691,20692,20694,20695,20697,20698,20700],{},"The trade-off is the ",[14,20679,19515],{}," fallback. When you run straight from a ",[14,20682,20683],{},"git clone"," without installing, no distribution metadata exists, so ",[14,20686,19638],{}," raises. The fallback string (",[14,20689,20690],{},"\"0.0.0+unknown\"",") keeps the CLI usable, but it means \"version from a dev checkout is meaningless\" — which is correct. If you want a real version in editable installs, ",[14,20693,1247],{}," \u002F ",[14,20696,14625],{}," writes metadata, and ",[14,20699,852],{}," resolves it normally.",[10,20702,20703,20704,20707],{},"Conventional Commits asks for discipline from contributors. The win is that the discipline is ",[23,20705,20706],{},"enforceable and useful"," — it drives both the changelog and the SemVer decision, so it isn't bureaucracy for its own sake.",[36,20709,4512],{"id":4511},[41,20711,20712,20727,20739,20752,20771],{},[44,20713,20714,765,20717,20719,20720,20723,20724,20726],{},[72,20715,20716],{},"Tag and version must agree.",[14,20718,19176],{},"'s ",[14,20721,20722],{},"tag_name = \"v{new_version}\""," guarantees the git tag matches ",[14,20725,29],{},". CI that builds on tag push should assert this before publishing.",[44,20728,20729,20732,20733,20735,20736,20738],{},[72,20730,20731],{},"Don't read the version with regex."," Parsing ",[14,20734,29],{}," at runtime to extract the version is fragile and adds a startup file read. ",[14,20737,852],{}," is the supported path and works from the installed wheel.",[44,20740,20741,765,20744,20747,20748,20751],{},[72,20742,20743],{},"Performance.",[14,20745,20746],{},"importlib.metadata.version()"," does a small metadata lookup on each call. For a CLI it's negligible, but call it once at import time (as in ",[14,20749,20750],{},"_version.py",") rather than per-command.",[44,20753,20754,20759,20760,18950,20762,20765,20766,1993,20768,20770],{},[72,20755,20756,20758],{},[14,20757,18833],{}," and Poetry both honor the same metadata."," Whether you manage deps with ",[97,20761,18833],{"href":19010},[97,20763,20764],{"href":258},"Poetry",", the version lives in ",[14,20767,29],{},[14,20769,852],{}," reads it identically — the runtime pattern doesn't change.",[44,20772,20773,20776,20777,20780,20781,20783],{},[72,20774,20775],{},"Pre-1.0."," Under SemVer, ",[14,20778,20779],{},"0.x"," lets you break things in MINOR bumps. State this in your README so users know ",[14,20782,20779],{}," is unstable by design.",[36,20785,1277],{"id":1276},[41,20787,20788,20792,20796,20800],{},[44,20789,20790],{},[97,20791,1423],{"href":1422},[44,20793,20794],{},[97,20795,259],{"href":258},[44,20797,20798],{},[97,20799,19011],{"href":19010},[44,20801,20802],{},[97,20803,5],{"href":9070},[1303,20805,13056],{},{"title":131,"searchDepth":152,"depth":152,"links":20807},[20808,20809,20810,20811,20813,20814,20815,20816,20817,20818],{"id":38,"depth":152,"text":39},{"id":19331,"depth":152,"text":19332},{"id":19383,"depth":152,"text":19384},{"id":19641,"depth":152,"text":20812},"Exposing --version",{"id":19919,"depth":152,"text":19920},{"id":20191,"depth":152,"text":19317},{"id":20297,"depth":152,"text":20298},{"id":18869,"depth":152,"text":18870},{"id":4511,"depth":152,"text":4512},{"id":1276,"depth":152,"text":1277},"Manage Python CLI versioning with semantic versioning, bump-my-version, and automated CHANGELOG generation using Conventional Commits and git-cliff.",{},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs",{"title":19249,"description":20819},"project-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Findex",[20825,20826,20827,20191],"versioning","changelogs","semver","4SFqV6MTEWrATaFgqHDdypVrwTmZ2lUuCnVhc2Fl7G0",{"id":20830,"title":20831,"body":20832,"date":1320,"description":22096,"difficulty":1322,"draft":1323,"extension":1324,"meta":22097,"navigation":183,"path":22098,"seo":22099,"stem":22100,"tags":22101,"updated":1320,"__hash__":22103},"content\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development\u002Findex.md","Poetry Workflows for CLI Development",{"type":7,"value":20833,"toc":22085},[20834,20850,20852,20976,20985,20991,20995,21011,21333,21364,21368,21389,21580,21602,21606,21625,21674,21685,21700,21704,21723,21790,21801,21805,21828,21911,21939,21943,21946,21978,21983,21985,22062,22064,22082],[10,20835,20836,20837,20840,20841,20844,20845,20847,20848,61],{},"Poetry gives a Python CLI project one tool for everything between the first ",[14,20838,20839],{},"poetry new"," and the ",[14,20842,20843],{},"poetry publish"," that ships it to PyPI: a reproducible lock file, isolated dependency groups for dev and test tooling, a declarative console-script entry point, and a build backend that produces wheels. This article walks the full loop for a real CLI, using a modern PEP 621 ",[14,20846,29],{},", and shows where Poetry differs from ",[97,20849,18833],{"href":19010},[36,20851,39],{"id":38},[126,20853,20855],{"className":634,"code":20854,"language":636,"meta":131,"style":131},"poetry new --src weatherly          # scaffold a src-layout package\npoetry add typer httpx rich         # runtime deps -> [project.dependencies]\npoetry add --group dev ruff mypy    # tooling, excluded from the published wheel\npoetry add --group test pytest pytest-cov\npoetry install                      # resolve, write poetry.lock, install into the venv\npoetry run weatherly now Lisbon     # run the console-script entry point\npoetry build                        # produce sdist + wheel in dist\u002F\npoetry publish                      # upload to PyPI\n",[14,20856,20857,20873,20891,20912,20929,20938,20956,20966],{"__ignoreMap":131},[135,20858,20859,20861,20864,20867,20870],{"class":137,"line":138},[135,20860,19245],{"class":145},[135,20862,20863],{"class":158}," new",[135,20865,20866],{"class":350}," --src",[135,20868,20869],{"class":158}," weatherly",[135,20871,20872],{"class":669},"          # scaffold a src-layout package\n",[135,20874,20875,20877,20879,20882,20885,20888],{"class":137,"line":152},[135,20876,19245],{"class":145},[135,20878,14168],{"class":158},[135,20880,20881],{"class":158}," typer",[135,20883,20884],{"class":158}," httpx",[135,20886,20887],{"class":158}," rich",[135,20889,20890],{"class":669},"         # runtime deps -> [project.dependencies]\n",[135,20892,20893,20895,20897,20900,20903,20906,20909],{"class":137,"line":162},[135,20894,19245],{"class":145},[135,20896,14168],{"class":158},[135,20898,20899],{"class":350}," --group",[135,20901,20902],{"class":158}," dev",[135,20904,20905],{"class":158}," ruff",[135,20907,20908],{"class":158}," mypy",[135,20910,20911],{"class":669},"    # tooling, excluded from the published wheel\n",[135,20913,20914,20916,20918,20920,20923,20926],{"class":137,"line":171},[135,20915,19245],{"class":145},[135,20917,14168],{"class":158},[135,20919,20899],{"class":350},[135,20921,20922],{"class":158}," test",[135,20924,20925],{"class":158}," pytest",[135,20927,20928],{"class":158}," pytest-cov\n",[135,20930,20931,20933,20935],{"class":137,"line":180},[135,20932,19245],{"class":145},[135,20934,18847],{"class":158},[135,20936,20937],{"class":669},"                      # resolve, write poetry.lock, install into the venv\n",[135,20939,20940,20942,20945,20947,20950,20953],{"class":137,"line":187},[135,20941,19245],{"class":145},[135,20943,20944],{"class":158}," run",[135,20946,20869],{"class":158},[135,20948,20949],{"class":158}," now",[135,20951,20952],{"class":158}," Lisbon",[135,20954,20955],{"class":669},"     # run the console-script entry point\n",[135,20957,20958,20960,20963],{"class":137,"line":201},[135,20959,19245],{"class":145},[135,20961,20962],{"class":158}," build",[135,20964,20965],{"class":669},"                        # produce sdist + wheel in dist\u002F\n",[135,20967,20968,20970,20973],{"class":137,"line":210},[135,20969,19245],{"class":145},[135,20971,20972],{"class":158}," publish",[135,20974,20975],{"class":669},"                      # upload to PyPI\n",[10,20977,20978,20979,12996,20981,20984],{},"The console-script name comes from ",[14,20980,56],{},[14,20982,20983],{},"poetry.lock"," pins the whole transitive graph so every machine resolves identically.",[10,20986,20987],{},[104,20988],{"alt":20989,"src":20990},"Diagram: the Poetry CLI lifecycle left to right — poetry init, add, lock (poetry.lock), install, build, and finally publish to PyPI.","\u002Fillustrations\u002Fpoetry-workflow.svg",[36,20992,20994],{"id":20993},"a-modern-poetry-pyprojecttoml-for-a-cli","A modern Poetry pyproject.toml for a CLI",[10,20996,20997,20998,21001,21002,21004,21005,21008,21009,61],{},"Poetry 2.x reads standard PEP 621 metadata from the ",[14,20999,21000],{},"[project]"," table. Put runtime metadata and dependencies there, declare the entry point under ",[14,21003,56],{},", and reserve ",[14,21006,21007],{},"[tool.poetry]"," for the few Poetry-specific knobs that PEP 621 has no field for — most notably the package layout and the dependency ",[23,21010,783],{},[126,21012,21014],{"className":128,"code":21013,"language":130,"meta":131,"style":131},"[project]\nname = \"weatherly\"\nversion = \"0.3.0\"\ndescription = \"A friendly command-line weather client.\"\nauthors = [{ name = \"Ada Lovelace\", email = \"ada@example.com\" }]\nreadme = \"README.md\"\nrequires-python = \">=3.9\"\nlicense = \"MIT\"\nkeywords = [\"cli\", \"weather\", \"typer\"]\ndependencies = [\n    \"typer>=0.12.0\",\n    \"httpx>=0.27.0\",\n    \"rich>=13.7.0\",\n]\n\n[project.urls]\nHomepage = \"https:\u002F\u002Fgithub.com\u002Fada\u002Fweatherly\"\nIssues = \"https:\u002F\u002Fgithub.com\u002Fada\u002Fweatherly\u002Fissues\"\n\n[project.scripts]\nweatherly = \"weatherly.cli:app\"\n\n[tool.poetry]\npackages = [{ include = \"weatherly\", from = \"src\" }]\n\n[tool.poetry.group.dev.dependencies]\nruff = \"^0.5.0\"\nmypy = \"^1.10.0\"\n\n[tool.poetry.group.test.dependencies]\npytest = \"^8.2.0\"\npytest-cov = \"^5.0.0\"\n\n[build-system]\nrequires = [\"poetry-core>=2.0.0\"]\nbuild-backend = \"poetry.core.masonry.api\"\n",[14,21015,21016,21024,21031,21037,21044,21058,21064,21071,21079,21098,21102,21109,21116,21123,21127,21131,21144,21152,21160,21164,21176,21184,21188,21200,21215,21219,21244,21252,21260,21264,21289,21297,21305,21309,21317,21326],{"__ignoreMap":131},[135,21017,21018,21020,21022],{"class":137,"line":138},[135,21019,142],{"class":141},[135,21021,146],{"class":145},[135,21023,149],{"class":141},[135,21025,21026,21028],{"class":137,"line":152},[135,21027,155],{"class":141},[135,21029,21030],{"class":158},"\"weatherly\"\n",[135,21032,21033,21035],{"class":137,"line":162},[135,21034,165],{"class":141},[135,21036,19433],{"class":158},[135,21038,21039,21041],{"class":137,"line":171},[135,21040,17792],{"class":141},[135,21042,21043],{"class":158},"\"A friendly command-line weather client.\"\n",[135,21045,21046,21048,21051,21053,21056],{"class":137,"line":180},[135,21047,17800],{"class":141},[135,21049,21050],{"class":158},"\"Ada Lovelace\"",[135,21052,17806],{"class":141},[135,21054,21055],{"class":158},"\"ada@example.com\"",[135,21057,17812],{"class":141},[135,21059,21060,21062],{"class":137,"line":187},[135,21061,17824],{"class":141},[135,21063,17827],{"class":158},[135,21065,21066,21068],{"class":137,"line":201},[135,21067,174],{"class":141},[135,21069,21070],{"class":158},"\">=3.9\"\n",[135,21072,21073,21076],{"class":137,"line":210},[135,21074,21075],{"class":141},"license = ",[135,21077,21078],{"class":158},"\"MIT\"\n",[135,21080,21081,21084,21087,21089,21092,21094,21096],{"class":137,"line":215},[135,21082,21083],{"class":141},"keywords = [",[135,21085,21086],{"class":158},"\"cli\"",[135,21088,861],{"class":141},[135,21090,21091],{"class":158},"\"weather\"",[135,21093,861],{"class":141},[135,21095,17624],{"class":158},[135,21097,149],{"class":141},[135,21099,21100],{"class":137,"line":225},[135,21101,17843],{"class":141},[135,21103,21104,21107],{"class":137,"line":236},[135,21105,21106],{"class":158},"    \"typer>=0.12.0\"",[135,21108,3238],{"class":141},[135,21110,21111,21114],{"class":137,"line":606},[135,21112,21113],{"class":158},"    \"httpx>=0.27.0\"",[135,21115,3238],{"class":141},[135,21117,21118,21121],{"class":137,"line":619},[135,21119,21120],{"class":158},"    \"rich>=13.7.0\"",[135,21122,3238],{"class":141},[135,21124,21125],{"class":137,"line":1752},[135,21126,149],{"class":141},[135,21128,21129],{"class":137,"line":1765},[135,21130,184],{"emptyLinePlaceholder":183},[135,21132,21133,21135,21137,21139,21142],{"class":137,"line":1774},[135,21134,142],{"class":141},[135,21136,146],{"class":145},[135,21138,61],{"class":141},[135,21140,21141],{"class":145},"urls",[135,21143,149],{"class":141},[135,21145,21146,21149],{"class":137,"line":1795},[135,21147,21148],{"class":141},"Homepage = ",[135,21150,21151],{"class":158},"\"https:\u002F\u002Fgithub.com\u002Fada\u002Fweatherly\"\n",[135,21153,21154,21157],{"class":137,"line":1830},[135,21155,21156],{"class":141},"Issues = ",[135,21158,21159],{"class":158},"\"https:\u002F\u002Fgithub.com\u002Fada\u002Fweatherly\u002Fissues\"\n",[135,21161,21162],{"class":137,"line":1846},[135,21163,184],{"emptyLinePlaceholder":183},[135,21165,21166,21168,21170,21172,21174],{"class":137,"line":1854},[135,21167,142],{"class":141},[135,21169,146],{"class":145},[135,21171,61],{"class":141},[135,21173,196],{"class":145},[135,21175,149],{"class":141},[135,21177,21178,21181],{"class":137,"line":1859},[135,21179,21180],{"class":141},"weatherly = ",[135,21182,21183],{"class":158},"\"weatherly.cli:app\"\n",[135,21185,21186],{"class":137,"line":1877},[135,21187,184],{"emptyLinePlaceholder":183},[135,21189,21190,21192,21194,21196,21198],{"class":137,"line":1893},[135,21191,142],{"class":141},[135,21193,11953],{"class":145},[135,21195,61],{"class":141},[135,21197,19245],{"class":145},[135,21199,149],{"class":141},[135,21201,21202,21205,21208,21211,21213],{"class":137,"line":1926},[135,21203,21204],{"class":141},"packages = [{ include = ",[135,21206,21207],{"class":158},"\"weatherly\"",[135,21209,21210],{"class":141},", from = ",[135,21212,17996],{"class":158},[135,21214,17812],{"class":141},[135,21216,21217],{"class":137,"line":1940},[135,21218,184],{"emptyLinePlaceholder":183},[135,21220,21221,21223,21225,21227,21229,21231,21233,21235,21238,21240,21242],{"class":137,"line":2906},[135,21222,142],{"class":141},[135,21224,11953],{"class":145},[135,21226,61],{"class":141},[135,21228,19245],{"class":145},[135,21230,61],{"class":141},[135,21232,915],{"class":145},[135,21234,61],{"class":141},[135,21236,21237],{"class":145},"dev",[135,21239,61],{"class":141},[135,21241,9357],{"class":145},[135,21243,149],{"class":141},[135,21245,21246,21249],{"class":137,"line":2931},[135,21247,21248],{"class":141},"ruff = ",[135,21250,21251],{"class":158},"\"^0.5.0\"\n",[135,21253,21254,21257],{"class":137,"line":2944},[135,21255,21256],{"class":141},"mypy = ",[135,21258,21259],{"class":158},"\"^1.10.0\"\n",[135,21261,21262],{"class":137,"line":3266},[135,21263,184],{"emptyLinePlaceholder":183},[135,21265,21266,21268,21270,21272,21274,21276,21278,21280,21283,21285,21287],{"class":137,"line":3277},[135,21267,142],{"class":141},[135,21269,11953],{"class":145},[135,21271,61],{"class":141},[135,21273,19245],{"class":145},[135,21275,61],{"class":141},[135,21277,915],{"class":145},[135,21279,61],{"class":141},[135,21281,21282],{"class":145},"test",[135,21284,61],{"class":141},[135,21286,9357],{"class":145},[135,21288,149],{"class":141},[135,21290,21291,21294],{"class":137,"line":3290},[135,21292,21293],{"class":141},"pytest = ",[135,21295,21296],{"class":158},"\"^8.2.0\"\n",[135,21298,21299,21302],{"class":137,"line":3295},[135,21300,21301],{"class":141},"pytest-cov = ",[135,21303,21304],{"class":158},"\"^5.0.0\"\n",[135,21306,21307],{"class":137,"line":3315},[135,21308,184],{"emptyLinePlaceholder":183},[135,21310,21311,21313,21315],{"class":137,"line":3329},[135,21312,142],{"class":141},[135,21314,220],{"class":145},[135,21316,149],{"class":141},[135,21318,21319,21321,21324],{"class":137,"line":3337},[135,21320,228],{"class":141},[135,21322,21323],{"class":158},"\"poetry-core>=2.0.0\"",[135,21325,149],{"class":141},[135,21327,21328,21330],{"class":137,"line":3350},[135,21329,239],{"class":141},[135,21331,21332],{"class":158},"\"poetry.core.masonry.api\"\n",[10,21334,21335,21336,21339,21340,21343,21344,21347,21348,21350,21351,21354,21355,21357,21358,21361,21362,61],{},"Two things are worth noting. First, runtime dependencies live in ",[14,21337,21338],{},"[project.dependencies]"," as PEP 508 strings (",[14,21341,21342],{},"typer>=0.12.0","), not in the old ",[14,21345,21346],{},"[tool.poetry.dependencies]"," caret table — that legacy form still works but the standard table is the forward-compatible choice. Second, ",[14,21349,56],{}," points at ",[14,21352,21353],{},"weatherly.cli:app",": the module path, a colon, then the callable. For a Typer app that callable ",[23,21356,5297],{}," the ",[14,21359,21360],{},"Typer()"," instance, because Typer is itself callable; for a Click group or a plain function you point at the function. The build backend turns that mapping into an executable on the user's ",[14,21363,33],{},[36,21365,21367],{"id":21366},"the-entry-point-and-the-cli-it-launches","The entry point and the CLI it launches",[10,21369,111,21370,21373,21374,21377,21378,21381,21382,21384,21385,21388],{},[14,21371,21372],{},"weatherly = \"weatherly.cli:app\""," line is the contract: when the wheel installs, the packaging machinery generates a ",[14,21375,21376],{},"weatherly"," launcher that imports ",[14,21379,21380],{},"weatherly.cli"," and calls ",[14,21383,447],{},". Here is the module it resolves to (",[14,21386,21387],{},"src\u002Fweatherly\u002Fcli.py","):",[126,21390,21392],{"className":316,"code":21391,"language":318,"meta":131,"style":131},"import typer\nfrom rich.console import Console\n\napp = typer.Typer(help=\"A friendly command-line weather client.\")\nconsole = Console()\n\n\n@app.command()\ndef now(city: str, units: str = \"metric\") -> None:\n    \"\"\"Show the current weather for CITY.\"\"\"\n    console.print(f\"[bold]{city}[\u002Fbold]: 21 degrees ({units})\")\n\n\n@app.command()\ndef version() -> None:\n    \"\"\"Print the installed version.\"\"\"\n    console.print(\"weatherly 0.3.0\")\n\n\nif __name__ == \"__main__\":\n    app()\n",[14,21393,21394,21400,21410,21414,21431,21439,21443,21447,21453,21480,21485,21515,21519,21523,21529,21542,21547,21556,21560,21564,21576],{"__ignoreMap":131},[135,21395,21396,21398],{"class":137,"line":138},[135,21397,326],{"class":325},[135,21399,2056],{"class":141},[135,21401,21402,21404,21406,21408],{"class":137,"line":152},[135,21403,334],{"class":325},[135,21405,8121],{"class":141},[135,21407,326],{"class":325},[135,21409,8126],{"class":141},[135,21411,21412],{"class":137,"line":162},[135,21413,184],{"emptyLinePlaceholder":183},[135,21415,21416,21418,21420,21422,21424,21426,21429],{"class":137,"line":171},[135,21417,16085],{"class":141},[135,21419,516],{"class":325},[135,21421,16559],{"class":141},[135,21423,13713],{"class":914},[135,21425,516],{"class":325},[135,21427,21428],{"class":158},"\"A friendly command-line weather client.\"",[135,21430,550],{"class":141},[135,21432,21433,21435,21437],{"class":137,"line":180},[135,21434,8155],{"class":141},[135,21436,516],{"class":325},[135,21438,8516],{"class":141},[135,21440,21441],{"class":137,"line":187},[135,21442,184],{"emptyLinePlaceholder":183},[135,21444,21445],{"class":137,"line":201},[135,21446,184],{"emptyLinePlaceholder":183},[135,21448,21449,21451],{"class":137,"line":210},[135,21450,2132],{"class":145},[135,21452,2135],{"class":141},[135,21454,21455,21457,21459,21462,21464,21467,21469,21471,21474,21476,21478],{"class":137,"line":215},[135,21456,493],{"class":325},[135,21458,20949],{"class":145},[135,21460,21461],{"class":141},"(city: ",[135,21463,1663],{"class":350},[135,21465,21466],{"class":141},", units: ",[135,21468,1663],{"class":350},[135,21470,2150],{"class":325},[135,21472,21473],{"class":158}," \"metric\"",[135,21475,1788],{"class":141},[135,21477,3093],{"class":350},[135,21479,360],{"class":141},[135,21481,21482],{"class":137,"line":225},[135,21483,21484],{"class":158},"    \"\"\"Show the current weather for CITY.\"\"\"\n",[135,21486,21487,21490,21492,21494,21496,21499,21501,21504,21506,21509,21511,21513],{"class":137,"line":236},[135,21488,21489],{"class":141},"    console.print(",[135,21491,568],{"class":325},[135,21493,7552],{"class":158},[135,21495,574],{"class":350},[135,21497,21498],{"class":141},"city",[135,21500,586],{"class":350},[135,21502,21503],{"class":158},"[\u002Fbold]: 21 degrees (",[135,21505,574],{"class":350},[135,21507,21508],{"class":141},"units",[135,21510,586],{"class":350},[135,21512,3235],{"class":158},[135,21514,550],{"class":141},[135,21516,21517],{"class":137,"line":606},[135,21518,184],{"emptyLinePlaceholder":183},[135,21520,21521],{"class":137,"line":619},[135,21522,184],{"emptyLinePlaceholder":183},[135,21524,21525,21527],{"class":137,"line":1752},[135,21526,2132],{"class":145},[135,21528,2135],{"class":141},[135,21530,21531,21533,21536,21538,21540],{"class":137,"line":1765},[135,21532,493],{"class":325},[135,21534,21535],{"class":145}," version",[135,21537,499],{"class":141},[135,21539,3093],{"class":350},[135,21541,360],{"class":141},[135,21543,21544],{"class":137,"line":1774},[135,21545,21546],{"class":158},"    \"\"\"Print the installed version.\"\"\"\n",[135,21548,21549,21551,21554],{"class":137,"line":1795},[135,21550,21489],{"class":141},[135,21552,21553],{"class":158},"\"weatherly 0.3.0\"",[135,21555,550],{"class":141},[135,21557,21558],{"class":137,"line":1830},[135,21559,184],{"emptyLinePlaceholder":183},[135,21561,21562],{"class":137,"line":1846},[135,21563,184],{"emptyLinePlaceholder":183},[135,21565,21566,21568,21570,21572,21574],{"class":137,"line":1854},[135,21567,347],{"class":325},[135,21569,351],{"class":350},[135,21571,354],{"class":325},[135,21573,357],{"class":158},[135,21575,360],{"class":141},[135,21577,21578],{"class":137,"line":1859},[135,21579,16270],{"class":141},[10,21581,111,21582,21585,21586,21589,21590,21592,21593,21595,21596,21598,21599,21601],{},[14,21583,21584],{},"if __name__ == \"__main__\""," block lets you run ",[14,21587,21588],{},"python -m weatherly.cli"," during local hacking, while ",[14,21591,56],{}," provides the installed ",[14,21594,21376],{}," command. Keeping both is a common pattern — see ",[97,21597,9071],{"href":9070}," for why you generally point the script at the Typer\u002FClick object rather than a bespoke ",[14,21600,391],{}," wrapper.",[36,21603,21605],{"id":21604},"poetry-lock-and-poetry-install","poetry lock and poetry install",[10,21607,21608,21611,21612,21614,21615,21618,21619,21621,21622,473],{},[14,21609,21610],{},"poetry install"," does two jobs. If ",[14,21613,20983],{}," is missing or stale, it resolves the dependency graph and writes a fresh lock; then it installs that exact graph into the project's virtual environment, including your package in editable mode. To resolve ",[23,21616,21617],{},"without"," installing — useful in CI or after editing ",[14,21620,29],{}," — run ",[14,21623,21624],{},"poetry lock",[126,21626,21628],{"className":634,"code":21627,"language":636,"meta":131,"style":131},"poetry lock              # resolve the graph, write\u002Fupdate poetry.lock only\npoetry install           # install the locked graph (creates the venv if needed)\npoetry install --sync    # additionally remove anything not in the lock\npoetry check --lock      # fail fast if pyproject.toml and poetry.lock disagree\n",[14,21629,21630,21640,21649,21661],{"__ignoreMap":131},[135,21631,21632,21634,21637],{"class":137,"line":138},[135,21633,19245],{"class":145},[135,21635,21636],{"class":158}," lock",[135,21638,21639],{"class":669},"              # resolve the graph, write\u002Fupdate poetry.lock only\n",[135,21641,21642,21644,21646],{"class":137,"line":152},[135,21643,19245],{"class":145},[135,21645,18847],{"class":158},[135,21647,21648],{"class":669},"           # install the locked graph (creates the venv if needed)\n",[135,21650,21651,21653,21655,21658],{"class":137,"line":162},[135,21652,19245],{"class":145},[135,21654,18847],{"class":158},[135,21656,21657],{"class":350}," --sync",[135,21659,21660],{"class":669},"    # additionally remove anything not in the lock\n",[135,21662,21663,21665,21668,21671],{"class":137,"line":171},[135,21664,19245],{"class":145},[135,21666,21667],{"class":158}," check",[135,21669,21670],{"class":350}," --lock",[135,21672,21673],{"class":669},"      # fail fast if pyproject.toml and poetry.lock disagree\n",[10,21675,21676,21678,21679,21681,21682,21684],{},[14,21677,20983],{}," pins every transitive package to an exact version and records content hashes, so a teammate or a CI runner that runs ",[14,21680,21610],{}," gets a byte-identical environment. Commit the lock file for applications and CLIs — it is the reproducibility guarantee. (For libraries meant to be imported into other projects, the lock is still convenient locally but the published constraints come from ",[14,21683,21338],{},".)",[10,21686,21687,21688,21691,21692,21695,21696,21699],{},"Because the lock captures the ",[23,21689,21690],{},"resolved"," graph, you change versions by editing constraints and re-locking, not by hand-editing the lock. ",[14,21693,21694],{},"poetry add httpx@^0.28"," bumps the constraint and re-resolves in one step; ",[14,21697,21698],{},"poetry update httpx"," re-resolves within the existing constraint to pick up a newer compatible release.",[36,21701,21703],{"id":21702},"dependency-groups-main-vs-dev-vs-test","Dependency groups: main vs dev vs test",[10,21705,111,21706,21708,21709,21711,21712,21715,21716,21718,21719,21722],{},[14,21707,9357],{}," array under ",[14,21710,21000],{}," is the ",[23,21713,21714],{},"main"," group — these are the only packages installed when someone ",[14,21717,16],{},"s your wheel. Everything a contributor needs but a user does not — linters, type checkers, the test runner — belongs in named groups under ",[14,21720,21721],{},"[tool.poetry.group.\u003Cname>.dependencies]",". They are recorded in the lock for reproducibility but never leak into the published artifact.",[126,21724,21726],{"className":634,"code":21725,"language":636,"meta":131,"style":131},"poetry install                                  # main + all non-optional groups\npoetry install --only main                      # runtime deps only (mimics a user install)\npoetry install --with test                      # main + the test group\npoetry install --without dev                    # everything except dev tooling\npoetry run pytest                               # the test group is now importable\n",[14,21727,21728,21737,21751,21765,21779],{"__ignoreMap":131},[135,21729,21730,21732,21734],{"class":137,"line":138},[135,21731,19245],{"class":145},[135,21733,18847],{"class":158},[135,21735,21736],{"class":669},"                                  # main + all non-optional groups\n",[135,21738,21739,21741,21743,21746,21748],{"class":137,"line":152},[135,21740,19245],{"class":145},[135,21742,18847],{"class":158},[135,21744,21745],{"class":350}," --only",[135,21747,496],{"class":158},[135,21749,21750],{"class":669},"                      # runtime deps only (mimics a user install)\n",[135,21752,21753,21755,21757,21760,21762],{"class":137,"line":162},[135,21754,19245],{"class":145},[135,21756,18847],{"class":158},[135,21758,21759],{"class":350}," --with",[135,21761,20922],{"class":158},[135,21763,21764],{"class":669},"                      # main + the test group\n",[135,21766,21767,21769,21771,21774,21776],{"class":137,"line":171},[135,21768,19245],{"class":145},[135,21770,18847],{"class":158},[135,21772,21773],{"class":350}," --without",[135,21775,20902],{"class":158},[135,21777,21778],{"class":669},"                    # everything except dev tooling\n",[135,21780,21781,21783,21785,21787],{"class":137,"line":180},[135,21782,19245],{"class":145},[135,21784,20944],{"class":158},[135,21786,20925],{"class":158},[135,21788,21789],{"class":669},"                               # the test group is now importable\n",[10,21791,21792,21793,21796,21797,21800],{},"In CI this maps cleanly onto stages: a lint job runs ",[14,21794,21795],{},"poetry install --only main,dev",", a test job runs ",[14,21798,21799],{},"poetry install --with test",". Groups are also how you keep heavy optional tooling (docs builders, profilers) out of the default contributor install.",[36,21802,21804],{"id":21803},"building-and-publishing-to-pypi","Building and publishing to PyPI",[10,21806,21807,21810,21811,21813,21814,21817,21818,21820,21821,21823,21824,21827],{},[14,21808,21809],{},"poetry build"," invokes ",[14,21812,251],{}," to produce both a source distribution and a wheel under ",[14,21815,21816],{},"dist\u002F",". ",[14,21819,20843],{}," uploads whatever is in ",[14,21822,21816],{},"; pass ",[14,21825,21826],{},"--build"," to do both in one step.",[126,21829,21831],{"className":634,"code":21830,"language":636,"meta":131,"style":131},"poetry build                                    # -> dist\u002Fweatherly-0.3.0.tar.gz + .whl\npoetry publish --build                          # build, then upload to PyPI\n\n# First, validate against TestPyPI:\npoetry config repositories.testpypi https:\u002F\u002Ftest.pypi.org\u002Flegacy\u002F\npoetry publish --build --repository testpypi\n\n# Authenticate with a PyPI API token (recommended over passwords):\npoetry config pypi-token.pypi pypi-AgEN...\n",[14,21832,21833,21842,21854,21858,21863,21876,21890,21894,21899],{"__ignoreMap":131},[135,21834,21835,21837,21839],{"class":137,"line":138},[135,21836,19245],{"class":145},[135,21838,20962],{"class":158},[135,21840,21841],{"class":669},"                                    # -> dist\u002Fweatherly-0.3.0.tar.gz + .whl\n",[135,21843,21844,21846,21848,21851],{"class":137,"line":152},[135,21845,19245],{"class":145},[135,21847,20972],{"class":158},[135,21849,21850],{"class":350}," --build",[135,21852,21853],{"class":669},"                          # build, then upload to PyPI\n",[135,21855,21856],{"class":137,"line":162},[135,21857,184],{"emptyLinePlaceholder":183},[135,21859,21860],{"class":137,"line":171},[135,21861,21862],{"class":669},"# First, validate against TestPyPI:\n",[135,21864,21865,21867,21870,21873],{"class":137,"line":180},[135,21866,19245],{"class":145},[135,21868,21869],{"class":158}," config",[135,21871,21872],{"class":158}," repositories.testpypi",[135,21874,21875],{"class":158}," https:\u002F\u002Ftest.pypi.org\u002Flegacy\u002F\n",[135,21877,21878,21880,21882,21884,21887],{"class":137,"line":187},[135,21879,19245],{"class":145},[135,21881,20972],{"class":158},[135,21883,21850],{"class":350},[135,21885,21886],{"class":350}," --repository",[135,21888,21889],{"class":158}," testpypi\n",[135,21891,21892],{"class":137,"line":201},[135,21893,184],{"emptyLinePlaceholder":183},[135,21895,21896],{"class":137,"line":210},[135,21897,21898],{"class":669},"# Authenticate with a PyPI API token (recommended over passwords):\n",[135,21900,21901,21903,21905,21908],{"class":137,"line":215},[135,21902,19245],{"class":145},[135,21904,21869],{"class":158},[135,21906,21907],{"class":158}," pypi-token.pypi",[135,21909,21910],{"class":158}," pypi-AgEN...\n",[10,21912,21913,21914,13602,21917,459,21920,21923,21924,21926,21927,21929,21930,21935,21936,21938],{},"Bump the version before publishing — ",[14,21915,21916],{},"poetry version patch",[14,21918,21919],{},"minor",[14,21921,21922],{},"major",") rewrites ",[14,21925,13668],{}," in ",[14,21928,21000],{}," for you. For a real release pipeline, prefer PyPI's ",[97,21931,21934],{"href":21932,"rel":21933},"https:\u002F\u002Fdocs.pypi.org\u002Ftrusted-publishers\u002F",[4654],"trusted publishing"," (OIDC) from CI so no long-lived token is stored at all; ",[14,21937,20843],{}," works under it because the upload still goes through the standard endpoint.",[36,21940,21942],{"id":21941},"poetry-vs-uv-when-to-reach-for-which","Poetry vs uv: when to reach for which",[10,21944,21945],{},"uv is a single Rust binary that resolves and installs at a different speed class, and it manages the Python interpreter itself. Poetry is the mature, batteries-included incumbent with a long-stable publishing story and the richest dependency-group ergonomics. The practical differences:",[41,21947,21948,21954,21962,21968],{},[44,21949,21950,21953],{},[72,21951,21952],{},"Speed."," uv's resolver and installer are dramatically faster; on a cold cache the gap is large, and it shows up most in CI.",[44,21955,21956,21959,21960,1516],{},[72,21957,21958],{},"Interpreter management."," uv downloads and pins Python versions; Poetry expects an interpreter to already exist (often via ",[14,21961,19121],{},[44,21963,21964,21967],{},[72,21965,21966],{},"Lock format."," Both produce a committed lock, but they are not interchangeable — pick one per project.",[44,21969,21970,21973,21974,21977],{},[72,21971,21972],{},"Maturity."," Poetry's ",[14,21975,21976],{},"publish",", plugin ecosystem, and group syntax are battle-tested across years of releases.",[10,21979,21980,21981,61],{},"If you want raw speed and unified interpreter+package management, lean uv. If you want a stable, well-documented workflow with first-class dependency groups and a publishing command you can set and forget, Poetry is a safe default. For a side-by-side of the scaffolding step specifically, see ",[97,21982,19091],{"href":19090},[36,21984,4512],{"id":4511},[41,21986,21987,22004,22017,22028,22050],{},[44,21988,21989,21994,21995,21997,21998,22000,22001,61],{},[72,21990,21991,21992,61],{},"Commit ",[14,21993,20983],{}," It is the only thing that makes ",[14,21996,21610],{}," reproducible across machines; treat a drift between it and ",[14,21999,29],{}," as a CI failure via ",[14,22002,22003],{},"poetry check --lock",[44,22005,22006,765,22009,22012,22013,22016],{},[72,22007,22008],{},"Use the in-project venv in CI.",[14,22010,22011],{},"poetry config virtualenvs.in-project true"," puts the environment under ",[14,22014,22015],{},".venv\u002F",", which caches cleanly between runs.",[44,22018,22019,22027],{},[72,22020,22021,22022,22024,22025,61],{},"Pin ",[14,22023,251],{},", not Poetry, in ",[14,22026,220],{}," The build backend version is what affects your wheel; the Poetry CLI version is a developer concern.",[44,22029,22030,22036,22037,22039,22040,22042,22043,22046,22047,22049],{},[72,22031,22032,22033,22035],{},"Test the ",[23,22034,11739],{}," entry point",", not just ",[14,22038,13515],{},". After ",[14,22041,21610],{},", run ",[14,22044,22045],{},"poetry run weatherly --help"," to confirm the ",[14,22048,56],{}," mapping actually resolves — a typo there only surfaces at install time.",[44,22051,22052,22055,22056,18950,22058,22061],{},[72,22053,22054],{},"Don't hand-edit the lock."," Re-resolve with ",[14,22057,21624],{},[14,22059,22060],{},"poetry update"," so the hashes stay consistent.",[36,22063,1277],{"id":1276},[41,22065,22066,22070,22074,22078],{},[44,22067,22068],{},[97,22069,1423],{"href":1422},[44,22071,22072],{},[97,22073,19011],{"href":19010},[44,22075,22076],{},[97,22077,19091],{"href":19090},[44,22079,22080],{},[97,22081,5],{"href":9070},[1303,22083,22084],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":131,"searchDepth":152,"depth":152,"links":22086},[22087,22088,22089,22090,22091,22092,22093,22094,22095],{"id":38,"depth":152,"text":39},{"id":20993,"depth":152,"text":20994},{"id":21366,"depth":152,"text":21367},{"id":21604,"depth":152,"text":21605},{"id":21702,"depth":152,"text":21703},{"id":21803,"depth":152,"text":21804},{"id":21941,"depth":152,"text":21942},{"id":4511,"depth":152,"text":4512},{"id":1276,"depth":152,"text":1277},"Use Poetry for Python CLI development — lock file management, script entry points, dependency groups, and automated publishing to PyPI.",{},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development",{"title":20831,"description":22096},"project-setup-dependency-management\u002Fpoetry-workflows-for-cli-development\u002Findex",[19245,9357,13073,22102],"publishing","dbUhIY9UxVhRy9-k2wtIrItEc-kmYR2wdepZBHYib2Q",{"id":22105,"title":22106,"body":22107,"date":1320,"description":22381,"difficulty":1457,"draft":1323,"extension":1324,"meta":22382,"navigation":183,"path":22383,"seo":22384,"stem":22385,"tags":22386,"updated":1320,"__hash__":22388},"content\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects\u002Findex.md","Pre-commit Hooks for CLI Projects",{"type":7,"value":22108,"toc":22373},[22109,22115,22119,22138,22144,22148,22154,22157,22188,22191,22195,22212,22311,22314,22318,22328,22332,22335,22351,22353,22370],[10,22110,22111,22112,22114],{},"A command-line tool is only as trustworthy as the code behind it. When users ",[14,22113,16],{}," your CLI and run it in their own pipelines, a regression you never caught locally becomes a broken build on someone else's machine. Pre-commit hooks close that gap: they run your linter, formatter, type checker, and tests automatically before any commit lands, so the messy work-in-progress never reaches your history. This hub explains what pre-commit is, why CLI projects benefit from it in particular, and how the individual quality gates fit together.",[36,22116,22118],{"id":22117},"what-pre-commit-actually-is","What pre-commit actually is",[10,22120,22121,22127,22128,22131,22132,22134,22135,22137],{},[97,22122,22125],{"href":22123,"rel":22124},"https:\u002F\u002Fpre-commit.com\u002F",[4654],[14,22126,19210],{}," is a framework — a small Python application — for managing Git hooks. You declare the checks you want in a ",[14,22129,22130],{},".pre-commit-config.yaml"," file at the repo root, and pre-commit installs a Git ",[14,22133,19210],{}," hook that runs them against your staged files every time you ",[14,22136,13556],{},". Crucially, it isolates each tool in its own managed environment, so contributors don't need to install ruff, mypy, or black globally. Clone the repo, run one install command, and everyone runs the exact same versions of the exact same checks.",[10,22139,22140,22141,22143],{},"This matters more for CLI projects than for, say, a one-off script. A CLI is software other people execute. It has an entry point, argument parsing, exit codes, and usually a published package on PyPI. Each of those is a surface where a small mistake — an unhandled ",[14,22142,3093],{},", a misformatted help string, a type error in a Typer callback — ships straight to users. Catching it at commit time is the cheapest possible place to catch it.",[36,22145,22147],{"id":22146},"the-gate-philosophy","The gate philosophy",[10,22149,22150],{},[104,22151],{"alt":22152,"src":22153},"Pipeline triggered on git commit: staged files pass ruff lint, ruff format, mypy, and pytest in order; passing all gates lets the commit land, while any failure blocks the commit.","\u002Fillustrations\u002Fprecommit-gate.svg",[10,22155,22156],{},"Think of pre-commit as a stack of fast, ordered gates. Each gate has one job, and code only proceeds if it passes all of them. For a Python CLI the four gates that earn their keep are:",[41,22158,22159,22165,22171,22183],{},[44,22160,22161,22164],{},[72,22162,22163],{},"Ruff (lint)"," — catches unused imports, undefined names, mutable default arguments, and hundreds of other bugs in milliseconds. It replaces flake8, isort, pyupgrade, and several plugins in a single fast binary.",[44,22166,22167,22170],{},[72,22168,22169],{},"Ruff (format)"," — applies a deterministic, black-compatible code style so diffs stay about logic, not whitespace. Running format and lint from the same tool keeps their rules from fighting each other.",[44,22172,22173,22175,22176,22179,22180,61],{},[72,22174,19156],{}," — verifies your type hints actually hold. For CLIs this is where you catch the ",[14,22177,22178],{},"str | None"," you forgot to guard before passing it to ",[14,22181,22182],{},"Path()",[44,22184,22185,22187],{},[72,22186,19159],{}," — runs your test suite as a final gate. A CLI's behavior (exit codes, stdout, error messages) is best pinned with tests, so a green suite before commit is a strong signal.",[10,22189,22190],{},"The ordering is deliberate: cheap, auto-fixing checks first (format, lint), then static analysis (mypy), then the slower behavioral check (pytest) last. If formatting alone fails, you don't waste time running the whole test suite.",[36,22192,22194],{"id":22193},"local-hooks-vs-ci-hooks","Local hooks vs CI hooks",[10,22196,22197,22198,22201,22202,21817,22205,22208,22209,22211],{},"There are two places these gates run, and you want both. ",[72,22199,22200],{},"Local hooks"," fire on your machine at commit time. They're fast and give instant feedback, but they're easy to bypass — anyone can ",[14,22203,22204],{},"git commit --no-verify",[72,22206,22207],{},"CI hooks"," run the same ",[14,22210,22130],{}," in your continuous-integration pipeline, where bypassing isn't possible. The single source of truth is the config file, so the local and CI runs check identical things:",[126,22213,22215],{"className":5873,"code":22214,"language":5875,"meta":131,"style":131},"jobs:\n  lint:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v5\n      - uses: actions\u002Fsetup-python@v6\n        with:\n          python-version: \"3.12\"\n      - run: pip install pre-commit\n      - run: pre-commit run --all-files --show-diff-on-failure\n",[14,22216,22217,22224,22231,22241,22248,22261,22272,22279,22289,22300],{"__ignoreMap":131},[135,22218,22219,22222],{"class":137,"line":138},[135,22220,22221],{"class":6501},"jobs",[135,22223,360],{"class":141},[135,22225,22226,22229],{"class":137,"line":152},[135,22227,22228],{"class":6501},"  lint",[135,22230,360],{"class":141},[135,22232,22233,22236,22238],{"class":137,"line":162},[135,22234,22235],{"class":6501},"    runs-on",[135,22237,2344],{"class":141},[135,22239,22240],{"class":158},"ubuntu-latest\n",[135,22242,22243,22246],{"class":137,"line":171},[135,22244,22245],{"class":6501},"    steps",[135,22247,360],{"class":141},[135,22249,22250,22253,22256,22258],{"class":137,"line":180},[135,22251,22252],{"class":141},"      - ",[135,22254,22255],{"class":6501},"uses",[135,22257,2344],{"class":141},[135,22259,22260],{"class":158},"actions\u002Fcheckout@v5\n",[135,22262,22263,22265,22267,22269],{"class":137,"line":187},[135,22264,22252],{"class":141},[135,22266,22255],{"class":6501},[135,22268,2344],{"class":141},[135,22270,22271],{"class":158},"actions\u002Fsetup-python@v6\n",[135,22273,22274,22277],{"class":137,"line":201},[135,22275,22276],{"class":6501},"        with",[135,22278,360],{"class":141},[135,22280,22281,22284,22286],{"class":137,"line":210},[135,22282,22283],{"class":6501},"          python-version",[135,22285,2344],{"class":141},[135,22287,22288],{"class":158},"\"3.12\"\n",[135,22290,22291,22293,22295,22297],{"class":137,"line":215},[135,22292,22252],{"class":141},[135,22294,451],{"class":6501},[135,22296,2344],{"class":141},[135,22298,22299],{"class":158},"pip install pre-commit\n",[135,22301,22302,22304,22306,22308],{"class":137,"line":225},[135,22303,22252],{"class":141},[135,22305,451],{"class":6501},[135,22307,2344],{"class":141},[135,22309,22310],{"class":158},"pre-commit run --all-files --show-diff-on-failure\n",[10,22312,22313],{},"Local hooks make the right thing convenient; CI hooks make it mandatory. Use local hooks for fast feedback and CI as the enforcement backstop.",[36,22315,22317],{"id":22316},"a-note-on-versions-and-reproducibility","A note on versions and reproducibility",[10,22319,22320,22321,22324,22325,22327],{},"Pre-commit pins each hook to a specific revision (a Git tag or commit), which is what makes runs reproducible across machines and across time. You upgrade those pins deliberately with ",[14,22322,22323],{},"pre-commit autoupdate"," rather than drifting silently. This pairs naturally with how you manage the rest of your toolchain — see ",[97,22326,19011],{"href":19010}," for keeping the dev dependencies that back these hooks locked and reproducible too.",[36,22329,22331],{"id":22330},"where-to-go-next","Where to go next",[10,22333,22334],{},"The step-by-step companion to this page walks through every command and the complete config, from a clean clone to a green CI run:",[41,22336,22337],{},[44,22338,22339,22344,22345,22347,22348,22350],{},[72,22340,22341],{},[97,22342,22343],{"href":19163},"Setting up pre-commit for Python CLI repos"," — install pre-commit, write a full ",[14,22346,22130],{}," with ruff, mypy, and a local pytest hook, wire up the matching ",[14,22349,29],{}," config, and run it in CI.",[36,22352,1277],{"id":1276},[41,22354,22355,22360,22365],{},[44,22356,22357,22359],{},[97,22358,1423],{"href":1422}," — the pillar this hub belongs to.",[44,22361,22362,22364],{},[97,22363,22343],{"href":19163}," — the hands-on walkthrough.",[44,22366,22367,22369],{},[97,22368,19172],{"href":19171}," — the other half of a disciplined release workflow.",[1303,22371,22372],{},"html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":131,"searchDepth":152,"depth":152,"links":22374},[22375,22376,22377,22378,22379,22380],{"id":22117,"depth":152,"text":22118},{"id":22146,"depth":152,"text":22147},{"id":22193,"depth":152,"text":22194},{"id":22316,"depth":152,"text":22317},{"id":22330,"depth":152,"text":22331},{"id":1276,"depth":152,"text":1277},"Integrate pre-commit hooks into Python CLI repos with ruff, mypy, and pytest gates to automate code quality checks before every commit.",{},"\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects",{"title":22106,"description":22381},"project-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects\u002Findex",[19210,17978,19156,19159,22387],"tooling","oAIl4xfIz1tz9HYU9-Onimt3pc0Y9_XSs_ObXBF_vD4",{"id":22390,"title":22343,"body":22391,"date":1320,"description":23705,"difficulty":1457,"draft":1323,"extension":1324,"meta":23706,"navigation":183,"path":23707,"seo":23708,"stem":23709,"tags":23710,"updated":1320,"__hash__":23711},"content\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects\u002Fsetting-up-pre-commit-for-python-cli-repos\u002Findex.md",{"type":7,"value":22392,"toc":23692},[22393,22402,22404,22467,22473,22479,22483,22488,22508,22514,22565,22571,22575,22581,22946,22949,23003,23007,23013,23291,23320,23324,23327,23338,23354,23371,23381,23385,23388,23401,23404,23408,23426,23429,23441,23454,23458,23464,23585,23598,23602,23611,23617,23619,23670,23672,23689],[10,22394,22395,22396,22398,22399,22401],{},"You have a Python CLI repo and you want every commit to be linted, formatted, type-checked, and tested before it lands — without relying on people remembering to run those tools by hand. This guide takes you from a clean clone to a green continuous-integration run: install pre-commit, write a complete ",[14,22397,22130],{},", add the matching ruff and mypy config to ",[14,22400,29],{},", install the Git hook, run it across the whole repo, and pin and update the hook versions. Everything targets Python 3.10+.",[36,22403,39],{"id":38},[126,22405,22407],{"className":634,"code":22406,"language":636,"meta":131,"style":131},"# 1. install the framework (into your project's dev environment)\npip install pre-commit\n\n# 2. add .pre-commit-config.yaml and the [tool.ruff]\u002F[tool.mypy] config below\n\n# 3. install the Git hook so it runs on every commit\npre-commit install\n\n# 4. run all hooks once across the existing codebase\npre-commit run --all-files\n",[14,22408,22409,22414,22424,22428,22433,22437,22442,22449,22453,22458],{"__ignoreMap":131},[135,22410,22411],{"class":137,"line":138},[135,22412,22413],{"class":669},"# 1. install the framework (into your project's dev environment)\n",[135,22415,22416,22419,22421],{"class":137,"line":152},[135,22417,22418],{"class":145},"pip",[135,22420,18847],{"class":158},[135,22422,22423],{"class":158}," pre-commit\n",[135,22425,22426],{"class":137,"line":162},[135,22427,184],{"emptyLinePlaceholder":183},[135,22429,22430],{"class":137,"line":171},[135,22431,22432],{"class":669},"# 2. add .pre-commit-config.yaml and the [tool.ruff]\u002F[tool.mypy] config below\n",[135,22434,22435],{"class":137,"line":180},[135,22436,184],{"emptyLinePlaceholder":183},[135,22438,22439],{"class":137,"line":187},[135,22440,22441],{"class":669},"# 3. install the Git hook so it runs on every commit\n",[135,22443,22444,22446],{"class":137,"line":201},[135,22445,19210],{"class":145},[135,22447,22448],{"class":158}," install\n",[135,22450,22451],{"class":137,"line":210},[135,22452,184],{"emptyLinePlaceholder":183},[135,22454,22455],{"class":137,"line":215},[135,22456,22457],{"class":669},"# 4. run all hooks once across the existing codebase\n",[135,22459,22460,22462,22464],{"class":137,"line":225},[135,22461,19210],{"class":145},[135,22463,20944],{"class":158},[135,22465,22466],{"class":350}," --all-files\n",[10,22468,22469,22470,22472],{},"After step 3, every ",[14,22471,13556],{}," runs ruff (lint + format), mypy, and your test suite automatically. Read on for the full config and the reasoning.",[10,22474,22475],{},[104,22476],{"alt":22477,"src":22478},"Four-step pre-commit setup flow as numbered cards: pip install pre-commit, add a .pre-commit-config.yaml, run pre-commit install which writes the .git\u002Fhooks script, then run pre-commit run --all-files.","\u002Fillustrations\u002Fprecommit-setup-flow.svg",[36,22480,22482],{"id":22481},"step-1-install-pre-commit","Step 1: install pre-commit",[10,22484,22485,22487],{},[14,22486,19210],{}," is itself a Python package. Install it into the same dev environment you use for the project so the version is reproducible:",[126,22489,22491],{"className":634,"code":22490,"language":636,"meta":131,"style":131},"pip install pre-commit\npre-commit --version\n",[14,22492,22493,22501],{"__ignoreMap":131},[135,22494,22495,22497,22499],{"class":137,"line":138},[135,22496,22418],{"class":145},[135,22498,18847],{"class":158},[135,22500,22423],{"class":158},[135,22502,22503,22505],{"class":137,"line":152},[135,22504,19210],{"class":145},[135,22506,22507],{"class":350}," --version\n",[10,22509,22510,22511,22513],{},"Add it to your project's dev dependencies as well — that way CI and new contributors get the same version. In a PEP 621 ",[14,22512,29],{}," that is a dependency group:",[126,22515,22517],{"className":128,"code":22516,"language":130,"meta":131,"style":131},"[dependency-groups]\ndev = [\n    \"pre-commit>=4.0\",\n    \"ruff>=0.6\",\n    \"mypy>=1.11\",\n    \"pytest>=8.0\",\n]\n",[14,22518,22519,22528,22533,22540,22547,22554,22561],{"__ignoreMap":131},[135,22520,22521,22523,22526],{"class":137,"line":138},[135,22522,142],{"class":141},[135,22524,22525],{"class":145},"dependency-groups",[135,22527,149],{"class":141},[135,22529,22530],{"class":137,"line":152},[135,22531,22532],{"class":141},"dev = [\n",[135,22534,22535,22538],{"class":137,"line":162},[135,22536,22537],{"class":158},"    \"pre-commit>=4.0\"",[135,22539,3238],{"class":141},[135,22541,22542,22545],{"class":137,"line":171},[135,22543,22544],{"class":158},"    \"ruff>=0.6\"",[135,22546,3238],{"class":141},[135,22548,22549,22552],{"class":137,"line":180},[135,22550,22551],{"class":158},"    \"mypy>=1.11\"",[135,22553,3238],{"class":141},[135,22555,22556,22559],{"class":137,"line":187},[135,22557,22558],{"class":158},"    \"pytest>=8.0\"",[135,22560,3238],{"class":141},[135,22562,22563],{"class":137,"line":201},[135,22564,149],{"class":141},[10,22566,22567,22568,22570],{},"If you manage dependencies with uv, see ",[97,22569,19011],{"href":19010}," for keeping that group locked.",[36,22572,22574],{"id":22573},"step-2-write-the-pre-commit-configyaml","Step 2: write the .pre-commit-config.yaml",[10,22576,22577,22578,22580],{},"Create ",[14,22579,22130],{}," at the repository root. This is the complete config for a CLI project — ruff for linting, ruff for formatting, mypy for type checking, and a local hook that runs pytest:",[126,22582,22584],{"className":5873,"code":22583,"language":5875,"meta":131,"style":131},"# .pre-commit-config.yaml\ndefault_language_version:\n  python: python3.10\n\nrepos:\n  - repo: https:\u002F\u002Fgithub.com\u002Fpre-commit\u002Fpre-commit-hooks\n    rev: v4.6.0\n    hooks:\n      - id: trailing-whitespace\n      - id: end-of-file-fixer\n      - id: check-yaml\n      - id: check-toml\n      - id: check-added-large-files\n\n  - repo: https:\u002F\u002Fgithub.com\u002Fastral-sh\u002Fruff-pre-commit\n    rev: v0.6.9\n    hooks:\n      - id: ruff\n        name: ruff (lint)\n        args: [--fix]\n      - id: ruff-format\n        name: ruff (format)\n\n  - repo: https:\u002F\u002Fgithub.com\u002Fpre-commit\u002Fmirrors-mypy\n    rev: v1.11.2\n    hooks:\n      - id: mypy\n        additional_dependencies:\n          - typer>=0.12\n        args: [--strict]\n\n  - repo: local\n    hooks:\n      - id: pytest\n        name: pytest (fast suite)\n        entry: pytest\n        language: system\n        types: [python]\n        pass_filenames: false\n        stages: [pre-push]\n",[14,22585,22586,22591,22598,22608,22612,22619,22631,22641,22648,22660,22671,22682,22693,22704,22708,22719,22728,22734,22745,22755,22767,22778,22787,22791,22802,22811,22817,22828,22835,22843,22854,22858,22869,22875,22886,22895,22904,22914,22925,22934],{"__ignoreMap":131},[135,22587,22588],{"class":137,"line":138},[135,22589,22590],{"class":669},"# .pre-commit-config.yaml\n",[135,22592,22593,22596],{"class":137,"line":152},[135,22594,22595],{"class":6501},"default_language_version",[135,22597,360],{"class":141},[135,22599,22600,22603,22605],{"class":137,"line":162},[135,22601,22602],{"class":6501},"  python",[135,22604,2344],{"class":141},[135,22606,22607],{"class":158},"python3.10\n",[135,22609,22610],{"class":137,"line":171},[135,22611,184],{"emptyLinePlaceholder":183},[135,22613,22614,22617],{"class":137,"line":180},[135,22615,22616],{"class":6501},"repos",[135,22618,360],{"class":141},[135,22620,22621,22623,22626,22628],{"class":137,"line":187},[135,22622,6555],{"class":141},[135,22624,22625],{"class":6501},"repo",[135,22627,2344],{"class":141},[135,22629,22630],{"class":158},"https:\u002F\u002Fgithub.com\u002Fpre-commit\u002Fpre-commit-hooks\n",[135,22632,22633,22636,22638],{"class":137,"line":201},[135,22634,22635],{"class":6501},"    rev",[135,22637,2344],{"class":141},[135,22639,22640],{"class":158},"v4.6.0\n",[135,22642,22643,22646],{"class":137,"line":210},[135,22644,22645],{"class":6501},"    hooks",[135,22647,360],{"class":141},[135,22649,22650,22652,22655,22657],{"class":137,"line":215},[135,22651,22252],{"class":141},[135,22653,22654],{"class":6501},"id",[135,22656,2344],{"class":141},[135,22658,22659],{"class":158},"trailing-whitespace\n",[135,22661,22662,22664,22666,22668],{"class":137,"line":225},[135,22663,22252],{"class":141},[135,22665,22654],{"class":6501},[135,22667,2344],{"class":141},[135,22669,22670],{"class":158},"end-of-file-fixer\n",[135,22672,22673,22675,22677,22679],{"class":137,"line":236},[135,22674,22252],{"class":141},[135,22676,22654],{"class":6501},[135,22678,2344],{"class":141},[135,22680,22681],{"class":158},"check-yaml\n",[135,22683,22684,22686,22688,22690],{"class":137,"line":606},[135,22685,22252],{"class":141},[135,22687,22654],{"class":6501},[135,22689,2344],{"class":141},[135,22691,22692],{"class":158},"check-toml\n",[135,22694,22695,22697,22699,22701],{"class":137,"line":619},[135,22696,22252],{"class":141},[135,22698,22654],{"class":6501},[135,22700,2344],{"class":141},[135,22702,22703],{"class":158},"check-added-large-files\n",[135,22705,22706],{"class":137,"line":1752},[135,22707,184],{"emptyLinePlaceholder":183},[135,22709,22710,22712,22714,22716],{"class":137,"line":1765},[135,22711,6555],{"class":141},[135,22713,22625],{"class":6501},[135,22715,2344],{"class":141},[135,22717,22718],{"class":158},"https:\u002F\u002Fgithub.com\u002Fastral-sh\u002Fruff-pre-commit\n",[135,22720,22721,22723,22725],{"class":137,"line":1774},[135,22722,22635],{"class":6501},[135,22724,2344],{"class":141},[135,22726,22727],{"class":158},"v0.6.9\n",[135,22729,22730,22732],{"class":137,"line":1795},[135,22731,22645],{"class":6501},[135,22733,360],{"class":141},[135,22735,22736,22738,22740,22742],{"class":137,"line":1830},[135,22737,22252],{"class":141},[135,22739,22654],{"class":6501},[135,22741,2344],{"class":141},[135,22743,22744],{"class":158},"ruff\n",[135,22746,22747,22750,22752],{"class":137,"line":1846},[135,22748,22749],{"class":6501},"        name",[135,22751,2344],{"class":141},[135,22753,22754],{"class":158},"ruff (lint)\n",[135,22756,22757,22760,22762,22765],{"class":137,"line":1854},[135,22758,22759],{"class":6501},"        args",[135,22761,17621],{"class":141},[135,22763,22764],{"class":158},"--fix",[135,22766,149],{"class":141},[135,22768,22769,22771,22773,22775],{"class":137,"line":1859},[135,22770,22252],{"class":141},[135,22772,22654],{"class":6501},[135,22774,2344],{"class":141},[135,22776,22777],{"class":158},"ruff-format\n",[135,22779,22780,22782,22784],{"class":137,"line":1877},[135,22781,22749],{"class":6501},[135,22783,2344],{"class":141},[135,22785,22786],{"class":158},"ruff (format)\n",[135,22788,22789],{"class":137,"line":1893},[135,22790,184],{"emptyLinePlaceholder":183},[135,22792,22793,22795,22797,22799],{"class":137,"line":1926},[135,22794,6555],{"class":141},[135,22796,22625],{"class":6501},[135,22798,2344],{"class":141},[135,22800,22801],{"class":158},"https:\u002F\u002Fgithub.com\u002Fpre-commit\u002Fmirrors-mypy\n",[135,22803,22804,22806,22808],{"class":137,"line":1940},[135,22805,22635],{"class":6501},[135,22807,2344],{"class":141},[135,22809,22810],{"class":158},"v1.11.2\n",[135,22812,22813,22815],{"class":137,"line":2906},[135,22814,22645],{"class":6501},[135,22816,360],{"class":141},[135,22818,22819,22821,22823,22825],{"class":137,"line":2931},[135,22820,22252],{"class":141},[135,22822,22654],{"class":6501},[135,22824,2344],{"class":141},[135,22826,22827],{"class":158},"mypy\n",[135,22829,22830,22833],{"class":137,"line":2944},[135,22831,22832],{"class":6501},"        additional_dependencies",[135,22834,360],{"class":141},[135,22836,22837,22840],{"class":137,"line":3266},[135,22838,22839],{"class":141},"          - ",[135,22841,22842],{"class":158},"typer>=0.12\n",[135,22844,22845,22847,22849,22852],{"class":137,"line":3277},[135,22846,22759],{"class":6501},[135,22848,17621],{"class":141},[135,22850,22851],{"class":158},"--strict",[135,22853,149],{"class":141},[135,22855,22856],{"class":137,"line":3290},[135,22857,184],{"emptyLinePlaceholder":183},[135,22859,22860,22862,22864,22866],{"class":137,"line":3295},[135,22861,6555],{"class":141},[135,22863,22625],{"class":6501},[135,22865,2344],{"class":141},[135,22867,22868],{"class":158},"local\n",[135,22870,22871,22873],{"class":137,"line":3315},[135,22872,22645],{"class":6501},[135,22874,360],{"class":141},[135,22876,22877,22879,22881,22883],{"class":137,"line":3329},[135,22878,22252],{"class":141},[135,22880,22654],{"class":6501},[135,22882,2344],{"class":141},[135,22884,22885],{"class":158},"pytest\n",[135,22887,22888,22890,22892],{"class":137,"line":3337},[135,22889,22749],{"class":6501},[135,22891,2344],{"class":141},[135,22893,22894],{"class":158},"pytest (fast suite)\n",[135,22896,22897,22900,22902],{"class":137,"line":3350},[135,22898,22899],{"class":6501},"        entry",[135,22901,2344],{"class":141},[135,22903,22885],{"class":158},[135,22905,22906,22909,22911],{"class":137,"line":3365},[135,22907,22908],{"class":6501},"        language",[135,22910,2344],{"class":141},[135,22912,22913],{"class":158},"system\n",[135,22915,22916,22919,22921,22923],{"class":137,"line":3373},[135,22917,22918],{"class":6501},"        types",[135,22920,17621],{"class":141},[135,22922,318],{"class":158},[135,22924,149],{"class":141},[135,22926,22927,22930,22932],{"class":137,"line":3406},[135,22928,22929],{"class":6501},"        pass_filenames",[135,22931,2344],{"class":141},[135,22933,19972],{"class":350},[135,22935,22936,22939,22941,22944],{"class":137,"line":3415},[135,22937,22938],{"class":6501},"        stages",[135,22940,17621],{"class":141},[135,22942,22943],{"class":158},"pre-push",[135,22945,149],{"class":141},[10,22947,22948],{},"A few details that matter for CLIs:",[41,22950,22951,22964,22979],{},[44,22952,22953,22959,22960,22963],{},[72,22954,22955,1230,22957],{},[14,22956,17978],{},[14,22958,22764],{}," auto-corrects safe lint violations (unused imports, import sorting) and re-stages them. ",[14,22961,22962],{},"ruff-format"," then applies the deterministic style. Running both from the same tool avoids the formatter\u002Flinter rule conflicts you get when mixing black and flake8.",[44,22965,22966,22973,22974,861,22976,22978],{},[72,22967,22968,1230,22970],{},[14,22969,19156],{},[14,22971,22972],{},"additional_dependencies"," — pre-commit runs mypy in an isolated environment, so it can't see your project's installed packages. List the typed libraries your CLI imports (here Typer) so mypy can resolve their stubs. Add ",[14,22975,2484],{},[14,22977,6973],{},", or whatever your CLI depends on.",[44,22980,22981,22987,22988,22991,22992,22994,22995,22998,22999,23002],{},[72,22982,22983,22984,22986],{},"The local ",[14,22985,19159],{}," hook"," runs the test suite. It's set to ",[14,22989,22990],{},"language: system",", meaning it uses the ",[14,22993,19159],{}," already on your PATH (in your dev env) rather than a pre-commit-managed one. ",[14,22996,22997],{},"pass_filenames: false"," stops pre-commit from passing changed filenames as test paths. It's gated to ",[14,23000,23001],{},"stages: [pre-push]"," so the full suite runs on push rather than slowing every single commit — move it to the default stage if you want it on every commit.",[36,23004,23006],{"id":23005},"step-3-add-the-matching-pyprojecttoml-config","Step 3: add the matching pyproject.toml config",[10,23008,23009,23010,23012],{},"The hooks above call ruff and mypy, but those tools read their own configuration from ",[14,23011,29],{},". Without it, ruff and mypy use defaults that may not match what the hooks expect. Add these tables:",[126,23014,23016],{"className":128,"code":23015,"language":130,"meta":131,"style":131},"[tool.ruff]\nline-length = 88\ntarget-version = \"py310\"\nsrc = [\"src\", \"tests\"]\n\n[tool.ruff.lint]\nselect = [\"E\", \"F\", \"I\", \"UP\", \"B\", \"SIM\"]\nignore = [\"E501\"]\n\n[tool.ruff.lint.per-file-ignores]\n\"tests\u002F*\" = [\"S101\"]\n\n[tool.ruff.format]\nquote-style = \"double\"\nindent-style = \"space\"\n\n[tool.mypy]\npython_version = \"3.10\"\nstrict = true\nwarn_unused_ignores = true\nwarn_return_any = true\nfiles = [\"src\", \"tests\"]\n\n[[tool.mypy.overrides]]\nmodule = [\"tests.*\"]\ndisallow_untyped_defs = false\n",[14,23017,23018,23030,23038,23045,23057,23061,23078,23113,23123,23127,23148,23158,23162,23179,23187,23195,23199,23211,23219,23226,23233,23240,23253,23257,23274,23284],{"__ignoreMap":131},[135,23019,23020,23022,23024,23026,23028],{"class":137,"line":138},[135,23021,142],{"class":141},[135,23023,11953],{"class":145},[135,23025,61],{"class":141},[135,23027,17978],{"class":145},[135,23029,149],{"class":141},[135,23031,23032,23035],{"class":137,"line":152},[135,23033,23034],{"class":141},"line-length = ",[135,23036,23037],{"class":350},"88\n",[135,23039,23040,23042],{"class":137,"line":162},[135,23041,17985],{"class":141},[135,23043,23044],{"class":158},"\"py310\"\n",[135,23046,23047,23049,23051,23053,23055],{"class":137,"line":171},[135,23048,17993],{"class":141},[135,23050,17996],{"class":158},[135,23052,861],{"class":141},[135,23054,18001],{"class":158},[135,23056,149],{"class":141},[135,23058,23059],{"class":137,"line":180},[135,23060,184],{"emptyLinePlaceholder":183},[135,23062,23063,23065,23067,23069,23071,23073,23076],{"class":137,"line":187},[135,23064,142],{"class":141},[135,23066,11953],{"class":145},[135,23068,61],{"class":141},[135,23070,17978],{"class":145},[135,23072,61],{"class":141},[135,23074,23075],{"class":145},"lint",[135,23077,149],{"class":141},[135,23079,23080,23083,23086,23088,23091,23093,23096,23098,23101,23103,23106,23108,23111],{"class":137,"line":201},[135,23081,23082],{"class":141},"select = [",[135,23084,23085],{"class":158},"\"E\"",[135,23087,861],{"class":141},[135,23089,23090],{"class":158},"\"F\"",[135,23092,861],{"class":141},[135,23094,23095],{"class":158},"\"I\"",[135,23097,861],{"class":141},[135,23099,23100],{"class":158},"\"UP\"",[135,23102,861],{"class":141},[135,23104,23105],{"class":158},"\"B\"",[135,23107,861],{"class":141},[135,23109,23110],{"class":158},"\"SIM\"",[135,23112,149],{"class":141},[135,23114,23115,23118,23121],{"class":137,"line":210},[135,23116,23117],{"class":141},"ignore = [",[135,23119,23120],{"class":158},"\"E501\"",[135,23122,149],{"class":141},[135,23124,23125],{"class":137,"line":215},[135,23126,184],{"emptyLinePlaceholder":183},[135,23128,23129,23131,23133,23135,23137,23139,23141,23143,23146],{"class":137,"line":225},[135,23130,142],{"class":141},[135,23132,11953],{"class":145},[135,23134,61],{"class":141},[135,23136,17978],{"class":145},[135,23138,61],{"class":141},[135,23140,23075],{"class":145},[135,23142,61],{"class":141},[135,23144,23145],{"class":145},"per-file-ignores",[135,23147,149],{"class":141},[135,23149,23150,23153,23156],{"class":137,"line":236},[135,23151,23152],{"class":141},"\"tests\u002F*\" = [",[135,23154,23155],{"class":158},"\"S101\"",[135,23157,149],{"class":141},[135,23159,23160],{"class":137,"line":606},[135,23161,184],{"emptyLinePlaceholder":183},[135,23163,23164,23166,23168,23170,23172,23174,23177],{"class":137,"line":619},[135,23165,142],{"class":141},[135,23167,11953],{"class":145},[135,23169,61],{"class":141},[135,23171,17978],{"class":145},[135,23173,61],{"class":141},[135,23175,23176],{"class":145},"format",[135,23178,149],{"class":141},[135,23180,23181,23184],{"class":137,"line":1752},[135,23182,23183],{"class":141},"quote-style = ",[135,23185,23186],{"class":158},"\"double\"\n",[135,23188,23189,23192],{"class":137,"line":1765},[135,23190,23191],{"class":141},"indent-style = ",[135,23193,23194],{"class":158},"\"space\"\n",[135,23196,23197],{"class":137,"line":1774},[135,23198,184],{"emptyLinePlaceholder":183},[135,23200,23201,23203,23205,23207,23209],{"class":137,"line":1795},[135,23202,142],{"class":141},[135,23204,11953],{"class":145},[135,23206,61],{"class":141},[135,23208,19156],{"class":145},[135,23210,149],{"class":141},[135,23212,23213,23216],{"class":137,"line":1830},[135,23214,23215],{"class":141},"python_version = ",[135,23217,23218],{"class":158},"\"3.10\"\n",[135,23220,23221,23224],{"class":137,"line":1846},[135,23222,23223],{"class":141},"strict = ",[135,23225,6543],{"class":350},[135,23227,23228,23231],{"class":137,"line":1854},[135,23229,23230],{"class":141},"warn_unused_ignores = ",[135,23232,6543],{"class":350},[135,23234,23235,23238],{"class":137,"line":1859},[135,23236,23237],{"class":141},"warn_return_any = ",[135,23239,6543],{"class":350},[135,23241,23242,23245,23247,23249,23251],{"class":137,"line":1877},[135,23243,23244],{"class":141},"files = [",[135,23246,17996],{"class":158},[135,23248,861],{"class":141},[135,23250,18001],{"class":158},[135,23252,149],{"class":141},[135,23254,23255],{"class":137,"line":1893},[135,23256,184],{"emptyLinePlaceholder":183},[135,23258,23259,23261,23263,23265,23267,23269,23272],{"class":137,"line":1926},[135,23260,20019],{"class":141},[135,23262,11953],{"class":145},[135,23264,61],{"class":141},[135,23266,19156],{"class":145},[135,23268,61],{"class":141},[135,23270,23271],{"class":145},"overrides",[135,23273,20033],{"class":141},[135,23275,23276,23279,23282],{"class":137,"line":1940},[135,23277,23278],{"class":141},"module = [",[135,23280,23281],{"class":158},"\"tests.*\"",[135,23283,149],{"class":141},[135,23285,23286,23289],{"class":137,"line":2906},[135,23287,23288],{"class":141},"disallow_untyped_defs = ",[135,23290,19972],{"class":350},[10,23292,23293,23294,23297,23298,23301,23302,23305,23306,23309,23310,1993,23313,23316,23317,23319],{},"What these do: ",[14,23295,23296],{},"target-version = \"py310\""," lets ruff's ",[14,23299,23300],{},"UP"," rules rewrite code to modern 3.10 syntax (for example, ",[14,23303,23304],{},"X | None"," instead of ",[14,23307,23308],{},"Optional[X]","). The ",[14,23311,23312],{},"B",[14,23314,23315],{},"SIM"," rule sets catch the bug-prone patterns common in argument-handling code. The mypy ",[14,23318,23271],{}," block relaxes the strict \"every function must be annotated\" rule for tests, where it's noise rather than signal.",[36,23321,23323],{"id":23322},"step-4-install-the-hook","Step 4: install the hook",[10,23325,23326],{},"Installing the Git hook is what makes pre-commit run automatically:",[126,23328,23330],{"className":634,"code":23329,"language":636,"meta":131,"style":131},"pre-commit install\n",[14,23331,23332],{"__ignoreMap":131},[135,23333,23334,23336],{"class":137,"line":138},[135,23335,19210],{"class":145},[135,23337,22448],{"class":158},[10,23339,23340,23341,23343,23344,23347,23348,23350,23351,23353],{},"This writes a ",[14,23342,19210],{}," script into ",[14,23345,23346],{},".git\u002Fhooks\u002F",". From now on, every ",[14,23349,13556],{}," runs the hooks against your staged files. Because the local pytest hook is gated to ",[14,23352,22943],{},", also install that stage:",[126,23355,23357],{"className":634,"code":23356,"language":636,"meta":131,"style":131},"pre-commit install --hook-type pre-push\n",[14,23358,23359],{"__ignoreMap":131},[135,23360,23361,23363,23365,23368],{"class":137,"line":138},[135,23362,19210],{"class":145},[135,23364,18847],{"class":158},[135,23366,23367],{"class":350}," --hook-type",[135,23369,23370],{"class":158}," pre-push\n",[10,23372,23373,23374,23376,23377,23380],{},"If a hook modifies a file (ruff's ",[14,23375,22764],{}," or the formatter), the commit aborts and the fixes are left in your working tree — review them, ",[14,23378,23379],{},"git add",", and commit again.",[36,23382,23384],{"id":23383},"step-5-run-against-the-whole-repo","Step 5: run against the whole repo",[10,23386,23387],{},"When you first adopt pre-commit, run every hook across the entire codebase, not just staged files. This surfaces the backlog of existing issues in one go:",[126,23389,23391],{"className":634,"code":23390,"language":636,"meta":131,"style":131},"pre-commit run --all-files\n",[14,23392,23393],{"__ignoreMap":131},[135,23394,23395,23397,23399],{"class":137,"line":138},[135,23396,19210],{"class":145},[135,23398,20944],{"class":158},[135,23400,22466],{"class":350},[10,23402,23403],{},"Expect the first run to make formatting and import-sorting changes. Commit those as a single \"apply pre-commit\" change so the diff is isolated and easy to review. After that, runs are incremental and fast.",[36,23405,23407],{"id":23406},"step-6-pin-and-update-hook-versions","Step 6: pin and update hook versions",[10,23409,23410,23411,23414,23415,861,23418,23421,23422,23425],{},"Notice every remote hook in the config has a ",[14,23412,23413],{},"rev:"," field pinned to a specific tag (",[14,23416,23417],{},"v0.6.9",[14,23419,23420],{},"v1.11.2","). This is deliberate — it guarantees everyone runs the identical version, and your checks don't change underneath you when an upstream tool ships new rules. Never leave ",[14,23423,23424],{},"rev"," floating.",[10,23427,23428],{},"To upgrade those pins on your schedule, run:",[126,23430,23432],{"className":634,"code":23431,"language":636,"meta":131,"style":131},"pre-commit autoupdate\n",[14,23433,23434],{"__ignoreMap":131},[135,23435,23436,23438],{"class":137,"line":138},[135,23437,19210],{"class":145},[135,23439,23440],{"class":158}," autoupdate\n",[10,23442,23443,23444,23446,23447,23450,23451,61],{},"This rewrites each ",[14,23445,23424],{}," to the latest tag of each hook repository. Run it periodically (a monthly dependency-bump PR is a good cadence), then run ",[14,23448,23449],{},"pre-commit run --all-files"," to absorb any new lint or format changes the upgrade introduced, and commit the result. Bumping these pins is part of the same release hygiene as ",[97,23452,23453],{"href":19171},"managing CLI versioning & changelogs",[36,23455,23457],{"id":23456},"step-7-run-it-in-ci","Step 7: run it in CI",[10,23459,23460,23461,23463],{},"Local hooks are convenient but skippable (",[14,23462,22204],{},"). CI is the enforcement backstop. Run the exact same config in your pipeline so nothing slips through. For GitHub Actions:",[126,23465,23467],{"className":5873,"code":23466,"language":5875,"meta":131,"style":131},"# .github\u002Fworkflows\u002Flint.yml\nname: lint\non: [push, pull_request]\n\njobs:\n  pre-commit:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v5\n      - uses: actions\u002Fsetup-python@v6\n        with:\n          python-version: \"3.12\"\n      - run: pip install pre-commit\n      - run: pre-commit run --all-files --show-diff-on-failure\n",[14,23468,23469,23474,23483,23500,23504,23510,23517,23525,23531,23541,23551,23557,23565,23575],{"__ignoreMap":131},[135,23470,23471],{"class":137,"line":138},[135,23472,23473],{"class":669},"# .github\u002Fworkflows\u002Flint.yml\n",[135,23475,23476,23478,23480],{"class":137,"line":152},[135,23477,9681],{"class":6501},[135,23479,2344],{"class":141},[135,23481,23482],{"class":158},"lint\n",[135,23484,23485,23488,23490,23493,23495,23498],{"class":137,"line":162},[135,23486,23487],{"class":350},"on",[135,23489,17621],{"class":141},[135,23491,23492],{"class":158},"push",[135,23494,861],{"class":141},[135,23496,23497],{"class":158},"pull_request",[135,23499,149],{"class":141},[135,23501,23502],{"class":137,"line":171},[135,23503,184],{"emptyLinePlaceholder":183},[135,23505,23506,23508],{"class":137,"line":180},[135,23507,22221],{"class":6501},[135,23509,360],{"class":141},[135,23511,23512,23515],{"class":137,"line":187},[135,23513,23514],{"class":6501},"  pre-commit",[135,23516,360],{"class":141},[135,23518,23519,23521,23523],{"class":137,"line":201},[135,23520,22235],{"class":6501},[135,23522,2344],{"class":141},[135,23524,22240],{"class":158},[135,23526,23527,23529],{"class":137,"line":210},[135,23528,22245],{"class":6501},[135,23530,360],{"class":141},[135,23532,23533,23535,23537,23539],{"class":137,"line":215},[135,23534,22252],{"class":141},[135,23536,22255],{"class":6501},[135,23538,2344],{"class":141},[135,23540,22260],{"class":158},[135,23542,23543,23545,23547,23549],{"class":137,"line":225},[135,23544,22252],{"class":141},[135,23546,22255],{"class":6501},[135,23548,2344],{"class":141},[135,23550,22271],{"class":158},[135,23552,23553,23555],{"class":137,"line":236},[135,23554,22276],{"class":6501},[135,23556,360],{"class":141},[135,23558,23559,23561,23563],{"class":137,"line":606},[135,23560,22283],{"class":6501},[135,23562,2344],{"class":141},[135,23564,22288],{"class":158},[135,23566,23567,23569,23571,23573],{"class":137,"line":619},[135,23568,22252],{"class":141},[135,23570,451],{"class":6501},[135,23572,2344],{"class":141},[135,23574,22299],{"class":158},[135,23576,23577,23579,23581,23583],{"class":137,"line":1752},[135,23578,22252],{"class":141},[135,23580,451],{"class":6501},[135,23582,2344],{"class":141},[135,23584,22310],{"class":158},[10,23586,23587,23590,23591,23594,23595,23597],{},[14,23588,23589],{},"--all-files"," checks the whole repo (CI has no staged-file concept), and ",[14,23592,23593],{},"--show-diff-on-failure"," prints exactly what a formatter or fixer would have changed, so a failing run is self-explanatory. Because both local and CI runs read the same ",[14,23596,22130],{},", they check identical things — there is one source of truth.",[36,23599,23601],{"id":23600},"why-this-setup-works","Why this setup works",[10,23603,23604,23605,23607,23608,23610],{},"The combination is fast where it needs to be and strict where it counts. Ruff runs in milliseconds, so lint and format gate every commit without friction. mypy in ",[14,23606,22851],{}," mode catches the type errors that bite CLIs hardest — the unguarded ",[14,23609,3093],{}," flowing into a path or subprocess call. pytest gates on push, so the slower behavioral check doesn't tax every tiny commit but still blocks a broken suite from reaching the remote. And every version is pinned, so the checks are reproducible across your laptop, a teammate's, and CI.",[10,23612,23613,23614,23616],{},"When ",[23,23615,8462],{}," to reach for all of this: a throwaway script doesn't need a pytest gate. But the moment a CLI has a published entry point and real users, the cost of a regression dwarfs the few minutes of setup here.",[36,23618,4512],{"id":4511},[41,23620,23621,23634,23648,23661],{},[44,23622,23623,23626,23627,23630,23631,23633],{},[72,23624,23625],{},"Isolated mypy environments",": the most common failure is mypy reporting ",[14,23628,23629],{},"import-untyped"," or missing-import errors for libraries it can't see. The fix is always ",[14,23632,22972],{}," on the mypy hook — list every typed third-party package your code imports.",[44,23635,23636,23639,23640,23643,23644,23647],{},[72,23637,23638],{},"Monorepos",": if your CLI lives in a subdirectory, set ",[14,23641,23642],{},"files:"," patterns (or ruff's ",[14,23645,23646],{},"src",") so hooks don't scan unrelated code.",[44,23649,23650,23653,23654,23657,23658,23660],{},[72,23651,23652],{},"CI caching",": pre-commit caches its hook environments under ",[14,23655,23656],{},"~\u002F.cache\u002Fpre-commit",". Cache that directory in CI (keyed on the hash of ",[14,23659,22130],{},") to cut minutes off each run.",[44,23662,23663,23669],{},[72,23664,23665,23668],{},[14,23666,23667],{},"--no-verify"," discipline",": treat a local bypass as a temporary escape hatch only — CI will still catch it, which is exactly why the CI gate is non-negotiable.",[36,23671,1277],{"id":1276},[41,23673,23674,23679,23684],{},[44,23675,23676,23678],{},[97,23677,19150],{"href":19149}," — the hub explaining the gate philosophy behind this setup.",[44,23680,23681,23683],{},[97,23682,1423],{"href":1422}," — the pillar this guide belongs to.",[44,23685,23686,23688],{},[97,23687,19011],{"href":19010}," — keep the dev dependencies that back these hooks locked and reproducible.",[1303,23690,23691],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":131,"searchDepth":152,"depth":152,"links":23693},[23694,23695,23696,23697,23698,23699,23700,23701,23702,23703,23704],{"id":38,"depth":152,"text":39},{"id":22481,"depth":152,"text":22482},{"id":22573,"depth":152,"text":22574},{"id":23005,"depth":152,"text":23006},{"id":23322,"depth":152,"text":23323},{"id":23383,"depth":152,"text":23384},{"id":23406,"depth":152,"text":23407},{"id":23456,"depth":152,"text":23457},{"id":23600,"depth":152,"text":23601},{"id":4511,"depth":152,"text":4512},{"id":1276,"depth":152,"text":1277},"Step-by-step guide to installing and configuring pre-commit in Python CLI repositories with ruff, black, and mypy hooks for Python 3.10+.",{},"\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects\u002Fsetting-up-pre-commit-for-python-cli-repos",{"title":22343,"description":23705},"project-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects\u002Fsetting-up-pre-commit-for-python-cli-repos\u002Findex",[19210,17978,19156,19159,22387],"l59v-L_6W5pVXPm11Y0j7T76YkUCSoIcUQuWU35P6j4",{"id":23713,"title":23714,"body":23715,"date":1320,"description":24262,"difficulty":1457,"draft":1323,"extension":1324,"meta":24263,"navigation":183,"path":24264,"seo":24265,"stem":24266,"tags":24267,"updated":1320,"__hash__":24269},"content\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management\u002Findex.md","uv for Python CLI Dependency Management",{"type":7,"value":23716,"toc":24246},[23717,23732,23734,23778,23784,23791,23803,23827,23833,23927,23934,23946,23957,23985,24001,24022,24028,24037,24052,24064,24068,24088,24094,24103,24142,24153,24160,24169,24198,24209,24211,24217,24224,24226,24243],[10,23718,23719,23721,23722,23724,23725,23724,23728,23731],{},[14,23720,18833],{}," is an extremely fast Python package and project manager written in Rust. For CLI development it replaces the whole ",[14,23723,22418],{}," + ",[14,23726,23727],{},"virtualenv",[14,23729,23730],{},"pip-tools"," stack — and often Poetry too — with a single binary that resolves dependencies, manages a project virtual environment, writes a cross-platform lockfile, and installs your tool onto the user's PATH. This hub walks the commands you reach for daily when building and shipping a Python command-line tool with uv.",[36,23733,39],{"id":38},[41,23735,23736,23747,23761,23767,23773],{},[44,23737,23738,23741,23742,19512,23744,23746],{},[14,23739,23740],{},"uv init --package mycli"," scaffolds a PEP 621 ",[14,23743,29],{},[14,23745,56],{}," entry point.",[44,23748,23749,23752,23753,23756,23757,23760],{},[14,23750,23751],{},"uv add typer"," adds a dependency and updates ",[14,23754,23755],{},"uv.lock"," in one step; ",[14,23758,23759],{},"uv sync"," materializes the environment.",[44,23762,23763,23766],{},[14,23764,23765],{},"uv run mycli"," executes your console script inside the managed venv without manual activation.",[44,23768,23769,23772],{},[14,23770,23771],{},"uv tool install ."," installs your CLI globally so end users can run it anywhere.",[44,23774,23775,23776,61],{},"For a head-to-head with the other popular bootstrapper, see ",[97,23777,19091],{"href":19090},[10,23779,23780],{},[104,23781],{"alt":23782,"src":23783},"The uv workflow: uv init, uv add, uv lock, uv sync, uv run — plus uv tool install for end users.","\u002Fillustrations\u002Fuv-lifecycle.svg",[36,23785,23787,23788],{"id":23786},"scaffolding-a-project-uv-init","Scaffolding a project: ",[14,23789,23790],{},"uv init",[10,23792,23793,23795,23796,23799,23800,23802],{},[14,23794,23790],{}," creates the project skeleton. For a CLI you want the ",[14,23797,23798],{},"--package"," flag, which lays out an importable ",[14,23801,1210],{}," package and registers a console-script entry point:",[126,23804,23806],{"className":634,"code":23805,"language":636,"meta":131,"style":131},"uv init --package mycli\ncd mycli\n",[14,23807,23808,23821],{"__ignoreMap":131},[135,23809,23810,23812,23815,23818],{"class":137,"line":138},[135,23811,18833],{"class":145},[135,23813,23814],{"class":158}," init",[135,23816,23817],{"class":350}," --package",[135,23819,23820],{"class":158}," mycli\n",[135,23822,23823,23825],{"class":137,"line":152},[135,23824,18825],{"class":350},[135,23826,23820],{"class":158},[10,23828,23829,23830,23832],{},"This produces a PEP 621 ",[14,23831,29],{}," — the standardized project metadata table that any modern build backend understands:",[126,23834,23836],{"className":128,"code":23835,"language":130,"meta":131,"style":131},"[project]\nname = \"mycli\"\nversion = \"0.1.0\"\ndescription = \"A friendly command-line tool.\"\nrequires-python = \">=3.9\"\ndependencies = []\n\n[project.scripts]\nmycli = \"mycli:main\"\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n",[14,23837,23838,23846,23853,23859,23866,23872,23877,23881,23893,23901,23905,23913,23921],{"__ignoreMap":131},[135,23839,23840,23842,23844],{"class":137,"line":138},[135,23841,142],{"class":141},[135,23843,146],{"class":145},[135,23845,149],{"class":141},[135,23847,23848,23850],{"class":137,"line":152},[135,23849,155],{"class":141},[135,23851,23852],{"class":158},"\"mycli\"\n",[135,23854,23855,23857],{"class":137,"line":162},[135,23856,165],{"class":141},[135,23858,11903],{"class":158},[135,23860,23861,23863],{"class":137,"line":171},[135,23862,17792],{"class":141},[135,23864,23865],{"class":158},"\"A friendly command-line tool.\"\n",[135,23867,23868,23870],{"class":137,"line":180},[135,23869,174],{"class":141},[135,23871,21070],{"class":158},[135,23873,23874],{"class":137,"line":187},[135,23875,23876],{"class":141},"dependencies = []\n",[135,23878,23879],{"class":137,"line":201},[135,23880,184],{"emptyLinePlaceholder":183},[135,23882,23883,23885,23887,23889,23891],{"class":137,"line":210},[135,23884,142],{"class":141},[135,23886,146],{"class":145},[135,23888,61],{"class":141},[135,23890,196],{"class":145},[135,23892,149],{"class":141},[135,23894,23895,23898],{"class":137,"line":215},[135,23896,23897],{"class":141},"mycli = ",[135,23899,23900],{"class":158},"\"mycli:main\"\n",[135,23902,23903],{"class":137,"line":225},[135,23904,184],{"emptyLinePlaceholder":183},[135,23906,23907,23909,23911],{"class":137,"line":236},[135,23908,142],{"class":141},[135,23910,220],{"class":145},[135,23912,149],{"class":141},[135,23914,23915,23917,23919],{"class":137,"line":606},[135,23916,228],{"class":141},[135,23918,231],{"class":158},[135,23920,149],{"class":141},[135,23922,23923,23925],{"class":137,"line":619},[135,23924,239],{"class":141},[135,23926,242],{"class":158},[10,23928,111,23929,23931,23932,61],{},[14,23930,56],{}," table is the standard way to declare CLI entry points — the same syntax covered in ",[97,23933,9071],{"href":9070},[36,23935,23937,23938,861,23941,861,23944],{"id":23936},"adding-dependencies-uv-add-uv-lock-uv-sync","Adding dependencies: ",[14,23939,23940],{},"uv add",[14,23942,23943],{},"uv lock",[14,23945,23759],{},[10,23947,23948,23950,23951,23953,23954,23956],{},[14,23949,23940],{}," is the command you use most. It records the dependency in ",[14,23952,29],{},", re-resolves the dependency graph, and updates ",[14,23955,23755],{}," atomically:",[126,23958,23960],{"className":634,"code":23959,"language":636,"meta":131,"style":131},"uv add \"typer>=0.12\"\nuv add --dev pytest ruff\n",[14,23961,23962,23971],{"__ignoreMap":131},[135,23963,23964,23966,23968],{"class":137,"line":138},[135,23965,18833],{"class":145},[135,23967,14168],{"class":158},[135,23969,23970],{"class":158}," \"typer>=0.12\"\n",[135,23972,23973,23975,23977,23980,23982],{"class":137,"line":152},[135,23974,18833],{"class":145},[135,23976,14168],{"class":158},[135,23978,23979],{"class":350}," --dev",[135,23981,20925],{"class":158},[135,23983,23984],{"class":158}," ruff\n",[10,23986,23987,23988,23991,23992,23995,23996,23998,23999,473],{},"Development-only tools go behind ",[14,23989,23990],{},"--dev",", landing in a ",[14,23993,23994],{},"[dependency-groups]"," table rather than your runtime ",[14,23997,9357],{},". Two lower-level commands sit underneath ",[14,24000,23940],{},[41,24002,24003,24014],{},[44,24004,24005,24007,24008,24010,24011,24013],{},[14,24006,23943],{}," re-resolves the full graph and writes ",[14,24009,23755],{}," without touching the environment. Run it after editing ",[14,24012,29],{}," by hand.",[44,24015,24016,24018,24019,24021],{},[14,24017,23759],{}," makes the installed environment match ",[14,24020,23755],{}," exactly — installing what's missing and removing what shouldn't be there.",[36,24023,24025,24026],{"id":24024},"the-lockfile-uvlock","The lockfile: ",[14,24027,23755],{},[10,24029,24030,24032,24033,24036],{},[14,24031,23755],{}," is a universal, cross-platform lockfile. Unlike a flat ",[14,24034,24035],{},"requirements.txt",", it captures resolutions for every platform and Python version your project supports, so a single committed lockfile reproduces identically on Linux, macOS, and Windows. Commit it to version control. Because the lock is the source of truth, CI can install with a single reproducible command:",[126,24038,24040],{"className":634,"code":24039,"language":636,"meta":131,"style":131},"uv sync --frozen\n",[14,24041,24042],{"__ignoreMap":131},[135,24043,24044,24046,24049],{"class":137,"line":138},[135,24045,18833],{"class":145},[135,24047,24048],{"class":158}," sync",[135,24050,24051],{"class":350}," --frozen\n",[10,24053,24054,24057,24058,24060,24061,24063],{},[14,24055,24056],{},"--frozen"," errors out if ",[14,24059,23755],{}," is stale relative to ",[14,24062,29],{},", which is exactly what you want in CI — it guarantees nobody forgot to re-lock.",[36,24065,24067],{"id":24066},"managing-the-virtual-environment","Managing the virtual environment",[10,24069,24070,24071,24073,24074,18950,24077,24079,24080,24083,24084,24087],{},"uv creates and manages a project venv at ",[14,24072,22015],{}," automatically — you rarely create one by hand. The first ",[14,24075,24076],{},"uv run",[14,24078,23759],{}," provisions it, and uv will even download a managed CPython build if your ",[14,24081,24082],{},"requires-python"," isn't satisfied locally. If you do want an explicit environment, ",[14,24085,24086],{},"uv venv"," creates one, but for project work you can skip it entirely and let the commands below handle activation transparently.",[36,24089,24091,24092],{"id":24090},"running-code-uv-run","Running code: ",[14,24093,24076],{},[10,24095,24096,24098,24099,24102],{},[14,24097,24076],{}," executes a command inside the project environment, syncing it first if needed — no ",[14,24100,24101],{},"source .venv\u002Fbin\u002Factivate"," required:",[126,24104,24106],{"className":634,"code":24105,"language":636,"meta":131,"style":131},"uv run mycli --help\nuv run pytest\nuv run python -c \"import mycli; print(mycli.__file__)\"\n",[14,24107,24108,24120,24128],{"__ignoreMap":131},[135,24109,24110,24112,24114,24117],{"class":137,"line":138},[135,24111,18833],{"class":145},[135,24113,20944],{"class":158},[135,24115,24116],{"class":158}," mycli",[135,24118,24119],{"class":350}," --help\n",[135,24121,24122,24124,24126],{"class":137,"line":152},[135,24123,18833],{"class":145},[135,24125,20944],{"class":158},[135,24127,3907],{"class":158},[135,24129,24130,24132,24134,24136,24139],{"class":137,"line":162},[135,24131,18833],{"class":145},[135,24133,20944],{"class":158},[135,24135,646],{"class":158},[135,24137,24138],{"class":350}," -c",[135,24140,24141],{"class":158}," \"import mycli; print(mycli.__file__)\"\n",[10,24143,24144,24145,24147,24148,1993,24150,24152],{},"This is the single most useful day-to-day command. Because it auto-syncs, ",[14,24146,23765],{}," always reflects the current ",[14,24149,29],{},[14,24151,23755],{},", which makes it ideal for both local iteration and CI scripts.",[36,24154,24156,24157],{"id":24155},"distributing-the-cli-uv-tool-install","Distributing the CLI: ",[14,24158,24159],{},"uv tool install",[10,24161,24162,24163,24165,24166,473],{},"Once your tool is ready to use as a global command, ",[14,24164,24159],{}," installs it into an isolated environment and puts its entry points on your PATH — the modern equivalent of ",[14,24167,24168],{},"pipx install",[126,24170,24172],{"className":634,"code":24171,"language":636,"meta":131,"style":131},"uv tool install my-cli-tool --from .\nmycli --version\n",[14,24173,24174,24191],{"__ignoreMap":131},[135,24175,24176,24178,24180,24182,24185,24188],{"class":137,"line":138},[135,24177,18833],{"class":145},[135,24179,20084],{"class":158},[135,24181,18847],{"class":158},[135,24183,24184],{"class":158}," my-cli-tool",[135,24186,24187],{"class":350}," --from",[135,24189,24190],{"class":158}," .\n",[135,24192,24193,24196],{"class":137,"line":152},[135,24194,24195],{"class":145},"mycli",[135,24197,22507],{"class":350},[10,24199,24200,24201,24204,24205,24208],{},"Each installed tool gets its own venv, so global CLIs never clash over conflicting dependencies. For quick one-off invocations of a published tool without installing it permanently, ",[14,24202,24203],{},"uvx mycli"," (an alias for ",[14,24206,24207],{},"uv tool run",") fetches and runs it in a throwaway environment.",[36,24210,22331],{"id":22330},[10,24212,24213,24214,24216],{},"uv and Poetry both bootstrap projects, declare entry points, and lock dependencies, but they make different trade-offs around speed and lockfile format. For a detailed, side-by-side decision guide with concrete ",[14,24215,29],{}," snippets, read:",[41,24218,24219],{},[44,24220,24221,24223],{},[97,24222,19091],{"href":19090}," — bootstrapping speed, lockfile strategy, and entry-point syntax compared.",[36,24225,1277],{"id":1276},[41,24227,24228,24233,24238],{},[44,24229,24230,24232],{},[97,24231,1423],{"href":1422}," — the parent pillar covering scaffolding, versioning, and packaging.",[44,24234,24235,24237],{},[97,24236,259],{"href":258}," — the same lifecycle managed with Poetry instead of uv.",[44,24239,24240,24242],{},[97,24241,19091],{"href":19090}," — choose between the two bootstrappers.",[1303,24244,24245],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}",{"title":131,"searchDepth":152,"depth":152,"links":24247},[24248,24249,24251,24253,24255,24256,24258,24260,24261],{"id":38,"depth":152,"text":39},{"id":23786,"depth":152,"text":24250},"Scaffolding a project: uv init",{"id":23936,"depth":152,"text":24252},"Adding dependencies: uv add, uv lock, uv sync",{"id":24024,"depth":152,"text":24254},"The lockfile: uv.lock",{"id":24066,"depth":152,"text":24067},{"id":24090,"depth":152,"text":24257},"Running code: uv run",{"id":24155,"depth":152,"text":24259},"Distributing the CLI: uv tool install",{"id":22330,"depth":152,"text":22331},{"id":1276,"depth":152,"text":1277},"Use uv to manage Python CLI dependencies with fast lockfile resolution, virtual env creation, and PEP 621-compliant pyproject.toml workflows.",{},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management",{"title":23714,"description":24262},"project-setup-dependency-management\u002Fuv-for-python-cli-dependency-management\u002Findex",[18833,24268,1330,13073],"dependency-management","PbqdYRbXBC0pNZ6sFxgXLDccqF_QvZcW5SaqRwbyAgg",{"id":24271,"title":19091,"body":24272,"date":1320,"description":25104,"difficulty":1322,"draft":1323,"extension":1324,"meta":25105,"navigation":183,"path":25106,"seo":25107,"stem":25108,"tags":25109,"updated":1320,"__hash__":25110},"content\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management\u002Fuv-init-vs-poetry-init-for-cli-tools\u002Findex.md",{"type":7,"value":24273,"toc":25091},[24274,24288,24290,24344,24350,24354,24365,24489,24497,24590,24602,24606,24764,24772,24778,24789,24825,24834,24838,24843,24875,24881,24904,24927,24931,24934,24984,24993,24997,25008,25023,25029,25031,25070,25072,25088],[10,24275,24276,1993,24278,24281,24282,24284,24285,24287],{},[14,24277,23790],{},[14,24279,24280],{},"poetry init"," both bootstrap a new Python project and end with a ",[14,24283,29],{}," you can build a CLI on. But they take meaningfully different paths to get there — different lockfile formats, different default entry-point conventions, and a roughly order-of-magnitude difference in how fast they resolve dependencies. This article puts the two side by side for the specific case of shipping a console-script CLI, with concrete ",[14,24286,29],{}," snippets for each so you can see exactly what you're committing to.",[36,24289,39],{"id":38},[41,24291,24292,24309,24321,24334],{},[44,24293,24294,765,24297,24299,24300,24302,24303,459,24305,24308],{},[72,24295,24296],{},"Speed:",[14,24298,23790],{}," plus the first ",[14,24301,23940],{}," is dramatically faster than ",[14,24304,24280],{},[14,24306,24307],{},"poetry add"," because uv's Rust resolver and aggressive caching avoid most network round-trips.",[44,24310,24311,24314,24315,24317,24318,24320],{},[72,24312,24313],{},"Lockfile:"," uv writes a universal, cross-platform ",[14,24316,23755],{},"; Poetry writes ",[14,24319,20983],{},". Neither is interchangeable, and you commit exactly one.",[44,24322,24323,24326,24327,24329,24330,24333],{},[72,24324,24325],{},"Entry points:"," both now support PEP 621 ",[14,24328,56],{},". Poetry historically used ",[14,24331,24332],{},"[tool.poetry.scripts]","; uv has always used the standard table.",[44,24335,24336,24339,24340,24343],{},[72,24337,24338],{},"Choose uv"," for speed and standards-first metadata; ",[72,24341,24342],{},"choose Poetry"," if your team already standardizes on it or relies on its publishing\u002Fgroup ergonomics.",[10,24345,24346],{},[104,24347],{"alt":24348,"src":24349},"Two-column comparison of uv init and poetry init across bootstrap, lockfile, scripts table, and speed.","\u002Fillustrations\u002Fuv-vs-poetry.svg",[36,24351,24353],{"id":24352},"bootstrapping-what-each-command-generates","Bootstrapping: what each command generates",[10,24355,24356,24358,24359,24361,24362,24364],{},[14,24357,24280],{}," runs an interactive prompt (or takes flags) and writes metadata. Until recently it emitted a Poetry-specific ",[14,24360,21007],{}," table; Poetry 2.x defaults to the PEP 621 ",[14,24363,21000],{}," table instead. A typical CLI-ready file looks like this:",[126,24366,24368],{"className":128,"code":24367,"language":130,"meta":131,"style":131},"[project]\nname = \"mycli\"\nversion = \"0.1.0\"\ndescription = \"A friendly command-line tool.\"\nrequires-python = \">=3.9\"\ndependencies = [\"typer>=0.12\"]\n\n[project.scripts]\nmycli = \"mycli.__main__:app\"\n\n[tool.poetry]\npackages = [{ include = \"mycli\", from = \"src\" }]\n\n[build-system]\nrequires = [\"poetry-core>=2.0\"]\nbuild-backend = \"poetry.core.masonry.api\"\n",[14,24369,24370,24378,24384,24390,24396,24402,24410,24414,24426,24433,24437,24449,24462,24466,24474,24483],{"__ignoreMap":131},[135,24371,24372,24374,24376],{"class":137,"line":138},[135,24373,142],{"class":141},[135,24375,146],{"class":145},[135,24377,149],{"class":141},[135,24379,24380,24382],{"class":137,"line":152},[135,24381,155],{"class":141},[135,24383,23852],{"class":158},[135,24385,24386,24388],{"class":137,"line":162},[135,24387,165],{"class":141},[135,24389,11903],{"class":158},[135,24391,24392,24394],{"class":137,"line":171},[135,24393,17792],{"class":141},[135,24395,23865],{"class":158},[135,24397,24398,24400],{"class":137,"line":180},[135,24399,174],{"class":141},[135,24401,21070],{"class":158},[135,24403,24404,24406,24408],{"class":137,"line":187},[135,24405,9284],{"class":141},[135,24407,19453],{"class":158},[135,24409,149],{"class":141},[135,24411,24412],{"class":137,"line":201},[135,24413,184],{"emptyLinePlaceholder":183},[135,24415,24416,24418,24420,24422,24424],{"class":137,"line":210},[135,24417,142],{"class":141},[135,24419,146],{"class":145},[135,24421,61],{"class":141},[135,24423,196],{"class":145},[135,24425,149],{"class":141},[135,24427,24428,24430],{"class":137,"line":215},[135,24429,23897],{"class":141},[135,24431,24432],{"class":158},"\"mycli.__main__:app\"\n",[135,24434,24435],{"class":137,"line":225},[135,24436,184],{"emptyLinePlaceholder":183},[135,24438,24439,24441,24443,24445,24447],{"class":137,"line":236},[135,24440,142],{"class":141},[135,24442,11953],{"class":145},[135,24444,61],{"class":141},[135,24446,19245],{"class":145},[135,24448,149],{"class":141},[135,24450,24451,24453,24456,24458,24460],{"class":137,"line":606},[135,24452,21204],{"class":141},[135,24454,24455],{"class":158},"\"mycli\"",[135,24457,21210],{"class":141},[135,24459,17996],{"class":158},[135,24461,17812],{"class":141},[135,24463,24464],{"class":137,"line":619},[135,24465,184],{"emptyLinePlaceholder":183},[135,24467,24468,24470,24472],{"class":137,"line":1752},[135,24469,142],{"class":141},[135,24471,220],{"class":145},[135,24473,149],{"class":141},[135,24475,24476,24478,24481],{"class":137,"line":1765},[135,24477,228],{"class":141},[135,24479,24480],{"class":158},"\"poetry-core>=2.0\"",[135,24482,149],{"class":141},[135,24484,24485,24487],{"class":137,"line":1774},[135,24486,239],{"class":141},[135,24488,21332],{"class":158},[10,24490,24491,24493,24494,24496],{},[14,24492,23740],{}," is non-interactive and standards-first from the start — it never writes a ",[14,24495,21007],{}," table and uses Hatchling as the default build backend:",[126,24498,24500],{"className":128,"code":24499,"language":130,"meta":131,"style":131},"[project]\nname = \"mycli\"\nversion = \"0.1.0\"\ndescription = \"A friendly command-line tool.\"\nrequires-python = \">=3.9\"\ndependencies = [\"typer>=0.12\"]\n\n[project.scripts]\nmycli = \"mycli:main\"\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n",[14,24501,24502,24510,24516,24522,24528,24534,24542,24546,24558,24564,24568,24576,24584],{"__ignoreMap":131},[135,24503,24504,24506,24508],{"class":137,"line":138},[135,24505,142],{"class":141},[135,24507,146],{"class":145},[135,24509,149],{"class":141},[135,24511,24512,24514],{"class":137,"line":152},[135,24513,155],{"class":141},[135,24515,23852],{"class":158},[135,24517,24518,24520],{"class":137,"line":162},[135,24519,165],{"class":141},[135,24521,11903],{"class":158},[135,24523,24524,24526],{"class":137,"line":171},[135,24525,17792],{"class":141},[135,24527,23865],{"class":158},[135,24529,24530,24532],{"class":137,"line":180},[135,24531,174],{"class":141},[135,24533,21070],{"class":158},[135,24535,24536,24538,24540],{"class":137,"line":187},[135,24537,9284],{"class":141},[135,24539,19453],{"class":158},[135,24541,149],{"class":141},[135,24543,24544],{"class":137,"line":201},[135,24545,184],{"emptyLinePlaceholder":183},[135,24547,24548,24550,24552,24554,24556],{"class":137,"line":210},[135,24549,142],{"class":141},[135,24551,146],{"class":145},[135,24553,61],{"class":141},[135,24555,196],{"class":145},[135,24557,149],{"class":141},[135,24559,24560,24562],{"class":137,"line":215},[135,24561,23897],{"class":141},[135,24563,23900],{"class":158},[135,24565,24566],{"class":137,"line":225},[135,24567,184],{"emptyLinePlaceholder":183},[135,24569,24570,24572,24574],{"class":137,"line":236},[135,24571,142],{"class":141},[135,24573,220],{"class":145},[135,24575,149],{"class":141},[135,24577,24578,24580,24582],{"class":137,"line":606},[135,24579,228],{"class":141},[135,24581,231],{"class":158},[135,24583,149],{"class":141},[135,24585,24586,24588],{"class":137,"line":619},[135,24587,239],{"class":141},[135,24589,242],{"class":158},[10,24591,24592,24593,24595,24596,24599,24600,61],{},"Both declare the CLI through the standardized ",[14,24594,56],{}," table, which maps the command name to an ",[14,24597,24598],{},"import.path:callable"," target — the convention detailed in ",[97,24601,9071],{"href":9070},[36,24603,24605],{"id":24604},"side-by-side-comparison","Side-by-side comparison",[4693,24607,24608,24623],{},[4696,24609,24610],{},[4699,24611,24612,24615,24619],{},[4702,24613,24614],{},"Dimension",[4702,24616,24617],{},[14,24618,23790],{},[4702,24620,24621],{},[14,24622,24280],{},[4715,24624,24625,24636,24647,24662,24676,24694,24704,24720,24734,24749],{},[4699,24626,24627,24630,24633],{},[4720,24628,24629],{},"Bootstrapping speed",[4720,24631,24632],{},"Very fast; non-interactive by default",[4720,24634,24635],{},"Slower; interactive prompt by default",[4699,24637,24638,24641,24644],{},[4720,24639,24640],{},"Resolver",[4720,24642,24643],{},"Rust, parallel, heavily cached",[4720,24645,24646],{},"Pure Python",[4699,24648,24649,24652,24657],{},[4720,24650,24651],{},"Lockfile",[4720,24653,24654,24656],{},[14,24655,23755],{}," (universal, cross-platform)",[4720,24658,24659,24661],{},[14,24660,20983],{}," (resolved for project)",[4699,24663,24664,24667,24671],{},[4720,24665,24666],{},"Add a dependency",[4720,24668,24669],{},[14,24670,23751],{},[4720,24672,24673],{},[14,24674,24675],{},"poetry add typer",[4699,24677,24678,24681,24686],{},[4720,24679,24680],{},"Entry-point table",[4720,24682,24683,24685],{},[14,24684,56],{}," (PEP 621)",[4720,24687,24688,24690,24691,24693],{},[14,24689,56],{}," (2.x); ",[14,24692,24332],{}," historically",[4699,24695,24696,24699,24702],{},[4720,24697,24698],{},"Default build backend",[4720,24700,24701],{},"Hatchling",[4720,24703,251],{},[4699,24705,24706,24709,24715],{},[4720,24707,24708],{},"Dev dependencies",[4720,24710,24711,14045,24713,20254],{},[14,24712,23994],{},[14,24714,23990],{},[4720,24716,24717],{},[14,24718,24719],{},"[tool.poetry.group.*]",[4699,24721,24722,24725,24729],{},[4720,24723,24724],{},"Run without activation",[4720,24726,24727],{},[14,24728,23765],{},[4720,24730,24731],{},[14,24732,24733],{},"poetry run mycli",[4699,24735,24736,24739,24743],{},[4720,24737,24738],{},"Global tool install",[4720,24740,24741],{},[14,24742,23771],{},[4720,24744,24745,24746,20254],{},"(use ",[14,24747,24748],{},"pipx",[4699,24750,24751,24754,24757],{},[4720,24752,24753],{},"Self-bootstrap install",[4720,24755,24756],{},"Single static binary",[4720,24758,24759,459,24761,24763],{},[14,24760,22418],{},[14,24762,24748],{},"\u002Finstaller script",[36,24765,24767,24768,10713,24770],{"id":24766},"lockfile-strategy-uvlock-vs-poetrylock","Lockfile strategy: ",[14,24769,23755],{},[14,24771,20983],{},[10,24773,24774,24775,24777],{},"This is the difference that matters most in CI and on teams. ",[14,24776,20983],{}," pins a single resolution that Poetry computes for your project; it's mature and well understood, but reproducing it elsewhere still depends on Poetry being installed.",[10,24779,24780,14920,24782,24785,24786,24788],{},[14,24781,23755],{},[23,24783,24784],{},"universal"," lockfile: it stores resolutions spanning every platform and Python version permitted by your ",[14,24787,24082],{},", so one committed file reproduces byte-identically on Linux, macOS, and Windows. In CI you enforce reproducibility with a single command:",[126,24790,24792],{"className":634,"code":24791,"language":636,"meta":131,"style":131},"# uv — fails if uv.lock is stale relative to pyproject.toml\nuv sync --frozen\n\n# Poetry — installs strictly from poetry.lock\npoetry install --no-root\n",[14,24793,24794,24799,24807,24811,24816],{"__ignoreMap":131},[135,24795,24796],{"class":137,"line":138},[135,24797,24798],{"class":669},"# uv — fails if uv.lock is stale relative to pyproject.toml\n",[135,24800,24801,24803,24805],{"class":137,"line":152},[135,24802,18833],{"class":145},[135,24804,24048],{"class":158},[135,24806,24051],{"class":350},[135,24808,24809],{"class":137,"line":162},[135,24810,184],{"emptyLinePlaceholder":183},[135,24812,24813],{"class":137,"line":171},[135,24814,24815],{"class":669},"# Poetry — installs strictly from poetry.lock\n",[135,24817,24818,24820,24822],{"class":137,"line":180},[135,24819,19245],{"class":145},[135,24821,18847],{"class":158},[135,24823,24824],{"class":350}," --no-root\n",[10,24826,24827,24828,24830,24831,24833],{},"You commit exactly one of these files. The two are not interchangeable — uv reads ",[14,24829,23755],{},", Poetry reads ",[14,24832,20983],{}," — so picking a tool is also picking a lockfile.",[36,24835,24837],{"id":24836},"entry-point-syntax-and-pyproject-compatibility","Entry-point syntax and pyproject compatibility",[10,24839,24840,24841,473],{},"The historical wrinkle is the scripts table. Poetry projects created before the PEP 621 era declared CLIs under ",[14,24842,24332],{},[126,24844,24846],{"className":128,"code":24845,"language":130,"meta":131,"style":131},"# Legacy Poetry style — still works, but non-standard\n[tool.poetry.scripts]\nmycli = \"mycli.__main__:app\"\n",[14,24847,24848,24853,24869],{"__ignoreMap":131},[135,24849,24850],{"class":137,"line":138},[135,24851,24852],{"class":669},"# Legacy Poetry style — still works, but non-standard\n",[135,24854,24855,24857,24859,24861,24863,24865,24867],{"class":137,"line":152},[135,24856,142],{"class":141},[135,24858,11953],{"class":145},[135,24860,61],{"class":141},[135,24862,19245],{"class":145},[135,24864,61],{"class":141},[135,24866,196],{"class":145},[135,24868,149],{"class":141},[135,24870,24871,24873],{"class":137,"line":162},[135,24872,23897],{"class":141},[135,24874,24432],{"class":158},[10,24876,24877,24878,24880],{},"The portable, tool-agnostic form is ",[14,24879,56],{},", which both uv and modern Poetry emit and which any PEP 517 build backend understands:",[126,24882,24884],{"className":128,"code":24883,"language":130,"meta":131,"style":131},"[project.scripts]\nmycli = \"mycli.__main__:app\"\n",[14,24885,24886,24898],{"__ignoreMap":131},[135,24887,24888,24890,24892,24894,24896],{"class":137,"line":138},[135,24889,142],{"class":141},[135,24891,146],{"class":145},[135,24893,61],{"class":141},[135,24895,196],{"class":145},[135,24897,149],{"class":141},[135,24899,24900,24902],{"class":137,"line":152},[135,24901,23897],{"class":141},[135,24903,24432],{"class":158},[10,24905,24906,24907,24909,24910,861,24913,24916,24917,24919,24920,24923,24924,24926],{},"Because uv builds on the standardized ",[14,24908,21000],{}," metadata, a uv project is broadly compatible with the wider packaging ecosystem out of the box — ",[14,24911,24912],{},"pip install .",[14,24914,24915],{},"python -m build",", and other PEP 621-aware tools all read the same fields. A Poetry 2.x project using ",[14,24918,21000],{}," enjoys the same portability. A ",[23,24921,24922],{},"legacy"," Poetry project still leaning on ",[14,24925,21007],{}," for its name\u002Fversion\u002Fdependencies is the one case where another tool can't fully read the metadata without translation.",[6864,24928,24930],{"id":24929},"migrating-a-legacy-poetry-project-to-uv","Migrating a legacy Poetry project to uv",[10,24932,24933],{},"If you're moving an older Poetry CLI to uv, the work is mechanical:",[1961,24935,24936,24951,24958,24968,24978],{},[44,24937,24938,24939,861,24941,861,24943,784,24945,4565,24947,1972,24949,61],{},"Move ",[14,24940,9681],{},[14,24942,13668],{},[14,24944,7256],{},[14,24946,9357],{},[14,24948,21007],{},[14,24950,21000],{},[44,24952,24953,24954,5659,24956,61],{},"Rename ",[14,24955,24332],{},[14,24957,56],{},[44,24959,24960,24961,24964,24965,1516],{},"Ensure a valid ",[14,24962,24963],{},"[build-system]"," table exists (e.g. Hatchling or ",[14,24966,24967],{},"poetry-core>=2.0",[44,24969,17090,24970,24972,24973,24975,24976,61],{},[14,24971,23759],{}," to generate ",[14,24974,23755],{},", then delete ",[14,24977,20983],{},[44,24979,24980,24981,24983],{},"Remove the now-redundant ",[14,24982,21007],{}," metadata once everything resolves.",[10,24985,24986,24987,24989,24990,24992],{},"After that, ",[14,24988,23765],{}," should behave exactly like ",[14,24991,24733],{}," did.",[36,24994,24996],{"id":24995},"when-to-choose-each","When to choose each",[10,24998,24999,25004,25005,25007],{},[72,25000,7271,25001,25003],{},[14,25002,23790],{}," when:"," you're starting fresh, you care about cold-cache speed (CI, contributor onboarding, Docker layers), or you want PEP 621-native metadata and a single static binary with no Python bootstrap of its own. uv also bundles ",[14,25006,24159],{}," for shipping the finished CLI globally, so the whole lifecycle lives in one tool.",[10,25009,25010,25014,25015,25017,25018,25020,25021,61],{},[72,25011,7271,25012,25003],{},[14,25013,24280],{}," your team already standardizes on Poetry, you depend on its mature publishing flow (",[14,25016,20843],{},") or its dependency-group ergonomics, or your CI and tooling are already wired around ",[14,25019,20983],{},". Poetry remains a solid, well-documented choice — the deeper end-to-end workflow is covered in ",[97,25022,259],{"href":258},[10,25024,25025,25026,25028],{},"The good news is the choice is reversible. Because both converge on PEP 621 ",[14,25027,21000],{}," metadata, switching mostly means regenerating the lockfile — the entry points and dependency declarations carry over unchanged.",[36,25030,4512],{"id":4511},[41,25032,25033,25047,25053,25064],{},[44,25034,25035,25038,25039,459,25041,25043,25044,25046],{},[72,25036,25037],{},"Commit the lockfile, ignore the venv."," Whichever tool you pick, ",[14,25040,23755],{},[14,25042,20983],{}," belongs in version control and ",[14,25045,22015],{}," does not.",[44,25048,25049,25052],{},[72,25050,25051],{},"Pin the tool version in CI."," Resolver behavior evolves; pin uv or Poetry in your CI image so a tool upgrade can't silently change a resolution.",[44,25054,25055,25058,25059,1993,25061,25063],{},[72,25056,25057],{},"Don't keep both lockfiles."," A repo with both ",[14,25060,23755],{},[14,25062,20983],{}," invites drift — delete the one you're not using.",[44,25065,25066,25069],{},[72,25067,25068],{},"Cross-platform CLIs favor uv.lock."," If you ship wheels for Windows, macOS, and Linux, uv's universal lock removes a class of \"works on my OS\" resolution bugs.",[36,25071,1277],{"id":1276},[41,25073,25074,25079,25084],{},[44,25075,25076,25078],{},[97,25077,19011],{"href":19010}," — the uv command reference this comparison builds on.",[44,25080,25081,25083],{},[97,25082,259],{"href":258}," — the full Poetry lifecycle for CLI projects.",[44,25085,25086,24232],{},[97,25087,1423],{"href":1422},[1303,25089,25090],{},"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 .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}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":131,"searchDepth":152,"depth":152,"links":25092},[25093,25094,25095,25096,25098,25101,25102,25103],{"id":38,"depth":152,"text":39},{"id":24352,"depth":152,"text":24353},{"id":24604,"depth":152,"text":24605},{"id":24766,"depth":152,"text":25097},"Lockfile strategy: uv.lock vs poetry.lock",{"id":24836,"depth":152,"text":24837,"children":25099},[25100],{"id":24929,"depth":162,"text":24930},{"id":24995,"depth":152,"text":24996},{"id":4511,"depth":152,"text":4512},{"id":1276,"depth":152,"text":1277},"Compare uv init and poetry init for Python CLI projects — lockfile strategy, bootstrapping speed, entry point syntax, and pyproject.toml compatibility.",{},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management\u002Fuv-init-vs-poetry-init-for-cli-tools",{"title":19091,"description":25104},"project-setup-dependency-management\u002Fuv-for-python-cli-dependency-management\u002Fuv-init-vs-poetry-init-for-cli-tools\u002Findex",[18833,19245,1330,13073,419],"p5gNvS39T4VJnjKKeFlh-az8Vm-spOcLYyV0U2yara8",{"id":25112,"title":25113,"body":25114,"date":1320,"description":25368,"difficulty":1457,"draft":1323,"extension":1324,"meta":25369,"navigation":183,"path":25370,"seo":25371,"stem":25372,"tags":25373,"updated":1320,"__hash__":25376},"content\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Findex.md","Python CLI Env Isolation Best Practices",{"type":7,"value":25115,"toc":25360},[25116,25119,25125,25129,25146,25150,25153,25198,25205,25224,25227,25231,25234,25281,25284,25288,25301,25305,25322,25338,25340,25357],[10,25117,25118],{},"A Python CLI that works on your machine and nowhere else almost always has the same\nroot cause: it leaked into the system interpreter. Isolation is the discipline that\nkeeps each tool's dependencies in their own sandbox, so an upgrade for one project\nnever silently breaks another — and so the build that passes in CI is the build your\nusers run. This hub explains why isolation matters specifically for CLIs and routes\nyou to the cross-platform details.",[10,25120,25121],{},[104,25122],{"alt":25123,"src":25124},"System Python at the top with a \"don't install here\" warning, above three isolated per-project virtual environments — project-a\u002F.venv, project-b\u002F.venv, and a tool env — each pinning its own dependency versions without conflict.","\u002Fillustrations\u002Fenv-isolation.svg",[36,25126,25128],{"id":25127},"the-golden-rule","The golden rule",[10,25130,25131,25134,25135,25137,25138,25141,25142,25145],{},[72,25132,25133],{},"Never install packages into the system Python."," On macOS and most Linux distros the\nsystem interpreter is owned by the OS package manager; ",[14,25136,16],{}," into it (or worse,\n",[14,25139,25140],{},"sudo pip install",") can corrupt the tools your OS depends on. Newer Python builds even\nrefuse with an ",[14,25143,25144],{},"externally-managed-environment"," error (PEP 668) — that error is a\nfeature, not an obstacle. Every dependency belongs in a virtual environment.",[36,25147,25149],{"id":25148},"three-ways-to-isolate-and-when-each-fits","Three ways to isolate (and when each fits)",[10,25151,25152],{},"The Python ecosystem gives you overlapping tools. They are not competitors so much as\nlayers of the same problem.",[41,25154,25155,25166,25189],{},[44,25156,25157,25161,25162,25165],{},[72,25158,25159],{},[14,25160,19116],{}," (standard library) creates a per-project environment from whatever\ninterpreter invoked it. It is universal, zero-install, and the right default for\nunderstanding what is happening. Its weakness is that it inherits the Python version\nyou happened to run ",[14,25163,25164],{},"python -m venv"," with.",[44,25167,25168,25171,25172,25174,25175,21926,25177,25179,25180,21817,25183,25185,25186,25188],{},[72,25169,25170],{},"uv-managed environments"," wrap ",[14,25173,19116],{}," with a fast resolver, a lockfile, and — via\n",[14,25176,24082],{},[14,25178,29],{}," — the ability to ",[23,25181,25182],{},"download and pin the\ninterpreter itself",[14,25184,23759],{}," recreates an identical environment from the lockfile,\nwhich is what makes builds reproducible. See\n",[97,25187,19011],{"href":19010},"\nfor the full workflow.",[44,25190,25191,25193,25194,25197],{},[72,25192,19121],{}," manages multiple Python ",[23,25195,25196],{},"versions"," on one machine. It does not isolate\ndependencies — you still create a venv on top of it — but it is how you guarantee that\n\"Python 3.12\" means the same patch release on your laptop and in CI.",[10,25199,25200,25201,25204],{},"For ",[72,25202,25203],{},"end-user installation",", the picture flips. Your users should not create a venv to\nrun your tool. Distribute it so each install is isolated automatically:",[41,25206,25207,25217],{},[44,25208,25209,25213,25214,25216],{},[72,25210,25211],{},[14,25212,24748],{}," installs a CLI into its own dedicated venv and puts only the entry-point\nscripts on the user's ",[14,25215,33],{},". One tool per environment, no cross-contamination.",[44,25218,25219,25223],{},[72,25220,25221],{},[14,25222,24159],{}," does the same thing, faster, and can manage the interpreter too.",[10,25225,25226],{},"Both give end users the isolation guarantee without asking them to know what a venv is.",[36,25228,25230],{"id":25229},"reproducibility-in-ci","Reproducibility in CI",[10,25232,25233],{},"Isolation is what makes CI trustworthy. The pattern is the same on every provider:\ncreate a fresh environment, install from a lockfile, never reuse a mutated global\nstate. With uv that is two commands the runner can cache deterministically:",[126,25235,25237],{"className":634,"code":25236,"language":636,"meta":131,"style":131},"# Reproducible CI environment — fresh and lockfile-driven.\nuv python install 3.12        # pin the interpreter version\nuv sync --frozen              # install exactly what the lockfile specifies\nuv run pytest                 # run inside the isolated env, no activation needed\n",[14,25238,25239,25244,25258,25270],{"__ignoreMap":131},[135,25240,25241],{"class":137,"line":138},[135,25242,25243],{"class":669},"# Reproducible CI environment — fresh and lockfile-driven.\n",[135,25245,25246,25248,25250,25252,25255],{"class":137,"line":152},[135,25247,18833],{"class":145},[135,25249,646],{"class":158},[135,25251,18847],{"class":158},[135,25253,25254],{"class":350}," 3.12",[135,25256,25257],{"class":669},"        # pin the interpreter version\n",[135,25259,25260,25262,25264,25267],{"class":137,"line":162},[135,25261,18833],{"class":145},[135,25263,24048],{"class":158},[135,25265,25266],{"class":350}," --frozen",[135,25268,25269],{"class":669},"              # install exactly what the lockfile specifies\n",[135,25271,25272,25274,25276,25278],{"class":137,"line":171},[135,25273,18833],{"class":145},[135,25275,20944],{"class":158},[135,25277,20925],{"class":158},[135,25279,25280],{"class":669},"                 # run inside the isolated env, no activation needed\n",[10,25282,25283],{},"Run a matrix across the Python versions you support, and pin them explicitly so a\nrunner image upgrade can't quietly change your interpreter underneath you.",[36,25285,25287],{"id":25286},"activation-free-execution","Activation-free execution",[10,25289,25290,25291,25293,25294,25297,25298,25300],{},"Activation (",[14,25292,24101],{},") is convenient at a terminal but fragile in\nscripts, Makefiles, and CI — it mutates shell state that doesn't survive a subprocess.\nPrefer calling the environment's interpreter directly (",[14,25295,25296],{},"\u002F.venv\u002Fbin\u002Fpython -m yourtool",")\nor ",[14,25299,24076],{},", both of which work without touching the shell. This matters most when the\nsame command has to run across Linux, macOS, and Windows, where activation scripts and\ndirectory layouts diverge.",[36,25302,25304],{"id":25303},"go-deeper-cross-platform-mechanics","Go deeper: cross-platform mechanics",[10,25306,25307,25308,25311,25312,25314,25315,25317,25318,25321],{},"The trade-offs above are platform-agnostic, but the ",[23,25309,25310],{},"mechanics"," of activation, ",[14,25313,33],{},"\nresolution, and interpreter discovery differ sharply between operating systems — ",[14,25316,304],{},"\nversus ",[14,25319,25320],{},"Scripts\u002F",", four different activation scripts, shebangs that only exist on POSIX.",[41,25323,25324],{},[44,25325,25326,25331,25332,25334,25335,25337],{},[72,25327,25328],{},[97,25329,25330],{"href":19125},"Managing Python CLI virtual environments","\n— venv layout differences, activation across bash\u002Fzsh\u002Ffish\u002FPowerShell, ",[14,25333,33],{},"\nresolution, interpreter discovery, shebang versus ",[14,25336,13515],{},", and using uv and pyenv\nfor consistent interpreters on Linux, macOS, and Windows. Includes a portable Python\nsnippet that introspects the running environment.",[36,25339,1277],{"id":1276},[41,25341,25342,25347,25352],{},[44,25343,25344,25346],{},[97,25345,1423],{"href":1422}," — the\nfull track this topic belongs to.",[44,25348,25349,25351],{},[97,25350,19011],{"href":19010},"\n— lockfiles, interpreter pinning, and the resolver that powers reproducible isolation.",[44,25353,25354,25356],{},[97,25355,25330],{"href":19125},"\n— the cross-platform deep dive.",[1303,25358,25359],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":131,"searchDepth":152,"depth":152,"links":25361},[25362,25363,25364,25365,25366,25367],{"id":25127,"depth":152,"text":25128},{"id":25148,"depth":152,"text":25149},{"id":25229,"depth":152,"text":25230},{"id":25286,"depth":152,"text":25287},{"id":25303,"depth":152,"text":25304},{"id":1276,"depth":152,"text":1277},"Isolate Python CLI dependencies with venv, uv, and pyenv to prevent conflicts and ensure reproducible execution across development and CI environments.",{},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices",{"title":25113,"description":25368},"project-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Findex",[19244,23727,18833,19121,25374,25375],"isolation","reproducibility","_QJI84Bb7CYhbFQIxaE9XzluWZo29NeRzyh_wX6LY1s",{"id":25378,"title":25379,"body":25380,"date":1320,"description":26657,"difficulty":1322,"draft":1323,"extension":1324,"meta":26658,"navigation":183,"path":26659,"seo":26660,"stem":26661,"tags":26662,"updated":1320,"__hash__":26665},"content\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fmanaging-virtual-environments-for-cross-platform-clis\u002Findex.md","Managing Python CLI Virtual Environments",{"type":7,"value":25381,"toc":26645},[25382,25394,25396,25448,25454,25458,25461,25592,25659,25676,25680,25690,25723,25752,25758,25767,25776,25780,25787,25807,25822,25828,25862,25868,25890,25914,25918,25937,26406,26409,26415,26442,26446,26449,26493,26539,26556,26558,26619,26621,26642],[10,25383,25384,25385,25387,25388,25390,25391,25393],{},"A virtual environment looks the same in your ",[14,25386,29],{}," on every platform, but on\ndisk it is not the same shape. The directory that holds your interpreter and console\nscripts is ",[14,25389,304],{}," on Linux and macOS and ",[14,25392,25320],{}," on Windows; the activation script\ncomes in four flavours; and the shebang line that makes a wrapper executable on POSIX\nmeans nothing on Windows. If your CLI's setup docs assume one platform, half your\ncontributors hit a wall on the first command. This guide shows how to manage venvs so\nthe same instructions work everywhere — and how to write code that discovers its own\nenvironment without guessing.",[36,25395,39],{"id":38},[41,25397,25398,25414,25435,25441],{},[44,25399,25400,25401,25403,25404,25406,25407,25410,25411,61],{},"The interpreter lives in ",[14,25402,304],{}," on POSIX, ",[14,25405,25320],{}," on Windows. ",[72,25408,25409],{},"Never hardcode\neither"," — ask ",[14,25412,25413],{},"sysconfig.get_path(\"scripts\")",[44,25415,25416,25417,25420,25421,3238,25424,25427,25428,20694,25431,25434],{},"Activation scripts differ per shell: ",[14,25418,25419],{},"activate"," (bash\u002Fzsh), ",[14,25422,25423],{},"activate.fish",[14,25425,25426],{},"Activate.ps1"," (PowerShell). In scripts and CI, skip activation entirely and call\n",[14,25429,25430],{},".venv\u002Fbin\u002Fpython",[14,25432,25433],{},".venv\\Scripts\\python.exe"," directly.",[44,25436,17117,25437,25440],{},[14,25438,25439],{},"python -m yourtool"," over relying on a shebang — shebangs are POSIX-only.",[44,25442,11778,25443,18950,25445,25447],{},[72,25444,18833],{},[72,25446,19121],{}," to pin one interpreter version across all three OSes so \"3.12\"\nmeans the same build everywhere.",[10,25449,25450],{},[104,25451],{"alt":25452,"src":25453},"Two side-by-side venv trees: on Linux\u002FmacOS the .venv\u002Fbin\u002F folder holds python, activate, and mytool; on Windows the .venv\\Scripts\\ folder holds python.exe, activate.bat, and mytool.exe — the differing bin vs Scripts folder is highlighted.","\u002Fillustrations\u002Fvenv-layout-cross-platform.svg",[36,25455,25457],{"id":25456},"the-directory-layout-differs-by-platform","The directory layout differs by platform",[10,25459,25460],{},"Create a venv and the structure depends on the OS:",[126,25462,25464],{"className":634,"code":25463,"language":636,"meta":131,"style":131},"# Linux \u002F macOS\npython -m venv .venv\n.venv\u002F\n├── bin\u002F\n│   ├── python        -> symlink to the interpreter\n│   ├── pip\n│   ├── activate      # bash \u002F zsh\n│   ├── activate.fish # fish\n│   └── yourtool      # your console_scripts entry point\n├── lib\u002F\n│   └── python3.12\u002Fsite-packages\u002F\n└── pyvenv.cfg\n",[14,25465,25466,25471,25483,25488,25496,25522,25531,25543,25555,25568,25575,25584],{"__ignoreMap":131},[135,25467,25468],{"class":137,"line":138},[135,25469,25470],{"class":669},"# Linux \u002F macOS\n",[135,25472,25473,25475,25478,25480],{"class":137,"line":152},[135,25474,318],{"class":145},[135,25476,25477],{"class":350}," -m",[135,25479,18836],{"class":158},[135,25481,25482],{"class":158}," .venv\n",[135,25484,25485],{"class":137,"line":162},[135,25486,25487],{"class":145},".venv\u002F\n",[135,25489,25490,25493],{"class":137,"line":171},[135,25491,25492],{"class":145},"├──",[135,25494,25495],{"class":158}," bin\u002F\n",[135,25497,25498,25501,25504,25506,25509,25511,25514,25516,25519],{"class":137,"line":180},[135,25499,25500],{"class":145},"│",[135,25502,25503],{"class":158},"   ├──",[135,25505,646],{"class":158},[135,25507,25508],{"class":141},"        -",[135,25510,1904],{"class":325},[135,25512,25513],{"class":158}," symlink",[135,25515,20267],{"class":158},[135,25517,25518],{"class":158}," the",[135,25520,25521],{"class":158}," interpreter\n",[135,25523,25524,25526,25528],{"class":137,"line":187},[135,25525,25500],{"class":145},[135,25527,25503],{"class":158},[135,25529,25530],{"class":158}," pip\n",[135,25532,25533,25535,25537,25540],{"class":137,"line":201},[135,25534,25500],{"class":145},[135,25536,25503],{"class":158},[135,25538,25539],{"class":158}," activate",[135,25541,25542],{"class":669},"      # bash \u002F zsh\n",[135,25544,25545,25547,25549,25552],{"class":137,"line":210},[135,25546,25500],{"class":145},[135,25548,25503],{"class":158},[135,25550,25551],{"class":158}," activate.fish",[135,25553,25554],{"class":669}," # fish\n",[135,25556,25557,25559,25562,25565],{"class":137,"line":215},[135,25558,25500],{"class":145},[135,25560,25561],{"class":158},"   └──",[135,25563,25564],{"class":158}," yourtool",[135,25566,25567],{"class":669},"      # your console_scripts entry point\n",[135,25569,25570,25572],{"class":137,"line":225},[135,25571,25492],{"class":145},[135,25573,25574],{"class":158}," lib\u002F\n",[135,25576,25577,25579,25581],{"class":137,"line":236},[135,25578,25500],{"class":145},[135,25580,25561],{"class":158},[135,25582,25583],{"class":158}," python3.12\u002Fsite-packages\u002F\n",[135,25585,25586,25589],{"class":137,"line":606},[135,25587,25588],{"class":145},"└──",[135,25590,25591],{"class":158}," pyvenv.cfg\n",[126,25593,25597],{"className":25594,"code":25595,"language":25596,"meta":131,"style":131},"language-powershell shiki shiki-themes github-light github-dark","# Windows (PowerShell)\npy -m venv .venv\n.venv\\\n├── Scripts\\\n│   ├── python.exe\n│   ├── pip.exe\n│   ├── activate.bat   # cmd.exe\n│   ├── Activate.ps1   # PowerShell\n│   └── yourtool.exe   # your console_scripts entry point\n├── Lib\\\n│   └── site-packages\\\n└── pyvenv.cfg\n","powershell",[14,25598,25599,25604,25609,25614,25619,25624,25629,25634,25639,25644,25649,25654],{"__ignoreMap":131},[135,25600,25601],{"class":137,"line":138},[135,25602,25603],{},"# Windows (PowerShell)\n",[135,25605,25606],{"class":137,"line":152},[135,25607,25608],{},"py -m venv .venv\n",[135,25610,25611],{"class":137,"line":162},[135,25612,25613],{},".venv\\\n",[135,25615,25616],{"class":137,"line":171},[135,25617,25618],{},"├── Scripts\\\n",[135,25620,25621],{"class":137,"line":180},[135,25622,25623],{},"│   ├── python.exe\n",[135,25625,25626],{"class":137,"line":187},[135,25627,25628],{},"│   ├── pip.exe\n",[135,25630,25631],{"class":137,"line":201},[135,25632,25633],{},"│   ├── activate.bat   # cmd.exe\n",[135,25635,25636],{"class":137,"line":210},[135,25637,25638],{},"│   ├── Activate.ps1   # PowerShell\n",[135,25640,25641],{"class":137,"line":215},[135,25642,25643],{},"│   └── yourtool.exe   # your console_scripts entry point\n",[135,25645,25646],{"class":137,"line":225},[135,25647,25648],{},"├── Lib\\\n",[135,25650,25651],{"class":137,"line":236},[135,25652,25653],{},"│   └── site-packages\\\n",[135,25655,25656],{"class":137,"line":606},[135,25657,25658],{},"└── pyvenv.cfg\n",[10,25660,25661,25662,10713,25665,25668,25669,25671,25672,25675],{},"Two consequences for cross-platform tooling: the executable directory name changes\n(",[14,25663,25664],{},"bin",[14,25666,25667],{},"Scripts","), and Windows wrappers carry a ",[14,25670,312],{}," suffix while POSIX ones don't.\nAny code or Makefile that joins ",[14,25673,25674],{},"\".venv\u002Fbin\u002Fpython\""," is already broken on Windows.",[36,25677,25679],{"id":25678},"activation-across-shells","Activation across shells",[10,25681,25682,25683,25685,25686,25689],{},"Activation just prepends the venv's script directory to ",[14,25684,33],{}," and sets a few\nenvironment variables for the current shell session. The script you ",[14,25687,25688],{},"source"," depends on\nyour shell:",[126,25691,25693],{"className":634,"code":25692,"language":636,"meta":131,"style":131},"# bash \u002F zsh (Linux, macOS)\nsource .venv\u002Fbin\u002Factivate\n\n# fish\nsource .venv\u002Fbin\u002Factivate.fish\n",[14,25694,25695,25700,25707,25711,25716],{"__ignoreMap":131},[135,25696,25697],{"class":137,"line":138},[135,25698,25699],{"class":669},"# bash \u002F zsh (Linux, macOS)\n",[135,25701,25702,25704],{"class":137,"line":152},[135,25703,25688],{"class":350},[135,25705,25706],{"class":158}," .venv\u002Fbin\u002Factivate\n",[135,25708,25709],{"class":137,"line":162},[135,25710,184],{"emptyLinePlaceholder":183},[135,25712,25713],{"class":137,"line":171},[135,25714,25715],{"class":669},"# fish\n",[135,25717,25718,25720],{"class":137,"line":180},[135,25719,25688],{"class":350},[135,25721,25722],{"class":158}," .venv\u002Fbin\u002Factivate.fish\n",[126,25724,25726],{"className":25594,"code":25725,"language":25596,"meta":131,"style":131},"# PowerShell (Windows, and PowerShell Core on macOS\u002FLinux)\n.venv\\Scripts\\Activate.ps1\n\n# cmd.exe\n.venv\\Scripts\\activate.bat\n",[14,25727,25728,25733,25738,25742,25747],{"__ignoreMap":131},[135,25729,25730],{"class":137,"line":138},[135,25731,25732],{},"# PowerShell (Windows, and PowerShell Core on macOS\u002FLinux)\n",[135,25734,25735],{"class":137,"line":152},[135,25736,25737],{},".venv\\Scripts\\Activate.ps1\n",[135,25739,25740],{"class":137,"line":162},[135,25741,184],{"emptyLinePlaceholder":183},[135,25743,25744],{"class":137,"line":171},[135,25745,25746],{},"# cmd.exe\n",[135,25748,25749],{"class":137,"line":180},[135,25750,25751],{},".venv\\Scripts\\activate.bat\n",[10,25753,25754,25755,25757],{},"If ",[14,25756,25426],{}," fails with an execution-policy error, allow signed local scripts for\nthe current user once:",[126,25759,25761],{"className":25594,"code":25760,"language":25596,"meta":131,"style":131},"Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned\n",[14,25762,25763],{"__ignoreMap":131},[135,25764,25765],{"class":137,"line":138},[135,25766,25760],{},[10,25768,25769,25770,765,25773,25775],{},"Activation is a developer convenience, not a deployment mechanism. It mutates ",[23,25771,25772],{},"this\nshell's",[14,25774,33],{},", and that change does not propagate into subprocesses, Makefile recipes,\nor CI steps that spawn a new shell. The robust alternative is to never activate.",[36,25777,25779],{"id":25778},"skip-activation-call-the-interpreter-directly","Skip activation: call the interpreter directly",[10,25781,25782,25783,25786],{},"The most portable way to run a tool from its environment is to invoke the environment's\nown Python and let the ",[14,25784,25785],{},"-m"," flag find your package:",[126,25788,25790],{"className":634,"code":25789,"language":636,"meta":131,"style":131},"# POSIX — no activation, works in scripts and CI\n.venv\u002Fbin\u002Fpython -m yourtool --help\n",[14,25791,25792,25797],{"__ignoreMap":131},[135,25793,25794],{"class":137,"line":138},[135,25795,25796],{"class":669},"# POSIX — no activation, works in scripts and CI\n",[135,25798,25799,25801,25803,25805],{"class":137,"line":152},[135,25800,25430],{"class":145},[135,25802,25477],{"class":350},[135,25804,25564],{"class":158},[135,25806,24119],{"class":350},[126,25808,25810],{"className":25594,"code":25809,"language":25596,"meta":131,"style":131},"# Windows — same idea, different path\n.venv\\Scripts\\python.exe -m yourtool --help\n",[14,25811,25812,25817],{"__ignoreMap":131},[135,25813,25814],{"class":137,"line":138},[135,25815,25816],{},"# Windows — same idea, different path\n",[135,25818,25819],{"class":137,"line":152},[135,25820,25821],{},".venv\\Scripts\\python.exe -m yourtool --help\n",[10,25823,25824,25825,25827],{},"Better still, let uv resolve the environment for you so you don't branch on the path at\nall. ",[14,25826,24076],{}," finds (or creates) the project's venv and runs the command inside it on\nevery platform identically:",[126,25829,25831],{"className":634,"code":25830,"language":636,"meta":131,"style":131},"uv run yourtool --help     # identical command on Linux, macOS, Windows\nuv run python -m yourtool  # or invoke the module directly\n",[14,25832,25833,25847],{"__ignoreMap":131},[135,25834,25835,25837,25839,25841,25844],{"class":137,"line":138},[135,25836,18833],{"class":145},[135,25838,20944],{"class":158},[135,25840,25564],{"class":158},[135,25842,25843],{"class":350}," --help",[135,25845,25846],{"class":669},"     # identical command on Linux, macOS, Windows\n",[135,25848,25849,25851,25853,25855,25857,25859],{"class":137,"line":152},[135,25850,18833],{"class":145},[135,25852,20944],{"class":158},[135,25854,646],{"class":158},[135,25856,25477],{"class":350},[135,25858,25564],{"class":158},[135,25860,25861],{"class":669},"  # or invoke the module directly\n",[36,25863,25865,25866],{"id":25864},"shebang-vs-python-m","Shebang vs ",[14,25867,13515],{},[10,25869,25870,25871,25874,25875,25878,25879,25882,25883,25885,25886,25889],{},"On POSIX, a console-script wrapper starts with a shebang pointing at the venv's\ninterpreter, e.g. ",[14,25872,25873],{},"#!\u002Fpath\u002Fto\u002F.venv\u002Fbin\u002Fpython",". That is what lets you type ",[14,25876,25877],{},"yourtool","\nafter activation. But shebangs are a kernel feature that ",[72,25880,25881],{},"Windows ignores entirely"," —\nthere, the ",[14,25884,312],{}," launcher does the equivalent job. So a script that shells out with a\nhardcoded ",[14,25887,25888],{},"#!\u002Fusr\u002Fbin\u002Fenv python3"," will behave differently across platforms, and one\nthat assumes the shebang is honoured won't run on Windows at all.",[10,25891,25892,25893,25896,25897,17094,25899,25901,25902,25905,25906,459,25908,25910,25911,25913],{},"The portable rule: ",[72,25894,25895],{},"don't depend on the shebang for cross-platform execution."," Invoke\nmodules with ",[14,25898,25439],{},[14,25900,25785],{}," form uses the ",[23,25903,25904],{},"current"," interpreter, so it\nruns against the right environment whether you activated it or called its Python\ndirectly. Reserve the console-script entry point for the end-user convenience case where\n",[14,25907,24748],{},[14,25909,24159],{}," put a launcher on ",[14,25912,33],{}," for them.",[36,25915,25917],{"id":25916},"interpreter-discovery-from-inside-your-cli","Interpreter discovery from inside your CLI",[10,25919,25920,25921,25923,25924,25926,25927,25929,25930,25932,25933,25936],{},"Sometimes your CLI needs to find its own environment — to locate a sibling console\nscript, write a config next to the interpreter, or spawn a subprocess with the same\nPython. Hardcoding ",[14,25922,304],{}," breaks on Windows; the standard library tells you the truth\ninstead. ",[14,25925,25413],{}," returns ",[14,25928,25664],{}," on POSIX and ",[14,25931,25667],{}," on\nWindows, and ",[14,25934,25935],{},"sys.prefix != sys.base_prefix"," reliably detects whether you're inside a\nvenv:",[126,25938,25940],{"className":316,"code":25939,"language":318,"meta":131,"style":131},"r\"\"\"Locate a virtual environment's executable directory cross-platform.\n\nWorks on Linux, macOS (bin\u002F) and Windows (Scripts\u002F) without hardcoding\nthe directory name. Run with the interpreter you want to inspect:\n\n    path\u002Fto\u002F.venv\u002Fbin\u002Fpython introspect.py         # POSIX\n    path\\to\\.venv\\Scripts\\python.exe introspect.py  # Windows\n\"\"\"\n\nfrom __future__ import annotations\n\nimport sys\nimport sysconfig\nfrom pathlib import Path\n\n\ndef in_virtualenv() -> bool:\n    # base_prefix differs from prefix only inside a venv\u002Fvirtualenv.\n    return sys.prefix != sys.base_prefix\n\n\ndef scripts_dir() -> Path:\n    # sysconfig knows the right layout for this interpreter and platform:\n    # 'Scripts' on Windows, 'bin' on POSIX. Never hardcode either name.\n    return Path(sysconfig.get_path(\"scripts\"))\n\n\ndef console_script(name: str) -> Path:\n    exe = scripts_dir() \u002F name\n    # Console-script wrappers gain a .exe suffix on Windows.\n    if sys.platform == \"win32\":\n        exe = exe.with_suffix(\".exe\")\n    return exe\n\n\ndef main() -> None:\n    print(f\"interpreter      : {sys.executable}\")\n    print(f\"version          : {sys.version.split()[0]}\")\n    print(f\"platform         : {sys.platform}\")\n    print(f\"in virtualenv    : {in_virtualenv()}\")\n    print(f\"sys.prefix       : {sys.prefix}\")\n    print(f\"sys.base_prefix  : {sys.base_prefix}\")\n    print(f\"scripts dir      : {scripts_dir()}\")\n    print(f\"this CLI wrapper : {console_script('mytool')}\")\n\n\nif __name__ == \"__main__\":\n    main()\n",[14,25941,25942,25949,25953,25958,25963,25967,25972,25977,25981,25985,25995,25999,26005,26012,26022,26026,26030,26043,26048,26060,26064,26068,26077,26082,26087,26099,26103,26107,26120,26135,26140,26154,26169,26176,26180,26184,26196,26218,26244,26266,26288,26310,26332,26354,26381,26385,26389,26401],{"__ignoreMap":131},[135,25943,25944,25946],{"class":137,"line":138},[135,25945,18259],{"class":325},[135,25947,25948],{"class":158},"\"\"\"Locate a virtual environment's executable directory cross-platform.\n",[135,25950,25951],{"class":137,"line":152},[135,25952,184],{"emptyLinePlaceholder":183},[135,25954,25955],{"class":137,"line":162},[135,25956,25957],{"class":158},"Works on Linux, macOS (bin\u002F) and Windows (Scripts\u002F) without hardcoding\n",[135,25959,25960],{"class":137,"line":171},[135,25961,25962],{"class":158},"the directory name. Run with the interpreter you want to inspect:\n",[135,25964,25965],{"class":137,"line":180},[135,25966,184],{"emptyLinePlaceholder":183},[135,25968,25969],{"class":137,"line":187},[135,25970,25971],{"class":158},"    path\u002Fto\u002F.venv\u002Fbin\u002Fpython introspect.py         # POSIX\n",[135,25973,25974],{"class":137,"line":201},[135,25975,25976],{"class":158},"    path\\to\\.venv\\Scripts\\python.exe introspect.py  # Windows\n",[135,25978,25979],{"class":137,"line":210},[135,25980,20354],{"class":158},[135,25982,25983],{"class":137,"line":215},[135,25984,184],{"emptyLinePlaceholder":183},[135,25986,25987,25989,25991,25993],{"class":137,"line":225},[135,25988,334],{"class":325},[135,25990,2586],{"class":350},[135,25992,2589],{"class":325},[135,25994,2592],{"class":141},[135,25996,25997],{"class":137,"line":236},[135,25998,184],{"emptyLinePlaceholder":183},[135,26000,26001,26003],{"class":137,"line":606},[135,26002,326],{"class":325},[135,26004,329],{"class":141},[135,26006,26007,26009],{"class":137,"line":619},[135,26008,326],{"class":325},[135,26010,26011],{"class":141}," sysconfig\n",[135,26013,26014,26016,26018,26020],{"class":137,"line":1752},[135,26015,334],{"class":325},[135,26017,4835],{"class":141},[135,26019,326],{"class":325},[135,26021,4840],{"class":141},[135,26023,26024],{"class":137,"line":1765},[135,26025,184],{"emptyLinePlaceholder":183},[135,26027,26028],{"class":137,"line":1774},[135,26029,184],{"emptyLinePlaceholder":183},[135,26031,26032,26034,26037,26039,26041],{"class":137,"line":1795},[135,26033,493],{"class":325},[135,26035,26036],{"class":145}," in_virtualenv",[135,26038,499],{"class":141},[135,26040,5112],{"class":350},[135,26042,360],{"class":141},[135,26044,26045],{"class":137,"line":1830},[135,26046,26047],{"class":669},"    # base_prefix differs from prefix only inside a venv\u002Fvirtualenv.\n",[135,26049,26050,26052,26055,26057],{"class":137,"line":1846},[135,26051,596],{"class":325},[135,26053,26054],{"class":141}," sys.prefix ",[135,26056,10265],{"class":325},[135,26058,26059],{"class":141}," sys.base_prefix\n",[135,26061,26062],{"class":137,"line":1854},[135,26063,184],{"emptyLinePlaceholder":183},[135,26065,26066],{"class":137,"line":1859},[135,26067,184],{"emptyLinePlaceholder":183},[135,26069,26070,26072,26075],{"class":137,"line":1877},[135,26071,493],{"class":325},[135,26073,26074],{"class":145}," scripts_dir",[135,26076,4875],{"class":141},[135,26078,26079],{"class":137,"line":1893},[135,26080,26081],{"class":669},"    # sysconfig knows the right layout for this interpreter and platform:\n",[135,26083,26084],{"class":137,"line":1926},[135,26085,26086],{"class":669},"    # 'Scripts' on Windows, 'bin' on POSIX. Never hardcode either name.\n",[135,26088,26089,26091,26094,26097],{"class":137,"line":1940},[135,26090,596],{"class":325},[135,26092,26093],{"class":141}," Path(sysconfig.get_path(",[135,26095,26096],{"class":158},"\"scripts\"",[135,26098,1054],{"class":141},[135,26100,26101],{"class":137,"line":2906},[135,26102,184],{"emptyLinePlaceholder":183},[135,26104,26105],{"class":137,"line":2931},[135,26106,184],{"emptyLinePlaceholder":183},[135,26108,26109,26111,26114,26116,26118],{"class":137,"line":2944},[135,26110,493],{"class":325},[135,26112,26113],{"class":145}," console_script",[135,26115,18114],{"class":141},[135,26117,1663],{"class":350},[135,26119,4949],{"class":141},[135,26121,26122,26125,26127,26130,26132],{"class":137,"line":3266},[135,26123,26124],{"class":141},"    exe ",[135,26126,516],{"class":325},[135,26128,26129],{"class":141}," scripts_dir() ",[135,26131,459],{"class":325},[135,26133,26134],{"class":141}," name\n",[135,26136,26137],{"class":137,"line":3277},[135,26138,26139],{"class":669},"    # Console-script wrappers gain a .exe suffix on Windows.\n",[135,26141,26142,26144,26147,26149,26152],{"class":137,"line":3290},[135,26143,530],{"class":325},[135,26145,26146],{"class":141}," sys.platform ",[135,26148,1815],{"class":325},[135,26150,26151],{"class":158}," \"win32\"",[135,26153,360],{"class":141},[135,26155,26156,26159,26161,26164,26167],{"class":137,"line":3295},[135,26157,26158],{"class":141},"        exe ",[135,26160,516],{"class":325},[135,26162,26163],{"class":141}," exe.with_suffix(",[135,26165,26166],{"class":158},"\".exe\"",[135,26168,550],{"class":141},[135,26170,26171,26173],{"class":137,"line":3315},[135,26172,596],{"class":325},[135,26174,26175],{"class":141}," exe\n",[135,26177,26178],{"class":137,"line":3329},[135,26179,184],{"emptyLinePlaceholder":183},[135,26181,26182],{"class":137,"line":3337},[135,26183,184],{"emptyLinePlaceholder":183},[135,26185,26186,26188,26190,26192,26194],{"class":137,"line":3350},[135,26187,493],{"class":325},[135,26189,496],{"class":145},[135,26191,499],{"class":141},[135,26193,3093],{"class":350},[135,26195,360],{"class":141},[135,26197,26198,26200,26202,26204,26207,26209,26212,26214,26216],{"class":137,"line":3365},[135,26199,563],{"class":350},[135,26201,544],{"class":141},[135,26203,568],{"class":325},[135,26205,26206],{"class":158},"\"interpreter      : ",[135,26208,574],{"class":350},[135,26210,26211],{"class":141},"sys.executable",[135,26213,586],{"class":350},[135,26215,589],{"class":158},[135,26217,550],{"class":141},[135,26219,26220,26222,26224,26226,26229,26231,26234,26236,26238,26240,26242],{"class":137,"line":3373},[135,26221,563],{"class":350},[135,26223,544],{"class":141},[135,26225,568],{"class":325},[135,26227,26228],{"class":158},"\"version          : ",[135,26230,574],{"class":350},[135,26232,26233],{"class":141},"sys.version.split()[",[135,26235,580],{"class":350},[135,26237,583],{"class":141},[135,26239,586],{"class":350},[135,26241,589],{"class":158},[135,26243,550],{"class":141},[135,26245,26246,26248,26250,26252,26255,26257,26260,26262,26264],{"class":137,"line":3406},[135,26247,563],{"class":350},[135,26249,544],{"class":141},[135,26251,568],{"class":325},[135,26253,26254],{"class":158},"\"platform         : ",[135,26256,574],{"class":350},[135,26258,26259],{"class":141},"sys.platform",[135,26261,586],{"class":350},[135,26263,589],{"class":158},[135,26265,550],{"class":141},[135,26267,26268,26270,26272,26274,26277,26279,26282,26284,26286],{"class":137,"line":3415},[135,26269,563],{"class":350},[135,26271,544],{"class":141},[135,26273,568],{"class":325},[135,26275,26276],{"class":158},"\"in virtualenv    : ",[135,26278,574],{"class":350},[135,26280,26281],{"class":141},"in_virtualenv()",[135,26283,586],{"class":350},[135,26285,589],{"class":158},[135,26287,550],{"class":141},[135,26289,26290,26292,26294,26296,26299,26301,26304,26306,26308],{"class":137,"line":3429},[135,26291,563],{"class":350},[135,26293,544],{"class":141},[135,26295,568],{"class":325},[135,26297,26298],{"class":158},"\"sys.prefix       : ",[135,26300,574],{"class":350},[135,26302,26303],{"class":141},"sys.prefix",[135,26305,586],{"class":350},[135,26307,589],{"class":158},[135,26309,550],{"class":141},[135,26311,26312,26314,26316,26318,26321,26323,26326,26328,26330],{"class":137,"line":3466},[135,26313,563],{"class":350},[135,26315,544],{"class":141},[135,26317,568],{"class":325},[135,26319,26320],{"class":158},"\"sys.base_prefix  : ",[135,26322,574],{"class":350},[135,26324,26325],{"class":141},"sys.base_prefix",[135,26327,586],{"class":350},[135,26329,589],{"class":158},[135,26331,550],{"class":141},[135,26333,26334,26336,26338,26340,26343,26345,26348,26350,26352],{"class":137,"line":3473},[135,26335,563],{"class":350},[135,26337,544],{"class":141},[135,26339,568],{"class":325},[135,26341,26342],{"class":158},"\"scripts dir      : ",[135,26344,574],{"class":350},[135,26346,26347],{"class":141},"scripts_dir()",[135,26349,586],{"class":350},[135,26351,589],{"class":158},[135,26353,550],{"class":141},[135,26355,26356,26358,26360,26362,26365,26367,26370,26373,26375,26377,26379],{"class":137,"line":3478},[135,26357,563],{"class":350},[135,26359,544],{"class":141},[135,26361,568],{"class":325},[135,26363,26364],{"class":158},"\"this CLI wrapper : ",[135,26366,574],{"class":350},[135,26368,26369],{"class":141},"console_script(",[135,26371,26372],{"class":158},"'mytool'",[135,26374,20254],{"class":141},[135,26376,586],{"class":350},[135,26378,589],{"class":158},[135,26380,550],{"class":141},[135,26382,26383],{"class":137,"line":3486},[135,26384,184],{"emptyLinePlaceholder":183},[135,26386,26387],{"class":137,"line":3500},[135,26388,184],{"emptyLinePlaceholder":183},[135,26390,26391,26393,26395,26397,26399],{"class":137,"line":3510},[135,26392,347],{"class":325},[135,26394,351],{"class":350},[135,26396,354],{"class":325},[135,26398,357],{"class":158},[135,26400,360],{"class":141},[135,26402,26403],{"class":137,"line":3522},[135,26404,26405],{"class":141},"    main()\n",[10,26407,26408],{},"Run against an isolated interpreter, this prints something like:",[126,26410,26413],{"className":26411,"code":26412,"language":5596,"meta":131},[5594],"interpreter      : \u002Ftmp\u002Fcli-validate\u002Fbin\u002Fpython\nversion          : 3.14.4\nplatform         : linux\nin virtualenv    : True\nsys.prefix       : \u002Ftmp\u002Fcli-validate\nsys.base_prefix  : \u002Fusr\nscripts dir      : \u002Ftmp\u002Fcli-validate\u002Fbin\nthis CLI wrapper : \u002Ftmp\u002Fcli-validate\u002Fbin\u002Fmytool\n",[14,26414,26412],{"__ignoreMap":131},[10,26416,26417,26418,26421,26422,26425,26426,26429,26430,26433,26434,26436,26437,26439,26440,61],{},"On Windows the same code prints ",[14,26419,26420],{},"scripts dir : ...\\Scripts"," and the wrapper as\n",[14,26423,26424],{},"mytool.exe"," — without a single platform branch in the discovery logic, because\n",[14,26427,26428],{},"sysconfig"," already encodes the platform's layout. When you need to spawn a subprocess\nwith the ",[23,26431,26432],{},"same"," interpreter, use ",[14,26435,26211],{}," rather than the bare name ",[14,26438,318],{},",\nwhich may resolve to a different install on ",[14,26441,33],{},[36,26443,26445],{"id":26444},"consistent-interpreters-with-uv-and-pyenv","Consistent interpreters with uv and pyenv",[10,26447,26448],{},"Layout differences are mechanical; version drift is insidious. If a contributor on\nmacOS runs 3.11 and CI runs 3.12, you get bugs that reproduce on exactly one machine.\nPin the interpreter so every platform agrees:",[126,26450,26452],{"className":634,"code":26451,"language":636,"meta":131,"style":131},"# uv: download and pin a specific CPython, then build the env from it\nuv python install 3.12\nuv venv --python 3.12      # creates .venv using that exact interpreter\nuv sync                    # install dependencies from the lockfile\n",[14,26453,26454,26459,26470,26484],{"__ignoreMap":131},[135,26455,26456],{"class":137,"line":138},[135,26457,26458],{"class":669},"# uv: download and pin a specific CPython, then build the env from it\n",[135,26460,26461,26463,26465,26467],{"class":137,"line":152},[135,26462,18833],{"class":145},[135,26464,646],{"class":158},[135,26466,18847],{"class":158},[135,26468,26469],{"class":350}," 3.12\n",[135,26471,26472,26474,26476,26479,26481],{"class":137,"line":162},[135,26473,18833],{"class":145},[135,26475,18836],{"class":158},[135,26477,26478],{"class":350}," --python",[135,26480,25254],{"class":350},[135,26482,26483],{"class":669},"      # creates .venv using that exact interpreter\n",[135,26485,26486,26488,26490],{"class":137,"line":171},[135,26487,18833],{"class":145},[135,26489,24048],{"class":158},[135,26491,26492],{"class":669},"                    # install dependencies from the lockfile\n",[126,26494,26496],{"className":634,"code":26495,"language":636,"meta":131,"style":131},"# pyenv (Linux\u002FmacOS; use pyenv-win on Windows): pin per-project\npyenv install 3.12.4\npyenv local 3.12.4         # writes .python-version\npython -m venv .venv       # the venv inherits 3.12.4\n",[14,26497,26498,26503,26512,26525],{"__ignoreMap":131},[135,26499,26500],{"class":137,"line":138},[135,26501,26502],{"class":669},"# pyenv (Linux\u002FmacOS; use pyenv-win on Windows): pin per-project\n",[135,26504,26505,26507,26509],{"class":137,"line":152},[135,26506,19121],{"class":145},[135,26508,18847],{"class":158},[135,26510,26511],{"class":350}," 3.12.4\n",[135,26513,26514,26516,26519,26522],{"class":137,"line":162},[135,26515,19121],{"class":145},[135,26517,26518],{"class":158}," local",[135,26520,26521],{"class":350}," 3.12.4",[135,26523,26524],{"class":669},"         # writes .python-version\n",[135,26526,26527,26529,26531,26533,26536],{"class":137,"line":171},[135,26528,318],{"class":145},[135,26530,25477],{"class":350},[135,26532,18836],{"class":158},[135,26534,26535],{"class":158}," .venv",[135,26537,26538],{"class":669},"       # the venv inherits 3.12.4\n",[10,26540,26541,26542,26545,26546,26548,26549,26552,26553,26555],{},"uv is the lower-friction option on CI and Windows because it ships a single static\nbinary and manages interpreters itself; pyenv is well established on developer machines\nbut needs ",[14,26543,26544],{},"pyenv-win"," (a separate project) for Windows. Either way, commit the version\npin (",[14,26547,24082],{}," plus the lockfile for uv, or ",[14,26550,26551],{},".python-version"," for pyenv) so the\nenvironment is reproducible rather than ambient. See\n",[97,26554,19011],{"href":19010},"\nfor the full lockfile workflow.",[36,26557,4512],{"id":4511},[41,26559,26560,26572,26588,26605],{},[44,26561,26562,26565,26566,5659,26568,26571],{},[72,26563,26564],{},"Don't commit the venv."," Add ",[14,26567,22015],{},[14,26569,26570],{},".gitignore",". The environment is derived\nfrom the lockfile and is platform-specific; checking it in guarantees breakage on the\nnext OS. Commit the lockfile instead.",[44,26573,26574,26579,26580,26582,26583,18950,26585,26587],{},[72,26575,26576,26578],{},[14,26577,33],{}," ordering wins ties."," After activation the venv directory is first, so\n",[14,26581,25877],{}," resolves there. Outside activation, a globally installed shim can shadow\nit — another reason to invoke ",[14,26584,26211],{},[14,26586,24076],{}," explicitly in automation.",[44,26589,26590,26593,26594,26597,26598,26601,26602,26604],{},[72,26591,26592],{},"CI caching can fight fresh environments."," Caching ",[14,26595,26596],{},".venv"," across runs can serve a\nstale or wrong-platform environment; cache the ",[23,26599,26600],{},"download\u002Fwheel"," directory or uv's\ncache, not the activated env, and recreate ",[14,26603,26596],{}," from the lockfile each run.",[44,26606,26607,26610,26611,26614,26615,26618],{},[72,26608,26609],{},"Line endings break POSIX wrappers."," If your repo ships shell wrapper scripts,\nCRLF endings make them unrunnable on Linux\u002FmacOS. Add ",[14,26612,26613],{},"* text=auto eol=lf"," to\n",[14,26616,26617],{},".gitattributes"," so wrappers stay LF regardless of who clones on Windows.",[36,26620,1277],{"id":1276},[41,26622,26623,26629,26637],{},[44,26624,26625,26628],{},[97,26626,26627],{"href":19111},"Python CLI env isolation best practices","\n— why isolation matters and how venv, uv, pyenv, and pipx fit together.",[44,26630,26631,26633,26634,26636],{},[97,26632,19011],{"href":19010},"\n— lockfiles, interpreter pinning, and ",[14,26635,24076],{}," for activation-free execution.",[44,26638,26639,26641],{},[97,26640,1423],{"href":1422}," — the\nfull project-foundation track.",[1303,26643,26644],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .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}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":131,"searchDepth":152,"depth":152,"links":26646},[26647,26648,26649,26650,26651,26653,26654,26655,26656],{"id":38,"depth":152,"text":39},{"id":25456,"depth":152,"text":25457},{"id":25678,"depth":152,"text":25679},{"id":25778,"depth":152,"text":25779},{"id":25864,"depth":152,"text":26652},"Shebang vs python -m",{"id":25916,"depth":152,"text":25917},{"id":26444,"depth":152,"text":26445},{"id":4511,"depth":152,"text":4512},{"id":1276,"depth":152,"text":1277},"Solve cross-platform venv activation, PATH resolution, and interpreter discovery for Python CLI tools on Linux, macOS, and Windows with uv and pyenv.",{},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fmanaging-virtual-environments-for-cross-platform-clis",{"title":25379,"description":26657},"project-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fmanaging-virtual-environments-for-cross-platform-clis\u002Findex",[23727,26663,26664,18833,19121,3444],"cross-platform","windows","h8BEq02N2rMtJ3kpgYrqaEffG7XdXCQ6CpL-_achJFY",1781695058755]