[{"data":1,"prerenderedAt":2925},["ShallowReactive",2],{"page-\u002Fcli-runtime-systems-integration\u002Flong-running-and-watch-mode-clis\u002Fhealth-checks-and-heartbeats-for-long-running-clis\u002F":3,"content-directory":2378},{"id":4,"title":5,"body":6,"date":2363,"description":2364,"difficulty":2365,"draft":2366,"extension":2367,"meta":2368,"navigation":151,"path":2369,"seo":2370,"stem":2371,"tags":2372,"updated":2363,"__hash__":2377},"content\u002Fcli-runtime-systems-integration\u002Flong-running-and-watch-mode-clis\u002Fhealth-checks-and-heartbeats-for-long-running-clis\u002Findex.md","Health Checks and Heartbeats for Long-Running CLIs",{"type":7,"value":8,"toc":2344},"minimark",[9,36,41,70,74,77,81,100,104,107,110,803,811,816,1488,1502,1506,1512,1534,1620,1636,1640,1651,1657,1661,1664,1713,1717,1724,2231,2242,2246,2252,2256,2260,2263,2267,2286,2290,2293,2297,2305,2309,2340],[10,11,12,13,17,18,21,22,25,26,29,30,35],"p",{},"The worker has been \"running\" for three days. ",[14,15,16],"code",{},"systemctl status"," says active, ",[14,19,20],{},"docker ps"," says up, the process is in ",[14,23,24],{},"ps",". It has also not processed a single event since Tuesday, because a network call hung without a timeout and the loop never came back. Or the nightly sync has not failed in a month — because the timer was disabled during a migration and it has not run in a month. A process that exists is not the same as a process that is doing its job, and nothing notices the difference unless you give it a way to. This guide adds a heartbeat to a long-running CLI command, a ",[14,27,28],{},"health"," subcommand that turns it into an exit code for probes and scripts, and a dead-man switch for scheduled jobs whose failure mode is not running at all. It belongs to the ",[31,32,34],"a",{"href":33},"\u002Fcli-runtime-systems-integration\u002Flong-running-and-watch-mode-clis\u002F","long-running and watch-mode topic",".",[37,38,40],"h2",{"id":39},"prerequisites","Prerequisites",[42,43,44,52,63],"ul",{},[45,46,47,48,51],"li",{},"Python 3.10+, Typer and ",[14,49,50],{},"httpx"," (for the dead-man switch ping).",[45,53,54,55,58,59,35],{},"A long-running loop, like the one on ",[31,56,57],{"href":33},"the topic overview",", or a scheduled command as in ",[31,60,62],{"href":61},"\u002Fcli-runtime-systems-integration\u002Flong-running-and-watch-mode-clis\u002Frunning-a-cli-on-a-schedule-with-cron-and-systemd\u002F","running a CLI on a schedule with cron and systemd",[45,64,65,66,35],{},"A state directory for the heartbeat file, from ",[31,67,69],{"href":68},"\u002Fcli-runtime-systems-integration\u002Ffilesystem-paths-and-atomic-writes\u002Fstoring-app-data-with-platformdirs\u002F","storing app data with platformdirs",[37,71,73],{"id":72},"three-questions-three-checks","Three questions, three checks",[10,75,76],{},"\"Is it healthy?\" hides three different questions, answered by different mechanisms and acted on by different things:",[78,79],"inline-diagram",{"name":80},"lr-health-kinds",[10,82,83,87,88,91,92,95,96,99],{},[84,85,86],"strong",{},"Liveness"," asks whether the loop is still making progress. If not, the right response is to restart the process — which is what Docker's ",[14,89,90],{},"HEALTHCHECK"," and Kubernetes liveness probes do. ",[84,93,94],{},"Readiness"," asks whether the process can do useful work right now — whether its dependencies are reachable — and is used to hold off traffic or raise an alert rather than restart. The ",[84,97,98],{},"dead-man switch"," asks whether a scheduled job ran at all, which no in-process check can answer, because a job that never starts never reports anything.",[37,101,103],{"id":102},"the-recipe-a-heartbeat","The recipe: a heartbeat",[10,105,106],{},"The simplest liveness signal is a small file that the loop rewrites after every unit of work. Its age is the answer to \"when did this last make progress?\", and anything that can read a file — a health command, a probe, a monitoring agent — can check it.",[78,108],{"name":109},"lr-heartbeat-flow",[111,112,117],"pre",{"className":113,"code":114,"language":115,"meta":116,"style":116},"language-python shiki shiki-themes github-light github-dark","# src\u002Fmytool\u002Fheartbeat.py\nfrom __future__ import annotations\n\nimport json\nimport os\nimport tempfile\nimport time\nfrom dataclasses import asdict, dataclass\nfrom pathlib import Path\n\n\n@dataclass(frozen=True)\nclass Beat:\n    at: float            # wall-clock seconds since the epoch\n    pid: int\n    processed: int       # total units of work so far\n    last_error: str | None = None\n\n\ndef write_beat(path: Path, processed: int, last_error: str | None = None) -> None:\n    \"\"\"Atomically replace the heartbeat file.\"\"\"\n    path.parent.mkdir(parents=True, exist_ok=True)\n    beat = Beat(at=time.time(), pid=os.getpid(), processed=processed, last_error=last_error)\n    fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=\".heartbeat.\")\n    with os.fdopen(fd, \"w\", encoding=\"utf-8\") as fh:\n        json.dump(asdict(beat), fh)\n    os.replace(tmp, path)\n\n\ndef read_beat(path: Path) -> Beat | None:\n    try:\n        return Beat(**json.loads(path.read_text(encoding=\"utf-8\")))\n    except (FileNotFoundError, json.JSONDecodeError, TypeError):\n        return None\n\n\ndef check(path: Path, max_age: float, now: float | None = None) -> tuple[bool, str]:\n    beat = read_beat(path)\n    if beat is None:\n        return False, \"no heartbeat yet\"\n    age = (now if now is not None else time.time()) - beat.at\n    detail = f\"last heartbeat {age:.0f}s ago (processed {beat.processed:,})\"\n    if beat.last_error:\n        detail += f\"; last error: {beat.last_error}\"\n    return age \u003C= max_age, detail\n","python","",[14,118,119,128,146,153,162,170,178,186,199,212,217,222,245,257,269,278,290,311,316,321,356,363,388,431,460,491,497,503,508,513,531,539,562,583,590,595,600,639,649,665,678,714,756,764,788],{"__ignoreMap":116},[120,121,124],"span",{"class":122,"line":123},"line",1,[120,125,127],{"class":126},"sJ8bj","# src\u002Fmytool\u002Fheartbeat.py\n",[120,129,131,135,139,142],{"class":122,"line":130},2,[120,132,134],{"class":133},"szBVR","from",[120,136,138],{"class":137},"sj4cs"," __future__",[120,140,141],{"class":133}," import",[120,143,145],{"class":144},"sVt8B"," annotations\n",[120,147,149],{"class":122,"line":148},3,[120,150,152],{"emptyLinePlaceholder":151},true,"\n",[120,154,156,159],{"class":122,"line":155},4,[120,157,158],{"class":133},"import",[120,160,161],{"class":144}," json\n",[120,163,165,167],{"class":122,"line":164},5,[120,166,158],{"class":133},[120,168,169],{"class":144}," os\n",[120,171,173,175],{"class":122,"line":172},6,[120,174,158],{"class":133},[120,176,177],{"class":144}," tempfile\n",[120,179,181,183],{"class":122,"line":180},7,[120,182,158],{"class":133},[120,184,185],{"class":144}," time\n",[120,187,189,191,194,196],{"class":122,"line":188},8,[120,190,134],{"class":133},[120,192,193],{"class":144}," dataclasses ",[120,195,158],{"class":133},[120,197,198],{"class":144}," asdict, dataclass\n",[120,200,202,204,207,209],{"class":122,"line":201},9,[120,203,134],{"class":133},[120,205,206],{"class":144}," pathlib ",[120,208,158],{"class":133},[120,210,211],{"class":144}," Path\n",[120,213,215],{"class":122,"line":214},10,[120,216,152],{"emptyLinePlaceholder":151},[120,218,220],{"class":122,"line":219},11,[120,221,152],{"emptyLinePlaceholder":151},[120,223,225,229,232,236,239,242],{"class":122,"line":224},12,[120,226,228],{"class":227},"sScJk","@dataclass",[120,230,231],{"class":144},"(",[120,233,235],{"class":234},"s4XuR","frozen",[120,237,238],{"class":133},"=",[120,240,241],{"class":137},"True",[120,243,244],{"class":144},")\n",[120,246,248,251,254],{"class":122,"line":247},13,[120,249,250],{"class":133},"class",[120,252,253],{"class":227}," Beat",[120,255,256],{"class":144},":\n",[120,258,260,263,266],{"class":122,"line":259},14,[120,261,262],{"class":144},"    at: ",[120,264,265],{"class":137},"float",[120,267,268],{"class":126},"            # wall-clock seconds since the epoch\n",[120,270,272,275],{"class":122,"line":271},15,[120,273,274],{"class":144},"    pid: ",[120,276,277],{"class":137},"int\n",[120,279,281,284,287],{"class":122,"line":280},16,[120,282,283],{"class":144},"    processed: ",[120,285,286],{"class":137},"int",[120,288,289],{"class":126},"       # total units of work so far\n",[120,291,293,296,299,302,305,308],{"class":122,"line":292},17,[120,294,295],{"class":144},"    last_error: ",[120,297,298],{"class":137},"str",[120,300,301],{"class":133}," |",[120,303,304],{"class":137}," None",[120,306,307],{"class":133}," =",[120,309,310],{"class":137}," None\n",[120,312,314],{"class":122,"line":313},18,[120,315,152],{"emptyLinePlaceholder":151},[120,317,319],{"class":122,"line":318},19,[120,320,152],{"emptyLinePlaceholder":151},[120,322,324,327,330,333,335,338,340,342,344,346,348,351,354],{"class":122,"line":323},20,[120,325,326],{"class":133},"def",[120,328,329],{"class":227}," write_beat",[120,331,332],{"class":144},"(path: Path, processed: ",[120,334,286],{"class":137},[120,336,337],{"class":144},", last_error: ",[120,339,298],{"class":137},[120,341,301],{"class":133},[120,343,304],{"class":137},[120,345,307],{"class":133},[120,347,304],{"class":137},[120,349,350],{"class":144},") -> ",[120,352,353],{"class":137},"None",[120,355,256],{"class":144},[120,357,359],{"class":122,"line":358},21,[120,360,362],{"class":361},"sZZnC","    \"\"\"Atomically replace the heartbeat file.\"\"\"\n",[120,364,366,369,372,374,376,379,382,384,386],{"class":122,"line":365},22,[120,367,368],{"class":144},"    path.parent.mkdir(",[120,370,371],{"class":234},"parents",[120,373,238],{"class":133},[120,375,241],{"class":137},[120,377,378],{"class":144},", ",[120,380,381],{"class":234},"exist_ok",[120,383,238],{"class":133},[120,385,241],{"class":137},[120,387,244],{"class":144},[120,389,391,394,396,399,402,404,407,410,412,415,418,420,423,426,428],{"class":122,"line":390},23,[120,392,393],{"class":144},"    beat ",[120,395,238],{"class":133},[120,397,398],{"class":144}," Beat(",[120,400,401],{"class":234},"at",[120,403,238],{"class":133},[120,405,406],{"class":144},"time.time(), ",[120,408,409],{"class":234},"pid",[120,411,238],{"class":133},[120,413,414],{"class":144},"os.getpid(), ",[120,416,417],{"class":234},"processed",[120,419,238],{"class":133},[120,421,422],{"class":144},"processed, ",[120,424,425],{"class":234},"last_error",[120,427,238],{"class":133},[120,429,430],{"class":144},"last_error)\n",[120,432,434,437,439,442,445,447,450,453,455,458],{"class":122,"line":433},24,[120,435,436],{"class":144},"    fd, tmp ",[120,438,238],{"class":133},[120,440,441],{"class":144}," tempfile.mkstemp(",[120,443,444],{"class":234},"dir",[120,446,238],{"class":133},[120,448,449],{"class":144},"path.parent, ",[120,451,452],{"class":234},"prefix",[120,454,238],{"class":133},[120,456,457],{"class":361},"\".heartbeat.\"",[120,459,244],{"class":144},[120,461,463,466,469,472,474,477,479,482,485,488],{"class":122,"line":462},25,[120,464,465],{"class":133},"    with",[120,467,468],{"class":144}," os.fdopen(fd, ",[120,470,471],{"class":361},"\"w\"",[120,473,378],{"class":144},[120,475,476],{"class":234},"encoding",[120,478,238],{"class":133},[120,480,481],{"class":361},"\"utf-8\"",[120,483,484],{"class":144},") ",[120,486,487],{"class":133},"as",[120,489,490],{"class":144}," fh:\n",[120,492,494],{"class":122,"line":493},26,[120,495,496],{"class":144},"        json.dump(asdict(beat), fh)\n",[120,498,500],{"class":122,"line":499},27,[120,501,502],{"class":144},"    os.replace(tmp, path)\n",[120,504,506],{"class":122,"line":505},28,[120,507,152],{"emptyLinePlaceholder":151},[120,509,511],{"class":122,"line":510},29,[120,512,152],{"emptyLinePlaceholder":151},[120,514,516,518,521,524,527,529],{"class":122,"line":515},30,[120,517,326],{"class":133},[120,519,520],{"class":227}," read_beat",[120,522,523],{"class":144},"(path: Path) -> Beat ",[120,525,526],{"class":133},"|",[120,528,304],{"class":137},[120,530,256],{"class":144},[120,532,534,537],{"class":122,"line":533},31,[120,535,536],{"class":133},"    try",[120,538,256],{"class":144},[120,540,542,545,547,550,553,555,557,559],{"class":122,"line":541},32,[120,543,544],{"class":133},"        return",[120,546,398],{"class":144},[120,548,549],{"class":133},"**",[120,551,552],{"class":144},"json.loads(path.read_text(",[120,554,476],{"class":234},[120,556,238],{"class":133},[120,558,481],{"class":361},[120,560,561],{"class":144},")))\n",[120,563,565,568,571,574,577,580],{"class":122,"line":564},33,[120,566,567],{"class":133},"    except",[120,569,570],{"class":144}," (",[120,572,573],{"class":137},"FileNotFoundError",[120,575,576],{"class":144},", json.JSONDecodeError, ",[120,578,579],{"class":137},"TypeError",[120,581,582],{"class":144},"):\n",[120,584,586,588],{"class":122,"line":585},34,[120,587,544],{"class":133},[120,589,310],{"class":137},[120,591,593],{"class":122,"line":592},35,[120,594,152],{"emptyLinePlaceholder":151},[120,596,598],{"class":122,"line":597},36,[120,599,152],{"emptyLinePlaceholder":151},[120,601,603,605,608,611,613,616,618,620,622,624,626,629,632,634,636],{"class":122,"line":602},37,[120,604,326],{"class":133},[120,606,607],{"class":227}," check",[120,609,610],{"class":144},"(path: Path, max_age: ",[120,612,265],{"class":137},[120,614,615],{"class":144},", now: ",[120,617,265],{"class":137},[120,619,301],{"class":133},[120,621,304],{"class":137},[120,623,307],{"class":133},[120,625,304],{"class":137},[120,627,628],{"class":144},") -> tuple[",[120,630,631],{"class":137},"bool",[120,633,378],{"class":144},[120,635,298],{"class":137},[120,637,638],{"class":144},"]:\n",[120,640,642,644,646],{"class":122,"line":641},38,[120,643,393],{"class":144},[120,645,238],{"class":133},[120,647,648],{"class":144}," read_beat(path)\n",[120,650,652,655,658,661,663],{"class":122,"line":651},39,[120,653,654],{"class":133},"    if",[120,656,657],{"class":144}," beat ",[120,659,660],{"class":133},"is",[120,662,304],{"class":137},[120,664,256],{"class":144},[120,666,668,670,673,675],{"class":122,"line":667},40,[120,669,544],{"class":133},[120,671,672],{"class":137}," False",[120,674,378],{"class":144},[120,676,677],{"class":361},"\"no heartbeat yet\"\n",[120,679,681,684,686,689,692,695,697,700,702,705,708,711],{"class":122,"line":680},41,[120,682,683],{"class":144},"    age ",[120,685,238],{"class":133},[120,687,688],{"class":144}," (now ",[120,690,691],{"class":133},"if",[120,693,694],{"class":144}," now ",[120,696,660],{"class":133},[120,698,699],{"class":133}," not",[120,701,304],{"class":137},[120,703,704],{"class":133}," else",[120,706,707],{"class":144}," time.time()) ",[120,709,710],{"class":133},"-",[120,712,713],{"class":144}," beat.at\n",[120,715,717,720,722,725,728,731,734,737,740,743,745,748,751,753],{"class":122,"line":716},42,[120,718,719],{"class":144},"    detail ",[120,721,238],{"class":133},[120,723,724],{"class":133}," f",[120,726,727],{"class":361},"\"last heartbeat ",[120,729,730],{"class":137},"{",[120,732,733],{"class":144},"age",[120,735,736],{"class":133},":.0f",[120,738,739],{"class":137},"}",[120,741,742],{"class":361},"s ago (processed ",[120,744,730],{"class":137},[120,746,747],{"class":144},"beat.processed",[120,749,750],{"class":133},":,",[120,752,739],{"class":137},[120,754,755],{"class":361},")\"\n",[120,757,759,761],{"class":122,"line":758},43,[120,760,654],{"class":133},[120,762,763],{"class":144}," beat.last_error:\n",[120,765,767,770,773,775,778,780,783,785],{"class":122,"line":766},44,[120,768,769],{"class":144},"        detail ",[120,771,772],{"class":133},"+=",[120,774,724],{"class":133},[120,776,777],{"class":361},"\"; last error: ",[120,779,730],{"class":137},[120,781,782],{"class":144},"beat.last_error",[120,784,739],{"class":137},[120,786,787],{"class":361},"\"\n",[120,789,791,794,797,800],{"class":122,"line":790},45,[120,792,793],{"class":133},"    return",[120,795,796],{"class":144}," age ",[120,798,799],{"class":133},"\u003C=",[120,801,802],{"class":144}," max_age, detail\n",[10,804,805,806,810],{},"The heartbeat is written with the same temporary-file-and-rename pattern as any state file, so a reader never sees a half-written JSON document — see ",[31,807,809],{"href":808},"\u002Fcli-runtime-systems-integration\u002Ffilesystem-paths-and-atomic-writes\u002Fwriting-files-atomically-in-python-clis\u002F","writing files atomically in Python CLIs",". Recording a running count of processed units and the last error turns the heartbeat into a useful status page as well as a liveness signal.",[812,813,815],"h3",{"id":814},"the-health-command","The health command",[111,817,819],{"className":113,"code":818,"language":115,"meta":116,"style":116},"# src\u002Fmytool\u002Fcli.py\nimport time\nfrom pathlib import Path\n\nimport httpx\nimport typer\n\nfrom mytool.heartbeat import check, write_beat\n\napp = typer.Typer()\nHEARTBEAT = Path.home() \u002F \".local\u002Fstate\u002Fmytool\u002Fheartbeat.json\"\n\n\n@app.callback()\ndef main() -> None:\n    \"\"\"Event worker.\"\"\"\n\n\n@app.command()\ndef work(interval: float = 5.0, once: bool = False) -> None:\n    \"\"\"Process events until stopped.\"\"\"\n    processed, last_error = 0, None\n    while True:\n        try:\n            processed += process_batch()\n            last_error = None\n        except Exception as exc:\n            last_error = f\"{type(exc).__name__}: {exc}\"\n        write_beat(HEARTBEAT, processed, last_error)     # after every unit, success or not\n        if once:\n            break\n        time.sleep(interval)\n\n\n@app.command()\ndef health(max_age: float = typer.Option(120, help=\"Seconds before the heartbeat is stale.\")) -> None:\n    \"\"\"Exit 0 if the worker made progress recently, 1 otherwise.\"\"\"\n    ok, detail = check(HEARTBEAT, max_age)\n    typer.echo(f\"{'ok' if ok else 'stale'}: {detail}\")\n    raise typer.Exit(0 if ok else 1)\n\n\n@app.command()\ndef nightly(ping_url: str = typer.Option(None, envvar=\"MYTOOL_PING_URL\")) -> None:\n    \"\"\"A scheduled job that reports success to a dead-man switch.\"\"\"\n    count = process_batch()\n    typer.echo(f\"processed {count} items\", err=True)\n    if ping_url:\n        try:\n            httpx.get(ping_url, timeout=10)\n        except httpx.HTTPError as exc:\n            typer.echo(f\"warning: could not report success: {exc}\", err=True)\n\n\ndef process_batch() -> int:\n    return 0   # stand-in for real work\n\n\nif __name__ == \"__main__\":\n    app()\n",[14,820,821,826,832,842,846,853,860,864,876,880,890,906,910,914,922,936,941,945,949,956,988,993,1008,1018,1025,1035,1044,1058,1090,1103,1111,1116,1121,1125,1129,1135,1172,1177,1192,1234,1256,1260,1264,1270,1304,1309,1319,1350,1358,1365,1381,1393,1422,1427,1432,1446,1456,1461,1466,1482],{"__ignoreMap":116},[120,822,823],{"class":122,"line":123},[120,824,825],{"class":126},"# src\u002Fmytool\u002Fcli.py\n",[120,827,828,830],{"class":122,"line":130},[120,829,158],{"class":133},[120,831,185],{"class":144},[120,833,834,836,838,840],{"class":122,"line":148},[120,835,134],{"class":133},[120,837,206],{"class":144},[120,839,158],{"class":133},[120,841,211],{"class":144},[120,843,844],{"class":122,"line":155},[120,845,152],{"emptyLinePlaceholder":151},[120,847,848,850],{"class":122,"line":164},[120,849,158],{"class":133},[120,851,852],{"class":144}," httpx\n",[120,854,855,857],{"class":122,"line":172},[120,856,158],{"class":133},[120,858,859],{"class":144}," typer\n",[120,861,862],{"class":122,"line":180},[120,863,152],{"emptyLinePlaceholder":151},[120,865,866,868,871,873],{"class":122,"line":188},[120,867,134],{"class":133},[120,869,870],{"class":144}," mytool.heartbeat ",[120,872,158],{"class":133},[120,874,875],{"class":144}," check, write_beat\n",[120,877,878],{"class":122,"line":201},[120,879,152],{"emptyLinePlaceholder":151},[120,881,882,885,887],{"class":122,"line":214},[120,883,884],{"class":144},"app ",[120,886,238],{"class":133},[120,888,889],{"class":144}," typer.Typer()\n",[120,891,892,895,897,900,903],{"class":122,"line":219},[120,893,894],{"class":137},"HEARTBEAT",[120,896,307],{"class":133},[120,898,899],{"class":144}," Path.home() ",[120,901,902],{"class":133},"\u002F",[120,904,905],{"class":361}," \".local\u002Fstate\u002Fmytool\u002Fheartbeat.json\"\n",[120,907,908],{"class":122,"line":224},[120,909,152],{"emptyLinePlaceholder":151},[120,911,912],{"class":122,"line":247},[120,913,152],{"emptyLinePlaceholder":151},[120,915,916,919],{"class":122,"line":259},[120,917,918],{"class":227},"@app.callback",[120,920,921],{"class":144},"()\n",[120,923,924,926,929,932,934],{"class":122,"line":271},[120,925,326],{"class":133},[120,927,928],{"class":227}," main",[120,930,931],{"class":144},"() -> ",[120,933,353],{"class":137},[120,935,256],{"class":144},[120,937,938],{"class":122,"line":280},[120,939,940],{"class":361},"    \"\"\"Event worker.\"\"\"\n",[120,942,943],{"class":122,"line":292},[120,944,152],{"emptyLinePlaceholder":151},[120,946,947],{"class":122,"line":313},[120,948,152],{"emptyLinePlaceholder":151},[120,950,951,954],{"class":122,"line":318},[120,952,953],{"class":227},"@app.command",[120,955,921],{"class":144},[120,957,958,960,963,966,968,970,973,976,978,980,982,984,986],{"class":122,"line":323},[120,959,326],{"class":133},[120,961,962],{"class":227}," work",[120,964,965],{"class":144},"(interval: ",[120,967,265],{"class":137},[120,969,307],{"class":133},[120,971,972],{"class":137}," 5.0",[120,974,975],{"class":144},", once: ",[120,977,631],{"class":137},[120,979,307],{"class":133},[120,981,672],{"class":137},[120,983,350],{"class":144},[120,985,353],{"class":137},[120,987,256],{"class":144},[120,989,990],{"class":122,"line":358},[120,991,992],{"class":361},"    \"\"\"Process events until stopped.\"\"\"\n",[120,994,995,998,1000,1003,1005],{"class":122,"line":365},[120,996,997],{"class":144},"    processed, last_error ",[120,999,238],{"class":133},[120,1001,1002],{"class":137}," 0",[120,1004,378],{"class":144},[120,1006,1007],{"class":137},"None\n",[120,1009,1010,1013,1016],{"class":122,"line":390},[120,1011,1012],{"class":133},"    while",[120,1014,1015],{"class":137}," True",[120,1017,256],{"class":144},[120,1019,1020,1023],{"class":122,"line":433},[120,1021,1022],{"class":133},"        try",[120,1024,256],{"class":144},[120,1026,1027,1030,1032],{"class":122,"line":462},[120,1028,1029],{"class":144},"            processed ",[120,1031,772],{"class":133},[120,1033,1034],{"class":144}," process_batch()\n",[120,1036,1037,1040,1042],{"class":122,"line":493},[120,1038,1039],{"class":144},"            last_error ",[120,1041,238],{"class":133},[120,1043,310],{"class":137},[120,1045,1046,1049,1052,1055],{"class":122,"line":499},[120,1047,1048],{"class":133},"        except",[120,1050,1051],{"class":137}," Exception",[120,1053,1054],{"class":133}," as",[120,1056,1057],{"class":144}," exc:\n",[120,1059,1060,1062,1064,1066,1069,1072,1075,1078,1081,1083,1086,1088],{"class":122,"line":505},[120,1061,1039],{"class":144},[120,1063,238],{"class":133},[120,1065,724],{"class":133},[120,1067,1068],{"class":361},"\"",[120,1070,1071],{"class":137},"{type",[120,1073,1074],{"class":144},"(exc).",[120,1076,1077],{"class":137},"__name__}",[120,1079,1080],{"class":361},": ",[120,1082,730],{"class":137},[120,1084,1085],{"class":144},"exc",[120,1087,739],{"class":137},[120,1089,787],{"class":361},[120,1091,1092,1095,1097,1100],{"class":122,"line":510},[120,1093,1094],{"class":144},"        write_beat(",[120,1096,894],{"class":137},[120,1098,1099],{"class":144},", processed, last_error)     ",[120,1101,1102],{"class":126},"# after every unit, success or not\n",[120,1104,1105,1108],{"class":122,"line":515},[120,1106,1107],{"class":133},"        if",[120,1109,1110],{"class":144}," once:\n",[120,1112,1113],{"class":122,"line":533},[120,1114,1115],{"class":133},"            break\n",[120,1117,1118],{"class":122,"line":541},[120,1119,1120],{"class":144},"        time.sleep(interval)\n",[120,1122,1123],{"class":122,"line":564},[120,1124,152],{"emptyLinePlaceholder":151},[120,1126,1127],{"class":122,"line":585},[120,1128,152],{"emptyLinePlaceholder":151},[120,1130,1131,1133],{"class":122,"line":592},[120,1132,953],{"class":227},[120,1134,921],{"class":144},[120,1136,1137,1139,1142,1145,1147,1149,1152,1155,1157,1160,1162,1165,1168,1170],{"class":122,"line":597},[120,1138,326],{"class":133},[120,1140,1141],{"class":227}," health",[120,1143,1144],{"class":144},"(max_age: ",[120,1146,265],{"class":137},[120,1148,307],{"class":133},[120,1150,1151],{"class":144}," typer.Option(",[120,1153,1154],{"class":137},"120",[120,1156,378],{"class":144},[120,1158,1159],{"class":234},"help",[120,1161,238],{"class":133},[120,1163,1164],{"class":361},"\"Seconds before the heartbeat is stale.\"",[120,1166,1167],{"class":144},")) -> ",[120,1169,353],{"class":137},[120,1171,256],{"class":144},[120,1173,1174],{"class":122,"line":602},[120,1175,1176],{"class":361},"    \"\"\"Exit 0 if the worker made progress recently, 1 otherwise.\"\"\"\n",[120,1178,1179,1182,1184,1187,1189],{"class":122,"line":641},[120,1180,1181],{"class":144},"    ok, detail ",[120,1183,238],{"class":133},[120,1185,1186],{"class":144}," check(",[120,1188,894],{"class":137},[120,1190,1191],{"class":144},", max_age)\n",[120,1193,1194,1197,1200,1202,1204,1207,1210,1213,1216,1219,1221,1223,1225,1228,1230,1232],{"class":122,"line":651},[120,1195,1196],{"class":144},"    typer.echo(",[120,1198,1199],{"class":133},"f",[120,1201,1068],{"class":361},[120,1203,730],{"class":137},[120,1205,1206],{"class":361},"'ok'",[120,1208,1209],{"class":133}," if",[120,1211,1212],{"class":144}," ok ",[120,1214,1215],{"class":133},"else",[120,1217,1218],{"class":361}," 'stale'",[120,1220,739],{"class":137},[120,1222,1080],{"class":361},[120,1224,730],{"class":137},[120,1226,1227],{"class":144},"detail",[120,1229,739],{"class":137},[120,1231,1068],{"class":361},[120,1233,244],{"class":144},[120,1235,1236,1239,1242,1245,1247,1249,1251,1254],{"class":122,"line":667},[120,1237,1238],{"class":133},"    raise",[120,1240,1241],{"class":144}," typer.Exit(",[120,1243,1244],{"class":137},"0",[120,1246,1209],{"class":133},[120,1248,1212],{"class":144},[120,1250,1215],{"class":133},[120,1252,1253],{"class":137}," 1",[120,1255,244],{"class":144},[120,1257,1258],{"class":122,"line":680},[120,1259,152],{"emptyLinePlaceholder":151},[120,1261,1262],{"class":122,"line":716},[120,1263,152],{"emptyLinePlaceholder":151},[120,1265,1266,1268],{"class":122,"line":758},[120,1267,953],{"class":227},[120,1269,921],{"class":144},[120,1271,1272,1274,1277,1280,1282,1284,1286,1288,1290,1293,1295,1298,1300,1302],{"class":122,"line":766},[120,1273,326],{"class":133},[120,1275,1276],{"class":227}," nightly",[120,1278,1279],{"class":144},"(ping_url: ",[120,1281,298],{"class":137},[120,1283,307],{"class":133},[120,1285,1151],{"class":144},[120,1287,353],{"class":137},[120,1289,378],{"class":144},[120,1291,1292],{"class":234},"envvar",[120,1294,238],{"class":133},[120,1296,1297],{"class":361},"\"MYTOOL_PING_URL\"",[120,1299,1167],{"class":144},[120,1301,353],{"class":137},[120,1303,256],{"class":144},[120,1305,1306],{"class":122,"line":790},[120,1307,1308],{"class":361},"    \"\"\"A scheduled job that reports success to a dead-man switch.\"\"\"\n",[120,1310,1312,1315,1317],{"class":122,"line":1311},46,[120,1313,1314],{"class":144},"    count ",[120,1316,238],{"class":133},[120,1318,1034],{"class":144},[120,1320,1322,1324,1326,1329,1331,1334,1336,1339,1341,1344,1346,1348],{"class":122,"line":1321},47,[120,1323,1196],{"class":144},[120,1325,1199],{"class":133},[120,1327,1328],{"class":361},"\"processed ",[120,1330,730],{"class":137},[120,1332,1333],{"class":144},"count",[120,1335,739],{"class":137},[120,1337,1338],{"class":361}," items\"",[120,1340,378],{"class":144},[120,1342,1343],{"class":234},"err",[120,1345,238],{"class":133},[120,1347,241],{"class":137},[120,1349,244],{"class":144},[120,1351,1353,1355],{"class":122,"line":1352},48,[120,1354,654],{"class":133},[120,1356,1357],{"class":144}," ping_url:\n",[120,1359,1361,1363],{"class":122,"line":1360},49,[120,1362,1022],{"class":133},[120,1364,256],{"class":144},[120,1366,1368,1371,1374,1376,1379],{"class":122,"line":1367},50,[120,1369,1370],{"class":144},"            httpx.get(ping_url, ",[120,1372,1373],{"class":234},"timeout",[120,1375,238],{"class":133},[120,1377,1378],{"class":137},"10",[120,1380,244],{"class":144},[120,1382,1384,1386,1389,1391],{"class":122,"line":1383},51,[120,1385,1048],{"class":133},[120,1387,1388],{"class":144}," httpx.HTTPError ",[120,1390,487],{"class":133},[120,1392,1057],{"class":144},[120,1394,1396,1399,1401,1404,1406,1408,1410,1412,1414,1416,1418,1420],{"class":122,"line":1395},52,[120,1397,1398],{"class":144},"            typer.echo(",[120,1400,1199],{"class":133},[120,1402,1403],{"class":361},"\"warning: could not report success: ",[120,1405,730],{"class":137},[120,1407,1085],{"class":144},[120,1409,739],{"class":137},[120,1411,1068],{"class":361},[120,1413,378],{"class":144},[120,1415,1343],{"class":234},[120,1417,238],{"class":133},[120,1419,241],{"class":137},[120,1421,244],{"class":144},[120,1423,1425],{"class":122,"line":1424},53,[120,1426,152],{"emptyLinePlaceholder":151},[120,1428,1430],{"class":122,"line":1429},54,[120,1431,152],{"emptyLinePlaceholder":151},[120,1433,1435,1437,1440,1442,1444],{"class":122,"line":1434},55,[120,1436,326],{"class":133},[120,1438,1439],{"class":227}," process_batch",[120,1441,931],{"class":144},[120,1443,286],{"class":137},[120,1445,256],{"class":144},[120,1447,1449,1451,1453],{"class":122,"line":1448},56,[120,1450,793],{"class":133},[120,1452,1002],{"class":137},[120,1454,1455],{"class":126},"   # stand-in for real work\n",[120,1457,1459],{"class":122,"line":1458},57,[120,1460,152],{"emptyLinePlaceholder":151},[120,1462,1464],{"class":122,"line":1463},58,[120,1465,152],{"emptyLinePlaceholder":151},[120,1467,1469,1471,1474,1477,1480],{"class":122,"line":1468},59,[120,1470,691],{"class":133},[120,1472,1473],{"class":137}," __name__",[120,1475,1476],{"class":133}," ==",[120,1478,1479],{"class":361}," \"__main__\"",[120,1481,256],{"class":144},[120,1483,1485],{"class":122,"line":1484},60,[120,1486,1487],{"class":144},"    app()\n",[10,1489,1490,1491,1494,1495,1497,1498,1501],{},"Writing the heartbeat after ",[84,1492,1493],{},"every"," unit, including failed ones, is deliberate: a loop that keeps failing but keeps trying is alive (and the ",[14,1496,425],{}," field says why it is unhappy), whereas a loop that stops writing heartbeats is stuck. Choose ",[14,1499,1500],{},"max_age"," as a few multiples of the longest normal interval between beats, so one slow batch does not trigger a restart.",[37,1503,1505],{"id":1504},"wiring-it-to-probes","Wiring it to probes",[10,1507,1508,1509,1511],{},"The ",[14,1510,28],{}," command's exit code is its whole API, which makes it usable by anything that runs commands:",[111,1513,1517],{"className":1514,"code":1515,"language":1516,"meta":116,"style":116},"language-dockerfile shiki shiki-themes github-light github-dark","# Dockerfile\nHEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \\\n  CMD [\"mytool\", \"health\", \"--max-age\", \"120\"]\n","dockerfile",[14,1518,1519,1524,1529],{"__ignoreMap":116},[120,1520,1521],{"class":122,"line":123},[120,1522,1523],{},"# Dockerfile\n",[120,1525,1526],{"class":122,"line":130},[120,1527,1528],{},"HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \\\n",[120,1530,1531],{"class":122,"line":148},[120,1532,1533],{},"  CMD [\"mytool\", \"health\", \"--max-age\", \"120\"]\n",[111,1535,1539],{"className":1536,"code":1537,"language":1538,"meta":116,"style":116},"language-yaml shiki shiki-themes github-light github-dark","# Kubernetes container spec\nlivenessProbe:\n  exec:\n    command: [\"mytool\", \"health\", \"--max-age\", \"120\"]\n  initialDelaySeconds: 60\n  periodSeconds: 30\n  failureThreshold: 3\n","yaml",[14,1540,1541,1546,1554,1561,1590,1600,1610],{"__ignoreMap":116},[120,1542,1543],{"class":122,"line":123},[120,1544,1545],{"class":126},"# Kubernetes container spec\n",[120,1547,1548,1552],{"class":122,"line":130},[120,1549,1551],{"class":1550},"s9eBZ","livenessProbe",[120,1553,256],{"class":144},[120,1555,1556,1559],{"class":122,"line":148},[120,1557,1558],{"class":1550},"  exec",[120,1560,256],{"class":144},[120,1562,1563,1566,1569,1572,1574,1577,1579,1582,1584,1587],{"class":122,"line":155},[120,1564,1565],{"class":1550},"    command",[120,1567,1568],{"class":144},": [",[120,1570,1571],{"class":361},"\"mytool\"",[120,1573,378],{"class":144},[120,1575,1576],{"class":361},"\"health\"",[120,1578,378],{"class":144},[120,1580,1581],{"class":361},"\"--max-age\"",[120,1583,378],{"class":144},[120,1585,1586],{"class":361},"\"120\"",[120,1588,1589],{"class":144},"]\n",[120,1591,1592,1595,1597],{"class":122,"line":164},[120,1593,1594],{"class":1550},"  initialDelaySeconds",[120,1596,1080],{"class":144},[120,1598,1599],{"class":137},"60\n",[120,1601,1602,1605,1607],{"class":122,"line":172},[120,1603,1604],{"class":1550},"  periodSeconds",[120,1606,1080],{"class":144},[120,1608,1609],{"class":137},"30\n",[120,1611,1612,1615,1617],{"class":122,"line":180},[120,1613,1614],{"class":1550},"  failureThreshold",[120,1616,1080],{"class":144},[120,1618,1619],{"class":137},"3\n",[10,1621,1622,1623,1626,1627,1631,1632,1635],{},"Keep the health command ",[84,1624,1625],{},"fast and dependency-free",": it runs every thirty seconds, often in a resource-limited container, and a health check that imports your whole application or calls the network adds load and new ways to fail. Reading one small file is ideal. If startup time matters here, the techniques in ",[31,1628,1630],{"href":1629},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Flazy-loading-subcommands-for-faster-startup\u002F","lazy-loading subcommands for faster startup"," keep ",[14,1633,1634],{},"mytool health"," from importing the heavy parts of the tool.",[37,1637,1639],{"id":1638},"the-dead-man-switch","The dead-man switch",[10,1641,1642,1643,1646,1647,1650],{},"A heartbeat catches a job that is running but stuck. It cannot catch a job that is not running — a cron entry deleted during a migration, a timer never re-enabled, a machine that was decommissioned. For scheduled jobs, the reliable pattern inverts the check: at the end of each ",[84,1644,1645],{},"successful"," run, the job pings an external service, and that service alerts when an expected ping does ",[84,1648,1649],{},"not"," arrive. Hosted services such as Healthchecks.io and Cronitor work this way, as do Prometheus Pushgateway plus an alert on a stale timestamp, and most uptime monitors' \"heartbeat\" check types.",[10,1652,1508,1653,1656],{},[14,1654,1655],{},"nightly"," command above does it in three lines: an optional ping URL from the environment, a short timeout, and a warning rather than a failure if the ping itself cannot be sent — the job succeeded, and failing it because the monitor was unreachable would be wrong. Only ping on success; a failed run that pings anyway defeats the purpose.",[37,1658,1660],{"id":1659},"ux-considerations","UX considerations",[78,1662],{"name":1663},"lr-health-terminal",[42,1665,1666,1676,1682,1695,1704],{},[45,1667,1668,1671,1672,1675],{},[84,1669,1670],{},"Human-readable and machine-readable at once."," One line of text for the person reading probe logs, and the exit code for the probe. Add ",[14,1673,1674],{},"--json"," if other tooling wants the details.",[45,1677,1678,1681],{},[84,1679,1680],{},"Include context in the output."," \"stale: last heartbeat 9m ago; last error: ConnectTimeout\" often diagnoses the problem without opening another log.",[45,1683,1684,1690,1691,1694],{},[84,1685,1686,1687,35],{},"Report the heartbeat in ",[14,1688,1689],{},"status"," When a person runs ",[14,1692,1693],{},"mytool status",", show the last heartbeat and last scheduled run. Humans notice stale timestamps faster than monitors get configured.",[45,1696,1697,1700,1701,1703],{},[84,1698,1699],{},"Do not restart on readiness."," If the API the worker depends on is down, restarting the worker changes nothing. Record dependency failures in ",[14,1702,425],{}," and alert on them; reserve restarts for genuine lack of progress.",[45,1705,1706,1709,1710,1712],{},[84,1707,1708],{},"Start-up grace."," Configure probes with an initial delay, or have ",[14,1711,28],{}," treat \"no heartbeat yet within N seconds of process start\" as healthy, so slow start-ups are not killed in a loop.",[37,1714,1716],{"id":1715},"testing-the-behaviour","Testing the behaviour",[10,1718,1719,1720,1723],{},"The health logic is pure — a file and a clock — so it tests cleanly with an injected ",[14,1721,1722],{},"now",":",[111,1725,1727],{"className":113,"code":1726,"language":115,"meta":116,"style":116},"# tests\u002Ftest_health.py\nimport time\n\nfrom typer.testing import CliRunner\n\nfrom mytool import cli\nfrom mytool.heartbeat import check, read_beat, write_beat\n\nrunner = CliRunner()\n\n\ndef test_fresh_heartbeat_is_healthy(tmp_path):\n    hb = tmp_path \u002F \"heartbeat.json\"\n    write_beat(hb, processed=10)\n    ok, detail = check(hb, max_age=60)\n    assert ok and \"processed 10\" in detail\n\n\ndef test_stale_heartbeat(tmp_path):\n    hb = tmp_path \u002F \"heartbeat.json\"\n    write_beat(hb, processed=10)\n    ok, detail = check(hb, max_age=60, now=time.time() + 600)\n    assert not ok\n\n\ndef test_missing_or_corrupt(tmp_path):\n    hb = tmp_path \u002F \"heartbeat.json\"\n    assert check(hb, 60) == (False, \"no heartbeat yet\")\n    hb.write_text(\"{not json\")\n    assert read_beat(hb) is None\n\n\ndef test_errors_are_reported_but_still_alive(tmp_path):\n    hb = tmp_path \u002F \"heartbeat.json\"\n    write_beat(hb, processed=3, last_error=\"ConnectTimeout: api\")\n    ok, detail = check(hb, max_age=60)\n    assert ok and \"ConnectTimeout\" in detail\n\n\ndef test_health_command_exit_codes(tmp_path, monkeypatch):\n    monkeypatch.setattr(cli, \"HEARTBEAT\", tmp_path \u002F \"hb.json\")\n    assert runner.invoke(cli.app, [\"health\"]).exit_code == 1\n    runner.invoke(cli.app, [\"work\", \"--once\"])\n    result = runner.invoke(cli.app, [\"health\", \"--max-age\", \"60\"])\n    assert result.exit_code == 0 and result.output.startswith(\"ok\")\n",[14,1728,1729,1734,1740,1744,1756,1760,1772,1783,1787,1797,1801,1805,1815,1830,1843,1861,1880,1884,1888,1897,1909,1921,1952,1961,1965,1969,1978,1990,2015,2025,2036,2040,2044,2053,2065,2087,2103,2118,2122,2126,2136,2154,2171,2187,2209],{"__ignoreMap":116},[120,1730,1731],{"class":122,"line":123},[120,1732,1733],{"class":126},"# tests\u002Ftest_health.py\n",[120,1735,1736,1738],{"class":122,"line":130},[120,1737,158],{"class":133},[120,1739,185],{"class":144},[120,1741,1742],{"class":122,"line":148},[120,1743,152],{"emptyLinePlaceholder":151},[120,1745,1746,1748,1751,1753],{"class":122,"line":155},[120,1747,134],{"class":133},[120,1749,1750],{"class":144}," typer.testing ",[120,1752,158],{"class":133},[120,1754,1755],{"class":144}," CliRunner\n",[120,1757,1758],{"class":122,"line":164},[120,1759,152],{"emptyLinePlaceholder":151},[120,1761,1762,1764,1767,1769],{"class":122,"line":172},[120,1763,134],{"class":133},[120,1765,1766],{"class":144}," mytool ",[120,1768,158],{"class":133},[120,1770,1771],{"class":144}," cli\n",[120,1773,1774,1776,1778,1780],{"class":122,"line":180},[120,1775,134],{"class":133},[120,1777,870],{"class":144},[120,1779,158],{"class":133},[120,1781,1782],{"class":144}," check, read_beat, write_beat\n",[120,1784,1785],{"class":122,"line":188},[120,1786,152],{"emptyLinePlaceholder":151},[120,1788,1789,1792,1794],{"class":122,"line":201},[120,1790,1791],{"class":144},"runner ",[120,1793,238],{"class":133},[120,1795,1796],{"class":144}," CliRunner()\n",[120,1798,1799],{"class":122,"line":214},[120,1800,152],{"emptyLinePlaceholder":151},[120,1802,1803],{"class":122,"line":219},[120,1804,152],{"emptyLinePlaceholder":151},[120,1806,1807,1809,1812],{"class":122,"line":224},[120,1808,326],{"class":133},[120,1810,1811],{"class":227}," test_fresh_heartbeat_is_healthy",[120,1813,1814],{"class":144},"(tmp_path):\n",[120,1816,1817,1820,1822,1825,1827],{"class":122,"line":247},[120,1818,1819],{"class":144},"    hb ",[120,1821,238],{"class":133},[120,1823,1824],{"class":144}," tmp_path ",[120,1826,902],{"class":133},[120,1828,1829],{"class":361}," \"heartbeat.json\"\n",[120,1831,1832,1835,1837,1839,1841],{"class":122,"line":259},[120,1833,1834],{"class":144},"    write_beat(hb, ",[120,1836,417],{"class":234},[120,1838,238],{"class":133},[120,1840,1378],{"class":137},[120,1842,244],{"class":144},[120,1844,1845,1847,1849,1852,1854,1856,1859],{"class":122,"line":271},[120,1846,1181],{"class":144},[120,1848,238],{"class":133},[120,1850,1851],{"class":144}," check(hb, ",[120,1853,1500],{"class":234},[120,1855,238],{"class":133},[120,1857,1858],{"class":137},"60",[120,1860,244],{"class":144},[120,1862,1863,1866,1868,1871,1874,1877],{"class":122,"line":280},[120,1864,1865],{"class":133},"    assert",[120,1867,1212],{"class":144},[120,1869,1870],{"class":133},"and",[120,1872,1873],{"class":361}," \"processed 10\"",[120,1875,1876],{"class":133}," in",[120,1878,1879],{"class":144}," detail\n",[120,1881,1882],{"class":122,"line":292},[120,1883,152],{"emptyLinePlaceholder":151},[120,1885,1886],{"class":122,"line":313},[120,1887,152],{"emptyLinePlaceholder":151},[120,1889,1890,1892,1895],{"class":122,"line":318},[120,1891,326],{"class":133},[120,1893,1894],{"class":227}," test_stale_heartbeat",[120,1896,1814],{"class":144},[120,1898,1899,1901,1903,1905,1907],{"class":122,"line":323},[120,1900,1819],{"class":144},[120,1902,238],{"class":133},[120,1904,1824],{"class":144},[120,1906,902],{"class":133},[120,1908,1829],{"class":361},[120,1910,1911,1913,1915,1917,1919],{"class":122,"line":358},[120,1912,1834],{"class":144},[120,1914,417],{"class":234},[120,1916,238],{"class":133},[120,1918,1378],{"class":137},[120,1920,244],{"class":144},[120,1922,1923,1925,1927,1929,1931,1933,1935,1937,1939,1941,1944,1947,1950],{"class":122,"line":365},[120,1924,1181],{"class":144},[120,1926,238],{"class":133},[120,1928,1851],{"class":144},[120,1930,1500],{"class":234},[120,1932,238],{"class":133},[120,1934,1858],{"class":137},[120,1936,378],{"class":144},[120,1938,1722],{"class":234},[120,1940,238],{"class":133},[120,1942,1943],{"class":144},"time.time() ",[120,1945,1946],{"class":133},"+",[120,1948,1949],{"class":137}," 600",[120,1951,244],{"class":144},[120,1953,1954,1956,1958],{"class":122,"line":390},[120,1955,1865],{"class":133},[120,1957,699],{"class":133},[120,1959,1960],{"class":144}," ok\n",[120,1962,1963],{"class":122,"line":433},[120,1964,152],{"emptyLinePlaceholder":151},[120,1966,1967],{"class":122,"line":462},[120,1968,152],{"emptyLinePlaceholder":151},[120,1970,1971,1973,1976],{"class":122,"line":493},[120,1972,326],{"class":133},[120,1974,1975],{"class":227}," test_missing_or_corrupt",[120,1977,1814],{"class":144},[120,1979,1980,1982,1984,1986,1988],{"class":122,"line":499},[120,1981,1819],{"class":144},[120,1983,238],{"class":133},[120,1985,1824],{"class":144},[120,1987,902],{"class":133},[120,1989,1829],{"class":361},[120,1991,1992,1994,1996,1998,2000,2003,2005,2008,2010,2013],{"class":122,"line":505},[120,1993,1865],{"class":133},[120,1995,1851],{"class":144},[120,1997,1858],{"class":137},[120,1999,484],{"class":144},[120,2001,2002],{"class":133},"==",[120,2004,570],{"class":144},[120,2006,2007],{"class":137},"False",[120,2009,378],{"class":144},[120,2011,2012],{"class":361},"\"no heartbeat yet\"",[120,2014,244],{"class":144},[120,2016,2017,2020,2023],{"class":122,"line":510},[120,2018,2019],{"class":144},"    hb.write_text(",[120,2021,2022],{"class":361},"\"{not json\"",[120,2024,244],{"class":144},[120,2026,2027,2029,2032,2034],{"class":122,"line":515},[120,2028,1865],{"class":133},[120,2030,2031],{"class":144}," read_beat(hb) ",[120,2033,660],{"class":133},[120,2035,310],{"class":137},[120,2037,2038],{"class":122,"line":533},[120,2039,152],{"emptyLinePlaceholder":151},[120,2041,2042],{"class":122,"line":541},[120,2043,152],{"emptyLinePlaceholder":151},[120,2045,2046,2048,2051],{"class":122,"line":564},[120,2047,326],{"class":133},[120,2049,2050],{"class":227}," test_errors_are_reported_but_still_alive",[120,2052,1814],{"class":144},[120,2054,2055,2057,2059,2061,2063],{"class":122,"line":585},[120,2056,1819],{"class":144},[120,2058,238],{"class":133},[120,2060,1824],{"class":144},[120,2062,902],{"class":133},[120,2064,1829],{"class":361},[120,2066,2067,2069,2071,2073,2076,2078,2080,2082,2085],{"class":122,"line":592},[120,2068,1834],{"class":144},[120,2070,417],{"class":234},[120,2072,238],{"class":133},[120,2074,2075],{"class":137},"3",[120,2077,378],{"class":144},[120,2079,425],{"class":234},[120,2081,238],{"class":133},[120,2083,2084],{"class":361},"\"ConnectTimeout: api\"",[120,2086,244],{"class":144},[120,2088,2089,2091,2093,2095,2097,2099,2101],{"class":122,"line":597},[120,2090,1181],{"class":144},[120,2092,238],{"class":133},[120,2094,1851],{"class":144},[120,2096,1500],{"class":234},[120,2098,238],{"class":133},[120,2100,1858],{"class":137},[120,2102,244],{"class":144},[120,2104,2105,2107,2109,2111,2114,2116],{"class":122,"line":602},[120,2106,1865],{"class":133},[120,2108,1212],{"class":144},[120,2110,1870],{"class":133},[120,2112,2113],{"class":361}," \"ConnectTimeout\"",[120,2115,1876],{"class":133},[120,2117,1879],{"class":144},[120,2119,2120],{"class":122,"line":641},[120,2121,152],{"emptyLinePlaceholder":151},[120,2123,2124],{"class":122,"line":651},[120,2125,152],{"emptyLinePlaceholder":151},[120,2127,2128,2130,2133],{"class":122,"line":667},[120,2129,326],{"class":133},[120,2131,2132],{"class":227}," test_health_command_exit_codes",[120,2134,2135],{"class":144},"(tmp_path, monkeypatch):\n",[120,2137,2138,2141,2144,2147,2149,2152],{"class":122,"line":680},[120,2139,2140],{"class":144},"    monkeypatch.setattr(cli, ",[120,2142,2143],{"class":361},"\"HEARTBEAT\"",[120,2145,2146],{"class":144},", tmp_path ",[120,2148,902],{"class":133},[120,2150,2151],{"class":361}," \"hb.json\"",[120,2153,244],{"class":144},[120,2155,2156,2158,2161,2163,2166,2168],{"class":122,"line":716},[120,2157,1865],{"class":133},[120,2159,2160],{"class":144}," runner.invoke(cli.app, [",[120,2162,1576],{"class":361},[120,2164,2165],{"class":144},"]).exit_code ",[120,2167,2002],{"class":133},[120,2169,2170],{"class":137}," 1\n",[120,2172,2173,2176,2179,2181,2184],{"class":122,"line":758},[120,2174,2175],{"class":144},"    runner.invoke(cli.app, [",[120,2177,2178],{"class":361},"\"work\"",[120,2180,378],{"class":144},[120,2182,2183],{"class":361},"\"--once\"",[120,2185,2186],{"class":144},"])\n",[120,2188,2189,2192,2194,2196,2198,2200,2202,2204,2207],{"class":122,"line":766},[120,2190,2191],{"class":144},"    result ",[120,2193,238],{"class":133},[120,2195,2160],{"class":144},[120,2197,1576],{"class":361},[120,2199,378],{"class":144},[120,2201,1581],{"class":361},[120,2203,378],{"class":144},[120,2205,2206],{"class":361},"\"60\"",[120,2208,2186],{"class":144},[120,2210,2211,2213,2216,2218,2220,2223,2226,2229],{"class":122,"line":790},[120,2212,1865],{"class":133},[120,2214,2215],{"class":144}," result.exit_code ",[120,2217,2002],{"class":133},[120,2219,1002],{"class":137},[120,2221,2222],{"class":133}," and",[120,2224,2225],{"class":144}," result.output.startswith(",[120,2227,2228],{"class":361},"\"ok\"",[120,2230,244],{"class":144},[10,2232,2233,2234,2237,2238,2241],{},"For the dead-man switch, replace the ping with an ",[14,2235,2236],{},"httpx.MockTransport"," or patch ",[14,2239,2240],{},"httpx.get"," and assert it is called exactly once on success and never on failure.",[37,2243,2245],{"id":2244},"conclusion","Conclusion",[10,2247,2248,2249,2251],{},"A long-running command should be able to answer \"are you making progress?\" cheaply and truthfully. Write an atomic heartbeat after every unit of work, expose it through a fast ",[14,2250,28],{}," command whose exit code probes can use, keep readiness problems in the heartbeat's error field rather than triggering restarts, and give scheduled jobs a dead-man switch so that silence itself raises the alarm. The cost is a few dozen lines; the payoff is never again discovering that a \"running\" worker stopped working days ago.",[37,2253,2255],{"id":2254},"frequently-asked-questions","Frequently asked questions",[812,2257,2259],{"id":2258},"why-a-file-instead-of-an-http-health-endpoint","Why a file instead of an HTTP health endpoint?",[10,2261,2262],{},"An HTTP endpoint needs a server thread and a port, which a CLI worker usually does not otherwise have. A file works with Docker and Kubernetes exec probes, systemd, cron-based checks and humans alike. If your worker already serves HTTP, an endpoint reporting the same heartbeat data is equally good.",[812,2264,2266],{"id":2265},"can-systemd-restart-a-stuck-process-on-its-own","Can systemd restart a stuck process on its own?",[10,2268,2269,2270,2273,2274,2277,2278,2281,2282,2285],{},"Yes, with its watchdog: set ",[14,2271,2272],{},"WatchdogSec="," in the unit and have the loop send ",[14,2275,2276],{},"WATCHDOG=1"," via ",[14,2279,2280],{},"sd_notify"," after each unit (the ",[14,2283,2284],{},"sdnotify"," package or a few lines over the notify socket). systemd restarts the service if the pings stop — the same idea as a heartbeat file, built into the supervisor.",[812,2287,2289],{"id":2288},"how-stale-is-too-stale","How stale is too stale?",[10,2291,2292],{},"Take the longest normal gap between heartbeats — the slowest batch plus the loop interval — and multiply by three or four. Too tight, and busy periods trigger restarts; too loose, and a stuck worker goes unnoticed for too long.",[812,2294,2296],{"id":2295},"should-the-heartbeat-include-metrics","Should the heartbeat include metrics?",[10,2298,2299,2300,2304],{},"A counter and the last error are enough for liveness. For real metrics — throughput, latency, queue depth — emit them to your metrics system or structured logs, as in ",[31,2301,2303],{"href":2302},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fstructured-json-logging-in-python-clis\u002F","structured JSON logging in Python CLIs",", and keep the heartbeat small.",[37,2306,2308],{"id":2307},"related","Related",[42,2310,2311,2317,2323,2328,2334],{},[45,2312,2313,2314],{},"Up: ",[31,2315,2316],{"href":33},"Long-running and watch-mode CLIs",[45,2318,2319],{},[31,2320,2322],{"href":2321},"\u002Fcli-runtime-systems-integration\u002Flong-running-and-watch-mode-clis\u002Fhandling-sigterm-and-graceful-shutdown\u002F","Handling SIGTERM and graceful shutdown",[45,2324,2325],{},[31,2326,2327],{"href":61},"Running a CLI on a schedule with cron and systemd",[45,2329,2330],{},[31,2331,2333],{"href":2332},"\u002Fcli-runtime-systems-integration\u002Flong-running-and-watch-mode-clis\u002Fbuilding-a-watch-mode-with-watchfiles\u002F","Building a watch mode with watchfiles",[45,2335,2336],{},[31,2337,2339],{"href":2338},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Fchoosing-exit-codes-for-cli-tools\u002F","Choosing exit codes for CLI tools",[2341,2342,2343],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":116,"searchDepth":130,"depth":130,"links":2345},[2346,2347,2348,2351,2352,2353,2354,2355,2356,2362],{"id":39,"depth":130,"text":40},{"id":72,"depth":130,"text":73},{"id":102,"depth":130,"text":103,"children":2349},[2350],{"id":814,"depth":148,"text":815},{"id":1504,"depth":130,"text":1505},{"id":1638,"depth":130,"text":1639},{"id":1659,"depth":130,"text":1660},{"id":1715,"depth":130,"text":1716},{"id":2244,"depth":130,"text":2245},{"id":2254,"depth":130,"text":2255,"children":2357},[2358,2359,2360,2361],{"id":2258,"depth":148,"text":2259},{"id":2265,"depth":148,"text":2266},{"id":2288,"depth":148,"text":2289},{"id":2295,"depth":148,"text":2296},{"id":2307,"depth":130,"text":2308},"2026-09-18","Tell ‘running’ from ‘working’ in long-lived Python CLIs: heartbeat files, a health command with exit codes, container probes and dead-man switches for cron jobs.","intermediate",false,"md",{},"\u002Fcli-runtime-systems-integration\u002Flong-running-and-watch-mode-clis\u002Fhealth-checks-and-heartbeats-for-long-running-clis",{"title":5,"description":2364},"cli-runtime-systems-integration\u002Flong-running-and-watch-mode-clis\u002Fhealth-checks-and-heartbeats-for-long-running-clis\u002Findex",[2373,2374,2375,2376],"health-checks","monitoring","reliability","containers","b2ExgwhUcq8DtOUjCCY-Iu9cshjOLIUer-Me-HEaz4U",[2379,2382,2385,2388,2391,2394,2397,2400,2403,2406,2409,2412,2415,2418,2421,2424,2427,2430,2433,2436,2439,2442,2445,2448,2451,2454,2457,2460,2463,2466,2469,2472,2475,2478,2481,2484,2487,2490,2493,2496,2499,2502,2505,2508,2511,2514,2517,2520,2523,2526,2529,2532,2535,2538,2541,2544,2547,2550,2553,2556,2559,2562,2565,2568,2571,2574,2577,2580,2583,2586,2589,2592,2595,2598,2601,2604,2607,2608,2611,2614,2617,2620,2623,2626,2629,2632,2635,2638,2641,2644,2647,2650,2652,2655,2658,2661,2664,2667,2670,2673,2676,2679,2682,2685,2688,2691,2694,2697,2700,2703,2706,2709,2712,2715,2718,2721,2724,2727,2730,2733,2736,2739,2742,2745,2748,2751,2754,2757,2760,2763,2766,2769,2772,2775,2778,2781,2784,2787,2790,2793,2796,2799,2802,2805,2808,2811,2814,2817,2820,2823,2826,2829,2832,2835,2838,2841,2844,2847,2850,2853,2856,2859,2862,2865,2868,2871,2874,2877,2880,2883,2886,2889,2892,2895,2898,2901,2904,2907,2910,2913,2916,2919,2922],{"path":2380,"title":2381},"\u002Fabout","About Python CLI Toolcraft",{"path":2383,"title":2384},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies","Advanced Argument Validation Strategies",{"path":2386,"title":2387},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fparsing-nested-json-arguments-in-python-clis","Parsing Nested JSON Args in Python CLIs",{"path":2389,"title":2390},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fvalidating-dependent-and-conflicting-options","Validating Dependent and Conflicting CLI Options",{"path":2392,"title":2393},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fvalidating-file-and-directory-paths-in-clis","Validating File and Directory Paths in CLIs",{"path":2395,"title":2396},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fwriting-custom-click-parameter-types","Writing Custom Click Parameter Types",{"path":2398,"title":2399},"\u002Fadvanced-input-parsing-user-experience\u002Fbuilding-terminal-uis-with-textual\u002Fbuilding-your-first-textual-app","Building Your First Textual App for a Python CLI",{"path":2401,"title":2402},"\u002Fadvanced-input-parsing-user-experience\u002Fbuilding-terminal-uis-with-textual\u002Fchoosing-between-a-cli-a-prompt-flow-and-a-tui","Choosing Between a CLI, a Prompt Flow and a TUI",{"path":2404,"title":2405},"\u002Fadvanced-input-parsing-user-experience\u002Fbuilding-terminal-uis-with-textual","Building Terminal UIs with Textual for Python CLIs",{"path":2407,"title":2408},"\u002Fadvanced-input-parsing-user-experience\u002Fbuilding-terminal-uis-with-textual\u002Ftesting-textual-apps-with-pilot","Testing Textual Apps with Pilot",{"path":2410,"title":2411},"\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":2413,"title":2414},"\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":2416,"title":2417},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation","CLI Help Output and Documentation",{"path":2419,"title":2420},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fversioning-and-deprecating-cli-flags","Versioning and Deprecating CLI Flags",{"path":2422,"title":2423},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fwriting-help-text-users-actually-read","Writing Help Text Users Actually Read",{"path":2425,"title":2426},"\u002Fadvanced-input-parsing-user-experience\u002Fcross-platform-terminal-compatibility\u002Fadapting-output-to-terminal-width","Adapting Python CLI Output to Terminal Width",{"path":2428,"title":2429},"\u002Fadvanced-input-parsing-user-experience\u002Fcross-platform-terminal-compatibility\u002Fdetecting-ci-environments-and-non-interactive-shells","Detecting CI Environments and Non-Interactive Shells",{"path":2431,"title":2432},"\u002Fadvanced-input-parsing-user-experience\u002Fcross-platform-terminal-compatibility\u002Ffixing-unicode-and-encoding-errors-on-windows","Fixing Unicode and Encoding Errors on Windows in Python CLIs",{"path":2434,"title":2435},"\u002Fadvanced-input-parsing-user-experience\u002Fcross-platform-terminal-compatibility","Cross-Platform Terminal Compatibility for Python CLIs",{"path":2437,"title":2438},"\u002Fadvanced-input-parsing-user-experience\u002Fcross-platform-terminal-compatibility\u002Frespecting-no-color-and-force-color","Respecting NO_COLOR and FORCE_COLOR in Python CLIs",{"path":2440,"title":2441},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Fchoosing-exit-codes-for-cli-tools","Choosing Exit Codes for CLI Tools",{"path":2443,"title":2444},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Fdesigning-an-exception-hierarchy-for-a-cli","Designing an Exception Hierarchy for a Python CLI",{"path":2446,"title":2447},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Ffriendly-error-messages-and-tracebacks","Friendly Error Messages and Tracebacks",{"path":2449,"title":2450},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Fhandling-keyboard-interrupt-cleanly","Handling Keyboard Interrupt Cleanly",{"path":2452,"title":2453},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes","Error Handling and Exit Codes for CLIs",{"path":2455,"title":2456},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Freporting-machine-readable-errors-in-json-mode","Reporting Machine-Readable Errors in JSON Mode",{"path":2458,"title":2459},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Fconfig-precedence-flags-env-files-defaults","Config Precedence: Flags, Env, Files, Defaults",{"path":2461,"title":2462},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Fdiscovering-project-config-files-by-walking-up-directories","Discovering Project Config Files by Walking Up Directories",{"path":2464,"title":2465},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars","Handling Config Files and Env Vars in CLIs",{"path":2467,"title":2468},"\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":2470,"title":2471},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Freading-toml-config-with-tomllib","Reading TOML Config with tomllib in Python CLIs",{"path":2473,"title":2474},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Ftyped-settings-with-pydantic-settings","Typed Settings with pydantic-settings in Python CLIs",{"path":2476,"title":2477},"\u002Fadvanced-input-parsing-user-experience","Advanced Input Parsing for Python CLIs",{"path":2479,"title":2480},"\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":2482,"title":2483},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Fbuilding-interactive-prompts-and-menus","Building Interactive Prompts and Menus in Python CLIs",{"path":2485,"title":2486},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich","Interactive Terminal UI with Rich",{"path":2488,"title":2489},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Flive-dashboards-with-rich-live","Live Dashboards with Rich Live in Python CLIs",{"path":2491,"title":2492},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Frendering-tables-and-json-with-rich","Rendering Tables and JSON with Rich",{"path":2494,"title":2495},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Ftheming-rich-output-consistently","Theming Rich Output Consistently in Python CLIs",{"path":2497,"title":2498},"\u002Fadvanced-input-parsing-user-experience\u002Fshell-completion-for-python-clis\u002Fdynamic-completion-values-from-apis-and-files","Dynamic Completion Values from APIs and Files",{"path":2500,"title":2501},"\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":2503,"title":2504},"\u002Fadvanced-input-parsing-user-experience\u002Fshell-completion-for-python-clis","Shell Completion for Python CLIs",{"path":2506,"title":2507},"\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":2509,"title":2510},"\u002Fadvanced-input-parsing-user-experience\u002Fshell-completion-for-python-clis\u002Ftesting-shell-completion-in-python-clis","Testing Shell Completion in Python CLIs",{"path":2512,"title":2513},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fadding-trace-ids-and-context-to-cli-logs","Adding Trace IDs and Context to Python CLI Logs",{"path":2515,"title":2516},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fadding-verbose-and-quiet-logging-flags","Adding Verbose and Quiet Logging Flags",{"path":2518,"title":2519},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps","Structured Logging for CLI Apps",{"path":2521,"title":2522},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fstructured-json-logging-in-python-clis","Structured JSON Logging in Python CLIs",{"path":2524,"title":2525},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fwriting-rotating-log-files-from-a-cli","Writing Rotating Log Files from a Python CLI",{"path":2527,"title":2528},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Fdetecting-tty-and-adapting-output","Detecting a TTY and Adapting Output",{"path":2530,"title":2531},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Femitting-json-output-for-scripting","Emitting JSON Output for Scripting",{"path":2533,"title":2534},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Fhandling-broken-pipe-and-sigpipe","Handling Broken Pipe and SIGPIPE",{"path":2536,"title":2537},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes","Working with stdin, stdout and Pipes",{"path":2539,"title":2540},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Fprocessing-large-files-and-ndjson-streams","Processing Large Files and NDJSON Streams in Python CLIs",{"path":2542,"title":2543},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Freading-piped-input-in-python-clis","Reading Piped Input in Python CLIs",{"path":2545,"title":2546},"\u002Fcli-runtime-systems-integration\u002Fcalling-http-apis-from-python-clis\u002Fbuilding-an-api-client-cli-with-httpx","Building an API Client CLI with httpx",{"path":2548,"title":2549},"\u002Fcli-runtime-systems-integration\u002Fcalling-http-apis-from-python-clis\u002Fdownloading-files-with-progress-in-python","Downloading Files with Progress in Python CLIs",{"path":2551,"title":2552},"\u002Fcli-runtime-systems-integration\u002Fcalling-http-apis-from-python-clis","Calling HTTP APIs from Python CLIs",{"path":2554,"title":2555},"\u002Fcli-runtime-systems-integration\u002Fcalling-http-apis-from-python-clis\u002Foauth-device-flow-login-for-clis","OAuth Device Flow Login for Python CLIs",{"path":2557,"title":2558},"\u002Fcli-runtime-systems-integration\u002Fcalling-http-apis-from-python-clis\u002Fpaginating-api-results-in-a-cli","Paginating API Results in a Python CLI",{"path":2560,"title":2561},"\u002Fcli-runtime-systems-integration\u002Fcalling-http-apis-from-python-clis\u002Fretries-and-backoff-for-cli-http-calls","Retries and Backoff for CLI HTTP Calls",{"path":2563,"title":2564},"\u002Fcli-runtime-systems-integration\u002Fconcurrency-and-async-in-python-clis\u002Fcancelling-async-tasks-on-ctrl-c","Cancelling Async Tasks on Ctrl+C in Python CLIs",{"path":2566,"title":2567},"\u002Fcli-runtime-systems-integration\u002Fconcurrency-and-async-in-python-clis","Concurrency and Async in Python CLIs",{"path":2569,"title":2570},"\u002Fcli-runtime-systems-integration\u002Fconcurrency-and-async-in-python-clis\u002Fmultiprocessing-for-cpu-bound-cli-tasks","Multiprocessing for CPU-Bound CLI Tasks",{"path":2572,"title":2573},"\u002Fcli-runtime-systems-integration\u002Fconcurrency-and-async-in-python-clis\u002Fparallelising-cli-work-with-thread-pools","Parallelising CLI Work with Thread Pools",{"path":2575,"title":2576},"\u002Fcli-runtime-systems-integration\u002Fconcurrency-and-async-in-python-clis\u002Frate-limiting-concurrent-requests-in-clis","Rate-Limiting Concurrent Requests in Python CLIs",{"path":2578,"title":2579},"\u002Fcli-runtime-systems-integration\u002Fconcurrency-and-async-in-python-clis\u002Frunning-async-code-in-typer-and-click","Running Async Code in Typer and Click",{"path":2581,"title":2582},"\u002Fcli-runtime-systems-integration\u002Ffilesystem-paths-and-atomic-writes\u002Fcross-platform-paths-with-pathlib","Cross-Platform Paths with pathlib in CLIs",{"path":2584,"title":2585},"\u002Fcli-runtime-systems-integration\u002Ffilesystem-paths-and-atomic-writes\u002Ffile-locking-for-concurrent-cli-runs","File Locking for Concurrent CLI Runs in Python",{"path":2587,"title":2588},"\u002Fcli-runtime-systems-integration\u002Ffilesystem-paths-and-atomic-writes","Filesystem Paths and Atomic Writes for CLIs",{"path":2590,"title":2591},"\u002Fcli-runtime-systems-integration\u002Ffilesystem-paths-and-atomic-writes\u002Fsafe-temporary-files-and-directories","Safe Temporary Files and Directories in CLIs",{"path":2593,"title":2594},"\u002Fcli-runtime-systems-integration\u002Ffilesystem-paths-and-atomic-writes\u002Fstoring-app-data-with-platformdirs","Storing CLI App Data with platformdirs",{"path":2596,"title":2597},"\u002Fcli-runtime-systems-integration\u002Ffilesystem-paths-and-atomic-writes\u002Fwriting-files-atomically-in-python-clis","Writing Files Atomically in Python CLIs",{"path":2599,"title":2600},"\u002Fcli-runtime-systems-integration","CLI Runtime & Systems Integration for Python",{"path":2602,"title":2603},"\u002Fcli-runtime-systems-integration\u002Flong-running-and-watch-mode-clis\u002Fbuilding-a-watch-mode-with-watchfiles","Building a Watch Mode with watchfiles in Python",{"path":2605,"title":2606},"\u002Fcli-runtime-systems-integration\u002Flong-running-and-watch-mode-clis\u002Fhandling-sigterm-and-graceful-shutdown","Handling SIGTERM and Graceful Shutdown in CLIs",{"path":2369,"title":5},{"path":2609,"title":2610},"\u002Fcli-runtime-systems-integration\u002Flong-running-and-watch-mode-clis","Long-Running and Watch-Mode Python CLIs",{"path":2612,"title":2613},"\u002Fcli-runtime-systems-integration\u002Flong-running-and-watch-mode-clis\u002Frunning-a-cli-on-a-schedule-with-cron-and-systemd","Running a Python CLI on a Schedule with cron and systemd",{"path":2615,"title":2616},"\u002Fcli-runtime-systems-integration\u002Frunning-subprocesses-from-python-clis\u002Favoiding-shell-injection-in-python-clis","Avoiding Shell Injection in Python CLIs",{"path":2618,"title":2619},"\u002Fcli-runtime-systems-integration\u002Frunning-subprocesses-from-python-clis\u002Fcalling-external-commands-safely-with-subprocess","Calling External Commands Safely with subprocess",{"path":2621,"title":2622},"\u002Fcli-runtime-systems-integration\u002Frunning-subprocesses-from-python-clis\u002Fhandling-subprocess-timeouts-and-exit-codes","Handling Subprocess Timeouts and Exit Codes",{"path":2624,"title":2625},"\u002Fcli-runtime-systems-integration\u002Frunning-subprocesses-from-python-clis","Running Subprocesses from Python CLIs",{"path":2627,"title":2628},"\u002Fcli-runtime-systems-integration\u002Frunning-subprocesses-from-python-clis\u002Fstreaming-subprocess-output-in-real-time","Streaming Subprocess Output in Real Time",{"path":2630,"title":2631},"\u002Fcli-runtime-systems-integration\u002Frunning-subprocesses-from-python-clis\u002Fwrapping-git-and-other-tools-from-a-python-cli","Wrapping git and Other Tools from a Python CLI",{"path":2633,"title":2634},"\u002Fcli-runtime-systems-integration\u002Fsecrets-and-credentials-in-python-clis","Secrets and Credentials in Python CLIs",{"path":2636,"title":2637},"\u002Fcli-runtime-systems-integration\u002Fsecrets-and-credentials-in-python-clis\u002Fprompting-for-passwords-securely","Prompting for Passwords Securely in Python CLIs",{"path":2639,"title":2640},"\u002Fcli-runtime-systems-integration\u002Fsecrets-and-credentials-in-python-clis\u002Freading-secrets-from-env-and-files","Reading Secrets from Env Vars and Files in CLIs",{"path":2642,"title":2643},"\u002Fcli-runtime-systems-integration\u002Fsecrets-and-credentials-in-python-clis\u002Fredacting-secrets-from-cli-output-and-logs","Redacting Secrets from CLI Output and Logs",{"path":2645,"title":2646},"\u002Fcli-runtime-systems-integration\u002Fsecrets-and-credentials-in-python-clis\u002Fstoring-tokens-with-keyring","Storing CLI Tokens Securely with keyring",{"path":2648,"title":2649},"\u002Fcli-runtime-systems-integration\u002Fsecrets-and-credentials-in-python-clis\u002Fsupporting-multiple-profiles-and-accounts","Supporting Multiple Profiles and Accounts in CLIs",{"path":902,"title":2651},"Python CLI Toolcraft",{"path":2653,"title":2654},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Fcaching-expensive-work-between-cli-runs","Caching Expensive Work Between Python CLI Runs",{"path":2656,"title":2657},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading","CLI Startup Performance and Lazy Loading",{"path":2659,"title":2660},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Flazy-loading-subcommands-for-faster-startup","Lazy Loading Subcommands for Faster Startup",{"path":2662,"title":2663},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Fprofiling-python-cli-startup-time","Profiling Python CLI Startup Time",{"path":2665,"title":2666},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Freducing-cli-dependency-weight","Reducing CLI Dependency Weight",{"path":2668,"title":2669},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fargparse-subparsers-for-subcommands","argparse Subparsers for Subcommands",{"path":2671,"title":2672},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fargparse-vs-click-vs-typer-comparison","argparse vs Click vs Typer Compared",{"path":2674,"title":2675},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse","Command-Line Parsing with argparse",{"path":2677,"title":2678},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fmigrating-from-argparse-to-typer","Migrating from argparse to Typer",{"path":2680,"title":2681},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fmutually-exclusive-options-in-argparse","Mutually Exclusive Options in argparse",{"path":2683,"title":2684},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fwriting-custom-argparse-actions","Writing Custom argparse Actions in Python",{"path":2686,"title":2687},"\u002Fmodern-python-cli-frameworks-architecture\u002Fdesigning-cli-interfaces-and-conventions\u002Fadding-dry-run-and-confirmation-to-destructive-commands","Adding Dry-Run and Confirmation to Destructive Commands",{"path":2689,"title":2690},"\u002Fmodern-python-cli-frameworks-architecture\u002Fdesigning-cli-interfaces-and-conventions\u002Ffollowing-posix-and-gnu-argument-conventions","Following POSIX and GNU Argument Conventions in Python",{"path":2692,"title":2693},"\u002Fmodern-python-cli-frameworks-architecture\u002Fdesigning-cli-interfaces-and-conventions\u002Fglobal-options-vs-per-command-options","Global Options vs Per-Command Options in Python CLIs",{"path":2695,"title":2696},"\u002Fmodern-python-cli-frameworks-architecture\u002Fdesigning-cli-interfaces-and-conventions","Designing CLI Interfaces and Conventions in Python",{"path":2698,"title":2699},"\u002Fmodern-python-cli-frameworks-architecture\u002Fdesigning-cli-interfaces-and-conventions\u002Fnaming-commands-and-flags-consistently","Naming Commands and Flags Consistently in Python CLIs",{"path":2701,"title":2702},"\u002Fmodern-python-cli-frameworks-architecture","Python CLI Frameworks and Architecture",{"path":2704,"title":2705},"\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis\u002Fdiscovering-plugins-with-entry-points","Discovering Plugins with Entry Points in Python CLIs",{"path":2707,"title":2708},"\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis\u002Fhook-based-plugins-with-pluggy","Hook-Based Plugins for Python CLIs with pluggy",{"path":2710,"title":2711},"\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis","Plugin Architectures for Extensible CLIs",{"path":2713,"title":2714},"\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis\u002Fversioning-a-plugin-api","Versioning a Plugin API for a Python CLI",{"path":2716,"title":2717},"\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":2719,"title":2720},"\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":2722,"title":2723},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fdependency-injection-patterns-for-cli-commands","Dependency Injection Patterns for CLI Commands",{"path":2725,"title":2726},"\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":2728,"title":2729},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis","Structuring Multi-Command Python CLIs",{"path":2731,"title":2732},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fsharing-common-options-across-commands","Sharing Common Options Across Python CLI Commands",{"path":2734,"title":2735},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fsharing-state-with-click-context-objects","Sharing State with Click Context Objects",{"path":2737,"title":2738},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fend-to-end-testing-an-installed-cli","End-to-End Testing an Installed Python CLI",{"path":2740,"title":2741},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications","Testing Python CLI Applications",{"path":2743,"title":2744},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmeasuring-cli-test-coverage","Measuring CLI Test Coverage",{"path":2746,"title":2747},"\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":2749,"title":2750},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fproperty-based-testing-cli-arguments-with-hypothesis","Property-Based Testing CLI Arguments with Hypothesis",{"path":2752,"title":2753},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fsnapshot-testing-cli-output","Snapshot Testing CLI Output",{"path":2755,"title":2756},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-click-commands-with-clirunner","Testing Click Commands with CliRunner",{"path":2758,"title":2759},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-interactive-prompts-and-stdin","Testing Interactive Prompts and stdin",{"path":2761,"title":2762},"\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":2764,"title":2765},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Fbuilding-dynamic-commands-in-click","Building Dynamic Commands in Click",{"path":2767,"title":2768},"\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":2770,"title":2771},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each","Typer vs Click: When to Use Each",{"path":2773,"title":2774},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Ftyper-callback-functions-explained","Typer callback functions explained",{"path":2776,"title":2777},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Fusing-annotated-options-in-typer","Using Annotated Options in Typer",{"path":2779,"title":2780},"\u002Fproject-setup-dependency-management\u002Fci-cd-pipelines-for-python-clis\u002Fautomating-releases-from-git-tags","Automating Python CLI Releases from Git Tags",{"path":2782,"title":2783},"\u002Fproject-setup-dependency-management\u002Fci-cd-pipelines-for-python-clis\u002Fcaching-uv-dependencies-in-ci","Caching uv Dependencies in CI for Python CLIs",{"path":2785,"title":2786},"\u002Fproject-setup-dependency-management\u002Fci-cd-pipelines-for-python-clis","CI\u002FCD Pipelines for Python CLIs",{"path":2788,"title":2789},"\u002Fproject-setup-dependency-management\u002Fci-cd-pipelines-for-python-clis\u002Fpublishing-to-pypi-with-trusted-publishing","Publishing a CLI to PyPI with Trusted Publishing",{"path":2791,"title":2792},"\u002Fproject-setup-dependency-management\u002Fci-cd-pipelines-for-python-clis\u002Fsmoke-testing-the-built-wheel-in-ci","Smoke-Testing the Built Wheel of a Python CLI in CI",{"path":2794,"title":2795},"\u002Fproject-setup-dependency-management\u002Fci-cd-pipelines-for-python-clis\u002Ftesting-a-cli-across-python-versions-with-github-actions","Testing a CLI Across Python Versions in GitHub Actions",{"path":2797,"title":2798},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter\u002Fbuilding-a-cookiecutter-template-for-typer-clis","Building a Cookiecutter Template for Typer CLIs",{"path":2800,"title":2801},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter\u002Fcopier-vs-cookiecutter-for-cli-templates","Copier vs Cookiecutter for CLI Templates",{"path":2803,"title":2804},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter","CLI Project Scaffolding with Cookiecutter",{"path":2806,"title":2807},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter\u002Fpost-generation-hooks-in-cli-templates","Post-Generation Hooks in Python CLI Templates",{"path":2809,"title":2810},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fbuilding-cross-platform-release-binaries-in-ci","Building Cross-Platform Release Binaries in CI",{"path":2812,"title":2813},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fbundling-a-python-cli-with-pyinstaller","Bundling a Python CLI with PyInstaller",{"path":2815,"title":2816},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fhomebrew-and-scoop-packaging-for-python-clis","Homebrew and Scoop Packaging for Python CLIs",{"path":2818,"title":2819},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries","Distributing CLIs as Standalone Binaries",{"path":2821,"title":2822},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fnuitka-vs-pyinstaller-for-python-clis","Nuitka vs PyInstaller for Python CLI Binaries",{"path":2824,"title":2825},"\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":2827,"title":2828},"\u002Fproject-setup-dependency-management","Project Setup & Dependency Management",{"path":2830,"title":2831},"\u002Fproject-setup-dependency-management\u002Flinting-and-type-checking-cli-code\u002Fconfiguring-ruff-for-a-cli-project","Configuring Ruff for a Python CLI Project",{"path":2833,"title":2834},"\u002Fproject-setup-dependency-management\u002Flinting-and-type-checking-cli-code\u002Fenforcing-import-boundaries-in-a-cli-codebase","Enforcing Import Boundaries in a Python CLI Codebase",{"path":2836,"title":2837},"\u002Fproject-setup-dependency-management\u002Flinting-and-type-checking-cli-code","Linting and Type-Checking Python CLI Code",{"path":2839,"title":2840},"\u002Fproject-setup-dependency-management\u002Flinting-and-type-checking-cli-code\u002Ftype-checking-click-and-typer-code-with-mypy","Type-Checking Click and Typer Code with mypy",{"path":2842,"title":2843},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fautomating-changelogs-with-conventional-commits","Automating Changelogs with Conventional Commits",{"path":2845,"title":2846},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fderiving-versions-from-git-tags-with-hatch-vcs","Deriving CLI Versions from Git Tags with hatch-vcs",{"path":2848,"title":2849},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fexposing-version-info-and-build-metadata","Exposing Version Info and Build Metadata",{"path":2851,"title":2852},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs","Managing CLI Versioning & Changelogs",{"path":2854,"title":2855},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fsemantic-versioning-policy-for-cli-tools","A Semantic Versioning Policy for CLI Tools",{"path":2857,"title":2858},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fbuilding-wheels-and-sdists-for-python-clis","Building Wheels and sdists for Python CLIs",{"path":2860,"title":2861},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fbundling-data-files-with-importlib-resources","Bundling Data Files with importlib.resources in CLIs",{"path":2863,"title":2864},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution","Packaging Python CLIs for Distribution",{"path":2866,"title":2867},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Finstalling-and-distributing-clis-with-pipx","Installing and Distributing CLIs with pipx",{"path":2869,"title":2870},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fpublishing-a-python-cli-to-pypi","Publishing a Python CLI to PyPI",{"path":2872,"title":2873},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fwriting-pyproject-toml-metadata-for-a-cli","Writing pyproject.toml Metadata for a Python CLI",{"path":2875,"title":2876},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development","Poetry Workflows for CLI Development",{"path":2878,"title":2879},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development\u002Fmigrating-a-cli-from-poetry-to-uv","Migrating a Python CLI from Poetry to uv",{"path":2881,"title":2882},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development\u002Fpoetry-dependency-groups-for-cli-tooling","Poetry Dependency Groups for CLI Tooling",{"path":2884,"title":2885},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development\u002Fpoetry-entry-points-and-scripts-for-clis","Poetry Entry Points and Scripts for CLIs",{"path":2887,"title":2888},"\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects","Pre-commit Hooks for CLI Projects",{"path":2890,"title":2891},"\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":2893,"title":2894},"\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects\u002Fshipping-your-cli-as-a-pre-commit-hook","Shipping Your Python CLI as a pre-commit Hook",{"path":2896,"title":2897},"\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects\u002Fwriting-local-pre-commit-hooks-in-python","Writing Local pre-commit Hooks in Python",{"path":2899,"title":2900},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management","uv for Python CLI Dependency Management",{"path":2902,"title":2903},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management\u002Frunning-one-off-cli-scripts-with-uv-run","Running One-Off CLI Scripts with uv run and PEP 723",{"path":2905,"title":2906},"\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":2908,"title":2909},"\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":2911,"title":2912},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management\u002Fuv-workspaces-for-multi-package-clis","uv Workspaces for Multi-Package Python CLIs",{"path":2914,"title":2915},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices","Python CLI Env Isolation Best Practices",{"path":2917,"title":2918},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fmanaging-virtual-environments-for-cross-platform-clis","Managing Python CLI Virtual Environments",{"path":2920,"title":2921},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fpinning-the-python-version-for-a-cli","Pinning the Python Version for a CLI",{"path":2923,"title":2924},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fsupporting-multiple-python-versions-with-nox","Supporting Multiple Python Versions with nox",1789736905050]