diff --git a/.semaphore/semaphore.yml b/.semaphore/semaphore.yml index 2d361f4..b38845f 100644 --- a/.semaphore/semaphore.yml +++ b/.semaphore/semaphore.yml @@ -9,7 +9,7 @@ global_job_config: prologue: commands: - checkout - - sem-version python 3.9 + - sem-version python 3.14 - pip install tox - COMMIT_MESSAGE_PREFIX="[ci skip] Publish version" blocks: diff --git a/confluent/docker_utils/__init__.py b/confluent/docker_utils/__init__.py index 1176be0..0e0f438 100644 --- a/confluent/docker_utils/__init__.py +++ b/confluent/docker_utils/__init__.py @@ -1,214 +1,269 @@ import base64 import os import subprocess +from typing import Any, Dict, Optional, Tuple -try: - import boto3 - HAS_BOTO3 = True -except ImportError: - HAS_BOTO3 = False - +import boto3 import docker -from compose.config.config import ConfigDetails, ConfigFile, load -from compose.container import Container -from compose.project import Project -from compose.service import ImageType -from compose.cli.docker_client import docker_client -from compose.config.environment import Environment - - -def api_client(): - return docker.from_env().api - - -def ecr_login(): - if not HAS_BOTO3: - raise ImportError( - "boto3 is required for ECR login. " - "Install with: pip install boto3" - ) - # see docker/docker-py#1677 - ecr = boto3.client('ecr') - login = ecr.get_authorization_token() - b64token = login['authorizationData'][0]['authorizationToken'].encode('utf-8') - username, password = base64.b64decode(b64token).decode('utf-8').split(':') - registry = login['authorizationData'][0]['proxyEndpoint'] - client = docker.from_env() - client.login(username, password, registry=registry) +from .compose import ( + ComposeConfig, + ComposeContainer, + ComposeProject, + ComposeService, + create_docker_client, + STATE_KEY, + STATUS_RUNNING, + VOLUME_MODE_RW, +) -def build_image(image_name, dockerfile_dir): - print("Building image %s from %s" % (image_name, dockerfile_dir)) - client = api_client() - output = client.build(dockerfile_dir, rm=True, tag=image_name) - response = "".join([" %s" % (line,) for line in output]) - print(response) +HOST_CONFIG_NETWORK_MODE = "NetworkMode" +HOST_CONFIG_BINDS = "Binds" +TESTING_LABEL = "io.confluent.docker.testing" -def image_exists(image_name): - client = api_client() - tags = [t for image in client.images() for t in image['RepoTags'] or []] - return image_name in tags +def docker_client() -> docker.DockerClient: + return docker.from_env() -def pull_image(image_name): - client = api_client() - if not image_exists(image_name): - client.pull(image_name) +def ecr_login() -> None: + ecr = boto3.client('ecr') + auth_data = ecr.get_authorization_token()['authorizationData'][0] + token = base64.b64decode(auth_data['authorizationToken'].encode()).decode() + username, password = token.split(':') + docker.from_env().login(username, password, registry=auth_data['proxyEndpoint']) + + +def build_image(image_name: str, dockerfile_dir: str) -> None: + print(f"Building image {image_name} from {dockerfile_dir}") + _, build_logs = docker_client().images.build( + path=dockerfile_dir, rm=True, tag=image_name, decode=True + ) + for log_line in build_logs: + if isinstance(log_line, dict) and 'stream' in log_line: + print(f" {log_line['stream']}", end='') + elif isinstance(log_line, (bytes, str)): + text = log_line.decode(errors='ignore') if isinstance(log_line, bytes) else log_line + print(f" {text}", end='') + + +def image_exists(image_name: str) -> bool: + try: + docker_client().images.get(image_name) + return True + except docker.errors.ImageNotFound: + return False + + +def pull_image(image_name: str) -> None: + if image_exists(image_name): + return + try: + docker_client().images.pull(image_name) + except docker.errors.APIError as err: + raise RuntimeError(f"Failed to pull image '{image_name}': {err}") from err + + +def parse_bind_mount(bind_spec: str) -> Tuple[str, Dict[str, str]]: + parts = bind_spec.split(':') + if len(parts) < 2: + raise ValueError(f"Invalid bind mount format: {bind_spec}") + host_path = parts[0] + container_path = parts[1] + mode = parts[2] if len(parts) > 2 else VOLUME_MODE_RW + return host_path, {'bind': container_path, 'mode': mode} + + +def _parse_binds(binds: list) -> Dict[str, Dict[str, str]]: + result = {} + for bind_spec in binds: + host_path, mount_config = parse_bind_mount(bind_spec) + result[host_path] = mount_config + return result + + +def _build_container_config(image: str, command: Optional[str], labels: Dict[str, str], + host_config: Dict[str, Any]) -> Dict[str, Any]: + config = { + 'image': image, + 'command': command, + 'labels': labels, + 'detach': True, + } + + if HOST_CONFIG_NETWORK_MODE in host_config: + config['network_mode'] = host_config[HOST_CONFIG_NETWORK_MODE] + if HOST_CONFIG_BINDS in host_config: + config['volumes'] = _parse_binds(host_config[HOST_CONFIG_BINDS]) + + return config + + +def run_docker_command(timeout: Optional[int] = None, **kwargs) -> bytes: + pull_image(kwargs['image']) + + container_config = _build_container_config( + image=kwargs['image'], + command=kwargs.get('command'), + labels={TESTING_LABEL: 'true'}, + host_config=kwargs.get('host_config', {}) + ) + + container = docker_client().containers.create(**container_config) + try: + container.start() + container.wait(timeout=timeout) + output = container.logs() + print(f"Running command {kwargs.get('command')}: {output}") + return output + finally: + _cleanup_container(container) -def run_docker_command(timeout=None, **kwargs): - pull_image(kwargs["image"]) - client = api_client() - kwargs["labels"] = {"io.confluent.docker.testing": "true"} - container = TestContainer.create(client, **kwargs) - container.start() - container.wait(timeout) - logs = container.logs() - print("Running command %s: %s" % (kwargs["command"], logs)) - container.shutdown() - return logs +def _cleanup_container(container) -> None: + try: + container.stop() + container.remove() + except docker.errors.NotFound: + print(f"Container {container.id} already removed") -def path_exists_in_image(image, path): - print("Checking for %s in %s" % (path, image)) - cmd = "bash -c '[ ! -e %s ] || echo success' " % (path,) - output = run_docker_command(image=image, command=cmd) - return b"success" in output +def path_exists_in_image(image: str, path: str) -> bool: + print(f"Checking for {path} in {image}") + output = run_docker_command( + image=image, + command=f"bash -c '[ ! -e {path} ] || echo success'" + ) + return b'success' in output -def executable_exists_in_image(image, path): - print("Checking for %s in %s" % (path, image)) - cmd = "bash -c '[ ! -x %s ] || echo success' " % (path,) - output = run_docker_command(image=image, command=cmd) - return b"success" in output +def executable_exists_in_image(image: str, path: str) -> bool: + print(f"Checking for {path} in {image}") + output = run_docker_command( + image=image, + command=f"bash -c '[ ! -x {path} ] || echo success'" + ) + return b'success' in output -def run_command_on_host(command): - logs = run_docker_command( - image="busybox", +def run_command_on_host(command: str) -> bytes: + return run_docker_command( + image='busybox', command=command, - host_config={'NetworkMode': 'host', 'Binds': ['/tmp:/tmp']}) - print("Running command %s: %s" % (command, logs)) - return logs - + host_config={ + HOST_CONFIG_NETWORK_MODE: 'host', + HOST_CONFIG_BINDS: ['/tmp:/tmp'] + } + ) -def run_cmd(command): - if command.startswith('"'): - cmd = "bash -c %s" % command - else: - cmd = command - output = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT) +def run_cmd(command: str) -> bytes: + shell_command = f'bash -c {command}' if command.startswith('"') else command + return subprocess.check_output(shell_command, shell=True, stderr=subprocess.STDOUT) - return output +def add_registry_and_tag(image: str, scope: str = '') -> str: + prefix = f'{scope}_' if scope else '' + registry = os.environ.get(f'DOCKER_{prefix}REGISTRY', '') + tag = os.environ.get(f'DOCKER_{prefix}TAG', 'latest') + return f'{registry}{image}:{tag}' -def add_registry_and_tag(image, scope=""): - """ - Fully qualify an image name. `scope` may be an empty - string, "UPSTREAM" for upstream dependencies, or "TEST" - for test dependencies. The injected values correspond to - DOCKER_(${scope}_)REGISTRY and DOCKER_(${scope}_)TAG environment - variables, which are set up by the Maven build. - :param str image: Image name, without registry prefix and tag postfix. - :param str scope: - """ - - if scope: - scope += "_" - - return "{0}{1}:{2}".format(os.environ.get("DOCKER_{0}REGISTRY".format(scope), ""), - image, - os.environ.get("DOCKER_{0}TAG".format(scope), "latest") - ) - - -class TestContainer(Container): - - def state(self): - return self.inspect_container["State"] - - def status(self): - return self.state()["Status"] - - def shutdown(self): +class TestContainer(ComposeContainer): + @classmethod + def create(cls, client: docker.DockerClient, **kwargs) -> 'TestContainer': + container_config = _build_container_config( + image=kwargs.get('image'), + command=kwargs.get('command'), + labels=kwargs.get('labels', {}), + host_config=kwargs.get('host_config', {}) + ) + return cls(client.containers.create(**container_config)) + + def state(self) -> Dict: + self.container.reload() + return self.container.attrs[STATE_KEY] + + def status(self) -> str: + return self.state()['Status'] + + def shutdown(self) -> None: self.stop() self.remove() + + def execute(self, command: str) -> bytes: + return self.exec_run(command) - def execute(self, command): - eid = self.create_exec(command) - return self.start_exec(eid) - - def wait(self, timeout): - return self.client.wait(self.id, timeout) - -class TestCluster(): - - def __init__(self, name, working_dir, config_file): - config_file_path = os.path.join(working_dir, config_file) - cfg_file = ConfigFile.from_filename(config_file_path) - c = ConfigDetails(working_dir, [cfg_file],) - self.cd = load(c) +class TestCluster: + def __init__(self, name: str, working_dir: str, config_file: str): self.name = name - - def get_project(self): - # Dont reuse the client to fix this bug : https://github.com/docker/compose/issues/1275 - client = docker_client(Environment()) - project = Project.from_config(self.name, self.cd, client) - return project - - def start(self): + self._config = ComposeConfig(working_dir, config_file) + + def get_project(self) -> ComposeProject: + return ComposeProject(self.name, self._config, create_docker_client()) + + def start(self) -> None: self.shutdown() self.get_project().up() - - def is_running(self): - state = [container.is_running for container in self.get_project().containers()] - return all(state) and len(state) > 0 - - def is_service_running(self, service_name): - return self.get_container(service_name).is_running - - def shutdown(self): - project = self.get_project() - project.down(ImageType.none, True, True) - project.remove_stopped() - - def get_container(self, service_name, stopped=False): + + def shutdown(self) -> None: + self.get_project().down(remove_volumes=True) + + def is_running(self) -> bool: + containers = self.get_project().containers() + return bool(containers) and all(c.is_running for c in containers) + + def is_service_running(self, service_name: str) -> bool: + try: + return self.get_container(service_name).is_running + except RuntimeError: + return False + + def get_container(self, service_name: str, stopped: bool = False) -> ComposeContainer: + if stopped: + containers = self.get_project().containers( + service_names=[service_name], stopped=True + ) + if containers: + return containers[0] + raise RuntimeError(f"No container for '{service_name}'") return self.get_project().get_service(service_name).get_container() - - def exit_code(self, service_name): - containers = self.get_project().containers([service_name], stopped=True) - return containers[0].exit_code - - def wait(self, service_name, timeout): - container = self.get_project().containers([service_name], stopped=True) - if container[0].is_running: - return self.get_project().client.wait(container[0].id, timeout) - - def run_command_on_service(self, service_name, command): - return self.run_command(command, self.get_container(service_name)) - - def service_logs(self, service_name, stopped=False): + + def exit_code(self, service_name: str) -> Optional[int]: + containers = self.get_project().containers( + service_names=[service_name], stopped=True + ) + return containers[0].exit_code if containers else None + + def wait(self, service_name: str, timeout: Optional[int] = None) -> Optional[Dict]: + containers = self.get_project().containers( + service_names=[service_name], stopped=True + ) + if containers and containers[0].is_running: + return containers[0].wait(timeout) + return None + + def service_logs(self, service_name: str, stopped: bool = False) -> bytes: if stopped: - containers = self.get_project().containers([service_name], stopped=True) - print(containers[0].logs()) - return containers[0].logs() - else: - return self.get_container(service_name).logs() - - def run_command(self, command, container): - print("Running %s on %s :" % (command, container)) - eid = container.create_exec(command) - output = container.start_exec(eid) - print("\n%s " % output) + containers = self.get_project().containers( + service_names=[service_name], stopped=True + ) + return containers[0].logs() if containers else b'' + return self.get_container(service_name).logs() + + def run_command_on_service(self, service_name: str, command: str) -> bytes: + return self.run_command(command, self.get_container(service_name)) + + def run_command(self, command: str, container: ComposeContainer) -> bytes: + print(f"Running {command} on {container.name}:") + output = container.exec_run(command) + decoded = output.decode('utf-8', errors='ignore') if isinstance(output, bytes) else output + print(f"\n{decoded}") return output - - def run_command_on_all(self, command): - results = {} - for container in self.get_project().containers(): - results[container.name_without_project] = self.run_command(command, container) - - return results + + def run_command_on_all(self, command: str) -> Dict[str, bytes]: + return { + container.name_without_project: self.run_command(command, container) + for container in self.get_project().containers() + } diff --git a/confluent/docker_utils/compose.py b/confluent/docker_utils/compose.py new file mode 100644 index 0000000..7e64e54 --- /dev/null +++ b/confluent/docker_utils/compose.py @@ -0,0 +1,401 @@ +import os +import re +from typing import Any, Dict, List, Optional + +import docker +import docker.errors +import yaml + +LABEL_PROJECT = "com.docker.compose.project" +LABEL_SERVICE = "com.docker.compose.service" + +STATUS_RUNNING = "running" +STATUS_EXITED = "exited" + +STATE_KEY = "State" +EXIT_CODE_KEY = "ExitCode" + +NETWORK_DRIVER_BRIDGE = "bridge" + +VOLUME_MODE_RW = "rw" + +ENV_VAR_BRACED_PATTERN = re.compile(r'\$\{([^}]+)\}') +ENV_VAR_SIMPLE_PATTERN = re.compile(r'\$([A-Za-z_][A-Za-z0-9_]*)') +ENV_VAR_DEFAULT_UNSET_PATTERN = re.compile(r'^([A-Za-z_][A-Za-z0-9_]*)-(.+)$') + + +def _resolve_braced_env_var(expr: str) -> str: + if ':-' in expr: + name, default = expr.split(':-', 1) + value = os.environ.get(name, '') + return default if value == '' else value + + match = ENV_VAR_DEFAULT_UNSET_PATTERN.match(expr) + if match: + name, default = match.groups() + env_val = os.environ.get(name) + return env_val if env_val is not None else default + + return os.environ.get(expr, '') + + +def _expand_env_string(value: str) -> str: + result = ENV_VAR_BRACED_PATTERN.sub( + lambda m: _resolve_braced_env_var(m.group(1)), value + ) + result = ENV_VAR_SIMPLE_PATTERN.sub( + lambda m: os.environ.get(m.group(1), ''), result + ) + return result + + +def _expand_env_vars(value: Any) -> Any: + if isinstance(value, str): + return _expand_env_string(value) + + if isinstance(value, dict): + return {k: _expand_env_vars(v) for k, v in value.items()} + + if isinstance(value, list): + return [_expand_env_vars(item) for item in value] + + return value + + +def create_docker_client() -> docker.DockerClient: + return docker.from_env() + + +class ComposeConfig: + def __init__(self, working_dir: str, config_file: str): + self.working_dir = working_dir + self.config_file_path = os.path.join(working_dir, config_file) + self._config = self._load() + + def _load(self) -> Dict[str, Any]: + with open(self.config_file_path, encoding='utf-8') as f: + raw_config = yaml.safe_load(f) + + if not raw_config or 'services' not in raw_config: + raise ValueError(f"Invalid compose file: missing 'services' in {self.config_file_path}") + + return _expand_env_vars(raw_config) + + @property + def services(self) -> Dict[str, Dict[str, Any]]: + return self._config.get('services', {}) + + def get_service(self, name: str) -> Dict[str, Any]: + if name not in self.services: + raise ValueError(f"Service '{name}' not found in compose file") + return self.services[name] + + +class ComposeContainer: + def __init__(self, container: docker.models.containers.Container): + self.container = container + + @property + def id(self) -> str: + return self.container.id + + @property + def name(self) -> str: + return self.container.name + + @property + def name_without_project(self) -> str: + service_label = self.container.labels.get(LABEL_SERVICE) + if service_label: + return service_label + parts = self.name.split('_') + if len(parts) >= 3: + return '_'.join(parts[1:-1]) + if len(parts) == 2: + return parts[1] + return self.name + + @property + def is_running(self) -> bool: + self.container.reload() + return self.container.status == STATUS_RUNNING + + @property + def exit_code(self) -> Optional[int]: + self.container.reload() + if self.container.status == STATUS_EXITED: + return self.container.attrs[STATE_KEY][EXIT_CODE_KEY] + return None + + @property + def client(self): + return self.container.client.api + + @property + def inspect_container(self) -> Dict[str, Any]: + self.container.reload() + return self.container.attrs + + def start(self) -> None: + self.container.start() + + def stop(self, timeout: int = 10) -> None: + try: + self.container.stop(timeout=timeout) + except docker.errors.NotFound: + pass + + def remove(self, force: bool = False, v: bool = False) -> None: + try: + self.container.remove(force=force, v=v) + except docker.errors.NotFound: + pass + + def wait(self, timeout: Optional[int] = None) -> Dict[str, Any]: + return self.container.wait(timeout=timeout) + + def logs(self) -> bytes: + return self.container.logs() + + def create_exec(self, command: str) -> str: + return self.container.client.api.exec_create(self.container.id, command)['Id'] + + def start_exec(self, exec_id: str) -> bytes: + return self.container.client.api.exec_start(exec_id) + + def exec_run(self, command: str) -> bytes: + return self.container.exec_run(command).output + + +class ComposeService: + def __init__(self, name: str, project: 'ComposeProject'): + self.name = name + self.project = project + + def get_container(self) -> ComposeContainer: + containers = self.project.containers(service_names=[self.name]) + if not containers: + raise RuntimeError(f"No running container for service '{self.name}'") + return containers[0] + + +class ComposeProject: + _PASSTHROUGH_KEYS = ('network_mode', 'working_dir', 'hostname', 'entrypoint', 'user', 'tty', 'stdin_open') + + def __init__(self, name: str, config: ComposeConfig, client: docker.DockerClient): + self.name = name + self.config = config + self.client = client + self._network = None + + @property + def network_name(self) -> str: + return f"{self.name}_default" + + def _get_or_create_network(self) -> docker.models.networks.Network: + if self._network: + return self._network + + try: + self._network = self.client.networks.get(self.network_name) + except docker.errors.NotFound: + self._network = self.client.networks.create( + self.network_name, + driver=NETWORK_DRIVER_BRIDGE, + labels={LABEL_PROJECT: self.name} + ) + return self._network + + def up(self, services: Optional[List[str]] = None) -> None: + self._get_or_create_network() + service_list = services or list(self.config.services.keys()) + try: + for service_name in service_list: + self._start_service(service_name) + except (docker.errors.APIError, RuntimeError, ValueError): + self.down() + raise + + def down(self, remove_images: Optional[str] = None, remove_volumes: bool = False, + remove_orphans: bool = False) -> None: + for container in self.containers(stopped=True): + try: + container.stop() + container.remove(force=True, v=remove_volumes) + except docker.errors.NotFound: + pass + + self._remove_network() + + def _remove_network(self) -> None: + try: + network = self.client.networks.get(self.network_name) + network.remove() + except docker.errors.NotFound: + pass + self._network = None + + def remove_stopped(self) -> None: + for container in self.containers(stopped=True): + if not container.is_running: + container.remove(force=True) + + def containers(self, service_names: Optional[List[str]] = None, + stopped: bool = False) -> List[ComposeContainer]: + filters = {'label': f'{LABEL_PROJECT}={self.name}'} + if not stopped: + filters['status'] = STATUS_RUNNING + + all_containers = self.client.containers.list(all=stopped, filters=filters) + result = [ComposeContainer(c) for c in all_containers] + + if service_names: + result = [c for c in result if c.container.labels.get(LABEL_SERVICE) in service_names] + + return result + + def get_service(self, name: str) -> ComposeService: + return ComposeService(name, self) + + def _start_service(self, service_name: str) -> ComposeContainer: + container_name = f"{self.name}_{service_name}_1" + + existing = self._get_existing_container(container_name) + if existing: + return existing + + service_config = self.config.get_service(service_name) + run_kwargs = self._build_run_kwargs(service_name, service_config) + + container = self.client.containers.run(**run_kwargs) + self._verify_container_running(container, service_name) + return ComposeContainer(container) + + def _get_existing_container(self, container_name: str) -> Optional[ComposeContainer]: + try: + existing = self.client.containers.get(container_name) + if existing.status != STATUS_RUNNING: + existing.start() + return ComposeContainer(existing) + except docker.errors.NotFound: + return None + + def _build_run_kwargs(self, service_name: str, service_config: Dict[str, Any]) -> Dict[str, Any]: + container_config = self._parse_service_config(service_config) + + if 'image' not in container_config or not container_config['image']: + raise ValueError(f"Service '{service_name}' has no valid image") + + kwargs = { + 'name': f"{self.name}_{service_name}_1", + 'detach': True, + 'labels': {LABEL_PROJECT: self.name, LABEL_SERVICE: service_name}, + **container_config + } + + if 'network_mode' not in container_config: + network = self._get_or_create_network() + kwargs['network'] = network.name + if 'hostname' not in container_config: + kwargs['hostname'] = service_name + + return kwargs + + def _parse_service_config(self, service_config: Dict[str, Any]) -> Dict[str, Any]: + config = {} + + if 'image' in service_config: + config['image'] = service_config['image'] + + if 'command' in service_config: + config['command'] = service_config['command'] + + if 'environment' in service_config: + config['environment'] = self._parse_environment(service_config['environment']) + + if 'ports' in service_config: + config['ports'] = self._parse_ports(service_config['ports']) + + if 'volumes' in service_config: + config['volumes'] = self._parse_volumes(service_config['volumes']) + + for key in self._PASSTHROUGH_KEYS: + if key in service_config: + config[key] = service_config[key] + + return config + + def _parse_environment(self, env: Any) -> Dict[str, str]: + if isinstance(env, list): + result = {} + for item in env: + if not isinstance(item, str): + continue + if '=' in item: + key, value = item.split('=', 1) + else: + key, value = item, os.environ.get(item, '') + result[key] = value + return result + + if isinstance(env, dict): + return { + key: os.environ.get(key, '') if value is None else str(value) + for key, value in env.items() + } + + raise ValueError(f"environment must be list or dict, got {type(env).__name__}") + + def _parse_ports(self, ports: List[Any]) -> Dict[str, Any]: + result = {} + for port_spec in ports: + port_str = str(port_spec) + parts = port_str.split(':') + + if len(parts) == 1: + container_port = self._normalize_port(parts[0]) + result[container_port] = None + elif len(parts) == 2: + host_port, container_port = parts + container_port = self._normalize_port(container_port) + result[container_port] = int(host_port) if host_port.isdigit() else host_port + elif len(parts) == 3: + ip_addr, host_port, container_port = parts + container_port = self._normalize_port(container_port) + host_binding = int(host_port) if host_port.isdigit() else None + result[container_port] = (ip_addr, host_binding) + + return result + + def _normalize_port(self, port: str) -> str: + port = port.strip() + if '/' not in port: + return f"{port}/tcp" + return port + + def _parse_volumes(self, volumes: List[str]) -> Dict[str, Dict[str, str]]: + result = {} + for volume_spec in volumes: + if ':' not in volume_spec: + continue + + parts = volume_spec.split(':') + host_path = parts[0] + container_path = parts[1] + mode = parts[2] if len(parts) > 2 else VOLUME_MODE_RW + + if host_path.startswith('./'): + host_path = os.path.join(self.config.working_dir, host_path[2:]) + + result[host_path] = {'bind': container_path, 'mode': mode} + + return result + + def _verify_container_running(self, container: docker.models.containers.Container, + service_name: str) -> None: + container.reload() + if container.status != STATUS_RUNNING: + logs = container.logs().decode('utf-8', errors='ignore')[-500:] + raise RuntimeError( + f"Service '{service_name}' exited immediately.\nLogs:\n{logs}" + ) diff --git a/requirements.txt b/requirements.txt index b95e24d..56afb23 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ -docker==7.1.0 -docker-compose==1.29.2 -Jinja2==3.1.6 -requests==2.32.5 +boto3~=1.42.49 +docker~=7.1.0 +Jinja2~=3.1.0 +PyYAML~=6.0.0 +requests~=2.32.0 diff --git a/tox.ini b/tox.ini index 3df3ec5..0654613 100644 --- a/tox.ini +++ b/tox.ini @@ -1,9 +1,9 @@ [tox] -envlist = py3 +envlist = py314 toxworkdir = {env:HOME:.}/.virtualenvs/docker_utils -[testenv:py3] -basepython = python3 +[testenv:py314] +basepython = python3.14 [testenv] install_command = pip install -U {packages} @@ -35,3 +35,5 @@ max-line-length = 160 [pytest] addopts = -n 4 +markers = + integration: marks tests as integration tests