[{"data":1,"prerenderedAt":2217},["ShallowReactive",2],{"page-\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002F":3,"content-directory":1973},{"id":4,"title":5,"body":6,"date":1956,"description":1957,"difficulty":1958,"draft":1959,"extension":1960,"meta":1961,"navigation":211,"path":1962,"seo":1963,"stem":1964,"tags":1965,"updated":1971,"__hash__":1972},"content\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Findex.md","Packaging Python CLIs for Distribution",{"type":7,"value":8,"toc":1932},"minimark",[9,22,27,103,107,111,133,271,296,423,427,430,433,460,471,475,478,481,546,553,557,567,716,730,734,741,814,942,946,949,989,993,1058,1062,1065,1092,1109,1119,1162,1165,1207,1218,1273,1279,1283,1286,1438,1449,1455,1459,1462,1654,1668,1671,1675,1678,1681,1698,1708,1711,1753,1760,1763,1792,1796,1801,1815,1825,1835,1839,1853,1857,1864,1868,1875,1879,1893,1897,1928],[10,11,12,13,17,18,21],"p",{},"A script that works on your machine is not a tool other people can use. To hand your CLI to a\nteammate, a CI job, or a stranger on PyPI, you have to turn it into an installable package: a\nsingle artifact that declares its command, its dependencies, and how to expose it on the\n",[14,15,16],"code",{},"PATH",". This overview walks the whole path — from a ",[14,19,20],{},"pyproject.toml"," that names your command,\nthrough building a wheel, to the three ways people will actually install it — and then routes\nyou to the deep guides for each step.",[23,24,26],"h2",{"id":25},"tldr","TL;DR",[28,29,30,45,68,86,100],"ul",{},[31,32,33,37,38,41,42,44],"li",{},[34,35,36],"strong",{},"A \"distributable\" CLI is a package with a console entry point."," Declare it under\n",[14,39,40],{},"[project.scripts]"," in ",[14,43,20],{},"; that generates the launcher on install.",[31,46,47,50,51,55,56,59,60,63,64,67],{},[34,48,49],{},"Two build artifacts."," A ",[52,53,54],"em",{},"wheel"," (",[14,57,58],{},".whl",") is the pre-built install; an ",[52,61,62],{},"sdist","\n(",[14,65,66],{},".tar.gz",") is the source fallback used to build a wheel when none fits.",[31,69,70,73,74,77,78,81,82,85],{},[34,71,72],{},"Three delivery routes."," ",[14,75,76],{},"pipx"," for end users who just want the command; PyPI + ",[14,79,80],{},"pip","\u002F",[14,83,84],{},"uv","\nfor public distribution; a private index for internal tools.",[31,87,88,95,96,99],{},[34,89,90,91,94],{},"Build once with ",[14,92,93],{},"python -m build",","," verify with ",[14,97,98],{},"twine check"," and a smoke install, then\npublish.",[31,101,102],{},"Read on for a minimal end-to-end example, then follow the three deep guides linked at the\nbottom.",[104,105],"inline-diagram",{"name":106},"packaging-distribution-flow",[23,108,110],{"id":109},"what-distributable-actually-means","What \"distributable\" actually means",[10,112,113,114,116,117,120,121,124,125,128,129,132],{},"The difference between a script and a distributable CLI is one table in ",[14,115,20],{},". A\nconsole entry point maps a command name to a callable, and the installer writes a small\nlauncher script into the environment's ",[14,118,119],{},"bin\u002F"," (or ",[14,122,123],{},"Scripts\\"," on Windows) directory that calls\nit. Once the package is installed, typing the command name Just Works — no ",[14,126,127],{},"python path\u002Fto\u002Fscript.py",", no fiddling with ",[14,130,131],{},"PYTHONPATH",".",[134,135,140],"pre",{"className":136,"code":137,"language":138,"meta":139,"style":139},"language-toml shiki shiki-themes github-light github-dark","[project]\nname = \"greet-cli\"\nversion = \"0.1.0\"\ndescription = \"A tiny greeting CLI\"\nrequires-python = \">=3.11\"\ndependencies = [\"click>=8.1\"]\n\n[project.scripts]\ngreet = \"greet_cli.__main__:main\"\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n","toml","",[14,141,142,158,168,177,186,195,206,213,227,236,241,251,262],{"__ignoreMap":139},[143,144,147,151,155],"span",{"class":145,"line":146},"line",1,[143,148,150],{"class":149},"sVt8B","[",[143,152,154],{"class":153},"sScJk","project",[143,156,157],{"class":149},"]\n",[143,159,161,164],{"class":145,"line":160},2,[143,162,163],{"class":149},"name = ",[143,165,167],{"class":166},"sZZnC","\"greet-cli\"\n",[143,169,171,174],{"class":145,"line":170},3,[143,172,173],{"class":149},"version = ",[143,175,176],{"class":166},"\"0.1.0\"\n",[143,178,180,183],{"class":145,"line":179},4,[143,181,182],{"class":149},"description = ",[143,184,185],{"class":166},"\"A tiny greeting CLI\"\n",[143,187,189,192],{"class":145,"line":188},5,[143,190,191],{"class":149},"requires-python = ",[143,193,194],{"class":166},"\">=3.11\"\n",[143,196,198,201,204],{"class":145,"line":197},6,[143,199,200],{"class":149},"dependencies = [",[143,202,203],{"class":166},"\"click>=8.1\"",[143,205,157],{"class":149},[143,207,209],{"class":145,"line":208},7,[143,210,212],{"emptyLinePlaceholder":211},true,"\n",[143,214,216,218,220,222,225],{"class":145,"line":215},8,[143,217,150],{"class":149},[143,219,154],{"class":153},[143,221,132],{"class":149},[143,223,224],{"class":153},"scripts",[143,226,157],{"class":149},[143,228,230,233],{"class":145,"line":229},9,[143,231,232],{"class":149},"greet = ",[143,234,235],{"class":166},"\"greet_cli.__main__:main\"\n",[143,237,239],{"class":145,"line":238},10,[143,240,212],{"emptyLinePlaceholder":211},[143,242,244,246,249],{"class":145,"line":243},11,[143,245,150],{"class":149},[143,247,248],{"class":153},"build-system",[143,250,157],{"class":149},[143,252,254,257,260],{"class":145,"line":253},12,[143,255,256],{"class":149},"requires = [",[143,258,259],{"class":166},"\"hatchling\"",[143,261,157],{"class":149},[143,263,265,268],{"class":145,"line":264},13,[143,266,267],{"class":149},"build-backend = ",[143,269,270],{"class":166},"\"hatchling.build\"\n",[10,272,273,274,277,278,281,282,285,286,289,290,295],{},"That ",[14,275,276],{},"greet = \"greet_cli.__main__:main\""," line is the whole trick: the name left of ",[14,279,280],{},"="," becomes\nthe shell command; the value is ",[14,283,284],{},"import.path:function",". The mechanics of choosing that target\n(and why ",[14,287,288],{},"__main__:main"," is a good default) are covered in\n",[291,292,294],"a",{"href":293},"\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",".\nThe matching source module is minimal:",[134,297,301],{"className":298,"code":299,"language":300,"meta":139,"style":139},"language-python shiki shiki-themes github-light github-dark","# src\u002Fgreet_cli\u002F__main__.py\nimport click\n\n@click.command()\n@click.argument(\"name\")\ndef main(name: str) -> None:\n    \"\"\"Say hello to NAME.\"\"\"\n    click.echo(f\"Hello, {name}!\")\n\nif __name__ == \"__main__\":\n    main()\n","python",[14,302,303,309,318,322,330,344,368,373,398,402,418],{"__ignoreMap":139},[143,304,305],{"class":145,"line":146},[143,306,308],{"class":307},"sJ8bj","# src\u002Fgreet_cli\u002F__main__.py\n",[143,310,311,315],{"class":145,"line":160},[143,312,314],{"class":313},"szBVR","import",[143,316,317],{"class":149}," click\n",[143,319,320],{"class":145,"line":170},[143,321,212],{"emptyLinePlaceholder":211},[143,323,324,327],{"class":145,"line":179},[143,325,326],{"class":153},"@click.command",[143,328,329],{"class":149},"()\n",[143,331,332,335,338,341],{"class":145,"line":188},[143,333,334],{"class":153},"@click.argument",[143,336,337],{"class":149},"(",[143,339,340],{"class":166},"\"name\"",[143,342,343],{"class":149},")\n",[143,345,346,349,352,355,359,362,365],{"class":145,"line":197},[143,347,348],{"class":313},"def",[143,350,351],{"class":153}," main",[143,353,354],{"class":149},"(name: ",[143,356,358],{"class":357},"sj4cs","str",[143,360,361],{"class":149},") -> ",[143,363,364],{"class":357},"None",[143,366,367],{"class":149},":\n",[143,369,370],{"class":145,"line":208},[143,371,372],{"class":166},"    \"\"\"Say hello to NAME.\"\"\"\n",[143,374,375,378,381,384,387,390,393,396],{"class":145,"line":215},[143,376,377],{"class":149},"    click.echo(",[143,379,380],{"class":313},"f",[143,382,383],{"class":166},"\"Hello, ",[143,385,386],{"class":357},"{",[143,388,389],{"class":149},"name",[143,391,392],{"class":357},"}",[143,394,395],{"class":166},"!\"",[143,397,343],{"class":149},[143,399,400],{"class":145,"line":229},[143,401,212],{"emptyLinePlaceholder":211},[143,403,404,407,410,413,416],{"class":145,"line":238},[143,405,406],{"class":313},"if",[143,408,409],{"class":357}," __name__",[143,411,412],{"class":313}," ==",[143,414,415],{"class":166}," \"__main__\"",[143,417,367],{"class":149},[143,419,420],{"class":145,"line":243},[143,421,422],{"class":149},"    main()\n",[23,424,426],{"id":425},"wheel-vs-sdist-at-a-glance","Wheel vs sdist at a glance",[10,428,429],{},"A build produces up to two artifacts, and it helps to know what each one is for before you\never run the build.",[104,431],{"name":432},"wheel-vs-sdist-matrix",[28,434,435,452],{},[31,436,437,443,444,447,448,451],{},[34,438,439,440,442],{},"Wheel (",[14,441,58],{},")"," — a ZIP with a specific name layout, already laid out the way it lands in\n",[14,445,446],{},"site-packages",". Installing it is basically an unzip, so it is fast and needs no build step\non the user's machine. Pure-Python CLIs ship a single ",[14,449,450],{},"...-py3-none-any.whl"," that works\neverywhere.",[31,453,454,459],{},[34,455,456,457,442],{},"Source distribution \u002F sdist (",[14,458,66],{}," — your source tree plus metadata. Installers use\nit when no compatible wheel exists, building a wheel locally first. It is also the\narchival, auditable form of a release.",[10,461,462,463,466,467,132],{},"For a pure-Python CLI you publish both: the wheel is what nearly everyone installs, the sdist\nis the fallback and the thing packagers (Linux distros, conda-forge) build from. The full\nmechanics — the ",[14,464,465],{},"dist\u002F"," layout, inspecting a wheel, including package data — live in\n",[291,468,470],{"href":469},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fbuilding-wheels-and-sdists-for-python-clis\u002F","building wheels and sdists for Python CLIs",[23,472,474],{"id":473},"the-three-delivery-routes","The three delivery routes",[10,476,477],{},"How your CLI reaches users shapes how you package and document it. There are three common\nroutes, and most real tools use more than one.",[104,479],{"name":480},"delivery-routes-tree",[482,483,484,506,529],"ol",{},[31,485,486,73,491,494,495,497,498,501,502,132],{},[34,487,488,490],{},[14,489,76],{}," (end users who want the command, not the library).",[14,492,493],{},"pipx install"," drops the CLI\ninto its own isolated virtual environment and links the command onto the ",[14,496,16],{},", so tools\nnever fight over dependency versions. This is the right recommendation in your README for\nanyone who just wants to ",[52,499,500],{},"run"," your tool. See\n",[291,503,505],{"href":504},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Finstalling-and-distributing-clis-with-pipx\u002F","installing and distributing CLIs with pipx",[31,507,508,516,517,520,521,524,525,132],{},[34,509,510,511,81,513,515],{},"PyPI + ",[14,512,80],{},[14,514,84],{}," (public distribution)."," Upload to the Python Package Index and anyone\ncan ",[14,518,519],{},"pip install your-cli"," or add it as a dependency. This is table stakes for an\nopen-source tool; it is what makes ",[14,522,523],{},"pipx install your-cli"," resolve at all. See\n",[291,526,528],{"href":527},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fpublishing-a-python-cli-to-pypi\u002F","publishing a Python CLI to PyPI",[31,530,531,534,535,538,539,542,543,545],{},[34,532,533],{},"A private\u002Finternal index."," For company-internal tools, run or rent a package index\n(Artifactory, a self-hosted ",[14,536,537],{},"devpi",", GitHub\u002FGitLab package registries) and point ",[14,540,541],{},"pip install --index-url"," or ",[14,544,84],{}," at it. The build and entry-point mechanics are identical; only\nthe upload target and credentials change.",[10,547,548,549,552],{},"A fourth route worth knowing: you do not need an index at all for a quick handoff. A freshly\nbuilt wheel installs straight from disk with ",[14,550,551],{},"pipx install .\u002Fdist\u002Fyour_cli-0.1.0-py3-none-any.whl",",\nwhich is perfect for sharing a pre-release with a colleague over Slack.",[23,554,556],{"id":555},"a-minimal-end-to-end-example","A minimal end-to-end example",[10,558,559,560,562,563,566],{},"Here is the whole loop, from a project directory to a working global command, with nothing\npublished anywhere. Assume the ",[14,561,20],{}," and ",[14,564,565],{},"src\u002Fgreet_cli\u002F__main__.py"," from above.",[134,568,572],{"className":569,"code":570,"language":571,"meta":139,"style":139},"language-bash shiki shiki-themes github-light github-dark","$ pip install build            # or: uv tool install build\n$ python -m build              # produces dist\u002F*.whl and dist\u002F*.tar.gz\n$ ls dist\u002F\ngreet_cli-0.1.0-py3-none-any.whl  greet_cli-0.1.0.tar.gz\n\n$ pipx install .\u002Fdist\u002Fgreet_cli-0.1.0-py3-none-any.whl\n  installed package greet-cli 0.1.0, installed using Python 3.12.3\n  These apps are now globally available\n    - greet\n\n$ greet World\nHello, World!\n","bash",[14,573,574,591,606,616,624,628,640,666,686,694,698,708],{"__ignoreMap":139},[143,575,576,579,582,585,588],{"class":145,"line":146},[143,577,578],{"class":153},"$",[143,580,581],{"class":166}," pip",[143,583,584],{"class":166}," install",[143,586,587],{"class":166}," build",[143,589,590],{"class":307},"            # or: uv tool install build\n",[143,592,593,595,598,601,603],{"class":145,"line":160},[143,594,578],{"class":153},[143,596,597],{"class":166}," python",[143,599,600],{"class":357}," -m",[143,602,587],{"class":166},[143,604,605],{"class":307},"              # produces dist\u002F*.whl and dist\u002F*.tar.gz\n",[143,607,608,610,613],{"class":145,"line":170},[143,609,578],{"class":153},[143,611,612],{"class":166}," ls",[143,614,615],{"class":166}," dist\u002F\n",[143,617,618,621],{"class":145,"line":179},[143,619,620],{"class":153},"greet_cli-0.1.0-py3-none-any.whl",[143,622,623],{"class":166},"  greet_cli-0.1.0.tar.gz\n",[143,625,626],{"class":145,"line":188},[143,627,212],{"emptyLinePlaceholder":211},[143,629,630,632,635,637],{"class":145,"line":197},[143,631,578],{"class":153},[143,633,634],{"class":166}," pipx",[143,636,584],{"class":166},[143,638,639],{"class":166}," .\u002Fdist\u002Fgreet_cli-0.1.0-py3-none-any.whl\n",[143,641,642,645,648,651,654,657,660,663],{"class":145,"line":208},[143,643,644],{"class":153},"  installed",[143,646,647],{"class":166}," package",[143,649,650],{"class":166}," greet-cli",[143,652,653],{"class":166}," 0.1.0,",[143,655,656],{"class":166}," installed",[143,658,659],{"class":166}," using",[143,661,662],{"class":166}," Python",[143,664,665],{"class":357}," 3.12.3\n",[143,667,668,671,674,677,680,683],{"class":145,"line":215},[143,669,670],{"class":153},"  These",[143,672,673],{"class":166}," apps",[143,675,676],{"class":166}," are",[143,678,679],{"class":166}," now",[143,681,682],{"class":166}," globally",[143,684,685],{"class":166}," available\n",[143,687,688,691],{"class":145,"line":229},[143,689,690],{"class":153},"    -",[143,692,693],{"class":166}," greet\n",[143,695,696],{"class":145,"line":238},[143,697,212],{"emptyLinePlaceholder":211},[143,699,700,702,705],{"class":145,"line":243},[143,701,578],{"class":153},[143,703,704],{"class":166}," greet",[143,706,707],{"class":166}," World\n",[143,709,710,713],{"class":145,"line":253},[143,711,712],{"class":153},"Hello,",[143,714,715],{"class":166}," World!\n",[10,717,718,719,721,722,725,726,729],{},"Four commands and the tool is on your ",[14,720,16],{},", isolated from every other Python tool you have\ninstalled. Swap the last two steps for a ",[14,723,724],{},"twine upload"," and users run ",[14,727,728],{},"pipx install greet-cli","\ninstead of pointing at a local file — same wheel, same entry point, public reach.",[23,731,733],{"id":732},"versioning-and-metadata-for-a-good-listing","Versioning and metadata for a good listing",[10,735,736,737,740],{},"Packaging is not only mechanics; the metadata in ",[14,738,739],{},"[project]"," is your product page on PyPI and\nthe contract users depend on. Get these right before your first upload, because names and\nreleased version numbers are effectively permanent:",[28,742,743,757,777,795,806],{},[31,744,745,749,750,562,753,756],{},[34,746,747],{},[14,748,389],{}," — must be globally unique on PyPI and is normalized (case- and separator-\ninsensitive: ",[14,751,752],{},"Greet_CLI",[14,754,755],{},"greet-cli"," collide). Check availability before you commit to\nit.",[31,758,759,764,765,771,772,776],{},[34,760,761],{},[14,762,763],{},"version"," — follow ",[291,766,770],{"href":767,"rel":768},"https:\u002F\u002Fsemver.org\u002F",[769],"nofollow","semantic versioning"," and never reuse a number;\nPyPI rejects re-uploads of an existing version. Our guide on\n",[291,773,775],{"href":774},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002F","managing CLI versioning and changelogs","\ncovers keeping this in sync with a changelog.",[31,778,779,791,792,132],{},[34,780,781,784,785,784,788],{},[14,782,783],{},"description",", ",[14,786,787],{},"readme",[14,789,790],{},"license"," — the summary line, the long description rendered on\nthe project page, and an SPDX license expression like ",[14,793,794],{},"license = \"MIT\"",[31,796,797,802,803,805],{},[34,798,799],{},[14,800,801],{},"requires-python"," — the interpreter floor. Set it honestly; ",[14,804,80],{}," uses it to refuse\ninstalls on unsupported Pythons instead of failing at runtime.",[31,807,808,813],{},[34,809,810],{},[14,811,812],{},"[project.urls]"," — Homepage, Source, and Changelog links that show up in the PyPI\nsidebar and build trust.",[134,815,817],{"className":136,"code":816,"language":138,"meta":139,"style":139},"[project]\nname = \"greet-cli\"\nversion = \"0.2.0\"\ndescription = \"A friendly greeting CLI\"\nreadme = \"README.md\"\nlicense = \"MIT\"\nrequires-python = \">=3.11\"\nauthors = [{ name = \"Ada Lovelace\", email = \"ada@example.com\" }]\nkeywords = [\"cli\", \"greeting\"]\n\n[project.urls]\nHomepage = \"https:\u002F\u002Fgithub.com\u002Fada\u002Fgreet-cli\"\nSource = \"https:\u002F\u002Fgithub.com\u002Fada\u002Fgreet-cli\"\nChangelog = \"https:\u002F\u002Fgithub.com\u002Fada\u002Fgreet-cli\u002Fblob\u002Fmain\u002FCHANGELOG.md\"\n",[14,818,819,827,833,840,847,855,863,869,886,901,905,918,926,933],{"__ignoreMap":139},[143,820,821,823,825],{"class":145,"line":146},[143,822,150],{"class":149},[143,824,154],{"class":153},[143,826,157],{"class":149},[143,828,829,831],{"class":145,"line":160},[143,830,163],{"class":149},[143,832,167],{"class":166},[143,834,835,837],{"class":145,"line":170},[143,836,173],{"class":149},[143,838,839],{"class":166},"\"0.2.0\"\n",[143,841,842,844],{"class":145,"line":179},[143,843,182],{"class":149},[143,845,846],{"class":166},"\"A friendly greeting CLI\"\n",[143,848,849,852],{"class":145,"line":188},[143,850,851],{"class":149},"readme = ",[143,853,854],{"class":166},"\"README.md\"\n",[143,856,857,860],{"class":145,"line":197},[143,858,859],{"class":149},"license = ",[143,861,862],{"class":166},"\"MIT\"\n",[143,864,865,867],{"class":145,"line":208},[143,866,191],{"class":149},[143,868,194],{"class":166},[143,870,871,874,877,880,883],{"class":145,"line":215},[143,872,873],{"class":149},"authors = [{ name = ",[143,875,876],{"class":166},"\"Ada Lovelace\"",[143,878,879],{"class":149},", email = ",[143,881,882],{"class":166},"\"ada@example.com\"",[143,884,885],{"class":149}," }]\n",[143,887,888,891,894,896,899],{"class":145,"line":229},[143,889,890],{"class":149},"keywords = [",[143,892,893],{"class":166},"\"cli\"",[143,895,784],{"class":149},[143,897,898],{"class":166},"\"greeting\"",[143,900,157],{"class":149},[143,902,903],{"class":145,"line":238},[143,904,212],{"emptyLinePlaceholder":211},[143,906,907,909,911,913,916],{"class":145,"line":243},[143,908,150],{"class":149},[143,910,154],{"class":153},[143,912,132],{"class":149},[143,914,915],{"class":153},"urls",[143,917,157],{"class":149},[143,919,920,923],{"class":145,"line":253},[143,921,922],{"class":149},"Homepage = ",[143,924,925],{"class":166},"\"https:\u002F\u002Fgithub.com\u002Fada\u002Fgreet-cli\"\n",[143,927,928,931],{"class":145,"line":264},[143,929,930],{"class":149},"Source = ",[143,932,925],{"class":166},[143,934,936,939],{"class":145,"line":935},14,[143,937,938],{"class":149},"Changelog = ",[143,940,941],{"class":166},"\"https:\u002F\u002Fgithub.com\u002Fada\u002Fgreet-cli\u002Fblob\u002Fmain\u002FCHANGELOG.md\"\n",[23,943,945],{"id":944},"where-to-go-next","Where to go next",[10,947,948],{},"Each step above has a dedicated deep guide. Read them in order the first time; jump straight\nto one when you already know the rest:",[28,950,951,962,978],{},[31,952,953,958,959,961],{},[34,954,955],{},[291,956,957],{"href":469},"Building wheels and sdists for Python CLIs"," —\n",[14,960,93],{},", what each artifact contains, and smoke-testing before you release.",[31,963,964,969,970,81,972,81,974,977],{},[34,965,966],{},[291,967,968],{"href":504},"Installing and distributing CLIs with pipx"," —\nthe isolation model, ",[14,971,493],{},[14,973,500],{},[14,975,976],{},"inject",", and installing from a wheel, git, or PyPI.",[31,979,980,985,986,988],{},[34,981,982],{},[291,983,984],{"href":527},"Publishing a Python CLI to PyPI"," —\nnames, TestPyPI, API tokens, ",[14,987,724],{},", and trusted publishing from CI.",[23,990,992],{"id":991},"production-notes","Production notes",[28,994,995,1013,1032,1052],{},[31,996,997,1004,1005,1007,1008,1012],{},[34,998,999,1000,1003],{},"Use a ",[14,1001,1002],{},"src\u002F"," layout."," Putting your package under ",[14,1006,1002],{}," stops the build from accidentally\nimporting your working tree instead of the installed package, which is exactly the kind of\nbug that only appears after you ship. Most scaffolds, including\n",[291,1009,1011],{"href":1010},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter\u002F","CLI project scaffolding with Cookiecutter",", default to it.",[31,1014,1015,73,1018,1021,1022,784,1025,784,1028,1031],{},[34,1016,1017],{},"Pin your build backend, not just your deps.",[14,1019,1020],{},"[build-system].requires"," should name a\nbackend (",[14,1023,1024],{},"hatchling",[14,1026,1027],{},"setuptools",[14,1029,1030],{},"flit-core",") — backend defaults drift across versions,\nand a floating backend is the classic cause of a build that worked last month and not today.",[31,1033,1034,1037,1038,1041,1042,1046,1047,1051],{},[34,1035,1036],{},"Decide dependency strategy up front."," A library pins loosely (",[14,1039,1040],{},"click>=8.1",") so it composes\nin others' environments; an application distributed via pipx can afford tighter pins because\nit lives in its own isolated venv. See\n",[291,1043,1045],{"href":1044},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management\u002F","uv for Python CLI dependency management","\nand ",[291,1048,1050],{"href":1049},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development\u002F","Poetry workflows for CLI development","\nfor two ways to manage that lockfile.",[31,1053,1054,1057],{},[34,1055,1056],{},"Test the artifact, not the repo."," A green test suite against your source tree does not\nprove the wheel installs and its entry point runs. Always install the built wheel into a\nthrowaway environment and invoke the command once before tagging a release.",[23,1059,1061],{"id":1060},"choosing-and-configuring-a-build-backend","Choosing and configuring a build backend",[10,1063,1064],{},"The backend is the component that turns your source tree into a wheel. For a pure-Python CLI, any\nof the current options works; what matters is declaring one explicitly and knowing how it finds\nyour package.",[134,1066,1068],{"className":136,"code":1067,"language":138,"meta":139,"style":139},"[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n",[14,1069,1070,1078,1086],{"__ignoreMap":139},[143,1071,1072,1074,1076],{"class":145,"line":146},[143,1073,150],{"class":149},[143,1075,248],{"class":153},[143,1077,157],{"class":149},[143,1079,1080,1082,1084],{"class":145,"line":160},[143,1081,256],{"class":149},[143,1083,259],{"class":166},[143,1085,157],{"class":149},[143,1087,1088,1090],{"class":145,"line":170},[143,1089,267],{"class":149},[143,1091,270],{"class":166},[10,1093,1094,1095,1097,1098,1100,1101,1104,1105,1108],{},"Hatchling is a good default: fast, no configuration for a standard ",[14,1096,1002],{}," layout, and it handles\npackage data sensibly. ",[14,1099,1027],{}," remains everywhere and is the right answer for a project that\nalready uses it. ",[14,1102,1103],{},"uv_build"," is worth considering if you already use uv. ",[14,1106,1107],{},"poetry-core"," comes with\nPoetry. None of these changes what users receive — the wheel is the wheel.",[10,1110,1111,1112,1114,1115,1118],{},"The one thing worth checking is discovery. With a ",[14,1113,1002],{}," layout most backends find\n",[14,1116,1117],{},"src\u002Fmytool\u002F"," automatically; if yours does not, say so explicitly rather than guessing:",[134,1120,1122],{"className":136,"code":1121,"language":138,"meta":139,"style":139},"[tool.hatch.build.targets.wheel]\npackages = [\"src\u002Fmytool\"]\n",[14,1123,1124,1152],{"__ignoreMap":139},[143,1125,1126,1128,1131,1133,1136,1138,1141,1143,1146,1148,1150],{"class":145,"line":146},[143,1127,150],{"class":149},[143,1129,1130],{"class":153},"tool",[143,1132,132],{"class":149},[143,1134,1135],{"class":153},"hatch",[143,1137,132],{"class":149},[143,1139,1140],{"class":153},"build",[143,1142,132],{"class":149},[143,1144,1145],{"class":153},"targets",[143,1147,132],{"class":149},[143,1149,54],{"class":153},[143,1151,157],{"class":149},[143,1153,1154,1157,1160],{"class":145,"line":160},[143,1155,1156],{"class":149},"packages = [",[143,1158,1159],{"class":166},"\"src\u002Fmytool\"",[143,1161,157],{"class":149},[10,1163,1164],{},"Non-code files need declaring too. A template, a schema, a bundled completion script — none of it\nships unless the backend is told:",[134,1166,1168],{"className":136,"code":1167,"language":138,"meta":139,"style":139},"[tool.hatch.build.targets.wheel.force-include]\n\"src\u002Fmytool\u002Ftemplates\" = \"mytool\u002Ftemplates\"\n",[14,1169,1170,1199],{"__ignoreMap":139},[143,1171,1172,1174,1176,1178,1180,1182,1184,1186,1188,1190,1192,1194,1197],{"class":145,"line":146},[143,1173,150],{"class":149},[143,1175,1130],{"class":153},[143,1177,132],{"class":149},[143,1179,1135],{"class":153},[143,1181,132],{"class":149},[143,1183,1140],{"class":153},[143,1185,132],{"class":149},[143,1187,1145],{"class":153},[143,1189,132],{"class":149},[143,1191,54],{"class":153},[143,1193,132],{"class":149},[143,1195,1196],{"class":153},"force-include",[143,1198,157],{"class":149},[143,1200,1201,1204],{"class":145,"line":160},[143,1202,1203],{"class":149},"\"src\u002Fmytool\u002Ftemplates\" = ",[143,1205,1206],{"class":166},"\"mytool\u002Ftemplates\"\n",[10,1208,1209,1210,1213,1214,1217],{},"And read it back at run time through ",[14,1211,1212],{},"importlib.resources",", never by computing a path relative to\n",[14,1215,1216],{},"__file__",":",[134,1219,1221],{"className":298,"code":1220,"language":300,"meta":139,"style":139},"from importlib.resources import files\n\ntemplate = files(\"mytool.templates\").joinpath(\"report.html\").read_text(encoding=\"utf-8\")\n",[14,1222,1223,1236,1240],{"__ignoreMap":139},[143,1224,1225,1228,1231,1233],{"class":145,"line":146},[143,1226,1227],{"class":313},"from",[143,1229,1230],{"class":149}," importlib.resources ",[143,1232,314],{"class":313},[143,1234,1235],{"class":149}," files\n",[143,1237,1238],{"class":145,"line":160},[143,1239,212],{"emptyLinePlaceholder":211},[143,1241,1242,1245,1247,1250,1253,1256,1259,1262,1266,1268,1271],{"class":145,"line":170},[143,1243,1244],{"class":149},"template ",[143,1246,280],{"class":313},[143,1248,1249],{"class":149}," files(",[143,1251,1252],{"class":166},"\"mytool.templates\"",[143,1254,1255],{"class":149},").joinpath(",[143,1257,1258],{"class":166},"\"report.html\"",[143,1260,1261],{"class":149},").read_text(",[143,1263,1265],{"class":1264},"s4XuR","encoding",[143,1267,280],{"class":313},[143,1269,1270],{"class":166},"\"utf-8\"",[143,1272,343],{"class":149},[10,1274,1275,1276,1278],{},"The ",[14,1277,1216],{}," approach works from a source checkout and breaks inside a zipapp or a frozen\nbinary, which is precisely when it is hardest to debug.",[23,1280,1282],{"id":1281},"metadata-that-makes-a-good-listing","Metadata that makes a good listing",[10,1284,1285],{},"The metadata is the tool's shop window and its compatibility contract. Six fields do the work:",[134,1287,1289],{"className":136,"code":1288,"language":138,"meta":139,"style":139},"[project]\nname = \"mytool\"\nversion = \"1.4.0\"\ndescription = \"Sync directories to object storage, quickly.\"\nreadme = \"README.md\"\nrequires-python = \">=3.11\"\nlicense = \"MIT\"\nkeywords = [\"cli\", \"sync\", \"s3\"]\nclassifiers = [\n  \"Environment :: Console\",\n  \"Intended Audience :: Developers\",\n  \"Programming Language :: Python :: 3 :: Only\",\n]\n\n[project.urls]\nHomepage = \"https:\u002F\u002Fexample.com\u002Fmytool\"\nDocumentation = \"https:\u002F\u002Fexample.com\u002Fmytool\u002Fdocs\"\nChangelog = \"https:\u002F\u002Fgithub.com\u002Fyou\u002Fmytool\u002Fblob\u002Fmain\u002FCHANGELOG.md\"\nIssues = \"https:\u002F\u002Fgithub.com\u002Fyou\u002Fmytool\u002Fissues\"\n",[14,1290,1291,1299,1306,1313,1320,1326,1332,1338,1356,1361,1369,1376,1383,1387,1391,1404,1412,1421,1429],{"__ignoreMap":139},[143,1292,1293,1295,1297],{"class":145,"line":146},[143,1294,150],{"class":149},[143,1296,154],{"class":153},[143,1298,157],{"class":149},[143,1300,1301,1303],{"class":145,"line":160},[143,1302,163],{"class":149},[143,1304,1305],{"class":166},"\"mytool\"\n",[143,1307,1308,1310],{"class":145,"line":170},[143,1309,173],{"class":149},[143,1311,1312],{"class":166},"\"1.4.0\"\n",[143,1314,1315,1317],{"class":145,"line":179},[143,1316,182],{"class":149},[143,1318,1319],{"class":166},"\"Sync directories to object storage, quickly.\"\n",[143,1321,1322,1324],{"class":145,"line":188},[143,1323,851],{"class":149},[143,1325,854],{"class":166},[143,1327,1328,1330],{"class":145,"line":197},[143,1329,191],{"class":149},[143,1331,194],{"class":166},[143,1333,1334,1336],{"class":145,"line":208},[143,1335,859],{"class":149},[143,1337,862],{"class":166},[143,1339,1340,1342,1344,1346,1349,1351,1354],{"class":145,"line":215},[143,1341,890],{"class":149},[143,1343,893],{"class":166},[143,1345,784],{"class":149},[143,1347,1348],{"class":166},"\"sync\"",[143,1350,784],{"class":149},[143,1352,1353],{"class":166},"\"s3\"",[143,1355,157],{"class":149},[143,1357,1358],{"class":145,"line":229},[143,1359,1360],{"class":149},"classifiers = [\n",[143,1362,1363,1366],{"class":145,"line":238},[143,1364,1365],{"class":166},"  \"Environment :: Console\"",[143,1367,1368],{"class":149},",\n",[143,1370,1371,1374],{"class":145,"line":243},[143,1372,1373],{"class":166},"  \"Intended Audience :: Developers\"",[143,1375,1368],{"class":149},[143,1377,1378,1381],{"class":145,"line":253},[143,1379,1380],{"class":166},"  \"Programming Language :: Python :: 3 :: Only\"",[143,1382,1368],{"class":149},[143,1384,1385],{"class":145,"line":264},[143,1386,157],{"class":149},[143,1388,1389],{"class":145,"line":935},[143,1390,212],{"emptyLinePlaceholder":211},[143,1392,1394,1396,1398,1400,1402],{"class":145,"line":1393},15,[143,1395,150],{"class":149},[143,1397,154],{"class":153},[143,1399,132],{"class":149},[143,1401,915],{"class":153},[143,1403,157],{"class":149},[143,1405,1407,1409],{"class":145,"line":1406},16,[143,1408,922],{"class":149},[143,1410,1411],{"class":166},"\"https:\u002F\u002Fexample.com\u002Fmytool\"\n",[143,1413,1415,1418],{"class":145,"line":1414},17,[143,1416,1417],{"class":149},"Documentation = ",[143,1419,1420],{"class":166},"\"https:\u002F\u002Fexample.com\u002Fmytool\u002Fdocs\"\n",[143,1422,1424,1426],{"class":145,"line":1423},18,[143,1425,938],{"class":149},[143,1427,1428],{"class":166},"\"https:\u002F\u002Fgithub.com\u002Fyou\u002Fmytool\u002Fblob\u002Fmain\u002FCHANGELOG.md\"\n",[143,1430,1432,1435],{"class":145,"line":1431},19,[143,1433,1434],{"class":149},"Issues = ",[143,1436,1437],{"class":166},"\"https:\u002F\u002Fgithub.com\u002Fyou\u002Fmytool\u002Fissues\"\n",[10,1439,1440,1442,1443,1445,1446,1448],{},[14,1441,801],{}," is the one that prevents real support tickets: it stops an install on an\ninterpreter your code cannot run on, with a clear message, instead of failing at import time with a\nsyntax error. ",[14,1444,787],{}," becomes the project page — and ",[14,1447,98],{}," will tell you if it fails to\nrender before anyone sees it.",[10,1450,1275,1451,1454],{},[14,1452,1453],{},"Changelog"," URL is worth including for a CLI specifically. Users evaluating an upgrade want to\nknow whether flags changed, and a link straight to that answer is more useful than a homepage.",[23,1456,1458],{"id":1457},"reproducible-builds-in-ci","Reproducible builds in CI",[10,1460,1461],{},"Build once, verify, then publish the exact artifacts you verified:",[134,1463,1467],{"className":1464,"code":1465,"language":1466,"meta":139,"style":139},"language-yaml shiki shiki-themes github-light github-dark","  build:\n    steps:\n      - uses: actions\u002Fcheckout@v4\n      - run: pipx run build                 # isolated build, no ambient packages\n      - run: pipx run twine check dist\u002F*\n      - uses: actions\u002Fupload-artifact@v4\n        with: { name: dist, path: dist\u002F }\n\n  smoke:\n    needs: build\n    steps:\n      - uses: actions\u002Fdownload-artifact@v4\n        with: { name: dist, path: dist }\n      - run: python -m venv \u002Ftmp\u002Fsmoke\n      - run: \u002Ftmp\u002Fsmoke\u002Fbin\u002Fpip install dist\u002F*.whl\n      - run: \u002Ftmp\u002Fsmoke\u002Fbin\u002Fmytool --version\n","yaml",[14,1468,1469,1477,1484,1498,1512,1523,1534,1561,1565,1572,1582,1588,1599,1621,1632,1643],{"__ignoreMap":139},[143,1470,1471,1475],{"class":145,"line":146},[143,1472,1474],{"class":1473},"s9eBZ","  build",[143,1476,367],{"class":149},[143,1478,1479,1482],{"class":145,"line":160},[143,1480,1481],{"class":1473},"    steps",[143,1483,367],{"class":149},[143,1485,1486,1489,1492,1495],{"class":145,"line":170},[143,1487,1488],{"class":149},"      - ",[143,1490,1491],{"class":1473},"uses",[143,1493,1494],{"class":149},": ",[143,1496,1497],{"class":166},"actions\u002Fcheckout@v4\n",[143,1499,1500,1502,1504,1506,1509],{"class":145,"line":179},[143,1501,1488],{"class":149},[143,1503,500],{"class":1473},[143,1505,1494],{"class":149},[143,1507,1508],{"class":166},"pipx run build",[143,1510,1511],{"class":307},"                 # isolated build, no ambient packages\n",[143,1513,1514,1516,1518,1520],{"class":145,"line":188},[143,1515,1488],{"class":149},[143,1517,500],{"class":1473},[143,1519,1494],{"class":149},[143,1521,1522],{"class":166},"pipx run twine check dist\u002F*\n",[143,1524,1525,1527,1529,1531],{"class":145,"line":197},[143,1526,1488],{"class":149},[143,1528,1491],{"class":1473},[143,1530,1494],{"class":149},[143,1532,1533],{"class":166},"actions\u002Fupload-artifact@v4\n",[143,1535,1536,1539,1542,1544,1546,1549,1551,1554,1556,1558],{"class":145,"line":208},[143,1537,1538],{"class":1473},"        with",[143,1540,1541],{"class":149},": { ",[143,1543,389],{"class":1473},[143,1545,1494],{"class":149},[143,1547,1548],{"class":166},"dist",[143,1550,784],{"class":149},[143,1552,1553],{"class":1473},"path",[143,1555,1494],{"class":149},[143,1557,465],{"class":166},[143,1559,1560],{"class":149}," }\n",[143,1562,1563],{"class":145,"line":215},[143,1564,212],{"emptyLinePlaceholder":211},[143,1566,1567,1570],{"class":145,"line":229},[143,1568,1569],{"class":1473},"  smoke",[143,1571,367],{"class":149},[143,1573,1574,1577,1579],{"class":145,"line":238},[143,1575,1576],{"class":1473},"    needs",[143,1578,1494],{"class":149},[143,1580,1581],{"class":166},"build\n",[143,1583,1584,1586],{"class":145,"line":243},[143,1585,1481],{"class":1473},[143,1587,367],{"class":149},[143,1589,1590,1592,1594,1596],{"class":145,"line":253},[143,1591,1488],{"class":149},[143,1593,1491],{"class":1473},[143,1595,1494],{"class":149},[143,1597,1598],{"class":166},"actions\u002Fdownload-artifact@v4\n",[143,1600,1601,1603,1605,1607,1609,1611,1613,1615,1617,1619],{"class":145,"line":264},[143,1602,1538],{"class":1473},[143,1604,1541],{"class":149},[143,1606,389],{"class":1473},[143,1608,1494],{"class":149},[143,1610,1548],{"class":166},[143,1612,784],{"class":149},[143,1614,1553],{"class":1473},[143,1616,1494],{"class":149},[143,1618,1548],{"class":166},[143,1620,1560],{"class":149},[143,1622,1623,1625,1627,1629],{"class":145,"line":935},[143,1624,1488],{"class":149},[143,1626,500],{"class":1473},[143,1628,1494],{"class":149},[143,1630,1631],{"class":166},"python -m venv \u002Ftmp\u002Fsmoke\n",[143,1633,1634,1636,1638,1640],{"class":145,"line":1393},[143,1635,1488],{"class":149},[143,1637,500],{"class":1473},[143,1639,1494],{"class":149},[143,1641,1642],{"class":166},"\u002Ftmp\u002Fsmoke\u002Fbin\u002Fpip install dist\u002F*.whl\n",[143,1644,1645,1647,1649,1651],{"class":145,"line":1406},[143,1646,1488],{"class":149},[143,1648,500],{"class":1473},[143,1650,1494],{"class":149},[143,1652,1653],{"class":166},"\u002Ftmp\u002Fsmoke\u002Fbin\u002Fmytool --version\n",[10,1655,1656,1657,1660,1661,1663,1664,1667],{},"Two properties matter. The build is ",[34,1658,1659],{},"isolated"," — ",[14,1662,93],{}," creates a clean environment\nfor the backend, so a package that happens to be installed on the runner cannot mask a missing\ndeclaration. And the smoke job installs the ",[34,1665,1666],{},"built artifact",", not the source tree, which is the\nonly test that catches a missing package, an unlisted dependency or a broken entry point.",[10,1669,1670],{},"Publish from a third job that depends on both, triggered by a tag, using trusted publishing so no\nlong-lived token exists in the repository.",[23,1672,1674],{"id":1673},"writing-install-instructions-users-can-follow","Writing install instructions users can follow",[10,1676,1677],{},"The README line that tells people how to install your tool decides how it behaves on their\nmachine, so it is worth choosing deliberately.",[10,1679,1680],{},"Lead with the isolated install:",[134,1682,1684],{"className":569,"code":1683,"language":571,"meta":139,"style":139},"pipx install mytool          # or: uv tool install mytool\n",[14,1685,1686],{"__ignoreMap":139},[143,1687,1688,1690,1692,1695],{"class":145,"line":146},[143,1689,76],{"class":153},[143,1691,584],{"class":166},[143,1693,1694],{"class":166}," mytool",[143,1696,1697],{"class":307},"          # or: uv tool install mytool\n",[10,1699,1700,1701,1703,1704,1707],{},"That gives the tool its own environment with only its command on ",[14,1702,16],{},", which makes a dependency\nconflict with another tool impossible. ",[14,1705,1706],{},"pip install mytool"," puts your dependencies into whichever\nenvironment happens to be active — often the system interpreter — and is the arrangement behind\nmost \"your tool broke my other tool\" reports.",[10,1709,1710],{},"Three more lines are worth including:",[134,1712,1714],{"className":569,"code":1713,"language":571,"meta":139,"style":139},"uvx mytool --help            # try it without installing anything\npipx upgrade mytool          # upgrade later\npipx uninstall mytool        # remove it cleanly\n",[14,1715,1716,1729,1741],{"__ignoreMap":139},[143,1717,1718,1721,1723,1726],{"class":145,"line":146},[143,1719,1720],{"class":153},"uvx",[143,1722,1694],{"class":166},[143,1724,1725],{"class":357}," --help",[143,1727,1728],{"class":307},"            # try it without installing anything\n",[143,1730,1731,1733,1736,1738],{"class":145,"line":160},[143,1732,76],{"class":153},[143,1734,1735],{"class":166}," upgrade",[143,1737,1694],{"class":166},[143,1739,1740],{"class":307},"          # upgrade later\n",[143,1742,1743,1745,1748,1750],{"class":145,"line":170},[143,1744,76],{"class":153},[143,1746,1747],{"class":166}," uninstall",[143,1749,1694],{"class":166},[143,1751,1752],{"class":307},"        # remove it cleanly\n",[10,1754,1755,1756,1759],{},"The first lowers the barrier to evaluation to nearly zero, and the last two answer questions people\notherwise open an issue about. If your tool has optional extras, show the syntax explicitly —\n",[14,1757,1758],{},"pipx install \"mytool[aws]\""," — because the quoting trips people up in zsh, where an unquoted pair of square brackets is read as a glob pattern rather than as part of the requirement.",[10,1761,1762],{},"For teams distributing internally, add the private-index form and the git form, since both come up:",[134,1764,1766],{"className":569,"code":1765,"language":571,"meta":139,"style":139},"pipx install --index-url https:\u002F\u002Fpypi.internal\u002Fsimple mytool\npipx install \"git+https:\u002F\u002Fgithub.com\u002Fyou\u002Fmytool@v1.4.0\"\n",[14,1767,1768,1783],{"__ignoreMap":139},[143,1769,1770,1772,1774,1777,1780],{"class":145,"line":146},[143,1771,76],{"class":153},[143,1773,584],{"class":166},[143,1775,1776],{"class":357}," --index-url",[143,1778,1779],{"class":166}," https:\u002F\u002Fpypi.internal\u002Fsimple",[143,1781,1782],{"class":166}," mytool\n",[143,1784,1785,1787,1789],{"class":145,"line":160},[143,1786,76],{"class":153},[143,1788,584],{"class":166},[143,1790,1791],{"class":166}," \"git+https:\u002F\u002Fgithub.com\u002Fyou\u002Fmytool@v1.4.0\"\n",[23,1793,1795],{"id":1794},"frequently-asked-questions","Frequently asked questions",[1797,1798,1800],"h3",{"id":1799},"do-i-need-to-build-a-wheel-per-platform","Do I need to build a wheel per platform?",[10,1802,1803,1804,1807,1808,1810,1811,1814],{},"Not for a pure-Python CLI. One ",[14,1805,1806],{},"py3-none-any"," wheel covers every platform and interpreter that\nsatisfies ",[14,1809,801],{},". Platform-specific wheels only enter the picture if you ship compiled\nextensions — at which point you need a build matrix and ",[14,1812,1813],{},"cibuildwheel",", which is a substantially\nlarger undertaking.",[1797,1816,1818,1819,562,1821,1824],{"id":1817},"what-is-the-difference-between-python-m-build-and-pip-wheel","What is the difference between ",[14,1820,93],{},[14,1822,1823],{},"pip wheel","?",[10,1826,1827,1829,1830,1832,1833,132],{},[14,1828,1140],{}," is the standards-compliant front end: it creates an isolated environment, invokes your\ndeclared backend, and produces both an sdist and a wheel. ",[14,1831,1823],{}," builds a wheel for\ninstallation purposes and pulls in dependencies too. For publishing, use ",[14,1834,1140],{},[1797,1836,1838],{"id":1837},"how-do-i-check-what-is-actually-inside-the-wheel","How do I check what is actually inside the wheel?",[10,1840,1841,1844,1845,1848,1849,1852],{},[14,1842,1843],{},"unzip -l dist\u002Fmytool-1.4.0-py3-none-any.whl"," lists every file in a second, and it is the fastest\nway to spot a missing package or a template that never made it in. ",[14,1846,1847],{},"python -m zipfile -l"," does the\nsame without needing ",[14,1850,1851],{},"unzip"," installed.",[1797,1854,1856],{"id":1855},"should-the-sdist-contain-the-tests","Should the sdist contain the tests?",[10,1858,1859,1860,1863],{},"Including them is conventional and harmless: it lets distribution packagers run your suite while\nbuilding. What must not be in either artifact is anything secret, large or generated — check the\nsdist contents once with ",[14,1861,1862],{},"tar tzf dist\u002F*.tar.gz"," and add exclusions if something surprising\nappears.",[1797,1865,1867],{"id":1866},"can-i-ship-a-tool-that-has-no-dependencies-at-all","Can I ship a tool that has no dependencies at all?",[10,1869,1870,1871,1874],{},"Yes, and it is a genuinely nice property for a CLI — the install cannot conflict with anything.\nAn ",[14,1872,1873],{},"argparse","-based tool with no third-party imports produces a wheel that installs anywhere in\nunder a second. Whether that is worth the extra code depends on your audience; for developer tools\nit usually is not, for bootstrap and installer scripts it usually is.",[1797,1876,1878],{"id":1877},"does-the-entry-point-name-have-to-match-the-package-name","Does the entry point name have to match the package name?",[10,1880,1881,1882,1884,1885,1888,1889,1892],{},"No. The key in ",[14,1883,40],{}," is the command users type, and the value is where it lives, so\na package called ",[14,1886,1887],{},"mycompany-deploy-tools"," can install a command called ",[14,1890,1891],{},"deploy",". Choose the command\nname for typing and the package name for the index, and remember the command name is a claim on\neveryone's PATH — make it distinctive.",[23,1894,1896],{"id":1895},"related","Related",[28,1898,1899,1906,1911,1916,1922],{},[31,1900,1901,1905],{},[291,1902,1904],{"href":1903},"\u002Fproject-setup-dependency-management\u002F","Project Setup & Dependency Management"," — the parent track: environments, dependencies, and release hygiene.",[31,1907,1908,1910],{},[291,1909,1045],{"href":1044}," — the fast resolver and installer that also builds and publishes.",[31,1912,1913,1915],{},[291,1914,1050],{"href":1049}," — an all-in-one alternative that manages deps, builds, and uploads.",[31,1917,1918,1921],{},[291,1919,1920],{"href":293},"Best practices for Python CLI entry points"," — how the command name maps to your code.",[31,1923,1924,1927],{},[291,1925,1926],{"href":774},"Managing CLI versioning and changelogs"," — keep versions and release notes honest before you publish.",[1929,1930,1931],"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 .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}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":139,"searchDepth":160,"depth":160,"links":1933},[1934,1935,1936,1937,1938,1939,1940,1941,1942,1943,1944,1945,1946,1955],{"id":25,"depth":160,"text":26},{"id":109,"depth":160,"text":110},{"id":425,"depth":160,"text":426},{"id":473,"depth":160,"text":474},{"id":555,"depth":160,"text":556},{"id":732,"depth":160,"text":733},{"id":944,"depth":160,"text":945},{"id":991,"depth":160,"text":992},{"id":1060,"depth":160,"text":1061},{"id":1281,"depth":160,"text":1282},{"id":1457,"depth":160,"text":1458},{"id":1673,"depth":160,"text":1674},{"id":1794,"depth":160,"text":1795,"children":1947},[1948,1949,1951,1952,1953,1954],{"id":1799,"depth":170,"text":1800},{"id":1817,"depth":170,"text":1950},"What is the difference between python -m build and pip wheel?",{"id":1837,"depth":170,"text":1838},{"id":1855,"depth":170,"text":1856},{"id":1866,"depth":170,"text":1867},{"id":1877,"depth":170,"text":1878},{"id":1895,"depth":160,"text":1896},"2026-07-05","Turn a Python CLI into an installable package: define entry points in pyproject.toml, build wheels and sdists, install with pipx, and publish to PyPI.","intermediate",false,"md",{},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution",{"title":5,"description":1957},"project-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Findex",[1966,1967,76,1968,1969,1970],"packaging","distribution","pypi","wheels","entry-points","2026-08-01","VLzJXQEETa_3DfZnFFDnjAx0MAMOv1VfJ2QhEGv0D_4",[1974,1977,1980,1983,1986,1989,1992,1995,1998,2001,2004,2007,2010,2013,2016,2019,2022,2025,2028,2031,2034,2037,2040,2043,2046,2049,2052,2055,2058,2061,2064,2067,2069,2072,2075,2078,2081,2084,2087,2090,2093,2096,2099,2102,2104,2107,2110,2113,2116,2119,2122,2125,2128,2131,2134,2137,2140,2143,2146,2149,2152,2155,2158,2161,2164,2167,2169,2172,2175,2178,2181,2182,2185,2187,2190,2193,2196,2199,2202,2205,2208,2211,2214],{"path":1975,"title":1976},"\u002Fabout","About Python CLI Toolcraft",{"path":1978,"title":1979},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies","Advanced Argument Validation Strategies",{"path":1981,"title":1982},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fparsing-nested-json-arguments-in-python-clis","Parsing Nested JSON Args in Python CLIs",{"path":1984,"title":1985},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fvalidating-file-and-directory-paths-in-clis","Validating File and Directory Paths in CLIs",{"path":1987,"title":1988},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fadding-examples-and-epilogs-to-help-output","Adding Examples and Epilogs to Help Output",{"path":1990,"title":1991},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fgenerating-man-pages-and-docs-from-a-cli","Generating Man Pages and Docs from a CLI",{"path":1993,"title":1994},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation","CLI Help Output and Documentation",{"path":1996,"title":1997},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fversioning-and-deprecating-cli-flags","Versioning and Deprecating CLI Flags",{"path":1999,"title":2000},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fwriting-help-text-users-actually-read","Writing Help Text Users Actually Read",{"path":2002,"title":2003},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Fchoosing-exit-codes-for-cli-tools","Choosing Exit Codes for CLI Tools",{"path":2005,"title":2006},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Ffriendly-error-messages-and-tracebacks","Friendly Error Messages and Tracebacks",{"path":2008,"title":2009},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Fhandling-keyboard-interrupt-cleanly","Handling Keyboard Interrupt Cleanly",{"path":2011,"title":2012},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes","Error Handling and Exit Codes for CLIs",{"path":2014,"title":2015},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Fconfig-precedence-flags-env-files-defaults","Config Precedence: Flags, Env, Files, Defaults",{"path":2017,"title":2018},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars","Handling Config Files and Env Vars in CLIs",{"path":2020,"title":2021},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Floading-yaml-configs-safely-in-cli-apps","Loading YAML configs safely in CLI apps",{"path":2023,"title":2024},"\u002Fadvanced-input-parsing-user-experience","Advanced Input Parsing for Python CLIs",{"path":2026,"title":2027},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Fadding-progress-bars-and-spinners-to-python-clis","Progress Bars and Spinners for Python CLIs",{"path":2029,"title":2030},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich","Interactive Terminal UI with Rich",{"path":2032,"title":2033},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Frendering-tables-and-json-with-rich","Rendering Tables and JSON with Rich",{"path":2035,"title":2036},"\u002Fadvanced-input-parsing-user-experience\u002Fshell-completion-for-python-clis\u002Fenabling-tab-completion-in-click-and-typer","Enabling Tab Completion in Click and Typer",{"path":2038,"title":2039},"\u002Fadvanced-input-parsing-user-experience\u002Fshell-completion-for-python-clis","Shell Completion for Python CLIs",{"path":2041,"title":2042},"\u002Fadvanced-input-parsing-user-experience\u002Fshell-completion-for-python-clis\u002Finstalling-shell-completion-for-bash-zsh-fish","Installing Shell Completion for bash, zsh, fish",{"path":2044,"title":2045},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fadding-verbose-and-quiet-logging-flags","Adding Verbose and Quiet Logging Flags",{"path":2047,"title":2048},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps","Structured Logging for CLI Apps",{"path":2050,"title":2051},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fstructured-json-logging-in-python-clis","Structured JSON Logging in Python CLIs",{"path":2053,"title":2054},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Fdetecting-tty-and-adapting-output","Detecting a TTY and Adapting Output",{"path":2056,"title":2057},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Femitting-json-output-for-scripting","Emitting JSON Output for Scripting",{"path":2059,"title":2060},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Fhandling-broken-pipe-and-sigpipe","Handling Broken Pipe and SIGPIPE",{"path":2062,"title":2063},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes","Working with stdin, stdout and Pipes",{"path":2065,"title":2066},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Freading-piped-input-in-python-clis","Reading Piped Input in Python CLIs",{"path":81,"title":2068},"Python CLI Toolcraft",{"path":2070,"title":2071},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading","CLI Startup Performance and Lazy Loading",{"path":2073,"title":2074},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Flazy-loading-subcommands-for-faster-startup","Lazy Loading Subcommands for Faster Startup",{"path":2076,"title":2077},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Fprofiling-python-cli-startup-time","Profiling Python CLI Startup Time",{"path":2079,"title":2080},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Freducing-cli-dependency-weight","Reducing CLI Dependency Weight",{"path":2082,"title":2083},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fargparse-subparsers-for-subcommands","argparse Subparsers for Subcommands",{"path":2085,"title":2086},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fargparse-vs-click-vs-typer-comparison","argparse vs Click vs Typer Compared",{"path":2088,"title":2089},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse","Command-Line Parsing with argparse",{"path":2091,"title":2092},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fmigrating-from-argparse-to-typer","Migrating from argparse to Typer",{"path":2094,"title":2095},"\u002Fmodern-python-cli-frameworks-architecture","Python CLI Frameworks and Architecture",{"path":2097,"title":2098},"\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis","Plugin Architectures for Extensible CLIs",{"path":2100,"title":2101},"\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis\u002Fwriting-a-plugin-for-an-existing-cli","Writing a Plugin for an Existing CLI",{"path":2103,"title":1920},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fbest-practices-for-python-cli-entry-points",{"path":2105,"title":2106},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fdependency-injection-patterns-for-cli-commands","Dependency Injection Patterns for CLI Commands",{"path":2108,"title":2109},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fhow-to-structure-a-large-python-cli-project","Structuring a Large Python CLI Project",{"path":2111,"title":2112},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis","Structuring Multi-Command Python CLIs",{"path":2114,"title":2115},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fsharing-state-with-click-context-objects","Sharing State with Click Context Objects",{"path":2117,"title":2118},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications","Testing Python CLI Applications",{"path":2120,"title":2121},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmeasuring-cli-test-coverage","Measuring CLI Test Coverage",{"path":2123,"title":2124},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmocking-filesystem-and-network-in-cli-tests","Mocking the Filesystem and Network in CLI Tests",{"path":2126,"title":2127},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fsnapshot-testing-cli-output","Snapshot Testing CLI Output",{"path":2129,"title":2130},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-click-commands-with-clirunner","Testing Click Commands with CliRunner",{"path":2132,"title":2133},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-interactive-prompts-and-stdin","Testing Interactive Prompts and stdin",{"path":2135,"title":2136},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Fbuilding-a-cli-with-subcommands-in-click","Building a CLI with subcommands in Click",{"path":2138,"title":2139},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Fconverting-a-click-app-to-typer","Converting a Click App to Typer",{"path":2141,"title":2142},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each","Typer vs Click: When to Use Each",{"path":2144,"title":2145},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Ftyper-callback-functions-explained","Typer callback functions explained",{"path":2147,"title":2148},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter\u002Fcopier-vs-cookiecutter-for-cli-templates","Copier vs Cookiecutter for CLI Templates",{"path":2150,"title":2151},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter","CLI Project Scaffolding with Cookiecutter",{"path":2153,"title":2154},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fbuilding-cross-platform-release-binaries-in-ci","Building Cross-Platform Release Binaries in CI",{"path":2156,"title":2157},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fbundling-a-python-cli-with-pyinstaller","Bundling a Python CLI with PyInstaller",{"path":2159,"title":2160},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fhomebrew-and-scoop-packaging-for-python-clis","Homebrew and Scoop Packaging for Python CLIs",{"path":2162,"title":2163},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries","Distributing CLIs as Standalone Binaries",{"path":2165,"title":2166},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fshipping-a-cli-as-a-zipapp-with-shiv","Shipping a CLI as a Zipapp with shiv",{"path":2168,"title":1904},"\u002Fproject-setup-dependency-management",{"path":2170,"title":2171},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fautomating-changelogs-with-conventional-commits","Automating Changelogs with Conventional Commits",{"path":2173,"title":2174},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fexposing-version-info-and-build-metadata","Exposing Version Info and Build Metadata",{"path":2176,"title":2177},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs","Managing CLI Versioning & Changelogs",{"path":2179,"title":2180},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fbuilding-wheels-and-sdists-for-python-clis","Building Wheels and sdists for Python CLIs",{"path":1962,"title":5},{"path":2183,"title":2184},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Finstalling-and-distributing-clis-with-pipx","Installing and Distributing CLIs with pipx",{"path":2186,"title":984},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fpublishing-a-python-cli-to-pypi",{"path":2188,"title":2189},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development","Poetry Workflows for CLI Development",{"path":2191,"title":2192},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development\u002Fpoetry-entry-points-and-scripts-for-clis","Poetry Entry Points and Scripts for CLIs",{"path":2194,"title":2195},"\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects","Pre-commit Hooks for CLI Projects",{"path":2197,"title":2198},"\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects\u002Fsetting-up-pre-commit-for-python-cli-repos","Setting up pre-commit for Python CLI repos",{"path":2200,"title":2201},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management","uv for Python CLI Dependency Management",{"path":2203,"title":2204},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management\u002Fuv-init-vs-poetry-init-for-cli-tools","uv init vs poetry init for CLI tools",{"path":2206,"title":2207},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management\u002Fuv-tool-install-vs-pipx-for-clis","uv tool install vs pipx for CLIs",{"path":2209,"title":2210},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices","Python CLI Env Isolation Best Practices",{"path":2212,"title":2213},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fmanaging-virtual-environments-for-cross-platform-clis","Managing Python CLI Virtual Environments",{"path":2215,"title":2216},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fpinning-the-python-version-for-a-cli","Pinning the Python Version for a CLI",1785614690035]