From 17d7eef0b8d437a6841c91615f200953063dd598 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 11:19:47 -0500 Subject: [PATCH 1/3] py(deps[libtmux]) Pin to sibling chainable-commands worktree why: Build the experimental ChainWorkspaceBuilder against the in-progress libtmux._experimental.chain API on the sibling libtmux worktree. what: - Add [tool.uv.sources] libtmux = { path = "../libtmux", editable = true } - Relock against the local editable checkout --- pyproject.toml | 5 +++++ uv.lock | 53 +++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8b5a634713..e939d729ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,11 @@ lint = [ requires = ["hatchling"] build-backend = "hatchling.build" +[tool.uv.sources] +# Experiment: pin libtmux to the sibling chainable-commands worktree so the +# experimental libtmux._experimental.chain API is importable. Do not merge. +libtmux = { path = "../libtmux", editable = true } + [tool.uv.exclude-newer-package] # git-pull packages release in lockstep with their workspaces, so a # fresh release blocking on the 3-day cooldown blocks every diff --git a/uv.lock b/uv.lock index 4953f4c511..cea7206dc2 100644 --- a/uv.lock +++ b/uv.lock @@ -609,10 +609,53 @@ wheels = [ [[package]] name = "libtmux" version = "0.61.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/c2/6abbac4420c35b3f1140c72607117236b160173f74fd86941f99b28375d5/libtmux-0.61.0.tar.gz", hash = "sha256:2d6081081a629b9236a36a64f874667533811d2ce5a1b0caa9c821f4d9d2e618", size = 546122, upload-time = "2026-07-04T12:57:36.401Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/14/934d0d9076d1d46fcc71f3397daea7921363781674337084b15d1bbcbf84/libtmux-0.61.0-py3-none-any.whl", hash = "sha256:b9315db6600757f840a8f16438725103e579aaa4be3c32728c105f2c284330df", size = 118059, upload-time = "2026-07-04T12:57:34.598Z" }, +source = { editable = "../libtmux" } + +[package.metadata] + +[package.metadata.requires-dev] +coverage = [ + { name = "codecov" }, + { name = "coverage" }, + { name = "pytest-cov" }, +] +dev = [ + { name = "codecov" }, + { name = "coverage" }, + { name = "gp-libs", specifier = ">=0.0.18" }, + { name = "gp-sphinx", specifier = "==0.0.1a32" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "pytest-rerunfailures" }, + { name = "pytest-watcher" }, + { name = "pytest-xdist" }, + { name = "ruff" }, + { name = "sphinx-autobuild" }, + { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a32" }, + { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.0.1a32" }, + { name = "types-docutils" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +docs = [ + { name = "gp-sphinx", specifier = "==0.0.1a32" }, + { name = "sphinx-autobuild" }, + { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a32" }, + { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.0.1a32" }, +] +lint = [ + { name = "mypy" }, + { name = "ruff" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +testing = [ + { name = "gp-libs", specifier = ">=0.0.18" }, + { name = "pytest" }, + { name = "pytest-mock" }, + { name = "pytest-rerunfailures" }, + { name = "pytest-watcher" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] [[package]] @@ -1685,7 +1728,7 @@ testing = [ [package.metadata] requires-dist = [ - { name = "libtmux", specifier = "~=0.61.0" }, + { name = "libtmux", editable = "../libtmux" }, { name = "pyyaml", specifier = ">=6.0" }, ] From c3767a21d7e513895b01bd5a499bdeae1a26d853 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 11:42:13 -0500 Subject: [PATCH 2/3] workspace(feat[chain]): add ChainWorkspaceBuilder why: Showcase libtmux's experimental chain API downstream: build a whole workspace's window/pane tree in the minimum tmux invocations instead of one subprocess per new-window / split-window. what: - Add ChainWorkspaceBuilder (subclass of WorkspaceBuilder): one ForwardPlan seeded from the session builds every window and pane, reusing the session's default window as window 1 (no orphan) via initial_window / initial_pane - send_keys_mode: readiness (default, classic per-pane shell-ready delivery) or batched (folded into the plan) - Reuse parent helpers (_wait_for_pane_ready, config_after_window, focus, plugins, before_script, options) - Tests: two/three-pane structure, focus, readiness commands land, batched --- src/tmuxp/workspace/chain_builder.py | 293 ++++++++++++++++++++++++++ tests/workspace/test_chain_builder.py | 110 ++++++++++ 2 files changed, 403 insertions(+) create mode 100644 src/tmuxp/workspace/chain_builder.py create mode 100644 tests/workspace/test_chain_builder.py diff --git a/src/tmuxp/workspace/chain_builder.py b/src/tmuxp/workspace/chain_builder.py new file mode 100644 index 0000000000..d3ae997425 --- /dev/null +++ b/src/tmuxp/workspace/chain_builder.py @@ -0,0 +1,293 @@ +"""Build a tmux workspace through libtmux's experimental chain API. + +``ChainWorkspaceBuilder`` is an alternative to +:class:`~tmuxp.workspace.builder.WorkspaceBuilder` that constructs the whole +window/pane tree with one +:class:`~libtmux._experimental.chain.ForwardPlan`, resolved over the minimum +number of tmux invocations instead of one subprocess per ``new-window`` / +``split-window``. Shell commands are delivered either readiness-aware (the +default, matching the classic builder) or batched into the plan. + +This is experimental and depends on the pinned ``libtmux._experimental.chain`` +worktree; do not ship to a release. +""" + +from __future__ import annotations + +import typing as t + +from libtmux._experimental.chain import ForwardPlan, ServerPlanRunner +from libtmux.pane import Pane +from libtmux.session import Session +from libtmux.window import Window + +from tmuxp import exc +from tmuxp.util import run_before_script +from tmuxp.workspace.builder import ( + WorkspaceBuilder, + _wait_for_pane_ready, + get_default_columns, + get_default_rows, +) + +if t.TYPE_CHECKING: + import collections.abc as cabc + + +Decorate: t.TypeAlias = "cabc.Callable[[t.Any], t.Any]" +SendKeysMode: t.TypeAlias = 't.Literal["readiness", "batched"]' + + +def _str_or_none(value: t.Any) -> str | None: + """Narrow an Any config value to a string or None.""" + return value if isinstance(value, str) else None + + +def _window_start_directory(window_config: dict[str, t.Any]) -> str | None: + """Resolve a window's start_directory (window, or its first pane).""" + panes = window_config.get("panes") or [{}] + return _str_or_none( + panes[0].get("start_directory", window_config.get("start_directory")), + ) + + +def _window_shell(window_config: dict[str, t.Any]) -> str | None: + """Resolve a window's launch command (window_shell, or its first pane shell).""" + panes = window_config.get("panes") or [{}] + return _str_or_none(panes[0].get("shell") or window_config.get("window_shell")) + + +def _pane_start_directory( + pane_config: dict[str, t.Any], window_config: dict[str, t.Any] +) -> str | None: + """Resolve a pane's start_directory (pane, then window).""" + return _str_or_none( + pane_config.get("start_directory", window_config.get("start_directory")), + ) + + +def _pane_shell( + pane_config: dict[str, t.Any], window_config: dict[str, t.Any] +) -> str | None: + """Resolve a pane's launch command (pane shell, then window_shell).""" + return _str_or_none(pane_config.get("shell", window_config.get("window_shell"))) + + +def _environment( + config: dict[str, t.Any], window_config: dict[str, t.Any] +) -> dict[str, str] | None: + """Resolve environment for a pane/window (own, then window default).""" + value = config.get("environment", window_config.get("environment")) + return value if isinstance(value, dict) else None + + +def _rename(name: str) -> Decorate: + """Return a decorate that renames a window.""" + return lambda handle: handle.window.rename(name) + + +def _set_window_option(key: str, value: t.Any) -> Decorate: + """Return a decorate that sets one window option.""" + return lambda handle: handle.window.set_option(key, str(value)) + + +def _select_layout(layout: str) -> Decorate: + """Return a decorate that applies a window layout.""" + return lambda handle: handle.window.select_layout(layout) + + +def _send_keys(command: str, *, enter: bool) -> Decorate: + """Return a decorate that sends keys into a pane (batched mode).""" + return lambda handle: handle.cmd.send_keys(command, enter=enter) + + +class ChainWorkspaceBuilder(WorkspaceBuilder): + """A :class:`WorkspaceBuilder` that builds the tree via the chain API. + + The session, then every window and pane, is described in one + :class:`~libtmux._experimental.chain.ForwardPlan` seeded from the live + session (reusing the session's default window as window 1, so nothing is + orphaned) and resolved in the fewest tmux invocations. Shell commands are + delivered per ``send_keys_mode``. + """ + + def __init__( + self, + *args: t.Any, + send_keys_mode: SendKeysMode = "readiness", + **kwargs: t.Any, + ) -> None: + super().__init__(*args, **kwargs) + self.send_keys_mode: SendKeysMode = send_keys_mode + + def build(self, session: Session | None = None, append: bool = False) -> None: + """Build the workspace, constructing the window/pane tree via the chain.""" + session = self._chain_create_session(session) + self._chain_setup_session(session) + + windows = self.session_config["windows"] + plan = ForwardPlan.from_session(session) + seed = plan.seed + for index, window_config in enumerate(windows): + first_window = index == 0 and not append + self._plan_window(plan, seed, window_config, first_window=first_window) + + plan.run_resolving(ServerPlanRunner(self.server)) + self._finalize(session, windows, append=append) + + # -- session creation + setup ------------------------------------------ + def _chain_create_session(self, session: Session | None) -> Session: + if session is not None: + self._session = session + return session + if not self.server: + msg = "ChainWorkspaceBuilder.build requires a server or a session" + raise exc.TmuxpException(msg) + + kwargs: dict[str, t.Any] = { + "x": get_default_columns(), + "y": get_default_rows(), + } + if "start_directory" in self.session_config: + kwargs["start_directory"] = self.session_config["start_directory"] + session = self.server.new_session( + session_name=self.session_config["session_name"], **kwargs + ) + assert session is not None + self._session = session + self.server = session.server + return session + + def _chain_setup_session(self, session: Session) -> None: + for plugin in self.plugins: + plugin.before_workspace_builder(session) + + if "before_script" in self.session_config: + cwd = self.session_config.get("start_directory") + try: + run_before_script( + self.session_config["before_script"], + cwd=cwd, + on_line=self.on_script_output, + ) + except Exception: + session.kill() + raise + + for option, value in self.session_config.get("options", {}).items(): + session.set_option(option, value) + for option, value in self.session_config.get("global_options", {}).items(): + session.set_option(option, value, global_=True) + for key, value in self.session_config.get("environment", {}).items(): + session.set_environment(key, value) + + # -- plan the window/pane tree ----------------------------------------- + def _plan_window( + self, + plan: ForwardPlan, + seed: t.Any, + window_config: dict[str, t.Any], + *, + first_window: bool, + ) -> None: + name = window_config.get("window_name") + if first_window: + window = seed.initial_window + first_pane = seed.initial_pane + if name: + window.do(_rename(name)) + else: + window = seed.new_window( + name=name, + start_directory=_window_start_directory(window_config), + environment=_environment(window_config, window_config), + window_shell=_window_shell(window_config), + ) + first_pane = window + + for key, value in window_config.get("options", {}).items(): + window.do(_set_window_option(key, value)) + + panes = window_config["panes"] + pane_handles = [first_pane] + previous = first_pane + for pane_config in panes[1:]: + handle = previous.split( + start_directory=_pane_start_directory(pane_config, window_config), + shell=_pane_shell(pane_config, window_config), + environment=_environment(pane_config, window_config), + ) + pane_handles.append(handle) + previous = handle + + if "layout" in window_config: + window.do(_select_layout(window_config["layout"])) + + if self.send_keys_mode == "batched": + for handle, pane_config in zip(pane_handles, panes, strict=False): + self._plan_pane_keys(handle, pane_config) + + def _plan_pane_keys(self, handle: t.Any, pane_config: dict[str, t.Any]) -> None: + enter = pane_config.get("enter", True) + for command in pane_config.get("shell_command", []): + handle.do(_send_keys(command["cmd"], enter=command.get("enter", enter))) + + # -- recover objects + deliver commands + focus ------------------------ + def _finalize( + self, session: Session, windows: list[dict[str, t.Any]], *, append: bool + ) -> None: + session.refresh() + # Windows in index order == config creation order (default window first, + # new windows appended); same for a window's panes. + built = ( + list(session.windows)[-len(windows) :] if append else list(session.windows) + ) + + focus_window: Window | None = None + for window, window_config in zip(built, windows, strict=False): + window.refresh() + for plugin in self.plugins: + plugin.on_window_create(window) + + focus_pane: Pane | None = None + for pane, pane_config in zip( + window.panes, window_config["panes"], strict=False + ): + if self.send_keys_mode == "readiness": + self._send_pane_commands(pane, pane_config, window_config) + if pane_config.get("focus"): + focus_pane = pane + + self.config_after_window(window, window_config) + for plugin in self.plugins: + plugin.after_window_finished(window) + if focus_pane is not None: + focus_pane.select() + if window_config.get("focus"): + focus_window = window + + if focus_window is not None: + focus_window.select() + + def _send_pane_commands( + self, + pane: Pane, + pane_config: dict[str, t.Any], + window_config: dict[str, t.Any], + ) -> None: + """Deliver a pane's shell commands readiness-aware (classic semantics).""" + if _pane_shell(pane_config, window_config) is None: + _wait_for_pane_ready(pane) + + if "suppress_history" in pane_config: + suppress = pane_config["suppress_history"] + else: + suppress = window_config.get("suppress_history", True) + + enter = pane_config.get("enter", True) + for command in pane_config.get("shell_command", []): + pane.send_keys( + command["cmd"], + suppress_history=command.get("suppress_history", suppress), + enter=command.get("enter", enter), + ) diff --git a/tests/workspace/test_chain_builder.py b/tests/workspace/test_chain_builder.py new file mode 100644 index 0000000000..9116bed307 --- /dev/null +++ b/tests/workspace/test_chain_builder.py @@ -0,0 +1,110 @@ +"""Tests for the chain-based workspace builder.""" + +from __future__ import annotations + +import typing as t + +from libtmux.test.retry import retry_until +from libtmux.window import Window + +from tests.fixtures import utils as test_utils +from tmuxp._internal.config_reader import ConfigReader +from tmuxp.workspace import loader +from tmuxp.workspace.chain_builder import ChainWorkspaceBuilder + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def _load(name: str) -> dict[str, t.Any]: + workspace = ConfigReader._from_file( + test_utils.get_workspace_file(f"workspace/builder/{name}"), + ) + workspace = loader.expand(workspace) + return loader.trickle(workspace) + + +def test_chain_split_windows(session: Session) -> None: + """The chain builder creates the windows and panes of a two-pane workspace.""" + workspace = _load("two_pane.yaml") + builder = ChainWorkspaceBuilder(session_config=workspace, server=session.server) + + builder.build(session=session) + + assert builder.session is session + assert [w.name for w in session.windows] == ["editor", "logging", "test"] + + editor = session.windows.get(window_name="editor") + assert isinstance(editor, Window) + editor.refresh() + assert len(editor.panes) == 2 + + +def test_chain_three_pane(session: Session) -> None: + """The chain builder splits a window into three panes.""" + workspace = _load("three_pane.yaml") + builder = ChainWorkspaceBuilder(session_config=workspace, server=session.server) + + builder.build(session=session) + + test_window = session.windows.get(window_name="test") + assert isinstance(test_window, Window) + test_window.refresh() + assert len(test_window.panes) == 3 + + +def test_chain_focus(session: Session) -> None: + """The chain builder honours window and pane focus.""" + workspace = _load("focus_and_pane.yaml") + builder = ChainWorkspaceBuilder(session_config=workspace, server=session.server) + + builder.build(session=session) + + assert session.active_window.name == "focused window" + + +def test_chain_commands_landed(session: Session) -> None: + """Readiness mode delivers each pane's shell command.""" + workspace = loader.trickle( + loader.expand( + { + "session_name": "cc_cmd", + "windows": [ + { + "window_name": "marker", + "panes": [{"shell_command": "echo CC_LANDED"}], + }, + ], + }, + ), + ) + builder = ChainWorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + window = session.windows.get(window_name="marker") + assert isinstance(window, Window) + pane = window.active_pane + assert pane is not None + + def _landed() -> bool: + return any("CC_LANDED" in line for line in pane.capture_pane()) + + assert retry_until(_landed, 2) + + +def test_chain_batched_mode(session: Session) -> None: + """Batched mode folds send_keys into the plan and still builds the tree.""" + workspace = _load("two_pane.yaml") + builder = ChainWorkspaceBuilder( + session_config=workspace, + server=session.server, + send_keys_mode="batched", + ) + + builder.build(session=session) + + assert [w.name for w in session.windows] == ["editor", "logging", "test"] + editor = session.windows.get(window_name="editor") + assert isinstance(editor, Window) + editor.refresh() + assert len(editor.panes) == 2 From 5b82f533c3657030ee9653b404d25068689e86ce Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 11:46:00 -0500 Subject: [PATCH 3/3] cli(load[builder]): --builder=chain flag why: Make the experimental ChainWorkspaceBuilder selectable from the CLI without replacing the default builder. what: - Add --builder={default,chain} to `tmuxp load` (Env: TMUXP_BUILDER), threaded through command_load -> load_workspace as builder_name - Select ChainWorkspaceBuilder when chosen; default builder otherwise - Test the flag parsing (smoke-verified `tmuxp load --builder=chain` builds the expected window/pane tree) --- src/tmuxp/cli/load.py | 31 +++++++++++++++++++++++++-- src/tmuxp/workspace/chain_builder.py | 2 +- tests/workspace/test_chain_builder.py | 11 ++++++++++ uv.lock | 7 ++++++ 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index a9faf89bce..753c74fd34 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -117,6 +117,7 @@ class CLILoadNamespace(argparse.Namespace): progress_format: str | None panel_lines: int | None no_progress: bool + builder: str def load_plugins( @@ -458,6 +459,7 @@ def load_workspace( progress_format: str | None = None, panel_lines: int | None = None, no_progress: bool = False, + builder_name: str = "default", ) -> Session | None: """Entrypoint for ``tmuxp load``, load a tmuxp "workspace" session via config file. @@ -591,16 +593,29 @@ def load_workspace( # Builder resolution + creation — outside spinner so plugin prompts are safe. # Trusted import roots (absent config -> [] -> a no-op sandbox) stay on # sys.path for the import and instantiation; each build dispatch re-enters - # the sandbox so build-time lazy imports resolve. + # the sandbox so build-time lazy imports resolve. ``--builder=chain`` forces + # the experimental chain builder; otherwise the registry resolves the class + # from the workspace's ``workspace_builder`` key. try: builder_paths = resolve_builder_paths(expanded_workspace, workspace_file) with prepended_sys_path(builder_paths): - builder_cls = resolve_builder_class(expanded_workspace) + builder_cls: type[WorkspaceBuilderProtocol] + if builder_name == "chain": + # Imported lazily: the experimental chain builder pulls in + # libtmux's unreleased ``_experimental.chain`` API, absent from + # published builds. Deferring keeps ``tmuxp load`` importable + # when the API is missing; only ``--builder=chain`` needs it. + from tmuxp.workspace.chain_builder import ChainWorkspaceBuilder + + builder_cls = ChainWorkspaceBuilder + else: + builder_cls = resolve_builder_class(expanded_workspace) builder = builder_cls( session_config=expanded_workspace, plugins=load_plugins(expanded_workspace, colors=cli_colors), server=t, ) + except exc.EmptyWorkspaceException: logger.warning( "workspace file is empty", @@ -843,6 +858,17 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP help=("Disable the animated progress spinner. Env: TMUXP_PROGRESS=0"), ) + parser.add_argument( + "--builder", + dest="builder", + choices=["default", "chain"], + default=os.environ.get("TMUXP_BUILDER", "default"), + help=( + "Workspace builder. 'chain' (experimental) builds the window/pane " + "tree via libtmux's chain API. Env: TMUXP_BUILDER" + ), + ) + try: import shtab @@ -927,4 +953,5 @@ def command_load( progress_format=args.progress_format, panel_lines=args.panel_lines, no_progress=args.no_progress, + builder_name=args.builder, ) diff --git a/src/tmuxp/workspace/chain_builder.py b/src/tmuxp/workspace/chain_builder.py index d3ae997425..b4f0a63dbc 100644 --- a/src/tmuxp/workspace/chain_builder.py +++ b/src/tmuxp/workspace/chain_builder.py @@ -25,10 +25,10 @@ from tmuxp.util import run_before_script from tmuxp.workspace.builder import ( WorkspaceBuilder, - _wait_for_pane_ready, get_default_columns, get_default_rows, ) +from tmuxp.workspace.builder.classic import _wait_for_pane_ready if t.TYPE_CHECKING: import collections.abc as cabc diff --git a/tests/workspace/test_chain_builder.py b/tests/workspace/test_chain_builder.py index 9116bed307..5f26117387 100644 --- a/tests/workspace/test_chain_builder.py +++ b/tests/workspace/test_chain_builder.py @@ -108,3 +108,14 @@ def test_chain_batched_mode(session: Session) -> None: assert isinstance(editor, Window) editor.refresh() assert len(editor.panes) == 2 + + +def test_cli_builder_flag() -> None: + """The load CLI exposes --builder with default and chain choices.""" + import argparse + + from tmuxp.cli.load import create_load_subparser + + parser = create_load_subparser(argparse.ArgumentParser()) + assert parser.parse_args(["ws.yaml", "--builder", "chain"]).builder == "chain" + assert parser.parse_args(["ws.yaml"]).builder == "default" diff --git a/uv.lock b/uv.lock index cea7206dc2..b25368ddb4 100644 --- a/uv.lock +++ b/uv.lock @@ -612,6 +612,8 @@ version = "0.61.0" source = { editable = "../libtmux" } [package.metadata] +requires-dist = [{ name = "fastmcp", marker = "extra == 'mcp'", specifier = ">=3.4.2" }] +provides-extras = ["mcp"] [package.metadata.requires-dev] coverage = [ @@ -622,6 +624,7 @@ coverage = [ dev = [ { name = "codecov" }, { name = "coverage" }, + { name = "fastmcp" }, { name = "gp-libs", specifier = ">=0.0.18" }, { name = "gp-sphinx", specifier = "==0.0.1a32" }, { name = "mypy" }, @@ -635,6 +638,8 @@ dev = [ { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a32" }, { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.0.1a32" }, + { name = "tomlkit" }, + { name = "ty" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -650,11 +655,13 @@ lint = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] testing = [ + { name = "fastmcp" }, { name = "gp-libs", specifier = ">=0.0.18" }, { name = "pytest" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, + { name = "tomlkit" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ]