[{"data":1,"prerenderedAt":2577},["ShallowReactive",2],{"page-\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002F":3,"content-directory":2330},{"id":4,"title":5,"body":6,"date":2315,"description":2316,"difficulty":2317,"draft":2318,"extension":2319,"meta":2320,"navigation":189,"path":2321,"seo":2322,"stem":2323,"tags":2324,"updated":2328,"__hash__":2329},"content\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Findex.md","Structured Logging for CLI Apps",{"type":7,"value":8,"toc":2289},"minimark",[9,22,27,104,108,117,134,162,302,315,319,322,325,380,387,513,524,528,537,613,640,644,653,656,746,756,760,767,801,804,808,825,966,977,981,1054,1058,1061,1352,1373,1376,1487,1494,1498,1516,1519,1541,1552,1615,1618,1622,1632,1742,1745,1752,1756,1759,1898,1945,1948,1954,2036,2040,2043,2170,2179,2182,2186,2194,2203,2207,2213,2217,2224,2228,2231,2239,2252,2256,2285],[10,11,12,13,17,18,21],"p",{},"Every CLI eventually needs to say something other than its result: a warning that a config key is deprecated, a debug trace of which file it opened, an error explaining why it stopped. Reaching for ",[14,15,16],"code",{},"print()"," for those messages quietly breaks your tool the moment someone pipes its output into another program. This overview shows how to wire Python's ",[14,19,20],{},"logging"," module into a command-line app so diagnostics go to the right stream, humans get readable output, and machines get parseable records — with verbosity you can dial up or down at runtime.",[23,24,26],"h2",{"id":25},"tldr","TL;DR",[28,29,30,52,59,78,96],"ul",{},[31,32,33,34,36,37,39,40,42,43,46,47,51],"li",{},"Use the ",[14,35,20],{}," module for diagnostics, not ",[14,38,16],{},". Reserve ",[14,41,16],{}," (or ",[14,44,45],{},"stdout",") for the actual ",[48,49,50],"strong",{},"result"," a caller wants to capture.",[31,53,54,55,58],{},"Send ",[48,56,57],{},"logs to stderr, results to stdout",". That one rule keeps your tool composable in pipes and scripts.",[31,60,61,62,65,66,69,70,73,74,77],{},"Learn the four moving parts once: a ",[48,63,64],{},"logger"," creates records, a ",[48,67,68],{},"handler"," routes them, a ",[48,71,72],{},"formatter"," renders them, and a ",[48,75,76],{},"level"," filters them.",[31,79,80,81,90,91,95],{},"Give humans a pretty console (a plain formatter or ",[82,83,87],"a",{"href":84,"rel":85},"https:\u002F\u002Frich.readthedocs.io\u002Fen\u002Fstable\u002Flogging.html",[86],"nofollow",[14,88,89],{},"RichHandler",") and give machines ",[82,92,94],{"href":93},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fstructured-json-logging-in-python-clis\u002F","structured JSON",".",[31,97,98,99,103],{},"Expose the level with ",[82,100,102],{"href":101},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fadding-verbose-and-quiet-logging-flags\u002F","verbose and quiet flags"," so users choose how much they see.",[105,106],"inline-diagram",{"name":107},"logging-levels-flow",[23,109,111,113,114,116],{"id":110},"print-versus-logging-for-clis",[14,112,16],{}," versus ",[14,115,20],{}," for CLIs",[10,118,119,121,122,126,127,129,130,133],{},[14,120,16],{}," is fine for the one thing your command produces — the converted file path, the JSON payload, the table of results. It is the wrong tool for everything ",[123,124,125],"em",{},"about"," the run. The moment you ",[14,128,16],{}," a status message, you have mixed diagnostics into the data stream, and a user who runs ",[14,131,132],{},"mycli export > data.json"," finds \"Connecting to database...\" wedged into their JSON.",[10,135,136,138,139,142,143,146,147,150,151,154,155,158,159,95],{},[14,137,20],{}," fixes this by separating three concerns you'd otherwise hand-roll: ",[48,140,141],{},"where"," a message goes (handler), ",[48,144,145],{},"how"," it looks (formatter), and ",[48,148,149],{},"whether"," it shows at all (level). You write one line — ",[14,152,153],{},"log.info(\"connected\")"," — and configuration elsewhere decides the rest. That indirection is exactly what lets the same call site be silent by default, verbose under ",[14,156,157],{},"-v",", and JSON under ",[14,160,161],{},"--log-format=json",[163,164,169],"pre",{"className":165,"code":166,"language":167,"meta":168,"style":168},"language-python shiki shiki-themes github-light github-dark","import logging\n\nlog = logging.getLogger(\"mycli\")\n\ndef export(rows: list[dict]) -> None:\n    log.info(\"exporting %d rows\", len(rows))   # diagnostic -> stderr\n    for row in rows:\n        print(row[\"id\"])                        # result -> stdout\n","python","",[14,170,171,184,191,210,215,241,269,284],{"__ignoreMap":168},[172,173,176,180],"span",{"class":174,"line":175},"line",1,[172,177,179],{"class":178},"szBVR","import",[172,181,183],{"class":182},"sVt8B"," logging\n",[172,185,187],{"class":174,"line":186},2,[172,188,190],{"emptyLinePlaceholder":189},true,"\n",[172,192,194,197,200,203,207],{"class":174,"line":193},3,[172,195,196],{"class":182},"log ",[172,198,199],{"class":178},"=",[172,201,202],{"class":182}," logging.getLogger(",[172,204,206],{"class":205},"sZZnC","\"mycli\"",[172,208,209],{"class":182},")\n",[172,211,213],{"class":174,"line":212},4,[172,214,190],{"emptyLinePlaceholder":189},[172,216,218,221,225,228,232,235,238],{"class":174,"line":217},5,[172,219,220],{"class":178},"def",[172,222,224],{"class":223},"sScJk"," export",[172,226,227],{"class":182},"(rows: list[",[172,229,231],{"class":230},"sj4cs","dict",[172,233,234],{"class":182},"]) -> ",[172,236,237],{"class":230},"None",[172,239,240],{"class":182},":\n",[172,242,244,247,250,253,256,259,262,265],{"class":174,"line":243},6,[172,245,246],{"class":182},"    log.info(",[172,248,249],{"class":205},"\"exporting ",[172,251,252],{"class":230},"%d",[172,254,255],{"class":205}," rows\"",[172,257,258],{"class":182},", ",[172,260,261],{"class":230},"len",[172,263,264],{"class":182},"(rows))   ",[172,266,268],{"class":267},"sJ8bj","# diagnostic -> stderr\n",[172,270,272,275,278,281],{"class":174,"line":271},7,[172,273,274],{"class":178},"    for",[172,276,277],{"class":182}," row ",[172,279,280],{"class":178},"in",[172,282,283],{"class":182}," rows:\n",[172,285,287,290,293,296,299],{"class":174,"line":286},8,[172,288,289],{"class":230},"        print",[172,291,292],{"class":182},"(row[",[172,294,295],{"class":205},"\"id\"",[172,297,298],{"class":182},"])                        ",[172,300,301],{"class":267},"# result -> stdout\n",[10,303,304,305,307,308,311,312,314],{},"The result goes to ",[14,306,45],{},"; the \"exporting N rows\" note goes to ",[14,309,310],{},"stderr"," once you attach a handler there. A caller redirecting ",[14,313,45],{}," still sees the log on their terminal, and a caller redirecting both keeps them in separate files.",[23,316,318],{"id":317},"the-logging-model-logger-handler-formatter-level","The logging model: logger, handler, formatter, level",[10,320,321],{},"Four objects do all the work, and understanding them is the whole game:",[105,323],{"name":324},"logging-pipeline",[28,326,327,344,364,370],{},[31,328,329,332,333,336,337,258,340,343],{},[48,330,331],{},"Logger"," — what you call (",[14,334,335],{},"logging.getLogger(\"mycli\")","). Loggers form a dotted namespace (",[14,338,339],{},"mycli",[14,341,342],{},"mycli.db",") so you can tune sub-areas independently.",[31,345,346,349,350,353,354,356,357,360,361,363],{},[48,347,348],{},"Handler"," — where records go: a ",[14,351,352],{},"StreamHandler"," to ",[14,355,310],{},", a ",[14,358,359],{},"FileHandler"," to a log file, a ",[14,362,89],{}," for a colorized console. One logger can have several.",[31,365,366,369],{},[48,367,368],{},"Formatter"," — how each record is rendered to text: a timestamp-and-level line for humans, a JSON object for machines.",[31,371,372,375,376,379],{},[48,373,374],{},"Level"," — the threshold. ",[14,377,378],{},"DEBUG \u003C INFO \u003C WARNING \u003C ERROR \u003C CRITICAL",". A record below the effective level is dropped before it's ever formatted.",[10,381,382,383,386],{},"Configure them once at startup, ideally in a single ",[14,384,385],{},"setup_logging()"," function called from your entry point:",[163,388,390],{"className":165,"code":389,"language":167,"meta":168,"style":168},"import logging\nimport sys\n\ndef setup_logging(level: int = logging.WARNING) -> None:\n    handler = logging.StreamHandler(sys.stderr)          # logs -> stderr\n    handler.setFormatter(logging.Formatter(\n        \"%(levelname)s %(name)s: %(message)s\"\n    ))\n    root = logging.getLogger()\n    root.handlers.clear()          # avoid duplicate handlers on re-init\n    root.addHandler(handler)\n    root.setLevel(level)\n",[14,391,392,398,405,409,438,451,456,476,481,492,501,507],{"__ignoreMap":168},[172,393,394,396],{"class":174,"line":175},[172,395,179],{"class":178},[172,397,183],{"class":182},[172,399,400,402],{"class":174,"line":186},[172,401,179],{"class":178},[172,403,404],{"class":182}," sys\n",[172,406,407],{"class":174,"line":193},[172,408,190],{"emptyLinePlaceholder":189},[172,410,411,413,416,419,422,425,428,431,434,436],{"class":174,"line":212},[172,412,220],{"class":178},[172,414,415],{"class":223}," setup_logging",[172,417,418],{"class":182},"(level: ",[172,420,421],{"class":230},"int",[172,423,424],{"class":178}," =",[172,426,427],{"class":182}," logging.",[172,429,430],{"class":230},"WARNING",[172,432,433],{"class":182},") -> ",[172,435,237],{"class":230},[172,437,240],{"class":182},[172,439,440,443,445,448],{"class":174,"line":217},[172,441,442],{"class":182},"    handler ",[172,444,199],{"class":178},[172,446,447],{"class":182}," logging.StreamHandler(sys.stderr)          ",[172,449,450],{"class":267},"# logs -> stderr\n",[172,452,453],{"class":174,"line":243},[172,454,455],{"class":182},"    handler.setFormatter(logging.Formatter(\n",[172,457,458,461,464,467,470,473],{"class":174,"line":271},[172,459,460],{"class":205},"        \"",[172,462,463],{"class":230},"%(levelname)s",[172,465,466],{"class":230}," %(name)s",[172,468,469],{"class":205},": ",[172,471,472],{"class":230},"%(message)s",[172,474,475],{"class":205},"\"\n",[172,477,478],{"class":174,"line":286},[172,479,480],{"class":182},"    ))\n",[172,482,484,487,489],{"class":174,"line":483},9,[172,485,486],{"class":182},"    root ",[172,488,199],{"class":178},[172,490,491],{"class":182}," logging.getLogger()\n",[172,493,495,498],{"class":174,"line":494},10,[172,496,497],{"class":182},"    root.handlers.clear()          ",[172,499,500],{"class":267},"# avoid duplicate handlers on re-init\n",[172,502,504],{"class":174,"line":503},11,[172,505,506],{"class":182},"    root.addHandler(handler)\n",[172,508,510],{"class":174,"line":509},12,[172,511,512],{"class":182},"    root.setLevel(level)\n",[10,514,515,516,519,520,523],{},"Two details matter. Clearing existing handlers makes the function safe to call more than once (tests love this). And configuring the ",[48,517,518],{},"root"," logger means every module that does ",[14,521,522],{},"logging.getLogger(__name__)"," inherits the handler for free — you never wire logging per-module.",[23,525,527],{"id":526},"logs-go-to-stderr-results-go-to-stdout","Logs go to stderr, results go to stdout",[10,529,530,531,533,534,536],{},"This is the rule that makes a CLI behave in a pipeline. ",[14,532,45],{}," is the data channel; ",[14,535,310],{}," is the diagnostics channel. Keep them separate and your tool composes:",[163,538,542],{"className":539,"code":540,"language":541,"meta":168,"style":168},"language-bash shiki shiki-themes github-light github-dark","$ mycli export --format json > out.json      # only results land in the file\nexporting 128 rows                            # log still shows on the terminal\n$ mycli export --format json 2>\u002Fdev\u002Fnull | jq '.[0]'   # drop logs, keep data\n","bash",[14,543,544,569,583],{"__ignoreMap":168},[172,545,546,549,552,554,557,560,563,566],{"class":174,"line":175},[172,547,548],{"class":223},"$",[172,550,551],{"class":205}," mycli",[172,553,224],{"class":205},[172,555,556],{"class":230}," --format",[172,558,559],{"class":205}," json",[172,561,562],{"class":178}," >",[172,564,565],{"class":205}," out.json",[172,567,568],{"class":267},"      # only results land in the file\n",[172,570,571,574,577,580],{"class":174,"line":186},[172,572,573],{"class":223},"exporting",[172,575,576],{"class":230}," 128",[172,578,579],{"class":205}," rows",[172,581,582],{"class":267},"                            # log still shows on the terminal\n",[172,584,585,587,589,591,593,595,598,601,604,607,610],{"class":174,"line":193},[172,586,548],{"class":223},[172,588,551],{"class":205},[172,590,224],{"class":205},[172,592,556],{"class":230},[172,594,559],{"class":205},[172,596,597],{"class":178}," 2>",[172,599,600],{"class":205},"\u002Fdev\u002Fnull",[172,602,603],{"class":178}," |",[172,605,606],{"class":223}," jq",[172,608,609],{"class":205}," '.[0]'",[172,611,612],{"class":267},"   # drop logs, keep data\n",[10,614,615,618,619,621,622,625,626,628,629,631,632,636,637,639],{},[14,616,617],{},"logging.StreamHandler()"," defaults to ",[14,620,310],{},", which is already correct — but be explicit (",[14,623,624],{},"StreamHandler(sys.stderr)",") so no one \"fixes\" it to ",[14,627,45],{}," later. The payoff is that ",[14,630,157],{}," can add as much noise as a user wants without ever corrupting the output another program is parsing. This same discipline underpins ",[82,633,635],{"href":634},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002F","choosing exit codes and error handling",": errors go to ",[14,638,310],{}," and set a non-zero exit, so scripts can branch on success without scraping text.",[23,641,643],{"id":642},"human-output-versus-machine-output","Human output versus machine output",[10,645,646,647],{},"The same log record should look different depending on who's reading. A developer at an interactive terminal wants a short, colorized line. A log collector wants a JSON object it can index. Decide by asking one question: ",[123,648,649,650,652],{},"is ",[14,651,310],{}," a TTY?",[105,654],{"name":655},"log-output-matrix",[163,657,659],{"className":165,"code":658,"language":167,"meta":168,"style":168},"import logging\nimport sys\n\ndef choose_formatter() -> logging.Formatter:\n    if sys.stderr.isatty():\n        return logging.Formatter(\"%(levelname)s: %(message)s\")   # human\n    # non-interactive (piped, CI, systemd): switch to structured output\n    from myapp.jsonlog import JsonFormatter\n    return JsonFormatter()\n",[14,660,661,667,673,677,687,695,720,725,738],{"__ignoreMap":168},[172,662,663,665],{"class":174,"line":175},[172,664,179],{"class":178},[172,666,183],{"class":182},[172,668,669,671],{"class":174,"line":186},[172,670,179],{"class":178},[172,672,404],{"class":182},[172,674,675],{"class":174,"line":193},[172,676,190],{"emptyLinePlaceholder":189},[172,678,679,681,684],{"class":174,"line":212},[172,680,220],{"class":178},[172,682,683],{"class":223}," choose_formatter",[172,685,686],{"class":182},"() -> logging.Formatter:\n",[172,688,689,692],{"class":174,"line":217},[172,690,691],{"class":178},"    if",[172,693,694],{"class":182}," sys.stderr.isatty():\n",[172,696,697,700,703,706,708,710,712,714,717],{"class":174,"line":243},[172,698,699],{"class":178},"        return",[172,701,702],{"class":182}," logging.Formatter(",[172,704,705],{"class":205},"\"",[172,707,463],{"class":230},[172,709,469],{"class":205},[172,711,472],{"class":230},[172,713,705],{"class":205},[172,715,716],{"class":182},")   ",[172,718,719],{"class":267},"# human\n",[172,721,722],{"class":174,"line":271},[172,723,724],{"class":267},"    # non-interactive (piped, CI, systemd): switch to structured output\n",[172,726,727,730,733,735],{"class":174,"line":286},[172,728,729],{"class":178},"    from",[172,731,732],{"class":182}," myapp.jsonlog ",[172,734,179],{"class":178},[172,736,737],{"class":182}," JsonFormatter\n",[172,739,740,743],{"class":174,"line":483},[172,741,742],{"class":178},"    return",[172,744,745],{"class":182}," JsonFormatter()\n",[10,747,748,749,751,752,755],{},"When ",[14,750,310],{}," is a terminal, render friendly text. When it's redirected — CI, a pipe, a service manager — emit structured records instead. Let a ",[14,753,754],{},"--log-format=json|console"," flag override the guess, because autodetection is a default, not a law. The deep recipe for the machine side lives in the child guide below.",[23,757,759],{"id":758},"the-two-guides-underneath-this-one","The two guides underneath this one",[10,761,762,763,766],{},"This overview stays at the level of ",[123,764,765],{},"how the pieces fit",". Two focused guides carry the implementations:",[28,768,769,783],{},[31,770,771,776,777,782],{},[82,772,773],{"href":93},[48,774,775],{},"Structured JSON logging in Python CLIs"," — emit machine-readable JSON with a stdlib formatter or ",[82,778,781],{"href":779,"rel":780},"https:\u002F\u002Fwww.structlog.org\u002F",[86],"structlog",", attach context fields like a request ID, and switch renderers based on the terminal.",[31,784,785,790,791,258,793,796,797,800],{},[82,786,787],{"href":101},[48,788,789],{},"Adding verbose and quiet logging flags"," — map ",[14,792,157],{},[14,794,795],{},"-vv",", and ",[14,798,799],{},"--quiet"," onto log levels, pick sane defaults, and keep verbose and quiet mutually exclusive.",[10,802,803],{},"Read the flags guide first if you just want users to be able to say \"tell me more\"; read the JSON guide when your logs need to land in a pipeline.",[23,805,807],{"id":806},"a-pretty-console-with-richhandler","A pretty console with RichHandler",[10,809,810,811,815,816,818,819,821,822,824],{},"For interactive use, ",[82,812,814],{"href":84,"rel":813},[86],"Rich"," gives you colorized levels, aligned columns, and syntax-highlighted tracebacks with almost no configuration. Swap the plain ",[14,817,352],{}," for a ",[14,820,89],{}," when ",[14,823,310],{}," is a terminal:",[163,826,828],{"className":165,"code":827,"language":167,"meta":168,"style":168},"import logging\nfrom rich.logging import RichHandler\n\ndef setup_rich_logging(level: int = logging.WARNING) -> None:\n    logging.basicConfig(\n        level=level,\n        format=\"%(message)s\",          # RichHandler adds level + time columns\n        datefmt=\"[%X]\",\n        handlers=[RichHandler(rich_tracebacks=True, show_path=False)],\n    )\n",[14,829,830,836,849,853,876,881,892,911,930,961],{"__ignoreMap":168},[172,831,832,834],{"class":174,"line":175},[172,833,179],{"class":178},[172,835,183],{"class":182},[172,837,838,841,844,846],{"class":174,"line":186},[172,839,840],{"class":178},"from",[172,842,843],{"class":182}," rich.logging ",[172,845,179],{"class":178},[172,847,848],{"class":182}," RichHandler\n",[172,850,851],{"class":174,"line":193},[172,852,190],{"emptyLinePlaceholder":189},[172,854,855,857,860,862,864,866,868,870,872,874],{"class":174,"line":212},[172,856,220],{"class":178},[172,858,859],{"class":223}," setup_rich_logging",[172,861,418],{"class":182},[172,863,421],{"class":230},[172,865,424],{"class":178},[172,867,427],{"class":182},[172,869,430],{"class":230},[172,871,433],{"class":182},[172,873,237],{"class":230},[172,875,240],{"class":182},[172,877,878],{"class":174,"line":217},[172,879,880],{"class":182},"    logging.basicConfig(\n",[172,882,883,887,889],{"class":174,"line":243},[172,884,886],{"class":885},"s4XuR","        level",[172,888,199],{"class":178},[172,890,891],{"class":182},"level,\n",[172,893,894,897,899,901,903,905,908],{"class":174,"line":271},[172,895,896],{"class":885},"        format",[172,898,199],{"class":178},[172,900,705],{"class":205},[172,902,472],{"class":230},[172,904,705],{"class":205},[172,906,907],{"class":182},",          ",[172,909,910],{"class":267},"# RichHandler adds level + time columns\n",[172,912,913,916,918,921,924,927],{"class":174,"line":286},[172,914,915],{"class":885},"        datefmt",[172,917,199],{"class":178},[172,919,920],{"class":205},"\"[",[172,922,923],{"class":230},"%X",[172,925,926],{"class":205},"]\"",[172,928,929],{"class":182},",\n",[172,931,932,935,937,940,943,945,948,950,953,955,958],{"class":174,"line":483},[172,933,934],{"class":885},"        handlers",[172,936,199],{"class":178},[172,938,939],{"class":182},"[RichHandler(",[172,941,942],{"class":885},"rich_tracebacks",[172,944,199],{"class":178},[172,946,947],{"class":230},"True",[172,949,258],{"class":182},[172,951,952],{"class":885},"show_path",[172,954,199],{"class":178},[172,956,957],{"class":230},"False",[172,959,960],{"class":182},")],\n",[172,962,963],{"class":174,"line":494},[172,964,965],{"class":182},"    )\n",[10,967,968,971,972,976],{},[14,969,970],{},"rich_tracebacks=True"," turns an unhandled exception into a readable, source-highlighted panel instead of a wall of monochrome text — a big usability win for the people running your tool. Rich writes to its own console (stderr by default), so the stdout\u002Fstderr split still holds. If your CLI already uses Rich for ",[82,973,975],{"href":974},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002F","progress bars and other terminal UI",", reusing its handler keeps every message visually consistent. Fall back to the plain formatter when output is redirected, so log files stay free of color escape codes.",[23,978,980],{"id":979},"production-notes","Production notes",[28,982,983,997,1015,1033,1042],{},[31,984,985,988,989,992,993,996],{},[48,986,987],{},"Set the level, don't gate at the call site."," Never wrap ",[14,990,991],{},"log.debug(...)"," in ",[14,994,995],{},"if verbose:",". Set the logger level once and let the framework filter — that's the entire point.",[31,998,999,1006,1007,1010,1011,1014],{},[48,1000,1001,1002,1005],{},"Don't ",[14,1003,1004],{},"basicConfig"," inside a library."," If your CLI is also importable, configure logging only in the entry-point\u002F",[14,1008,1009],{},"main()",", never at import time. Libraries should add a ",[14,1012,1013],{},"NullHandler"," and let the application decide.",[31,1016,1017,1020,1021,1024,1025,1028,1029,1032],{},[48,1018,1019],{},"Interpolate lazily."," Write ",[14,1022,1023],{},"log.info(\"got %s rows\", n)",", not ",[14,1026,1027],{},"log.info(f\"got {n} rows\")",". The ",[14,1030,1031],{},"%","-args are only formatted if the record actually passes the level filter.",[31,1034,1035,1041],{},[48,1036,1037,1038,1040],{},"One ",[14,1039,385],{}," call."," Clear handlers before adding new ones so re-initialization (in tests or plugins) doesn't double every line.",[31,1043,1044,1050,1051,1053],{},[48,1045,1046,1047,95],{},"Capture in tests with ",[14,1048,1049],{},"caplog"," pytest's ",[14,1052,1049],{}," fixture records emitted logs so you can assert on level and message without parsing terminal text.",[23,1055,1057],{"id":1056},"one-configuration-function-called-once","One configuration function, called once",[10,1059,1060],{},"Logging configuration belongs in exactly one place, runs before any command, and takes its input\nfrom the verbosity flags:",[163,1062,1064],{"className":165,"code":1063,"language":167,"meta":168,"style":168},"import logging\nimport sys\n\nLEVELS = {0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG}\nNOISY = (\"httpx\", \"urllib3\", \"botocore\", \"asyncio\")\n\ndef configure_logging(verbosity: int, *, quiet: bool = False, json_format: bool = False) -> None:\n    level = logging.ERROR if quiet else LEVELS.get(verbosity, logging.DEBUG)\n\n    handler = logging.StreamHandler(sys.stderr)\n    handler.setFormatter(\n        JsonFormatter() if json_format else logging.Formatter(\"%(levelname)s %(name)s: %(message)s\")\n    )\n\n    root = logging.getLogger()\n    root.handlers.clear()          # idempotent: repeated calls do not stack handlers\n    root.addHandler(handler)\n    root.setLevel(level)\n\n    for name in NOISY:\n        logging.getLogger(name).setLevel(max(level, logging.WARNING))\n",[14,1065,1066,1072,1078,1082,1123,1153,1157,1200,1231,1235,1244,1249,1278,1283,1288,1297,1305,1310,1315,1320,1335],{"__ignoreMap":168},[172,1067,1068,1070],{"class":174,"line":175},[172,1069,179],{"class":178},[172,1071,183],{"class":182},[172,1073,1074,1076],{"class":174,"line":186},[172,1075,179],{"class":178},[172,1077,404],{"class":182},[172,1079,1080],{"class":174,"line":193},[172,1081,190],{"emptyLinePlaceholder":189},[172,1083,1084,1087,1089,1092,1095,1098,1100,1102,1105,1107,1110,1112,1115,1117,1120],{"class":174,"line":212},[172,1085,1086],{"class":230},"LEVELS",[172,1088,424],{"class":178},[172,1090,1091],{"class":182}," {",[172,1093,1094],{"class":230},"0",[172,1096,1097],{"class":182},": logging.",[172,1099,430],{"class":230},[172,1101,258],{"class":182},[172,1103,1104],{"class":230},"1",[172,1106,1097],{"class":182},[172,1108,1109],{"class":230},"INFO",[172,1111,258],{"class":182},[172,1113,1114],{"class":230},"2",[172,1116,1097],{"class":182},[172,1118,1119],{"class":230},"DEBUG",[172,1121,1122],{"class":182},"}\n",[172,1124,1125,1128,1130,1133,1136,1138,1141,1143,1146,1148,1151],{"class":174,"line":217},[172,1126,1127],{"class":230},"NOISY",[172,1129,424],{"class":178},[172,1131,1132],{"class":182}," (",[172,1134,1135],{"class":205},"\"httpx\"",[172,1137,258],{"class":182},[172,1139,1140],{"class":205},"\"urllib3\"",[172,1142,258],{"class":182},[172,1144,1145],{"class":205},"\"botocore\"",[172,1147,258],{"class":182},[172,1149,1150],{"class":205},"\"asyncio\"",[172,1152,209],{"class":182},[172,1154,1155],{"class":174,"line":243},[172,1156,190],{"emptyLinePlaceholder":189},[172,1158,1159,1161,1164,1167,1169,1171,1174,1177,1180,1182,1185,1188,1190,1192,1194,1196,1198],{"class":174,"line":271},[172,1160,220],{"class":178},[172,1162,1163],{"class":223}," configure_logging",[172,1165,1166],{"class":182},"(verbosity: ",[172,1168,421],{"class":230},[172,1170,258],{"class":182},[172,1172,1173],{"class":178},"*",[172,1175,1176],{"class":182},", quiet: ",[172,1178,1179],{"class":230},"bool",[172,1181,424],{"class":178},[172,1183,1184],{"class":230}," False",[172,1186,1187],{"class":182},", json_format: ",[172,1189,1179],{"class":230},[172,1191,424],{"class":178},[172,1193,1184],{"class":230},[172,1195,433],{"class":182},[172,1197,237],{"class":230},[172,1199,240],{"class":182},[172,1201,1202,1205,1207,1209,1212,1215,1218,1221,1224,1227,1229],{"class":174,"line":286},[172,1203,1204],{"class":182},"    level ",[172,1206,199],{"class":178},[172,1208,427],{"class":182},[172,1210,1211],{"class":230},"ERROR",[172,1213,1214],{"class":178}," if",[172,1216,1217],{"class":182}," quiet ",[172,1219,1220],{"class":178},"else",[172,1222,1223],{"class":230}," LEVELS",[172,1225,1226],{"class":182},".get(verbosity, logging.",[172,1228,1119],{"class":230},[172,1230,209],{"class":182},[172,1232,1233],{"class":174,"line":483},[172,1234,190],{"emptyLinePlaceholder":189},[172,1236,1237,1239,1241],{"class":174,"line":494},[172,1238,442],{"class":182},[172,1240,199],{"class":178},[172,1242,1243],{"class":182}," logging.StreamHandler(sys.stderr)\n",[172,1245,1246],{"class":174,"line":503},[172,1247,1248],{"class":182},"    handler.setFormatter(\n",[172,1250,1251,1254,1257,1260,1262,1264,1266,1268,1270,1272,1274,1276],{"class":174,"line":509},[172,1252,1253],{"class":182},"        JsonFormatter() ",[172,1255,1256],{"class":178},"if",[172,1258,1259],{"class":182}," json_format ",[172,1261,1220],{"class":178},[172,1263,702],{"class":182},[172,1265,705],{"class":205},[172,1267,463],{"class":230},[172,1269,466],{"class":230},[172,1271,469],{"class":205},[172,1273,472],{"class":230},[172,1275,705],{"class":205},[172,1277,209],{"class":182},[172,1279,1281],{"class":174,"line":1280},13,[172,1282,965],{"class":182},[172,1284,1286],{"class":174,"line":1285},14,[172,1287,190],{"emptyLinePlaceholder":189},[172,1289,1291,1293,1295],{"class":174,"line":1290},15,[172,1292,486],{"class":182},[172,1294,199],{"class":178},[172,1296,491],{"class":182},[172,1298,1300,1302],{"class":174,"line":1299},16,[172,1301,497],{"class":182},[172,1303,1304],{"class":267},"# idempotent: repeated calls do not stack handlers\n",[172,1306,1308],{"class":174,"line":1307},17,[172,1309,506],{"class":182},[172,1311,1313],{"class":174,"line":1312},18,[172,1314,512],{"class":182},[172,1316,1318],{"class":174,"line":1317},19,[172,1319,190],{"emptyLinePlaceholder":189},[172,1321,1323,1325,1328,1330,1333],{"class":174,"line":1322},20,[172,1324,274],{"class":178},[172,1326,1327],{"class":182}," name ",[172,1329,280],{"class":178},[172,1331,1332],{"class":230}," NOISY",[172,1334,240],{"class":182},[172,1336,1338,1341,1344,1347,1349],{"class":174,"line":1337},21,[172,1339,1340],{"class":182},"        logging.getLogger(name).setLevel(",[172,1342,1343],{"class":230},"max",[172,1345,1346],{"class":182},"(level, logging.",[172,1348,430],{"class":230},[172,1350,1351],{"class":182},"))\n",[10,1353,1354,1355,1357,1358,1361,1362,1365,1366,1369,1370,1372],{},"Four decisions are worth calling out. The stream is ",[48,1356,310],{},", always, so logs never contaminate\nresults. ",[14,1359,1360],{},"handlers.clear()"," makes the function ",[48,1363,1364],{},"idempotent",", which matters because tests call it\nrepeatedly and duplicated handlers produce duplicated lines. Known-noisy libraries are ",[48,1367,1368],{},"capped","\nso ",[14,1371,795],{}," shows your debug output rather than someone else's retry chatter. And the format is a\nparameter rather than a second code path.",[10,1374,1375],{},"Wire it into the callback so it happens before dispatch:",[163,1377,1379],{"className":165,"code":1378,"language":167,"meta":168,"style":168},"@app.callback()\ndef main(\n    ctx: typer.Context,\n    verbose: Annotated[int, typer.Option(\"--verbose\", \"-v\", count=True)] = 0,\n    quiet: Annotated[bool, typer.Option(\"--quiet\", \"-q\")] = False,\n) -> None:\n    configure_logging(verbose, quiet=quiet)\n",[14,1380,1381,1389,1399,1404,1441,1466,1474],{"__ignoreMap":168},[172,1382,1383,1386],{"class":174,"line":175},[172,1384,1385],{"class":223},"@app.callback",[172,1387,1388],{"class":182},"()\n",[172,1390,1391,1393,1396],{"class":174,"line":186},[172,1392,220],{"class":178},[172,1394,1395],{"class":223}," main",[172,1397,1398],{"class":182},"(\n",[172,1400,1401],{"class":174,"line":193},[172,1402,1403],{"class":182},"    ctx: typer.Context,\n",[172,1405,1406,1409,1411,1414,1417,1419,1422,1424,1427,1429,1431,1434,1436,1439],{"class":174,"line":212},[172,1407,1408],{"class":182},"    verbose: Annotated[",[172,1410,421],{"class":230},[172,1412,1413],{"class":182},", typer.Option(",[172,1415,1416],{"class":205},"\"--verbose\"",[172,1418,258],{"class":182},[172,1420,1421],{"class":205},"\"-v\"",[172,1423,258],{"class":182},[172,1425,1426],{"class":885},"count",[172,1428,199],{"class":178},[172,1430,947],{"class":230},[172,1432,1433],{"class":182},")] ",[172,1435,199],{"class":178},[172,1437,1438],{"class":230}," 0",[172,1440,929],{"class":182},[172,1442,1443,1446,1448,1450,1453,1455,1458,1460,1462,1464],{"class":174,"line":217},[172,1444,1445],{"class":182},"    quiet: Annotated[",[172,1447,1179],{"class":230},[172,1449,1413],{"class":182},[172,1451,1452],{"class":205},"\"--quiet\"",[172,1454,258],{"class":182},[172,1456,1457],{"class":205},"\"-q\"",[172,1459,1433],{"class":182},[172,1461,199],{"class":178},[172,1463,1184],{"class":230},[172,1465,929],{"class":182},[172,1467,1468,1470,1472],{"class":174,"line":243},[172,1469,433],{"class":182},[172,1471,237],{"class":230},[172,1473,240],{"class":182},[172,1475,1476,1479,1482,1484],{"class":174,"line":271},[172,1477,1478],{"class":182},"    configure_logging(verbose, ",[172,1480,1481],{"class":885},"quiet",[172,1483,199],{"class":178},[172,1485,1486],{"class":182},"quiet)\n",[10,1488,1489,1490,1493],{},"Nothing else in the program asks how verbose the user wanted things. Modules call\n",[14,1491,1492],{},"log = logging.getLogger(__name__)"," at import time and log at the level the message deserves; the\nconfiguration decides what is shown.",[23,1495,1497],{"id":1496},"naming-levels-and-what-to-log","Naming, levels and what to log",[10,1499,1500,1501,1504,1505,1508,1509,1508,1512,1515],{},"A logger per module, named ",[14,1502,1503],{},"__name__",", is the whole naming convention. It gives you free\nhierarchy — ",[14,1506,1507],{},"mytool.core.sync"," inherits from ",[14,1510,1511],{},"mytool.core",[14,1513,1514],{},"mytool"," — so a user can\nbe told to run with a specific subsystem at debug level, and it makes the source of a line obvious\nwithout adding it to the message.",[10,1517,1518],{},"Choosing a level is easier with a rule than with taste:",[28,1520,1521,1526,1531,1536],{},[31,1522,1523,1525],{},[48,1524,1211],{}," — the operation failed. Something the user must know about, and usually the last thing\nbefore a non-zero exit.",[31,1527,1528,1530],{},[48,1529,430],{}," — it worked, but not the way it should. A deprecated key, a retry, a fallback.",[31,1532,1533,1535],{},[48,1534,1109],{}," — a step completed. What a user asking \"what is it doing\" wants: one line per\nmeaningful unit of work, not one per file.",[31,1537,1538,1540],{},[48,1539,1119],{}," — everything you would want in a bug report: arguments, resolved configuration, URLs,\ntimings.",[10,1542,1543,1544,1547,1548,1551],{},"Two mistakes account for most bad logging. ",[48,1545,1546],{},"Logging and raising"," the same problem produces two\nreports of one failure — either log it and handle it, or raise it and let the boundary report it,\nnever both. And ",[48,1549,1550],{},"using f-strings for the message",":",[163,1553,1555],{"className":165,"code":1554,"language":167,"meta":168,"style":168},"log.debug(\"uploading %s to %s\", path, bucket)      # formatted only if DEBUG is enabled\nlog.debug(f\"uploading {path} to {bucket}\")         # formatted always, even when discarded\n",[14,1556,1557,1580],{"__ignoreMap":168},[172,1558,1559,1562,1565,1568,1570,1572,1574,1577],{"class":174,"line":175},[172,1560,1561],{"class":182},"log.debug(",[172,1563,1564],{"class":205},"\"uploading ",[172,1566,1567],{"class":230},"%s",[172,1569,353],{"class":205},[172,1571,1567],{"class":230},[172,1573,705],{"class":205},[172,1575,1576],{"class":182},", path, bucket)      ",[172,1578,1579],{"class":267},"# formatted only if DEBUG is enabled\n",[172,1581,1582,1584,1587,1589,1592,1595,1598,1600,1602,1605,1607,1609,1612],{"class":174,"line":186},[172,1583,1561],{"class":182},[172,1585,1586],{"class":178},"f",[172,1588,1564],{"class":205},[172,1590,1591],{"class":230},"{",[172,1593,1594],{"class":182},"path",[172,1596,1597],{"class":230},"}",[172,1599,353],{"class":205},[172,1601,1591],{"class":230},[172,1603,1604],{"class":182},"bucket",[172,1606,1597],{"class":230},[172,1608,705],{"class":205},[172,1610,1611],{"class":182},")         ",[172,1613,1614],{"class":267},"# formatted always, even when discarded\n",[10,1616,1617],{},"The first also keeps the arguments as structured fields for any handler that wants them, which is\nwhat makes the JSON format useful rather than a wrapper around a sentence.",[23,1619,1621],{"id":1620},"exceptions-and-the-one-place-they-belong","Exceptions, and the one place they belong",[10,1623,1624,1627,1628,1631],{},[14,1625,1626],{},"log.exception"," attaches the current traceback and is only correct inside an ",[14,1629,1630],{},"except"," block. In a\nCLI it should appear approximately once, at the boundary, not scattered wherever an error is\ncaught:",[163,1633,1635],{"className":165,"code":1634,"language":167,"meta":168,"style":168},"def main() -> None:\n    try:\n        app()\n    except MytoolError as exc:\n        log.error(\"%s\", exc)                       # expected: one line, no traceback\n        sys.exit(EXIT[type(exc)])\n    except Exception:\n        log.exception(\"internal error\")            # unexpected: full traceback at ERROR\n        sys.exit(70)\n",[14,1636,1637,1650,1657,1662,1676,1693,1710,1719,1733],{"__ignoreMap":168},[172,1638,1639,1641,1643,1646,1648],{"class":174,"line":175},[172,1640,220],{"class":178},[172,1642,1395],{"class":223},[172,1644,1645],{"class":182},"() -> ",[172,1647,237],{"class":230},[172,1649,240],{"class":182},[172,1651,1652,1655],{"class":174,"line":186},[172,1653,1654],{"class":178},"    try",[172,1656,240],{"class":182},[172,1658,1659],{"class":174,"line":193},[172,1660,1661],{"class":182},"        app()\n",[172,1663,1664,1667,1670,1673],{"class":174,"line":212},[172,1665,1666],{"class":178},"    except",[172,1668,1669],{"class":182}," MytoolError ",[172,1671,1672],{"class":178},"as",[172,1674,1675],{"class":182}," exc:\n",[172,1677,1678,1681,1683,1685,1687,1690],{"class":174,"line":217},[172,1679,1680],{"class":182},"        log.error(",[172,1682,705],{"class":205},[172,1684,1567],{"class":230},[172,1686,705],{"class":205},[172,1688,1689],{"class":182},", exc)                       ",[172,1691,1692],{"class":267},"# expected: one line, no traceback\n",[172,1694,1695,1698,1701,1704,1707],{"class":174,"line":243},[172,1696,1697],{"class":182},"        sys.exit(",[172,1699,1700],{"class":230},"EXIT",[172,1702,1703],{"class":182},"[",[172,1705,1706],{"class":230},"type",[172,1708,1709],{"class":182},"(exc)])\n",[172,1711,1712,1714,1717],{"class":174,"line":271},[172,1713,1666],{"class":178},[172,1715,1716],{"class":230}," Exception",[172,1718,240],{"class":182},[172,1720,1721,1724,1727,1730],{"class":174,"line":286},[172,1722,1723],{"class":182},"        log.exception(",[172,1725,1726],{"class":205},"\"internal error\"",[172,1728,1729],{"class":182},")            ",[172,1731,1732],{"class":267},"# unexpected: full traceback at ERROR\n",[172,1734,1735,1737,1740],{"class":174,"line":483},[172,1736,1697],{"class":182},[172,1738,1739],{"class":230},"70",[172,1741,209],{"class":182},[10,1743,1744],{},"The distinction is the whole point. An expected failure — a missing file, an unreachable service —\ngets a sentence, because a traceback tells the user nothing they can act on. An unexpected\nexception is a bug in your code, and the traceback is the bug report.",[10,1746,1747,1748,1751],{},"For development, ",[14,1749,1750],{},"--debug"," should turn the boundary off entirely and let Python print the\ntraceback itself, which keeps the interactive experience honest without changing what users see.",[23,1753,1755],{"id":1754},"testing-what-the-tool-logged","Testing what the tool logged",[10,1757,1758],{},"Logging is easy to leave untested and then break silently. Two small tests cover the cases that\nmatter.",[163,1760,1762],{"className":165,"code":1761,"language":167,"meta":168,"style":168},"import logging\n\ndef test_verbosity_maps_to_levels(caplog):\n    configure_logging(verbosity=1)\n    with caplog.at_level(logging.INFO):\n        logging.getLogger(\"mytool.core\").info(\"started\")\n        logging.getLogger(\"mytool.core\").debug(\"noisy detail\")\n\n    messages = [record.message for record in caplog.records]\n    assert \"started\" in messages\n    assert \"noisy detail\" not in messages       # -v must not enable DEBUG\n",[14,1763,1764,1770,1774,1784,1798,1811,1827,1841,1845,1866,1880],{"__ignoreMap":168},[172,1765,1766,1768],{"class":174,"line":175},[172,1767,179],{"class":178},[172,1769,183],{"class":182},[172,1771,1772],{"class":174,"line":186},[172,1773,190],{"emptyLinePlaceholder":189},[172,1775,1776,1778,1781],{"class":174,"line":193},[172,1777,220],{"class":178},[172,1779,1780],{"class":223}," test_verbosity_maps_to_levels",[172,1782,1783],{"class":182},"(caplog):\n",[172,1785,1786,1789,1792,1794,1796],{"class":174,"line":212},[172,1787,1788],{"class":182},"    configure_logging(",[172,1790,1791],{"class":885},"verbosity",[172,1793,199],{"class":178},[172,1795,1104],{"class":230},[172,1797,209],{"class":182},[172,1799,1800,1803,1806,1808],{"class":174,"line":217},[172,1801,1802],{"class":178},"    with",[172,1804,1805],{"class":182}," caplog.at_level(logging.",[172,1807,1109],{"class":230},[172,1809,1810],{"class":182},"):\n",[172,1812,1813,1816,1819,1822,1825],{"class":174,"line":243},[172,1814,1815],{"class":182},"        logging.getLogger(",[172,1817,1818],{"class":205},"\"mytool.core\"",[172,1820,1821],{"class":182},").info(",[172,1823,1824],{"class":205},"\"started\"",[172,1826,209],{"class":182},[172,1828,1829,1831,1833,1836,1839],{"class":174,"line":271},[172,1830,1815],{"class":182},[172,1832,1818],{"class":205},[172,1834,1835],{"class":182},").debug(",[172,1837,1838],{"class":205},"\"noisy detail\"",[172,1840,209],{"class":182},[172,1842,1843],{"class":174,"line":286},[172,1844,190],{"emptyLinePlaceholder":189},[172,1846,1847,1850,1852,1855,1858,1861,1863],{"class":174,"line":483},[172,1848,1849],{"class":182},"    messages ",[172,1851,199],{"class":178},[172,1853,1854],{"class":182}," [record.message ",[172,1856,1857],{"class":178},"for",[172,1859,1860],{"class":182}," record ",[172,1862,280],{"class":178},[172,1864,1865],{"class":182}," caplog.records]\n",[172,1867,1868,1871,1874,1877],{"class":174,"line":494},[172,1869,1870],{"class":178},"    assert",[172,1872,1873],{"class":205}," \"started\"",[172,1875,1876],{"class":178}," in",[172,1878,1879],{"class":182}," messages\n",[172,1881,1882,1884,1887,1890,1892,1895],{"class":174,"line":503},[172,1883,1870],{"class":178},[172,1885,1886],{"class":205}," \"noisy detail\"",[172,1888,1889],{"class":178}," not",[172,1891,1876],{"class":178},[172,1893,1894],{"class":182}," messages       ",[172,1896,1897],{"class":267},"# -v must not enable DEBUG\n",[163,1899,1901],{"className":165,"code":1900,"language":167,"meta":168,"style":168},"def test_configure_is_idempotent():\n    configure_logging(0)\n    configure_logging(0)\n    assert len(logging.getLogger().handlers) == 1\n",[14,1902,1903,1913,1921,1929],{"__ignoreMap":168},[172,1904,1905,1907,1910],{"class":174,"line":175},[172,1906,220],{"class":178},[172,1908,1909],{"class":223}," test_configure_is_idempotent",[172,1911,1912],{"class":182},"():\n",[172,1914,1915,1917,1919],{"class":174,"line":186},[172,1916,1788],{"class":182},[172,1918,1094],{"class":230},[172,1920,209],{"class":182},[172,1922,1923,1925,1927],{"class":174,"line":193},[172,1924,1788],{"class":182},[172,1926,1094],{"class":230},[172,1928,209],{"class":182},[172,1930,1931,1933,1936,1939,1942],{"class":174,"line":212},[172,1932,1870],{"class":178},[172,1934,1935],{"class":230}," len",[172,1937,1938],{"class":182},"(logging.getLogger().handlers) ",[172,1940,1941],{"class":178},"==",[172,1943,1944],{"class":230}," 1\n",[10,1946,1947],{},"The second one looks trivial and catches a real regression: a configuration function that appends\nrather than replaces produces duplicated output the moment anything calls it twice, and the tests\nare usually where that first happens.",[10,1949,1950,1951,1551],{},"For the stream itself, assert through the CLI runner that errors land on stderr rather than\nstdout — that is the property scripts depend on, and it is the one most easily broken by a stray\n",[14,1952,1953],{},"print",[163,1955,1957],{"className":165,"code":1956,"language":167,"meta":168,"style":168},"def test_errors_go_to_stderr():\n    result = CliRunner(mix_stderr=False).invoke(app, [\"sync\", \"\u002Fdoes\u002Fnot\u002Fexist\"])\n    assert result.exit_code != 0\n    assert result.stdout == \"\"\n    assert \"does not exist\" in result.stderr\n",[14,1958,1959,1968,1999,2012,2024],{"__ignoreMap":168},[172,1960,1961,1963,1966],{"class":174,"line":175},[172,1962,220],{"class":178},[172,1964,1965],{"class":223}," test_errors_go_to_stderr",[172,1967,1912],{"class":182},[172,1969,1970,1973,1975,1978,1981,1983,1985,1988,1991,1993,1996],{"class":174,"line":186},[172,1971,1972],{"class":182},"    result ",[172,1974,199],{"class":178},[172,1976,1977],{"class":182}," CliRunner(",[172,1979,1980],{"class":885},"mix_stderr",[172,1982,199],{"class":178},[172,1984,957],{"class":230},[172,1986,1987],{"class":182},").invoke(app, [",[172,1989,1990],{"class":205},"\"sync\"",[172,1992,258],{"class":182},[172,1994,1995],{"class":205},"\"\u002Fdoes\u002Fnot\u002Fexist\"",[172,1997,1998],{"class":182},"])\n",[172,2000,2001,2003,2006,2009],{"class":174,"line":193},[172,2002,1870],{"class":178},[172,2004,2005],{"class":182}," result.exit_code ",[172,2007,2008],{"class":178},"!=",[172,2010,2011],{"class":230}," 0\n",[172,2013,2014,2016,2019,2021],{"class":174,"line":212},[172,2015,1870],{"class":178},[172,2017,2018],{"class":182}," result.stdout ",[172,2020,1941],{"class":178},[172,2022,2023],{"class":205}," \"\"\n",[172,2025,2026,2028,2031,2033],{"class":174,"line":217},[172,2027,1870],{"class":178},[172,2029,2030],{"class":205}," \"does not exist\"",[172,2032,1876],{"class":178},[172,2034,2035],{"class":182}," result.stderr\n",[23,2037,2039],{"id":2038},"logging-to-a-file-as-well-as-the-terminal","Logging to a file as well as the terminal",[10,2041,2042],{},"Some tools benefit from keeping a rolling record of their own runs — a deployment tool, a\nlong-running sync, anything whose failures get investigated hours later. Two handlers on the same\nroot logger cover it:",[163,2044,2046],{"className":165,"code":2045,"language":167,"meta":168,"style":168},"from logging.handlers import RotatingFileHandler\n\ndef add_file_log(path: Path, *, level: int = logging.DEBUG) -> None:\n    path.parent.mkdir(parents=True, exist_ok=True)\n    handler = RotatingFileHandler(path, maxBytes=2_000_000, backupCount=3, encoding=\"utf-8\")\n    handler.setLevel(level)\n    handler.setFormatter(JsonFormatter())\n    logging.getLogger().addHandler(handler)\n",[14,2047,2048,2060,2064,2093,2116,2155,2160,2165],{"__ignoreMap":168},[172,2049,2050,2052,2055,2057],{"class":174,"line":175},[172,2051,840],{"class":178},[172,2053,2054],{"class":182}," logging.handlers ",[172,2056,179],{"class":178},[172,2058,2059],{"class":182}," RotatingFileHandler\n",[172,2061,2062],{"class":174,"line":186},[172,2063,190],{"emptyLinePlaceholder":189},[172,2065,2066,2068,2071,2074,2076,2079,2081,2083,2085,2087,2089,2091],{"class":174,"line":193},[172,2067,220],{"class":178},[172,2069,2070],{"class":223}," add_file_log",[172,2072,2073],{"class":182},"(path: Path, ",[172,2075,1173],{"class":178},[172,2077,2078],{"class":182},", level: ",[172,2080,421],{"class":230},[172,2082,424],{"class":178},[172,2084,427],{"class":182},[172,2086,1119],{"class":230},[172,2088,433],{"class":182},[172,2090,237],{"class":230},[172,2092,240],{"class":182},[172,2094,2095,2098,2101,2103,2105,2107,2110,2112,2114],{"class":174,"line":212},[172,2096,2097],{"class":182},"    path.parent.mkdir(",[172,2099,2100],{"class":885},"parents",[172,2102,199],{"class":178},[172,2104,947],{"class":230},[172,2106,258],{"class":182},[172,2108,2109],{"class":885},"exist_ok",[172,2111,199],{"class":178},[172,2113,947],{"class":230},[172,2115,209],{"class":182},[172,2117,2118,2120,2122,2125,2128,2130,2133,2135,2138,2140,2143,2145,2148,2150,2153],{"class":174,"line":217},[172,2119,442],{"class":182},[172,2121,199],{"class":178},[172,2123,2124],{"class":182}," RotatingFileHandler(path, ",[172,2126,2127],{"class":885},"maxBytes",[172,2129,199],{"class":178},[172,2131,2132],{"class":230},"2_000_000",[172,2134,258],{"class":182},[172,2136,2137],{"class":885},"backupCount",[172,2139,199],{"class":178},[172,2141,2142],{"class":230},"3",[172,2144,258],{"class":182},[172,2146,2147],{"class":885},"encoding",[172,2149,199],{"class":178},[172,2151,2152],{"class":205},"\"utf-8\"",[172,2154,209],{"class":182},[172,2156,2157],{"class":174,"line":243},[172,2158,2159],{"class":182},"    handler.setLevel(level)\n",[172,2161,2162],{"class":174,"line":271},[172,2163,2164],{"class":182},"    handler.setFormatter(JsonFormatter())\n",[172,2166,2167],{"class":174,"line":286},[172,2168,2169],{"class":182},"    logging.getLogger().addHandler(handler)\n",[10,2171,2172,2173,2176,2177,95],{},"The terminal handler stays at whatever the verbosity flags asked for; the file handler records\neverything at DEBUG in JSON. That combination means the user sees a clean run and you get the full\ndetail when they report a problem — ",[14,2174,2175],{},"mytool doctor --log"," can then print the path rather than\nasking them to reproduce with ",[14,2178,795],{},[10,2180,2181],{},"Put the file under the user's state or cache directory rather than the working directory, cap its\nsize with rotation, and never write it unless the user opted in or the path is somewhere clearly\ntheirs. A tool that silently fills a home directory with logs is a tool people uninstall, so make the location visible in the help text.",[23,2183,2185],{"id":2184},"frequently-asked-questions","Frequently asked questions",[2187,2188,2190,2191,2193],"h3",{"id":2189},"should-i-use-logging-or-just-print-to-stderr","Should I use ",[14,2192,20],{}," or just print to stderr?",[10,2195,2196,2197,2199,2200,2202],{},"Print is fine until the first request for \"more detail when it fails\". Once you find yourself\nwriting ",[14,2198,995],{}," in front of output, ",[14,2201,20],{}," is already cheaper: levels, per-module control,\none place to change the destination, and a formatter swap when a machine needs to read the output.\nMigrating later is mechanical but touches every call site, so the threshold is low.",[2187,2204,2206],{"id":2205},"why-do-my-log-lines-appear-twice","Why do my log lines appear twice?",[10,2208,2209,2210,2212],{},"Almost always a duplicated handler. ",[14,2211,1004],{}," is a no-op if the root logger already has one,\nwhile adding a handler by hand in a function that runs more than once stacks them. Clearing the\nroot handlers at the start of your configuration function makes it idempotent and removes the\nclass of problem.",[2187,2214,2216],{"id":2215},"should-library-code-inside-my-project-configure-logging","Should library code inside my project configure logging?",[10,2218,2219,2220,2223],{},"No — only the entry point configures. Modules get a logger and log; the application decides where\nit goes. That is what lets the same ",[14,2221,2222],{},"core\u002F"," package be imported by a scheduled job, a test, or\nanother program without hijacking its logging setup.",[2187,2225,2227],{"id":2226},"how-do-i-keep-debug-logs-from-leaking-secrets","How do I keep debug logs from leaking secrets?",[10,2229,2230],{},"Never log a settings object or a request wholesale, and redact by key name at the formatter or\nprocessor level so it applies everywhere rather than at each call site. Log a token's presence and\nlength, never its value, and treat a URL with credentials in it as a secret too.",[2187,2232,2234,2235,2238],{"id":2233},"what-about-warningswarn-versus-a-log-warning","What about ",[14,2236,2237],{},"warnings.warn"," versus a log warning?",[10,2240,2241,2244,2245,2247,2248,2251],{},[14,2242,2243],{},"warnings"," is for messages aimed at developers using your package — a deprecated function, an API\nthat will change. A log warning is for the person running the command. In a CLI, most things\npeople reach for ",[14,2246,2243],{}," for should be a ",[14,2249,2250],{},"log.warning",", because the audience is a user watching\nstderr rather than someone reading a Python traceback.",[23,2253,2255],{"id":2254},"related","Related",[28,2257,2258,2265,2270,2274,2280],{},[31,2259,2260,2261],{},"Up: ",[82,2262,2264],{"href":2263},"\u002Fadvanced-input-parsing-user-experience\u002F","Advanced Input Parsing for Python CLIs",[31,2266,2267,2268],{},"Down: ",[82,2269,775],{"href":93},[31,2271,2267,2272],{},[82,2273,789],{"href":101},[31,2275,2276,2277],{},"Sideways: ",[82,2278,2279],{"href":634},"Error handling and exit codes",[31,2281,2276,2282],{},[82,2283,2284],{"href":974},"Interactive terminal UI with Rich",[2286,2287,2288],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":168,"searchDepth":186,"depth":186,"links":2290},[2291,2292,2294,2295,2296,2297,2298,2299,2300,2301,2302,2303,2304,2305,2314],{"id":25,"depth":186,"text":26},{"id":110,"depth":186,"text":2293},"print() versus logging for CLIs",{"id":317,"depth":186,"text":318},{"id":526,"depth":186,"text":527},{"id":642,"depth":186,"text":643},{"id":758,"depth":186,"text":759},{"id":806,"depth":186,"text":807},{"id":979,"depth":186,"text":980},{"id":1056,"depth":186,"text":1057},{"id":1496,"depth":186,"text":1497},{"id":1620,"depth":186,"text":1621},{"id":1754,"depth":186,"text":1755},{"id":2038,"depth":186,"text":2039},{"id":2184,"depth":186,"text":2185,"children":2306},[2307,2309,2310,2311,2312],{"id":2189,"depth":193,"text":2308},"Should I use logging or just print to stderr?",{"id":2205,"depth":193,"text":2206},{"id":2215,"depth":193,"text":2216},{"id":2226,"depth":193,"text":2227},{"id":2233,"depth":193,"text":2313},"What about warnings.warn versus a log warning?",{"id":2254,"depth":186,"text":2255},"2026-07-05","Add configurable logging to Python CLIs: wire the logging module for the terminal, emit JSON logs for machines, and map verbose and quiet flags.","intermediate",false,"md",{},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps",{"title":5,"description":2316},"advanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Findex",[20,2325,2326,2327],"cli","json","errors","2026-08-01","7hH604sLJsuPejfFLcwWf7E2dxE__6F-UXIWKbitM4k",[2331,2334,2337,2340,2343,2346,2349,2352,2355,2358,2361,2364,2367,2370,2373,2376,2379,2381,2384,2387,2390,2393,2396,2399,2402,2403,2406,2409,2412,2415,2418,2421,2424,2427,2430,2433,2436,2439,2442,2445,2448,2451,2454,2457,2460,2463,2466,2469,2472,2475,2478,2481,2484,2487,2490,2493,2496,2499,2502,2505,2508,2511,2514,2517,2520,2523,2526,2529,2532,2535,2538,2541,2544,2547,2550,2553,2556,2559,2562,2565,2568,2571,2574],{"path":2332,"title":2333},"\u002Fabout","About Python CLI Toolcraft",{"path":2335,"title":2336},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies","Advanced Argument Validation Strategies",{"path":2338,"title":2339},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fparsing-nested-json-arguments-in-python-clis","Parsing Nested JSON Args in Python CLIs",{"path":2341,"title":2342},"\u002Fadvanced-input-parsing-user-experience\u002Fadvanced-argument-validation-strategies\u002Fvalidating-file-and-directory-paths-in-clis","Validating File and Directory Paths in CLIs",{"path":2344,"title":2345},"\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":2347,"title":2348},"\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":2350,"title":2351},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation","CLI Help Output and Documentation",{"path":2353,"title":2354},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fversioning-and-deprecating-cli-flags","Versioning and Deprecating CLI Flags",{"path":2356,"title":2357},"\u002Fadvanced-input-parsing-user-experience\u002Fcli-help-output-and-documentation\u002Fwriting-help-text-users-actually-read","Writing Help Text Users Actually Read",{"path":2359,"title":2360},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Fchoosing-exit-codes-for-cli-tools","Choosing Exit Codes for CLI Tools",{"path":2362,"title":2363},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Ffriendly-error-messages-and-tracebacks","Friendly Error Messages and Tracebacks",{"path":2365,"title":2366},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes\u002Fhandling-keyboard-interrupt-cleanly","Handling Keyboard Interrupt Cleanly",{"path":2368,"title":2369},"\u002Fadvanced-input-parsing-user-experience\u002Ferror-handling-and-exit-codes","Error Handling and Exit Codes for CLIs",{"path":2371,"title":2372},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars\u002Fconfig-precedence-flags-env-files-defaults","Config Precedence: Flags, Env, Files, Defaults",{"path":2374,"title":2375},"\u002Fadvanced-input-parsing-user-experience\u002Fhandling-configuration-files-env-vars","Handling Config Files and Env Vars in CLIs",{"path":2377,"title":2378},"\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":2380,"title":2264},"\u002Fadvanced-input-parsing-user-experience",{"path":2382,"title":2383},"\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":2385,"title":2386},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich","Interactive Terminal UI with Rich",{"path":2388,"title":2389},"\u002Fadvanced-input-parsing-user-experience\u002Finteractive-terminal-ui-with-rich\u002Frendering-tables-and-json-with-rich","Rendering Tables and JSON with Rich",{"path":2391,"title":2392},"\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":2394,"title":2395},"\u002Fadvanced-input-parsing-user-experience\u002Fshell-completion-for-python-clis","Shell Completion for Python CLIs",{"path":2397,"title":2398},"\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":2400,"title":2401},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fadding-verbose-and-quiet-logging-flags","Adding Verbose and Quiet Logging Flags",{"path":2321,"title":5},{"path":2404,"title":2405},"\u002Fadvanced-input-parsing-user-experience\u002Fstructured-logging-for-cli-apps\u002Fstructured-json-logging-in-python-clis","Structured JSON Logging in Python CLIs",{"path":2407,"title":2408},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Fdetecting-tty-and-adapting-output","Detecting a TTY and Adapting Output",{"path":2410,"title":2411},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Femitting-json-output-for-scripting","Emitting JSON Output for Scripting",{"path":2413,"title":2414},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Fhandling-broken-pipe-and-sigpipe","Handling Broken Pipe and SIGPIPE",{"path":2416,"title":2417},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes","Working with stdin, stdout and Pipes",{"path":2419,"title":2420},"\u002Fadvanced-input-parsing-user-experience\u002Fworking-with-stdin-stdout-and-pipes\u002Freading-piped-input-in-python-clis","Reading Piped Input in Python CLIs",{"path":2422,"title":2423},"\u002F","Python CLI Toolcraft",{"path":2425,"title":2426},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading","CLI Startup Performance and Lazy Loading",{"path":2428,"title":2429},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Flazy-loading-subcommands-for-faster-startup","Lazy Loading Subcommands for Faster Startup",{"path":2431,"title":2432},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Fprofiling-python-cli-startup-time","Profiling Python CLI Startup Time",{"path":2434,"title":2435},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcli-startup-performance-and-lazy-loading\u002Freducing-cli-dependency-weight","Reducing CLI Dependency Weight",{"path":2437,"title":2438},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fargparse-subparsers-for-subcommands","argparse Subparsers for Subcommands",{"path":2440,"title":2441},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fargparse-vs-click-vs-typer-comparison","argparse vs Click vs Typer Compared",{"path":2443,"title":2444},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse","Command-Line Parsing with argparse",{"path":2446,"title":2447},"\u002Fmodern-python-cli-frameworks-architecture\u002Fcommand-line-parsing-with-argparse\u002Fmigrating-from-argparse-to-typer","Migrating from argparse to Typer",{"path":2449,"title":2450},"\u002Fmodern-python-cli-frameworks-architecture","Python CLI Frameworks and Architecture",{"path":2452,"title":2453},"\u002Fmodern-python-cli-frameworks-architecture\u002Fplugin-architectures-for-extensible-clis","Plugin Architectures for Extensible CLIs",{"path":2455,"title":2456},"\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":2458,"title":2459},"\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":2461,"title":2462},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fdependency-injection-patterns-for-cli-commands","Dependency Injection Patterns for CLI Commands",{"path":2464,"title":2465},"\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":2467,"title":2468},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis","Structuring Multi-Command Python CLIs",{"path":2470,"title":2471},"\u002Fmodern-python-cli-frameworks-architecture\u002Fstructuring-multi-command-python-clis\u002Fsharing-state-with-click-context-objects","Sharing State with Click Context Objects",{"path":2473,"title":2474},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications","Testing Python CLI Applications",{"path":2476,"title":2477},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fmeasuring-cli-test-coverage","Measuring CLI Test Coverage",{"path":2479,"title":2480},"\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":2482,"title":2483},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Fsnapshot-testing-cli-output","Snapshot Testing CLI Output",{"path":2485,"title":2486},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-click-commands-with-clirunner","Testing Click Commands with CliRunner",{"path":2488,"title":2489},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftesting-python-cli-applications\u002Ftesting-interactive-prompts-and-stdin","Testing Interactive Prompts and stdin",{"path":2491,"title":2492},"\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":2494,"title":2495},"\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":2497,"title":2498},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each","Typer vs Click: When to Use Each",{"path":2500,"title":2501},"\u002Fmodern-python-cli-frameworks-architecture\u002Ftyper-vs-click-when-to-use-each\u002Ftyper-callback-functions-explained","Typer callback functions explained",{"path":2503,"title":2504},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter\u002Fcopier-vs-cookiecutter-for-cli-templates","Copier vs Cookiecutter for CLI Templates",{"path":2506,"title":2507},"\u002Fproject-setup-dependency-management\u002Fcli-project-scaffolding-with-cookiecutter","CLI Project Scaffolding with Cookiecutter",{"path":2509,"title":2510},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fbuilding-cross-platform-release-binaries-in-ci","Building Cross-Platform Release Binaries in CI",{"path":2512,"title":2513},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fbundling-a-python-cli-with-pyinstaller","Bundling a Python CLI with PyInstaller",{"path":2515,"title":2516},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries\u002Fhomebrew-and-scoop-packaging-for-python-clis","Homebrew and Scoop Packaging for Python CLIs",{"path":2518,"title":2519},"\u002Fproject-setup-dependency-management\u002Fdistributing-clis-as-standalone-binaries","Distributing CLIs as Standalone Binaries",{"path":2521,"title":2522},"\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":2524,"title":2525},"\u002Fproject-setup-dependency-management","Project Setup & Dependency Management",{"path":2527,"title":2528},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fautomating-changelogs-with-conventional-commits","Automating Changelogs with Conventional Commits",{"path":2530,"title":2531},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs\u002Fexposing-version-info-and-build-metadata","Exposing Version Info and Build Metadata",{"path":2533,"title":2534},"\u002Fproject-setup-dependency-management\u002Fmanaging-cli-versioning-changelogs","Managing CLI Versioning & Changelogs",{"path":2536,"title":2537},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fbuilding-wheels-and-sdists-for-python-clis","Building Wheels and sdists for Python CLIs",{"path":2539,"title":2540},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution","Packaging Python CLIs for Distribution",{"path":2542,"title":2543},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Finstalling-and-distributing-clis-with-pipx","Installing and Distributing CLIs with pipx",{"path":2545,"title":2546},"\u002Fproject-setup-dependency-management\u002Fpackaging-python-clis-for-distribution\u002Fpublishing-a-python-cli-to-pypi","Publishing a Python CLI to PyPI",{"path":2548,"title":2549},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development","Poetry Workflows for CLI Development",{"path":2551,"title":2552},"\u002Fproject-setup-dependency-management\u002Fpoetry-workflows-for-cli-development\u002Fpoetry-entry-points-and-scripts-for-clis","Poetry Entry Points and Scripts for CLIs",{"path":2554,"title":2555},"\u002Fproject-setup-dependency-management\u002Fpre-commit-hooks-for-cli-projects","Pre-commit Hooks for CLI Projects",{"path":2557,"title":2558},"\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":2560,"title":2561},"\u002Fproject-setup-dependency-management\u002Fuv-for-python-cli-dependency-management","uv for Python CLI Dependency Management",{"path":2563,"title":2564},"\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":2566,"title":2567},"\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":2569,"title":2570},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices","Python CLI Env Isolation Best Practices",{"path":2572,"title":2573},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fmanaging-virtual-environments-for-cross-platform-clis","Managing Python CLI Virtual Environments",{"path":2575,"title":2576},"\u002Fproject-setup-dependency-management\u002Fvirtual-environments-isolation-best-practices\u002Fpinning-the-python-version-for-a-cli","Pinning the Python Version for a CLI",1785614690030]