Skip to content

Commit 9366927

Browse files
committed
Add run_lanes(func, lanes) and resolve_jobs(ctx, count)
1 parent 65a245c commit 9366927

6 files changed

Lines changed: 293 additions & 12 deletions

File tree

changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
- Add a `--theme=auto` value that selects the `dark` or `light` palette from the detected terminal background. Accepted on every `@command` and `@group`; the `--theme` default stays `dark`.
1414
- Detect the terminal background from the `CLITHEME` and `COLORFGBG` environment variables, and, when a `ThemeOption` is built with `query_background=True`, from a live xterm OSC 11 terminal query.
1515
- Add `resolve_background` and `query_osc_background` to `click_extra.color`, and `resolve_auto_theme` and the `AUTO_THEME` constant to `click_extra.theme`.
16+
- Add `run_lanes(func, lanes)` to `click_extra.execution`: a fan-out that runs each lane's items serially while running distinct lanes concurrently, sized by the resolved `--jobs` count. `run_jobs` is its single-item-per-lane special case.
17+
- Add `resolve_jobs(ctx, count)` to `click_extra.execution`, exposing the worker-count policy shared by `run_jobs` and `run_lanes` for callers that need the resolved count before fanning out. Both helpers gain a `serial_at_debug` keyword that collapses to sequential at `DEBUG` verbosity.
18+
- Publish `format_cli_prompt` in `click_extra.testing` (was the private `_format_cli_prompt`): render a themed, copy-pasteable prompt simulating a CLI invocation.
1619

1720
## [`8.1.4` (2026-06-27)](https://github.com/kdeldycke/click-extra/compare/v8.1.3...v8.1.4)
1821

click_extra/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,9 @@
150150
JobsOption,
151151
TimerOption,
152152
ZeroExitOption,
153+
resolve_jobs,
153154
run_jobs,
155+
run_lanes,
154156
)
155157
from .highlight import (
156158
HelpFormatter,
@@ -217,7 +219,7 @@
217219
parse_test_suite,
218220
run_test_suite,
219221
)
220-
from .testing import CliRunner, Result
222+
from .testing import CliRunner, Result, format_cli_prompt
221223
from .theme import (
222224
BUILTIN_THEMES,
223225
HelpTheme,
@@ -355,6 +357,7 @@
355357
"edit",
356358
"file_path",
357359
"flatten_config_keys",
360+
"format_cli_prompt",
358361
"format_filename",
359362
"format_from_path",
360363
"format_param_row",
@@ -404,8 +407,10 @@
404407
"render_manpages",
405408
"render_table",
406409
"require_sibling_param",
410+
"resolve_jobs",
407411
"run_config_validation",
408412
"run_jobs",
413+
"run_lanes",
409414
"run_test_suite",
410415
"search_params",
411416
"secho",

click_extra/execution.py

Lines changed: 113 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -279,20 +279,64 @@ def __init__(
279279
)
280280

281281

282+
def resolve_jobs(
283+
ctx: click.Context | None,
284+
count: int,
285+
*,
286+
serial_at_debug: bool = False,
287+
) -> int:
288+
"""Resolve how many worker threads to use for a batch of ``count`` items.
289+
290+
Returns the number of items to process in parallel; ``1`` means run
291+
sequentially in the calling thread. This is the policy shared by
292+
:func:`run_jobs` and :func:`run_lanes`, exposed on its own for callers that
293+
must know the resolved count *before* they fan out (for example to pick a
294+
progress-rendering mode). It collapses to sequential when:
295+
296+
- there is no active CLI context (programmatic or test use),
297+
- a single item leaves nothing to parallelize, or
298+
- the resolved :class:`JobsOption` count
299+
(``ctx.meta[click_extra.context.JOBS]``) is ``1`` or less.
300+
301+
Otherwise that count wins, capped at ``count``: there is no point spinning up
302+
more workers than there are items.
303+
304+
:param ctx: the active Click context, read for the resolved ``--jobs`` count
305+
(and, with ``serial_at_debug``, the verbosity). ``None`` forces sequential.
306+
:param count: how many items are about to be scheduled.
307+
:param serial_at_debug: when set, also collapse to sequential at ``DEBUG``
308+
verbosity, where coherent per-worker log narration matters more than the
309+
speed-up (interleaved threads would scramble it). Off by default.
310+
"""
311+
if count <= 1 or ctx is None:
312+
return 1
313+
# Compared against the stdlib level rather than click_extra.logging.LogLevel
314+
# (which mirrors it) to keep this module free of a logging-module import cycle.
315+
if serial_at_debug and context.get(ctx, context.VERBOSITY_LEVEL) == logging.DEBUG:
316+
return 1
317+
jobs = context.get(ctx, context.JOBS, 1)
318+
return min(jobs, count) if jobs > 1 else 1
319+
320+
282321
def run_jobs(
283322
func: Callable[[T], R],
284323
items: Iterable[T],
285324
*,
286325
jobs: int | None = None,
326+
serial_at_debug: bool = False,
287327
) -> Iterator[R]:
288328
"""Run ``func`` over ``items``, parallelized per the resolved ``--jobs`` count.
289329
290-
The worker count is taken from ``jobs`` when given, else from the active
291-
command's :class:`JobsOption` value (``ctx.meta[click_extra.context.JOBS]``),
292-
else ``1``. With a single worker (or at most one item) the items run
293-
**sequentially and lazily**, so a caller can stop early on the first result
294-
(for example to abort on the first failure); otherwise they run in a thread
295-
pool. Either way results are yielded in submission order, like :func:`map`.
330+
The worker count is taken from ``jobs`` when given, else resolved from the
331+
active command's :class:`JobsOption` value by :func:`resolve_jobs`, else ``1``.
332+
With a single worker (or at most one item) the items run **sequentially and
333+
lazily**, so a caller can stop early on the first result (for example to abort
334+
on the first failure); otherwise they run in a thread pool. Either way results
335+
are yielded in submission order, like :func:`map`.
336+
337+
This is the single-task-per-item special case of :func:`run_lanes` (every item
338+
is its own lane). Reach for :func:`run_lanes` when some items must run serially
339+
relative to one another while others run concurrently.
296340
297341
The pool is thread-based, which suits the I/O- and subprocess-bound work CLI
298342
tools usually parallelize (each child releases the GIL). The count is a
@@ -302,13 +346,15 @@ def run_jobs(
302346
:param items: The work items. Materialized up front to size the pool.
303347
:param jobs: Override the worker count instead of reading it from the
304348
context. ``1`` or fewer forces sequential execution.
349+
:param serial_at_debug: forwarded to :func:`resolve_jobs` when ``jobs`` is not
350+
given: collapse to sequential at ``DEBUG`` verbosity.
305351
:return: An iterator over ``func``'s results, in the order of ``items``.
306352
"""
353+
work = list(items)
307354
if jobs is None:
308355
ctx = click.get_current_context(silent=True)
309-
jobs = context.get(ctx, context.JOBS, 1) if ctx is not None else 1
356+
jobs = resolve_jobs(ctx, len(work), serial_at_debug=serial_at_debug)
310357

311-
work = list(items)
312358
if jobs <= 1 or len(work) <= 1:
313359
# Sequential and lazy: the caller can break early (for example on the
314360
# first failure) and the remaining items never run.
@@ -321,6 +367,65 @@ def run_jobs(
321367
yield from executor.map(func, work)
322368

323369

370+
def run_lanes(
371+
func: Callable[[T], R],
372+
lanes: Iterable[Iterable[T]],
373+
*,
374+
jobs: int | None = None,
375+
serial_at_debug: bool = False,
376+
) -> Iterator[R]:
377+
"""Run ``func`` over grouped items: serial within a lane, concurrent across.
378+
379+
Each *lane* is an iterable of items. ``func`` is mapped over every item, but a
380+
lane's own items run **serially and in order** on a single worker, while distinct
381+
lanes run **concurrently** up to the resolved ``--jobs`` count. This is the right
382+
primitive when some work must be serialized relative to itself (a shared lock, a
383+
rate limit, one mailbox file, one package-manager backend) yet still overlap with
384+
unrelated work.
385+
386+
:func:`run_jobs` is the degenerate case where every lane holds a single item.
387+
Concurrency is sized by the *number of lanes* (one worker per lane), since a
388+
lane never splits across workers.
389+
390+
Results are yielded in lane-submission order, a lane's items in order, like
391+
:func:`map`. With a single worker the run stays lazy (the caller can break
392+
early); otherwise every lane is submitted up front. A lane runs entirely on one
393+
worker, so a stateful resource bound to the lane (a per-lane cache, a connection)
394+
is touched by only that one thread and needs no lock.
395+
396+
:param func: Called once per item; its return value is yielded.
397+
:param lanes: The lanes, each an iterable of items. Materialized up front.
398+
:param jobs: Override the worker count instead of reading it from the context.
399+
``1`` or fewer forces fully sequential execution.
400+
:param serial_at_debug: forwarded to :func:`resolve_jobs` when ``jobs`` is not
401+
given: collapse to sequential at ``DEBUG`` verbosity.
402+
:return: An iterator over ``func``'s results, lane by lane in submission order.
403+
"""
404+
lane_list = [list(lane) for lane in lanes]
405+
if not lane_list:
406+
return
407+
if jobs is None:
408+
ctx = click.get_current_context(silent=True)
409+
jobs = resolve_jobs(ctx, len(lane_list), serial_at_debug=serial_at_debug)
410+
elif jobs > 1:
411+
jobs = min(jobs, len(lane_list))
412+
413+
if jobs <= 1:
414+
# Sequential and lazy across every lane and item: the caller can break early.
415+
for lane in lane_list:
416+
for item in lane:
417+
yield func(item)
418+
else:
419+
# Each lane is a serial chain run on one worker; chains run concurrently and
420+
# their results are yielded in submission order.
421+
def run_chain(lane: list[T]) -> list[R]:
422+
return [func(item) for item in lane]
423+
424+
with ThreadPoolExecutor(max_workers=jobs) as executor:
425+
for chain_results in executor.map(run_chain, lane_list):
426+
yield from chain_results
427+
428+
324429
class TimerOption(ExtraOption):
325430
"""A pre-configured option that is adding a ``--time``/``--no-time`` flag to print
326431
elapsed time at the end of CLI execution.

click_extra/testing.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,17 @@ def args_cleanup(*args: TArg | TNestedArgs) -> tuple[str, ...]:
162162
return tuple(str(arg) for arg in flatten(args) if arg is not None)
163163

164164

165-
def _format_cli_prompt(
165+
def format_cli_prompt(
166166
cmd_args: Iterable[str],
167167
extra_env: TEnvVars | None = None,
168168
) -> str:
169-
"""Simulate the console prompt used to invoke the CLI."""
169+
"""Render the shell prompt simulating a CLI invocation, for logs and dry-runs.
170+
171+
Prefixes :data:`PROMPT` to any ``extra_env`` assignments and the command line,
172+
each styled through the active theme
173+
(:func:`~click_extra.theme.get_current_theme`). Useful to print a
174+
copy-pasteable command trace in debug logs, dry-runs and test output.
175+
"""
170176
active_theme = get_current_theme()
171177
extra_env_string = ""
172178
if extra_env:
@@ -188,7 +194,7 @@ def render_cli_run(
188194
189195
Mostly used to print debug traces to user or in test results.
190196
"""
191-
prompt = _format_cli_prompt(args, env)
197+
prompt = format_cli_prompt(args, env)
192198

193199
if isinstance(result, click.testing.Result):
194200
view = StreamView.from_result(result)

docs/execution.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,35 @@ assert "Baked APPLE" in result.stdout
147147

148148
The pool is thread-based, which fits the I/O- and subprocess-bound work CLIs usually parallelize (each child releases the GIL). With a single worker the run stays lazy, so a caller can stop on the first result, for example to abort on the first failure.
149149

150+
## Running lanes in parallel
151+
152+
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.
153+
154+
```{click:source}
155+
from click import command, echo
156+
from click_extra import jobs_option, run_lanes
157+
158+
@command
159+
@jobs_option
160+
def bake():
161+
"""Bake several trays: items on a tray bake in order, trays bake in parallel."""
162+
trays = (("apple", "banana"), ("cherry",))
163+
for baked in run_lanes(str.upper, trays):
164+
echo(f"Baked {baked}")
165+
```
166+
167+
```{click:run}
168+
result = invoke(bake, args=["--jobs", "2"])
169+
assert result.exit_code == 0
170+
assert "Baked APPLE" in result.stdout
171+
```
172+
173+
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.
174+
175+
## Resolving the job count
176+
177+
`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 returns the same number those helpers use: `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.
178+
150179
## Zero exit code
151180

152181
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.

0 commit comments

Comments
 (0)