Repository: pypa/hatch

Issue:
Default shell with arguments
Hey guys,

so I have been working with hatch now for a couple of hours and all seems very nice. Good job!
However, one gripe I still have would be that the default shell cannot take arguments? In my case I would like to run a login shell (`bash -l`) instead of the default.

My config looks like this:

```
mode = "local"
project = ""
shell = "bash"

[dirs]
.... etc
```

and it works fine. But if I try this:

```
mode = "local"
project = ""
shell = "bash -l"

[dirs]
.... etc
```

I will get the following error:

```
Traceback (most recent call last):
  File "/home/niid/.local/bin/hatch", line 8, in <module>
    sys.exit(main())
  File "/home/niid/.local/pipx/venvs/hatch/lib/python3.8/site-packages/hatch/cli/__init__.py", line 202, in main
    return hatch(windows_expand_args=False)
  File "/home/niid/.local/pipx/venvs/hatch/lib/python3.8/site-packages/click/core.py", line 1130, in __call__
    return self.main(*args, **kwargs)
  File "/home/niid/.local/pipx/venvs/hatch/lib/python3.8/site-packages/click/core.py", line 1055, in main
    rv = self.invoke(ctx)
  File "/home/niid/.local/pipx/venvs/hatch/lib/python3.8/site-packages/click/core.py", line 1657, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
  File "/home/niid/.local/pipx/venvs/hatch/lib/python3.8/site-packages/click/core.py", line 1404, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/home/niid/.local/pipx/venvs/hatch/lib/python3.8/site-packages/click/core.py", line 760, in invoke
    return __callback(*args, **kwargs)
  File "/home/niid/.local/pipx/venvs/hatch/lib/python3.8/site-packages/click/decorators.py", line 38, in new_func
    return f(get_current_context().obj, *args, **kwargs)
  File "/home/niid/.local/pipx/venvs/hatch/lib/python3.8/site-packages/hatch/cli/shell/__init__.py", line 35, in shell
    environment.enter_shell(shell_name, shell_path)
  File "/home/niid/.local/pipx/venvs/hatch/lib/python3.8/site-packages/hatch/env/virtual.py", line 104, in enter_shell
    self.platform.exit_with_command([path])
  File "/home/niid/.local/pipx/venvs/hatch/lib/python3.8/site-packages/hatch/utils/platform.py", line 208, in exit_with_command
    return os.execvp(command[0], command)
  File "/usr/lib/python3.8/os.py", line 568, in execvp
    _execvpe(file, args)
  File "/usr/lib/python3.8/os.py", line 610, in _execvpe
    raise last_exc
  File "/usr/lib/python3.8/os.py", line 601, in _execvpe
    exec_func(fullname, *argrest)
FileNotFoundError: [Errno 2] No such file or directory
```

Discussion:

Patch:
diff --git a/docs/config/hatch.md b/docs/config/hatch.md
index 98d7e132..c7cd2433 100644
--- a/docs/config/hatch.md
+++ b/docs/config/hatch.md
@@ -94,19 +94,29 @@ If the executable name of your shell differs from the supported name, you can de
     path = "pwsh"
     ```
 
+On non-Windows systems, you can change the default arguments used to spawn most shells with the `args` key. The default for such supported shells is usually `["-i"]`.
+
+=== ":octicons-file-code-16: config.toml"
+
+    ```toml
+    [shell]
+    name = "bash"
+    args = ["--login"]
+    ```
+
 ### Supported
 
-| Shell | Name | macOS | Windows | Unix |
-| --- | --- | --- | --- | --- |
-| [Bash](https://www.gnu.org/software/bash/) | `bash` | :white_check_mark: | | :white_check_mark: |
-| [Command Prompt](https://en.wikipedia.org/wiki/Cmd.exe) | `cmd` | | :white_check_mark: | |
-| [C shell](https://en.wikipedia.org/wiki/C_shell) | `csh` | :white_check_mark: | | :white_check_mark: |
-| [fish](https://github.com/fish-shell/fish-shell) | `fish` | :white_check_mark: | | :white_check_mark: |
-| [Nushell](https://github.com/nushell/nushell) | `nu` | :white_check_mark: | :white_check_mark: | :white_check_mark: |
-| [PowerShell](https://github.com/PowerShell/PowerShell) | `powershell` | :white_check_mark: | :white_check_mark: | :white_check_mark: |
-| [tcsh](https://en.wikipedia.org/wiki/Tcsh) | `tcsh` | :white_check_mark: | | :white_check_mark: |
-| [xonsh](https://github.com/xonsh/xonsh) | `xonsh` | :white_check_mark: | :white_check_mark: | :white_check_mark: |
-| [Z shell](https://en.wikipedia.org/wiki/Z_shell) | `zsh` | :white_check_mark: | | :white_check_mark: |
+| Shell | Name | Arguments | macOS | Windows | Unix |
+| --- | --- | --- | --- | --- | --- |
+| [Bash](https://www.gnu.org/software/bash/) | `bash` | `["-i"]` | :white_check_mark: | | :white_check_mark: |
+| [Command Prompt](https://en.wikipedia.org/wiki/Cmd.exe) | `cmd` | | | :white_check_mark: | |
+| [C shell](https://en.wikipedia.org/wiki/C_shell) | `csh` | `["-i"]` | :white_check_mark: | | :white_check_mark: |
+| [fish](https://github.com/fish-shell/fish-shell) | `fish` | `["-i"]` | :white_check_mark: | | :white_check_mark: |
+| [Nushell](https://github.com/nushell/nushell) | `nu` | `[]` | :white_check_mark: | :white_check_mark: | :white_check_mark: |
+| [PowerShell](https://github.com/PowerShell/PowerShell) | `powershell` | | :white_check_mark: | :white_check_mark: | :white_check_mark: |
+| [tcsh](https://en.wikipedia.org/wiki/Tcsh) | `tcsh` | `["-i"]` | :white_check_mark: | | :white_check_mark: |
+| [xonsh](https://github.com/xonsh/xonsh) | `xonsh` | | :white_check_mark: | :white_check_mark: | :white_check_mark: |
+| [Z shell](https://en.wikipedia.org/wiki/Z_shell) | `zsh` | `["-i"]` | :white_check_mark: | | :white_check_mark: |
 
 ### Default
 
diff --git a/docs/history.md b/docs/history.md
index 54bf36ee..ef902a3c 100644
--- a/docs/history.md
+++ b/docs/history.md
@@ -10,10 +10,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 
 ### Unreleased
 
+***Changed:***
+
+- The `enter_shell` environment plugin method now accepts an additional `args` parameter
+
 ***Added:***
 
 - Allow context string formatting for environment dependencies
 - Add environment context string formatting fields `env_name`, `env_type`, `matrix`, `verbosity`, and `args`
+- Support overriding the default arguments used to spawn shells on non-Windows systems
 
 ***Fixed:***
 
diff --git a/hatch.toml b/hatch.toml
index d2e70f97..64a57b63 100644
--- a/hatch.toml
+++ b/hatch.toml
@@ -12,7 +12,7 @@ post-install-commands = [
   "python -m pip install --disable-pip-version-check {verbosity:flag:-1} -e ./backend",
 ]
 [envs.default.scripts]
-full = "pytest -n auto --reruns 5 --reruns-delay 1 -r aR --cov-report=term-missing --cov-config=pyproject.toml --cov=src/hatch --cov=backend/src/hatchling --cov=tests {args:tests}"
+full = "pytest -n auto --reruns 5 --reruns-delay 3 -r aR --cov-report=term-missing --cov-config=pyproject.toml --cov=src/hatch --cov=backend/src/hatchling --cov=tests {args:tests}"
 dev = "pytest -p no:randomly --no-cov {args:tests}"
 
 [envs.test.overrides]
diff --git a/src/hatch/cli/shell/__init__.py b/src/hatch/cli/shell/__init__.py
index 613bb7e0..c6d4d85b 100644
--- a/src/hatch/cli/shell/__init__.py
+++ b/src/hatch/cli/shell/__init__.py
@@ -4,8 +4,9 @@ import click
 @click.command(short_help="Enter a shell within a project's environment")
 @click.argument('shell_name', required=False)
 @click.argument('shell_path', required=False)
+@click.argument('shell_args', required=False, nargs=-1)
 @click.pass_obj
-def shell(app, shell_name, shell_path):  # no cov
+def shell(app, shell_name, shell_path, shell_args):  # no cov
     """Enter a shell within a project's environment."""
     if app.env == app.env_active:
         app.abort(f'Already in environment: {app.env}')
@@ -21,6 +22,8 @@ def shell(app, shell_name, shell_path):  # no cov
         shell_name = app.config.shell.name
     if not shell_path:
         shell_path = app.config.shell.path
+    if not shell_args:
+        shell_args = app.config.shell.args
 
     if not shell_path:
         from ...utils.fs import Path
@@ -32,4 +35,4 @@ def shell(app, shell_name, shell_path):  # no cov
         environment = app.get_environment()
         app.prepare_environment(environment)
 
-        environment.enter_shell(shell_name, shell_path)
+        environment.enter_shell(shell_name, shell_path, shell_args)
diff --git a/src/hatch/config/model.py b/src/hatch/config/model.py
index 1964b7ce..76576236 100644
--- a/src/hatch/config/model.py
+++ b/src/hatch/config/model.py
@@ -239,6 +239,7 @@ class ShellConfig(LazilyParsedConfig):
 
         self._field_name = FIELD_TO_PARSE
         self._field_path = FIELD_TO_PARSE
+        self._field_args = FIELD_TO_PARSE
 
     @property
     def name(self):
@@ -278,6 +279,29 @@ class ShellConfig(LazilyParsedConfig):
         self.raw_data['path'] = value
         self._field_path = FIELD_TO_PARSE
 
+    @property
+    def args(self):
+        if self._field_args is FIELD_TO_PARSE:
+            if 'args' in self.raw_data:
+                args = self.raw_data['args']
+                if not isinstance(args, list):
+                    self.raise_error('must be an array')
+
+                for i, entry in enumerate(args, 1):
+                    if not isinstance(entry, str):
+                        self.raise_error('must be a string', extra_steps=(str(i),))
+
+                self._field_args = args
+            else:
+                self._field_args = self.raw_data['args'] = []
+
+        return self._field_args
+
+    @args.setter
+    def args(self, value):
+        self.raw_data['args'] = value
+        self._field_args = FIELD_TO_PARSE
+
 
 class DirsConfig(LazilyParsedConfig):
     def __init__(self, *args, **kwargs):
diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py
index 63fd1b30..5e99c3c9 100644
--- a/src/hatch/env/plugin/interface.py
+++ b/src/hatch/env/plugin/interface.py
@@ -666,7 +666,7 @@ class EnvironmentInterface(ABC):
         """
         return self.platform.capture_process(self.construct_build_command(**kwargs))
 
-    def enter_shell(self, name, path):
+    def enter_shell(self, name, path, args):
         """
         Spawn a [shell](../config/hatch.md#shell) within the environment.
 
@@ -675,7 +675,7 @@ class EnvironmentInterface(ABC):
         directly or provide the same guarantee.
         """
         with self.command_context():
-            self.platform.exit_with_command([path])
+            self.platform.exit_with_command([path, *args])
 
     def run_shell_command(self, command: str):
         """
diff --git a/src/hatch/env/virtual.py b/src/hatch/env/virtual.py
index bdeb39bd..490c825e 100644
--- a/src/hatch/env/virtual.py
+++ b/src/hatch/env/virtual.py
@@ -96,15 +96,15 @@ class VirtualEnvironment(EnvironmentInterface):
         with self.safe_activation():
             yield
 
-    def enter_shell(self, name, path):
+    def enter_shell(self, name, path, args):
         shell_executor = getattr(self.shells, f'enter_{name}', None)
         if shell_executor is None:
             # Manually activate in lieu of an activation script
             with self.safe_activation():
-                self.platform.exit_with_command([path])
+                self.platform.exit_with_command([path, *args])
         else:
             with self.get_env_vars():
-                shell_executor(path, self.virtual_env.executables_directory)
+                shell_executor(path, args, self.virtual_env.executables_directory)
 
     def check_compatibility(self):
         super().check_compatibility()
diff --git a/src/hatch/utils/shells.py b/src/hatch/utils/shells.py
index 6d117ef7..d419b50d 100644
--- a/src/hatch/utils/shells.py
+++ b/src/hatch/utils/shells.py
@@ -2,13 +2,13 @@ class ShellManager:
     def __init__(self, environment):
         self.environment = environment
 
-    def enter_cmd(self, shell_path, exe_dir):
-        self.environment.platform.exit_with_command([shell_path or 'cmd', '/k', str(exe_dir / 'activate.bat')])
+    def enter_cmd(self, path, args, exe_dir):
+        self.environment.platform.exit_with_command([path or 'cmd', '/k', str(exe_dir / 'activate.bat')])
 
-    def enter_powershell(self, shell_path, exe_dir):
+    def enter_powershell(self, path, args, exe_dir):
         self.environment.platform.exit_with_command(
             [
-                shell_path or 'powershell',
+                path or 'powershell',
                 '-executionpolicy',
                 'bypass',
                 '-NoExit',
@@ -18,53 +18,53 @@ class ShellManager:
             ]
         )
 
-    def enter_xonsh(self, shell_path, exe_dir):
+    def enter_xonsh(self, path, args, exe_dir):
         if self.environment.platform.windows:
             with self.environment:
                 self.environment.platform.exit_with_command(
-                    [shell_path or 'xonsh', '-i', '-D', f'VIRTUAL_ENV={exe_dir.parent.name}']
+                    [path or 'xonsh', '-i', '-D', f'VIRTUAL_ENV={exe_dir.parent.name}']
                 )
         else:
             self.spawn_linux_shell(
-                shell_path or 'xonsh',
+                path or 'xonsh',
                 ['-i', '-D', f'VIRTUAL_ENV={exe_dir.parent.name}'],
                 # Just in case pyenv works with xonsh, supersede it.
                 callback=lambda terminal: terminal.sendline(f'$PATH.insert(0, {str(exe_dir)!r})'),
             )
 
-    def enter_bash(self, shell_path, exe_dir):
-        self.spawn_linux_shell(shell_path or 'bash', ['-i'], script=exe_dir / 'activate')
+    def enter_bash(self, path, args, exe_dir):
+        self.spawn_linux_shell(path or 'bash', args or ['-i'], script=exe_dir / 'activate')
 
-    def enter_fish(self, shell_path, exe_dir):
-        self.spawn_linux_shell(shell_path or 'fish', ['-i'], script=exe_dir / 'activate.fish')
+    def enter_fish(self, path, args, exe_dir):
+        self.spawn_linux_shell(path or 'fish', args or ['-i'], script=exe_dir / 'activate.fish')
 
-    def enter_zsh(self, shell_path, exe_dir):
-        self.spawn_linux_shell(shell_path or 'zsh', ['-i'], script=exe_dir / 'activate')
+    def enter_zsh(self, path, args, exe_dir):
+        self.spawn_linux_shell(path or 'zsh', args or ['-i'], script=exe_dir / 'activate')
 
-    def enter_nu(self, shell_path, exe_dir):
-        executable = shell_path or 'nu'
+    def enter_nu(self, path, args, exe_dir):
+        executable = path or 'nu'
         activation_script = exe_dir / 'activate.nu'
         if self.environment.platform.windows:
             self.environment.platform.exit_with_command(
                 [executable, '-c', f'source "{activation_script}"; "{executable}"']
             )
         else:
-            self.spawn_linux_shell(executable, None, script=activation_script)
+            self.spawn_linux_shell(executable, args or None, script=activation_script)
 
-    def enter_tcsh(self, shell_path, exe_dir):
-        self.spawn_linux_shell(shell_path or 'tcsh', ['-i'], script=exe_dir / 'activate.csh')
+    def enter_tcsh(self, path, args, exe_dir):
+        self.spawn_linux_shell(path or 'tcsh', args or ['-i'], script=exe_dir / 'activate.csh')
 
-    def enter_csh(self, shell_path, exe_dir):
-        self.spawn_linux_shell(shell_path or 'csh', ['-i'], script=exe_dir / 'activate.csh')
+    def enter_csh(self, path, args, exe_dir):
+        self.spawn_linux_shell(path or 'csh', args or ['-i'], script=exe_dir / 'activate.csh')
 
-    def spawn_linux_shell(self, shell_path, args, *, script=None, callback=None):
+    def spawn_linux_shell(self, path, args, *, script=None, callback=None):
         import shutil
         import signal
 
         import pexpect
 
         columns, lines = shutil.get_terminal_size()
-        terminal = pexpect.spawn(shell_path, args=args, dimensions=(lines, columns))
+        terminal = pexpect.spawn(path, args=args, dimensions=(lines, columns))
 
         def sigwinch_passthrough(sig, data):
             terminal.setwinsize(lines, columns)


Test patch:
diff --git a/tests/config/test_model.py b/tests/config/test_model.py
index 21b35dd5..6ba877fa 100644
--- a/tests/config/test_model.py
+++ b/tests/config/test_model.py
@@ -152,6 +152,7 @@ class TestShell:
 
         assert config.shell.name == config.shell.name == ''
         assert config.shell.path == config.shell.path == ''
+        assert config.shell.args == config.shell.args == []
         assert config.raw_data == {'shell': ''}
 
     def test_invalid_type(self, helpers):
@@ -173,6 +174,7 @@ class TestShell:
 
         assert config.shell.name == 'foo'
         assert config.shell.path == 'foo'
+        assert config.shell.args == []
         assert config.raw_data == {'shell': 'foo'}
 
     def test_table(self):
@@ -180,14 +182,24 @@ class TestShell:
 
         assert config.shell.name == 'foo'
         assert config.shell.path == 'foo'
-        assert config.raw_data == {'shell': {'name': 'foo', 'path': 'foo'}}
+        assert config.shell.args == []
+        assert config.raw_data == {'shell': {'name': 'foo', 'path': 'foo', 'args': []}}
 
     def test_table_with_path(self):
         config = RootConfig({'shell': {'name': 'foo', 'path': 'bar'}})
 
         assert config.shell.name == 'foo'
         assert config.shell.path == 'bar'
-        assert config.raw_data == {'shell': {'name': 'foo', 'path': 'bar'}}
+        assert config.shell.args == []
+        assert config.raw_data == {'shell': {'name': 'foo', 'path': 'bar', 'args': []}}
+
+    def test_table_with_path_and_args(self):
+        config = RootConfig({'shell': {'name': 'foo', 'path': 'bar', 'args': ['baz']}})
+
+        assert config.shell.name == 'foo'
+        assert config.shell.path == 'bar'
+        assert config.shell.args == ['baz']
+        assert config.raw_data == {'shell': {'name': 'foo', 'path': 'bar', 'args': ['baz']}}
 
     def test_table_no_name(self, helpers):
         config = RootConfig({'shell': {}})
@@ -231,6 +243,34 @@ class TestShell:
         ):
             _ = config.shell.path
 
+    def test_table_args_not_array(self, helpers):
+        config = RootConfig({'shell': {'args': 9000}})
+
+        with pytest.raises(
+            ConfigurationError,
+            match=helpers.dedent(
+                """
+                Error parsing config:
+                shell -> args
+                  must be an array"""
+            ),
+        ):
+            _ = config.shell.args
+
+    def test_table_args_entry_not_string(self, helpers):
+        config = RootConfig({'shell': {'args': [9000]}})
+
+        with pytest.raises(
+            ConfigurationError,
+            match=helpers.dedent(
+                """
+                Error parsing config:
+                shell -> args -> 1
+                  must be a string"""
+            ),
+        ):
+            _ = config.shell.args
+
     def test_set_lazy_error(self, helpers):
         config = RootConfig({})
 
@@ -282,6 +322,23 @@ class TestShell:
         ):
             _ = config.shell.path
 
+    def test_table_args_set_lazy_error(self, helpers):
+        config = RootConfig({'shell': {'name': 'foo'}})
+
+        config.shell.args = 9000
+        assert config.raw_data == {'shell': {'name': 'foo', 'args': 9000}}
+
+        with pytest.raises(
+            ConfigurationError,
+            match=helpers.dedent(
+                """
+                Error parsing config:
+                shell -> args
+                  must be an array"""
+            ),
+        ):
+            _ = config.shell.args
+
 
 class TestDirs:
     def test_default(self, default_cache_dir, default_data_dir):