[{"data":1,"prerenderedAt":3481},["ShallowReactive",2],{"page-\u002Fadvanced-input-parsing-user-experience\u002Fbuilding-terminal-uis-with-textual\u002Fbuilding-your-first-textual-app\u002F":3,"content-directory":2933},{"id":4,"title":5,"body":6,"date":2918,"description":2919,"difficulty":2920,"draft":2921,"extension":2922,"meta":2923,"navigation":120,"path":2924,"seo":2925,"stem":2926,"tags":2927,"updated":2918,"__hash__":2932},"content\u002Fadvanced-input-parsing-user-experience\u002Fbuilding-terminal-uis-with-textual\u002Fbuilding-your-first-textual-app\u002Findex.md","Building Your First Textual App for a Python CLI",{"type":7,"value":8,"toc":2899},"minimark",[9,29,34,64,68,79,626,637,641,652,656,659,1908,2028,2050,2066,2070,2077,2106,2125,2135,2139,2142,2163,2185,2188,2192,2220,2224,2227,2454,2458,2505,2509,2516,2759,2786,2790,2793,2797,2802,2809,2813,2834,2838,2844,2848,2861,2865,2896],[10,11,12,13,17,18,22,23,28],"p",{},"The quickest way to understand Textual is to build something real with it. This guide builds a small ",[14,15,16],"strong",{},"job monitor"," — the kind of screen a CLI for a task queue, a CI system or a batch-processing service might offer as ",[19,20,21],"code",{},"mytool jobs watch",". It lists jobs with their state and progress, refreshes every couple of seconds without freezing, highlights failures in colour, and lets the user cancel a job after confirming in a dialog. Along the way it covers every core Textual idea a CLI author needs: composing widgets, styling with Textual CSS, handling messages, binding keys to actions, loading data in background workers, timers and modal screens. It belongs to the ",[24,25,27],"a",{"href":26},"\u002Fadvanced-input-parsing-user-experience\u002Fbuilding-terminal-uis-with-textual\u002F","building terminal UIs with Textual topic",", which covers when a TUI is worth building at all.",[30,31,33],"h2",{"id":32},"prerequisites","Prerequisites",[35,36,37,45,53],"ul",{},[38,39,40,41,44],"li",{},"Python 3.10+ and Textual 1.0 or newer (",[19,42,43],{},"uv add textual","); the example was checked against Textual 8.",[38,46,47,48,52],{},"Basic familiarity with Rich markup, which Textual uses for styled text; see ",[24,49,51],{"href":50},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002F","interactive terminal UI with Rich",".",[38,54,55,56,59,60,63],{},"Optional: ",[19,57,58],{},"textual-dev"," (",[19,61,62],{},"uv add --dev textual-dev",") for the live developer console.",[30,65,67],{"id":66},"step-1-separate-the-data-from-the-interface","Step 1: separate the data from the interface",[10,69,70,71,74,75,52],{},"Before any widget, decide where the data comes from — and keep it out of the UI. A small class with a ",[19,72,73],{},"fetch"," method stands in for an API client here; in a real CLI it is the same client the non-interactive commands use, as described in ",[24,76,78],{"href":77},"\u002Fcli-runtime-systems-integration\u002Fcalling-http-apis-from-python-clis\u002Fbuilding-an-api-client-cli-with-httpx\u002F","building an API client CLI with httpx",[80,81,86],"pre",{"className":82,"code":83,"language":84,"meta":85,"style":85},"language-python shiki shiki-themes github-light github-dark","# src\u002Fjobs\u002Fsource.py\nfrom __future__ import annotations\n\nimport random\nfrom dataclasses import dataclass\n\n\n@dataclass(frozen=True)\nclass Job:\n    id: str\n    name: str\n    state: str          # queued | running | done | failed\n    progress: int       # 0-100\n\n\nclass JobSource:\n    \"\"\"Where jobs come from. The real one calls an API; tests pass a fake.\"\"\"\n\n    def __init__(self, seed: int = 1) -> None:\n        self._rng = random.Random(seed)\n        self._jobs = {f\"j{i}\": Job(f\"j{i}\", name, \"queued\", 0)\n                      for i, name in enumerate([\"backup\", \"reindex\", \"export\", \"thumbnails\"], 1)}\n\n    def fetch(self) -> list[Job]:\n        for key, job in list(self._jobs.items()):\n            if job.state in (\"queued\", \"running\"):\n                progress = min(100, job.progress + self._rng.randint(5, 30))\n                state = \"done\" if progress == 100 else \"running\"\n                self._jobs[key] = Job(job.id, job.name, state, progress)\n        return list(self._jobs.values())\n\n    def cancel(self, job_id: str) -> None:\n        job = self._jobs[job_id]\n        self._jobs[job_id] = Job(job.id, job.name, \"failed\", job.progress)\n","python","",[19,87,88,97,115,122,131,144,149,154,177,189,201,209,221,233,238,243,253,260,265,293,307,367,412,417,428,450,473,512,541,555,570,575,594,607],{"__ignoreMap":85},[89,90,93],"span",{"class":91,"line":92},"line",1,[89,94,96],{"class":95},"sJ8bj","# src\u002Fjobs\u002Fsource.py\n",[89,98,100,104,108,111],{"class":91,"line":99},2,[89,101,103],{"class":102},"szBVR","from",[89,105,107],{"class":106},"sj4cs"," __future__",[89,109,110],{"class":102}," import",[89,112,114],{"class":113},"sVt8B"," annotations\n",[89,116,118],{"class":91,"line":117},3,[89,119,121],{"emptyLinePlaceholder":120},true,"\n",[89,123,125,128],{"class":91,"line":124},4,[89,126,127],{"class":102},"import",[89,129,130],{"class":113}," random\n",[89,132,134,136,139,141],{"class":91,"line":133},5,[89,135,103],{"class":102},[89,137,138],{"class":113}," dataclasses ",[89,140,127],{"class":102},[89,142,143],{"class":113}," dataclass\n",[89,145,147],{"class":91,"line":146},6,[89,148,121],{"emptyLinePlaceholder":120},[89,150,152],{"class":91,"line":151},7,[89,153,121],{"emptyLinePlaceholder":120},[89,155,157,161,164,168,171,174],{"class":91,"line":156},8,[89,158,160],{"class":159},"sScJk","@dataclass",[89,162,163],{"class":113},"(",[89,165,167],{"class":166},"s4XuR","frozen",[89,169,170],{"class":102},"=",[89,172,173],{"class":106},"True",[89,175,176],{"class":113},")\n",[89,178,180,183,186],{"class":91,"line":179},9,[89,181,182],{"class":102},"class",[89,184,185],{"class":159}," Job",[89,187,188],{"class":113},":\n",[89,190,192,195,198],{"class":91,"line":191},10,[89,193,194],{"class":106},"    id",[89,196,197],{"class":113},": ",[89,199,200],{"class":106},"str\n",[89,202,204,207],{"class":91,"line":203},11,[89,205,206],{"class":113},"    name: ",[89,208,200],{"class":106},[89,210,212,215,218],{"class":91,"line":211},12,[89,213,214],{"class":113},"    state: ",[89,216,217],{"class":106},"str",[89,219,220],{"class":95},"          # queued | running | done | failed\n",[89,222,224,227,230],{"class":91,"line":223},13,[89,225,226],{"class":113},"    progress: ",[89,228,229],{"class":106},"int",[89,231,232],{"class":95},"       # 0-100\n",[89,234,236],{"class":91,"line":235},14,[89,237,121],{"emptyLinePlaceholder":120},[89,239,241],{"class":91,"line":240},15,[89,242,121],{"emptyLinePlaceholder":120},[89,244,246,248,251],{"class":91,"line":245},16,[89,247,182],{"class":102},[89,249,250],{"class":159}," JobSource",[89,252,188],{"class":113},[89,254,256],{"class":91,"line":255},17,[89,257,259],{"class":258},"sZZnC","    \"\"\"Where jobs come from. The real one calls an API; tests pass a fake.\"\"\"\n",[89,261,263],{"class":91,"line":262},18,[89,264,121],{"emptyLinePlaceholder":120},[89,266,268,271,274,277,279,282,285,288,291],{"class":91,"line":267},19,[89,269,270],{"class":102},"    def",[89,272,273],{"class":106}," __init__",[89,275,276],{"class":113},"(self, seed: ",[89,278,229],{"class":106},[89,280,281],{"class":102}," =",[89,283,284],{"class":106}," 1",[89,286,287],{"class":113},") -> ",[89,289,290],{"class":106},"None",[89,292,188],{"class":113},[89,294,296,299,302,304],{"class":91,"line":295},20,[89,297,298],{"class":106},"        self",[89,300,301],{"class":113},"._rng ",[89,303,170],{"class":102},[89,305,306],{"class":113}," random.Random(seed)\n",[89,308,310,312,315,317,320,323,326,329,332,335,338,341,343,345,347,349,351,353,356,359,362,365],{"class":91,"line":309},21,[89,311,298],{"class":106},[89,313,314],{"class":113},"._jobs ",[89,316,170],{"class":102},[89,318,319],{"class":113}," {",[89,321,322],{"class":102},"f",[89,324,325],{"class":258},"\"j",[89,327,328],{"class":106},"{",[89,330,331],{"class":113},"i",[89,333,334],{"class":106},"}",[89,336,337],{"class":258},"\"",[89,339,340],{"class":113},": Job(",[89,342,322],{"class":102},[89,344,325],{"class":258},[89,346,328],{"class":106},[89,348,331],{"class":113},[89,350,334],{"class":106},[89,352,337],{"class":258},[89,354,355],{"class":113},", name, ",[89,357,358],{"class":258},"\"queued\"",[89,360,361],{"class":113},", ",[89,363,364],{"class":106},"0",[89,366,176],{"class":113},[89,368,370,373,376,379,382,385,388,390,393,395,398,400,403,406,409],{"class":91,"line":369},22,[89,371,372],{"class":102},"                      for",[89,374,375],{"class":113}," i, name ",[89,377,378],{"class":102},"in",[89,380,381],{"class":106}," enumerate",[89,383,384],{"class":113},"([",[89,386,387],{"class":258},"\"backup\"",[89,389,361],{"class":113},[89,391,392],{"class":258},"\"reindex\"",[89,394,361],{"class":113},[89,396,397],{"class":258},"\"export\"",[89,399,361],{"class":113},[89,401,402],{"class":258},"\"thumbnails\"",[89,404,405],{"class":113},"], ",[89,407,408],{"class":106},"1",[89,410,411],{"class":113},")}\n",[89,413,415],{"class":91,"line":414},23,[89,416,121],{"emptyLinePlaceholder":120},[89,418,420,422,425],{"class":91,"line":419},24,[89,421,270],{"class":102},[89,423,424],{"class":159}," fetch",[89,426,427],{"class":113},"(self) -> list[Job]:\n",[89,429,431,434,437,439,442,444,447],{"class":91,"line":430},25,[89,432,433],{"class":102},"        for",[89,435,436],{"class":113}," key, job ",[89,438,378],{"class":102},[89,440,441],{"class":106}," list",[89,443,163],{"class":113},[89,445,446],{"class":106},"self",[89,448,449],{"class":113},"._jobs.items()):\n",[89,451,453,456,459,461,463,465,467,470],{"class":91,"line":452},26,[89,454,455],{"class":102},"            if",[89,457,458],{"class":113}," job.state ",[89,460,378],{"class":102},[89,462,59],{"class":113},[89,464,358],{"class":258},[89,466,361],{"class":113},[89,468,469],{"class":258},"\"running\"",[89,471,472],{"class":113},"):\n",[89,474,476,479,481,484,486,489,492,495,498,501,504,506,509],{"class":91,"line":475},27,[89,477,478],{"class":113},"                progress ",[89,480,170],{"class":102},[89,482,483],{"class":106}," min",[89,485,163],{"class":113},[89,487,488],{"class":106},"100",[89,490,491],{"class":113},", job.progress ",[89,493,494],{"class":102},"+",[89,496,497],{"class":106}," self",[89,499,500],{"class":113},"._rng.randint(",[89,502,503],{"class":106},"5",[89,505,361],{"class":113},[89,507,508],{"class":106},"30",[89,510,511],{"class":113},"))\n",[89,513,515,518,520,523,526,529,532,535,538],{"class":91,"line":514},28,[89,516,517],{"class":113},"                state ",[89,519,170],{"class":102},[89,521,522],{"class":258}," \"done\"",[89,524,525],{"class":102}," if",[89,527,528],{"class":113}," progress ",[89,530,531],{"class":102},"==",[89,533,534],{"class":106}," 100",[89,536,537],{"class":102}," else",[89,539,540],{"class":258}," \"running\"\n",[89,542,544,547,550,552],{"class":91,"line":543},29,[89,545,546],{"class":106},"                self",[89,548,549],{"class":113},"._jobs[key] ",[89,551,170],{"class":102},[89,553,554],{"class":113}," Job(job.id, job.name, state, progress)\n",[89,556,558,561,563,565,567],{"class":91,"line":557},30,[89,559,560],{"class":102},"        return",[89,562,441],{"class":106},[89,564,163],{"class":113},[89,566,446],{"class":106},[89,568,569],{"class":113},"._jobs.values())\n",[89,571,573],{"class":91,"line":572},31,[89,574,121],{"emptyLinePlaceholder":120},[89,576,578,580,583,586,588,590,592],{"class":91,"line":577},32,[89,579,270],{"class":102},[89,581,582],{"class":159}," cancel",[89,584,585],{"class":113},"(self, job_id: ",[89,587,217],{"class":106},[89,589,287],{"class":113},[89,591,290],{"class":106},[89,593,188],{"class":113},[89,595,597,600,602,604],{"class":91,"line":596},33,[89,598,599],{"class":113},"        job ",[89,601,170],{"class":102},[89,603,497],{"class":106},[89,605,606],{"class":113},"._jobs[job_id]\n",[89,608,610,612,615,617,620,623],{"class":91,"line":609},34,[89,611,298],{"class":106},[89,613,614],{"class":113},"._jobs[job_id] ",[89,616,170],{"class":102},[89,618,619],{"class":113}," Job(job.id, job.name, ",[89,621,622],{"class":258},"\"failed\"",[89,624,625],{"class":113},", job.progress)\n",[10,627,628,629,632,633,636],{},"The simulated source advances jobs randomly on each fetch, so the monitor has something to show. Because the UI only ever calls ",[19,630,631],{},"fetch()"," and ",[19,634,635],{},"cancel()",", tests can hand it a fake, and the TUI never learns how jobs are actually obtained.",[30,638,640],{"id":639},"step-2-compose-the-screen","Step 2: compose the screen",[10,642,643,644,647,648,651],{},"A Textual ",[19,645,646],{},"App"," declares its widgets in ",[19,649,650],{},"compose()",". They form a tree — Textual calls it the DOM — and CSS lays them out:",[653,654],"inline-diagram",{"name":655},"tx-compose",[10,657,658],{},"Here is the whole application. The sections below walk through it piece by piece.",[80,660,662],{"className":82,"code":661,"language":84,"meta":85,"style":85},"# src\u002Fjobs\u002Fapp.py\nfrom __future__ import annotations\n\nfrom textual import work\nfrom textual.app import App, ComposeResult\nfrom textual.containers import Grid\nfrom textual.screen import ModalScreen\nfrom textual.widgets import Button, DataTable, Footer, Header, Label\n\nfrom jobs.source import Job, JobSource\n\nSTATE_STYLE = {\"queued\": \"dim\", \"running\": \"bold yellow\", \"done\": \"green\", \"failed\": \"red\"}\n\n\nclass ConfirmCancel(ModalScreen[bool]):\n    \"\"\"Ask before cancelling; dismiss(True) or dismiss(False).\"\"\"\n\n    def __init__(self, job: Job) -> None:\n        super().__init__()\n        self.job = job\n\n    def compose(self) -> ComposeResult:\n        with Grid(id=\"dialog\"):\n            yield Label(f\"Cancel job {self.job.name!r}?\", id=\"question\")\n            yield Button(\"Cancel job\", variant=\"error\", id=\"yes\")\n            yield Button(\"Keep running\", variant=\"primary\", id=\"no\")\n\n    def on_button_pressed(self, event: Button.Pressed) -> None:\n        self.dismiss(event.button.id == \"yes\")\n\n\nclass JobMonitor(App[None]):\n    CSS_PATH = \"monitor.tcss\"\n    BINDINGS = [(\"q\", \"quit\", \"Quit\"), (\"r\", \"refresh\", \"Refresh\"), (\"c\", \"cancel\", \"Cancel job\")]\n\n    def __init__(self, source: JobSource, interval: float = 2.0) -> None:\n        super().__init__()\n        self.source = source\n        self.interval = interval\n\n    def compose(self) -> ComposeResult:\n        yield Header(show_clock=True)\n        yield DataTable(cursor_type=\"row\", zebra_stripes=True)\n        yield Footer()\n\n    def on_mount(self) -> None:\n        self.title = \"Job monitor\"\n        table = self.query_one(DataTable)\n        table.add_columns(\"Job\", \"State\", \"Progress\")\n        table.loading = True\n        self.action_refresh()\n        self.set_interval(self.interval, self.action_refresh)\n\n    def action_refresh(self) -> None:\n        self.load_jobs()\n\n    @work(exclusive=True, thread=True)\n    def load_jobs(self) -> None:\n        jobs = self.source.fetch()                 # blocking I\u002FO, off the event loop\n        self.call_from_thread(self.show_jobs, jobs)\n\n    def show_jobs(self, jobs: list[Job]) -> None:\n        table = self.query_one(DataTable)\n        table.loading = False\n        cursor = table.cursor_row\n        table.clear()\n        for job in jobs:\n            style = STATE_STYLE[job.state]\n            table.add_row(job.name, f\"[{style}]{job.state}[\u002F]\", f\"{job.progress:>3}%\", key=job.id)\n        if jobs:\n            table.move_cursor(row=min(cursor, len(jobs) - 1))\n        running = sum(j.state == \"running\" for j in jobs)\n        self.sub_title = f\"{running} running, {len(jobs)} total\"\n\n    def action_cancel(self) -> None:\n        table = self.query_one(DataTable)\n        if table.row_count == 0:\n            return\n        job_id = table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value\n        job = next(j for j in self.source.fetch() if j.id == job_id)\n\n        def done(confirmed: bool | None) -> None:\n            if confirmed:\n                self.source.cancel(job_id)\n                self.notify(f\"cancelled {job.name}\")\n                self.action_refresh()\n\n        self.push_screen(ConfirmCancel(job), done)\n",[19,663,664,669,679,683,695,707,719,731,743,747,759,763,810,814,818,834,839,843,856,870,882,886,896,914,952,983,1012,1016,1030,1044,1048,1052,1066,1076,1132,1137,1161,1172,1185,1198,1203,1212,1230,1257,1265,1270,1285,1298,1311,1332,1343,1351,1369,1374,1388,1396,1401,1427,1441,1457,1470,1475,1490,1501,1511,1522,1528,1541,1555,1615,1623,1653,1683,1719,1724,1738,1749,1764,1770,1781,1817,1822,1848,1856,1864,1888,1895,1900],{"__ignoreMap":85},[89,665,666],{"class":91,"line":92},[89,667,668],{"class":95},"# src\u002Fjobs\u002Fapp.py\n",[89,670,671,673,675,677],{"class":91,"line":99},[89,672,103],{"class":102},[89,674,107],{"class":106},[89,676,110],{"class":102},[89,678,114],{"class":113},[89,680,681],{"class":91,"line":117},[89,682,121],{"emptyLinePlaceholder":120},[89,684,685,687,690,692],{"class":91,"line":124},[89,686,103],{"class":102},[89,688,689],{"class":113}," textual ",[89,691,127],{"class":102},[89,693,694],{"class":113}," work\n",[89,696,697,699,702,704],{"class":91,"line":133},[89,698,103],{"class":102},[89,700,701],{"class":113}," textual.app ",[89,703,127],{"class":102},[89,705,706],{"class":113}," App, ComposeResult\n",[89,708,709,711,714,716],{"class":91,"line":146},[89,710,103],{"class":102},[89,712,713],{"class":113}," textual.containers ",[89,715,127],{"class":102},[89,717,718],{"class":113}," Grid\n",[89,720,721,723,726,728],{"class":91,"line":151},[89,722,103],{"class":102},[89,724,725],{"class":113}," textual.screen ",[89,727,127],{"class":102},[89,729,730],{"class":113}," ModalScreen\n",[89,732,733,735,738,740],{"class":91,"line":156},[89,734,103],{"class":102},[89,736,737],{"class":113}," textual.widgets ",[89,739,127],{"class":102},[89,741,742],{"class":113}," Button, DataTable, Footer, Header, Label\n",[89,744,745],{"class":91,"line":179},[89,746,121],{"emptyLinePlaceholder":120},[89,748,749,751,754,756],{"class":91,"line":191},[89,750,103],{"class":102},[89,752,753],{"class":113}," jobs.source ",[89,755,127],{"class":102},[89,757,758],{"class":113}," Job, JobSource\n",[89,760,761],{"class":91,"line":203},[89,762,121],{"emptyLinePlaceholder":120},[89,764,765,768,770,772,774,776,779,781,783,785,788,790,793,795,798,800,802,804,807],{"class":91,"line":211},[89,766,767],{"class":106},"STATE_STYLE",[89,769,281],{"class":102},[89,771,319],{"class":113},[89,773,358],{"class":258},[89,775,197],{"class":113},[89,777,778],{"class":258},"\"dim\"",[89,780,361],{"class":113},[89,782,469],{"class":258},[89,784,197],{"class":113},[89,786,787],{"class":258},"\"bold yellow\"",[89,789,361],{"class":113},[89,791,792],{"class":258},"\"done\"",[89,794,197],{"class":113},[89,796,797],{"class":258},"\"green\"",[89,799,361],{"class":113},[89,801,622],{"class":258},[89,803,197],{"class":113},[89,805,806],{"class":258},"\"red\"",[89,808,809],{"class":113},"}\n",[89,811,812],{"class":91,"line":223},[89,813,121],{"emptyLinePlaceholder":120},[89,815,816],{"class":91,"line":235},[89,817,121],{"emptyLinePlaceholder":120},[89,819,820,822,825,828,831],{"class":91,"line":240},[89,821,182],{"class":102},[89,823,824],{"class":159}," ConfirmCancel",[89,826,827],{"class":113},"(ModalScreen[",[89,829,830],{"class":106},"bool",[89,832,833],{"class":113},"]):\n",[89,835,836],{"class":91,"line":245},[89,837,838],{"class":258},"    \"\"\"Ask before cancelling; dismiss(True) or dismiss(False).\"\"\"\n",[89,840,841],{"class":91,"line":255},[89,842,121],{"emptyLinePlaceholder":120},[89,844,845,847,849,852,854],{"class":91,"line":262},[89,846,270],{"class":102},[89,848,273],{"class":106},[89,850,851],{"class":113},"(self, job: Job) -> ",[89,853,290],{"class":106},[89,855,188],{"class":113},[89,857,858,861,864,867],{"class":91,"line":267},[89,859,860],{"class":106},"        super",[89,862,863],{"class":113},"().",[89,865,866],{"class":106},"__init__",[89,868,869],{"class":113},"()\n",[89,871,872,874,877,879],{"class":91,"line":295},[89,873,298],{"class":106},[89,875,876],{"class":113},".job ",[89,878,170],{"class":102},[89,880,881],{"class":113}," job\n",[89,883,884],{"class":91,"line":309},[89,885,121],{"emptyLinePlaceholder":120},[89,887,888,890,893],{"class":91,"line":369},[89,889,270],{"class":102},[89,891,892],{"class":159}," compose",[89,894,895],{"class":113},"(self) -> ComposeResult:\n",[89,897,898,901,904,907,909,912],{"class":91,"line":414},[89,899,900],{"class":102},"        with",[89,902,903],{"class":113}," Grid(",[89,905,906],{"class":166},"id",[89,908,170],{"class":102},[89,910,911],{"class":258},"\"dialog\"",[89,913,472],{"class":113},[89,915,916,919,922,924,927,930,933,936,938,941,943,945,947,950],{"class":91,"line":419},[89,917,918],{"class":102},"            yield",[89,920,921],{"class":113}," Label(",[89,923,322],{"class":102},[89,925,926],{"class":258},"\"Cancel job ",[89,928,929],{"class":106},"{self",[89,931,932],{"class":113},".job.name",[89,934,935],{"class":102},"!r",[89,937,334],{"class":106},[89,939,940],{"class":258},"?\"",[89,942,361],{"class":113},[89,944,906],{"class":166},[89,946,170],{"class":102},[89,948,949],{"class":258},"\"question\"",[89,951,176],{"class":113},[89,953,954,956,959,962,964,967,969,972,974,976,978,981],{"class":91,"line":430},[89,955,918],{"class":102},[89,957,958],{"class":113}," Button(",[89,960,961],{"class":258},"\"Cancel job\"",[89,963,361],{"class":113},[89,965,966],{"class":166},"variant",[89,968,170],{"class":102},[89,970,971],{"class":258},"\"error\"",[89,973,361],{"class":113},[89,975,906],{"class":166},[89,977,170],{"class":102},[89,979,980],{"class":258},"\"yes\"",[89,982,176],{"class":113},[89,984,985,987,989,992,994,996,998,1001,1003,1005,1007,1010],{"class":91,"line":452},[89,986,918],{"class":102},[89,988,958],{"class":113},[89,990,991],{"class":258},"\"Keep running\"",[89,993,361],{"class":113},[89,995,966],{"class":166},[89,997,170],{"class":102},[89,999,1000],{"class":258},"\"primary\"",[89,1002,361],{"class":113},[89,1004,906],{"class":166},[89,1006,170],{"class":102},[89,1008,1009],{"class":258},"\"no\"",[89,1011,176],{"class":113},[89,1013,1014],{"class":91,"line":475},[89,1015,121],{"emptyLinePlaceholder":120},[89,1017,1018,1020,1023,1026,1028],{"class":91,"line":514},[89,1019,270],{"class":102},[89,1021,1022],{"class":159}," on_button_pressed",[89,1024,1025],{"class":113},"(self, event: Button.Pressed) -> ",[89,1027,290],{"class":106},[89,1029,188],{"class":113},[89,1031,1032,1034,1037,1039,1042],{"class":91,"line":543},[89,1033,298],{"class":106},[89,1035,1036],{"class":113},".dismiss(event.button.id ",[89,1038,531],{"class":102},[89,1040,1041],{"class":258}," \"yes\"",[89,1043,176],{"class":113},[89,1045,1046],{"class":91,"line":557},[89,1047,121],{"emptyLinePlaceholder":120},[89,1049,1050],{"class":91,"line":572},[89,1051,121],{"emptyLinePlaceholder":120},[89,1053,1054,1056,1059,1062,1064],{"class":91,"line":577},[89,1055,182],{"class":102},[89,1057,1058],{"class":159}," JobMonitor",[89,1060,1061],{"class":113},"(App[",[89,1063,290],{"class":106},[89,1065,833],{"class":113},[89,1067,1068,1071,1073],{"class":91,"line":596},[89,1069,1070],{"class":106},"    CSS_PATH",[89,1072,281],{"class":102},[89,1074,1075],{"class":258}," \"monitor.tcss\"\n",[89,1077,1078,1081,1083,1086,1089,1091,1094,1096,1099,1102,1105,1107,1110,1112,1115,1117,1120,1122,1125,1127,1129],{"class":91,"line":609},[89,1079,1080],{"class":106},"    BINDINGS",[89,1082,281],{"class":102},[89,1084,1085],{"class":113}," [(",[89,1087,1088],{"class":258},"\"q\"",[89,1090,361],{"class":113},[89,1092,1093],{"class":258},"\"quit\"",[89,1095,361],{"class":113},[89,1097,1098],{"class":258},"\"Quit\"",[89,1100,1101],{"class":113},"), (",[89,1103,1104],{"class":258},"\"r\"",[89,1106,361],{"class":113},[89,1108,1109],{"class":258},"\"refresh\"",[89,1111,361],{"class":113},[89,1113,1114],{"class":258},"\"Refresh\"",[89,1116,1101],{"class":113},[89,1118,1119],{"class":258},"\"c\"",[89,1121,361],{"class":113},[89,1123,1124],{"class":258},"\"cancel\"",[89,1126,361],{"class":113},[89,1128,961],{"class":258},[89,1130,1131],{"class":113},")]\n",[89,1133,1135],{"class":91,"line":1134},35,[89,1136,121],{"emptyLinePlaceholder":120},[89,1138,1140,1142,1144,1147,1150,1152,1155,1157,1159],{"class":91,"line":1139},36,[89,1141,270],{"class":102},[89,1143,273],{"class":106},[89,1145,1146],{"class":113},"(self, source: JobSource, interval: ",[89,1148,1149],{"class":106},"float",[89,1151,281],{"class":102},[89,1153,1154],{"class":106}," 2.0",[89,1156,287],{"class":113},[89,1158,290],{"class":106},[89,1160,188],{"class":113},[89,1162,1164,1166,1168,1170],{"class":91,"line":1163},37,[89,1165,860],{"class":106},[89,1167,863],{"class":113},[89,1169,866],{"class":106},[89,1171,869],{"class":113},[89,1173,1175,1177,1180,1182],{"class":91,"line":1174},38,[89,1176,298],{"class":106},[89,1178,1179],{"class":113},".source ",[89,1181,170],{"class":102},[89,1183,1184],{"class":113}," source\n",[89,1186,1188,1190,1193,1195],{"class":91,"line":1187},39,[89,1189,298],{"class":106},[89,1191,1192],{"class":113},".interval ",[89,1194,170],{"class":102},[89,1196,1197],{"class":113}," interval\n",[89,1199,1201],{"class":91,"line":1200},40,[89,1202,121],{"emptyLinePlaceholder":120},[89,1204,1206,1208,1210],{"class":91,"line":1205},41,[89,1207,270],{"class":102},[89,1209,892],{"class":159},[89,1211,895],{"class":113},[89,1213,1215,1218,1221,1224,1226,1228],{"class":91,"line":1214},42,[89,1216,1217],{"class":102},"        yield",[89,1219,1220],{"class":113}," Header(",[89,1222,1223],{"class":166},"show_clock",[89,1225,170],{"class":102},[89,1227,173],{"class":106},[89,1229,176],{"class":113},[89,1231,1233,1235,1238,1241,1243,1246,1248,1251,1253,1255],{"class":91,"line":1232},43,[89,1234,1217],{"class":102},[89,1236,1237],{"class":113}," DataTable(",[89,1239,1240],{"class":166},"cursor_type",[89,1242,170],{"class":102},[89,1244,1245],{"class":258},"\"row\"",[89,1247,361],{"class":113},[89,1249,1250],{"class":166},"zebra_stripes",[89,1252,170],{"class":102},[89,1254,173],{"class":106},[89,1256,176],{"class":113},[89,1258,1260,1262],{"class":91,"line":1259},44,[89,1261,1217],{"class":102},[89,1263,1264],{"class":113}," Footer()\n",[89,1266,1268],{"class":91,"line":1267},45,[89,1269,121],{"emptyLinePlaceholder":120},[89,1271,1273,1275,1278,1281,1283],{"class":91,"line":1272},46,[89,1274,270],{"class":102},[89,1276,1277],{"class":159}," on_mount",[89,1279,1280],{"class":113},"(self) -> ",[89,1282,290],{"class":106},[89,1284,188],{"class":113},[89,1286,1288,1290,1293,1295],{"class":91,"line":1287},47,[89,1289,298],{"class":106},[89,1291,1292],{"class":113},".title ",[89,1294,170],{"class":102},[89,1296,1297],{"class":258}," \"Job monitor\"\n",[89,1299,1301,1304,1306,1308],{"class":91,"line":1300},48,[89,1302,1303],{"class":113},"        table ",[89,1305,170],{"class":102},[89,1307,497],{"class":106},[89,1309,1310],{"class":113},".query_one(DataTable)\n",[89,1312,1314,1317,1320,1322,1325,1327,1330],{"class":91,"line":1313},49,[89,1315,1316],{"class":113},"        table.add_columns(",[89,1318,1319],{"class":258},"\"Job\"",[89,1321,361],{"class":113},[89,1323,1324],{"class":258},"\"State\"",[89,1326,361],{"class":113},[89,1328,1329],{"class":258},"\"Progress\"",[89,1331,176],{"class":113},[89,1333,1335,1338,1340],{"class":91,"line":1334},50,[89,1336,1337],{"class":113},"        table.loading ",[89,1339,170],{"class":102},[89,1341,1342],{"class":106}," True\n",[89,1344,1346,1348],{"class":91,"line":1345},51,[89,1347,298],{"class":106},[89,1349,1350],{"class":113},".action_refresh()\n",[89,1352,1354,1356,1359,1361,1364,1366],{"class":91,"line":1353},52,[89,1355,298],{"class":106},[89,1357,1358],{"class":113},".set_interval(",[89,1360,446],{"class":106},[89,1362,1363],{"class":113},".interval, ",[89,1365,446],{"class":106},[89,1367,1368],{"class":113},".action_refresh)\n",[89,1370,1372],{"class":91,"line":1371},53,[89,1373,121],{"emptyLinePlaceholder":120},[89,1375,1377,1379,1382,1384,1386],{"class":91,"line":1376},54,[89,1378,270],{"class":102},[89,1380,1381],{"class":159}," action_refresh",[89,1383,1280],{"class":113},[89,1385,290],{"class":106},[89,1387,188],{"class":113},[89,1389,1391,1393],{"class":91,"line":1390},55,[89,1392,298],{"class":106},[89,1394,1395],{"class":113},".load_jobs()\n",[89,1397,1399],{"class":91,"line":1398},56,[89,1400,121],{"emptyLinePlaceholder":120},[89,1402,1404,1407,1409,1412,1414,1416,1418,1421,1423,1425],{"class":91,"line":1403},57,[89,1405,1406],{"class":159},"    @work",[89,1408,163],{"class":113},[89,1410,1411],{"class":166},"exclusive",[89,1413,170],{"class":102},[89,1415,173],{"class":106},[89,1417,361],{"class":113},[89,1419,1420],{"class":166},"thread",[89,1422,170],{"class":102},[89,1424,173],{"class":106},[89,1426,176],{"class":113},[89,1428,1430,1432,1435,1437,1439],{"class":91,"line":1429},58,[89,1431,270],{"class":102},[89,1433,1434],{"class":159}," load_jobs",[89,1436,1280],{"class":113},[89,1438,290],{"class":106},[89,1440,188],{"class":113},[89,1442,1444,1447,1449,1451,1454],{"class":91,"line":1443},59,[89,1445,1446],{"class":113},"        jobs ",[89,1448,170],{"class":102},[89,1450,497],{"class":106},[89,1452,1453],{"class":113},".source.fetch()                 ",[89,1455,1456],{"class":95},"# blocking I\u002FO, off the event loop\n",[89,1458,1460,1462,1465,1467],{"class":91,"line":1459},60,[89,1461,298],{"class":106},[89,1463,1464],{"class":113},".call_from_thread(",[89,1466,446],{"class":106},[89,1468,1469],{"class":113},".show_jobs, jobs)\n",[89,1471,1473],{"class":91,"line":1472},61,[89,1474,121],{"emptyLinePlaceholder":120},[89,1476,1478,1480,1483,1486,1488],{"class":91,"line":1477},62,[89,1479,270],{"class":102},[89,1481,1482],{"class":159}," show_jobs",[89,1484,1485],{"class":113},"(self, jobs: list[Job]) -> ",[89,1487,290],{"class":106},[89,1489,188],{"class":113},[89,1491,1493,1495,1497,1499],{"class":91,"line":1492},63,[89,1494,1303],{"class":113},[89,1496,170],{"class":102},[89,1498,497],{"class":106},[89,1500,1310],{"class":113},[89,1502,1504,1506,1508],{"class":91,"line":1503},64,[89,1505,1337],{"class":113},[89,1507,170],{"class":102},[89,1509,1510],{"class":106}," False\n",[89,1512,1514,1517,1519],{"class":91,"line":1513},65,[89,1515,1516],{"class":113},"        cursor ",[89,1518,170],{"class":102},[89,1520,1521],{"class":113}," table.cursor_row\n",[89,1523,1525],{"class":91,"line":1524},66,[89,1526,1527],{"class":113},"        table.clear()\n",[89,1529,1531,1533,1536,1538],{"class":91,"line":1530},67,[89,1532,433],{"class":102},[89,1534,1535],{"class":113}," job ",[89,1537,378],{"class":102},[89,1539,1540],{"class":113}," jobs:\n",[89,1542,1544,1547,1549,1552],{"class":91,"line":1543},68,[89,1545,1546],{"class":113},"            style ",[89,1548,170],{"class":102},[89,1550,1551],{"class":106}," STATE_STYLE",[89,1553,1554],{"class":113},"[job.state]\n",[89,1556,1558,1561,1563,1566,1568,1571,1573,1576,1578,1581,1583,1586,1588,1590,1592,1594,1597,1600,1602,1605,1607,1610,1612],{"class":91,"line":1557},69,[89,1559,1560],{"class":113},"            table.add_row(job.name, ",[89,1562,322],{"class":102},[89,1564,1565],{"class":258},"\"[",[89,1567,328],{"class":106},[89,1569,1570],{"class":113},"style",[89,1572,334],{"class":106},[89,1574,1575],{"class":258},"]",[89,1577,328],{"class":106},[89,1579,1580],{"class":113},"job.state",[89,1582,334],{"class":106},[89,1584,1585],{"class":258},"[\u002F]\"",[89,1587,361],{"class":113},[89,1589,322],{"class":102},[89,1591,337],{"class":258},[89,1593,328],{"class":106},[89,1595,1596],{"class":113},"job.progress",[89,1598,1599],{"class":102},":>3",[89,1601,334],{"class":106},[89,1603,1604],{"class":258},"%\"",[89,1606,361],{"class":113},[89,1608,1609],{"class":166},"key",[89,1611,170],{"class":102},[89,1613,1614],{"class":113},"job.id)\n",[89,1616,1618,1621],{"class":91,"line":1617},70,[89,1619,1620],{"class":102},"        if",[89,1622,1540],{"class":113},[89,1624,1626,1629,1632,1634,1637,1640,1643,1646,1649,1651],{"class":91,"line":1625},71,[89,1627,1628],{"class":113},"            table.move_cursor(",[89,1630,1631],{"class":166},"row",[89,1633,170],{"class":102},[89,1635,1636],{"class":106},"min",[89,1638,1639],{"class":113},"(cursor, ",[89,1641,1642],{"class":106},"len",[89,1644,1645],{"class":113},"(jobs) ",[89,1647,1648],{"class":102},"-",[89,1650,284],{"class":106},[89,1652,511],{"class":113},[89,1654,1656,1659,1661,1664,1667,1669,1672,1675,1678,1680],{"class":91,"line":1655},72,[89,1657,1658],{"class":113},"        running ",[89,1660,170],{"class":102},[89,1662,1663],{"class":106}," sum",[89,1665,1666],{"class":113},"(j.state ",[89,1668,531],{"class":102},[89,1670,1671],{"class":258}," \"running\"",[89,1673,1674],{"class":102}," for",[89,1676,1677],{"class":113}," j ",[89,1679,378],{"class":102},[89,1681,1682],{"class":113}," jobs)\n",[89,1684,1686,1688,1691,1693,1696,1698,1700,1703,1705,1708,1711,1714,1716],{"class":91,"line":1685},73,[89,1687,298],{"class":106},[89,1689,1690],{"class":113},".sub_title ",[89,1692,170],{"class":102},[89,1694,1695],{"class":102}," f",[89,1697,337],{"class":258},[89,1699,328],{"class":106},[89,1701,1702],{"class":113},"running",[89,1704,334],{"class":106},[89,1706,1707],{"class":258}," running, ",[89,1709,1710],{"class":106},"{len",[89,1712,1713],{"class":113},"(jobs)",[89,1715,334],{"class":106},[89,1717,1718],{"class":258}," total\"\n",[89,1720,1722],{"class":91,"line":1721},74,[89,1723,121],{"emptyLinePlaceholder":120},[89,1725,1727,1729,1732,1734,1736],{"class":91,"line":1726},75,[89,1728,270],{"class":102},[89,1730,1731],{"class":159}," action_cancel",[89,1733,1280],{"class":113},[89,1735,290],{"class":106},[89,1737,188],{"class":113},[89,1739,1741,1743,1745,1747],{"class":91,"line":1740},76,[89,1742,1303],{"class":113},[89,1744,170],{"class":102},[89,1746,497],{"class":106},[89,1748,1310],{"class":113},[89,1750,1752,1754,1757,1759,1762],{"class":91,"line":1751},77,[89,1753,1620],{"class":102},[89,1755,1756],{"class":113}," table.row_count ",[89,1758,531],{"class":102},[89,1760,1761],{"class":106}," 0",[89,1763,188],{"class":113},[89,1765,1767],{"class":91,"line":1766},78,[89,1768,1769],{"class":102},"            return\n",[89,1771,1773,1776,1778],{"class":91,"line":1772},79,[89,1774,1775],{"class":113},"        job_id ",[89,1777,170],{"class":102},[89,1779,1780],{"class":113}," table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value\n",[89,1782,1784,1786,1788,1791,1794,1797,1799,1801,1803,1806,1809,1812,1814],{"class":91,"line":1783},80,[89,1785,599],{"class":113},[89,1787,170],{"class":102},[89,1789,1790],{"class":106}," next",[89,1792,1793],{"class":113},"(j ",[89,1795,1796],{"class":102},"for",[89,1798,1677],{"class":113},[89,1800,378],{"class":102},[89,1802,497],{"class":106},[89,1804,1805],{"class":113},".source.fetch() ",[89,1807,1808],{"class":102},"if",[89,1810,1811],{"class":113}," j.id ",[89,1813,531],{"class":102},[89,1815,1816],{"class":113}," job_id)\n",[89,1818,1820],{"class":91,"line":1819},81,[89,1821,121],{"emptyLinePlaceholder":120},[89,1823,1825,1828,1831,1834,1836,1839,1842,1844,1846],{"class":91,"line":1824},82,[89,1826,1827],{"class":102},"        def",[89,1829,1830],{"class":159}," done",[89,1832,1833],{"class":113},"(confirmed: ",[89,1835,830],{"class":106},[89,1837,1838],{"class":102}," |",[89,1840,1841],{"class":106}," None",[89,1843,287],{"class":113},[89,1845,290],{"class":106},[89,1847,188],{"class":113},[89,1849,1851,1853],{"class":91,"line":1850},83,[89,1852,455],{"class":102},[89,1854,1855],{"class":113}," confirmed:\n",[89,1857,1859,1861],{"class":91,"line":1858},84,[89,1860,546],{"class":106},[89,1862,1863],{"class":113},".source.cancel(job_id)\n",[89,1865,1867,1869,1872,1874,1877,1879,1882,1884,1886],{"class":91,"line":1866},85,[89,1868,546],{"class":106},[89,1870,1871],{"class":113},".notify(",[89,1873,322],{"class":102},[89,1875,1876],{"class":258},"\"cancelled ",[89,1878,328],{"class":106},[89,1880,1881],{"class":113},"job.name",[89,1883,334],{"class":106},[89,1885,337],{"class":258},[89,1887,176],{"class":113},[89,1889,1891,1893],{"class":91,"line":1890},86,[89,1892,546],{"class":106},[89,1894,1350],{"class":113},[89,1896,1898],{"class":91,"line":1897},87,[89,1899,121],{"emptyLinePlaceholder":120},[89,1901,1903,1905],{"class":91,"line":1902},88,[89,1904,298],{"class":106},[89,1906,1907],{"class":113},".push_screen(ConfirmCancel(job), done)\n",[80,1909,1913],{"className":1910,"code":1911,"language":1912,"meta":85,"style":85},"language-css shiki shiki-themes github-light github-dark","\u002F* src\u002Fjobs\u002Fmonitor.tcss *\u002F\nDataTable {\n    height: 1fr;\n}\n\nConfirmCancel {\n    align: center middle;\n}\n\n#dialog {\n    grid-size: 2;\n    grid-gutter: 1 2;\n    padding: 1 2;\n    width: 50;\n    height: 11;\n    border: thick $accent;\n    background: $surface;\n}\n\n#question {\n    column-span: 2;\n    width: 100%;\n    content-align: center middle;\n}\n","css",[19,1914,1915,1920,1925,1930,1934,1938,1943,1948,1952,1956,1961,1966,1971,1976,1981,1986,1991,1996,2000,2004,2009,2014,2019,2024],{"__ignoreMap":85},[89,1916,1917],{"class":91,"line":92},[89,1918,1919],{},"\u002F* src\u002Fjobs\u002Fmonitor.tcss *\u002F\n",[89,1921,1922],{"class":91,"line":99},[89,1923,1924],{},"DataTable {\n",[89,1926,1927],{"class":91,"line":117},[89,1928,1929],{},"    height: 1fr;\n",[89,1931,1932],{"class":91,"line":124},[89,1933,809],{},[89,1935,1936],{"class":91,"line":133},[89,1937,121],{"emptyLinePlaceholder":120},[89,1939,1940],{"class":91,"line":146},[89,1941,1942],{},"ConfirmCancel {\n",[89,1944,1945],{"class":91,"line":151},[89,1946,1947],{},"    align: center middle;\n",[89,1949,1950],{"class":91,"line":156},[89,1951,809],{},[89,1953,1954],{"class":91,"line":179},[89,1955,121],{"emptyLinePlaceholder":120},[89,1957,1958],{"class":91,"line":191},[89,1959,1960],{},"#dialog {\n",[89,1962,1963],{"class":91,"line":203},[89,1964,1965],{},"    grid-size: 2;\n",[89,1967,1968],{"class":91,"line":211},[89,1969,1970],{},"    grid-gutter: 1 2;\n",[89,1972,1973],{"class":91,"line":223},[89,1974,1975],{},"    padding: 1 2;\n",[89,1977,1978],{"class":91,"line":235},[89,1979,1980],{},"    width: 50;\n",[89,1982,1983],{"class":91,"line":240},[89,1984,1985],{},"    height: 11;\n",[89,1987,1988],{"class":91,"line":245},[89,1989,1990],{},"    border: thick $accent;\n",[89,1992,1993],{"class":91,"line":255},[89,1994,1995],{},"    background: $surface;\n",[89,1997,1998],{"class":91,"line":262},[89,1999,809],{},[89,2001,2002],{"class":91,"line":267},[89,2003,121],{"emptyLinePlaceholder":120},[89,2005,2006],{"class":91,"line":295},[89,2007,2008],{},"#question {\n",[89,2010,2011],{"class":91,"line":309},[89,2012,2013],{},"    column-span: 2;\n",[89,2015,2016],{"class":91,"line":369},[89,2017,2018],{},"    width: 100%;\n",[89,2020,2021],{"class":91,"line":414},[89,2022,2023],{},"    content-align: center middle;\n",[89,2025,2026],{"class":91,"line":419},[89,2027,809],{},[10,2029,2030,2034,2035,2038,2039,632,2042,2045,2046,2049],{},[14,2031,2032],{},[19,2033,650],{}," yields a header with a clock, a ",[19,2036,2037],{},"DataTable"," and a footer. ",[19,2040,2041],{},"Header",[19,2043,2044],{},"Footer"," are docked to the top and bottom by default; the table fills the rest because the stylesheet gives it ",[19,2047,2048],{},"height: 1fr",". The footer shows the app's key bindings automatically, so users can see how to quit, refresh and cancel.",[10,2051,2052,2057,2058,2061,2062,2065],{},[14,2053,2054],{},[19,2055,2056],{},"CSS_PATH"," points at a ",[19,2059,2060],{},".tcss"," file next to the module. Keeping styles out of Python means layout tweaks do not touch behaviour, and ",[19,2063,2064],{},"textual run --dev jobs.app:JobMonitor"," reloads the stylesheet live while you edit it.",[30,2067,2069],{"id":2068},"step-3-load-data-without-freezing","Step 3: load data without freezing",[10,2071,2072,2073,2076],{},"A terminal UI must keep responding to keys while data loads. Textual runs on an asyncio event loop, so a blocking call — an HTTP request, a slow file read — inside a handler freezes everything. ",[14,2074,2075],{},"Workers"," move that work off the loop:",[35,2078,2079,2093,2100],{},[38,2080,2081,2084,2085,2088,2089,2092],{},[19,2082,2083],{},"@work(exclusive=True, thread=True)"," runs ",[19,2086,2087],{},"load_jobs"," in a thread. ",[19,2090,2091],{},"exclusive=True"," cancels a previous load still in flight, so rapid refreshes never pile up.",[38,2094,2095,2096,2099],{},"The worker cannot touch widgets directly from its thread; ",[19,2097,2098],{},"self.call_from_thread(self.show_jobs, jobs)"," schedules the update back on the event loop.",[38,2101,2102,2105],{},[19,2103,2104],{},"table.loading = True"," shows Textual's built-in loading indicator until the first result arrives.",[10,2107,2108,2109,2112,2113,2116,2117,2120,2121,2124],{},"For async clients such as ",[19,2110,2111],{},"httpx.AsyncClient",", use ",[19,2114,2115],{},"@work(exclusive=True)"," on an ",[19,2118,2119],{},"async def"," method instead and await the call directly; no thread is needed. Either way, the table refreshes in place: ",[19,2122,2123],{},"show_jobs"," remembers the cursor row, clears and refills the table, and puts the cursor back, so a refresh every two seconds does not yank the user's selection away.",[10,2126,2127,2130,2131,2134],{},[19,2128,2129],{},"self.set_interval(self.interval, self.action_refresh)"," in ",[19,2132,2133],{},"on_mount"," sets up the periodic refresh. Keep intervals at seconds rather than milliseconds — a monitor that refreshes ten times a second burns CPU for no benefit a human can see.",[30,2136,2138],{"id":2137},"step-4-react-to-keys-and-messages","Step 4: react to keys and messages",[653,2140],{"name":2141},"tx-events",[10,2143,2144,2145,2148,2149,361,2152,361,2155,2158,2159,2162],{},"Textual delivers user input as ",[14,2146,2147],{},"messages",". Widgets post them — ",[19,2150,2151],{},"DataTable.RowHighlighted",[19,2153,2154],{},"Button.Pressed",[19,2156,2157],{},"Input.Changed"," — and they bubble up the DOM until a handler named after the message handles them. ",[19,2160,2161],{},"on_button_pressed"," in the modal screen is one such handler.",[10,2164,2165,2168,2169,2172,2173,2176,2177,2180,2181,2184],{},[14,2166,2167],{},"Bindings"," connect keys to ",[14,2170,2171],{},"actions",": the tuple ",[19,2174,2175],{},"(\"c\", \"cancel\", \"Cancel job\")"," calls ",[19,2178,2179],{},"action_cancel"," when ",[19,2182,2183],{},"c"," is pressed and labels it in the footer. Choose bindings users already know from other terminal tools:",[653,2186],{"name":2187},"tx-bindings",[30,2189,2191],{"id":2190},"step-5-confirm-destructive-actions-in-a-modal","Step 5: confirm destructive actions in a modal",[10,2193,2194,2195,2198,2199,2202,2203,2206,2207,2210,2211,2214,2215,2219],{},"Cancelling a job cannot be undone, so the monitor asks first. ",[19,2196,2197],{},"ConfirmCancel"," is a ",[19,2200,2201],{},"ModalScreen[bool]"," — a screen that dims what is underneath, captures focus, and returns a boolean. ",[19,2204,2205],{},"push_screen(ConfirmCancel(job), done)"," shows it and registers ",[19,2208,2209],{},"done"," as the callback that receives the result of ",[19,2212,2213],{},"dismiss(...)",". This is the TUI version of the confirm-before-destroying pattern in ",[24,2216,2218],{"href":2217},"\u002Fmodern-python-cli-frameworks-architecture\u002Fdesigning-cli-interfaces-and-conventions\u002Fadding-dry-run-and-confirmation-to-destructive-commands\u002F","adding dry-run and confirmation to destructive commands",": the safe option is the primary button, and pressing Escape or clicking \"Keep running\" leaves everything as it was.",[30,2221,2223],{"id":2222},"step-6-run-it-from-the-cli","Step 6: run it from the CLI",[10,2225,2226],{},"The monitor is one command in a larger CLI. Import Textual lazily so every other command stays fast, and refuse to start without a terminal:",[80,2228,2230],{"className":82,"code":2229,"language":84,"meta":85,"style":85},"# src\u002Fjobs\u002Fcli.py\nimport sys\n\nimport typer\n\napp = typer.Typer()\n\n\n@app.callback()\ndef main() -> None:\n    \"\"\"Job tools.\"\"\"\n\n\n@app.command()\ndef watch(interval: float = typer.Option(2.0, min=0.5, help=\"Seconds between refreshes.\")) -> None:\n    \"\"\"Watch jobs live (q to quit).\"\"\"\n    if not sys.stdout.isatty():\n        typer.echo(\"error: watch needs a terminal; use 'jobs list --json' in scripts\", err=True)\n        raise typer.Exit(2)\n    from jobs.app import JobMonitor\n    from jobs.source import JobSource\n\n    JobMonitor(JobSource(), interval=interval).run()\n",[19,2231,2232,2237,2244,2248,2255,2259,2269,2273,2277,2284,2299,2304,2308,2312,2319,2365,2370,2381,2400,2413,2426,2437,2441],{"__ignoreMap":85},[89,2233,2234],{"class":91,"line":92},[89,2235,2236],{"class":95},"# src\u002Fjobs\u002Fcli.py\n",[89,2238,2239,2241],{"class":91,"line":99},[89,2240,127],{"class":102},[89,2242,2243],{"class":113}," sys\n",[89,2245,2246],{"class":91,"line":117},[89,2247,121],{"emptyLinePlaceholder":120},[89,2249,2250,2252],{"class":91,"line":124},[89,2251,127],{"class":102},[89,2253,2254],{"class":113}," typer\n",[89,2256,2257],{"class":91,"line":133},[89,2258,121],{"emptyLinePlaceholder":120},[89,2260,2261,2264,2266],{"class":91,"line":146},[89,2262,2263],{"class":113},"app ",[89,2265,170],{"class":102},[89,2267,2268],{"class":113}," typer.Typer()\n",[89,2270,2271],{"class":91,"line":151},[89,2272,121],{"emptyLinePlaceholder":120},[89,2274,2275],{"class":91,"line":156},[89,2276,121],{"emptyLinePlaceholder":120},[89,2278,2279,2282],{"class":91,"line":179},[89,2280,2281],{"class":159},"@app.callback",[89,2283,869],{"class":113},[89,2285,2286,2289,2292,2295,2297],{"class":91,"line":191},[89,2287,2288],{"class":102},"def",[89,2290,2291],{"class":159}," main",[89,2293,2294],{"class":113},"() -> ",[89,2296,290],{"class":106},[89,2298,188],{"class":113},[89,2300,2301],{"class":91,"line":203},[89,2302,2303],{"class":258},"    \"\"\"Job tools.\"\"\"\n",[89,2305,2306],{"class":91,"line":211},[89,2307,121],{"emptyLinePlaceholder":120},[89,2309,2310],{"class":91,"line":223},[89,2311,121],{"emptyLinePlaceholder":120},[89,2313,2314,2317],{"class":91,"line":235},[89,2315,2316],{"class":159},"@app.command",[89,2318,869],{"class":113},[89,2320,2321,2323,2326,2329,2331,2333,2336,2339,2341,2343,2345,2348,2350,2353,2355,2358,2361,2363],{"class":91,"line":240},[89,2322,2288],{"class":102},[89,2324,2325],{"class":159}," watch",[89,2327,2328],{"class":113},"(interval: ",[89,2330,1149],{"class":106},[89,2332,281],{"class":102},[89,2334,2335],{"class":113}," typer.Option(",[89,2337,2338],{"class":106},"2.0",[89,2340,361],{"class":113},[89,2342,1636],{"class":166},[89,2344,170],{"class":102},[89,2346,2347],{"class":106},"0.5",[89,2349,361],{"class":113},[89,2351,2352],{"class":166},"help",[89,2354,170],{"class":102},[89,2356,2357],{"class":258},"\"Seconds between refreshes.\"",[89,2359,2360],{"class":113},")) -> ",[89,2362,290],{"class":106},[89,2364,188],{"class":113},[89,2366,2367],{"class":91,"line":245},[89,2368,2369],{"class":258},"    \"\"\"Watch jobs live (q to quit).\"\"\"\n",[89,2371,2372,2375,2378],{"class":91,"line":255},[89,2373,2374],{"class":102},"    if",[89,2376,2377],{"class":102}," not",[89,2379,2380],{"class":113}," sys.stdout.isatty():\n",[89,2382,2383,2386,2389,2391,2394,2396,2398],{"class":91,"line":262},[89,2384,2385],{"class":113},"        typer.echo(",[89,2387,2388],{"class":258},"\"error: watch needs a terminal; use 'jobs list --json' in scripts\"",[89,2390,361],{"class":113},[89,2392,2393],{"class":166},"err",[89,2395,170],{"class":102},[89,2397,173],{"class":106},[89,2399,176],{"class":113},[89,2401,2402,2405,2408,2411],{"class":91,"line":267},[89,2403,2404],{"class":102},"        raise",[89,2406,2407],{"class":113}," typer.Exit(",[89,2409,2410],{"class":106},"2",[89,2412,176],{"class":113},[89,2414,2415,2418,2421,2423],{"class":91,"line":295},[89,2416,2417],{"class":102},"    from",[89,2419,2420],{"class":113}," jobs.app ",[89,2422,127],{"class":102},[89,2424,2425],{"class":113}," JobMonitor\n",[89,2427,2428,2430,2432,2434],{"class":91,"line":309},[89,2429,2417],{"class":102},[89,2431,753],{"class":113},[89,2433,127],{"class":102},[89,2435,2436],{"class":113}," JobSource\n",[89,2438,2439],{"class":91,"line":369},[89,2440,121],{"emptyLinePlaceholder":120},[89,2442,2443,2446,2449,2451],{"class":91,"line":414},[89,2444,2445],{"class":113},"    JobMonitor(JobSource(), ",[89,2447,2448],{"class":166},"interval",[89,2450,170],{"class":102},[89,2452,2453],{"class":113},"interval).run()\n",[30,2455,2457],{"id":2456},"ux-considerations","UX considerations",[35,2459,2460,2470,2476,2482,2496],{},[38,2461,2462,2465,2466,2469],{},[14,2463,2464],{},"Show state with more than colour."," The state column says \"failed\" in red, not just a red row, so it reads correctly with ",[19,2467,2468],{},"NO_COLOR"," or for colour-blind users.",[38,2471,2472,2475],{},[14,2473,2474],{},"Keep the cursor stable across refreshes."," Nothing is more irritating than a live view that resets the selection every two seconds.",[38,2477,2478,2481],{},[14,2479,2480],{},"Put a summary in the header."," \"1 running, 4 total\" in the subtitle answers the most common question without scanning the table.",[38,2483,2484,2487,2488,2491,2492,2495],{},[14,2485,2486],{},"Notify, do not print."," ",[19,2489,2490],{},"self.notify(...)"," shows a transient toast; ",[19,2493,2494],{},"print"," would corrupt the screen.",[38,2497,2498,2487,2501,2504],{},[14,2499,2500],{},"Make leaving obvious.",[19,2502,2503],{},"q"," in the footer, Ctrl+C always, and the terminal restored exactly as it was on exit.",[30,2506,2508],{"id":2507},"testing-the-behaviour","Testing the behaviour",[10,2510,2511,2512,2515],{},"Textual's ",[19,2513,2514],{},"run_test"," starts the app headless, and the Pilot drives it. This test waits for the first load, checks the table, cancels a job through the modal and confirms the change reached the table:",[80,2517,2519],{"className":82,"code":2518,"language":84,"meta":85,"style":85},"# tests\u002Ftest_app.py\nfrom textual.widgets import DataTable\nfrom jobs.app import ConfirmCancel, JobMonitor\nfrom jobs.source import JobSource\n\nasync def test_loads_and_cancels():\n    app = JobMonitor(JobSource(seed=1), interval=60)\n    async with app.run_test(size=(80, 24)) as pilot:\n        await app.workers.wait_for_complete()\n        await pilot.pause()\n        table = app.query_one(DataTable)\n        assert table.row_count == 4\n        await pilot.press(\"c\")\n        await pilot.pause()\n        assert isinstance(app.screen, ConfirmCancel)\n        await pilot.click(\"#yes\")\n        await pilot.pause()\n        await app.workers.wait_for_complete()\n        await pilot.pause()\n        assert \"failed\" in str(table.get_row_at(0)[1])\n",[19,2520,2521,2526,2537,2548,2558,2562,2576,2605,2640,2648,2655,2664,2676,2687,2693,2703,2715,2721,2727,2733],{"__ignoreMap":85},[89,2522,2523],{"class":91,"line":92},[89,2524,2525],{"class":95},"# tests\u002Ftest_app.py\n",[89,2527,2528,2530,2532,2534],{"class":91,"line":99},[89,2529,103],{"class":102},[89,2531,737],{"class":113},[89,2533,127],{"class":102},[89,2535,2536],{"class":113}," DataTable\n",[89,2538,2539,2541,2543,2545],{"class":91,"line":117},[89,2540,103],{"class":102},[89,2542,2420],{"class":113},[89,2544,127],{"class":102},[89,2546,2547],{"class":113}," ConfirmCancel, JobMonitor\n",[89,2549,2550,2552,2554,2556],{"class":91,"line":124},[89,2551,103],{"class":102},[89,2553,753],{"class":113},[89,2555,127],{"class":102},[89,2557,2436],{"class":113},[89,2559,2560],{"class":91,"line":133},[89,2561,121],{"emptyLinePlaceholder":120},[89,2563,2564,2567,2570,2573],{"class":91,"line":146},[89,2565,2566],{"class":102},"async",[89,2568,2569],{"class":102}," def",[89,2571,2572],{"class":159}," test_loads_and_cancels",[89,2574,2575],{"class":113},"():\n",[89,2577,2578,2581,2583,2586,2589,2591,2593,2596,2598,2600,2603],{"class":91,"line":151},[89,2579,2580],{"class":113},"    app ",[89,2582,170],{"class":102},[89,2584,2585],{"class":113}," JobMonitor(JobSource(",[89,2587,2588],{"class":166},"seed",[89,2590,170],{"class":102},[89,2592,408],{"class":106},[89,2594,2595],{"class":113},"), ",[89,2597,2448],{"class":166},[89,2599,170],{"class":102},[89,2601,2602],{"class":106},"60",[89,2604,176],{"class":113},[89,2606,2607,2610,2613,2616,2619,2621,2623,2626,2628,2631,2634,2637],{"class":91,"line":156},[89,2608,2609],{"class":102},"    async",[89,2611,2612],{"class":102}," with",[89,2614,2615],{"class":113}," app.run_test(",[89,2617,2618],{"class":166},"size",[89,2620,170],{"class":102},[89,2622,163],{"class":113},[89,2624,2625],{"class":106},"80",[89,2627,361],{"class":113},[89,2629,2630],{"class":106},"24",[89,2632,2633],{"class":113},")) ",[89,2635,2636],{"class":102},"as",[89,2638,2639],{"class":113}," pilot:\n",[89,2641,2642,2645],{"class":91,"line":179},[89,2643,2644],{"class":102},"        await",[89,2646,2647],{"class":113}," app.workers.wait_for_complete()\n",[89,2649,2650,2652],{"class":91,"line":191},[89,2651,2644],{"class":102},[89,2653,2654],{"class":113}," pilot.pause()\n",[89,2656,2657,2659,2661],{"class":91,"line":203},[89,2658,1303],{"class":113},[89,2660,170],{"class":102},[89,2662,2663],{"class":113}," app.query_one(DataTable)\n",[89,2665,2666,2669,2671,2673],{"class":91,"line":211},[89,2667,2668],{"class":102},"        assert",[89,2670,1756],{"class":113},[89,2672,531],{"class":102},[89,2674,2675],{"class":106}," 4\n",[89,2677,2678,2680,2683,2685],{"class":91,"line":223},[89,2679,2644],{"class":102},[89,2681,2682],{"class":113}," pilot.press(",[89,2684,1119],{"class":258},[89,2686,176],{"class":113},[89,2688,2689,2691],{"class":91,"line":235},[89,2690,2644],{"class":102},[89,2692,2654],{"class":113},[89,2694,2695,2697,2700],{"class":91,"line":240},[89,2696,2668],{"class":102},[89,2698,2699],{"class":106}," isinstance",[89,2701,2702],{"class":113},"(app.screen, ConfirmCancel)\n",[89,2704,2705,2707,2710,2713],{"class":91,"line":245},[89,2706,2644],{"class":102},[89,2708,2709],{"class":113}," pilot.click(",[89,2711,2712],{"class":258},"\"#yes\"",[89,2714,176],{"class":113},[89,2716,2717,2719],{"class":91,"line":255},[89,2718,2644],{"class":102},[89,2720,2654],{"class":113},[89,2722,2723,2725],{"class":91,"line":262},[89,2724,2644],{"class":102},[89,2726,2647],{"class":113},[89,2728,2729,2731],{"class":91,"line":267},[89,2730,2644],{"class":102},[89,2732,2654],{"class":113},[89,2734,2735,2737,2740,2743,2746,2749,2751,2754,2756],{"class":91,"line":295},[89,2736,2668],{"class":102},[89,2738,2739],{"class":258}," \"failed\"",[89,2741,2742],{"class":102}," in",[89,2744,2745],{"class":106}," str",[89,2747,2748],{"class":113},"(table.get_row_at(",[89,2750,364],{"class":106},[89,2752,2753],{"class":113},")[",[89,2755,408],{"class":106},[89,2757,2758],{"class":113},"])\n",[10,2760,2761,2762,2765,2766,2769,2770,2773,2774,2777,2778,2781,2782,52],{},"The fake source is the real ",[19,2763,2764],{},"JobSource"," with a fixed seed, so results are deterministic; ",[19,2767,2768],{},"interval=60"," keeps the timer from firing during the test; and ",[19,2771,2772],{},"app.workers.wait_for_complete()"," waits for background loads instead of sleeping. The async test needs ",[19,2775,2776],{},"pytest-asyncio"," with ",[19,2779,2780],{},"asyncio_mode = \"auto\""," (or anyio's plugin). The full testing approach, including snapshot tests of the rendered screen, is in ",[24,2783,2785],{"href":2784},"\u002Fadvanced-input-parsing-user-experience\u002Fbuilding-terminal-uis-with-textual\u002Ftesting-textual-apps-with-pilot\u002F","testing Textual apps with Pilot",[30,2787,2789],{"id":2788},"conclusion","Conclusion",[10,2791,2792],{},"A first Textual app for a CLI comes together from a small set of ideas: keep data behind a plain class, compose widgets and style them with TCSS, load data in exclusive workers so the interface never freezes, refresh on a sensible timer while preserving the cursor, bind conventional keys to actions, and confirm destructive actions in a modal screen. Launch it from a lazily importing CLI command that refuses to run without a terminal, and test it headless with the Pilot. From here, more screens, filtering and live logs are variations on the same pieces.",[30,2794,2796],{"id":2795},"frequently-asked-questions","Frequently asked questions",[2798,2799,2801],"h3",{"id":2800},"why-use-a-thread-worker-instead-of-async","Why use a thread worker instead of async?",[10,2803,2804,2805,2808],{},"Only because the data source here is synchronous. With an async client, an async worker avoids threads entirely. Thread workers are the right tool for blocking libraries — many SDKs, database drivers and ",[19,2806,2807],{},"subprocess"," calls.",[2798,2810,2812],{"id":2811},"how-do-i-show-a-log-of-events-below-the-table","How do I show a log of events below the table?",[10,2814,2815,2816,2819,2820,2823,2824,2826,2827,2830,2831,2833],{},"Add a ",[19,2817,2818],{},"Log"," or ",[19,2821,2822],{},"RichLog"," widget in ",[19,2825,650],{},", give it a height in the stylesheet, and call ",[19,2828,2829],{},"write_line()"," from ",[19,2832,2123],{}," when states change. It scrolls automatically and keeps a bounded history.",[2798,2835,2837],{"id":2836},"can-the-monitor-accept-command-line-options-like-a-filter","Can the monitor accept command-line options like a filter?",[10,2839,2840,2841,2843],{},"Yes — pass them to the app's constructor from the Typer command, exactly as ",[19,2842,2448],{}," is passed here. The app stores them and uses them when loading or rendering.",[2798,2845,2847],{"id":2846},"how-do-i-make-it-look-right-in-light-terminals","How do I make it look right in light terminals?",[10,2849,2850,2851,361,2854,361,2857,2860],{},"Use Textual's theme variables (",[19,2852,2853],{},"$accent",[19,2855,2856],{},"$surface",[19,2858,2859],{},"$panel",") in CSS rather than fixed colours, and let users switch themes with the command palette (Ctrl+P). Textual ships light and dark themes that adapt the whole interface.",[30,2862,2864],{"id":2863},"related","Related",[35,2866,2867,2873,2878,2884,2890],{},[38,2868,2869,2870],{},"Up: ",[24,2871,2872],{"href":26},"Building terminal UIs with Textual",[38,2874,2875],{},[24,2876,2877],{"href":2784},"Testing Textual apps with Pilot",[38,2879,2880],{},[24,2881,2883],{"href":2882},"\u002Fadvanced-input-parsing-user-experience\u002Fbuilding-terminal-uis-with-textual\u002Fchoosing-between-a-cli-a-prompt-flow-and-a-tui\u002F","Choosing between a CLI, a prompt flow and a TUI",[38,2885,2886],{},[24,2887,2889],{"href":2888},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Flive-dashboards-with-rich-live\u002F","Live dashboards with Rich Live",[38,2891,2892],{},[24,2893,2895],{"href":2894},"\u002Fcli-runtime-systems-integration\u002Fconcurrency-and-async-in-python-clis\u002Frunning-async-code-in-typer-and-click\u002F","Running async code in Typer and Click",[1570,2897,2898],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":85,"searchDepth":99,"depth":99,"links":2900},[2901,2902,2903,2904,2905,2906,2907,2908,2909,2910,2911,2917],{"id":32,"depth":99,"text":33},{"id":66,"depth":99,"text":67},{"id":639,"depth":99,"text":640},{"id":2068,"depth":99,"text":2069},{"id":2137,"depth":99,"text":2138},{"id":2190,"depth":99,"text":2191},{"id":2222,"depth":99,"text":2223},{"id":2456,"depth":99,"text":2457},{"id":2507,"depth":99,"text":2508},{"id":2788,"depth":99,"text":2789},{"id":2795,"depth":99,"text":2796,"children":2912},[2913,2914,2915,2916],{"id":2800,"depth":117,"text":2801},{"id":2811,"depth":117,"text":2812},{"id":2836,"depth":117,"text":2837},{"id":2846,"depth":117,"text":2847},{"id":2863,"depth":99,"text":2864},"2026-09-18","Build a Textual job monitor step by step: compose widgets, style with TCSS, load data in workers, refresh on a timer, bind keys and confirm actions in a modal screen.","intermediate",false,"md",{},"\u002Fadvanced-input-parsing-user-experience\u002Fbuilding-terminal-uis-with-textual\u002Fbuilding-your-first-textual-app",{"title":5,"description":2919},"advanced-input-parsing-user-experience\u002Fbuilding-terminal-uis-with-textual\u002Fbuilding-your-first-textual-app\u002Findex",[2928,2929,2930,2931],"textual","tui","terminal-ux","asyncio","jFOAQeAY_LAv5nM-iEamL10ltMIDmRnpkGd_faXDKH8",[2934,2937,2940,2943,2946,2949,2952,2953,2956,2959,2962,2965,2968,2971,2974,2977,2980,2983,2986,2989,2992,2995,2998,3001,3004,3007,3010,3013,3016,3019,3022,3025,3028,3031,3034,3037,3040,3043,3046,3049,3052,3055,3058,3061,3064,3067,3070,3073,3076,3079,3082,3085,3088,3091,3094,3097,3100,3103,3106,3109,3112,3115,3118,3121,3124,3127,3130,3133,3136,3139,3142,3145,3148,3151,3154,3157,3160,3163,3166,3169,3172,3175,3178,3181,3184,3187,3190,3193,3196,3199,3202,3205,3208,3211,3214,3217,3220,3223,3226,3229,3232,3235,3238,3241,3244,3247,3250,3253,3256,3259,3262,3265,3268,3271,3274,3277,3280,3283,3286,3289,3292,3295,3298,3301,3304,3307,3310,3313,3316,3319,3322,3325,3328,3331,3334,3337,3340,3343,3346,3349,3352,3355,3358,3361,3364,3367,3370,3373,3376,3379,3382,3385,3388,3391,3394,3397,3400,3403,3406,3409,3412,3415,3418,3421,3424,3427,3430,3433,3436,3439,3442,3445,3448,3451,3454,3457,3460,3463,3466,3469,3472,3475,3478],{"path":2935,"title":2936},"\u002Fabout","About Python CLI Toolcraft",{"path":2938,"title":2939},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies","Advanced Argument Validation Strategies",{"path":2941,"title":2942},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fparsing-nested-json-arguments-in-python-clis","Parsing Nested JSON Args in Python CLIs",{"path":2944,"title":2945},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fvalidating-dependent-and-conflicting-options","Validating Dependent and Conflicting CLI Options",{"path":2947,"title":2948},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fvalidating-file-and-directory-paths-in-clis","Validating File and Directory Paths in CLIs",{"path":2950,"title":2951},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fwriting-custom-click-parameter-types","Writing Custom Click Parameter Types",{"path":2924,"title":5},{"path":2954,"title":2955},"\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":2957,"title":2958},"\u002Fadvanced-input-parsing-user-experience\u002Fbuilding-terminal-uis-with-textual","Building Terminal UIs with Textual for Python CLIs",{"path":2960,"title":2961},"\u002Fadvanced-input-parsing-user-experience\u002Fbuilding-terminal-uis-with-textual\u002Ftesting-textual-apps-with-pilot","Testing Textual Apps with Pilot",{"path":2963,"title":2964},"\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":2966,"title":2967},"\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":2969,"title":2970},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation","CLI Help Output and Documentation",{"path":2972,"title":2973},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fversioning-and-deprecating-cli-flags","Versioning and Deprecating CLI Flags",{"path":2975,"title":2976},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fwriting-help-text-users-actually-read","Writing Help Text Users Actually Read",{"path":2978,"title":2979},"\u002Fadvanced-input-parsing-user-experience\u002Fcross-platform-terminal-compatibility\u002Fadapting-output-to-terminal-width","Adapting Python CLI Output to Terminal Width",{"path":2981,"title":2982},"\u002Fadvanced-input-parsing-user-experience\u002Fcross-platform-terminal-compatibility\u002Fdetecting-ci-environments-and-non-interactive-shells","Detecting CI Environments and Non-Interactive Shells",{"path":2984,"title":2985},"\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":2987,"title":2988},"\u002Fadvanced-input-parsing-user-experience\u002Fcross-platform-terminal-compatibility","Cross-Platform Terminal Compatibility for Python CLIs",{"path":2990,"title":2991},"\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":2993,"title":2994},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Fchoosing-exit-codes-for-cli-tools","Choosing Exit Codes for CLI Tools",{"path":2996,"title":2997},"\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":2999,"title":3000},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Ffriendly-error-messages-and-tracebacks","Friendly Error Messages and Tracebacks",{"path":3002,"title":3003},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Fhandling-keyboard-interrupt-cleanly","Handling Keyboard Interrupt Cleanly",{"path":3005,"title":3006},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes","Error Handling and Exit Codes for CLIs",{"path":3008,"title":3009},"\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":3011,"title":3012},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Fconfig-precedence-flags-env-files-defaults","Config Precedence: Flags, Env, Files, Defaults",{"path":3014,"title":3015},"\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":3017,"title":3018},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars","Handling Config Files and Env Vars in CLIs",{"path":3020,"title":3021},"\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":3023,"title":3024},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Freading-toml-config-with-tomllib","Reading TOML Config with tomllib in Python CLIs",{"path":3026,"title":3027},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Ftyped-settings-with-pydantic-settings","Typed Settings with pydantic-settings in Python CLIs",{"path":3029,"title":3030},"\u002Fadvanced-input-parsing-user-experience","Advanced Input Parsing for Python CLIs",{"path":3032,"title":3033},"\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":3035,"title":3036},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Fbuilding-interactive-prompts-and-menus","Building Interactive Prompts and Menus in Python CLIs",{"path":3038,"title":3039},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich","Interactive Terminal UI with Rich",{"path":3041,"title":3042},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Flive-dashboards-with-rich-live","Live Dashboards with Rich Live in Python CLIs",{"path":3044,"title":3045},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Frendering-tables-and-json-with-rich","Rendering Tables and JSON with Rich",{"path":3047,"title":3048},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Ftheming-rich-output-consistently","Theming Rich Output Consistently in Python CLIs",{"path":3050,"title":3051},"\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":3053,"title":3054},"\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":3056,"title":3057},"\u002Fadvanced-input-parsing-user-experience\u002Fshell-completion-for-python-clis","Shell Completion for Python CLIs",{"path":3059,"title":3060},"\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":3062,"title":3063},"\u002Fadvanced-input-parsing-user-experience\u002Fshell-completion-for-python-clis\u002Ftesting-shell-completion-in-python-clis","Testing Shell Completion in Python CLIs",{"path":3065,"title":3066},"\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":3068,"title":3069},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fadding-verbose-and-quiet-logging-flags","Adding Verbose and Quiet Logging Flags",{"path":3071,"title":3072},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps","Structured Logging for CLI Apps",{"path":3074,"title":3075},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fstructured-json-logging-in-python-clis","Structured JSON Logging in Python CLIs",{"path":3077,"title":3078},"\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":3080,"title":3081},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Fdetecting-tty-and-adapting-output","Detecting a TTY and Adapting Output",{"path":3083,"title":3084},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Femitting-json-output-for-scripting","Emitting JSON Output for Scripting",{"path":3086,"title":3087},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Fhandling-broken-pipe-and-sigpipe","Handling Broken Pipe and SIGPIPE",{"path":3089,"title":3090},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes","Working with stdin, stdout and Pipes",{"path":3092,"title":3093},"\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":3095,"title":3096},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Freading-piped-input-in-python-clis","Reading Piped Input in Python CLIs",{"path":3098,"title":3099},"\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":3101,"title":3102},"\u002Fcli-runtime-systems-integration\u002Fcalling-http-apis-from-python-clis\u002Fdownloading-files-with-progress-in-python","Downloading Files with Progress in Python CLIs",{"path":3104,"title":3105},"\u002Fcli-runtime-systems-integration\u002Fcalling-http-apis-from-python-clis","Calling HTTP APIs from Python CLIs",{"path":3107,"title":3108},"\u002Fcli-runtime-systems-integration\u002Fcalling-http-apis-from-python-clis\u002Foauth-device-flow-login-for-clis","OAuth Device Flow Login for Python CLIs",{"path":3110,"title":3111},"\u002Fcli-runtime-systems-integration\u002Fcalling-http-apis-from-python-clis\u002Fpaginating-api-results-in-a-cli","Paginating API Results in a Python CLI",{"path":3113,"title":3114},"\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":3116,"title":3117},"\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":3119,"title":3120},"\u002Fcli-runtime-systems-integration\u002Fconcurrency-and-async-in-python-clis","Concurrency and Async in Python CLIs",{"path":3122,"title":3123},"\u002Fcli-runtime-systems-integration\u002Fconcurrency-and-async-in-python-clis\u002Fmultiprocessing-for-cpu-bound-cli-tasks","Multiprocessing for CPU-Bound CLI Tasks",{"path":3125,"title":3126},"\u002Fcli-runtime-systems-integration\u002Fconcurrency-and-async-in-python-clis\u002Fparallelising-cli-work-with-thread-pools","Parallelising CLI Work with Thread Pools",{"path":3128,"title":3129},"\u002Fcli-runtime-systems-integration\u002Fconcurrency-and-async-in-python-clis\u002Frate-limiting-concurrent-requests-in-clis","Rate-Limiting Concurrent Requests in Python CLIs",{"path":3131,"title":3132},"\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":3134,"title":3135},"\u002Fcli-runtime-systems-integration\u002Ffilesystem-paths-and-atomic-writes\u002Fcross-platform-paths-with-pathlib","Cross-Platform Paths with pathlib in CLIs",{"path":3137,"title":3138},"\u002Fcli-runtime-systems-integration\u002Ffilesystem-paths-and-atomic-writes\u002Ffile-locking-for-concurrent-cli-runs","File Locking for Concurrent CLI Runs in Python",{"path":3140,"title":3141},"\u002Fcli-runtime-systems-integration\u002Ffilesystem-paths-and-atomic-writes","Filesystem Paths and Atomic Writes for CLIs",{"path":3143,"title":3144},"\u002Fcli-runtime-systems-integration\u002Ffilesystem-paths-and-atomic-writes\u002Fsafe-temporary-files-and-directories","Safe Temporary Files and Directories in CLIs",{"path":3146,"title":3147},"\u002Fcli-runtime-systems-integration\u002Ffilesystem-paths-and-atomic-writes\u002Fstoring-app-data-with-platformdirs","Storing CLI App Data with platformdirs",{"path":3149,"title":3150},"\u002Fcli-runtime-systems-integration\u002Ffilesystem-paths-and-atomic-writes\u002Fwriting-files-atomically-in-python-clis","Writing Files Atomically in Python CLIs",{"path":3152,"title":3153},"\u002Fcli-runtime-systems-integration","CLI Runtime & Systems Integration for Python",{"path":3155,"title":3156},"\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":3158,"title":3159},"\u002Fcli-runtime-systems-integration\u002Flong-running-and-watch-mode-clis\u002Fhandling-sigterm-and-graceful-shutdown","Handling SIGTERM and Graceful Shutdown in CLIs",{"path":3161,"title":3162},"\u002Fcli-runtime-systems-integration\u002Flong-running-and-watch-mode-clis\u002Fhealth-checks-and-heartbeats-for-long-running-clis","Health Checks and Heartbeats for Long-Running CLIs",{"path":3164,"title":3165},"\u002Fcli-runtime-systems-integration\u002Flong-running-and-watch-mode-clis","Long-Running and Watch-Mode Python CLIs",{"path":3167,"title":3168},"\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":3170,"title":3171},"\u002Fcli-runtime-systems-integration\u002Frunning-subprocesses-from-python-clis\u002Favoiding-shell-injection-in-python-clis","Avoiding Shell Injection in Python CLIs",{"path":3173,"title":3174},"\u002Fcli-runtime-systems-integration\u002Frunning-subprocesses-from-python-clis\u002Fcalling-external-commands-safely-with-subprocess","Calling External Commands Safely with subprocess",{"path":3176,"title":3177},"\u002Fcli-runtime-systems-integration\u002Frunning-subprocesses-from-python-clis\u002Fhandling-subprocess-timeouts-and-exit-codes","Handling Subprocess Timeouts and Exit Codes",{"path":3179,"title":3180},"\u002Fcli-runtime-systems-integration\u002Frunning-subprocesses-from-python-clis","Running Subprocesses from Python CLIs",{"path":3182,"title":3183},"\u002Fcli-runtime-systems-integration\u002Frunning-subprocesses-from-python-clis\u002Fstreaming-subprocess-output-in-real-time","Streaming Subprocess Output in Real Time",{"path":3185,"title":3186},"\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":3188,"title":3189},"\u002Fcli-runtime-systems-integration\u002Fsecrets-and-credentials-in-python-clis","Secrets and Credentials in Python CLIs",{"path":3191,"title":3192},"\u002Fcli-runtime-systems-integration\u002Fsecrets-and-credentials-in-python-clis\u002Fprompting-for-passwords-securely","Prompting for Passwords Securely in Python CLIs",{"path":3194,"title":3195},"\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":3197,"title":3198},"\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":3200,"title":3201},"\u002Fcli-runtime-systems-integration\u002Fsecrets-and-credentials-in-python-clis\u002Fstoring-tokens-with-keyring","Storing CLI Tokens Securely with keyring",{"path":3203,"title":3204},"\u002Fcli-runtime-systems-integration\u002Fsecrets-and-credentials-in-python-clis\u002Fsupporting-multiple-profiles-and-accounts","Supporting Multiple Profiles and Accounts in CLIs",{"path":3206,"title":3207},"\u002F","Python CLI Toolcraft",{"path":3209,"title":3210},"\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":3212,"title":3213},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading","CLI Startup Performance and Lazy Loading",{"path":3215,"title":3216},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Flazy-loading-subcommands-for-faster-startup","Lazy Loading Subcommands for Faster Startup",{"path":3218,"title":3219},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Fprofiling-python-cli-startup-time","Profiling Python CLI Startup Time",{"path":3221,"title":3222},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Freducing-cli-dependency-weight","Reducing CLI Dependency Weight",{"path":3224,"title":3225},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fargparse-subparsers-for-subcommands","argparse Subparsers for Subcommands",{"path":3227,"title":3228},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fargparse-vs-click-vs-typer-comparison","argparse vs Click vs Typer Compared",{"path":3230,"title":3231},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse","Command-Line Parsing with argparse",{"path":3233,"title":3234},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fmigrating-from-argparse-to-typer","Migrating from argparse to Typer",{"path":3236,"title":3237},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fmutually-exclusive-options-in-argparse","Mutually Exclusive Options in argparse",{"path":3239,"title":3240},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fwriting-custom-argparse-actions","Writing Custom argparse Actions in Python",{"path":3242,"title":3243},"\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":3245,"title":3246},"\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":3248,"title":3249},"\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":3251,"title":3252},"\u002Fmodern-python-cli-frameworks-architecture\u002Fdesigning-cli-interfaces-and-conventions","Designing CLI Interfaces and Conventions in Python",{"path":3254,"title":3255},"\u002Fmodern-python-cli-frameworks-architecture\u002Fdesigning-cli-interfaces-and-conventions\u002Fnaming-commands-and-flags-consistently","Naming Commands and Flags Consistently in Python CLIs",{"path":3257,"title":3258},"\u002Fmodern-python-cli-frameworks-architecture","Python CLI Frameworks and Architecture",{"path":3260,"title":3261},"\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis\u002Fdiscovering-plugins-with-entry-points","Discovering Plugins with Entry Points in Python CLIs",{"path":3263,"title":3264},"\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis\u002Fhook-based-plugins-with-pluggy","Hook-Based Plugins for Python CLIs with pluggy",{"path":3266,"title":3267},"\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis","Plugin Architectures for Extensible CLIs",{"path":3269,"title":3270},"\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis\u002Fversioning-a-plugin-api","Versioning a Plugin API for a Python CLI",{"path":3272,"title":3273},"\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":3275,"title":3276},"\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":3278,"title":3279},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fdependency-injection-patterns-for-cli-commands","Dependency Injection Patterns for CLI Commands",{"path":3281,"title":3282},"\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":3284,"title":3285},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis","Structuring Multi-Command Python CLIs",{"path":3287,"title":3288},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fsharing-common-options-across-commands","Sharing Common Options Across Python CLI Commands",{"path":3290,"title":3291},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fsharing-state-with-click-context-objects","Sharing State with Click Context Objects",{"path":3293,"title":3294},"\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":3296,"title":3297},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications","Testing Python CLI Applications",{"path":3299,"title":3300},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmeasuring-cli-test-coverage","Measuring CLI Test Coverage",{"path":3302,"title":3303},"\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":3305,"title":3306},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fproperty-based-testing-cli-arguments-with-hypothesis","Property-Based Testing CLI Arguments with Hypothesis",{"path":3308,"title":3309},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fsnapshot-testing-cli-output","Snapshot Testing CLI Output",{"path":3311,"title":3312},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-click-commands-with-clirunner","Testing Click Commands with CliRunner",{"path":3314,"title":3315},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-interactive-prompts-and-stdin","Testing Interactive Prompts and stdin",{"path":3317,"title":3318},"\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":3320,"title":3321},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Fbuilding-dynamic-commands-in-click","Building Dynamic Commands in Click",{"path":3323,"title":3324},"\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":3326,"title":3327},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each","Typer vs Click: When to Use Each",{"path":3329,"title":3330},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Ftyper-callback-functions-explained","Typer callback functions explained",{"path":3332,"title":3333},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Fusing-annotated-options-in-typer","Using Annotated Options in Typer",{"path":3335,"title":3336},"\u002Fproject-setup-dependency-management\u002Fci-cd-pipelines-for-python-clis\u002Fautomating-releases-from-git-tags","Automating Python CLI Releases from Git Tags",{"path":3338,"title":3339},"\u002Fproject-setup-dependency-management\u002Fci-cd-pipelines-for-python-clis\u002Fcaching-uv-dependencies-in-ci","Caching uv Dependencies in CI for Python CLIs",{"path":3341,"title":3342},"\u002Fproject-setup-dependency-management\u002Fci-cd-pipelines-for-python-clis","CI\u002FCD Pipelines for Python CLIs",{"path":3344,"title":3345},"\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":3347,"title":3348},"\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":3350,"title":3351},"\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":3353,"title":3354},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter\u002Fbuilding-a-cookiecutter-template-for-typer-clis","Building a Cookiecutter Template for Typer CLIs",{"path":3356,"title":3357},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter\u002Fcopier-vs-cookiecutter-for-cli-templates","Copier vs Cookiecutter for CLI Templates",{"path":3359,"title":3360},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter","CLI Project Scaffolding with Cookiecutter",{"path":3362,"title":3363},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter\u002Fpost-generation-hooks-in-cli-templates","Post-Generation Hooks in Python CLI Templates",{"path":3365,"title":3366},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fbuilding-cross-platform-release-binaries-in-ci","Building Cross-Platform Release Binaries in CI",{"path":3368,"title":3369},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fbundling-a-python-cli-with-pyinstaller","Bundling a Python CLI with PyInstaller",{"path":3371,"title":3372},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fhomebrew-and-scoop-packaging-for-python-clis","Homebrew and Scoop Packaging for Python CLIs",{"path":3374,"title":3375},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries","Distributing CLIs as Standalone Binaries",{"path":3377,"title":3378},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fnuitka-vs-pyinstaller-for-python-clis","Nuitka vs PyInstaller for Python CLI Binaries",{"path":3380,"title":3381},"\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":3383,"title":3384},"\u002Fproject-setup-dependency-management","Project Setup & Dependency Management",{"path":3386,"title":3387},"\u002Fproject-setup-dependency-management\u002Flinting-and-type-checking-cli-code\u002Fconfiguring-ruff-for-a-cli-project","Configuring Ruff for a Python CLI Project",{"path":3389,"title":3390},"\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":3392,"title":3393},"\u002Fproject-setup-dependency-management\u002Flinting-and-type-checking-cli-code","Linting and Type-Checking Python CLI Code",{"path":3395,"title":3396},"\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":3398,"title":3399},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fautomating-changelogs-with-conventional-commits","Automating Changelogs with Conventional Commits",{"path":3401,"title":3402},"\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":3404,"title":3405},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fexposing-version-info-and-build-metadata","Exposing Version Info and Build Metadata",{"path":3407,"title":3408},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs","Managing CLI Versioning & Changelogs",{"path":3410,"title":3411},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fsemantic-versioning-policy-for-cli-tools","A Semantic Versioning Policy for CLI Tools",{"path":3413,"title":3414},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fbuilding-wheels-and-sdists-for-python-clis","Building Wheels and sdists for Python CLIs",{"path":3416,"title":3417},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fbundling-data-files-with-importlib-resources","Bundling Data Files with importlib.resources in CLIs",{"path":3419,"title":3420},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution","Packaging Python CLIs for Distribution",{"path":3422,"title":3423},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Finstalling-and-distributing-clis-with-pipx","Installing and Distributing CLIs with pipx",{"path":3425,"title":3426},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fpublishing-a-python-cli-to-pypi","Publishing a Python CLI to PyPI",{"path":3428,"title":3429},"\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":3431,"title":3432},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development","Poetry Workflows for CLI Development",{"path":3434,"title":3435},"\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":3437,"title":3438},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development\u002Fpoetry-dependency-groups-for-cli-tooling","Poetry Dependency Groups for CLI Tooling",{"path":3440,"title":3441},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development\u002Fpoetry-entry-points-and-scripts-for-clis","Poetry Entry Points and Scripts for CLIs",{"path":3443,"title":3444},"\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects","Pre-commit Hooks for CLI Projects",{"path":3446,"title":3447},"\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":3449,"title":3450},"\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":3452,"title":3453},"\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":3455,"title":3456},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management","uv for Python CLI Dependency Management",{"path":3458,"title":3459},"\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":3461,"title":3462},"\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":3464,"title":3465},"\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":3467,"title":3468},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management\u002Fuv-workspaces-for-multi-package-clis","uv Workspaces for Multi-Package Python CLIs",{"path":3470,"title":3471},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices","Python CLI Env Isolation Best Practices",{"path":3473,"title":3474},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fmanaging-virtual-environments-for-cross-platform-clis","Managing Python CLI Virtual Environments",{"path":3476,"title":3477},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fpinning-the-python-version-for-a-cli","Pinning the Python Version for a CLI",{"path":3479,"title":3480},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fsupporting-multiple-python-versions-with-nox","Supporting Multiple Python Versions with nox",1789736905042]