1
1
# Copyright (c) Jupyter Development Team.
2
2
# Distributed under the terms of the Modified BSD License.
3
- import logging
4
3
import os
5
- import socket
6
- from contextlib import closing
7
- from typing import Any
4
+ from collections .abc import Generator
8
5
9
6
import docker
10
7
import pytest # type: ignore
11
8
import requests
12
- from docker .models .containers import Container
13
9
from requests .adapters import HTTPAdapter
14
10
from urllib3 .util .retry import Retry
15
11
16
- LOGGER = logging .getLogger (__name__ )
17
-
18
-
19
- def find_free_port () -> str :
20
- """Returns the available host port. Can be called in multiple threads/processes."""
21
- with closing (socket .socket (socket .AF_INET , socket .SOCK_STREAM )) as s :
22
- s .bind (("" , 0 ))
23
- s .setsockopt (socket .SOL_SOCKET , socket .SO_REUSEADDR , 1 )
24
- return s .getsockname ()[1 ] # type: ignore
25
-
26
-
27
- def get_health (container : Container ) -> str :
28
- api_client = docker .APIClient ()
29
- inspect_results = api_client .inspect_container (container .name )
30
- return inspect_results ["State" ]["Health" ]["Status" ] # type: ignore
12
+ from tests .utils .tracked_container import TrackedContainer
31
13
32
14
33
15
@pytest .fixture (scope = "session" )
@@ -52,95 +34,10 @@ def image_name() -> str:
52
34
return os .environ ["TEST_IMAGE" ]
53
35
54
36
55
- class TrackedContainer :
56
- """Wrapper that collects docker container configuration and delays
57
- container creation/execution.
58
-
59
- Parameters
60
- ----------
61
- docker_client: docker.DockerClient
62
- Docker client instance
63
- image_name: str
64
- Name of the docker image to launch
65
- **kwargs: dict, optional
66
- Default keyword arguments to pass to docker.DockerClient.containers.run
67
- """
68
-
69
- def __init__ (
70
- self ,
71
- docker_client : docker .DockerClient ,
72
- image_name : str ,
73
- ** kwargs : Any ,
74
- ):
75
- self .container : Container | None = None
76
- self .docker_client : docker .DockerClient = docker_client
77
- self .image_name : str = image_name
78
- self .kwargs : Any = kwargs
79
-
80
- def run_detached (self , ** kwargs : Any ) -> Container :
81
- """Runs a docker container using the pre-configured image name
82
- and a mix of the pre-configured container options and those passed
83
- to this method.
84
-
85
- Keeps track of the docker.Container instance spawned to kill it
86
- later.
87
-
88
- Parameters
89
- ----------
90
- **kwargs: dict, optional
91
- Keyword arguments to pass to docker.DockerClient.containers.run
92
- extending and/or overriding key/value pairs passed to the constructor
93
-
94
- Returns
95
- -------
96
- docker.Container
97
- """
98
- all_kwargs = self .kwargs | kwargs
99
- LOGGER .info (f"Running { self .image_name } with args { all_kwargs } ..." )
100
- self .container = self .docker_client .containers .run (
101
- self .image_name ,
102
- ** all_kwargs ,
103
- )
104
- return self .container
105
-
106
- def run_and_wait (
107
- self ,
108
- timeout : int ,
109
- no_warnings : bool = True ,
110
- no_errors : bool = True ,
111
- no_failure : bool = True ,
112
- ** kwargs : Any ,
113
- ) -> str :
114
- running_container = self .run_detached (** kwargs )
115
- rv = running_container .wait (timeout = timeout )
116
- logs = running_container .logs ().decode ("utf-8" )
117
- assert isinstance (logs , str )
118
- LOGGER .debug (logs )
119
- assert no_warnings == (not self .get_warnings (logs ))
120
- assert no_errors == (not self .get_errors (logs ))
121
- assert no_failure == (rv ["StatusCode" ] == 0 )
122
- return logs
123
-
124
- @staticmethod
125
- def get_errors (logs : str ) -> list [str ]:
126
- return TrackedContainer ._lines_starting_with (logs , "ERROR" )
127
-
128
- @staticmethod
129
- def get_warnings (logs : str ) -> list [str ]:
130
- return TrackedContainer ._lines_starting_with (logs , "WARNING" )
131
-
132
- @staticmethod
133
- def _lines_starting_with (logs : str , pattern : str ) -> list [str ]:
134
- return [line for line in logs .splitlines () if line .startswith (pattern )]
135
-
136
- def remove (self ) -> None :
137
- """Kills and removes the tracked docker container."""
138
- if self .container :
139
- self .container .remove (force = True )
140
-
141
-
142
37
@pytest .fixture (scope = "function" )
143
- def container (docker_client : docker .DockerClient , image_name : str ) -> Container :
38
+ def container (
39
+ docker_client : docker .DockerClient , image_name : str
40
+ ) -> Generator [TrackedContainer ]:
144
41
"""Notebook container with initial configuration appropriate for testing
145
42
(e.g., HTTP port exposed to the host for HTTP calls).
146
43
0 commit comments