diff --git a/CHANGES b/CHANGES index 8c1a395d00..1c006c2e5d 100644 --- a/CHANGES +++ b/CHANGES @@ -36,7 +36,7 @@ Run the developmental install with: $ tmuxp@next load yoursession ``` -## tmuxp 1.73.0 (Yet to be released) +## tmuxp 1.74.0 (Yet to be released) @@ -44,6 +44,53 @@ $ tmuxp@next load yoursession _Notes on the upcoming release will go here._ +## tmuxp 1.73.0 (2026-06-28) + +tmuxp 1.73.0 makes the workspace build step pluggable and tunable. A workspace can now build through a third-party builder selected by registered entry-point name or Python import path, and a new `workspace_builder_options` catalog controls the pane-readiness wait per workspace. The built-in builder stays the default, so existing workspaces keep working — though the new `pane_readiness: auto` default skips the prompt wait on non-zsh shells. See {ref}`custom-workspace-builders` for the guide. + +### What's new + +#### Pluggable workspace builders (#1066) + +tmuxp can now build a workspace with a builder other than its built-in one. Point +a workspace file's `workspace_builder` key at an installed builder — by registered +name or by Python import path — and tmuxp loads the session through it. Builders +that ship outside tmuxp's environment can be imported from directories listed in +`workspace_builder_paths`, which tmuxp trusts only for that load. The built-in +builder stays the default, so existing workspaces are unaffected. See +{ref}`custom-workspace-builders`. + +#### Configurable pane readiness (#1066) + +Before sending a pane's layout and commands, tmuxp can wait for the shell to draw +its prompt — a guard against a zsh prompt-redraw artifact. That wait is now a +policy you set per workspace: + +```yaml +workspace_builder_options: + pane_readiness: auto # auto | always | never (also true/false) +``` + +The new default, `auto`, waits only when the session's shell is zsh, so zsh users +see no change while bash, sh, and other shells skip a wait they never needed. Set +`always` to keep the previous wait-everywhere behavior, or `never` to skip it +entirely. See {ref}`custom-workspace-builders`. + +#### Workspace builder API (#1066) + +`tmuxp.workspace.builder` is now a package. The default builder is +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`, with +`WorkspaceBuilder` kept as a backwards-compatible alias. Third-party builders +implement {class}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` +(shaped to allow async builders later) and are resolved by +{func}`~tmuxp.workspace.builder.registry.resolve_builder_class` from an entry +point or import path, with trusted import directories handled by +{func}`~tmuxp.workspace.builder.registry.resolve_builder_paths`. Builder behavior +is configured through {class}`~tmuxp.workspace.options.WorkspaceBuilderOptions` +and {class}`~tmuxp.workspace.options.PaneReadiness`, and resolution failures raise +{exc}`~tmuxp.exc.WorkspaceBuilderError` and its subclasses. See +{ref}`custom-workspace-builders` for the guide. + ## tmuxp 1.72.0 (2026-06-28) tmuxp 1.72.0 bumps libtmux to 0.60.0, completing tmux 3.7 feature parity at the library layer tmuxp builds on. The new libtmux capabilities — floating panes, typed tmux 3.7 options, new pane format variables, and new command flags — are reachable through tmuxp's Python API and `tmuxp shell`, while the YAML workspace format and tmux 3.2a-3.6 support stay unchanged. diff --git a/conftest.py b/conftest.py index 1f0583439e..ad606b5342 100644 --- a/conftest.py +++ b/conftest.py @@ -114,7 +114,7 @@ def socket_name(request: pytest.FixtureRequest) -> str: # Modules that actually need tmux fixtures in their doctests DOCTEST_NEEDS_TMUX = { - "tmuxp.workspace.builder", + "tmuxp.workspace.builder.classic", } diff --git a/docs/configuration/examples.md b/docs/configuration/examples.md index 9651341309..2d463e4ba5 100644 --- a/docs/configuration/examples.md +++ b/docs/configuration/examples.md @@ -791,7 +791,7 @@ windows: tmuxp sessions can be scripted in python. The first way is to use the ORM in the {ref}`API`. The second is to pass a {py:obj}`dict` into -{class}`~tmuxp.workspace.builder.WorkspaceBuilder` with a correct schema. +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` with a correct schema. See: {meth}`tmuxp.validation.validate_schema`. ::: diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 619cb62710..a3e82e39c8 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -27,6 +27,12 @@ TMUXP_CONFIGDIR and other env vars. Sample workspace configurations. ::: +:::{grid-item-card} Workspace builders +:link: workspace-builders +:link-type: doc +Select a builder and tune pane readiness. +::: + :::: tmuxp loads your terminal workspace into tmux using workspace files. @@ -211,6 +217,12 @@ TMUXP_CONFIGDIR and other env vars. Sample workspace configurations. ::: +:::{grid-item-card} Workspace builders +:link: workspace-builders +:link-type: doc +Select a builder and tune pane readiness. +::: + :::: ```{toctree} @@ -219,4 +231,5 @@ Sample workspace configurations. top-level environmental-variables examples +workspace-builders ``` diff --git a/docs/configuration/top-level.md b/docs/configuration/top-level.md index 72eb24f32f..b8e480522e 100644 --- a/docs/configuration/top-level.md +++ b/docs/configuration/top-level.md @@ -40,3 +40,12 @@ Notes: ``` Above: Use `tmux` directly to attach _banana_. + +## Workspace builder keys + +A workspace file can also select a custom builder and tune builder behavior with +`workspace_builder`, `workspace_builder_paths`, and `workspace_builder_options`. + +```{seealso} +{ref}`workspace-builders` +``` diff --git a/docs/configuration/workspace-builders.md b/docs/configuration/workspace-builders.md new file mode 100644 index 0000000000..5354d66900 --- /dev/null +++ b/docs/configuration/workspace-builders.md @@ -0,0 +1,142 @@ +(workspace-builders)= + +# Workspace builders + +```{versionadded} 1.72.0 +``` + +Most workspaces never need these keys. By default tmuxp builds your session with +its built-in *classic* builder and waits for a pane's shell prompt only when that +shell is zsh — existing workspace files keep working unchanged. Set the keys below +to swap in a different builder or to tune the prompt wait. **Omit a key (or remove +it) to restore the default.** + +| Key | Type | Default | Purpose | +| --- | --- | --- | --- | +| `workspace_builder` | string | `classic` | Which builder turns the workspace into a session. | +| `workspace_builder_paths` | string or list of strings | _(none)_ | Trusted directories to import a builder from. | +| `workspace_builder_options` | mapping | _(all defaults)_ | Builder-behavior knobs, such as `pane_readiness`. | + +For the narrative — writing a builder, packaging one, the trust boundary, and +testing — see {ref}`custom-workspace-builders`. + +(workspace-builder-key)= + +## `workspace_builder` + +Selects the builder. The default, `classic`, is tmuxp's built-in builder. A value +is resolved in this order: + +1. absent or empty → the built-in classic builder (nothing is imported); +2. contains `:` → a `module:attr` object reference; +3. no `.` and no `:` → a builder registered under the `tmuxp.workspace_builders` + entry-point group, selected by name; +4. dotted with no `:` → an entry-point name if one is registered, otherwise a + `module.attr` import path. + +```yaml +session_name: my-session +workspace_builder: classic +windows: + - panes: + - vim +``` + +See {ref}`custom-workspace-builders` for selecting and packaging builders, and +{func}`~tmuxp.workspace.builder.registry.resolve_builder_class` for the resolver. + +(workspace-builder-paths-key)= + +## `workspace_builder_paths` + +Directories to import a builder from when it lives outside tmuxp's environment — +for example, a script in your config directory. Accepts a single string or a list +of strings. tmuxp expands `~` and environment variables, resolves relative entries +against the workspace file's directory, and requires each entry to be an existing +directory; the paths are added to `sys.path` only for the import and build. + +```yaml +workspace_builder: my_local_builder:CustomBuilder +workspace_builder_paths: + - ~/.config/tmuxp/builders +``` + +```{warning} +A workspace file that names a builder runs that builder's Python code. Only load +workspace files you trust. See the security note in {ref}`custom-workspace-builders`. +``` + +(workspace-builder-options-key)= + +## `workspace_builder_options` + +A catalog of builder-behavior settings, independent of which builder you use. +Today it holds a single key, `pane_readiness`, which controls whether tmuxp waits +for a pane's shell prompt before sending its layout and commands — a guard against +a zsh prompt-redraw artifact: + +```yaml +workspace_builder_options: + pane_readiness: auto +``` + +| Value | Behavior | +| --- | --- | +| `auto` _(default)_ | Wait only when the session's shell is zsh. | +| `always` | Always wait for default-shell panes. | +| `never` | Never wait; fastest, but accepts the prompt/layout race for shells that need it. | + +`pane_readiness` also accepts truthy/falsy aliases — `true`/`on`/`yes`/`1` map to +`always`, and `false`/`off`/`no`/`0` map to `never` (full list in +{ref}`custom-workspace-builders`). An unrecognized value fails the load with: + +```text +invalid pane_readiness value: 'sometimes'; expected one of: auto, always/true/on/yes/1, never/false/off/no/0 +``` + +Panes that run a custom `shell` or `window_shell` never wait, regardless of policy. +See {class}`~tmuxp.workspace.options.PaneReadiness` and +{class}`~tmuxp.workspace.options.WorkspaceBuilderOptions` for the parsing rules. + +## Minimal complete example + +````{tab} YAML +```yaml +session_name: my-session +workspace_builder: classic +workspace_builder_paths: + - ~/.config/tmuxp/builders +workspace_builder_options: + pane_readiness: auto +windows: + - window_name: editor + panes: + - vim +``` +```` + +````{tab} JSON +```json +{ + "session_name": "my-session", + "workspace_builder": "classic", + "workspace_builder_paths": ["~/.config/tmuxp/builders"], + "workspace_builder_options": { + "pane_readiness": "auto" + }, + "windows": [ + { + "window_name": "editor", + "panes": ["vim"] + } + ] +} +``` +```` + +```{seealso} +{ref}`custom-workspace-builders` — narrative guide to selecting, packaging, +writing, and testing builders · +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` · +{class}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` +``` diff --git a/docs/internals/api/workspace/builder.md b/docs/internals/api/workspace/builder.md deleted file mode 100644 index 24525e1efb..0000000000 --- a/docs/internals/api/workspace/builder.md +++ /dev/null @@ -1,8 +0,0 @@ -# Builder - `tmuxp.workspace.builder` - -```{eval-rst} -.. automodule:: tmuxp.workspace.builder - :members: - :show-inheritance: - :undoc-members: -``` diff --git a/docs/internals/api/workspace/builder/classic.md b/docs/internals/api/workspace/builder/classic.md new file mode 100644 index 0000000000..856ebc63d4 --- /dev/null +++ b/docs/internals/api/workspace/builder/classic.md @@ -0,0 +1,8 @@ +# Classic builder - `tmuxp.workspace.builder.classic` + +```{eval-rst} +.. automodule:: tmuxp.workspace.builder.classic + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/builder/index.md b/docs/internals/api/workspace/builder/index.md new file mode 100644 index 0000000000..0b3ddbfabe --- /dev/null +++ b/docs/internals/api/workspace/builder/index.md @@ -0,0 +1,40 @@ +# Builder - `tmuxp.workspace.builder` + +`tmuxp.workspace.builder` is a package. The classic, default builder lives in +{mod}`tmuxp.workspace.builder.classic`; the public contract and the selection +machinery live alongside it. + +`WorkspaceBuilder` remains importable from `tmuxp.workspace.builder` as a +backwards-compatible alias of +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`. + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Classic builder +:link: classic +:link-type: doc +The built-in, default builder — `tmuxp.workspace.builder.classic`. +::: + +:::{grid-item-card} Builder protocol +:link: protocol +:link-type: doc +The contract a builder must satisfy — `tmuxp.workspace.builder.protocol`. +::: + +:::{grid-item-card} Builder registry +:link: registry +:link-type: doc +Builder selection and trusted import paths — `tmuxp.workspace.builder.registry`. +::: + +:::: + +```{toctree} +:hidden: + +classic +protocol +registry +``` diff --git a/docs/internals/api/workspace/builder/protocol.md b/docs/internals/api/workspace/builder/protocol.md new file mode 100644 index 0000000000..1c980b33b3 --- /dev/null +++ b/docs/internals/api/workspace/builder/protocol.md @@ -0,0 +1,8 @@ +# Builder protocol - `tmuxp.workspace.builder.protocol` + +```{eval-rst} +.. automodule:: tmuxp.workspace.builder.protocol + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/builder/registry.md b/docs/internals/api/workspace/builder/registry.md new file mode 100644 index 0000000000..3a9fa4f7f3 --- /dev/null +++ b/docs/internals/api/workspace/builder/registry.md @@ -0,0 +1,8 @@ +# Builder registry - `tmuxp.workspace.builder.registry` + +```{eval-rst} +.. automodule:: tmuxp.workspace.builder.registry + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/index.md b/docs/internals/api/workspace/index.md index 4c2d6241ad..73c844cf27 100644 --- a/docs/internals/api/workspace/index.md +++ b/docs/internals/api/workspace/index.md @@ -9,11 +9,12 @@ If you need an internal API stabilized please [file an issue](https://github.com ::: ```{toctree} -builder +builder/index constants finders freezer importers loader +options validation ``` diff --git a/docs/internals/api/workspace/options.md b/docs/internals/api/workspace/options.md new file mode 100644 index 0000000000..e492ce6728 --- /dev/null +++ b/docs/internals/api/workspace/options.md @@ -0,0 +1,8 @@ +# Options - `tmuxp.workspace.options` + +```{eval-rst} +.. automodule:: tmuxp.workspace.options + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/redirects.txt b/docs/redirects.txt index be9e1bdd10..53b041de9d 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -23,7 +23,7 @@ "api/cli/shell.md" "internals/api/cli/shell.md" "api/cli/utils.md" "internals/api/cli/utils.md" "api/workspace/index.md" "internals/api/workspace/index.md" -"api/workspace/builder.md" "internals/api/workspace/builder.md" +"api/workspace/builder.md" "internals/api/workspace/builder/index.md" "api/workspace/constants.md" "internals/api/workspace/constants.md" "api/workspace/finders.md" "internals/api/workspace/finders.md" "api/workspace/freezer.md" "internals/api/workspace/freezer.md" diff --git a/docs/topics/custom-workspace-builders.md b/docs/topics/custom-workspace-builders.md new file mode 100644 index 0000000000..26f6f38ff7 --- /dev/null +++ b/docs/topics/custom-workspace-builders.md @@ -0,0 +1,191 @@ +(custom-workspace-builders)= + +# Custom workspace builders + +A *workspace builder* turns an expanded workspace ``dict`` into a live tmux +session. tmuxp ships one builder, +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`, and uses it +by default. Advanced users can point tmuxp at a different builder, and packagers +can distribute builders that users select by name. + +This is an advanced, opt-in feature. Existing workspace files keep using the +classic builder with no changes. + +```{seealso} +If you only want to set these keys in a workspace file, see +{ref}`workspace-builders` in the configuration reference. +``` + +## How a workspace is built + +1. `tmuxp load` reads the YAML/JSON file and expands it (shorthand, environment + variables, trickle-down defaults). +2. tmuxp resolves which builder to use from the `workspace_builder` config key + (default: the classic builder). +3. tmuxp constructs the builder with the expanded workspace and a + {class}`libtmux.Server`, then calls `.build()`. +4. The builder creates the session, windows, and panes, honoring plugin hooks + and progress callbacks. + +## Selecting a builder + +### By dotted path + +Point `workspace_builder` at an importable class. Both a +`module:attr` object reference and a dotted `module.attr` path are accepted: + +```yaml +session_name: my-session +workspace_builder: my_tmuxp_builders.builders:CustomBuilder +windows: + - window_name: editor + panes: + - vim +``` + +### By entry-point name + +Packaged builders register under the `tmuxp.workspace_builders` entry-point +group, letting users select them by a short name instead of an internal module +path: + +```yaml +workspace_builder: classic +``` + +The built-in classic builder is registered this way. A distribution registers +its own builder in `pyproject.toml`: + +```toml +[project.entry-points."tmuxp.workspace_builders"] +mybuilder = "my_tmuxp_builders.builders:CustomBuilder" +``` + +### Trusted import paths + +When a builder lives outside tmuxp's runtime environment (for example, a script +in your config directory), list trusted directories in +`workspace_builder_paths`. tmuxp expands `~` and environment variables, +resolves relative entries against the workspace file's directory, requires each +entry to be an existing directory, and temporarily prepends them to `sys.path` +for the import and build: + +```yaml +workspace_builder: my_local_builder:CustomBuilder +workspace_builder_paths: + - ~/.config/tmuxp/builders +``` + +:::{warning} +A workspace file that names a builder runs that builder's Python code. Only load +workspace files you trust. tmuxp deliberately does **not** use +`site.addsitedir()` for these paths — that would execute `.pth` startup files +and is broader than making a module importable. +::: + +## Writing a builder + +The simplest custom builder subclasses the classic builder and overrides what it +needs: + +```python +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + + +class CustomBuilder(ClassicWorkspaceBuilder): + """A builder that renames the session after building.""" + + def build(self, session=None, append=False): + super().build(session=session, append=append) + self.session.rename_session(f"{self.session.name}-custom") +``` + +A builder written from scratch must satisfy +{class}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol`. The contract +covers what `tmuxp load` drives: + +- **Constructor** accepting `session_config`, `server`, and the optional + `plugins` list and `on_progress` / `on_before_script` / `on_script_output` / + `on_build_event` callbacks. +- **`build(session=None, append=False)`** — create or populate the session; the + `append` path adds windows to an existing session. +- **`session`** — the populated {class}`libtmux.Session`. +- **`session_exists()`** and **`find_current_attached_session()`** — used by the + CLI for attach/append decisions. +- **`plugins`** — the list of plugin instances; honor the plugin lifecycle hooks + (`before_workspace_builder`, `on_window_create`, `after_window_finished`). +- The **`on_*` callbacks** — call them at the documented milestones so the CLI's + progress display and `before_script` output stay accurate. + +The contract is synchronous today. It is shaped so an async builder can be added +later as an additive extension without changing this surface. + +## Pane readiness + +tmuxp waits for a pane's shell prompt before dispatching layout and commands, +which avoids a zsh prompt-redraw artifact. That wait is only needed for zsh, so +it is configurable independently of which builder you use, through the +`workspace_builder_options` catalog: + +```yaml +workspace_builder_options: + pane_readiness: auto # auto | always | never (+ truthy/falsy aliases) +``` + +- **`auto`** (default) — wait only when the session's interactive shell is zsh. +- **`always`** (or `true`/`on`/`yes`/`1`) — always wait for default-shell panes. +- **`never`** (or `false`/`off`/`no`/`0`) — never wait; fastest, but accepts the + prompt/layout race for shells that need it. + +Panes with a custom `shell` or `window_shell` never wait, regardless of policy — +those run a command in place of an interactive shell, so there is no prompt to +wait for. + +See {class}`~tmuxp.workspace.options.PaneReadiness` and +{class}`~tmuxp.workspace.options.WorkspaceBuilderOptions` for the parsing rules. + +## Testing a builder + +Resolve, construct, and build against a real tmux server fixture: + +```python +from tmuxp.workspace import loader +from tmuxp.workspace.builder import registry + + +def test_custom_builder(server): + config = loader.expand( + { + "session_name": "demo", + "workspace_builder": "my_tmuxp_builders.builders:CustomBuilder", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + }, + ) + builder_cls = registry.resolve_builder_class(config) + builder = builder_cls(session_config=config, server=server) + builder.build() + assert builder.session.name == "demo" + builder.session.kill() +``` + +For builders that live in a trusted directory, build the `sys.path` sandbox with +{func}`~tmuxp.workspace.builder.registry.resolve_builder_paths` and +{func}`~tmuxp.workspace.builder.registry.prepended_sys_path`. + +## Choosing an approach + +- **Classic builder** — the default. Use it for any workspace that depends on + strict, pane-by-pane side effects (`start_directory`, `shell`, `window_shell`, + pane environment). +- **Readiness tuning** — set `pane_readiness` to trade prompt-safety for speed + without swapping builders. +- **A custom builder** — when you need behavior the classic builder doesn't + provide. Keep dependency-sensitive setup in `before_script` or + `shell_command_before` if your builder relaxes ordering guarantees. + +## Reference + +- {class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` +- {class}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` +- {func}`~tmuxp.workspace.builder.registry.resolve_builder_class` +- {class}`~tmuxp.workspace.options.PaneReadiness` diff --git a/docs/topics/index.md b/docs/topics/index.md index 050ddb9d58..8dd1d70866 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -19,6 +19,12 @@ CI integration, scripting, and automation patterns. Plugin system for custom behavior. ::: +:::{grid-item-card} Custom workspace builders +:link: custom-workspace-builders +:link-type: doc +Select or ship a custom builder; tune pane readiness. +::: + :::{grid-item-card} Library vs CLI :link: library-vs-cli :link-type: doc @@ -47,6 +53,7 @@ instead of raw shell commands, supports JSON and YAML, and can workflows plugins +custom-workspace-builders library-vs-cli troubleshooting ``` diff --git a/pyproject.toml b/pyproject.toml index e3360098f0..7abefdeaf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tmuxp" -version = "1.72.0" +version = "1.73.0" description = "Session manager for tmux, which allows users to save and load tmux sessions through simple configuration files." requires-python = ">=3.10,<4.0" authors = [ @@ -52,6 +52,9 @@ Changes = "https://github.com/tmux-python/tmuxp/blob/master/CHANGES" [project.scripts] tmuxp = 'tmuxp:cli.cli' +[project.entry-points."tmuxp.workspace_builders"] +classic = "tmuxp.workspace.builder.classic:ClassicWorkspaceBuilder" + [dependency-groups] dev = [ # Docs diff --git a/src/tmuxp/__about__.py b/src/tmuxp/__about__.py index 2aa569d6cf..4bfc1730c9 100644 --- a/src/tmuxp/__about__.py +++ b/src/tmuxp/__about__.py @@ -8,7 +8,7 @@ __title__ = "tmuxp" __package_name__ = "tmuxp" -__version__ = "1.72.0" +__version__ = "1.73.0" __description__ = "tmux session manager" __email__ = "tony@git-pull.com" __author__ = "Tony Narlock" diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 375cdb1b22..ed85f67d32 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -18,7 +18,12 @@ from tmuxp._internal import config_reader from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace import loader -from tmuxp.workspace.builder import WorkspaceBuilder +from tmuxp.workspace.builder import ( + WorkspaceBuilderProtocol, + prepended_sys_path, + resolve_builder_class, + resolve_builder_paths, +) from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir from ._colors import ColorMode, Colors, build_description, get_color_mode @@ -195,7 +200,7 @@ def load_plugins( return plugins -def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: +def _reattach(builder: WorkspaceBuilderProtocol, colors: Colors | None = None) -> None: """ Reattach session (depending on env being inside tmux already or not). @@ -232,7 +237,7 @@ def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: def _load_attached( - builder: WorkspaceBuilder, + builder: WorkspaceBuilderProtocol, detached: bool, pre_attach_hook: t.Callable[[], None] | None = None, ) -> None: @@ -265,7 +270,7 @@ def _load_attached( def _load_detached( - builder: WorkspaceBuilder, + builder: WorkspaceBuilderProtocol, colors: Colors | None = None, pre_output_hook: t.Callable[[], None] | None = None, ) -> None: @@ -292,7 +297,9 @@ def _load_detached( logger.info("session created in detached state") -def _load_append_windows_to_current_session(builder: WorkspaceBuilder) -> None: +def _load_append_windows_to_current_session( + builder: WorkspaceBuilderProtocol, +) -> None: """ Load workspace as new windows in current session. @@ -305,7 +312,7 @@ def _load_append_windows_to_current_session(builder: WorkspaceBuilder) -> None: assert builder.session is not None -def _setup_plugins(builder: WorkspaceBuilder) -> Session: +def _setup_plugins(builder: WorkspaceBuilderProtocol) -> Session: """Execute hooks for plugins running after ``before_script``. Parameters @@ -320,7 +327,7 @@ def _setup_plugins(builder: WorkspaceBuilder) -> Session: def _dispatch_build( - builder: WorkspaceBuilder, + builder: WorkspaceBuilderProtocol, detached: bool, append: bool, answer_yes: bool, @@ -577,13 +584,19 @@ def load_workspace( shutil.which("tmux") # raise exception if tmux not found - # WorkspaceBuilder creation — outside spinner so plugin prompts are safe + # 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. try: - builder = WorkspaceBuilder( - session_config=expanded_workspace, - plugins=load_plugins(expanded_workspace, colors=cli_colors), - server=t, - ) + builder_paths = resolve_builder_paths(expanded_workspace, workspace_file) + with prepended_sys_path(builder_paths): + 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", @@ -594,6 +607,10 @@ def load_workspace( + f" {PrivatePath(workspace_file)} is empty or parsed no workspace data", ) return None + except exc.WorkspaceBuilderError as e: + logger.debug("workspace builder resolution failed", exc_info=True) + tmuxp_echo(cli_colors.error("[Builder Error]") + f" {e}") + sys.exit(1) session_name = expanded_workspace["session_name"] @@ -612,13 +629,14 @@ def load_workspace( if _progress_disabled: _private_path = str(PrivatePath(workspace_file)) - result = _dispatch_build( - builder, - detached, - append, - answer_yes, - cli_colors, - ) + with prepended_sys_path(builder_paths): + result = _dispatch_build( + builder, + detached, + append, + answer_yes, + cli_colors, + ) if result is not None: summary = "" try: @@ -687,16 +705,17 @@ def _emit_success() -> None: ) if _resolved_panel != 0: builder.on_script_output = spinner.add_output_line - result = _dispatch_build( - builder, - detached, - append, - answer_yes, - cli_colors, - pre_attach_hook=_emit_success, - on_error_hook=spinner.stop, - pre_prompt_hook=spinner.stop, - ) + with prepended_sys_path(builder_paths): + result = _dispatch_build( + builder, + detached, + append, + answer_yes, + cli_colors, + pre_attach_hook=_emit_success, + on_error_hook=spinner.stop, + pre_prompt_hook=spinner.stop, + ) if result is not None: _emit_success() return result diff --git a/src/tmuxp/exc.py b/src/tmuxp/exc.py index 545038d7ca..8e76e531d5 100644 --- a/src/tmuxp/exc.py +++ b/src/tmuxp/exc.py @@ -94,6 +94,116 @@ def __init__(self, *args: object, **kwargs: object) -> None: return super().__init__("No session active.", *args, **kwargs) +class WorkspaceBuilderError(WorkspaceError): + """Base error for resolving and validating a workspace builder.""" + + +class WorkspaceBuilderNotFound(WorkspaceBuilderError): + """Configured ``workspace_builder`` could not be resolved. + + >>> print(WorkspaceBuilderNotFound("nope")) + Workspace builder 'nope' could not be found.... + """ + + def __init__( + self, + target: str, + available: list[str] | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = f"Workspace builder {target!r} could not be found." + if available: + names = ", ".join(sorted(available)) + msg += f" Available entry point builders: {names}." + else: + msg += ( + " Provide a Python dotted path (e.g. 'package.module:Builder') " + "or register an entry point in the 'tmuxp.workspace_builders' group." + ) + return super().__init__(msg, *args, **kwargs) + + +class WorkspaceBuilderImportError(WorkspaceBuilderError): + """Configured ``workspace_builder`` failed to import. + + >>> print(WorkspaceBuilderImportError("pkg:B")) + Could not import workspace builder 'pkg:B'.... + """ + + def __init__( + self, + target: str, + reason: str | None = None, + paths: list[str] | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = f"Could not import workspace builder {target!r}." + if reason: + msg += f" {reason}" + if paths: + msg += f" Searched trusted paths: {', '.join(paths)}." + msg += ( + " Confirm the builder is importable, or add its directory to " + "'workspace_builder_paths'." + ) + return super().__init__(msg, *args, **kwargs) + + +class InvalidWorkspaceBuilder(WorkspaceBuilderError): + """Resolved ``workspace_builder`` object is not a usable builder. + + >>> print(InvalidWorkspaceBuilder("pkg:B")) + 'pkg:B' is not a valid workspace builder. + """ + + def __init__( + self, + target: str, + reason: str | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = f"{target!r} is not a valid workspace builder" + msg += f": {reason}" if reason else "." + return super().__init__(msg, *args, **kwargs) + + +class WorkspaceBuilderPathError(WorkspaceBuilderError): + """A ``workspace_builder_paths`` entry is not a usable directory. + + >>> print(WorkspaceBuilderPathError("/x")) + workspace_builder_paths entry is invalid: /x.... + """ + + def __init__( + self, + path: str, + reason: str | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = f"workspace_builder_paths entry is invalid: {path}." + msg += f" {reason}" if reason else " Each entry must be an existing directory." + return super().__init__(msg, *args, **kwargs) + + +class InvalidWorkspaceBuilderOption(WorkspaceBuilderError): + """A ``workspace_builder_options`` value is invalid. + + >>> print(InvalidWorkspaceBuilderOption("bad")) + Invalid workspace_builder_options: bad + """ + + def __init__(self, reason: str, *args: object, **kwargs: object) -> None: + return super().__init__( + f"Invalid workspace_builder_options: {reason}", + *args, + **kwargs, + ) + + class TmuxpPluginException(TmuxpException): """Base Exception for Tmuxp Errors.""" diff --git a/src/tmuxp/workspace/builder/__init__.py b/src/tmuxp/workspace/builder/__init__.py new file mode 100644 index 0000000000..469a5308e9 --- /dev/null +++ b/src/tmuxp/workspace/builder/__init__.py @@ -0,0 +1,43 @@ +"""Workspace builders: turn an expanded workspace ``dict`` into a tmux session. + +:class:`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` is the default +implementation. A workspace may select a different builder with the +``workspace_builder`` config key, resolved by +:mod:`tmuxp.workspace.builder.registry`. + +``WorkspaceBuilder`` remains importable here as a backwards-compatible alias of +:class:`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`. +""" + +from __future__ import annotations + +from tmuxp.workspace.builder.classic import ( + ClassicWorkspaceBuilder, + get_default_columns, + get_default_rows, +) +from tmuxp.workspace.builder.protocol import WorkspaceBuilderProtocol +from tmuxp.workspace.builder.registry import ( + WORKSPACE_BUILDERS_GROUP, + available_builders, + prepended_sys_path, + resolve_builder_class, + resolve_builder_paths, +) + +# Backwards-compatible alias: the classic builder was historically named +# ``WorkspaceBuilder`` and imported from ``tmuxp.workspace.builder``. +WorkspaceBuilder = ClassicWorkspaceBuilder + +__all__ = [ + "WORKSPACE_BUILDERS_GROUP", + "ClassicWorkspaceBuilder", + "WorkspaceBuilder", + "WorkspaceBuilderProtocol", + "available_builders", + "get_default_columns", + "get_default_rows", + "prepended_sys_path", + "resolve_builder_class", + "resolve_builder_paths", +] diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder/classic.py similarity index 92% rename from src/tmuxp/workspace/builder.py rename to src/tmuxp/workspace/builder/classic.py index 728b477963..03d2fba87c 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder/classic.py @@ -17,6 +17,12 @@ from tmuxp import exc from tmuxp.log import TmuxpLoggerAdapter from tmuxp.util import get_current_pane, run_before_script +from tmuxp.workspace.options import ( + PaneReadiness, + WorkspaceBuilderOptions, + resolve_session_shell, + shell_is_zsh, +) if t.TYPE_CHECKING: from collections.abc import Iterator @@ -101,7 +107,7 @@ def get_default_rows() -> int: return int(os.getenv("TMUXP_DEFAULT_ROWS", os.getenv("ROWS", ROWS_FALLBACK))) -class WorkspaceBuilder: +class ClassicWorkspaceBuilder: """Load workspace from workspace :py:obj:`dict` object. Build tmux workspace from a configuration. Creates and names windows, sets options, @@ -134,7 +140,7 @@ class WorkspaceBuilder: ... - cmd: htop ... ''', Loader=yaml.Loader) - >>> builder = WorkspaceBuilder(session_config=session_config, server=server) + >>> builder = ClassicWorkspaceBuilder(session_config=session_config, server=server) **New session:** @@ -176,7 +182,7 @@ class WorkspaceBuilder: ... "session_name": "progress-demo", ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], ... } - >>> builder = WorkspaceBuilder( + >>> builder = ClassicWorkspaceBuilder( ... session_config=progress_cfg, ... server=server, ... on_progress=calls.append, @@ -192,7 +198,7 @@ class WorkspaceBuilder: ... "session_name": "hook-demo", ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], ... } - >>> builder = WorkspaceBuilder( + >>> builder = ClassicWorkspaceBuilder( ... session_config=no_script_cfg, ... server=server, ... on_before_script=lambda: hook_calls.append(True), @@ -208,7 +214,7 @@ class WorkspaceBuilder: ... "session_name": "script-output-demo", ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], ... } - >>> builder = WorkspaceBuilder( + >>> builder = ClassicWorkspaceBuilder( ... session_config=no_script_cfg2, ... server=server, ... on_script_output=script_lines.append, @@ -224,7 +230,7 @@ class WorkspaceBuilder: ... "session_name": "events-demo", ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], ... } - >>> builder = WorkspaceBuilder( + >>> builder = ClassicWorkspaceBuilder( ... session_config=event_cfg, ... server=server, ... on_build_event=events.append, @@ -247,7 +253,7 @@ class WorkspaceBuilder: ... "before_script": "echo hello", ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], ... } - >>> builder = WorkspaceBuilder( + >>> builder = ClassicWorkspaceBuilder( ... session_config=script_event_cfg, ... server=server, ... on_build_event=script_events.append, @@ -297,7 +303,9 @@ class WorkspaceBuilder: their panes, returning full :class:`libtmux.Window` and :class:`libtmux.Pane` objects each step of the way:: - workspace = WorkspaceBuilder(session_config=session_config, server=server) + workspace = ClassicWorkspaceBuilder( + session_config=session_config, server=server + ) It handles the magic of cases where the user may want to start a session inside tmux (when `$TMUX` is in the env variables). @@ -310,6 +318,8 @@ class WorkspaceBuilder: on_before_script: t.Callable[[], None] | None on_script_output: t.Callable[[str], None] | None on_build_event: t.Callable[[dict[str, t.Any]], None] | None + _builder_options: WorkspaceBuilderOptions + _pane_readiness_wait: bool def __init__( self, @@ -373,6 +383,14 @@ def __init__( self.on_script_output = on_script_output self.on_build_event = on_build_event + # Builder-behavior catalog (e.g. pane readiness). Parsed eagerly so an + # invalid value fails fast; the readiness decision itself needs a live + # session and is resolved in build(). + self._builder_options = WorkspaceBuilderOptions.from_config(session_config) + # Safe default for direct iter_create_panes() use that bypasses build(); + # build() replaces this with the policy-resolved value. + self._pane_readiness_wait = True + if self.server is not None and self.session_exists( session_name=self.session_config["session_name"], ): @@ -425,7 +443,7 @@ def build(self, session: Session | None = None, append: bool = False) -> None: if not session: if not self.server: msg = ( - "WorkspaceBuilder.build requires server to be passed " + "ClassicWorkspaceBuilder.build requires server to be passed " "on initialization, or pass in session object to here." ) raise exc.TmuxpException( @@ -538,6 +556,27 @@ def build(self, session: Session | None = None, append: bool = False) -> None: for option, value in self.session_config["environment"].items(): self.session.set_environment(option, value) + # Resolve the pane-readiness decision once, now that the session exists + # and its options (including `default-shell`) have been applied above. + # AUTO waits only for zsh (the shell the prompt-redraw wait was added + # for); ALWAYS/NEVER force the choice. + readiness = self._builder_options.pane_readiness + if readiness is PaneReadiness.ALWAYS: + self._pane_readiness_wait = True + elif readiness is PaneReadiness.NEVER: + self._pane_readiness_wait = False + else: # PaneReadiness.AUTO + self._pane_readiness_wait = shell_is_zsh( + resolve_session_shell(self.session), + ) + _log.debug( + "pane readiness resolved", + extra={ + "tmux_pane_readiness": readiness.value, + "tmux_pane_readiness_wait": self._pane_readiness_wait, + }, + ) + for window, window_config in self.iter_create_windows(session, append): assert isinstance(window, Window) @@ -781,9 +820,11 @@ def get_pane_shell( # Skip readiness wait when a custom shell/command launcher is set. # The shell/window_shell key runs a command (e.g. "top", "sleep 999") # that replaces the default shell — the pane exits when the command - # exits, so there is no interactive prompt to wait for. + # exits, so there is no interactive prompt to wait for. The + # pane_readiness policy (resolved in build()) further gates whether + # default-shell panes wait at all. pane_shell = pane_config.get("shell", window_config.get("window_shell")) - if pane_shell is None: + if pane_shell is None and self._pane_readiness_wait: _wait_for_pane_ready(pane) if "layout" in window_config: diff --git a/src/tmuxp/workspace/builder/protocol.py b/src/tmuxp/workspace/builder/protocol.py new file mode 100644 index 0000000000..0557bf7849 --- /dev/null +++ b/src/tmuxp/workspace/builder/protocol.py @@ -0,0 +1,108 @@ +"""Public contract for tmuxp workspace builders. + +A workspace builder turns an expanded workspace ``dict`` into a live tmux +session. :class:`tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` is the +default implementation; third parties may ship their own and select them with +the ``workspace_builder`` config key (see +:mod:`tmuxp.workspace.builder.registry`). + +This module is intentionally dependency-light (typing only) so builder authors +can import the contract without pulling in tmuxp's resolution machinery. + +The contract is synchronous today. Async builders are an *additive* future +extension: a subclass protocol can declare an ``async`` build entry point and a +capability flag, discovered through the same entry-point group, without +changing this sync surface. +""" + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from libtmux.server import Server + from libtmux.session import Session + + +@t.runtime_checkable +class WorkspaceBuilderProtocol(t.Protocol): + """Contract a builder must satisfy to be driven by ``tmuxp load``. + + A conforming builder is constructed with at least ``session_config`` and + ``server`` (plus the optional ``plugins`` and ``on_*`` callbacks the classic + builder accepts). It exposes :meth:`build`, surfaces the populated tmux + session via :attr:`session`, and honors the plugin/progress integration + points the CLI drives. + + Because the protocol carries data members (``session``, ``plugins``, the + ``on_*`` callbacks), validate *instances* with :func:`isinstance`; + class-level ``issubclass`` checks are not supported for runtime-checkable + protocols with non-method members. + + Examples + -------- + A builder implementing the full contract satisfies the protocol: + + >>> from tmuxp.workspace.builder.protocol import WorkspaceBuilderProtocol + >>> class MiniBuilder: + ... plugins: list = [] + ... on_progress = None + ... on_before_script = None + ... on_script_output = None + ... on_build_event = None + ... def __init__(self, session_config, server, **kwargs): + ... self._session_config = session_config + ... def build(self, session=None, append=False): + ... ... + ... def session_exists(self, session_name): + ... ... + ... def find_current_attached_session(self): + ... ... + ... @property + ... def session(self): + ... ... + >>> builder = MiniBuilder(session_config={}, server=None) + >>> isinstance(builder, WorkspaceBuilderProtocol) + True + + An object missing the contract does not: + + >>> isinstance(object(), WorkspaceBuilderProtocol) + False + """ + + plugins: list[t.Any] + on_progress: t.Callable[[str], None] | None + on_before_script: t.Callable[[], None] | None + on_script_output: t.Callable[[str], None] | None + on_build_event: t.Callable[[dict[str, t.Any]], None] | None + + def __init__( + self, + session_config: dict[str, t.Any], + server: Server, + plugins: list[t.Any] | None = None, + on_progress: t.Callable[[str], None] | None = None, + on_before_script: t.Callable[[], None] | None = None, + on_script_output: t.Callable[[str], None] | None = None, + on_build_event: t.Callable[[dict[str, t.Any]], None] | None = None, + ) -> None: + """Construct a builder from an expanded workspace and a tmux server.""" + ... + + @property + def session(self) -> Session: + """Return the tmux session the builder created or populated.""" + ... + + def build(self, session: Session | None = None, append: bool = False) -> None: + """Build the workspace, creating or populating a tmux session.""" + ... + + def session_exists(self, session_name: str) -> bool: + """Return ``True`` if a session with ``session_name`` already exists.""" + ... + + def find_current_attached_session(self) -> Session: + """Return the session currently attached within ``$TMUX``.""" + ... diff --git a/src/tmuxp/workspace/builder/registry.py b/src/tmuxp/workspace/builder/registry.py new file mode 100644 index 0000000000..e29a58ec70 --- /dev/null +++ b/src/tmuxp/workspace/builder/registry.py @@ -0,0 +1,326 @@ +"""Resolve and sandbox workspace builders selected by a workspace config. + +A workspace may point tmuxp at a builder other than the classic +:class:`tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` via the +``workspace_builder`` key (a Python dotted path, ``module:attr`` reference, or +an entry-point name in the ``tmuxp.workspace_builders`` group). When the builder +lives outside the active environment, ``workspace_builder_paths`` lists trusted +directories that are temporarily added to ``sys.path`` for the import and build. + +Security note: only literal directories are prepended to ``sys.path``. This +deliberately avoids :func:`site.addsitedir`, which executes ``.pth`` startup +files and is broader than making a module importable. The trust boundary is the +author of the workspace file. +""" + +from __future__ import annotations + +import contextlib +import importlib +import inspect +import os +import pathlib +import sys +import typing as t +from importlib import metadata + +from tmuxp import exc +from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder +from tmuxp.workspace.loader import expandshell + +if t.TYPE_CHECKING: + from collections.abc import Iterator + + from tmuxp.workspace.builder.protocol import WorkspaceBuilderProtocol + +WORKSPACE_BUILDERS_GROUP = "tmuxp.workspace_builders" +"""Entry-point group packaged builders register under.""" + + +def resolve_builder_paths( + session_config: dict[str, t.Any], + workspace_file: str | os.PathLike[str] | None = None, +) -> list[pathlib.Path]: + """Resolve and validate trusted ``workspace_builder_paths`` import roots. + + Each entry is shell-expanded (``~`` and ``$VARS``), resolved relative to the + workspace file's directory when not absolute, and required to be an existing + directory. Returns ``[]`` when the key is absent, so existing workspaces add + nothing to ``sys.path``. + + Parameters + ---------- + session_config : dict + the expanded workspace configuration + workspace_file : str or os.PathLike, optional + path to the workspace file; its parent anchors relative entries + + Returns + ------- + list of pathlib.Path + + Examples + -------- + Absent key resolves to an empty list (no-op for existing workspaces): + + >>> resolve_builder_paths({}, None) + [] + + An existing directory is resolved: + + >>> root = tmp_path / "roots" + >>> root.mkdir() + >>> resolve_builder_paths({"workspace_builder_paths": [str(root)]}, None) == [ + ... root.resolve() + ... ] + True + + A missing directory is rejected: + + >>> resolve_builder_paths( + ... {"workspace_builder_paths": [str(tmp_path / "missing")]}, None + ... ) + Traceback (most recent call last): + ... + tmuxp.exc.WorkspaceBuilderPathError: ... + """ + raw = session_config.get("workspace_builder_paths") or [] + if isinstance(raw, (str, os.PathLike)): + raw = [raw] + + base = ( + pathlib.Path(workspace_file).parent + if workspace_file is not None + else pathlib.Path.cwd() + ) + + resolved: list[pathlib.Path] = [] + for entry in raw: + if not isinstance(entry, (str, os.PathLike)): + raise exc.WorkspaceBuilderPathError( + str(entry), + reason="entries must be path strings", + ) + candidate = pathlib.Path(expandshell(str(entry))) + if not candidate.is_absolute(): + candidate = base / candidate + candidate = candidate.resolve() + if not candidate.is_dir(): + raise exc.WorkspaceBuilderPathError(str(PrivatePath(candidate))) + resolved.append(candidate) + return resolved + + +@contextlib.contextmanager +def prepended_sys_path( + paths: list[pathlib.Path] | None, +) -> Iterator[None]: + """Temporarily prepend directories to ``sys.path``, restoring it on exit. + + Paths are prepended in order (first entry ends up at ``sys.path[0]``). An + empty or falsy ``paths`` is a no-op. Uses only literal directory entries; it + avoids :func:`site.addsitedir` (which runs ``.pth`` startup code). + + Parameters + ---------- + paths : list of pathlib.Path or None + directories to prepend + + Examples + -------- + >>> import sys + >>> ext = tmp_path / "ext" + >>> ext.mkdir() + >>> before = list(sys.path) + >>> with prepended_sys_path([ext]): + ... sys.path[0] == str(ext) + True + >>> sys.path == before + True + """ + if not paths: + yield + return + saved = list(sys.path) + for path in reversed(paths): + sys.path.insert(0, str(path)) + try: + yield + finally: + sys.path[:] = saved + + +def available_builders() -> list[str]: + """Return the names of builders registered via entry points. + + Examples + -------- + >>> isinstance(available_builders(), list) + True + """ + return [ep.name for ep in metadata.entry_points(group=WORKSPACE_BUILDERS_GROUP)] + + +def _load_entry_point(name: str) -> t.Any | None: + """Load a builder registered under entry-point ``name``, else ``None``. + + Examples + -------- + >>> _load_entry_point("definitely-not-a-registered-builder") is None + True + """ + for ep in metadata.entry_points(group=WORKSPACE_BUILDERS_GROUP): + if ep.name == name: + try: + return ep.load() + except (ImportError, AttributeError) as e: + raise exc.WorkspaceBuilderImportError(name, reason=str(e)) from e + return None + + +def _import_target(target: str) -> t.Any: + """Import an object from a ``module:attr`` or dotted ``module.attr`` path. + + Examples + -------- + >>> _import_target( + ... "tmuxp.workspace.builder.classic:ClassicWorkspaceBuilder" + ... ).__name__ + 'ClassicWorkspaceBuilder' + + The historical ``tmuxp.workspace.builder:WorkspaceBuilder`` alias resolves + to the same class: + + >>> _import_target("tmuxp.workspace.builder:WorkspaceBuilder").__name__ + 'ClassicWorkspaceBuilder' + """ + if ":" in target: + module_name, _, attr = target.partition(":") + else: + module_name, _, attr = target.rpartition(".") + if not module_name or not attr: + raise exc.WorkspaceBuilderNotFound(target) + try: + module = importlib.import_module(module_name) + except ImportError as e: + raise exc.WorkspaceBuilderImportError(target, reason=str(e)) from e + obj: t.Any = module + try: + for part in attr.split("."): + obj = getattr(obj, part) + except AttributeError as e: + raise exc.WorkspaceBuilderImportError(target, reason=str(e)) from e + return obj + + +def _validate_builder(obj: t.Any, target: str) -> None: + """Validate that ``obj`` is a usable workspace builder. + + A class must expose a callable ``build`` method and a constructor accepting + ``session_config``, ``server``, and ``plugins`` (or ``**kwargs``) — the + arguments ``tmuxp load`` always passes. Non-class callables (factories) are + trusted and validated at instantiation. + + Examples + -------- + >>> from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + >>> _validate_builder(ClassicWorkspaceBuilder, "classic") + + >>> _validate_builder(object, "object") + Traceback (most recent call last): + ... + tmuxp.exc.InvalidWorkspaceBuilder: 'object' is not a valid workspace builder: ... + """ + if inspect.isclass(obj): + if not callable(getattr(obj, "build", None)): + raise exc.InvalidWorkspaceBuilder( + target, + reason="class has no callable 'build' method", + ) + try: + params = inspect.signature(obj).parameters + except (TypeError, ValueError): + return + has_var_kw = any( + p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values() + ) + missing = {"session_config", "server", "plugins"} - set(params) + if not has_var_kw and missing: + joined = ", ".join(sorted(missing)) + raise exc.InvalidWorkspaceBuilder( + target, + reason=f"constructor missing parameter(s): {joined}", + ) + elif not callable(obj): + raise exc.InvalidWorkspaceBuilder(target, reason="not a class or callable") + + +def resolve_builder_class( + session_config: dict[str, t.Any], +) -> type[WorkspaceBuilderProtocol]: + """Resolve the workspace builder class selected by ``session_config``. + + Resolution of the ``workspace_builder`` value: + + 1. absent/empty → the classic + :class:`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` (no + import, no entry-point scan); + 2. contains ``:`` → a ``module:attr`` object reference; + 3. no ``.`` and no ``:`` → an entry-point name in the + ``tmuxp.workspace_builders`` group; + 4. dotted, no ``:`` → an entry-point name if registered, otherwise a + ``module.attr`` dotted path. + + Parameters + ---------- + session_config : dict + the expanded workspace configuration + + Returns + ------- + type + a workspace builder class (or builder factory) satisfying + :class:`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` + + Examples + -------- + The default resolves to the classic builder without importing anything: + + >>> from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + >>> resolve_builder_class({}) is ClassicWorkspaceBuilder + True + + A dotted ``module:attr`` reference is imported and validated: + + >>> resolve_builder_class( + ... { + ... "workspace_builder": ( + ... "tmuxp.workspace.builder.classic:ClassicWorkspaceBuilder" + ... ) + ... } + ... ) is ClassicWorkspaceBuilder + True + """ + target = session_config.get("workspace_builder") + if not target: + return ClassicWorkspaceBuilder + target_str = str(target).strip() + + obj: t.Any + if ":" in target_str: + obj = _import_target(target_str) + elif "." not in target_str: + obj = _load_entry_point(target_str) + if obj is None: + raise exc.WorkspaceBuilderNotFound( + target_str, + available=available_builders(), + ) + else: + obj = _load_entry_point(target_str) + if obj is None: + obj = _import_target(target_str) + + _validate_builder(obj, target_str) + return t.cast("type[WorkspaceBuilderProtocol]", obj) diff --git a/src/tmuxp/workspace/options.py b/src/tmuxp/workspace/options.py new file mode 100644 index 0000000000..cc2fad0401 --- /dev/null +++ b/src/tmuxp/workspace/options.py @@ -0,0 +1,229 @@ +"""Behavior options for tmuxp workspace builders. + +The ``workspace_builder_options`` config catalog holds settings that tune how a +workspace builder runs, independent of *which* builder is selected. It is a +sibling to the tmux ``options`` / ``global_options`` / ``environment`` catalogs +and is the home for builder-behavior knobs (today: pane readiness; later: +parallel/async builder settings). + +Example +------- +.. code-block:: yaml + + workspace_builder_options: + pane_readiness: auto # auto | always | never (+ truthy/falsy aliases) +""" + +from __future__ import annotations + +import dataclasses +import enum +import os +import typing as t + +from tmuxp import exc + +_AUTO_ALIASES = frozenset({"auto"}) +_ALWAYS_ALIASES = frozenset({"always", "true", "on", "yes", "1"}) +_NEVER_ALIASES = frozenset({"never", "false", "off", "no", "0"}) + + +class PaneReadiness(enum.Enum): + """Policy for whether the builder waits for a pane's shell prompt. + + tmuxp waits for each default-shell pane to draw its prompt before + dispatching layout and commands, which avoids a zsh prompt-redraw artifact + (see :func:`tmuxp.workspace.builder.classic._wait_for_pane_ready`). The wait + is only needed for zsh, so the default :attr:`AUTO` policy waits only when + the session's interactive shell is zsh. + """ + + AUTO = "auto" + ALWAYS = "always" + NEVER = "never" + + @classmethod + def from_config(cls, value: t.Any) -> PaneReadiness: + """Parse a ``pane_readiness`` config value into a policy. + + Accepts the canonical ``auto`` / ``always`` / ``never`` strings, plus + the truthy/falsy aliases users expect from boolean-like config keys. + + Parameters + ---------- + value : Any + value from ``workspace_builder_options.pane_readiness``; ``None`` + (key absent) resolves to :attr:`AUTO` + + Returns + ------- + PaneReadiness + + Examples + -------- + >>> PaneReadiness.from_config(None) is PaneReadiness.AUTO + True + >>> PaneReadiness.from_config("auto") is PaneReadiness.AUTO + True + >>> PaneReadiness.from_config(True) is PaneReadiness.ALWAYS + True + >>> PaneReadiness.from_config("on") is PaneReadiness.ALWAYS + True + >>> PaneReadiness.from_config(1) is PaneReadiness.ALWAYS + True + >>> PaneReadiness.from_config(False) is PaneReadiness.NEVER + True + >>> PaneReadiness.from_config("never") is PaneReadiness.NEVER + True + + Unknown values raise an actionable error: + + >>> PaneReadiness.from_config("sometimes") + Traceback (most recent call last): + ... + ValueError: invalid pane_readiness value: 'sometimes'; expected one of: + auto, always/true/on/yes/1, never/false/off/no/0 + """ + if value is None: + return cls.AUTO + if isinstance(value, cls): + return value + if isinstance(value, bool): + return cls.ALWAYS if value else cls.NEVER + normalized = str(value).strip().lower() + if normalized in _AUTO_ALIASES: + return cls.AUTO + if normalized in _ALWAYS_ALIASES: + return cls.ALWAYS + if normalized in _NEVER_ALIASES: + return cls.NEVER + msg = ( + f"invalid pane_readiness value: {value!r}; expected one of: " + "auto, always/true/on/yes/1, never/false/off/no/0" + ) + raise ValueError(msg) + + +@dataclasses.dataclass(frozen=True) +class WorkspaceBuilderOptions: + """Parsed ``workspace_builder_options`` catalog. + + An absent ``workspace_builder_options`` catalog uses the defaults, whose + :attr:`PaneReadiness.AUTO` policy waits for a pane's prompt only when the + session shell is zsh: zsh workspaces build as before, while bash, sh, and + other shells skip the wait. Set ``pane_readiness: always`` to restore the + previous wait-everywhere behavior. + """ + + pane_readiness: PaneReadiness = PaneReadiness.AUTO + """pane-prompt wait policy; defaults to :attr:`PaneReadiness.AUTO`""" + + @classmethod + def from_config(cls, session_config: dict[str, t.Any]) -> WorkspaceBuilderOptions: + """Build options from a full workspace ``session_config`` dict. + + Reads the optional ``workspace_builder_options`` catalog; an absent or + empty catalog yields the defaults (see the class docstring for how the + default :attr:`PaneReadiness.AUTO` policy affects non-zsh shells). + + Parameters + ---------- + session_config : dict + the expanded workspace configuration + + Returns + ------- + WorkspaceBuilderOptions + + Examples + -------- + >>> WorkspaceBuilderOptions.from_config({}).pane_readiness + + + >>> cfg = {"workspace_builder_options": {"pane_readiness": "always"}} + >>> WorkspaceBuilderOptions.from_config(cfg).pane_readiness + + """ + catalog = session_config.get("workspace_builder_options") or {} + if not isinstance(catalog, dict): + msg = f"must be a mapping, got {type(catalog).__name__}" + raise exc.InvalidWorkspaceBuilderOption(msg) + try: + pane_readiness = PaneReadiness.from_config(catalog.get("pane_readiness")) + except ValueError as e: + raise exc.InvalidWorkspaceBuilderOption(str(e)) from e + return cls(pane_readiness=pane_readiness) + + +def shell_is_zsh(shell: str | None) -> bool: + """Return ``True`` when ``shell`` names the zsh shell. + + Parameters + ---------- + shell : str or None + a shell path or name (e.g. ``/usr/bin/zsh``) + + Returns + ------- + bool + + Examples + -------- + >>> shell_is_zsh("/usr/bin/zsh") + True + >>> shell_is_zsh("/bin/bash") + False + >>> shell_is_zsh(None) + False + """ + return "zsh" in (shell or "") + + +def resolve_session_shell( + session: t.Any, + env: t.Mapping[str, str] | None = None, +) -> str: + """Resolve the effective interactive shell for a tmux session. + + Prefers tmux's ``default-shell`` option (which reflects a workspace's + ``options.default-shell`` once applied, otherwise tmux's global default), + and falls back to the ``SHELL`` environment variable. + + Parameters + ---------- + session : :class:`libtmux.Session` + live session exposing ``show_option("default-shell")`` + env : Mapping, optional + environment mapping for the ``SHELL`` fallback; defaults to + :data:`os.environ` + + Returns + ------- + str + resolved shell path/name, or ``""`` when undeterminable + + Examples + -------- + >>> class FakeSession: + ... def __init__(self, shell): + ... self._shell = shell + ... def show_option(self, name, **kwargs): + ... return self._shell + + The tmux ``default-shell`` wins when set: + + >>> resolve_session_shell(FakeSession("/usr/bin/zsh"), env={}) + '/usr/bin/zsh' + + The ``SHELL`` env var is the fallback: + + >>> resolve_session_shell(FakeSession(None), env={"SHELL": "/bin/bash"}) + '/bin/bash' + """ + # include_inherited resolves a globally-set default-shell (e.g. + # ``set -g default-shell`` in tmux.conf) that the session inherits rather + # than overrides; without it tmux returns None and we'd fall back to $SHELL. + default_shell = session.show_option("default-shell", include_inherited=True) + environ = os.environ if env is None else env + env_shell = environ.get("SHELL", "") + return str(default_shell or env_shell or "") diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index ec045dcf3c..275e798d68 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -653,6 +653,100 @@ def test_load_plugins_plugin_missing( assert "[Plugin Error]" in result.out +class WorkspaceBuilderErrorFixture(t.NamedTuple): + """Test fixture for invalid ``workspace_builder`` selections.""" + + test_id: str + yaml: str + expected_substring: str + + +WORKSPACE_BUILDER_ERROR_FIXTURES: list[WorkspaceBuilderErrorFixture] = [ + WorkspaceBuilderErrorFixture( + test_id="unknown_entry_point_name", + yaml="""\ +session_name: builder-error +workspace_builder: nonexistent-builder +windows: +- panes: + - echo hi +""", + expected_substring="could not be found", + ), + WorkspaceBuilderErrorFixture( + test_id="dotted_import_error", + yaml="""\ +session_name: builder-error +workspace_builder: tmuxp.does_not_exist:Thing +windows: +- panes: + - echo hi +""", + expected_substring="Could not import", + ), + WorkspaceBuilderErrorFixture( + test_id="invalid_object", + yaml="""\ +session_name: builder-error +workspace_builder: tests.fixtures.workspace_builders.invalid:NotABuilder +windows: +- panes: + - echo hi +""", + expected_substring="not a valid workspace builder", + ), + WorkspaceBuilderErrorFixture( + test_id="missing_builder_path", + yaml="""\ +session_name: builder-error +workspace_builder: any:Builder +workspace_builder_paths: +- /nonexistent/tmuxp/builder/dir +windows: +- panes: + - echo hi +""", + expected_substring="workspace_builder_paths entry is invalid", + ), + WorkspaceBuilderErrorFixture( + test_id="invalid_pane_readiness", + yaml="""\ +session_name: builder-error +workspace_builder_options: + pane_readiness: sometimes +windows: +- panes: + - echo hi +""", + expected_substring="pane_readiness", + ), +] + + +@pytest.mark.parametrize( + list(WorkspaceBuilderErrorFixture._fields), + WORKSPACE_BUILDER_ERROR_FIXTURES, + ids=[test.test_id for test in WORKSPACE_BUILDER_ERROR_FIXTURES], +) +def test_load_workspace_builder_error_exits( + tmp_path: pathlib.Path, + server: Server, + capsys: pytest.CaptureFixture[str], + test_id: str, + yaml: str, + expected_substring: str, +) -> None: + """Invalid workspace_builder exits with a styled error, not a traceback.""" + config_file = tmp_path / ".tmuxp.yaml" + config_file.write_text(yaml, encoding="utf-8") + + with pytest.raises(SystemExit) as excinfo: + load_workspace(config_file, socket_name=server.socket_name, detached=True) + + assert excinfo.value.code == 1 + assert expected_substring in capsys.readouterr().out + + def test_plugin_system_before_script( monkeypatch_plugin_test_packages: None, server: Server, diff --git a/tests/fixtures/workspace_builders/__init__.py b/tests/fixtures/workspace_builders/__init__.py new file mode 100644 index 0000000000..4599a8613e --- /dev/null +++ b/tests/fixtures/workspace_builders/__init__.py @@ -0,0 +1,3 @@ +"""Custom workspace builder fixtures for resolution tests.""" + +from __future__ import annotations diff --git a/tests/fixtures/workspace_builders/invalid.py b/tests/fixtures/workspace_builders/invalid.py new file mode 100644 index 0000000000..020839315f --- /dev/null +++ b/tests/fixtures/workspace_builders/invalid.py @@ -0,0 +1,19 @@ +"""Objects that are not valid workspace builders, used by resolution tests.""" + +from __future__ import annotations + +import typing as t + + +class NotABuilder: + """A class missing the required ``build`` method.""" + + +class MissingPluginsBuilder: + """A builder whose constructor omits the ``plugins`` parameter.""" + + def __init__(self, session_config: t.Any, server: t.Any) -> None: + self.session_config = session_config + + def build(self, session: t.Any = None, append: bool = False) -> None: + """No-op build for validation tests.""" diff --git a/tests/fixtures/workspace_builders/valid.py b/tests/fixtures/workspace_builders/valid.py new file mode 100644 index 0000000000..bb3fb154f9 --- /dev/null +++ b/tests/fixtures/workspace_builders/valid.py @@ -0,0 +1,9 @@ +"""A minimal valid custom workspace builder used by resolution tests.""" + +from __future__ import annotations + +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + + +class CustomBuilder(ClassicWorkspaceBuilder): + """Trivial subclass that satisfies the workspace builder contract.""" diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 007a489c16..32cbdffcf4 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -25,8 +25,9 @@ from tmuxp import exc from tmuxp._internal.config_reader import ConfigReader from tmuxp.cli.load import load_plugins -from tmuxp.workspace import builder as builder_module, loader -from tmuxp.workspace.builder import WorkspaceBuilder, _wait_for_pane_ready +from tmuxp.workspace import loader +from tmuxp.workspace.builder import WorkspaceBuilder, classic as builder_classic +from tmuxp.workspace.builder.classic import _wait_for_pane_ready if t.TYPE_CHECKING: from libtmux.server import Server @@ -1563,6 +1564,8 @@ class PaneReadinessFixture(t.NamedTuple): yaml=textwrap.dedent( """\ session_name: readiness-test +workspace_builder_options: + pane_readiness: always windows: - panes: - shell_command: @@ -1578,6 +1581,8 @@ class PaneReadinessFixture(t.NamedTuple): yaml=textwrap.dedent( """\ session_name: readiness-test +workspace_builder_options: + pane_readiness: always windows: - panes: - shell_command: @@ -1592,6 +1597,8 @@ class PaneReadinessFixture(t.NamedTuple): yaml=textwrap.dedent( """\ session_name: readiness-test +workspace_builder_options: + pane_readiness: always windows: - panes: - shell_command: @@ -1608,6 +1615,8 @@ class PaneReadinessFixture(t.NamedTuple): yaml=textwrap.dedent( """\ session_name: readiness-test +workspace_builder_options: + pane_readiness: always windows: - window_shell: top panes: @@ -1635,7 +1644,7 @@ def test_pane_readiness_call_count( ) -> None: """Verify _wait_for_pane_ready is called only for appropriate panes.""" call_count = 0 - original = builder_module._wait_for_pane_ready + original = builder_classic._wait_for_pane_ready def counting_wait( pane: Pane, @@ -1646,7 +1655,7 @@ def counting_wait( call_count += 1 return original(pane, timeout=timeout, interval=interval) - monkeypatch.setattr(builder_module, "_wait_for_pane_ready", counting_wait) + monkeypatch.setattr(builder_classic, "_wait_for_pane_ready", counting_wait) yaml_workspace = tmp_path / "readiness.yaml" yaml_workspace.write_text(yaml, encoding="utf-8") @@ -1659,6 +1668,151 @@ def counting_wait( assert call_count == expected_wait_count +def _count_readiness_waits( + *, + server: Server, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + yaml: str, + shell: str | None = None, +) -> int: + """Build a workspace from ``yaml`` and count ``_wait_for_pane_ready`` calls. + + When ``shell`` is given, the resolved session shell is forced so ``auto`` + detection is deterministic regardless of the shell the suite runs under. + """ + call_count = 0 + original = builder_classic._wait_for_pane_ready + + def counting_wait( + pane: Pane, + timeout: float = 2.0, + interval: float = 0.05, + ) -> bool: + nonlocal call_count + call_count += 1 + return original(pane, timeout=timeout, interval=interval) + + monkeypatch.setattr(builder_classic, "_wait_for_pane_ready", counting_wait) + if shell is not None: + monkeypatch.setattr( + builder_classic, + "resolve_session_shell", + lambda session, env=None: shell, + ) + + yaml_workspace = tmp_path / "readiness.yaml" + yaml_workspace.write_text(yaml, encoding="utf-8") + workspace = loader.trickle(loader.expand(ConfigReader._from_file(yaml_workspace))) + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + return call_count + + +_TWO_DEFAULT_PANES = """\ +- panes: + - shell_command: + - cmd: echo hello + - shell_command: + - cmd: echo world +""" + + +def _readiness_yaml(windows: str, policy: str | None = None) -> str: + """Compose a readiness workspace with an optional pane_readiness policy.""" + catalog = ( + f"workspace_builder_options:\n pane_readiness: {policy}\n" if policy else "" + ) + return f"session_name: readiness-test\n{catalog}windows:\n{windows}" + + +def test_pane_readiness_auto_waits_for_zsh( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Wait for default-shell panes under auto when the session shell is zsh.""" + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(_TWO_DEFAULT_PANES), + shell="/usr/bin/zsh", + ) + assert count == 2 + + +def test_pane_readiness_auto_skips_non_zsh( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Skip the wait under auto for non-zsh shells (the intended speedup).""" + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(_TWO_DEFAULT_PANES), + shell="/bin/bash", + ) + assert count == 0 + + +def test_pane_readiness_always_waits_non_zsh( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Wait for default-shell panes under always even for non-zsh shells.""" + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(_TWO_DEFAULT_PANES, policy="always"), + shell="/bin/bash", + ) + assert count == 2 + + +def test_pane_readiness_never_skips_zsh( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Skip the wait under never even for zsh.""" + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(_TWO_DEFAULT_PANES, policy="never"), + shell="/usr/bin/zsh", + ) + assert count == 0 + + +def test_pane_readiness_custom_shell_skips_under_always( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A window_shell pane skips the wait even when policy is always.""" + windows = textwrap.dedent( + """\ +- window_shell: top + panes: + - shell_command: [] + - shell_command: [] +""", + ) + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(windows, policy="always"), + ) + assert count == 0 + + def test_select_layout_not_called_after_yield( tmp_path: pathlib.Path, server: Server, diff --git a/tests/workspace/test_builder_registry.py b/tests/workspace/test_builder_registry.py new file mode 100644 index 0000000000..09dce0b143 --- /dev/null +++ b/tests/workspace/test_builder_registry.py @@ -0,0 +1,188 @@ +"""Tests for workspace builder resolution (:mod:`tmuxp.workspace.builder.registry`).""" + +from __future__ import annotations + +import sys +import textwrap +import typing as t + +import pytest + +from tmuxp import exc +from tmuxp.workspace import loader +from tmuxp.workspace.builder import registry +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.server import Server + +VALID = "tests.fixtures.workspace_builders.valid" +INVALID = "tests.fixtures.workspace_builders.invalid" + + +def test_resolve_default_returns_classic() -> None: + """An absent workspace_builder resolves to the classic builder.""" + assert registry.resolve_builder_class({}) is ClassicWorkspaceBuilder + + +def test_resolve_entry_point_classic() -> None: + """The 'classic' entry point resolves to the classic builder.""" + resolved = registry.resolve_builder_class({"workspace_builder": "classic"}) + assert resolved is ClassicWorkspaceBuilder + + +def test_resolve_dotted_object_reference() -> None: + """A ``module:attr`` reference resolves and validates.""" + from tests.fixtures.workspace_builders.valid import CustomBuilder + + resolved = registry.resolve_builder_class( + {"workspace_builder": f"{VALID}:CustomBuilder"}, + ) + assert resolved is CustomBuilder + + +def test_resolve_dotted_path_reference() -> None: + """A dotted ``module.attr`` path resolves and validates.""" + from tests.fixtures.workspace_builders.valid import CustomBuilder + + resolved = registry.resolve_builder_class( + {"workspace_builder": f"{VALID}.CustomBuilder"}, + ) + assert resolved is CustomBuilder + + +def test_resolve_compat_alias_path() -> None: + """The historical ``tmuxp.workspace.builder:WorkspaceBuilder`` still resolves.""" + resolved = registry.resolve_builder_class( + {"workspace_builder": "tmuxp.workspace.builder:WorkspaceBuilder"}, + ) + assert resolved is ClassicWorkspaceBuilder + + +def test_resolve_not_found_bare_name() -> None: + """An unknown bare name raises WorkspaceBuilderNotFound.""" + with pytest.raises(exc.WorkspaceBuilderNotFound): + registry.resolve_builder_class({"workspace_builder": "nonexistent-builder"}) + + +def test_resolve_import_error() -> None: + """An unimportable dotted path raises WorkspaceBuilderImportError.""" + with pytest.raises(exc.WorkspaceBuilderImportError): + registry.resolve_builder_class( + {"workspace_builder": "tmuxp.does_not_exist:Thing"}, + ) + + +def test_resolve_invalid_builder() -> None: + """A class without a build method raises InvalidWorkspaceBuilder.""" + with pytest.raises(exc.InvalidWorkspaceBuilder): + registry.resolve_builder_class( + {"workspace_builder": f"{INVALID}:NotABuilder"}, + ) + + +def test_resolve_rejects_constructor_without_plugins() -> None: + """A builder whose __init__ lacks plugins is rejected before instantiation. + + The CLI always calls the builder with ``plugins=...``, so such a constructor + would otherwise raise a raw TypeError instead of a styled error. + """ + with pytest.raises(exc.InvalidWorkspaceBuilder): + registry.resolve_builder_class( + {"workspace_builder": f"{INVALID}:MissingPluginsBuilder"}, + ) + + +def test_available_builders_includes_classic() -> None: + """The classic entry point is discoverable.""" + assert "classic" in registry.available_builders() + + +def test_resolve_builder_paths_absent() -> None: + """No workspace_builder_paths resolves to an empty list.""" + assert registry.resolve_builder_paths({}, None) == [] + + +def test_resolve_builder_paths_requires_directory(tmp_path: pathlib.Path) -> None: + """A missing directory raises WorkspaceBuilderPathError.""" + with pytest.raises(exc.WorkspaceBuilderPathError): + registry.resolve_builder_paths( + {"workspace_builder_paths": [str(tmp_path / "missing")]}, + None, + ) + + +def test_resolve_builder_paths_relative_to_workspace_file( + tmp_path: pathlib.Path, +) -> None: + """Relative entries resolve against the workspace file's directory.""" + builders_dir = tmp_path / "builders" + builders_dir.mkdir() + workspace_file = tmp_path / "workspace.yaml" + workspace_file.write_text("session_name: x\n", encoding="utf-8") + + resolved = registry.resolve_builder_paths( + {"workspace_builder_paths": ["builders"]}, + workspace_file, + ) + assert resolved == [builders_dir.resolve()] + + +def test_prepended_sys_path_restores(tmp_path: pathlib.Path) -> None: + """The sandbox restores sys.path exactly on exit.""" + before = list(sys.path) + with registry.prepended_sys_path([tmp_path]): + assert sys.path[0] == str(tmp_path) + assert sys.path == before + + +def test_trusted_path_enables_import(tmp_path: pathlib.Path) -> None: + """A builder in a trusted path imports only while the path is active.""" + sys.modules.pop("ext_builder_trusted", None) + module = tmp_path / "ext_builder_trusted.py" + module.write_text( + textwrap.dedent( + '''\ +"""External builder for trusted-path tests.""" + +from __future__ import annotations + +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + + +class ExternalBuilder(ClassicWorkspaceBuilder): + """External custom builder.""" +''', + ), + encoding="utf-8", + ) + config = {"workspace_builder": "ext_builder_trusted:ExternalBuilder"} + + with pytest.raises(exc.WorkspaceBuilderImportError): + registry.resolve_builder_class(config) + + paths = registry.resolve_builder_paths( + {"workspace_builder_paths": [str(tmp_path)]}, + None, + ) + with registry.prepended_sys_path(paths): + resolved = registry.resolve_builder_class(config) + assert resolved.__name__ == "ExternalBuilder" + + +def test_resolve_and_build_selected_builder(server: Server) -> None: + """A config selecting a builder builds through the resolved class.""" + config = loader.expand( + { + "session_name": "registry-build", + "workspace_builder": "classic", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + }, + ) + builder_cls = registry.resolve_builder_class(config) + builder = builder_cls(session_config=config, server=server) + builder.build() + assert builder.session.name == "registry-build" + builder.session.kill() diff --git a/tests/workspace/test_options.py b/tests/workspace/test_options.py new file mode 100644 index 0000000000..c7c9aa369b --- /dev/null +++ b/tests/workspace/test_options.py @@ -0,0 +1,150 @@ +"""Tests for workspace builder options (:mod:`tmuxp.workspace.options`).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from tmuxp import exc +from tmuxp.workspace.options import ( + PaneReadiness, + WorkspaceBuilderOptions, + resolve_session_shell, + shell_is_zsh, +) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, PaneReadiness.AUTO), + ("auto", PaneReadiness.AUTO), + ("AUTO", PaneReadiness.AUTO), + (True, PaneReadiness.ALWAYS), + ("always", PaneReadiness.ALWAYS), + ("on", PaneReadiness.ALWAYS), + ("yes", PaneReadiness.ALWAYS), + ("1", PaneReadiness.ALWAYS), + (1, PaneReadiness.ALWAYS), + (False, PaneReadiness.NEVER), + ("never", PaneReadiness.NEVER), + ("off", PaneReadiness.NEVER), + ("no", PaneReadiness.NEVER), + ("0", PaneReadiness.NEVER), + (0, PaneReadiness.NEVER), + (PaneReadiness.ALWAYS, PaneReadiness.ALWAYS), + ], +) +def test_pane_readiness_from_config(value: t.Any, expected: PaneReadiness) -> None: + """from_config maps canonical values and truthy/falsy aliases.""" + assert PaneReadiness.from_config(value) is expected + + +@pytest.mark.parametrize("value", ["sometimes", "maybe", "2", ""]) +def test_pane_readiness_from_config_invalid(value: str) -> None: + """from_config rejects unknown values with an actionable error.""" + with pytest.raises(ValueError, match="pane_readiness"): + PaneReadiness.from_config(value) + + +def test_workspace_builder_options_defaults() -> None: + """An absent catalog yields AUTO readiness.""" + assert WorkspaceBuilderOptions.from_config({}).pane_readiness is PaneReadiness.AUTO + cfg = {"session_name": "x", "windows": []} + assert WorkspaceBuilderOptions.from_config(cfg).pane_readiness is PaneReadiness.AUTO + + +def test_workspace_builder_options_reads_catalog() -> None: + """from_config reads pane_readiness from the catalog.""" + cfg = {"workspace_builder_options": {"pane_readiness": "never"}} + options = WorkspaceBuilderOptions.from_config(cfg) + assert options.pane_readiness is PaneReadiness.NEVER + + +def test_workspace_builder_options_invalid_catalog_type() -> None: + """A non-mapping catalog is rejected as a builder-option error.""" + with pytest.raises(exc.InvalidWorkspaceBuilderOption, match="must be a mapping"): + WorkspaceBuilderOptions.from_config({"workspace_builder_options": ["nope"]}) + + +def test_workspace_builder_options_invalid_pane_readiness() -> None: + """An invalid pane_readiness is wrapped as a builder-option error.""" + with pytest.raises(exc.InvalidWorkspaceBuilderOption, match="pane_readiness"): + WorkspaceBuilderOptions.from_config( + {"workspace_builder_options": {"pane_readiness": "sometimes"}}, + ) + + +@pytest.mark.parametrize( + ("shell", "expected"), + [ + ("/usr/bin/zsh", True), + ("/bin/zsh", True), + ("zsh", True), + ("/bin/bash", False), + ("/bin/sh", False), + ("", False), + (None, False), + ], +) +def test_shell_is_zsh(shell: str | None, expected: bool) -> None: + """shell_is_zsh detects zsh by name.""" + assert shell_is_zsh(shell) is expected + + +class _FakeSession: + """Minimal stand-in exposing ``show_option`` for shell resolution.""" + + def __init__(self, shell: str | None) -> None: + self._shell = shell + + def show_option(self, name: str, **kwargs: t.Any) -> str | None: + """Return the canned default-shell value.""" + return self._shell + + +def test_resolve_session_shell_prefers_default_shell() -> None: + """The tmux default-shell wins over $SHELL.""" + shell = resolve_session_shell( + _FakeSession("/usr/bin/zsh"), + env={"SHELL": "/bin/bash"}, + ) + assert shell == "/usr/bin/zsh" + + +def test_resolve_session_shell_falls_back_to_env() -> None: + """$SHELL is the fallback when default-shell is empty.""" + shell = resolve_session_shell(_FakeSession(None), env={"SHELL": "/bin/bash"}) + assert shell == "/bin/bash" + + +def test_resolve_session_shell_empty_when_unknown() -> None: + """Returns an empty string when neither source resolves.""" + assert resolve_session_shell(_FakeSession(None), env={}) == "" + + +class _InheritedShellSession: + """Session whose default-shell is only visible with include_inherited.""" + + def __init__(self, shell: str) -> None: + self._shell = shell + + def show_option( + self, + name: str, + *, + include_inherited: bool = False, + **kwargs: t.Any, + ) -> str | None: + """Return the shell only when inherited options are requested.""" + return self._shell if include_inherited else None + + +def test_resolve_session_shell_reads_inherited_default_shell() -> None: + """A globally-inherited default-shell wins over the $SHELL fallback.""" + shell = resolve_session_shell( + _InheritedShellSession("/usr/bin/zsh"), + env={"SHELL": "/bin/bash"}, + ) + assert shell == "/usr/bin/zsh" diff --git a/uv.lock b/uv.lock index d366553884..11ede46c19 100644 --- a/uv.lock +++ b/uv.lock @@ -1608,7 +1608,7 @@ wheels = [ [[package]] name = "tmuxp" -version = "1.72.0" +version = "1.73.0" source = { editable = "." } dependencies = [ { name = "libtmux" },