Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ autohooks:
* `pythonpath` for dependency management via [pip]
* `poetry` for dependency management via [poetry] (recommended)
* `pipenv` for dependency management via [pipenv]
* `uv` for dependency management via [uv]

These modes handle how autohooks, the plugins and their dependencies are loaded
during git hook execution.
Expand Down Expand Up @@ -165,3 +166,4 @@ Licensed under the [GNU General Public License v3.0 or later](LICENSE).
[poetry]: https://python-poetry.org/
[pylint]: https://pylint.readthedocs.io/en/latest/
[ruff]: https://docs.astral.sh/ruff/
[uv]: https://docs.astral.sh/uv/
3 changes: 2 additions & 1 deletion autohooks/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,10 @@ def main():
str(Mode.PYTHONPATH),
str(Mode.PIPENV),
str(Mode.POETRY),
str(Mode.UV),
],
help="Mode for loading autohooks during hook execution. Either load "
"autohooks from the PYTHON_PATH, via pipenv or via poetry.",
"autohooks from the PYTHON_PATH, via pipenv, via poetry or via uv.",
)
activate_parser.set_defaults(func=install_hooks)

Expand Down
6 changes: 5 additions & 1 deletion autohooks/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,14 @@ def _gather_mode(mode_string: Optional[str]) -> Mode:
Gather the mode from a mode string
"""
mode = Mode.from_string(mode_string)
is_virtual_env = mode == Mode.PIPENV or mode == Mode.POETRY
is_virtual_env = (
mode == Mode.PIPENV or mode == Mode.POETRY or mode == Mode.UV
)
if is_virtual_env and not is_split_env():
if mode == Mode.POETRY:
mode = Mode.POETRY_MULTILINE
elif mode == Mode.UV:
mode = Mode.UV_MULTILINE
else:
mode = Mode.PIPENV_MULTILINE
return mode
Expand Down
7 changes: 7 additions & 0 deletions autohooks/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
POETRY_SHEBANG,
PYTHON3_SHEBANG,
TEMPLATE_VERSION,
UV_MULTILINE_SHEBANG,
UV_SHEBANG,
PreCommitTemplate,
)
from autohooks.utils import get_git_hook_directory_path
Expand Down Expand Up @@ -65,6 +67,8 @@ def read_mode(self) -> Mode:
return Mode.POETRY
if shebang == PIPENV_SHEBANG:
return Mode.PIPENV
if shebang == UV_SHEBANG:
return Mode.UV

shebang = f"{lines[0][2:]}\n"
shebang += "\n".join(lines[1:5])
Expand All @@ -74,6 +78,9 @@ def read_mode(self) -> Mode:
if shebang == PIPENV_MULTILINE_SHEBANG:
return Mode.PIPENV_MULTILINE

if shebang == UV_MULTILINE_SHEBANG:
return Mode.UV_MULTILINE

return Mode.UNKNOWN

def read_version(self) -> int:
Expand Down
10 changes: 8 additions & 2 deletions autohooks/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ class Mode(Enum):
PIPENV = 1
PYTHONPATH = 2
POETRY = 3
PIPENV_MULTILINE = 4
POETRY_MULTILINE = 5
UV = 4
PIPENV_MULTILINE = 5
POETRY_MULTILINE = 6
UV_MULTILINE = 7
UNDEFINED = -1
UNKNOWN = -2

Expand All @@ -29,6 +31,10 @@ def get_effective_mode(self):
return Mode.POETRY
if self.value == Mode.POETRY_MULTILINE.value:
return Mode.POETRY_MULTILINE
if self.value == Mode.UV.value:
return Mode.UV
if self.value == Mode.UV_MULTILINE.value:
return Mode.UV_MULTILINE
return Mode.PYTHONPATH

@staticmethod
Expand Down
12 changes: 12 additions & 0 deletions autohooks/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
PYTHON3_SHEBANG = "/usr/bin/env python3"
PIPENV_SHEBANG = "/usr/bin/env -S pipenv run python3"
POETRY_SHEBANG = "/usr/bin/env -S poetry run python"
UV_SHEBANG = "/usr/bin/env -S uv run python"
# For OS's that don't support '/usr/bin/env -S'.
PIPENV_MULTILINE_SHEBANG = (
"/bin/sh\n"
Expand All @@ -28,6 +29,13 @@
'exit "$?"\n'
"'''"
)
UV_MULTILINE_SHEBANG = (
"/bin/sh\n"
"\"true\" ''':'\n"
'uv run python "$0" "$@"\n'
'exit "$?"\n'
"'''"
)

TEMPLATE_VERSION = 1

Expand Down Expand Up @@ -55,10 +63,14 @@ def render(self, *, mode: Mode) -> str:
params["SHEBANG"] = PIPENV_SHEBANG
elif mode == Mode.POETRY:
params["SHEBANG"] = POETRY_SHEBANG
elif mode == Mode.UV:
params["SHEBANG"] = UV_SHEBANG
elif mode == Mode.PIPENV_MULTILINE:
params["SHEBANG"] = PIPENV_MULTILINE_SHEBANG
elif mode == Mode.POETRY_MULTILINE:
params["SHEBANG"] = POETRY_MULTILINE_SHEBANG
elif mode == Mode.UV_MULTILINE:
params["SHEBANG"] = UV_MULTILINE_SHEBANG
else:
params["SHEBANG"] = PYTHON3_SHEBANG

Expand Down
21 changes: 20 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ and to set python modules to be run as [autohooks plugins](./plugins).
## Mode Configuration

The mode can be set by adding a `mode =` line to the *pyproject.toml* file.
Current possible options are `"pythonpath"`, `"pipenv"` and `"poetry"` (see
Current possible options are `"pythonpath"`, `"pipenv"`, `"poetry"` and `"uv"`(see
[autohooks mode](./modes)). If the mode setting is missing, the `pythonpath`
mode is used.

Expand Down Expand Up @@ -40,6 +40,13 @@ mode = "pythonpath"
```
````

````{group-tab} uv
```toml
[tool.autohooks]
mode = "uv"
```
````

`````

Calling `autohooks activate` also allows for overriding the [mode](./modes)
Expand All @@ -66,6 +73,12 @@ autohooks activate --mode pythonpath --force
```
````

````{group-tab} uv
```bash
autohooks activate --mode uv --force
```
````

`````

Please keep in mind that autohooks will always issue a warning if the mode used
Expand Down Expand Up @@ -110,4 +123,10 @@ autohooks plugins add autohooks.plugins.pylint
```
````

````{group-tab} uv
```bash
uv run autohooks plugins add autohooks.plugins.pylint
```
````

`````
23 changes: 23 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ For fulfilling these steps different tooling can be used. Most prominent and cur
* [poetry]
* [pipenv]
* [pip]
* [uv]

```{note}
Using [poetry] is recommended for installing and maintaining autohooks.
Expand Down Expand Up @@ -56,6 +57,14 @@ To install autohooks globally for the current user run
python3 -m pip install --user autohooks
```
`````

`````{group-tab} uv
To install autohooks as a development dependency run

```{code-block}
uv add --dev autohooks
```
`````
``````

## 2. Activating the Git Hook
Expand All @@ -82,6 +91,12 @@ pipenv run autohooks activate --mode pipenv
autohooks activate --mode pythonpath
```
````

````{group-tab} uv
```bash
uv run autohooks activate --mode uv
```
````
`````

## 3. Installing and Configuring the Plugins to Be Run
Expand Down Expand Up @@ -111,7 +126,15 @@ python3 -m pip install --user autohooks-plugin-pylint
autohooks plugins add autohooks.plugins.pylint
```
````

````{group-tab} uv
```bash
uv add --dev autohooks-plugin-pylint
uv run autohooks plugins add autohooks.plugins.pylint
```
````
`````
[pipenv]: https://pipenv.readthedocs.io/en/latest/
[poetry]: https://python-poetry.org/
[pip]: https://pip.pypa.io/en/stable/
[uv]: https://docs.astral.sh/uv/
18 changes: 14 additions & 4 deletions docs/modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Currently three modes for using autohooks are supported:
* `pythonpath`
* `poetry`
* `pipenv`
* `uv`

These modes handle how autohooks, the plugins and their dependencies are loaded
during git hook execution.
Expand All @@ -14,13 +15,13 @@ and no mode is set during [activation](./installation.md), autohooks
will use the [pythonpath mode](#pythonpath-mode) by default.

```{note}
`poetry` or `pipenv` modes leverage the `/usr/bin/env` command using the
`poetry`, `pipenv` or `uv` modes leverage the `/usr/bin/env` command using the
`--split-string` (`-S`) option. If `autohooks` detects that it is
running on an OS where `/usr/bin/env` is yet to support _split_strings_
(notably ubuntu < 19.x), `autohooks` will automatically change to an
internally chosen `poetry_multiline`/`pipenv_mutliline` mode. The
internally chosen `poetry_multiline`/`pipenv_mutliline`/`uv_multiline` mode. The
'multiline' modes *should not* be user-configured options; setting your
project to use `poetry` or `pipenv`allows team members the greatest
project to use `poetry`, `pipenv` or `uv` allows team members the greatest
latitude to use an OS of their choice yet leverage the sane
`/usr/bin/env --split-string` if possible. Though `poetry_multiline`
would generally work for all, it is very confusing sorcery.
Expand All @@ -44,7 +45,7 @@ this mode with a virtual environment is that [activating the environment](https:
has to be done manually.

To benefit from the advantages of a virtual environment a much better choice is
to use [poetry] or [pipenv] for managing the virtual environment automatically.
to use [poetry], [pipenv] or [uv] for managing the virtual environment automatically.

## Poetry Mode

Expand All @@ -67,5 +68,14 @@ installation is deterministic and reliable between different developer setups.
In contrast to the `pythonpath` mode the activation of the virtual environment
provided by [pipenv] is done automatically in the background.

## uv Mode

With the `uv` mode it is possible to run autohooks in a
dedicated environment controlled by [uv]. By using the `uv` mode the
virtual environment will be activated automatically in the background when
executing the autohooks based git commit hook. All dependencies are managed
by uv using the `pyproject.toml` and `uv.lock` files.

[pipenv]: https://pipenv.readthedocs.io/en/latest/
[poetry]: https://python-poetry.org/
[uv]: https://docs.astral.sh/uv/
20 changes: 20 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,26 @@ def test_get_mode_poetry_multiline(self):

self.assertEqual(len(config.get_pre_commit_script_names()), 0)

def test_get_mode_uv_multiline(self):
config = AutohooksConfig.from_dict(
{"tool": {"autohooks": {"mode": "uv_multiline"}}}
)

self.assertTrue(config.has_autohooks_config())
self.assertEqual(config.get_mode(), Mode.UV_MULTILINE)

self.assertEqual(len(config.get_pre_commit_script_names()), 0)

def test_get_mode_uv(self):
config = AutohooksConfig.from_dict(
{"tool": {"autohooks": {"mode": "uv"}}}
)

self.assertTrue(config.has_autohooks_config())
self.assertEqual(config.get_mode(), Mode.UV)

self.assertEqual(len(config.get_pre_commit_script_names()), 0)

def test_get_mode_pythonpath(self):
config = AutohooksConfig.from_dict(
{"tool": {"autohooks": {"mode": "pythonpath"}}}
Expand Down
26 changes: 26 additions & 0 deletions tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
POETRY_SHEBANG,
PYTHON3_SHEBANG,
TEMPLATE_VERSION,
UV_MULTILINE_SHEBANG,
UV_SHEBANG,
PreCommitTemplate,
)
from autohooks.utils import exec_git
Expand Down Expand Up @@ -161,6 +163,12 @@ def test_poetry_mode(self):

self.assertEqual(pre_commit_hook.read_mode(), Mode.POETRY)

def test_uv_mode(self):
path = FakeReadPath(f"#!{UV_SHEBANG}")
pre_commit_hook = PreCommitHook(path)

self.assertEqual(pre_commit_hook.read_mode(), Mode.UV)

def test_poetry_mode_with_python3(self):
path = FakeReadPath(f"#!{POETRY_SHEBANG}3")
pre_commit_hook = PreCommitHook(path)
Expand All @@ -179,6 +187,12 @@ def test_poetry_multiline_mode(self):

self.assertEqual(pre_commit_hook.read_mode(), Mode.POETRY_MULTILINE)

def test_uv_multiline_mode(self):
path = FakeReadPath(f"#!{UV_MULTILINE_SHEBANG}")
pre_commit_hook = PreCommitHook(path)

self.assertEqual(pre_commit_hook.read_mode(), Mode.UV_MULTILINE)

def test_pythonpath_mode(self):
path = FakeReadPath(f"#!{PYTHON3_SHEBANG}")
pre_commit_hook = PreCommitHook(path)
Expand Down Expand Up @@ -211,6 +225,18 @@ def test_poetry_mode(self):
text = args[0]
self.assertRegex(text, f"^#!{POETRY_SHEBANG} *")

def test_uv_mode(self):
write_path = Mock()
pre_commit_hook = PreCommitHook(write_path)
pre_commit_hook.write(mode=Mode.UV)

write_path.chmod.assert_called_with(0o775)
self.assertTrue(write_path.write_text.called)

args, _kwargs = write_path.write_text.call_args
text = args[0]
self.assertRegex(text, f"^#!{UV_SHEBANG} *")

def test_pythonpath_mode(self):
write_path = Mock()
pre_commit_hook = PreCommitHook(write_path)
Expand Down
12 changes: 12 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ def test_get_effective_mode(self):
self.assertEqual(
Mode.POETRY_MULTILINE.get_effective_mode(), Mode.POETRY_MULTILINE
)
self.assertEqual(Mode.UV.get_effective_mode(), Mode.UV)
self.assertEqual(
Mode.UV_MULTILINE.get_effective_mode(), Mode.UV_MULTILINE
)
self.assertEqual(Mode.UNDEFINED.get_effective_mode(), Mode.PYTHONPATH)
self.assertEqual(Mode.UNKNOWN.get_effective_mode(), Mode.PYTHONPATH)

Expand Down Expand Up @@ -53,6 +57,14 @@ def test_get_poetry_multiline_mode_from_string(self):
Mode.from_string("POETRY_MULTILINE"), Mode.POETRY_MULTILINE
)

def test_get_uv_mode_from_string(self):
self.assertEqual(Mode.from_string("uv"), Mode.UV)
self.assertEqual(Mode.from_string("UV"), Mode.UV)

def test_get_uv_multiline_mode_from_string(self):
self.assertEqual(Mode.from_string("uv_multiline"), Mode.UV_MULTILINE)
self.assertEqual(Mode.from_string("UV_MULTILINE"), Mode.UV_MULTILINE)

def test_get_invalid_mode_from_string(self):
self.assertEqual(Mode.from_string("foo"), Mode.UNKNOWN)
self.assertEqual(Mode.from_string(None), Mode.UNDEFINED)
Expand Down
Loading
Loading