Execution¶
Click Extra bundles a few pre-configured options that control how a CLI runs: how long it takes (--time), how many parallel jobs it may use (--jobs), and what exit code it returns (-0/--zero-exit). Each publishes its resolved value on ctx.meta for downstream code to consume.
Timer¶
Click Extra can measure the execution time of a CLI via a dedicated --time/--no-time option.
Here how to use the standalone decorator:
from time import sleep
from click import command, echo, pass_context
from click_extra import timer_option
@command
@timer_option
def timer():
sleep(0.2)
echo("Hello world!")
$ timer --help
Usage: timer [OPTIONS]
Options:
--time / --no-time Measure and print elapsed execution time.
--help Show this message and exit.
$ timer --time
Hello world!
Execution time: 0.200 seconds.
You can get the timestamp of the CLI start from the context:
from click import command, echo, pass_context
from click_extra import timer_option
@command
@timer_option
@pass_context
def timer_command(ctx):
start_time = ctx.meta["click_extra.start_time"]
echo(f"Start timestamp: {start_time}")
$ timer --time
Start timestamp: 73.040865931
Execution time: 0.000 seconds.
Parallel jobs¶
A pre-configured --jobs option to control parallel execution. It accepts an integer, or one of two keywords: auto (the default: one fewer than the available logical CPU cores, leaving a core free for the main process and system tasks) and max (every available logical CPU core). A value of 0 disables parallelism and runs sequentially.
The option itself does not drive any concurrency: it only captures the user’s intent.
Important
The core count is the number of logical CPUs available to the process: hardware threads, not physical cores. It comes from Python’s os.process_cpu_count() on Python 3.13+, which honors the process’s CPU affinity mask and, on Linux, the cgroup quota, so a container limited to two CPUs counts two, not the host’s full core count. On older runtimes it falls back to os.cpu_count(), which is cgroup-blind. On a CPU with simultaneous multi-threading (Intel Hyper-Threading, AMD SMT) a 4-physical-core chip reports 8. This is deliberately the logical count, since subprocess- and I/O-bound work overlaps well across hardware threads. It can differ from the physical-core counts used elsewhere (psutil.cpu_count(logical=False), or pytest-xdist’s -n auto), so --jobs auto may pick a higher number than a physical-core heuristic would.
from click import command, echo, pass_context
from click_extra import jobs_option
@command
@jobs_option
@pass_context
def build(ctx):
"""Build the project."""
jobs = ctx.meta["click_extra.jobs"]
echo(f"Building with {jobs} parallel jobs.")
$ build --help
Usage: build [OPTIONS]
Build the project.
Options:
--jobs [auto|max|INTEGER] Number of parallel jobs. Accepts an integer, 'auto'
(the host's logical CPUs minus one) or 'max' (all
logical CPUs). 0 runs sequentially. [default:
auto]
--help Show this message and exit.
$ build --jobs 4
Building with 4 parallel jobs.
The auto and max keywords resolve to a core count, keeping the same command portable across machines with different CPU counts:
$ build --jobs max
Building with 4 parallel jobs.
The option’s default is auto (the help screen shows [default: auto]), so a bare invocation resolves exactly like the keyword, on whatever host it runs:
$ build
Building with 3 parallel jobs.
$ build --jobs auto
Building with 3 parallel jobs.
$ build --jobs max
Building with 4 parallel jobs.
How each value resolves across hosts:
Scenario |
|
|
|
|---|---|---|---|
Count cannot be determined |
|
1 |
1 |
Single-CPU host or container |
1 |
1 |
1 |
Two-CPU host |
2 |
2 |
2 |
Four-vCPU CI runner |
4 |
3 |
4 |
Ten-core workstation |
10 |
9 |
10 |
Eight physical cores with SMT |
16 |
15 |
16 |
64-CPU host, cgroup quota of 2, on Python 3.13+ |
2 |
2 |
2 |
The same container, on Python 3.12 and older |
64 |
63 |
64 |
auto reserves one logical CPU for the main process and system tasks, but only from three CPUs up: below that, the reservation would leave a single worker, so the whole machine is used instead. A count of 1 runs sequentially, warned about when an explicit auto/max request collapses to it, silently when it is the option’s own default. The last two rows are os.process_cpu_count() at work: on Python 3.13+ it reads the container’s quota, where older runtimes’ os.cpu_count() sees the whole host and would oversubscribe the allocation.
Warning
A value of 0 disables parallelism: it is rounded up to 1 and a warning notes that execution will run sequentially. Negative values are likewise clamped to 1. When the count exceeds the available logical CPU cores, a warning is logged but the value is honored: the pool is a thread pool, and oversubscription is exactly how I/O- and subprocess-bound work overlaps, so the caveat only bites CPU-bound workloads, where the extra threads just contend for the GIL.
Warning
auto and max express a wish for parallelism, but on a single-CPU host they resolve to a single job and run sequentially. An explicit request (on the command line, or through an environment variable or configuration file) logs a warning when it collapses, so the silent sequential fallback is not mistaken for parallel execution. The option’s own default only logs at info level, sparing every bare invocation on a 1-CPU host a warning it cannot act on. An explicit --jobs 1 is treated as a deliberate sequential choice and stays silent.
Tip
The resolved (clamped, validated) job count is published on ctx.meta as JOBS for downstream code to consume. See the available keys table to read it from your own callbacks. It is also logged at info level alongside the host’s logical CPU count, so --verbosity INFO reveals how many workers a --jobs command will use.
Running jobs in parallel¶
run_jobs(func, items) maps func over items using the resolved --jobs count, so a command with @jobs_option parallelizes its work with no extra plumbing. It reads the worker count from the context (or an explicit jobs= override), runs sequentially when that count is 1 or there is a single item, and otherwise spreads the work across a thread pool. Results are yielded in submission order, like map.
from click import command, echo
from click_extra import jobs_option, run_jobs
@command
@jobs_option
def bake():
"""Bake several items in parallel."""
items = ("apple", "banana", "cherry")
for baked in run_jobs(str.upper, items):
echo(f"Baked {baked}")
$ bake --jobs 2
Baked APPLE
Baked BANANA
Baked CHERRY
The pool is thread-based, which fits the I/O- and subprocess-bound work CLIs usually parallelize (each child releases the GIL).
items is never materialized. Only a bounded window of tasks is queued at a time, sized from the worker count, and the stream is pulled one item further each time a slot frees. So an unbounded generator, or one whose items are expensive to produce, stays memory-flat and is read no further than the caller consumes. Stopping early leaves the rest unread and unscheduled at any worker count, not just at --jobs 1:
from itertools import count
from click import command, echo
from click_extra import jobs_option, run_jobs
@command
@jobs_option
def bake():
"""Stop at the first batch, however endless the conveyor belt is."""
for baked in run_jobs(str, count()):
echo(f"Baked {baked}")
if baked == "0":
break
$ bake --jobs 4
Baked 0
Running lanes in parallel¶
Sometimes work cannot all run concurrently: a subset must be serialized relative to itself (a shared lock, a rate limit, one mailbox file read at a time, one package-manager backend) while still overlapping with unrelated subsets. run_lanes(func, lanes) groups items into lanes: a lane’s own items run serially and in order on a single worker, while distinct lanes run concurrently up to the resolved --jobs count. run_jobs is the degenerate case where every lane holds a single item.
from click import command, echo
from click_extra import jobs_option, run_lanes
@command
@jobs_option
def bake():
"""Bake several trays: items on a tray bake in order, trays bake in parallel."""
trays = (("apple", "banana"), ("cherry",))
for baked in run_lanes(str.upper, trays):
echo(f"Baked {baked}")
$ bake --jobs 2
Baked APPLE
Baked BANANA
Baked CHERRY
Concurrency is sized by the number of lanes (one worker per lane), and results are yielded in lane-submission order. Because a lane runs entirely on one worker, a stateful resource bound to that lane (a per-lane cache, a connection) is touched by only one thread and needs no lock.
Lanes are read as lazily as run_jobs reads items: a lane is turned into a list only when it is about to be scheduled, and only a window of them is in flight. A stream of lanes never sits in memory all at once.
Resolving the job count¶
run_jobs and run_lanes decide their worker count internally, but a caller that must know it before fanning out (for example to pick a progress-rendering mode) can call resolve_jobs(ctx, count) directly. It applies the same policy those helpers do: 1 (sequential) when there is no context, a single item, or --jobs 1, otherwise the resolved count capped at count. Passing serial_at_debug=True also collapses to sequential at DEBUG verbosity, where coherent per-worker log narration matters more than the speed-up; both helpers forward this flag.
The helpers themselves pass no cap, because a lazy stream cannot report its length without being drained. Every other part of the policy still applies, and the sizing is left to the pool: a thread pool spawns its threads on demand, so a ceiling above the number of items costs nothing.
Zero exit code¶
A pre-configured -0/--zero-exit option flag, following the convention popularized by linters and static analysers: they exit with a non-zero code whenever they report findings, so automation can gate on it. Setting this flag flips that behavior, so the CLI returns 0 as long as it ran to completion, reserving non-zero codes for actual execution failures.
The option itself does not alter the exit code: it only captures the user’s intent.
from click import command, echo, pass_context
from click_extra import zero_exit_option
@command
@zero_exit_option
@pass_context
def inspect(ctx):
"""Inspect a basket of fruits."""
bruised = 2
echo(f"Found {bruised} bruised fruits.")
if bruised and not ctx.meta["click_extra.zero_exit"]:
ctx.exit(1)
$ inspect --help
Usage: inspect [OPTIONS]
Inspect a basket of fruits.
Options:
-0, --zero-exit Always exit with a status code of 0, even when problems are
found.
--help Show this message and exit.
By default the command reports a non-zero exit code when it finds problems:
$ inspect
Found 2 bruised fruits.
With --zero-exit (or its -0 shorthand) the command still reports its findings but always exits with 0:
$ inspect --zero-exit
Found 2 bruised fruits.
Tip
The resolved flag is published on ctx.meta as ZERO_EXIT for downstream code to consume. See the available keys table to read it from your own callbacks.
click_extra.execution API¶
classDiagram
ExtraOption <|-- JobsOption
ExtraOption <|-- TimerOption
ExtraOption <|-- ZeroExitOption
ParamType <|-- JobCount
Type for arbitrary nested CLI arguments.
Arguments can be str, pathlib.Path objects or None values.
- click_extra.execution.TNestedArgs
Type for arbitrary nested CLI arguments.
Arguments can be
str,pathlib.Pathobjects orNonevalues.alias of
Iterable[str|Path|None|Iterable[TNestedArgs]]
- click_extra.execution.CPU_COUNT = 4
Number of logical CPUs available to this process, or
Noneif undetermined.A count of logical processors (hardware threads), resolved by
_logical_cpu_count():os.process_cpu_count()on Python 3.13+, falling back toos.cpu_count()on older runtimes. On a CPU with simultaneous multi-threading (Intel Hyper-Threading, AMD SMT) a 4-physical-core chip reports8. It is therefore not a count of physical cores, and is usually larger than what physical-core tools report, such aspsutil.cpu_count(logical=False)or pytest-xdist’s-n auto(which counts physical cores). Parallelism here is keyed on the logical count on purpose: subprocess- and I/O-bound work overlaps well across hardware threads.
- click_extra.execution.DEFAULT_JOBS = 3
Default number of parallel jobs:
CPU_COUNTminus one reserved core.Leaves one logical CPU free for the main process and system tasks, but only on hosts with three logical CPUs or more: on smaller hosts the reservation would collapse the pool to a single (sequential) worker, and threads waiting on subprocesses and I/O cost nothing there, so the whole machine is used instead. Falls back to
1(sequential) when the count cannot be determined.Caution
On a single-CPU host this still resolves to
1and the default silently runs sequentially.JobCount.convert()logs whenever a parallel-intent keyword collapses to a single job this way: as a warning for an explicit request, at info level for the option’s own default.
- class click_extra.execution.JobCount[source]
Bases:
ParamTypeParse a
--jobsvalue: an integer or theauto/maxkeyword.Resolves the symbolic keywords against the host’s logical CPU count (
CPU_COUNT), counting hardware threads, not physical cores:autoresolves toDEFAULT_JOBS(one fewer than the available logical CPUs, except on hosts with fewer than three, where reserving a core would leave a single worker), the same heuristic used as the option’s default.maxresolves toCPU_COUNT(every available logical CPU).
Any other token is parsed as an integer and left to
JobsOption.validate_jobs()for clamping and range-checking. Resolving the keywords here keeps the value handed downstream a plainint, so consumers never have to know about the keywords.- name: str = 'jobs'
the descriptive name of this type
- choices = ('auto', 'max')
Symbolic keywords accepted besides an integer count, in render order.
Exposed as
choicesso the help colorizer highlights them likeclick.Choicevalues: the keyword collector duck-types on this attribute (see thegetattr(param.type, "choices", ...)branch in_HelpColorsMixin._collect_params). It is also the single source of truth reused byget_metavar()andconvert(), so the metavar and the parser never drift apart.
- get_metavar(param, ctx=None)[source]
Render
[auto|max|INTEGER](brackets included, asChoicedoes).
- convert(value, param, ctx)[source]
Resolve a keyword to a logical-core count, else parse as an integer.
An already-resolved integer is returned untouched, so option defaults and re-validation can flow back through conversion unharmed. When a parallel-intent keyword (
auto/max) resolves to a single job, the collapse is logged: the request reads as “use several cores”, but the host has too few logical CPUs, so execution is silently sequential. An explicit request (command line, environment variable, config file) logs a warning; the option’s own default only logs at info level, else every bare invocation on a 1-CPU host would emit a warning the user never asked for, polluting captured runner streams and the CLI output rendered in Sphinx docs.- Return type:
- shell_complete(ctx, param, incomplete)[source]
Suggest the
auto/maxkeywords; an integer count is free-form.Completion proposes only the symbolic keywords, matched case-insensitively to mirror how
convert()lower-cases its input. An integer has no finite set to enumerate, so none is offered, yetconvert()still accepts one.- Return type:
- class click_extra.execution.JobsOption(param_decls=None, default='auto', expose_value=False, show_default=True, type=<click_extra.execution.JobCount object>, help="Number of parallel jobs. Accepts an integer, 'auto' (the host's logical CPUs minus one) or 'max' (all logical CPUs). 0 runs sequentially.", **kwargs)[source]
Bases:
ExtraOptionA pre-configured
--jobsoption to control parallel execution.Accepts an integer or one of two keywords resolved by
JobCount:auto(the default: one fewer than the available logical CPU cores, leaving a core free for the main process and system tasks, except on hosts with fewer than three logical CPUs, where reserving one would leave a single worker) andmax(every available logical CPU core). A value of0disables parallelism and runs sequentially.The core count is the number of logical CPUs (hardware threads) available to the process, not physical cores: see
CPU_COUNT. On a host with a single logical CPU,auto/maxresolve to a single job andJobCountlogs that execution will be sequential: as a warning when the keyword was requested explicitly, at info level when it came from the option’s own default.The resolved value is stored as an
intinctx.meta[click_extra.context.JOBS].Warning
JobsOptiononly resolves and publishes the job count: it does not drive any concurrency by itself. Pass it torun_jobs()(which reads the resolvedctx.meta[click_extra.context.JOBS]count), or read that value yourself and act on it.- validate_jobs(ctx, param, value)[source]
Validate the resolved job count and store it in context metadata.
JobCounthas already resolved anyauto/maxkeyword to an integer by the time this runs. A value of0disables parallelism: it is rounded up to1(sequential execution) with a warning. Negative values are likewise clamped to1. A count above the available cores is honored: the pool is aThreadPoolExecutor, and oversubscription is how I/O- and subprocess-bound work overlaps, so the warning only flags the CPU-bound case where extra threads just contend for the GIL. The resolved count is then logged at info level next to the host’s logical CPU count (CPU_COUNT), so a CLI’s parallelism is visible under--verbosity INFO.- Return type:
- click_extra.execution.resolve_jobs(ctx, count, *, serial_at_debug=False)[source]
Resolve how many worker threads to use for a batch of
countitems.Returns the number of items to process in parallel;
1means run sequentially in the calling thread. This is the policy shared byrun_jobs()andrun_lanes(), exposed on its own for callers that must know the resolved count before they fan out (for example to pick a progress-rendering mode). It collapses to sequential when:there is no active CLI context (programmatic or test use),
a single item leaves nothing to parallelize, or
the resolved
JobsOptioncount (ctx.meta[click_extra.context.JOBS]) is1or less.
Otherwise that count wins, capped at
count: there is no point spinning up more workers than there are items.- Parameters:
ctx (
Context|None) – the active Click context, read for the resolved--jobscount (and, withserial_at_debug, the verbosity).Noneforces sequential.count (
int) – how many items are about to be scheduled.serial_at_debug (
bool) – when set, also collapse to sequential atDEBUGverbosity, where coherent per-worker log narration matters more than the speed-up (interleaved threads would scramble it). Off by default.
- Return type:
- click_extra.execution.run_jobs(func, items, *, jobs=None, serial_at_debug=False)[source]
Run
funcoveritems, parallelized per the resolved--jobscount.The worker count is taken from
jobswhen given, else resolved from the active command’sJobsOptionvalue byresolve_jobs(), else1. With a single worker (or at most one item) the items run sequentially and lazily, so a caller can stop early on the first result (for example to abort on the first failure); otherwise they run in a thread pool. Either way results are yielded in submission order, likemap().This is the single-task-per-item special case of
run_lanes()(every item is its own lane). Reach forrun_lanes()when some items must run serially relative to one another while others run concurrently.The pool is thread-based, which suits the I/O- and subprocess-bound work CLI tools usually parallelize (each child releases the GIL). The count is a number of logical CPUs: see
CPU_COUNT.itemsis never materialized: only a bounded window of tasks is queued at a time, so an unbounded or expensive-to-produce stream stays memory-flat and is read no further than the caller consumes.- Parameters:
func (
Callable[[TypeVar(T)],TypeVar(R)]) – Called once per item; its return value is yielded.items (
Iterable[TypeVar(T)]) – The work items. Read lazily, a window at a time.jobs (
int|None) – Override the worker count instead of reading it from the context.1or fewer forces sequential execution.serial_at_debug (
bool) – forwarded toresolve_jobs()whenjobsis not given: collapse to sequential atDEBUGverbosity.
- Return type:
- Returns:
An iterator over
func’s results, in the order ofitems.
- click_extra.execution.run_lanes(func, lanes, *, jobs=None, serial_at_debug=False)[source]
Run
funcover grouped items: serial within a lane, concurrent across.Each lane is an iterable of items.
funcis mapped over every item, but a lane’s own items run serially and in order on a single worker, while distinct lanes run concurrently up to the resolved--jobscount. This is the right primitive when some work must be serialized relative to itself (a shared lock, a rate limit, one mailbox file, one package-manager backend) yet still overlap with unrelated work.run_jobs()is the degenerate case where every lane holds a single item. Concurrency is sized by the number of lanes (one worker per lane), since a lane never splits across workers.Results are yielded in lane-submission order, a lane’s items in order, like
map(). The run stays lazy at any worker count: a lane is materialized only when it is about to be scheduled, and only a bounded window of lanes is in flight, so a caller can break early and the lanes behind it are never read. A lane runs entirely on one worker, so a stateful resource bound to the lane (a per-lane cache, a connection) is touched by only that one thread and needs no lock.- Parameters:
func (
Callable[[TypeVar(T)],TypeVar(R)]) – Called once per item; its return value is yielded.lanes (
Iterable[Iterable[TypeVar(T)]]) – The lanes, each an iterable of items. Read lazily, a window of lanes at a time; a lane’s own items are materialized when it is scheduled.jobs (
int|None) – Override the worker count instead of reading it from the context.1or fewer forces fully sequential execution.serial_at_debug (
bool) – forwarded toresolve_jobs()whenjobsis not given: collapse to sequential atDEBUGverbosity.
- Return type:
- Returns:
An iterator over
func’s results, lane by lane in submission order.
- class click_extra.execution.TimerOption(param_decls=None, default=False, expose_value=False, is_eager=True, help='Measure and print elapsed execution time.', **kwargs)[source]
Bases:
ExtraOptionA pre-configured option that is adding a
--time/--no-timeflag to print elapsed time at the end of CLI execution.The start time is made available in the context in
ctx.meta[click_extra.context.START_TIME].- print_timer()[source]
Compute and print elapsed execution time.
Always prints, even when a sibling eager option (
--version,--params,--show-config…) short-circuited the command body viactx.exit(). That makes--timea usable probe for the cost of Click Extra’s own machinery (option parsing, config loading, eager callbacks), not just user command bodies.- Return type:
- init_timer(ctx, param, value)[source]
Set up the execution-timer machinery for the current invocation.
Captures
time.perf_counter()as the start time, stores it onctx.metaunderclick_extra.context.START_TIME, and queuesprint_timer()as a context-close callback so the elapsed duration is printed even when a sibling eager option (--version,--params…) short-circuits the command body.- Return type:
- class click_extra.execution.ZeroExitOption(param_decls=None, default=False, expose_value=False, is_flag=True, help='Always exit with a status code of 0, even when problems are found.', **kwargs)[source]
Bases:
ExtraOptionA pre-configured
-0/--zero-exitoption flag.Follows the convention popularized by linters and static analysers, which exit with a non-zero code whenever they report findings so that automation can gate on it. Setting this flag flips that behavior: the CLI returns
0as long as it ran to completion, reserving non-zero codes for actual execution failures.The resolved value is stored in
ctx.meta[click_extra.context.ZERO_EXIT], aligning with every other Click Extra option’s per-invocation context-meta storage pattern.Warning
This option is a placeholder: it does not alter the CLI’s exit code by itself. Downstream code must read
ctx.meta[click_extra.context.ZERO_EXIT]and act on it.- set_zero_exit(ctx, param, value)[source]
Store the resolved zero-exit flag on the context’s
metadict.Read via
click_extra.context.get(ctx, click_extra.context.ZERO_EXIT).- Return type:
- click_extra.execution.PROMPT = '$ '
Prompt used to simulate the CLI execution.
Hint
Use ASCII characters to avoid issues with Windows terminals.
- click_extra.execution.INDENT = ' '
Constants for rendering of CLI execution.
- click_extra.execution.args_cleanup(*args)[source]
Flatten recursive iterables, remove all
None, and cast each element to strings.Helps serialize
pathlib.Pathand other objects.It also allows for nested iterables and
Nonevalues as CLI arguments for convenience. We just need to flatten and filters them out.
- click_extra.execution.highlight_bin_name(program, theme=None)[source]
Style the binary’s own name inside
program, leaving its directory plain./opt/homebrew/bin/masrenders with onlymasin the active theme’sinvoked_commandstyle, so the part of the path the eye scans for stands out from the noise of its location. A bare name (no separator) is styled whole. Both POSIX and Windows separators are recognized, whichever comes last.- Parameters:
program (
str) – the command, path and all.theme (
HelpTheme|None) – palette to style with. Defaults to the theme the current invocation runs under, seeformat_cli_prompt().
- Return type:
- Returns:
the styled command.
- click_extra.execution.format_cli_prompt(cmd_args, extra_env=None, theme=None, prompt=None)[source]
Render the shell prompt simulating a CLI invocation, for logs and dry-runs.
Prefixes
PROMPTto anyextra_envassignments and the command line. Each token family is styled with the theme slot (get_current_theme()) it holds elsewhere in a CLI’s output, so the line reads like the help screens do:the prompt sigil with
bracket, the structural-token style;each environment assignment as
envvarname, plain=,defaultvalue;the program’s binary name with
invoked_command, its directory plain (seehighlight_bin_name());the
-/--flags withoption; other arguments stay plain.
Useful to print a copy-pasteable command trace in debug logs, dry-runs and test output.
- Parameters:
extra_env (
Mapping[str,str|None] |None) – environment assignments to prefix it with.theme (
HelpTheme|None) – palette to style the line with. Defaults to the theme the current invocation runs under, which is what a CLI printing its own trace wants. A caller drawing the line onto a surface of its own choosing (a light capture, say) passes the one that surface can show.prompt (
str|None) – sigil to draw before the command, when the shell being pictured is not the one running. A capture mimicking a Windows terminal passesPS C:\>;NonekeepsPROMPT, which is this platform’s.
- Return type:
- Returns:
the styled prompt line.
- click_extra.execution.terminate_live_processes()[source]
Send
SIGTERMto every subprocess currently running throughrun_cli().Called from the main thread’s
SIGINThandler (seeinstall_interrupt_handler()) so a concurrent fan-out aborts promptly: terminating the children unblocks the worker threads parked inrun_cli(), letting the thread pool drain instead of hanging on a child that ignored the terminal’s process-groupSIGINT.A child spawned with
start_new_sessionnever receives the terminal’sSIGINTat all (it left the foreground process group), so its whole group is signalled here, descendants included.Uses
SIGTERMrather thanSIGKILLso a child still gets to clean up, notably to restore terminal state asudopassword prompt may have altered. The registry is snapshotted under the lock, then signalled outside it, becauserun_cli()may be discarding its own entries from other threads at the same time.- Return type:
- click_extra.execution.install_interrupt_handler(ctx)[source]
Make the first Ctrl+C terminate in-flight subprocesses, then abort as usual.
Installs a
SIGINThandler for the duration of the CLI run that callsterminate_live_processes()before re-raisingKeyboardInterrupt(exactly what Python’s default handler raises). The abort then proceeds normally, but a concurrent fan-out no longer hangs on surviving children. The previous handler is restored whenctxcloses.Must run in the main thread:
signal.signal()refuses to install a handler from any other, so a non-main-thread caller (embedded use, some tests) is a no-op that keeps the default Ctrl+C behavior.A signal handler is required here rather than a
try/except KeyboardInterruptaround the fan-out: Python delivers Ctrl+C only to the main thread, so worker threads never see the interrupt, and the exception unwinds through the executor’s blockingshutdown(wait=True)teardown before anyexceptin the caller could run. The children must be killed at signal-delivery time, ahead of that teardown.- Return type:
- click_extra.execution.run_cli(args, *, extra_env=None, cwd=None, timeout=None, label=None, merge_streams=False, errors='replace', windows_creation_flags=0, start_new_session=False, command_level=20, output_level=10, log=None)[source]
Run a CLI in a subprocess, disclosing the call and streaming its output live.
A
subprocess.run()work-alike for CLI-wrapping tools, with observability built in:the invocation is logged before the spawn, as the copy-pasteable
$ ENV=value command argsline offormat_cli_prompt(), so a user can reproduce by hand what the tool runs on their system;each line of the child’s output is forwarded to the logger as it is produced (ANSI-stripped, tagged with
label), instead of being held back until the child exits, so a long-running command narrates its progress live;the child is registered in the live-process registry for the duration of the call, so
terminate_live_processes()(wired to Ctrl+C byinstall_interrupt_handler()) can abort it.
Contract mirrored from
subprocess.run():returns a
subprocess.CompletedProcesswith the full capturedstdoutandstderrdecoded as UTF-8;raises
subprocess.TimeoutExpired(with the partial capture attached) when the child, or the draining of its output, outlivestimeout. The child is killed first — its whole process tree on Windows (see_kill_windows_process_tree()), its whole POSIX process group when spawned withstart_new_session, the direct child alone otherwise;a
KeyboardInterruptmid-run kills the child (with the same tree, group or direct scope), then propagates.
The child reads from
subprocess.DEVNULLso it can never block onstdin, and never opens a console window on Windows.Note
The pipes are opened in universal-newlines text mode, so a bare
\r(a child redrawing a progress bar in place) terminates a line just like\n: each redraw is streamed as its own log line, and the captured text normalizes both to\n, exactly assubprocess.Popen.communicate()does.- Parameters:
args (
str|Path|None|Iterable[str|Path|None|Iterable[Iterable[str|Path|None|Iterable[TNestedArgs]]]]) – the command line. Nested iterables are flattened,Nonevalues dropped, and every element (Path, versions, …) cast to a string; seeargs_cleanup().extra_env (
Mapping[str,str|None] |None) – environment variables forced over the inherited environment for this call (seeenv_copy()). They are part of the disclosed prompt line, since reproducing the call requires them.cwd (
Path|str|None) – directory to run the child in.Noneinherits the caller’s, which issubprocess.run()’s own default. A relativeargs[0]is resolved by the OS against this directory, not the caller’s, so pass an absolute path (or a name on thePATH) when moving the child elsewhere.timeout (
float|None) – seconds before the child is killed.Nonewaits forever.label (
str|None) – tag identifying this call on each streamed output line, for when several children interleave in one log. Carried as the record’slabelattribute, whichclick_extra.logging.Formatterrenders glued to the level name and styled like an invoked command (debug:mas: Warning: ...); a foreign formatter can readrecord.labelitself. Applied to the output lines only, never the prompt line.merge_streams (
bool) – route the child’sstderrintostdoutso the OS interleaves both in write order. The result’sstderris thenNone, like asubprocess.run()call withstderr=STDOUT.errors (
str) – decoding error handler for the child’s output. The default"replace"swaps undecodable bytes for�; pass"backslashreplace"to keep them inspectable as escapes.windows_creation_flags (
int) – extra Windows process-creation flags, OR-ed with the always-onCREATE_NO_WINDOW. No-op off Windows.start_new_session (
bool) – make the child lead its own POSIX session and process group (subprocess.Popen’s parameter of the same name). Every kill path — thetimeoutoverrun, a mid-runKeyboardInterrupt, andterminate_live_processes()— then signals the whole group, so a grandchild spawned by the child (a shim re-executing the real binary, an installer helper) is reaped along with it instead of surviving as an orphan holding the output pipes open. Off by default, and to be left off when a descendant must keep the controlling terminal: a new session detaches from it, so an interactive prompt raised from inside the child (sudoreading/dev/tty) would fail, andsudo’s tty-keyed credential cache would no longer match. No-op on Windows, where the timeout path already kills the full tree. Only the reaping half of this flag has that Windows equivalent, and the other half has none: a POSIX session also detaches the child from the controlling terminal, where a Windows child keeps sharing the parent’s console and can still reach back into it. A subprocess-heavy test suite is where that surfaces —meta-package-manager’s Windows CI saw a package manager’s own teardown land in the parentpytestprocess as a mid-runKeyboardInterrupt, which a console control event on the shared console explains and which no POSIX runner showed. The lever there iswindows_creation_flags(CREATE_NEW_PROCESS_GROUP), left to the caller because it also changes how a real Ctrl-C reaches the child.command_level (
int) – logging level of the invocation-disclosure line. Defaults tologging.INFO; lower it tologging.DEBUGfor internal probes not worth narrating.output_level (
int) – logging level of the streamed output lines. Defaults tologging.DEBUG.log (
Logger|None) – destination logger. Defaults to the root logger, whose level theVerbosityOptionfamily manages.
- Return type: