dockerctx 2026.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dockerctx/__init__.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
""" A context manager for a docker container. """
|
|
2
|
+
from __future__ import division, print_function
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
import uuid
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
import typing
|
|
12
|
+
import docker
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
__version__ = version('dockerctx')
|
|
17
|
+
except PackageNotFoundError:
|
|
18
|
+
__version__ = '0.0.0'
|
|
19
|
+
__all__ = ['new_container']
|
|
20
|
+
logger = logging.getLogger('dockerctx')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@contextmanager
|
|
24
|
+
def new_container(
|
|
25
|
+
image_name,
|
|
26
|
+
new_container_name=lambda: uuid.uuid4().hex,
|
|
27
|
+
ports=None,
|
|
28
|
+
tmpfs=None,
|
|
29
|
+
ready_test=None,
|
|
30
|
+
docker_api_version='auto',
|
|
31
|
+
persist=lambda: False,
|
|
32
|
+
**kwargs):
|
|
33
|
+
"""Start a docker container, and kill+remove when done.
|
|
34
|
+
|
|
35
|
+
:param new_container_name: The container name. By default, a UUID will be used.
|
|
36
|
+
If a callable, the result must be a str.
|
|
37
|
+
:type new_container_name: str | callable
|
|
38
|
+
:param ports: The list of port mappings to configure on the docker
|
|
39
|
+
container. The format is the same as that used in the `docker`
|
|
40
|
+
package, e.g. `ports={'5432/tcp': 60011}`
|
|
41
|
+
:type ports: typing.Dict[str, int]
|
|
42
|
+
:param tmpfs: When creating a container you can specify paths to be mounted
|
|
43
|
+
with tmpfs. It can be a list or a dictionary to configure on the docker
|
|
44
|
+
container. If it's a list, each item is a string specifying the path and
|
|
45
|
+
(optionally) any configuration for the mount, e.g. `tmpfs={'/mnt/vol2': '',
|
|
46
|
+
'/mnt/vol1': 'size=3G,uid=1000'}`
|
|
47
|
+
:type tmpfs: typing.Dict[str, str]
|
|
48
|
+
:param ready_test: A function to run to verify whether the container is "ready"
|
|
49
|
+
(in some sense) before yielding the container back to the caller. An example
|
|
50
|
+
of such a test is the `accepting_connections` function in the this module,
|
|
51
|
+
which will try repeatedly to connect to a socket, until either successfuly,
|
|
52
|
+
or a max timeout is reached. Use functools.partial to wrap up the args.
|
|
53
|
+
:type ready_test: typing.Callable[[], bool]
|
|
54
|
+
:param privileged: a privileged container is given access to all devices on
|
|
55
|
+
the host as well as set some configuration in AppArmor or SELinux to allow
|
|
56
|
+
the container nearly all the same access to the host as processes running
|
|
57
|
+
outside containers on the host.
|
|
58
|
+
:type persist: typing.Callable[[], bool]
|
|
59
|
+
:param persist: If True, the docker container will NOT be destroyed
|
|
60
|
+
during exit. This is sometimes useful if you want to keep a container
|
|
61
|
+
around, but only if tests fail. (That's why it's a callable: it only
|
|
62
|
+
gets called during the "exit" phase of the context manager).
|
|
63
|
+
:param kwargs: These extra keyword arguments will be passed through to the
|
|
64
|
+
`client.containers.run()` call. One of the more commons ones is to pass
|
|
65
|
+
a custom command through.
|
|
66
|
+
|
|
67
|
+
"""
|
|
68
|
+
_ = new_container_name
|
|
69
|
+
name = str(_() if callable(_) else _)
|
|
70
|
+
client = docker.from_env(version=docker_api_version)
|
|
71
|
+
|
|
72
|
+
logger.info('New container: %s', name)
|
|
73
|
+
container = client.containers.run(
|
|
74
|
+
image_name, name=name, tmpfs=tmpfs, detach=True, ports=ports,
|
|
75
|
+
**kwargs
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
logger.info('Waiting for container to be ready')
|
|
79
|
+
if ready_test and not ready_test():
|
|
80
|
+
raise ConnectionError(
|
|
81
|
+
'Container {} not ready fast enough.'.format(name)
|
|
82
|
+
)
|
|
83
|
+
yield container
|
|
84
|
+
finally:
|
|
85
|
+
if persist():
|
|
86
|
+
logger.info('Leaving container up.')
|
|
87
|
+
else:
|
|
88
|
+
logger.info('Stopping container %s', name)
|
|
89
|
+
try:
|
|
90
|
+
container.stop(timeout=1)
|
|
91
|
+
except docker.errors.APIError as e:
|
|
92
|
+
logger.error('Error stopping container: %s', e)
|
|
93
|
+
|
|
94
|
+
logger.info('Removing container %s', name)
|
|
95
|
+
try:
|
|
96
|
+
container.remove(force=True)
|
|
97
|
+
except docker.errors.APIError as e:
|
|
98
|
+
logger.error('Error removing container: %s', e)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def accepting_connections(host, port, timeout=20):
|
|
102
|
+
"""Try to make a socket connection to `(host, port)`
|
|
103
|
+
|
|
104
|
+
I'll try every 200 ms, and eventually give up after `timeout`.
|
|
105
|
+
|
|
106
|
+
:type host: str
|
|
107
|
+
:type port: int
|
|
108
|
+
:type timeout: int
|
|
109
|
+
:return: True for successful connection, False otherwise
|
|
110
|
+
:rtype: bool
|
|
111
|
+
"""
|
|
112
|
+
t0 = time.time()
|
|
113
|
+
while time.time() - t0 < timeout:
|
|
114
|
+
try:
|
|
115
|
+
# s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
116
|
+
s = socket.create_connection((host, port))
|
|
117
|
+
logger.debug('Connected!')
|
|
118
|
+
s.close()
|
|
119
|
+
return True
|
|
120
|
+
except socket.error as ex:
|
|
121
|
+
logger.debug("Connection failed with errno %s: %s",
|
|
122
|
+
ex.errno, ex.strerror)
|
|
123
|
+
time.sleep(0.2)
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def pg_ready(host, port, dbuser='postgres', dbpass='password', dbname='postgres',
|
|
128
|
+
timeout=20, poll_freq=0.2):
|
|
129
|
+
"""Wait until a postgres instance is ready to receive connections.
|
|
130
|
+
|
|
131
|
+
.. note::
|
|
132
|
+
|
|
133
|
+
This requires psycopg2 to be installed.
|
|
134
|
+
|
|
135
|
+
:type host: str
|
|
136
|
+
:type port: int
|
|
137
|
+
:type timeout: float
|
|
138
|
+
:type poll_freq: float
|
|
139
|
+
"""
|
|
140
|
+
import psycopg2
|
|
141
|
+
t0 = time.time()
|
|
142
|
+
while time.time() - t0 < timeout:
|
|
143
|
+
try:
|
|
144
|
+
conn = psycopg2.connect(
|
|
145
|
+
"host={host} port={port} user={dbuser} password={dbpass} "
|
|
146
|
+
"dbname={dbname}".format(**vars())
|
|
147
|
+
)
|
|
148
|
+
logger.debug('Connected successfully.')
|
|
149
|
+
conn.close()
|
|
150
|
+
return True
|
|
151
|
+
except psycopg2.OperationalError as ex:
|
|
152
|
+
logger.debug("Connection failed: {0}".format(ex));
|
|
153
|
+
time.sleep(poll_freq)
|
|
154
|
+
|
|
155
|
+
logger.error('Postgres readiness check timed out.')
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@contextmanager
|
|
160
|
+
def session_scope(session_cls):
|
|
161
|
+
"""Provide a transactional scope around a series of operations.
|
|
162
|
+
|
|
163
|
+
.. note::
|
|
164
|
+
|
|
165
|
+
This requires SQLAlchemy to be installed.
|
|
166
|
+
|
|
167
|
+
:type: sqlalchemy.orm.Session
|
|
168
|
+
"""
|
|
169
|
+
session = session_cls()
|
|
170
|
+
try:
|
|
171
|
+
logger.debug('Yielding session')
|
|
172
|
+
yield session
|
|
173
|
+
logger.debug('Committing session')
|
|
174
|
+
session.commit()
|
|
175
|
+
except:
|
|
176
|
+
logger.exception('Error detected, rolling back session')
|
|
177
|
+
session.rollback()
|
|
178
|
+
raise
|
|
179
|
+
finally:
|
|
180
|
+
logger.debug('Closing session')
|
|
181
|
+
session.close()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def get_open_port():
|
|
185
|
+
"""Return a currently-unused local network TCP port number
|
|
186
|
+
|
|
187
|
+
This is extremely handy when running unit tests because you may
|
|
188
|
+
not always be able to get the port of your choice, especially
|
|
189
|
+
in a continuous-integration context.
|
|
190
|
+
|
|
191
|
+
:return: TCP port number
|
|
192
|
+
:rtype: int
|
|
193
|
+
"""
|
|
194
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
195
|
+
s.bind(('', 0)) # Using zero means the OS assigns one
|
|
196
|
+
address_info = s.getsockname()
|
|
197
|
+
port = int(address_info[1])
|
|
198
|
+
s.close()
|
|
199
|
+
return port
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dockerctx
|
|
3
|
+
Version: 2026.1.1
|
|
4
|
+
Summary: A context manager for a docker container.
|
|
5
|
+
Author: Caleb Hattingh
|
|
6
|
+
Author-email: Caleb Hattingh <caleb.hattingh@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Natural Language :: English
|
|
11
|
+
Classifier: Programming Language :: Python
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Requires-Dist: docker
|
|
20
|
+
Requires-Dist: psycopg2-binary
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Project-URL: Homepage, https://github.com/cjrh/dockerctx
|
|
23
|
+
Description-Content-Type: text/x-rst
|
|
24
|
+
|
|
25
|
+
.. image:: https://github.com/cjrh/dockerctx/workflows/Python%20application/badge.svg
|
|
26
|
+
:target: https://github.com/cjrh/dockerctx/actions
|
|
27
|
+
|
|
28
|
+
.. image:: https://coveralls.io/repos/github/cjrh/dockerctx/badge.svg?branch=master
|
|
29
|
+
:target: https://coveralls.io/github/cjrh/dockerctx?branch=master
|
|
30
|
+
|
|
31
|
+
.. image:: https://img.shields.io/pypi/pyversions/dockerctx.svg
|
|
32
|
+
:target: https://pypi.python.org/pypi/dockerctx
|
|
33
|
+
|
|
34
|
+
.. image:: https://img.shields.io/github/tag/cjrh/dockerctx.svg
|
|
35
|
+
:target: https://github.com/cjrh/dockerctx
|
|
36
|
+
|
|
37
|
+
.. image:: https://img.shields.io/pypi/v/dockerctx.svg
|
|
38
|
+
:target: https://img.shields.io/pypi/v/dockerctx.svg
|
|
39
|
+
|
|
40
|
+
dockerctx
|
|
41
|
+
=========
|
|
42
|
+
|
|
43
|
+
*dockerctx* is a context manager for managing the lifetime of a docker container.
|
|
44
|
+
|
|
45
|
+
The main use case is for setting up scaffolding for running tests, where you want
|
|
46
|
+
something a little broader than *unit tests*, but less heavily integrated than,
|
|
47
|
+
say, what you might write using `Robot framework`_.
|
|
48
|
+
|
|
49
|
+
.. _Robot framework: http://robotframework.org/
|
|
50
|
+
|
|
51
|
+
Install
|
|
52
|
+
-------
|
|
53
|
+
|
|
54
|
+
.. code-block:: bash
|
|
55
|
+
|
|
56
|
+
$ pip install dockerctx
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
The development-specific requirements will be installed automatically.
|
|
60
|
+
|
|
61
|
+
.. _flit: https://flit.readthedocs.io/en/latest/
|
|
62
|
+
|
|
63
|
+
Demo
|
|
64
|
+
----
|
|
65
|
+
|
|
66
|
+
This is taken from one of the tests:
|
|
67
|
+
|
|
68
|
+
.. code-block:: python3
|
|
69
|
+
|
|
70
|
+
import time
|
|
71
|
+
import redis
|
|
72
|
+
import pytest
|
|
73
|
+
from dockerctx import new_container
|
|
74
|
+
|
|
75
|
+
# First make a pytest fixture
|
|
76
|
+
|
|
77
|
+
@pytest.fixture(scope='function')
|
|
78
|
+
def f_redis():
|
|
79
|
+
|
|
80
|
+
# This is the new thing! It's pretty clear. The `ready_test` provides
|
|
81
|
+
# a way to customize what "ready" means for each container. Here,
|
|
82
|
+
# we simply pause for a bit.
|
|
83
|
+
|
|
84
|
+
with new_container(
|
|
85
|
+
image_name='redis:latest',
|
|
86
|
+
ports={'6379/tcp': 56379},
|
|
87
|
+
ready_test=lambda: time.sleep(0.5) or True) as container:
|
|
88
|
+
yield container
|
|
89
|
+
|
|
90
|
+
# Here is the test. Since the fixture is at the "function" level, a fully
|
|
91
|
+
# new Redis container will be created for each test that uses this fixture.
|
|
92
|
+
# After the test completes, the container will be removed.
|
|
93
|
+
|
|
94
|
+
def test_redis_a(f_redis):
|
|
95
|
+
# The container object comes from the `docker` python package. Here we
|
|
96
|
+
# access only the "name" attribute, but there are many others.
|
|
97
|
+
print('Container %s' % f_redis.name)
|
|
98
|
+
r = redis.StrictRedis(host='localhost', port=56379, db=0)
|
|
99
|
+
r.set('foo', 'bar')
|
|
100
|
+
assert r.get('foo') == b'bar'
|
|
101
|
+
|
|
102
|
+
Note that a brand new Redis container is created here, used within the
|
|
103
|
+
context of the context manager (which is wrapped into a *pytest* fixture
|
|
104
|
+
here), and then the container is destroyed after the context manager
|
|
105
|
+
exits.
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
In the src, there is another, much more elaborate test which
|
|
109
|
+
|
|
110
|
+
#. runs a *postgres* container;
|
|
111
|
+
#. waits for postgres to begin accepting connections;
|
|
112
|
+
#. creates a database;
|
|
113
|
+
#. creates tables (using the SQLAlchemy_ ORM);
|
|
114
|
+
#. performs database operations;
|
|
115
|
+
#. tears down and removes the container afterwards.
|
|
116
|
+
|
|
117
|
+
.. _SQLAlchemy: http://www.sqlalchemy.org/
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
dockerctx/__init__.py,sha256=pH44R2OBJ1mga94ySwlQjyiCfUAg0YoGPlWLnBQkkqQ,6846
|
|
2
|
+
dockerctx-2026.1.1.dist-info/licenses/LICENSE,sha256=sMyqgCurQZSqNZRvfh_jQxYKkxjHekuekMaN0IdlrcU,1071
|
|
3
|
+
dockerctx-2026.1.1.dist-info/WHEEL,sha256=f5fWSvWsg5Knq5GWa6t1nJIug0Tqo69GqAWD_9LbBKw,81
|
|
4
|
+
dockerctx-2026.1.1.dist-info/METADATA,sha256=FRno6NpgEL-Ic98BSqE_d_DBnviVSvW5TkbsCH3wpII,3922
|
|
5
|
+
dockerctx-2026.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2017 Caleb Hattingh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|