Repository: pre-commit/pre-commit

Issue:
faster check for docker running
currently this is how pre-commit checks if docker is available:

https://github.com/pre-commit/pre-commit/blob/9a5461db24beeee9960423b853cfbfbaefb6cc7a/pre_commit/languages/docker.py#L29-L41

there's two low-hanging-fruit problems with this:

1. it is checked repeatedly, but can probably be checked just once per process (can use [`functools.lru_cache`](https://docs.python.org/3/library/functools.html#functools.lru_cache) as a "run once" decorator)
2. `docker ps` is pretty slow, there's probably a faster way to see if docker is available -- on my machine it takes _more than a second_! even without containers
   ```console
   $ time docker ps
   CONTAINER ID  IMAGE   COMMAND  CREATED  STATUS  PORTS   NAMES

   real	0m1.169s
   user	0m0.088s
   sys	0m0.085s
   ```


Discussion:
Just taking a look at this - `docker ps` is super quick on my machine though so I'm not sure I can make a fair comparison. `docker info` is an option, but that takes 10x longer than `ps` for me at least. Other alternatives could be something like `pgrep dockerd`, which is definitely quicker. That obviously wouldn't work on Windows though.

I can PR the `lru_cache` for now at least though?
sure! feel free to do just part 1 if part 2 is more difficult / nuanced
PR for the lru_cache open!

Here's some data for the two native docker options:

<details>

```bash
$ TIMEFORMAT=%R
$ for i in $(seq 1 10); do time /usr/bin/docker ps &> /dev/null; done
0.040
0.039
0.040
0.034
0.035
0.037
0.041
0.038
0.035
0.042
$ for i in $(seq 1 10); do time /usr/bin/docker info &> /dev/null; done
0.086
0.077
0.078
0.087
0.088
0.080
0.093
0.080
0.082
0.096
$ for i in $(seq 1 10); do time pgrep dockerd &> /dev/null; done
0.009
0.005
0.005
0.005
0.005
0.005
0.005
0.005
0.005
0.006
```
</details>

So (for me) docker info looks to be around twice as slow overall. Do you think the `pgrep` option is viable? We could use that but fall back to `docker ps` for Windows?


hummm

actually on second though, maybe we just delete `assert_docker_running` entirely -- the messaging is probably fine without it?
Up to you! 😄

The message looks fine without the current check from a user point of view imo. We may want to keep it around in the test code for the `skipif_cant_run_docker` hook though?
yeah let's move it to `testing.util` then

Resolving patch:
diff --git a/pre_commit/languages/docker.py b/pre_commit/languages/docker.py
--- a/pre_commit/languages/docker.py
+++ b/pre_commit/languages/docker.py
@@ -7,9 +7,7 @@
 from pre_commit.hook import Hook
 from pre_commit.languages import helpers
 from pre_commit.prefix import Prefix
-from pre_commit.util import CalledProcessError
 from pre_commit.util import clean_path_on_failure
-from pre_commit.util import cmd_output_b
 
 ENVIRONMENT_DIR = 'docker'
 PRE_COMMIT_LABEL = 'PRE_COMMIT'
@@ -26,21 +24,6 @@ def docker_tag(prefix: Prefix) -> str:  # pragma: win32 no cover
     return f'pre-commit-{md5sum}'
 
 
-def docker_is_running() -> bool:  # pragma: win32 no cover
-    try:
-        cmd_output_b('docker', 'ps')
-    except CalledProcessError:
-        return False
-    else:
-        return True
-
-
-def assert_docker_available() -> None:  # pragma: win32 no cover
-    assert docker_is_running(), (
-        'Docker is either not running or not configured in this environment'
-    )
-
-
 def build_docker_image(
         prefix: Prefix,
         *,
@@ -63,7 +46,6 @@ def install_environment(
 ) -> None:  # pragma: win32 no cover
     helpers.assert_version_default('docker', version)
     helpers.assert_no_additional_deps('docker', additional_dependencies)
-    assert_docker_available()
 
     directory = prefix.path(
         helpers.environment_dir(ENVIRONMENT_DIR, C.DEFAULT),
@@ -101,7 +83,6 @@ def run_hook(
         file_args: Sequence[str],
         color: bool,
 ) -> Tuple[int, bytes]:  # pragma: win32 no cover
-    assert_docker_available()
     # Rebuild the docker image in case it has gone missing, as many people do
     # automated cleanup of docker images.
     build_docker_image(hook.prefix, pull=False)
diff --git a/pre_commit/languages/docker_image.py b/pre_commit/languages/docker_image.py
--- a/pre_commit/languages/docker_image.py
+++ b/pre_commit/languages/docker_image.py
@@ -3,7 +3,6 @@
 
 from pre_commit.hook import Hook
 from pre_commit.languages import helpers
-from pre_commit.languages.docker import assert_docker_available
 from pre_commit.languages.docker import docker_cmd
 
 ENVIRONMENT_DIR = None
@@ -17,6 +16,5 @@ def run_hook(
         file_args: Sequence[str],
         color: bool,
 ) -> Tuple[int, bytes]:  # pragma: win32 no cover
-    assert_docker_available()
     cmd = docker_cmd() + hook.cmd
     return helpers.run_xargs(hook, cmd, file_args, color=color)


Test patch:
diff --git a/testing/util.py b/testing/util.py
--- a/testing/util.py
+++ b/testing/util.py
@@ -5,14 +5,24 @@
 import pytest
 
 from pre_commit import parse_shebang
-from pre_commit.languages.docker import docker_is_running
+from pre_commit.util import CalledProcessError
 from pre_commit.util import cmd_output
+from pre_commit.util import cmd_output_b
 from testing.auto_namedtuple import auto_namedtuple
 
 
 TESTING_DIR = os.path.abspath(os.path.dirname(__file__))
 
 
+def docker_is_running() -> bool:  # pragma: win32 no cover
+    try:
+        cmd_output_b('docker', 'ps')
+    except CalledProcessError:  # pragma: no cover
+        return False
+    else:
+        return True
+
+
 def get_resource_path(path):
     return os.path.join(TESTING_DIR, 'resources', path)
 
diff --git a/tests/languages/docker_test.py b/tests/languages/docker_test.py
--- a/tests/languages/docker_test.py
+++ b/tests/languages/docker_test.py
@@ -1,15 +1,6 @@
 from unittest import mock
 
 from pre_commit.languages import docker
-from pre_commit.util import CalledProcessError
-
-
-def test_docker_is_running_process_error():
-    with mock.patch(
-        'pre_commit.languages.docker.cmd_output_b',
-        side_effect=CalledProcessError(1, (), 0, b'', None),
-    ):
-        assert docker.docker_is_running() is False
 
 
 def test_docker_fallback_user():