[{"data":1,"prerenderedAt":1243},["ShallowReactive",2],{"page-\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fpublishing-a-python-cli-to-pypi\u002F":3,"content-directory":1092},{"id":4,"title":5,"body":6,"date":1077,"description":1078,"difficulty":1079,"draft":1080,"extension":1081,"meta":1082,"navigation":422,"path":1083,"seo":1084,"stem":1085,"tags":1086,"updated":1077,"__hash__":1091},"content\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fpublishing-a-python-cli-to-pypi\u002Findex.md","Publishing a Python CLI to PyPI",{"type":7,"value":8,"toc":1065},"minimark",[9,22,27,83,87,115,166,173,209,227,231,239,292,299,353,356,360,375,382,387,491,494,526,536,540,543,600,637,641,648,655,852,868,872,875,926,945,949,960,967,971,1025,1029,1061],[10,11,12,13,17,18,21],"p",{},"Publishing to PyPI is what turns ",[14,15,16],"code",{},"pipx install your-cli"," from a wish into a command that works\nfor anyone. The mechanics are straightforward — upload your built artifacts with ",[14,19,20],{},"twine"," — but\nthe parts that bite are the ones around it: choosing a name that is actually free, testing the\nupload safely first, handling credentials without leaking them, and automating releases so you\nare not typing tokens by hand. This guide walks the whole release, ending with the modern\nrecommendation: trusted publishing from CI with no long-lived token at all.",[23,24,26],"h2",{"id":25},"tldr","TL;DR",[28,29,30,38,51,65,77],"ul",{},[31,32,33,37],"li",{},[34,35,36],"strong",{},"Check the name is free"," before you commit to it — PyPI names are global, normalized, and\nfirst-come.",[31,39,40,50],{},[34,41,42,43],{},"Dry-run on ",[44,45,49],"a",{"href":46,"rel":47},"https:\u002F\u002Ftest.pypi.org\u002F",[48],"nofollow","TestPyPI"," to rehearse the upload without burning a\nreal version number.",[31,52,53,56,57,60,61,64],{},[34,54,55],{},"Authenticate with an API token",", scoped to the project, stored in ",[14,58,59],{},"~\u002F.pypirc"," or the\n",[14,62,63],{},"TWINE_*"," environment variables — never your password.",[31,66,67,72,73,76],{},[34,68,69],{},[14,70,71],{},"twine upload dist\u002F*"," publishes; then verify with ",[14,74,75],{},"pipx install \u003Cname>"," from a clean\nmachine.",[31,78,79,82],{},[34,80,81],{},"Prefer trusted publishing"," (GitHub Actions + OIDC) so releases carry no secret to leak or\nrotate.",[23,84,86],{"id":85},"pick-a-name-and-check-availability","Pick a name and check availability",[10,88,89,90,93,94,97,98,101,102,105,106,109,110,114],{},"The ",[14,91,92],{},"name"," in ",[14,95,96],{},"[project]"," becomes your identity on PyPI, and it is permanent and global. Names\nare normalized before comparison — case, underscores, hyphens, and dots all fold together, so\n",[14,99,100],{},"Greet_CLI",", ",[14,103,104],{},"greet-cli",", and ",[14,107,108],{},"greet.cli"," are the ",[111,112,113],"em",{},"same"," name. Before you get attached to one,\ncheck that it is free:",[116,117,122],"pre",{"className":118,"code":119,"language":120,"meta":121,"style":121},"language-bash shiki shiki-themes github-light github-dark","$ curl -o \u002Fdev\u002Fnull -s -w \"%{http_code}\\n\" https:\u002F\u002Fpypi.org\u002Fpypi\u002Fgreet-cli\u002Fjson\n404      # 404 = available; 200 = already taken\n","bash","",[14,123,124,156],{"__ignoreMap":121},[125,126,129,133,137,141,144,147,150,153],"span",{"class":127,"line":128},"line",1,[125,130,132],{"class":131},"sScJk","$",[125,134,136],{"class":135},"sZZnC"," curl",[125,138,140],{"class":139},"sj4cs"," -o",[125,142,143],{"class":135}," \u002Fdev\u002Fnull",[125,145,146],{"class":139}," -s",[125,148,149],{"class":139}," -w",[125,151,152],{"class":135}," \"%{http_code}\\n\"",[125,154,155],{"class":135}," https:\u002F\u002Fpypi.org\u002Fpypi\u002Fgreet-cli\u002Fjson\n",[125,157,159,162],{"class":127,"line":158},2,[125,160,161],{"class":131},"404",[125,163,165],{"class":164},"sJ8bj","      # 404 = available; 200 = already taken\n",[10,167,168,169,172],{},"If it is taken, pick another — you cannot reuse or force a name, and squatting disputes are\nslow. Once chosen, set it in ",[14,170,171],{},"pyproject.toml"," and never change it casually; a rename means a\nbrand-new project that your existing users won't get updates from.",[116,174,178],{"className":175,"code":176,"language":177,"meta":121,"style":121},"language-toml shiki shiki-themes github-light github-dark","[project]\nname = \"greet-cli\"\nversion = \"0.1.0\"\n","toml",[14,179,180,192,200],{"__ignoreMap":121},[125,181,182,186,189],{"class":127,"line":128},[125,183,185],{"class":184},"sVt8B","[",[125,187,188],{"class":131},"project",[125,190,191],{"class":184},"]\n",[125,193,194,197],{"class":127,"line":158},[125,195,196],{"class":184},"name = ",[125,198,199],{"class":135},"\"greet-cli\"\n",[125,201,203,206],{"class":127,"line":202},3,[125,204,205],{"class":184},"version = ",[125,207,208],{"class":135},"\"0.1.0\"\n",[10,210,211,212,101,215,101,218,221,222,226],{},"The rest of the metadata (",[14,213,214],{},"readme",[14,216,217],{},"license",[14,219,220],{},"[project.urls]",") is your listing page; the\n",[44,223,225],{"href":224},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002F","packaging overview","\ncovers filling it in well.",[23,228,230],{"id":229},"rehearse-on-testpypi","Rehearse on TestPyPI",[10,232,233,234,238],{},"TestPyPI is a full, separate copy of the index for exactly this: practicing an upload without\nconsequences. Register a separate account there, then upload your built artifacts (see\n",[44,235,237],{"href":236},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fbuilding-wheels-and-sdists-for-python-clis\u002F","building wheels and sdists",")\nto it first:",[116,240,242],{"className":118,"code":241,"language":120,"meta":121,"style":121},"$ python -m build\n$ twine check dist\u002F*\n$ twine upload --repository testpypi dist\u002F*\n",[14,243,244,257,273],{"__ignoreMap":121},[125,245,246,248,251,254],{"class":127,"line":128},[125,247,132],{"class":131},[125,249,250],{"class":135}," python",[125,252,253],{"class":139}," -m",[125,255,256],{"class":135}," build\n",[125,258,259,261,264,267,270],{"class":127,"line":158},[125,260,132],{"class":131},[125,262,263],{"class":135}," twine",[125,265,266],{"class":135}," check",[125,268,269],{"class":135}," dist\u002F",[125,271,272],{"class":139},"*\n",[125,274,275,277,279,282,285,288,290],{"class":127,"line":202},[125,276,132],{"class":131},[125,278,263],{"class":135},[125,280,281],{"class":135}," upload",[125,283,284],{"class":139}," --repository",[125,286,287],{"class":135}," testpypi",[125,289,269],{"class":135},[125,291,272],{"class":139},[10,293,294,295,298],{},"Then install from TestPyPI to confirm the whole chain works. Because your dependencies live on\nreal PyPI, point ",[14,296,297],{},"--extra-index-url"," back at it so they still resolve:",[116,300,302],{"className":118,"code":301,"language":120,"meta":121,"style":121},"$ pipx install --index-url https:\u002F\u002Ftest.pypi.org\u002Fsimple\u002F \\\n      --pip-args \"--extra-index-url https:\u002F\u002Fpypi.org\u002Fsimple\u002F\" greet-cli\n$ greet World\nHello, World!\n",[14,303,304,323,334,344],{"__ignoreMap":121},[125,305,306,308,311,314,317,320],{"class":127,"line":128},[125,307,132],{"class":131},[125,309,310],{"class":135}," pipx",[125,312,313],{"class":135}," install",[125,315,316],{"class":139}," --index-url",[125,318,319],{"class":135}," https:\u002F\u002Ftest.pypi.org\u002Fsimple\u002F",[125,321,322],{"class":139}," \\\n",[125,324,325,328,331],{"class":127,"line":158},[125,326,327],{"class":139},"      --pip-args",[125,329,330],{"class":135}," \"--extra-index-url https:\u002F\u002Fpypi.org\u002Fsimple\u002F\"",[125,332,333],{"class":135}," greet-cli\n",[125,335,336,338,341],{"class":127,"line":202},[125,337,132],{"class":131},[125,339,340],{"class":135}," greet",[125,342,343],{"class":135}," World\n",[125,345,347,350],{"class":127,"line":346},4,[125,348,349],{"class":131},"Hello,",[125,351,352],{"class":135}," World!\n",[10,354,355],{},"If that installs and runs, the real upload will too. TestPyPI purges old projects periodically\nand is not for permanent hosting — it is a rehearsal stage only.",[23,357,359],{"id":358},"api-tokens-and-where-to-put-them","API tokens and where to put them",[10,361,362,363,366,367,370,371,374],{},"PyPI does not accept passwords for uploads; you authenticate with an ",[34,364,365],{},"API token",". Create one\nat ",[111,368,369],{},"Account settings → API tokens",". Make your first token account-scoped (you need it to create\na brand-new project), then, after the first upload, create a ",[34,372,373],{},"project-scoped"," token and delete\nthe broad one — least privilege, so a leak can't touch your other projects.",[10,376,377,378,381],{},"A token is used as the password with the username ",[14,379,380],{},"__token__",". Two good places to keep it:",[10,383,89,384,386],{},[14,385,59],{}," file, for a workstation:",[116,388,390],{"className":175,"code":389,"language":177,"meta":121,"style":121},"[distutils]\nindex-servers =\n    pypi\n    testpypi\n\n[pypi]\nusername = __token__\npassword = pypi-AgEIcHlwaS5vcmc...your-token...\n\n[testpypi]\nrepository = https:\u002F\u002Ftest.pypi.org\u002Flegacy\u002F\nusername = __token__\npassword = pypi-AgEIcHl...your-testpypi-token...\n",[14,391,392,401,406,412,417,424,434,443,452,457,467,476,483],{"__ignoreMap":121},[125,393,394,396,399],{"class":127,"line":128},[125,395,185],{"class":184},[125,397,398],{"class":131},"distutils",[125,400,191],{"class":184},[125,402,403],{"class":127,"line":158},[125,404,405],{"class":184},"index-servers =\n",[125,407,408],{"class":127,"line":202},[125,409,411],{"class":410},"s7hpK","    pypi\n",[125,413,414],{"class":127,"line":346},[125,415,416],{"class":410},"    testpypi\n",[125,418,420],{"class":127,"line":419},5,[125,421,423],{"emptyLinePlaceholder":422},true,"\n",[125,425,427,429,432],{"class":127,"line":426},6,[125,428,185],{"class":184},[125,430,431],{"class":131},"pypi",[125,433,191],{"class":184},[125,435,437,440],{"class":127,"line":436},7,[125,438,439],{"class":184},"username = _",[125,441,442],{"class":410},"_token__\n",[125,444,446,449],{"class":127,"line":445},8,[125,447,448],{"class":184},"password = p",[125,450,451],{"class":410},"ypi-AgEIcHlwaS5vcmc...your-token...\n",[125,453,455],{"class":127,"line":454},9,[125,456,423],{"emptyLinePlaceholder":422},[125,458,460,462,465],{"class":127,"line":459},10,[125,461,185],{"class":184},[125,463,464],{"class":131},"testpypi",[125,466,191],{"class":184},[125,468,470,473],{"class":127,"line":469},11,[125,471,472],{"class":184},"repository = h",[125,474,475],{"class":410},"ttps:\u002F\u002Ftest.pypi.org\u002Flegacy\u002F\n",[125,477,479,481],{"class":127,"line":478},12,[125,480,439],{"class":184},[125,482,442],{"class":410},[125,484,486,488],{"class":127,"line":485},13,[125,487,448],{"class":184},[125,489,490],{"class":410},"ypi-AgEIcHl...your-testpypi-token...\n",[10,492,493],{},"Or environment variables, which are better for CI because nothing is written to disk:",[116,495,497],{"className":118,"code":496,"language":120,"meta":121,"style":121},"export TWINE_USERNAME=__token__\nexport TWINE_PASSWORD=pypi-AgEIcHlwaS5vcmc...\n",[14,498,499,514],{"__ignoreMap":121},[125,500,501,505,508,511],{"class":127,"line":128},[125,502,504],{"class":503},"szBVR","export",[125,506,507],{"class":184}," TWINE_USERNAME",[125,509,510],{"class":503},"=",[125,512,513],{"class":184},"__token__\n",[125,515,516,518,521,523],{"class":127,"line":158},[125,517,504],{"class":503},[125,519,520],{"class":184}," TWINE_PASSWORD",[125,522,510],{"class":503},[125,524,525],{"class":184},"pypi-AgEIcHlwaS5vcmc...\n",[10,527,528,529,531,532,535],{},"Never commit either. ",[14,530,59],{}," should be ",[14,533,534],{},"chmod 600"," and outside any repo; the env-var form\nbelongs in your CI provider's secret store, not in the workflow file.",[23,537,539],{"id":538},"upload-with-twine","Upload with twine",[10,541,542],{},"With artifacts built, checked, and credentials in place, the publish itself is one command:",[116,544,546],{"className":118,"code":545,"language":120,"meta":121,"style":121},"$ twine check dist\u002F*\n$ twine upload dist\u002F*\nUploading greet_cli-0.1.0-py3-none-any.whl\nUploading greet_cli-0.1.0.tar.gz\nView at:\nhttps:\u002F\u002Fpypi.org\u002Fproject\u002Fgreet-cli\u002F0.1.0\u002F\n",[14,547,548,560,572,580,587,595],{"__ignoreMap":121},[125,549,550,552,554,556,558],{"class":127,"line":128},[125,551,132],{"class":131},[125,553,263],{"class":135},[125,555,266],{"class":135},[125,557,269],{"class":135},[125,559,272],{"class":139},[125,561,562,564,566,568,570],{"class":127,"line":158},[125,563,132],{"class":131},[125,565,263],{"class":135},[125,567,281],{"class":135},[125,569,269],{"class":135},[125,571,272],{"class":139},[125,573,574,577],{"class":127,"line":202},[125,575,576],{"class":131},"Uploading",[125,578,579],{"class":135}," greet_cli-0.1.0-py3-none-any.whl\n",[125,581,582,584],{"class":127,"line":346},[125,583,576],{"class":131},[125,585,586],{"class":135}," greet_cli-0.1.0.tar.gz\n",[125,588,589,592],{"class":127,"line":419},[125,590,591],{"class":131},"View",[125,593,594],{"class":135}," at:\n",[125,596,597],{"class":127,"line":426},[125,598,599],{"class":131},"https:\u002F\u002Fpypi.org\u002Fproject\u002Fgreet-cli\u002F0.1.0\u002F\n",[10,601,602,603,605,606,93,609,612,613,616,617,620,621,624,625,628,629,631,632,636],{},"Two things to internalize. First, ",[14,604,71],{}," uploads ",[111,607,608],{},"everything",[14,610,611],{},"dist\u002F",", so\nclear stale files (",[14,614,615],{},"rm -rf dist\u002F"," before building) or name the exact files to avoid shipping a\nleftover from a previous version. Second, ",[34,618,619],{},"a version number is single-use",": PyPI permanently\nrejects re-uploading a version, even after you delete it. If ",[14,622,623],{},"0.1.0"," had a bug, you release\n",[14,626,627],{},"0.1.1"," — you cannot overwrite ",[14,630,623],{},". Keep versions marching forward, ideally driven from a\ntag as in\n",[44,633,635],{"href":634},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002F","managing CLI versioning and changelogs",".",[23,638,640],{"id":639},"trusted-publishing-from-github-actions","Trusted publishing from GitHub Actions",[10,642,643,644,647],{},"The modern, recommended way to publish is ",[34,645,646],{},"trusted publishing",": PyPI trusts a specific GitHub\nActions workflow directly via OpenID Connect, so the runner mints a short-lived token at publish\ntime. There is no long-lived secret to store, leak, or rotate — the single biggest source of\nsupply-chain incidents for packages.",[10,649,650,651,654],{},"Set it up once in the PyPI project's ",[111,652,653],{},"Publishing"," settings: register your GitHub repository,\nworkflow filename, and (optionally) an environment name. Then this workflow publishes on every\ntagged release:",[116,656,660],{"className":657,"code":658,"language":659,"meta":121,"style":121},"language-yaml shiki shiki-themes github-light github-dark","name: Publish to PyPI\n\non:\n  push:\n    tags: [\"v*\"]\n\njobs:\n  publish:\n    runs-on: ubuntu-latest\n    environment: release\n    permissions:\n      id-token: write        # required for OIDC trusted publishing\n    steps:\n      - uses: actions\u002Fcheckout@v4\n      - uses: actions\u002Fsetup-python@v5\n        with:\n          python-version: \"3.12\"\n      - run: python -m pip install build\n      - run: python -m build\n      - uses: pypa\u002Fgh-action-pypi-publish@release\u002Fv1\n","yaml",[14,661,662,673,677,685,692,705,709,716,723,733,743,750,763,770,784,796,804,815,828,840],{"__ignoreMap":121},[125,663,664,667,670],{"class":127,"line":128},[125,665,92],{"class":666},"s9eBZ",[125,668,669],{"class":184},": ",[125,671,672],{"class":135},"Publish to PyPI\n",[125,674,675],{"class":127,"line":158},[125,676,423],{"emptyLinePlaceholder":422},[125,678,679,682],{"class":127,"line":202},[125,680,681],{"class":139},"on",[125,683,684],{"class":184},":\n",[125,686,687,690],{"class":127,"line":346},[125,688,689],{"class":666},"  push",[125,691,684],{"class":184},[125,693,694,697,700,703],{"class":127,"line":419},[125,695,696],{"class":666},"    tags",[125,698,699],{"class":184},": [",[125,701,702],{"class":135},"\"v*\"",[125,704,191],{"class":184},[125,706,707],{"class":127,"line":426},[125,708,423],{"emptyLinePlaceholder":422},[125,710,711,714],{"class":127,"line":436},[125,712,713],{"class":666},"jobs",[125,715,684],{"class":184},[125,717,718,721],{"class":127,"line":445},[125,719,720],{"class":666},"  publish",[125,722,684],{"class":184},[125,724,725,728,730],{"class":127,"line":454},[125,726,727],{"class":666},"    runs-on",[125,729,669],{"class":184},[125,731,732],{"class":135},"ubuntu-latest\n",[125,734,735,738,740],{"class":127,"line":459},[125,736,737],{"class":666},"    environment",[125,739,669],{"class":184},[125,741,742],{"class":135},"release\n",[125,744,745,748],{"class":127,"line":469},[125,746,747],{"class":666},"    permissions",[125,749,684],{"class":184},[125,751,752,755,757,760],{"class":127,"line":478},[125,753,754],{"class":666},"      id-token",[125,756,669],{"class":184},[125,758,759],{"class":135},"write",[125,761,762],{"class":164},"        # required for OIDC trusted publishing\n",[125,764,765,768],{"class":127,"line":485},[125,766,767],{"class":666},"    steps",[125,769,684],{"class":184},[125,771,773,776,779,781],{"class":127,"line":772},14,[125,774,775],{"class":184},"      - ",[125,777,778],{"class":666},"uses",[125,780,669],{"class":184},[125,782,783],{"class":135},"actions\u002Fcheckout@v4\n",[125,785,787,789,791,793],{"class":127,"line":786},15,[125,788,775],{"class":184},[125,790,778],{"class":666},[125,792,669],{"class":184},[125,794,795],{"class":135},"actions\u002Fsetup-python@v5\n",[125,797,799,802],{"class":127,"line":798},16,[125,800,801],{"class":666},"        with",[125,803,684],{"class":184},[125,805,807,810,812],{"class":127,"line":806},17,[125,808,809],{"class":666},"          python-version",[125,811,669],{"class":184},[125,813,814],{"class":135},"\"3.12\"\n",[125,816,818,820,823,825],{"class":127,"line":817},18,[125,819,775],{"class":184},[125,821,822],{"class":666},"run",[125,824,669],{"class":184},[125,826,827],{"class":135},"python -m pip install build\n",[125,829,831,833,835,837],{"class":127,"line":830},19,[125,832,775],{"class":184},[125,834,822],{"class":666},[125,836,669],{"class":184},[125,838,839],{"class":135},"python -m build\n",[125,841,843,845,847,849],{"class":127,"line":842},20,[125,844,775],{"class":184},[125,846,778],{"class":666},[125,848,669],{"class":184},[125,850,851],{"class":135},"pypa\u002Fgh-action-pypi-publish@release\u002Fv1\n",[10,853,854,855,858,859,862,863,867],{},"Notice there is no token anywhere. The ",[14,856,857],{},"id-token: write"," permission lets the runner prove its\nidentity to PyPI, which hands back a scoped, minutes-long credential. Tag a release\n(",[14,860,861],{},"git tag v0.1.0 && git push --tags",") and the package publishes itself. This pairs naturally\nwith tag-driven versioning and with\n",[44,864,866],{"href":865},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fautomating-changelogs-with-conventional-commits\u002F","automating changelogs with conventional commits","\nto produce fully hands-off releases.",[23,869,871],{"id":870},"verify-the-published-release","Verify the published release",[10,873,874],{},"Publishing is not done until a user's install path works. From a clean machine or container —\nnot your dev box, where things may already be cached — install exactly the way your users will:",[116,876,878],{"className":118,"code":877,"language":120,"meta":121,"style":121},"$ pipx install greet-cli\n  installed package greet-cli 0.1.0\n    - greet\n$ greet World\nHello, World!\n",[14,879,880,890,904,912,920],{"__ignoreMap":121},[125,881,882,884,886,888],{"class":127,"line":128},[125,883,132],{"class":131},[125,885,310],{"class":135},[125,887,313],{"class":135},[125,889,333],{"class":135},[125,891,892,895,898,901],{"class":127,"line":158},[125,893,894],{"class":131},"  installed",[125,896,897],{"class":135}," package",[125,899,900],{"class":135}," greet-cli",[125,902,903],{"class":139}," 0.1.0\n",[125,905,906,909],{"class":127,"line":202},[125,907,908],{"class":131},"    -",[125,910,911],{"class":135}," greet\n",[125,913,914,916,918],{"class":127,"line":346},[125,915,132],{"class":131},[125,917,340],{"class":135},[125,919,343],{"class":135},[125,921,922,924],{"class":127,"line":419},[125,923,349],{"class":131},[125,925,352],{"class":135},[10,927,928,929,932,933,937,938,941,942,636],{},"Also open ",[14,930,931],{},"https:\u002F\u002Fpypi.org\u002Fproject\u002Fgreet-cli\u002F"," and confirm the README renders, the links in\nthe sidebar work, and the version is what you expect. Installing via\n",[44,934,936],{"href":935},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Finstalling-and-distributing-clis-with-pipx\u002F","pipx","\nhere doubles as proof that your ",[14,939,940],{},"[project.scripts]"," entry point survived the whole trip and the\ncommand exists on ",[14,943,944],{},"PATH",[23,946,948],{"id":947},"yanking-a-bad-release","Yanking a bad release",[10,950,951,952,955,956,959],{},"Sometimes a release ships broken and you cannot delete it (and shouldn't — deletion breaks\nanyone who pinned it). The right move is to ",[34,953,954],{},"yank"," it. A yanked version stays downloadable for\npins that already reference it exactly, but new installs and resolvers skip it, so\n",[14,957,958],{},"pipx install greet-cli"," quietly picks the previous good version instead.",[10,961,962,963,966],{},"Yank from the release's page in the PyPI web UI (",[111,964,965],{},"Manage → the version → Options → Yank","), then\npublish a fixed version. Yanking is for \"this release is broken, steer people away,\" not for\n\"I changed my mind\" — it is reversible, but the cleaner story is always to roll forward to a new\nversion.",[23,968,970],{"id":969},"production-notes","Production notes",[28,972,973,989,999,1009,1019],{},[31,974,975,978,979,982,983,986,987,636],{},[34,976,977],{},"Automate, then never publish by hand."," Manual ",[14,980,981],{},"twine upload"," is fine for a first release,\nbut tag-triggered trusted publishing removes the token ",[111,984,985],{},"and"," the human error of uploading the\nwrong ",[14,988,611],{},[31,990,991,994,995,998],{},[34,992,993],{},"Generate a PEP 740 attestation."," ",[14,996,997],{},"gh-action-pypi-publish"," emits signed provenance\nautomatically under trusted publishing, giving users a verifiable link from the artifact back\nto the exact commit and workflow that built it.",[31,1000,1001,1004,1005,1008],{},[34,1002,1003],{},"Register the name early."," If you are worried about a name being taken, publish a ",[14,1006,1007],{},"0.0.1","\nplaceholder to claim it; you own the project from the first upload.",[31,1010,1011,1018],{},[34,1012,1013,1014,1017],{},"Keep ",[14,1015,1016],{},"requires-python"," honest."," It filters which interpreters even attempt the install; an\noverly generous floor turns a clean rejection into a confusing runtime crash for users.",[31,1020,1021,1024],{},[34,1022,1023],{},"TestPyPI accounts are separate."," Its account database, tokens, and trusted-publisher config\nare independent from PyPI — set each up on both if you rehearse there.",[23,1026,1028],{"id":1027},"related","Related",[28,1030,1031,1037,1043,1049,1055],{},[31,1032,1033,1036],{},[44,1034,1035],{"href":224},"Packaging Python CLIs for Distribution"," — the overview this guide sits under.",[31,1038,1039,1042],{},[44,1040,1041],{"href":236},"Building wheels and sdists for Python CLIs"," — produce and verify the artifacts you upload here.",[31,1044,1045,1048],{},[44,1046,1047],{"href":935},"Installing and distributing CLIs with pipx"," — how users install what you publish.",[31,1050,1051,1054],{},[44,1052,1053],{"href":634},"Managing CLI versioning and changelogs"," — pick and single-source the version each release stamps.",[31,1056,1057,1060],{},[44,1058,1059],{"href":865},"Automating changelogs with conventional commits"," — the other half of a hands-off release pipeline.",[1062,1063,1064],"style",{},"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 .s7hpK, html code.shiki .s7hpK{--shiki-default:#B31D28;--shiki-default-font-style:italic;--shiki-dark:#FDAEB7;--shiki-dark-font-style:italic}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":121,"searchDepth":158,"depth":158,"links":1066},[1067,1068,1069,1070,1071,1072,1073,1074,1075,1076],{"id":25,"depth":158,"text":26},{"id":85,"depth":158,"text":86},{"id":229,"depth":158,"text":230},{"id":358,"depth":158,"text":359},{"id":538,"depth":158,"text":539},{"id":639,"depth":158,"text":640},{"id":870,"depth":158,"text":871},{"id":947,"depth":158,"text":948},{"id":969,"depth":158,"text":970},{"id":1027,"depth":158,"text":1028},"2026-07-05","Publish a Python CLI to PyPI and TestPyPI with twine and API tokens, pick a unique package name, and automate releases so pipx and pip installs work.","intermediate",false,"md",{},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fpublishing-a-python-cli-to-pypi",{"title":5,"description":1078},"project-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fpublishing-a-python-cli-to-pypi\u002Findex",[431,1087,1088,1089,1090],"packaging","distribution","versioning","cli","cJ7dA2Hz8AsE9BYY2u6cFDxc2j_Hi4hpMi8T-Rkn4RM",[1093,1096,1099,1102,1105,1108,1111,1114,1117,1120,1123,1126,1129,1132,1135,1138,1141,1144,1147,1150,1153,1156,1159,1162,1165,1168,1171,1174,1177,1180,1183,1186,1189,1192,1195,1198,1201,1204,1207,1210,1212,1215,1216,1219,1222,1225,1228,1231,1234,1237,1240],{"path":1094,"title":1095},"\u002Fabout","About Python CLI Toolcraft",{"path":1097,"title":1098},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies","Advanced Argument Validation Strategies",{"path":1100,"title":1101},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fparsing-nested-json-arguments-in-python-clis","Parsing Nested JSON Args in Python CLIs",{"path":1103,"title":1104},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Fchoosing-exit-codes-for-cli-tools","Choosing Exit Codes for CLI Tools",{"path":1106,"title":1107},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Ffriendly-error-messages-and-tracebacks","Friendly Error Messages and Tracebacks",{"path":1109,"title":1110},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes","Error Handling and Exit Codes for CLIs",{"path":1112,"title":1113},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Fconfig-precedence-flags-env-files-defaults","Config Precedence: Flags, Env, Files, Defaults",{"path":1115,"title":1116},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars","Handling Config Files and Env Vars in CLIs",{"path":1118,"title":1119},"\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":1121,"title":1122},"\u002Fadvanced-input-parsing-user-experience","Advanced Input Parsing for Python CLIs",{"path":1124,"title":1125},"\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":1127,"title":1128},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich","Interactive Terminal UI with Rich",{"path":1130,"title":1131},"\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":1133,"title":1134},"\u002Fadvanced-input-parsing-user-experience\u002Fshell-completion-for-python-clis","Shell Completion for Python CLIs",{"path":1136,"title":1137},"\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":1139,"title":1140},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fadding-verbose-and-quiet-logging-flags","Adding Verbose and Quiet Logging Flags",{"path":1142,"title":1143},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps","Structured Logging for CLI Apps",{"path":1145,"title":1146},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fstructured-json-logging-in-python-clis","Structured JSON Logging in Python CLIs",{"path":1148,"title":1149},"\u002F","Python CLI Toolcraft",{"path":1151,"title":1152},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading","CLI Startup Performance and Lazy Loading",{"path":1154,"title":1155},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Flazy-loading-subcommands-for-faster-startup","Lazy Loading Subcommands for Faster Startup",{"path":1157,"title":1158},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Fprofiling-python-cli-startup-time","Profiling Python CLI Startup Time",{"path":1160,"title":1161},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fargparse-subparsers-for-subcommands","argparse Subparsers for Subcommands",{"path":1163,"title":1164},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse","Command-Line Parsing with argparse",{"path":1166,"title":1167},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fmigrating-from-argparse-to-typer","Migrating from argparse to Typer",{"path":1169,"title":1170},"\u002Fmodern-python-cli-frameworks-architecture","Python CLI Frameworks and Architecture",{"path":1172,"title":1173},"\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis","Plugin Architectures for Extensible CLIs",{"path":1175,"title":1176},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fbest-practices-for-python-cli-entry-points","Best practices for Python CLI entry points",{"path":1178,"title":1179},"\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":1181,"title":1182},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis","Structuring Multi-Command Python CLIs",{"path":1184,"title":1185},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fsharing-state-with-click-context-objects","Sharing State with Click Context Objects",{"path":1187,"title":1188},"\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":1190,"title":1191},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each","Typer vs Click: When to Use Each",{"path":1193,"title":1194},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Ftyper-callback-functions-explained","Typer callback functions explained",{"path":1196,"title":1197},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter","CLI Project Scaffolding with Cookiecutter",{"path":1199,"title":1200},"\u002Fproject-setup-dependency-management","Project Setup & Dependency Management",{"path":1202,"title":1203},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fautomating-changelogs-with-conventional-commits","Automating Changelogs with Conventional Commits",{"path":1205,"title":1206},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs","Managing CLI Versioning & Changelogs",{"path":1208,"title":1209},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fbuilding-wheels-and-sdists-for-python-clis","Building Wheels and sdists for Python CLIs",{"path":1211,"title":1035},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution",{"path":1213,"title":1214},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Finstalling-and-distributing-clis-with-pipx","Installing and Distributing CLIs with pipx",{"path":1083,"title":5},{"path":1217,"title":1218},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development","Poetry Workflows for CLI Development",{"path":1220,"title":1221},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development\u002Fpoetry-entry-points-and-scripts-for-clis","Poetry Entry Points and Scripts for CLIs",{"path":1223,"title":1224},"\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects","Pre-commit Hooks for CLI Projects",{"path":1226,"title":1227},"\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":1229,"title":1230},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management","uv for Python CLI Dependency Management",{"path":1232,"title":1233},"\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":1235,"title":1236},"\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":1238,"title":1239},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices","Python CLI Env Isolation Best Practices",{"path":1241,"title":1242},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fmanaging-virtual-environments-for-cross-platform-clis","Managing Python CLI Virtual Environments",1783281867199]