diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 840ccd27..f8932e0d 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -113,6 +113,22 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install -e .[ci,dev] + # NOTE: Unfortunately the gain of image caching is too small, + # and even it increases the latency of Linux jobs. :( + # - name: Cache Docker images + # uses: ScribeMD/docker-cache@0.5.0 + # with: + # key: docker-${{ runner.os }} + - name: Prepare Docker images (Linux) + if: ${{ runner.os == 'Linux' }} + run: | + docker pull python:3.12-alpine + - name: Prepare Docker images (Windows) + if: ${{ runner.os == 'Windows' }} + # Unfortunately, there is no slim version for Windows. + # This may take more than 10 minutes as the image size is a few gigabytes. + run: | + docker pull python:3.12 - name: Start Docker services if: ${{ matrix.registry == '1' }} run: | @@ -134,6 +150,9 @@ jobs: path: coverage-unit-${{ matrix.python-version }}-${{ matrix.os }}-${{ matrix.registry }}.xml if-no-files-found: error retention-days: 1 + - name: Clean up Docker images produced during tests + run: | + docker image list --filter 'reference=aiodocker-*' --format '{{.Repository}}:{{.Tag}}' | xargs -r docker rmi check: # This job does nothing and is only used for the branch protection name: ✅ Ensure the required checks passing diff --git a/CHANGES/850.misc b/CHANGES/850.misc new file mode 100644 index 00000000..56c326ba --- /dev/null +++ b/CHANGES/850.misc @@ -0,0 +1 @@ +Add more type annotations to the core APIs and retire codes for Python 3.7 compatibility diff --git a/aiodocker/channel.py b/aiodocker/channel.py index 4a993988..fe0d3dd4 100644 --- a/aiodocker/channel.py +++ b/aiodocker/channel.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio diff --git a/aiodocker/configs.py b/aiodocker/configs.py index a4355aae..56b3f6cc 100644 --- a/aiodocker/configs.py +++ b/aiodocker/configs.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import json from base64 import b64encode -from typing import Any, List, Mapping, Optional +from typing import Any, List, Mapping, Optional, Sequence from .utils import clean_filters, clean_map @@ -9,7 +11,11 @@ class DockerConfigs: def __init__(self, docker): self.docker = docker - async def list(self, *, filters: Optional[Mapping] = None) -> List[Mapping]: + async def list( + self, + *, + filters: Optional[Mapping[str, str | Sequence[str]]] = None, + ) -> List[Mapping]: """ Return a list of configs diff --git a/aiodocker/containers.py b/aiodocker/containers.py index a165bcc7..afc7dec8 100644 --- a/aiodocker/containers.py +++ b/aiodocker/containers.py @@ -1,31 +1,58 @@ +from __future__ import annotations + import json import shlex import tarfile -from typing import Any, Dict, Mapping, Optional, Sequence, Tuple, Union - +from typing import ( + TYPE_CHECKING, + Any, + AsyncIterator, + Dict, + List, + Literal, + Mapping, + Optional, + Sequence, + Tuple, + Union, + overload, +) + +from aiohttp import ClientWebSocketResponse from multidict import MultiDict from yarl import URL +from aiodocker.types import JSONObject + from .exceptions import DockerContainerError, DockerError from .execs import Exec from .jsonstream import json_stream_list, json_stream_stream from .logs import DockerLog from .multiplexed import multiplexed_result_list, multiplexed_result_stream from .stream import Stream +from .types import PortInfo from .utils import identical, parse_result +if TYPE_CHECKING: + from .docker import Docker + + class DockerContainers: - def __init__(self, docker): + def __init__(self, docker: Docker) -> None: self.docker = docker - async def list(self, **kwargs): + async def list(self, **kwargs) -> List[DockerContainer]: data = await self.docker._query_json( "containers/json", method="GET", params=kwargs ) return [DockerContainer(self.docker, **x) for x in data] - async def create_or_replace(self, name, config): + async def create_or_replace( + self, + name: str, + config: JSONObject, + ) -> DockerContainer: container = None try: @@ -44,25 +71,29 @@ async def create_or_replace(self, name, config): return container - async def create(self, config, *, name=None): + async def create( + self, + config: JSONObject, + *, + name: Optional[str] = None, + ) -> DockerContainer: url = "containers/create" - - config = json.dumps(config, sort_keys=True).encode("utf-8") + encoded_config = json.dumps(config, sort_keys=True).encode("utf-8") kwargs = {} if name: kwargs["name"] = name data = await self.docker._query_json( - url, method="POST", data=config, params=kwargs + url, method="POST", data=encoded_config, params=kwargs ) return DockerContainer(self.docker, id=data["Id"]) async def run( self, - config, + config: JSONObject, *, auth: Optional[Union[Mapping, str, bytes]] = None, name: Optional[str] = None, - ): + ) -> DockerContainer: """ Create and start a container. @@ -77,7 +108,7 @@ async def run( except DockerError as err: # image not found, try pulling it if err.status == 404 and "Image" in config: - await self.docker.pull(config["Image"], auth=auth) + await self.docker.pull(str(config["Image"]), auth=auth) container = await self.create(config, name=name) else: raise err @@ -91,26 +122,28 @@ async def run( return container - async def get(self, container, **kwargs): + async def get(self, container_id: str, **kwargs) -> DockerContainer: data = await self.docker._query_json( - f"containers/{container}/json", + f"containers/{container_id}/json", method="GET", params=kwargs, ) return DockerContainer(self.docker, **data) - def container(self, container_id, **kwargs): + def container(self, container_id: str, **kwargs) -> DockerContainer: data = {"id": container_id} data.update(kwargs) return DockerContainer(self.docker, **data) def exec(self, exec_id: str) -> Exec: """Return Exec instance for already created exec object.""" - return Exec(self.docker, exec_id, None) + return Exec(self.docker, exec_id) class DockerContainer: - def __init__(self, docker, **kwargs): + _container: Dict[str, Any] + + def __init__(self, docker: Docker, **kwargs) -> None: self.docker = docker self._container = kwargs self._id = self._container.get( @@ -122,17 +155,42 @@ def __init__(self, docker, **kwargs): def id(self) -> str: return self._id - def log(self, *, stdout=False, stderr=False, follow=False, **kwargs): + @overload + async def log( + self, + *, + stdout: bool = False, + stderr: bool = False, + follow: Literal[False] = False, + **kwargs, + ) -> List[str]: ... + + @overload + def log( + self, + *, + stdout: bool = False, + stderr: bool = False, + follow: Literal[True], + **kwargs, + ) -> AsyncIterator[str]: ... + + def log( + self, + *, + stdout: bool = False, + stderr: bool = False, + follow: bool = False, + **kwargs, + ) -> Any: if stdout is False and stderr is False: raise TypeError("Need one of stdout or stderr") params = {"stdout": stdout, "stderr": stderr, "follow": follow} params.update(kwargs) - cm = self.docker._query( f"containers/{self._id}/logs", method="GET", params=params ) - if follow: return self._logs_stream(cm) else: @@ -154,7 +212,6 @@ async def _logs_list(self, cm): try: inspect_info = await self.show() except DockerError: - cm.cancel() raise is_tty = inspect_info["Config"]["Tty"] @@ -181,20 +238,20 @@ async def put_archive(self, path, data): data = await parse_result(response) return data - async def show(self, **kwargs): + async def show(self, **kwargs) -> Dict[str, Any]: data = await self.docker._query_json( f"containers/{self._id}/json", method="GET", params=kwargs ) self._container = data return data - async def stop(self, **kwargs): + async def stop(self, **kwargs) -> None: async with self.docker._query( f"containers/{self._id}/stop", method="POST", params=kwargs ): pass - async def start(self, **kwargs): + async def start(self, **kwargs) -> None: async with self.docker._query( f"containers/{self._id}/start", method="POST", @@ -203,7 +260,7 @@ async def start(self, **kwargs): ): pass - async def restart(self, timeout=None): + async def restart(self, timeout=None) -> None: params = {} if timeout is not None: params["t"] = timeout @@ -214,13 +271,13 @@ async def restart(self, timeout=None): ): pass - async def kill(self, **kwargs): + async def kill(self, **kwargs) -> None: async with self.docker._query( f"containers/{self._id}/kill", method="POST", params=kwargs ): pass - async def wait(self, *, timeout=None, **kwargs): + async def wait(self, *, timeout=None, **kwargs) -> Dict[str, Any]: data = await self.docker._query_json( f"containers/{self._id}/wait", method="POST", @@ -229,13 +286,13 @@ async def wait(self, *, timeout=None, **kwargs): ) return data - async def delete(self, **kwargs): + async def delete(self, **kwargs) -> None: async with self.docker._query( f"containers/{self._id}", method="DELETE", params=kwargs ): pass - async def rename(self, newname): + async def rename(self, newname) -> None: async with self.docker._query( f"containers/{self._id}/rename", method="POST", @@ -244,7 +301,7 @@ async def rename(self, newname): ): pass - async def websocket(self, **params): + async def websocket(self, **params) -> ClientWebSocketResponse: if not params: params = {"stdin": True, "stdout": True, "stderr": True, "stream": True} path = f"containers/{self._id}/attach/ws" @@ -280,7 +337,7 @@ async def setup() -> Tuple[URL, Optional[bytes], bool]: return Stream(self.docker, setup, None) - async def port(self, private_port): + async def port(self, private_port: int | str) -> List[PortInfo] | None: if "NetworkSettings" not in self._container: await self.show() @@ -302,7 +359,25 @@ async def port(self, private_port): return h_ports - def stats(self, *, stream=True): + @overload + def stats( + self, + *, + stream: Literal[True] = True, + ) -> AsyncIterator[Dict[str, Any]]: ... + + @overload + async def stats( + self, + *, + stream: Literal[False], + ) -> List[Dict[str, Any]]: ... + + def stats( + self, + *, + stream: bool = True, + ) -> Any: cm = self.docker._query( f"containers/{self._id}/stats", params={"stream": "1" if stream else "0"}, @@ -333,7 +408,7 @@ async def exec( environment: Optional[Union[Mapping[str, str], Sequence[str]]] = None, workdir: Optional[str] = None, detach_keys: Optional[str] = None, - ): + ) -> Exec: if isinstance(cmd, str): cmd = shlex.split(cmd) if environment is None: @@ -418,8 +493,8 @@ async def unpause(self) -> None: async with self.docker._query(f"containers/{self._id}/unpause", method="POST"): pass - def __getitem__(self, key): + def __getitem__(self, key: str) -> Any: return self._container[key] - def __hasitem__(self, key): + def __hasitem__(self, key: str) -> bool: return key in self._container diff --git a/aiodocker/docker.py b/aiodocker/docker.py index a07499ad..d69968ae 100644 --- a/aiodocker/docker.py +++ b/aiodocker/docker.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import json import logging @@ -5,14 +7,25 @@ import re import ssl import sys +from contextlib import AbstractAsyncContextManager, asynccontextmanager from pathlib import Path from types import TracebackType -from typing import Any, Dict, Mapping, Optional, Type, Union +from typing import ( + Any, + AsyncIterator, + Dict, + Optional, + Tuple, + Type, + Union, +) import aiohttp from multidict import CIMultiDict from yarl import URL +from aiodocker.types import JSONObject + # Sub-API classes from .configs import DockerConfigs from .containers import DockerContainer, DockerContainers @@ -27,11 +40,11 @@ from .swarm import DockerSwarm from .system import DockerSystem from .tasks import DockerTasks -from .utils import _AsyncCM, httpize, parse_result +from .utils import httpize, parse_result from .volumes import DockerVolume, DockerVolumes -__all__ = ( +__all__: Tuple[str, ...] = ( "Docker", "DockerContainers", "DockerContainer", @@ -77,21 +90,22 @@ def __init__( if docker_host is None: docker_host = os.environ.get("DOCKER_HOST", None) if docker_host is None: - for sockpath in _sock_search_paths: - if sockpath.is_socket(): - docker_host = "unix://" + str(sockpath) - break - if docker_host is None and sys.platform == "win32": - try: - if Path("\\\\.\\pipe\\docker_engine").exists(): - docker_host = "npipe:////./pipe/docker_engine" - except OSError as ex: - if ex.winerror == 231: # type: ignore - # All pipe instances are busy - # but the pipe definitely exists - docker_host = "npipe:////./pipe/docker_engine" - else: - raise + if sys.platform == "win32": + try: + if Path("\\\\.\\pipe\\docker_engine").exists(): + docker_host = "npipe:////./pipe/docker_engine" + except OSError as ex: + if ex.winerror == 231: # type: ignore + # All pipe instances are busy + # but the pipe definitely exists + docker_host = "npipe:////./pipe/docker_engine" + else: + raise + else: + for sockpath in _sock_search_paths: + if sockpath.is_socket(): + docker_host = "unix://" + str(sockpath) + break self.docker_host = docker_host if api_version != "auto" and _rx_version.search(api_version) is None: @@ -153,7 +167,7 @@ def __init__( self.pull = self.images.pull self.push = self.images.push - async def __aenter__(self) -> "Docker": + async def __aenter__(self) -> Docker: return self async def __aexit__( @@ -193,37 +207,37 @@ def _canonicalize_url( async def _check_version(self) -> None: if self.api_version == "auto": ver = await self._query_json("version", versioned_api=False) - self.api_version = "v" + ver["ApiVersion"] + self.api_version = "v" + str(ver["ApiVersion"]) - def _query( + @asynccontextmanager + async def _query( self, path: Union[str, URL], method: str = "GET", *, - params: Optional[Mapping[str, Any]] = None, + params: Optional[JSONObject] = None, data: Optional[Any] = None, headers=None, timeout=None, chunked=None, read_until_eof: bool = True, versioned_api: bool = True, - ): + ) -> AsyncIterator[aiohttp.ClientResponse]: """ Get the response object by performing the HTTP request. - The caller is responsible to finalize the response object. + The caller is responsible to finalize the response object + via the async context manager protocol. """ - return _AsyncCM( - self._do_query( - path=path, - method=method, - params=params, - data=data, - headers=headers, - timeout=timeout, - chunked=chunked, - read_until_eof=read_until_eof, - versioned_api=versioned_api, - ) + yield await self._do_query( + path=path, + method=method, + params=params, + data=data, + headers=headers, + timeout=timeout, + chunked=chunked, + read_until_eof=read_until_eof, + versioned_api=versioned_api, ) async def _do_query( @@ -231,14 +245,14 @@ async def _do_query( path: Union[str, URL], method: str, *, - params: Optional[Mapping[str, Any]], + params: Optional[JSONObject], data: Any, headers, timeout, chunked, read_until_eof: bool, versioned_api: bool, - ): + ) -> aiohttp.ClientResponse: if versioned_api: await self._check_version() url = self._canonicalize_url(path, versioned_api=versioned_api) @@ -287,13 +301,13 @@ async def _query_json( path: Union[str, URL], method: str = "GET", *, - params: Optional[Mapping[str, Any]] = None, + params: Optional[JSONObject] = None, data: Optional[Any] = None, headers=None, timeout=None, read_until_eof: bool = True, versioned_api: bool = True, - ): + ) -> Any: """ A shorthand of _query() that treats the input as JSON. """ @@ -320,13 +334,13 @@ def _query_chunked_post( path: Union[str, URL], method: str = "POST", *, - params: Optional[Mapping[str, Any]] = None, + params: Optional[JSONObject] = None, data: Optional[Any] = None, headers=None, timeout=None, read_until_eof: bool = True, versioned_api: bool = True, - ): + ) -> AbstractAsyncContextManager[aiohttp.ClientResponse]: """ A shorthand for uploading data by chunks """ diff --git a/aiodocker/events.py b/aiodocker/events.py index 760ba8eb..3508d5d2 100644 --- a/aiodocker/events.py +++ b/aiodocker/events.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import datetime as dt import warnings diff --git a/aiodocker/execs.py b/aiodocker/execs.py index da66a2d2..c02c6cb1 100644 --- a/aiodocker/execs.py +++ b/aiodocker/execs.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json from typing import ( TYPE_CHECKING, @@ -29,7 +31,7 @@ class Exec: - def __init__(self, docker: "Docker", id: str, tty: Optional[bool]) -> None: + def __init__(self, docker: "Docker", id: str, tty: Optional[bool] = None) -> None: self.docker = docker self._id = id self._tty = tty @@ -40,7 +42,8 @@ def id(self) -> str: async def inspect(self) -> Dict[str, Any]: ret = await self.docker._query_json(f"exec/{self._id}/json") - self._tty = ret["ProcessConfig"]["tty"] + assert isinstance(ret["ProcessConfig"], dict) + self._tty = bool(ret["ProcessConfig"]["tty"]) return ret async def resize(self, *, h: Optional[int] = None, w: Optional[int] = None) -> None: @@ -52,8 +55,8 @@ async def resize(self, *, h: Optional[int] = None, w: Optional[int] = None) -> N if not dct: return url = URL(f"exec/{self._id}/resize").with_query(dct) - async with self.docker._query(url, method="POST") as resp: - resp + async with self.docker._query(url, method="POST"): + pass @overload def start( @@ -73,7 +76,12 @@ async def start( ) -> bytes: pass - def start(self, *, timeout=None, detach=False): + def start( + self, + *, + timeout: aiohttp.ClientTimeout | None = None, + detach: bool = False, + ) -> Any: """ Start this exec instance. Args: @@ -92,7 +100,10 @@ def start(self, *, timeout=None, detach=False): from stdout or 2 if from stderr. """ if detach: - return self._start_detached(timeout, self._tty) + return self._start_detached( + timeout, + self._tty if self._tty is not None else False, + ) else: async def setup() -> Tuple[URL, bytes, bool]: diff --git a/aiodocker/images.py b/aiodocker/images.py index 09f7a68f..488ecc8c 100644 --- a/aiodocker/images.py +++ b/aiodocker/images.py @@ -11,14 +11,13 @@ List, Literal, Mapping, - MutableMapping, Optional, Union, overload, ) from .jsonstream import json_stream_list, json_stream_stream -from .types import SupportsRead +from .types import JSONObject, SupportsRead from .utils import clean_map, compose_auth_header @@ -30,14 +29,14 @@ class DockerImages: def __init__(self, docker: Docker) -> None: self.docker = docker - async def list(self, **params) -> Mapping: + async def list(self, **params) -> List[Any]: """ List of images """ response = await self.docker._query_json("images/json", "GET", params=params) return response - async def inspect(self, name: str) -> Mapping: + async def inspect(self, name: str) -> Dict[str, Any]: """ Return low-level information about an image @@ -47,7 +46,7 @@ async def inspect(self, name: str) -> Mapping: response = await self.docker._query_json(f"images/{name}/json") return response - async def get(self, name: str) -> Mapping: + async def get(self, name: str) -> Dict[str, Any]: warnings.warn( """images.get is deprecated and will be removed in the next release, please use images.inspect instead.""", @@ -56,7 +55,7 @@ async def get(self, name: str) -> Mapping: ) return await self.inspect(name) - async def history(self, name: str) -> Mapping: + async def history(self, name: str) -> Dict[str, Any]: response = await self.docker._query_json(f"images/{name}/history") return response @@ -65,7 +64,7 @@ async def pull( self, from_image: str, *, - auth: Optional[Union[MutableMapping, str, bytes]] = None, + auth: Optional[Union[JSONObject, str, bytes]] = None, tag: Optional[str] = None, repo: Optional[str] = None, platform: Optional[str] = None, @@ -77,7 +76,7 @@ def pull( self, from_image: str, *, - auth: Optional[Union[MutableMapping, str, bytes]] = None, + auth: Optional[Union[JSONObject, str, bytes]] = None, tag: Optional[str] = None, repo: Optional[str] = None, platform: Optional[str] = None, @@ -88,7 +87,7 @@ def pull( self, from_image: str, *, - auth: Optional[Union[MutableMapping, str, bytes]] = None, + auth: Optional[Union[JSONObject, str, bytes]] = None, tag: Optional[str] = None, repo: Optional[str] = None, platform: Optional[str] = None, @@ -146,7 +145,7 @@ async def push( self, name: str, *, - auth: Optional[Union[MutableMapping, str, bytes]] = None, + auth: Optional[Union[JSONObject, str, bytes]] = None, tag: Optional[str] = None, stream: Literal[False] = False, ) -> Dict[str, Any]: ... @@ -156,7 +155,7 @@ def push( self, name: str, *, - auth: Optional[Union[MutableMapping, str, bytes]] = None, + auth: Optional[Union[JSONObject, str, bytes]] = None, tag: Optional[str] = None, stream: Literal[True], ) -> AsyncIterator[Dict[str, Any]]: ... @@ -165,7 +164,7 @@ def push( self, name: str, *, - auth: Optional[Union[MutableMapping, str, bytes]] = None, + auth: Optional[Union[JSONObject, str, bytes]] = None, tag: Optional[str] = None, stream: bool = False, ) -> Any: @@ -216,7 +215,7 @@ async def tag(self, name: str, repo: str, *, tag: Optional[str] = None) -> bool: async def delete( self, name: str, *, force: bool = False, noprune: bool = False - ) -> List: + ) -> List[Any]: """ Remove an image along with any untagged parent images that were referenced by that image @@ -231,7 +230,10 @@ async def delete( List of deleted images """ params = {"force": force, "noprune": noprune} - return await self.docker._query_json(f"images/{name}", "DELETE", params=params) + response = await self.docker._query_json( + f"images/{name}", "DELETE", params=params + ) + return response @staticmethod async def _stream(fileobj: SupportsRead[bytes]) -> AsyncIterator[bytes]: @@ -250,11 +252,11 @@ async def build( tag: Optional[str] = None, quiet: bool = False, nocache: bool = False, - buildargs: Optional[Mapping] = None, + buildargs: Optional[Mapping[str, str]] = None, pull: bool = False, rm: bool = True, forcerm: bool = False, - labels: Optional[Mapping] = None, + labels: Optional[Mapping[str, str]] = None, platform: Optional[str] = None, stream: Literal[False] = False, encoding: Optional[str] = None, @@ -271,11 +273,11 @@ def build( tag: Optional[str] = None, quiet: bool = False, nocache: bool = False, - buildargs: Optional[Mapping] = None, + buildargs: Optional[Mapping[str, str]] = None, pull: bool = False, rm: bool = True, forcerm: bool = False, - labels: Optional[Mapping] = None, + labels: Optional[Mapping[str, str]] = None, platform: Optional[str] = None, stream: Literal[True], encoding: Optional[str] = None, @@ -291,11 +293,11 @@ def build( tag: Optional[str] = None, quiet: bool = False, nocache: bool = False, - buildargs: Optional[Mapping] = None, + buildargs: Optional[Mapping[str, str]] = None, pull: bool = False, rm: bool = True, forcerm: bool = False, - labels: Optional[Mapping] = None, + labels: Optional[Mapping[str, str]] = None, platform: Optional[str] = None, stream: bool = False, encoding: Optional[str] = None, diff --git a/aiodocker/jsonstream.py b/aiodocker/jsonstream.py index 0ddaa8a7..63dc8685 100644 --- a/aiodocker/jsonstream.py +++ b/aiodocker/jsonstream.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import logging import types diff --git a/aiodocker/logs.py b/aiodocker/logs.py index e26ce706..81e318c1 100644 --- a/aiodocker/logs.py +++ b/aiodocker/logs.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import warnings from collections import ChainMap from typing import TYPE_CHECKING, Any @@ -36,15 +38,16 @@ async def run(self, **params: Any) -> None: default_params = {"stdout": True, "stderr": True} params2 = ChainMap(forced_params, params, default_params) try: - self.response = await self.docker._query( + async with self.docker._query( f"containers/{self.container._id}/logs", params=params2 - ) - assert self.response is not None - while True: - msg = await self.response.content.readline() - if not msg: - break - await self.channel.publish(msg) + ) as resp: + self.response = resp + assert self.response is not None + while True: + msg = await self.response.content.readline() + if not msg: + break + await self.channel.publish(msg) except (aiohttp.ClientConnectionError, aiohttp.ServerDisconnectedError): pass finally: diff --git a/aiodocker/multiplexed.py b/aiodocker/multiplexed.py index 60337414..991b4f46 100644 --- a/aiodocker/multiplexed.py +++ b/aiodocker/multiplexed.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import struct import types diff --git a/aiodocker/networks.py b/aiodocker/networks.py index 7ea5a982..e6e6f46b 100644 --- a/aiodocker/networks.py +++ b/aiodocker/networks.py @@ -1,5 +1,7 @@ +from __future__ import annotations + import json -from typing import Any, Dict, List, Mapping, Optional +from typing import Any, Dict, List, Mapping, Optional, Sequence from .utils import clean_filters @@ -8,7 +10,11 @@ class DockerNetworks: def __init__(self, docker): self.docker = docker - async def list(self, *, filters: Optional[Mapping] = None) -> List[Dict[str, Any]]: + async def list( + self, + *, + filters: Optional[Mapping[str, str | Sequence[str]]] = None, + ) -> List[Dict[str, Any]]: """ Return a list of networks @@ -29,14 +35,14 @@ async def list(self, *, filters: Optional[Mapping] = None) -> List[Dict[str, Any data = await self.docker._query_json("networks", params=params) return data - async def create(self, config: Dict[str, Any]) -> "DockerNetwork": + async def create(self, config: Dict[str, Any]) -> DockerNetwork: bconfig = json.dumps(config, sort_keys=True).encode("utf-8") data = await self.docker._query_json( "networks/create", method="POST", data=bconfig ) return DockerNetwork(self.docker, data["Id"]) - async def get(self, net_specs: str) -> "DockerNetwork": + async def get(self, net_specs: str) -> DockerNetwork: data = await self.docker._query_json(f"networks/{net_specs}", method="GET") return DockerNetwork(self.docker, data["Id"]) diff --git a/aiodocker/nodes.py b/aiodocker/nodes.py index 27e4bbba..549d48b5 100644 --- a/aiodocker/nodes.py +++ b/aiodocker/nodes.py @@ -1,4 +1,6 @@ -from typing import Any, List, Mapping, Optional +from __future__ import annotations + +from typing import Any, List, Mapping, Optional, Sequence from .utils import clean_filters @@ -7,7 +9,11 @@ class DockerSwarmNodes: def __init__(self, docker): self.docker = docker - async def list(self, *, filters: Optional[Mapping] = None) -> List[Mapping]: + async def list( + self, + *, + filters: Optional[Mapping[str, str | Sequence[str]]] = None, + ) -> List[Mapping]: """ Return a list of swarm's nodes. diff --git a/aiodocker/secrets.py b/aiodocker/secrets.py index 809d15c3..bf44b49e 100644 --- a/aiodocker/secrets.py +++ b/aiodocker/secrets.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import json from base64 import b64encode -from typing import Any, List, Mapping, Optional +from typing import Any, List, Mapping, Optional, Sequence from .utils import clean_filters, clean_map @@ -9,7 +11,11 @@ class DockerSecrets: def __init__(self, docker): self.docker = docker - async def list(self, *, filters: Optional[Mapping] = None) -> List[Mapping]: + async def list( + self, + *, + filters: Optional[Mapping[str, str | Sequence[str]]] = None, + ) -> List[Mapping]: """ Return a list of secrets diff --git a/aiodocker/services.py b/aiodocker/services.py index afc276d9..b8786cbf 100644 --- a/aiodocker/services.py +++ b/aiodocker/services.py @@ -1,5 +1,16 @@ +from __future__ import annotations + import json -from typing import Any, AsyncIterator, List, Mapping, MutableMapping, Optional, Union +from typing import ( + Any, + AsyncIterator, + List, + Mapping, + MutableMapping, + Optional, + Sequence, + Union, +) from .multiplexed import multiplexed_result_list, multiplexed_result_stream from .utils import ( @@ -15,7 +26,11 @@ class DockerServices: def __init__(self, docker): self.docker = docker - async def list(self, *, filters: Optional[Mapping] = None) -> List[Mapping]: + async def list( + self, + *, + filters: Optional[Mapping[str, str | Sequence[str]]] = None, + ) -> List[Mapping]: """ Return a list of services diff --git a/aiodocker/stream.py b/aiodocker/stream.py index 7e239865..50e82126 100644 --- a/aiodocker/stream.py +++ b/aiodocker/stream.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import socket import struct import warnings @@ -20,6 +22,8 @@ class Message(NamedTuple): class Stream: + _resp: aiohttp.ClientResponse | None + def __init__( self, docker: "Docker", @@ -121,7 +125,7 @@ async def close(self) -> None: transport.write_eof() self._resp.close() - async def __aenter__(self) -> "Stream": + async def __aenter__(self) -> Stream: await self._init() return self @@ -130,8 +134,9 @@ async def __aexit__( exc_typ: Type[BaseException], exc_val: BaseException, exc_tb: TracebackType, - ) -> None: + ) -> Optional[bool]: await self.close() + return None def __del__(self, _warnings=warnings) -> None: if self._resp is not None: diff --git a/aiodocker/swarm.py b/aiodocker/swarm.py index d73f0d0c..1863f9be 100644 --- a/aiodocker/swarm.py +++ b/aiodocker/swarm.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Iterable, Mapping, Optional from .utils import clean_map diff --git a/aiodocker/system.py b/aiodocker/system.py index 78c66580..51b56a9e 100644 --- a/aiodocker/system.py +++ b/aiodocker/system.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Mapping diff --git a/aiodocker/tasks.py b/aiodocker/tasks.py index f6eb1a5f..721e7dba 100644 --- a/aiodocker/tasks.py +++ b/aiodocker/tasks.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Any, List, Mapping, Optional from .utils import clean_filters diff --git a/aiodocker/types.py b/aiodocker/types.py index aa97a169..94738210 100644 --- a/aiodocker/types.py +++ b/aiodocker/types.py @@ -1,11 +1,40 @@ +from __future__ import annotations + from typing import ( + Mapping, Protocol, + Sequence, + TypedDict, TypeVar, + Union, ) +from typing_extensions import TypeAlias + _T_co = TypeVar("_T_co", covariant=True) class SupportsRead(Protocol[_T_co]): def read(self, length: int = ..., /) -> _T_co: ... + + +# NOTE: Currently these types are used to annotate arguments only. +# When returning values, we need extra type-narrowing for individual fields, +# so it is better to define per-API typed DTOs. +JSONValue: TypeAlias = Union[ + str, + int, + float, + bool, + None, + Mapping[str, "JSONValue"], + Sequence["JSONValue"], +] +JSONObject: TypeAlias = Mapping[str, "JSONValue"] +JSONList: TypeAlias = Sequence["JSONValue"] + + +class PortInfo(TypedDict): + HostIp: str + HostPort: str diff --git a/aiodocker/utils.py b/aiodocker/utils.py index 4dc89492..77ca2d26 100644 --- a/aiodocker/utils.py +++ b/aiodocker/utils.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import base64 import codecs import json @@ -7,14 +9,18 @@ from typing import ( IO, Any, + Dict, Iterable, Mapping, - MutableMapping, Optional, + Sequence, Tuple, Union, + cast, ) +from .types import JSONObject + async def parse_result(response, response_type=None, *, encoding="utf-8"): """ @@ -120,7 +126,7 @@ def human_bool(s) -> bool: return bool(s) -def httpize(d: Optional[Mapping]) -> Optional[Mapping[str, Any]]: +def httpize(d: Optional[JSONObject]) -> Optional[Mapping[str, str]]: if d is None: return None converted = {} @@ -185,13 +191,15 @@ def format_env(key, value: Union[None, bytes, str]) -> str: return f"{key}={value}" -def clean_networks(networks: Optional[Iterable[str]] = None) -> Optional[Iterable[str]]: +def clean_networks( + networks: Optional[Iterable[str]] = None, +) -> Optional[Sequence[Dict[str, Any]]]: """ Cleans the values inside `networks` Returns a new list """ if not networks: - return networks + return [] if not isinstance(networks, list): raise TypeError("networks parameter must be a list.") @@ -203,19 +211,22 @@ def clean_networks(networks: Optional[Iterable[str]] = None) -> Optional[Iterabl return result -def clean_filters(filters: Optional[Mapping] = None) -> str: +def clean_filters(filters: Optional[Mapping[str, Any] | Sequence[str]] = None) -> str: """ - Checks the values inside `filters` - https://docs.docker.com/engine/api/v1.29/#operation/ServiceList - Returns a new dictionary in the format `map[string][]string` jsonized + Ensures that the values inside `filters` are lists of string values, by + wrapping scalar values as a single-item lists. Returns the result as the + jsonized form of `map[string][]string` as described in + https://docs.docker.com/engine/api/v1.29/#operation/ServiceList . """ - - if filters and isinstance(filters, dict): + if filters is None: + return "{}" + if isinstance(filters, dict): for k, v in filters.items(): if not isinstance(v, list): v = [v] filters[k] = v - + else: + raise TypeError("filters must be a mapping") return json.dumps(filters) @@ -246,7 +257,7 @@ def mktar_from_dockerfile(fileobj: Union[BytesIO, IO[bytes]]) -> IO[bytes]: def compose_auth_header( - auth: Union[MutableMapping, str, bytes], registry_addr: Optional[str] = None + auth: Union[JSONObject, str, bytes], registry_addr: Optional[str] = None ) -> str: """ Validate and compose base64-encoded authentication header @@ -261,15 +272,16 @@ def compose_auth_header( A base64-encoded X-Registry-Auth header value """ if isinstance(auth, Mapping): + auth2 = dict(auth) # Validate the JSON format only. if "identitytoken" in auth: pass elif "auth" in auth: - return compose_auth_header(auth["auth"], registry_addr) + return compose_auth_header(cast(JSONObject, auth["auth"]), registry_addr) else: if registry_addr: - auth["serveraddress"] = registry_addr - auth_json = json.dumps(auth).encode("utf-8") + auth2["serveraddress"] = registry_addr + auth_json = json.dumps(auth2).encode("utf-8") elif isinstance(auth, (str, bytes)): # Parse simple "username:password"-formatted strings # and attach the server address specified. @@ -286,24 +298,4 @@ def compose_auth_header( auth_json = json.dumps(config).encode("utf-8") else: raise TypeError("auth must be base64 encoded string/bytes or a dictionary") - auth = base64.b64encode(auth_json).decode("ascii") - return auth - - -class _AsyncCM: - __slots__ = ("_coro", "_resp") - - def __init__(self, coro): - self._coro = coro - self._resp = None - - async def __aenter__(self): - resp = await self._coro - self._resp = resp - return await resp.__aenter__() - - async def __aexit__(self, exc_type, exc_val, exc_tb): - return await self._resp.__aexit__(exc_type, exc_val, exc_tb) - - def cancel(self): - self._coro.close() + return base64.b64encode(auth_json).decode("ascii") diff --git a/aiodocker/volumes.py b/aiodocker/volumes.py index 4ba4d8c7..5b8243b6 100644 --- a/aiodocker/volumes.py +++ b/aiodocker/volumes.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json from .utils import clean_filters diff --git a/pyproject.toml b/pyproject.toml index 659535da..d39c1fd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,7 @@ preview = true # enable the black's preview style [tool.mypy] files = ["aiodocker", "examples", "tests"] -ignore-missing-imports = true +ignore_missing_imports = true [tool.pytest.ini_options] addopts = "--cov-branch --cov-report xml --cov=aiodocker -v" diff --git a/tests/conftest.py b/tests/conftest.py index 909a1415..a076abb7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,23 @@ +from __future__ import annotations + import asyncio import os import sys import traceback import uuid -from typing import Any, Dict +from typing import ( + Any, + AsyncIterator, + Awaitable, + Callable, + Dict, +) import pytest from packaging.version import parse as parse_version +from typing_extensions import TypeAlias +from aiodocker.containers import DockerContainer from aiodocker.docker import Docker from aiodocker.exceptions import DockerError @@ -23,18 +33,6 @@ } -if sys.platform == "win32": - if sys.version_info < (3, 7): - # Python 3.6 has no WindowsProactorEventLoopPolicy class - from asyncio import events - - class WindowsProactorEventLoopPolicy(events.BaseDefaultEventLoopPolicy): - _loop_factory = asyncio.ProactorEventLoop - - else: - WindowsProactorEventLoopPolicy = asyncio.WindowsProactorEventLoopPolicy - - def _random_name(): return "aiodocker-" + uuid.uuid4().hex[:7] @@ -66,26 +64,25 @@ async def random_name(): @pytest.fixture(scope="session") def image_name() -> str: if sys.platform == "win32": - return "python:latest" - else: - return "python:alpine" + return "python:3.12" + return "python:3.12-alpine" @pytest.fixture(scope="session") async def testing_images(image_name: str) -> None: # Prepare a small Linux image shared by most test cases. docker = Docker() - required_images = [image_name] - if image_name != "python:latest": - required_images.append("python:latest") - for img in required_images: - try: - await docker.images.inspect(img) - except DockerError as e: - assert e.status == 404 - print(f'Pulling "{img}" for the testing session...') - await docker.pull(img) - await docker.close() + try: + required_images = [image_name] + for img in required_images: + try: + await docker.images.inspect(img) + except DockerError as e: + assert e.status == 404 + print(f'Pulling "{img}" for the testing session...') + await docker.pull(img) + finally: + await docker.close() @pytest.fixture @@ -101,16 +98,21 @@ async def docker(testing_images): raise RuntimeError(f"Cannot find docker API version for {version}") docker = Docker(**kwargs) - yield docker - await docker.close() + print(asyncio.get_running_loop()) + try: + yield docker + finally: + await docker.close() @pytest.fixture -async def requires_api_version(docker): +async def requires_api_version( + docker: Docker, +) -> AsyncIterator[Callable[[str, str], None]]: # Update version info from auto to the real value await docker.version() - def check(version, reason): + def check(version: str, reason: str) -> None: if parse_version(docker.api_version[1:]) < parse_version(version[1:]): pytest.skip(reason) @@ -122,28 +124,47 @@ async def swarm(docker): if sys.platform == "win32": pytest.skip("swarm commands dont work on Windows") assert await docker.swarm.init() - yield docker - assert await docker.swarm.leave(force=True) + try: + yield docker + finally: + assert await docker.swarm.leave(force=True) -@pytest.fixture -async def make_container(docker): - container = None +AsyncContainerFactory: TypeAlias = Callable[ + [Dict[str, Any], str], Awaitable[DockerContainer] +] - async def _spawn(config: Dict[str, Any], name=None): + +@pytest.fixture +async def make_container( + docker: Docker, +) -> AsyncIterator[AsyncContainerFactory]: + container: DockerContainer | None = None + + async def _spawn( + config: Dict[str, Any], + name: str, + ) -> DockerContainer: nonlocal container container = await docker.containers.create_or_replace(config=config, name=name) + assert container is not None await container.start() return container - yield _spawn - - if container is not None: - await container.delete(force=True) + try: + yield _spawn + finally: + if container is not None: + assert isinstance(container, DockerContainer) + await container.delete(force=True) @pytest.fixture -async def shell_container(docker, make_container, image_name): +async def shell_container( + docker: Docker, + make_container, + image_name: str, +) -> AsyncContainerFactory: config = { "Cmd": ["python"], "Image": image_name, diff --git a/tests/test_containers.py b/tests/test_containers.py index 7ff5f195..551c36ea 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -1,20 +1,21 @@ import asyncio -import os import sys import pytest +from aiodocker.containers import DockerContainer +from aiodocker.docker import Docker from aiodocker.exceptions import DockerContainerError, DockerError -async def _validate_hello(container): +async def _validate_hello(container: DockerContainer) -> None: try: await container.start() response = await container.wait() assert response["StatusCode"] == 0 await asyncio.sleep(5) # wait for output in case of slow test container logs = await container.log(stdout=True) - assert "hello" + os.linesep in logs + assert "hello\n" in logs with pytest.raises(TypeError): await container.log() @@ -23,7 +24,7 @@ async def _validate_hello(container): @pytest.mark.asyncio -async def test_run_existing_container(docker, image_name): +async def test_run_existing_container(docker: Docker, image_name: str) -> None: await docker.pull(image_name) container = await docker.containers.run( config={ @@ -37,7 +38,9 @@ async def test_run_existing_container(docker, image_name): @pytest.mark.asyncio -async def test_run_container_with_missing_image(docker, image_name): +async def test_run_container_with_missing_image( + docker: Docker, image_name: str +) -> None: try: await docker.images.delete(image_name) except DockerError as e: @@ -61,7 +64,7 @@ async def test_run_container_with_missing_image(docker, image_name): @pytest.mark.asyncio -async def test_run_failing_start_container(docker, image_name): +async def test_run_failing_start_container(docker: Docker, image_name: str) -> None: try: await docker.images.delete(image_name) except DockerError as e: @@ -91,7 +94,7 @@ async def test_run_failing_start_container(docker, image_name): @pytest.mark.asyncio -async def test_restart(docker, image_name): +async def test_restart(docker: Docker, image_name: str) -> None: # sleep for 10 min to emulate hanging container container = await docker.containers.run( config={ @@ -117,7 +120,7 @@ async def test_restart(docker, image_name): @pytest.mark.asyncio -async def test_container_stats_list(docker, image_name): +async def test_container_stats_list(docker: Docker, image_name: str) -> None: container = await docker.containers.run( config={ "Cmd": ["-c", "print('hello')"], @@ -137,7 +140,7 @@ async def test_container_stats_list(docker, image_name): @pytest.mark.asyncio -async def test_container_stats_stream(docker, image_name): +async def test_container_stats_stream(docker: Docker, image_name: str) -> None: container = await docker.containers.run( config={ "Cmd": ["-c", "print('hello')"], @@ -161,7 +164,7 @@ async def test_container_stats_stream(docker, image_name): @pytest.mark.asyncio -async def test_resize(shell_container): +async def test_resize(shell_container: DockerContainer) -> None: await shell_container.resize(w=120, h=10) @@ -169,7 +172,9 @@ async def test_resize(shell_container): sys.platform == "win32", reason="Commit unpaused containers doesn't work on Windows" ) @pytest.mark.asyncio -async def test_commit(docker, image_name, shell_container): +async def test_commit( + docker: Docker, image_name: str, shell_container: DockerContainer +) -> None: """ "Container" key was removed in v1.45. "ContainerConfig" is not present, although this information is now present in "Config" @@ -192,7 +197,9 @@ async def test_commit(docker, image_name, shell_container): sys.platform == "win32", reason="Commit unpaused containers doesn't work on Windows" ) @pytest.mark.asyncio -async def test_commit_with_changes(docker, image_name, shell_container): +async def test_commit_with_changes( + docker: Docker, image_name: str, shell_container: DockerContainer +) -> None: ret = await shell_container.commit(changes=["EXPOSE 8000", 'CMD ["py"]']) img_id = ret["Id"] img = await docker.images.inspect(img_id) @@ -203,7 +210,7 @@ async def test_commit_with_changes(docker, image_name, shell_container): @pytest.mark.skipif(sys.platform == "win32", reason="Pause doesn't work on Windows") @pytest.mark.asyncio -async def test_pause_unpause(shell_container): +async def test_pause_unpause(shell_container: DockerContainer) -> None: await shell_container.pause() container_info = await shell_container.show() assert "State" in container_info @@ -228,7 +235,57 @@ async def test_pause_unpause(shell_container): @pytest.mark.asyncio -async def test_cancel_log(docker): +async def test_capture_log_oneshot(docker: Docker, image_name: str) -> None: + container = await docker.containers.run( + config={ + "Cmd": [ + "python", + "-c", + "import time;time.sleep(0.2);print(1);time.sleep(0.2);print(2)", + ], + "Image": image_name, + } + ) + try: + await asyncio.sleep(1) + log = await container.log( + stdout=True, + stderr=True, + follow=False, + ) + assert ["1\n", "2\n"] == log + finally: + await container.delete(force=True) + + +@pytest.mark.asyncio +async def test_capture_log_stream(docker: Docker, image_name: str) -> None: + container = await docker.containers.run( + config={ + "Cmd": [ + "python", + "-c", + "import time;time.sleep(0.2);print(1);time.sleep(0.2);print(2)", + ], + "Image": image_name, + } + ) + try: + log_gen = container.log( + stdout=True, + stderr=True, + follow=True, + ) + log = [] + async for line in log_gen: + log.append(line) + assert ["1\n", "2\n"] == log + finally: + await container.delete(force=True) + + +@pytest.mark.asyncio +async def test_cancel_log(docker: Docker) -> None: container = docker.containers.container("invalid_container_id") with pytest.raises(DockerError): diff --git a/tests/test_events.py b/tests/test_events.py index c84f60d6..310f1a7f 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -1,10 +1,14 @@ +from __future__ import annotations + import asyncio import pytest +from aiodocker.docker import Docker + @pytest.mark.asyncio -async def test_events_default_task(docker): +async def test_events_default_task(docker: Docker) -> None: docker.events.subscribe() assert docker.events.task is not None await docker.close() @@ -13,7 +17,7 @@ async def test_events_default_task(docker): @pytest.mark.asyncio -async def test_events_provided_task(docker): +async def test_events_provided_task(docker: Docker) -> None: task = asyncio.ensure_future(docker.events.run()) docker.events.subscribe(create_task=False) assert docker.events.task is None @@ -25,7 +29,7 @@ async def test_events_provided_task(docker): @pytest.mark.asyncio -async def test_events_no_task(docker): +async def test_events_no_task(docker: Docker) -> None: assert docker.events.task is None await docker.close() assert docker.events.json_stream is None diff --git a/tests/test_execs.py b/tests/test_execs.py index ce167da9..0c515c3a 100644 --- a/tests/test_execs.py +++ b/tests/test_execs.py @@ -5,13 +5,15 @@ import pytest from async_timeout import timeout +from aiodocker.containers import DockerContainer +from aiodocker.docker import Docker from aiodocker.execs import Stream async def expect_prompt(stream: Stream) -> bytes: + inp = [] + ret: List[bytes] = [] try: - inp = [] - ret: List[bytes] = [] async with timeout(3): while not ret or not ret[-1].endswith(b">>>"): msg = await stream.read_out() @@ -23,14 +25,14 @@ async def expect_prompt(stream: Stream) -> bytes: lines = [line if b"\x1b[K" not in line else b"" for line in lines] lines = [line for line in lines if line] ret.extend(lines) - return b"\n".join(ret) except asyncio.TimeoutError: - raise AssertionError(f"[Timeout] {ret} {inp}") + pytest.fail(f"[Timeout] {ret} {inp}") + return b"\n".join(ret) @pytest.mark.asyncio @pytest.mark.parametrize("stderr", [True, False], ids=lambda x: f"stderr={x}") -async def test_exec_attached(shell_container, stderr): +async def test_exec_attached(shell_container: DockerContainer, stderr: bool) -> None: if stderr: cmd = ["python", "-c", "import sys;print('Hello', file=sys.stderr)"] else: @@ -45,6 +47,7 @@ async def test_exec_attached(shell_container, stderr): ) async with execute.start(detach=False) as stream: msg = await stream.read_out() + assert msg is not None assert msg.stream == 2 if stderr else 1 assert msg.data.strip() == b"Hello" @@ -54,7 +57,7 @@ async def test_exec_attached(shell_container, stderr): sys.platform == "win32", reason="TTY session in Windows generates too complex ANSI escape sequences", ) -async def test_exec_attached_tty(shell_container): +async def test_exec_attached_tty(shell_container: DockerContainer) -> None: execute = await shell_container.exec( stdout=True, stderr=True, @@ -85,7 +88,9 @@ async def test_exec_attached_tty(shell_container): @pytest.mark.asyncio @pytest.mark.parametrize("tty", [True, False], ids=lambda x: f"tty={x}") @pytest.mark.parametrize("stderr", [True, False], ids=lambda x: f"stderr={x}") -async def test_exec_detached(shell_container, tty, stderr): +async def test_exec_detached( + shell_container: DockerContainer, tty: bool, stderr: bool +) -> None: if stderr: cmd = ["python", "-c", "import sys;print('Hello', file=sys.stderr)"] else: @@ -102,7 +107,7 @@ async def test_exec_detached(shell_container, tty, stderr): @pytest.mark.asyncio -async def test_exec_resize(shell_container): +async def test_exec_resize(shell_container: DockerContainer) -> None: execute = await shell_container.exec( stdout=True, stderr=True, @@ -115,7 +120,7 @@ async def test_exec_resize(shell_container): @pytest.mark.asyncio -async def test_exec_inspect(shell_container): +async def test_exec_inspect(shell_container: DockerContainer) -> None: execute = await shell_container.exec( stdout=True, stderr=True, @@ -141,7 +146,9 @@ async def test_exec_inspect(shell_container): @pytest.mark.asyncio -async def test_exec_restore_tty_attached(docker, shell_container): +async def test_exec_restore_tty_attached( + docker: Docker, shell_container: DockerContainer +) -> None: exec1 = await shell_container.exec( stdout=True, stderr=True, @@ -151,13 +158,16 @@ async def test_exec_restore_tty_attached(docker, shell_container): ) exec2 = docker.containers.exec(exec1.id) - async with exec2.start() as stream: + stream = exec2.start() + assert isinstance(stream, Stream) + async with stream: assert exec2._tty - stream @pytest.mark.asyncio -async def test_exec_restore_tty_detached(docker, shell_container): +async def test_exec_restore_tty_detached( + docker: Docker, shell_container: DockerContainer +) -> None: exec1 = await shell_container.exec( stdout=True, stderr=True, diff --git a/tests/test_images.py b/tests/test_images.py index 7f29f5c5..e67c9ff9 100644 --- a/tests/test_images.py +++ b/tests/test_images.py @@ -1,21 +1,29 @@ +from __future__ import annotations + import os import sys from io import BytesIO +from typing import AsyncIterator, Callable import pytest from aiodocker import utils +from aiodocker.docker import Docker from aiodocker.exceptions import DockerError -def skip_windows(): +def skip_windows() -> None: if sys.platform == "win32": # replaced xfail with skip for sake of tests speed pytest.skip("image operation fails on Windows") @pytest.mark.asyncio -async def test_build_from_remote_file(docker, random_name, requires_api_version): +async def test_build_from_remote_file( + docker: Docker, + random_name: Callable[[], str], + requires_api_version: Callable[[str, str], None], +) -> None: skip_windows() requires_api_version( @@ -30,15 +38,16 @@ async def test_build_from_remote_file(docker, random_name, requires_api_version) ) tag = f"{random_name()}:1.0" - params = {"tag": tag, "remote": remote} - await docker.images.build(**params) + await docker.images.build(tag=tag, remote=remote, stream=False) image = await docker.images.inspect(tag) assert image @pytest.mark.asyncio -async def test_build_from_remote_tar(docker, random_name): +async def test_build_from_remote_tar( + docker: Docker, random_name: Callable[[], str] +) -> None: skip_windows() remote = ( @@ -47,27 +56,28 @@ async def test_build_from_remote_tar(docker, random_name): ) tag = f"{random_name()}:1.0" - params = {"tag": tag, "remote": remote} - await docker.images.build(**params) + await docker.images.build(tag=tag, remote=remote, stream=False) image = await docker.images.inspect(tag) assert image @pytest.mark.asyncio -async def test_history(docker, image_name): +async def test_history(docker: Docker, image_name: str) -> None: history = await docker.images.history(name=image_name) assert history @pytest.mark.asyncio -async def test_list_images(docker, image_name): +async def test_list_images(docker: Docker, image_name: str) -> None: images = await docker.images.list(filter=image_name) assert len(images) >= 1 @pytest.mark.asyncio -async def test_tag_image(docker, random_name, image_name): +async def test_tag_image( + docker: Docker, random_name: Callable[[], str], image_name: str +) -> None: repository = random_name() await docker.images.tag(name=image_name, repo=repository, tag="1.0") await docker.images.tag(name=image_name, repo=repository, tag="2.0") @@ -76,14 +86,14 @@ async def test_tag_image(docker, random_name, image_name): @pytest.mark.asyncio -async def test_push_image(docker, image_name): +async def test_push_image(docker: Docker, image_name: str) -> None: repository = "localhost:5000/image" await docker.images.tag(name=image_name, repo=repository) await docker.images.push(name=repository) @pytest.mark.asyncio -async def test_push_image_stream(docker, image_name): +async def test_push_image_stream(docker: Docker, image_name: str) -> None: repository = "localhost:5000/image" await docker.images.tag(name=image_name, repo=repository) async for item in docker.images.push(name=repository, stream=True): @@ -91,7 +101,7 @@ async def test_push_image_stream(docker, image_name): @pytest.mark.asyncio -async def test_delete_image(docker, image_name): +async def test_delete_image(docker: Docker, image_name: str) -> None: repository = "localhost:5000/image" await docker.images.tag(name=image_name, repo=repository) assert await docker.images.inspect(repository) @@ -99,7 +109,9 @@ async def test_delete_image(docker, image_name): @pytest.mark.asyncio -async def test_not_existing_image(docker, random_name): +async def test_not_existing_image( + docker: Docker, random_name: Callable[[], str] +) -> None: name = f"{random_name()}:latest" with pytest.raises(DockerError) as excinfo: await docker.images.inspect(name=name) @@ -107,7 +119,7 @@ async def test_not_existing_image(docker, random_name): @pytest.mark.asyncio -async def test_pull_image(docker, image_name): +async def test_pull_image(docker: Docker, image_name: str) -> None: image = await docker.images.inspect(name=image_name) assert image @@ -117,7 +129,7 @@ async def test_pull_image(docker, image_name): @pytest.mark.asyncio -async def test_pull_image_stream(docker, image_name): +async def test_pull_image_stream(docker: Docker, image_name: str) -> None: image = await docker.images.inspect(name=image_name) assert image @@ -126,7 +138,9 @@ async def test_pull_image_stream(docker, image_name): @pytest.mark.asyncio -async def test_build_from_tar(docker, random_name, image_name): +async def test_build_from_tar( + docker: Docker, random_name: Callable[[], str], image_name: str +) -> None: name = f"{random_name()}:latest" dockerfile = f""" # Shared Volume @@ -141,7 +155,9 @@ async def test_build_from_tar(docker, random_name, image_name): @pytest.mark.asyncio -async def test_build_from_tar_stream(docker, random_name, image_name): +async def test_build_from_tar_stream( + docker: Docker, random_name: Callable[[], str], image_name: str +) -> None: name = f"{random_name()}:latest" dockerfile = f""" # Shared Volume @@ -159,7 +175,7 @@ async def test_build_from_tar_stream(docker, random_name, image_name): @pytest.mark.asyncio -async def test_export_image(docker, image_name): +async def test_export_image(docker: Docker, image_name: str) -> None: name = image_name async with docker.images.export_image(name=name) as exported_image: assert exported_image @@ -168,10 +184,10 @@ async def test_export_image(docker, image_name): @pytest.mark.asyncio -async def test_import_image(docker): +async def test_import_image(docker: Docker) -> None: skip_windows() - async def file_sender(file_name=None): + async def file_sender(file_name: str) -> AsyncIterator[bytes]: with open(file_name, "rb") as f: chunk = f.read(2**16) while chunk: @@ -180,7 +196,8 @@ async def file_sender(file_name=None): dir = os.path.dirname(__file__) hello_world = os.path.join(dir, "docker/google-containers-pause.tar") - response = await docker.images.import_image(data=file_sender(file_name=hello_world)) + # FIXME: improve annotation for chunked data generator + response = await docker.images.import_image(data=file_sender(hello_world)) # type: ignore for item in response: assert "error" not in item @@ -193,7 +210,7 @@ async def file_sender(file_name=None): @pytest.mark.asyncio -async def test_pups_image_auth(docker, image_name): +async def test_pups_image_auth(docker: Docker, image_name: str) -> None: skip_windows() name = image_name @@ -224,7 +241,7 @@ async def test_pups_image_auth(docker, image_name): @pytest.mark.asyncio -async def test_build_image_invalid_platform(docker, image_name): +async def test_build_image_invalid_platform(docker: Docker, image_name: str) -> None: dockerfile = f""" FROM {image_name} """ @@ -244,7 +261,7 @@ async def test_build_image_invalid_platform(docker, image_name): @pytest.mark.asyncio -async def test_pull_image_invalid_platform(docker, image_name): +async def test_pull_image_invalid_platform(docker: Docker, image_name: str) -> None: with pytest.raises(DockerError) as excinfo: await docker.images.pull("hello-world", platform="foo") diff --git a/tests/test_integration.py b/tests/test_integration.py index fdbd703a..6a4683a1 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -7,13 +7,14 @@ import sys import tarfile import time -from typing import List +from typing import Any, Dict, List import aiohttp import pytest from async_timeout import timeout import aiodocker +from aiodocker.containers import DockerContainer from aiodocker.docker import Docker from aiodocker.execs import Stream @@ -38,14 +39,14 @@ async def expect_prompt(stream: Stream) -> bytes: raise AssertionError(f"[Timeout] {ret} {inp}") -def skip_windows(): +def skip_windows() -> None: if sys.platform == "win32": # replaced xfail with skip for sake of tests speed pytest.skip("image operation fails on Windows") @pytest.mark.asyncio -async def test_autodetect_host(monkeypatch): +async def test_autodetect_host(monkeypatch: pytest.MonkeyPatch) -> None: docker = Docker() if "DOCKER_HOST" in os.environ: if ( @@ -63,34 +64,41 @@ async def test_autodetect_host(monkeypatch): @pytest.mark.asyncio -async def test_ssl_context(monkeypatch): +async def test_ssl_context(monkeypatch: pytest.MonkeyPatch) -> None: cert_dir = pathlib.Path(__file__).parent / "certs" monkeypatch.setenv("DOCKER_HOST", "tcp://127.0.0.1:3456") monkeypatch.setenv("DOCKER_TLS_VERIFY", "1") monkeypatch.setenv("DOCKER_CERT_PATH", str(cert_dir)) docker = Docker() - assert docker.connector._ssl - await docker.close() - with pytest.raises(TypeError): - docker = Docker(ssl_context="bad ssl context") + try: + assert isinstance(docker.connector, aiohttp.TCPConnector) + assert docker.connector._ssl + finally: + await docker.close() + ssl_ctx = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH) - ssl_ctx.set_ciphers(ssl._RESTRICTED_SERVER_CIPHERS) ssl_ctx.load_verify_locations(cafile=str(cert_dir / "ca.pem")) ssl_ctx.load_cert_chain( certfile=str(cert_dir / "cert.pem"), keyfile=str(cert_dir / "key.pem") ) docker = Docker(ssl_context=ssl_ctx) - assert docker.connector._ssl - await docker.close() + try: + assert isinstance(docker.connector, aiohttp.TCPConnector) + assert docker.connector._ssl + finally: + await docker.close() + + with pytest.raises(TypeError): + docker = Docker(ssl_context="bad ssl context") # type: ignore @pytest.mark.skipif( sys.platform == "win32", reason="Unix sockets are not supported on Windows" ) @pytest.mark.asyncio -async def test_connect_invalid_unix_socket(): +async def test_connect_invalid_unix_socket() -> None: docker = Docker("unix:///var/run/does-not-exist-docker.sock") - assert isinstance(docker.connector, aiohttp.connector.UnixConnector) + assert isinstance(docker.connector, aiohttp.UnixConnector) with pytest.raises(aiodocker.DockerError): await docker.containers.list() await docker.close() @@ -100,10 +108,10 @@ async def test_connect_invalid_unix_socket(): sys.platform == "win32", reason="Unix sockets are not supported on Windows" ) @pytest.mark.asyncio -async def test_connect_envvar(monkeypatch): +async def test_connect_envvar(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("DOCKER_HOST", "unix:///var/run/does-not-exist-docker.sock") docker = Docker() - assert isinstance(docker.connector, aiohttp.connector.UnixConnector) + assert isinstance(docker.connector, aiohttp.UnixConnector) assert docker.docker_host == "unix://localhost" with pytest.raises(aiodocker.DockerError): await docker.containers.list() @@ -119,7 +127,7 @@ async def test_connect_envvar(monkeypatch): @pytest.mark.asyncio -async def test_connect_with_connector(monkeypatch): +async def test_connect_with_connector() -> None: connector = aiohttp.BaseConnector() docker = Docker(connector=connector) assert docker.connector == connector @@ -127,11 +135,11 @@ async def test_connect_with_connector(monkeypatch): @pytest.mark.asyncio -async def test_container_lifecycles(docker, image_name): +async def test_container_lifecycles(docker: Docker, image_name: str) -> None: containers = await docker.containers.list(all=True) orig_count = len(containers) - config = { + config: Dict[str, Any] = { "Cmd": ["python"], "Image": image_name, "AttachStdin": False, @@ -166,31 +174,34 @@ async def test_container_lifecycles(docker, image_name): sys.platform in ["darwin", "win32"], reason="Docker for Mac and Windows has a bug with websocket", ) -async def test_stdio_stdin(docker, testing_images, shell_container): +async def test_stdio_stdin( + docker: Docker, testing_images: List[str], shell_container: DockerContainer +) -> None: # echo of the input. ws = await shell_container.websocket(stdin=True, stdout=True, stream=True) await ws.send_str("print('hello world\\n')\n") - output = b"" + output_bytes = b"" found = False try: # collect the websocket outputs for at most 2 secs until we see the # output. async with timeout(2): while True: - output += await ws.receive_bytes() - if b"print('hello world\\n')" in output: + output_bytes += await ws.receive_bytes() + if b"print('hello world\\n')" in output_bytes: found = True break except asyncio.TimeoutError: pass await ws.close() if not found: - found = b"print('hello world\\n')" in output - assert found, output + found = b"print('hello world\\n')" in output_bytes + assert found, output_bytes # cross-check with container logs. - log = [] + log: List[str] = [] found = False + output_str = "" try: # collect the logs for at most 2 secs until we see the output. @@ -203,15 +214,17 @@ async def test_stdio_stdin(docker, testing_images, shell_container): except asyncio.TimeoutError: pass if not found: - output = "".join(log) - output.strip() - found = "hello world" in output.split("\r\n") - assert found, output + output_str = "".join(log) + output_str.strip() + found = "hello world" in output_str.split("\r\n") + assert found, output_str @pytest.mark.asyncio @pytest.mark.parametrize("stderr", [True, False], ids=lambda x: f"stderr={x}") -async def test_attach_nontty(docker, image_name, make_container, stderr): +async def test_attach_nontty( + docker: Docker, image_name: str, make_container, stderr: bool +) -> None: if stderr: cmd = [ "python", @@ -221,7 +234,7 @@ async def test_attach_nontty(docker, image_name, make_container, stderr): else: cmd = ["python", "-c", "import time; time.sleep(3); print('Hello')"] - config = { + config: Dict[str, Any] = { "Cmd": cmd, "Image": image_name, "AttachStdin": False, @@ -232,19 +245,25 @@ async def test_attach_nontty(docker, image_name, make_container, stderr): "StdinOnce": False, } - container = await make_container(config, name="aiodocker-testing-attach-nontty") + container: DockerContainer = await make_container( + config, name="aiodocker-testing-attach-nontty" + ) async with container.attach(stdin=False, stdout=True, stderr=True) as stream: - fileno, data = await stream.read_out() + msg = await stream.read_out() + assert msg is not None + fileno, data = msg assert fileno == 2 if stderr else 1 assert data.strip() == b"Hello" @pytest.mark.asyncio -async def test_attach_nontty_wait_for_exit(docker, image_name, make_container): +async def test_attach_nontty_wait_for_exit( + docker: Docker, image_name: str, make_container +) -> None: cmd = ["python", "-c", "import time; time.sleep(3); print('Hello')"] - config = { + config: Dict[str, Any] = { "Cmd": cmd, "Image": image_name, "AttachStdin": False, @@ -255,7 +274,7 @@ async def test_attach_nontty_wait_for_exit(docker, image_name, make_container): "StdinOnce": False, } - container = await make_container( + container: DockerContainer = await make_container( config, name="aiodocker-testing-attach-nontty-wait-for-exit", ) @@ -265,9 +284,9 @@ async def test_attach_nontty_wait_for_exit(docker, image_name, make_container): @pytest.mark.asyncio -async def test_attach_tty(docker, image_name, make_container): +async def test_attach_tty(docker: Docker, image_name: str, make_container) -> None: skip_windows() - config = { + config: Dict[str, Any] = { "Cmd": ["python", "-q"], "Image": image_name, "AttachStdin": True, @@ -278,7 +297,9 @@ async def test_attach_tty(docker, image_name, make_container): "StdinOnce": False, } - container = await make_container(config, name="aiodocker-testing-attach-tty") + container: DockerContainer = await make_container( + config, name="aiodocker-testing-attach-tty" + ) async with container.attach(stdin=True, stdout=True, stderr=True) as stream: await container.resize(w=80, h=25) @@ -301,7 +322,9 @@ async def test_attach_tty(docker, image_name, make_container): @pytest.mark.asyncio -async def test_wait_timeout(docker, testing_images, shell_container): +async def test_wait_timeout( + docker: Docker, testing_images: List[str], shell_container: DockerContainer +) -> None: t1 = datetime.datetime.now() with pytest.raises(asyncio.TimeoutError): await shell_container.wait(timeout=0.5) @@ -311,10 +334,10 @@ async def test_wait_timeout(docker, testing_images, shell_container): @pytest.mark.asyncio -async def test_put_archive(docker, image_name): +async def test_put_archive(docker: Docker, image_name: str) -> None: skip_windows() - config = { + config: Dict[str, Any] = { "Cmd": ["python", "-c", "print(open('tmp/bar/foo.txt').read())"], "Image": image_name, "AttachStdin": False, @@ -331,7 +354,7 @@ async def test_put_archive(docker, image_name): info = tarfile.TarInfo(name="bar") info.type = tarfile.DIRTYPE info.mode = 0o755 - info.mtime = time.time() + info.mtime = int(time.time()) tar.addfile(tarinfo=info) tarinfo = tarfile.TarInfo(name="bar/foo.txt") @@ -353,10 +376,10 @@ async def test_put_archive(docker, image_name): @pytest.mark.asyncio -async def test_get_archive(image_name, make_container): +async def test_get_archive(image_name: str, make_container) -> None: skip_windows() - config = { + config: Dict[str, Any] = { "Cmd": [ "python", "-c", @@ -370,7 +393,7 @@ async def test_get_archive(image_name, make_container): "OpenStdin": False, } - container = await make_container( + container: DockerContainer = await make_container( config=config, name="aiodocker-testing-get-archive" ) await container.start() @@ -378,8 +401,9 @@ async def test_get_archive(image_name, make_container): tar_archive = await container.get_archive("tmp/foo.txt") assert tar_archive is not None - assert len(tar_archive.members) == 1 + assert len(tar_archive.getmembers()) == 1 foo_file = tar_archive.extractfile("foo.txt") + assert foo_file is not None assert foo_file.read() == b"test\n" @@ -387,8 +411,8 @@ async def test_get_archive(image_name, make_container): @pytest.mark.skipif( sys.platform == "win32", reason="Port is not exposed on Windows by some reason" ) -async def test_port(docker, image_name): - config = { +async def test_port(docker: Docker, image_name: str) -> None: + config: Dict[str, Any] = { "Cmd": [ "python", "-c", @@ -415,7 +439,7 @@ async def test_port(docker, image_name): @pytest.mark.asyncio -async def test_events(docker, image_name): +async def test_events(docker: Docker, image_name: str) -> None: # Сheck the stop procedure docker.events.subscribe() await docker.events.stop() @@ -423,7 +447,7 @@ async def test_events(docker, image_name): subscriber = docker.events.subscribe() # Do some stuffs to generate events. - config = {"Cmd": ["python"], "Image": image_name} + config: Dict[str, Any] = {"Cmd": ["python"], "Image": image_name} container = await docker.containers.create_or_replace( config=config, name="aiodocker-testing-temp" ) diff --git a/tests/test_networks.py b/tests/test_networks.py index 8a9c6905..0d9cd6dc 100644 --- a/tests/test_networks.py +++ b/tests/test_networks.py @@ -1,10 +1,14 @@ +from __future__ import annotations + import pytest -import aiodocker +from aiodocker.docker import Docker +from aiodocker.exceptions import DockerError +from aiodocker.networks import DockerNetwork @pytest.mark.asyncio -async def test_list_networks(docker): +async def test_list_networks(docker: Docker) -> None: data = await docker.networks.list() networks = {net["Name"]: net for net in data} assert "none" in networks @@ -12,7 +16,7 @@ async def test_list_networks(docker): @pytest.mark.asyncio -async def test_list_networks_with_filter(docker): +async def test_list_networks_with_filter(docker: Docker) -> None: network = await docker.networks.create({ "Name": "test-net-filter", "Labels": {"some": "label"}, @@ -27,11 +31,11 @@ async def test_list_networks_with_filter(docker): @pytest.mark.asyncio -async def test_networks(docker): +async def test_networks(docker: Docker) -> None: network = await docker.networks.create({"Name": "test-net"}) net_find = await docker.networks.get("test-net") assert (await net_find.show())["Name"] == "test-net" - assert isinstance(network, aiodocker.networks.DockerNetwork) + assert isinstance(network, DockerNetwork) data = await network.show() assert data["Name"] == "test-net" container = None @@ -47,8 +51,8 @@ async def test_networks(docker): @pytest.mark.asyncio -async def test_network_delete_error(docker): +async def test_network_delete_error(docker: Docker) -> None: network = await docker.networks.create({"Name": "test-delete-net"}) assert await network.delete() is True - with pytest.raises(aiodocker.exceptions.DockerError): + with pytest.raises(DockerError): await network.delete() diff --git a/tests/test_system.py b/tests/test_system.py index 9408ed94..43835f0d 100644 --- a/tests/test_system.py +++ b/tests/test_system.py @@ -1,8 +1,10 @@ import pytest +from aiodocker.docker import Docker + @pytest.mark.asyncio -async def test_system_info(docker): +async def test_system_info(docker: Docker) -> None: docker_info = await docker.system.info() assert "ID" in docker_info assert "ServerVersion" in docker_info diff --git a/tests/test_utils.py b/tests/test_utils.py index 988c41d3..90f83fae 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,18 +1,21 @@ +from __future__ import annotations + import json +from typing import Any, Dict, Sequence import pytest from aiodocker import utils -def test_clean_mapping(): - dirty_dict = {"a": None, "b": {}, "c": [], "d": 1} - clean_dict = {"b": {}, "c": [], "d": 1} +def test_clean_mapping() -> None: + dirty_dict: Dict[Any, Any] = {"a": None, "b": {}, "c": [], "d": 1} + clean_dict: Dict[Any, Any] = {"b": {}, "c": [], "d": 1} result = utils.clean_map(dirty_dict) assert result == clean_dict -def test_parse_content_type(): +def test_parse_content_type() -> None: ct = "text/plain" mt, st, opts = utils.parse_content_type(ct) assert mt == "text" @@ -36,22 +39,14 @@ def test_parse_content_type(): mt, st, opts = utils.parse_content_type(ct) -def test_format_env(): - key = "name" - value = "hello" - assert utils.format_env(key, value) == "name=hello" - - key = "name" - value = None - assert utils.format_env(key, value) == "name" +def test_format_env() -> None: + assert utils.format_env("name", "hello") == "name=hello" + assert utils.format_env("name", None) == "name" + assert utils.format_env("name", b"hello") == "name=hello" - key = "name" - value = b"hello" - assert utils.format_env(key, value) == "name=hello" - -def test_clean_networks(): - networks = [] +def test_clean_networks() -> None: + networks: Sequence[str] = [] assert utils.clean_networks(networks) == [] networks = ("test-network-1", "test-network-2") @@ -64,24 +59,12 @@ def test_clean_networks(): assert utils.clean_networks(networks) == result -def test_clean_filters(): +def test_clean_filters() -> None: filters = {"a": ["1", "2", "3", "4"], "b": "string"} result = {"a": ["1", "2", "3", "4"], "b": ["string"]} assert utils.clean_filters(filters=filters) == json.dumps(result) + assert utils.clean_filters(filters={}) == "{}" + assert utils.clean_filters(filters=None) == "{}" - filters = () - result = {"a": ["1", "2", "3", "4"], "b": ["string"]} - utils.clean_filters(filters=filters) == json.dumps(result) - - -@pytest.mark.asyncio -async def test_cancel_async_cm(): - async def _coro(): - return - - cm = utils._AsyncCM(_coro()) - cm.cancel() - - with pytest.raises(RuntimeError): - async with cm: - pass + with pytest.raises(TypeError): + assert utils.clean_filters(filters=()) diff --git a/tests/test_volumes.py b/tests/test_volumes.py index 72e447d5..d1ca5419 100644 --- a/tests/test_volumes.py +++ b/tests/test_volumes.py @@ -1,10 +1,11 @@ import pytest -import aiodocker +from aiodocker.docker import Docker +from aiodocker.exceptions import DockerError @pytest.mark.asyncio -async def test_create_search_get_delete(docker): +async def test_create_search_get_delete(docker: Docker) -> None: name = "aiodocker-test-volume-two" await docker.volumes.create({ "Name": name, @@ -17,13 +18,13 @@ async def test_create_search_get_delete(docker): volume_data = volumes[0] volume = await docker.volumes.get(volume_data["Name"]) await volume.delete() - with pytest.raises(aiodocker.exceptions.DockerError): + with pytest.raises(DockerError): await docker.volumes.get(name) @pytest.mark.asyncio @pytest.mark.parametrize("force_delete", [True, False]) -async def test_create_show_delete_volume(docker, force_delete): +async def test_create_show_delete_volume(docker: Docker, force_delete: bool) -> None: name = "aiodocker-test-volume" volume = await docker.volumes.create({ "Name": name, @@ -34,5 +35,5 @@ async def test_create_show_delete_volume(docker, force_delete): data = await volume.show() assert data await volume.delete(force_delete) - with pytest.raises(aiodocker.exceptions.DockerError): + with pytest.raises(DockerError): await docker.volumes.get(name)