diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfbcc701..88ba3417 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,11 @@ jobs: - uses: actions/setup-python@v4 with: python-version: '3.11' - - run: pip install -U ruff==0.0.284 + - run: pip install -U ruff==0.0.284 mypy==1.5.1 - name: Run ruff run: ruff docker tests + - name: Run mypy + run: mypy docker unit-tests: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index c88ccc1b..4a8d555e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,163 @@ -build -dist -*.egg-info -*.egg/ -*.pyc -*.swp +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class -.tox +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ .coverage -html/* +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ -# Compiled Documentation -_build/ -README.rst +# Translations +*.mo +*.pot -# setuptools_scm -_version.py +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv env/ venv/ -.idea/ -*.iml +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Ruff linter +.ruff_cache/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/Makefile b/Makefile index 79486e3e..1a158dd6 100644 --- a/Makefile +++ b/Makefile @@ -46,7 +46,7 @@ build-dind-certs: docker build -t dpy-dind-certs -f tests/Dockerfile-dind-certs . .PHONY: test -test: ruff unit-test-py3 integration-dind integration-dind-ssl +test: ruff mypy unit-test-py3 integration-dind integration-dind-ssl .PHONY: unit-test-py3 unit-test-py3: build-py3 @@ -167,6 +167,10 @@ integration-dind-ssl: build-dind-certs build-py3 setup-network ruff: build-py3 docker run -t --rm docker-sdk-python3 ruff docker tests +.PHONY: mypy +mypy: build-py3 + docker run -t --rm docker-sdk-python3 mypy docker + .PHONY: docs docs: build-docs docker run --rm -t -v `pwd`:/src docker-sdk-python-docs sphinx-build docs docs/_build diff --git a/docker/api/client.py b/docker/api/client.py index 7d0f1636..0af2ffb0 100644 --- a/docker/api/client.py +++ b/docker/api/client.py @@ -4,7 +4,7 @@ import urllib import ssl import sys from functools import partial -from typing import Any, AnyStr, Optional, Union, Dict, overload, NoReturn, Iterator +from typing import Any, AnyStr, Optional, Union, Dict, cast, overload, NoReturn, Iterator if sys.version_info >= (3, 8): from typing import Literal @@ -25,11 +25,12 @@ from ..errors import (DockerException, InvalidVersion, TLSParameterError, create_api_error_from_http_exception) from ..tls import TLSConfig from ..transport import SSLHTTPAdapter, UnixHTTPAdapter +from ..transport.basehttpadapter import BaseHTTPAdapter from ..utils import check_resource, config, update_headers, utils from ..utils.json_stream import json_stream from ..utils.proxy import ProxyConfig from ..utils.socket import consume_socket_output, demux_adaptor, frames_iter -from ..utils.typing import BytesOrDict +from ..utils.typing import StrOrDict from .build import BuildApiMixin from .config import ConfigApiMixin from .container import ContainerApiMixin @@ -112,6 +113,8 @@ class APIClient( 'base_url', 'timeout'] + _custom_adapter: BaseHTTPAdapter + def __init__(self, base_url: Optional[str] = None, version: Optional[str] = None, timeout: int = DEFAULT_TIMEOUT_SECONDS, tls: Optional[Union[bool, TLSConfig]] = False, user_agent: str = DEFAULT_USER_AGENT, num_pools: Optional[int] = None, @@ -124,7 +127,6 @@ class APIClient( 'If using TLS, the base_url argument must be provided.' ) - self.base_url = base_url self.timeout = timeout self.headers['User-Agent'] = user_agent @@ -143,9 +145,7 @@ class APIClient( ) self.credstore_env = credstore_env - base_url = utils.parse_host( - base_url, IS_WINDOWS_PLATFORM, tls=bool(tls) - ) + base_url = cast(str, utils.parse_host(base_url, IS_WINDOWS_PLATFORM, tls=bool(tls))) # SSH has a different default for num_pools to all other adapters num_pools = num_pools or DEFAULT_NUM_POOLS_SSH if \ base_url.startswith('ssh://') else DEFAULT_NUM_POOLS @@ -261,9 +261,9 @@ class APIClient( ) quote_f = partial(urllib.parse.quote, safe="/:") - args = map(quote_f, args) + args_quoted = map(quote_f, args) - formatted_path = pathfmt.format(*args) + formatted_path = pathfmt.format(*args_quoted) if kwargs.get('versioned_api', True): return f'{self.base_url}/v{self._version}{formatted_path}' else: @@ -281,7 +281,7 @@ class APIClient( ... @overload - def _result(self, response: requests.Response, json: Literal[False] = ..., binary: Literal[False] = ...) -> str: + def _result(self, response: requests.Response, json: Literal[False] = ..., binary: Literal[False] = ...) -> str: # type: ignore[misc] ... @overload @@ -329,7 +329,7 @@ class APIClient( def _attach_websocket(self, container: str, params: Optional[Dict[str, Any]] = None) -> websocket.WebSocket: url = self._url("/containers/{0}/attach/ws", container) req = requests.Request("POST", url, params=self._attach_params(params)) - full_url = req.prepare().url + full_url = cast(str, req.prepare().url) full_url = full_url.replace("http://", "ws://", 1) full_url = full_url.replace("https://", "wss://", 1) return self._create_websocket_connection(full_url) @@ -364,10 +364,10 @@ class APIClient( ... @overload - def _stream_helper(self, response: requests.Response, decode: Literal[False] = ...) -> Iterator[bytes]: + def _stream_helper(self, response: requests.Response, decode: Literal[False] = ...) -> Iterator[str]: ... - def _stream_helper(self, response: requests.Response, decode: bool = False) -> Iterator[BytesOrDict]: + def _stream_helper(self, response: requests.Response, decode: bool = False) -> Iterator[StrOrDict]: """Generator for data coming from a chunked-encoded HTTP response.""" if response.raw._fp.chunked: @@ -386,7 +386,7 @@ class APIClient( else: # Response isn't chunked, meaning we probably # encountered an error immediately - yield self._result(response, json=decode) + yield self._result(response, json=decode) # type: ignore[misc] def _multiplexed_buffer_helper(self, response: requests.Response) -> Iterator[bytes]: """A generator of multiplexed data blocks read from a buffered @@ -426,14 +426,18 @@ class APIClient( yield data @overload - def _stream_raw_result(self, response: requests.Response, chunk_size: int = ..., decode: Literal[False] = ...) -> Iterator[bytes]: + def _stream_raw_result(self, response: requests.Response, chunk_size: int, decode: Literal[False]) -> Iterator[bytes]: + ... + + @overload + def _stream_raw_result(self, response: requests.Response, decode: Literal[False]) -> Iterator[bytes]: ... @overload def _stream_raw_result(self, response: requests.Response, chunk_size: int = ..., decode: Literal[True] = ...) -> Iterator[str]: ... - def _stream_raw_result(self, response: requests.Response, chunk_size: int = 1, decode: bool = True) -> Iterator[AnyStr]: + def _stream_raw_result(self, response: requests.Response, chunk_size: int = 1, decode: bool = True) -> Iterator[AnyStr]: # type: ignore[misc] ''' Stream result for TTY-enabled container and raw binary data''' self._raise_for_status(response) @@ -489,13 +493,13 @@ class APIClient( timeout = -1 if hasattr(s, 'gettimeout'): - timeout = s.gettimeout() + timeout = cast(int, s.gettimeout()) # type: ignore[union-attr] # Don't change the timeout if it is already disabled. if timeout is None or timeout == 0.0: continue - s.settimeout(None) + s.settimeout(None) # type: ignore[union-attr] @check_resource('container') def _check_is_tty(self, container: str) -> bool: @@ -510,7 +514,7 @@ class APIClient( def _get_result(self, container: str, stream: Literal[False], res: requests.Response) -> bytes: ... - def _get_result(self, container: str, stream: bool, res: requests.Response): + def _get_result(self, container, stream, res): return self._get_result_tty(stream, res, self._check_is_tty(container)) @overload diff --git a/docker/auth.py b/docker/auth.py index 45a76728..09a69dee 100644 --- a/docker/auth.py +++ b/docker/auth.py @@ -2,7 +2,7 @@ from __future__ import annotations import base64 import json import logging -from typing import Any, AnyStr, Tuple, Dict, Optional, Union +from typing import Any, AnyStr, Tuple, Dict, Optional, Union, cast from . import credentials from . import errors @@ -67,7 +67,7 @@ def split_repo_name(repo_name: str) -> Tuple[str, str]: ): # This is a docker index repo (ex: username/foobar or ubuntu) return INDEX_NAME, repo_name - return tuple(parts) + return tuple(parts) # type: ignore[return-value] def get_credential_store(authconfig, registry): @@ -82,7 +82,7 @@ class AuthConfig(dict): dct['auths'] = {} self.update(dct) self._credstore_env = credstore_env - self._stores = {} + self._stores: Dict[str, credentials.Store] = {} @classmethod def parse_auth(cls, entries: Dict[str, Any], raise_on_error: bool = False) -> Dict[str, Any]: @@ -145,7 +145,7 @@ class AuthConfig(dict): return conf @classmethod - def load_config(cls, config_path: str, config_dict: Dict[str, Any], credstore_env: Optional[Dict[str, Any]] = None) -> AuthConfig: + def load_config(cls, config_path: Optional[str], config_dict: Optional[Dict[str, Any]], credstore_env: Optional[Dict[str, Any]] = None) -> AuthConfig: """ Loads authentication data from a Docker configuration file in the given root directory or if config_path is passed use given path. @@ -161,7 +161,7 @@ class AuthConfig(dict): return cls({}, credstore_env) try: with open(config_file) as f: - config_dict = json.load(f) + config_dict = cast(Dict[str, Any], json.load(f)) except (OSError, KeyError, ValueError) as e: # Likely missing new Docker config file or it's in an # unknown format, continue to attempt to read old location @@ -245,7 +245,7 @@ class AuthConfig(dict): log.debug("No entry found") return None - def _resolve_authconfig_credstore(self, registry: str, credstore_name: str) -> Optional[Dict[str, Any]]: + def _resolve_authconfig_credstore(self, registry: Optional[str], credstore_name: str) -> Optional[Dict[str, Any]]: if not registry or registry == INDEX_NAME: # The ecosystem is a little schizophrenic with index.docker.io VS # docker.io - in that case, it seems the full URL is necessary. @@ -273,14 +273,14 @@ class AuthConfig(dict): f'Credentials store error: {repr(e)}' ) from e - def _get_store_instance(self, name: str) -> Dict[str, Any]: + def _get_store_instance(self, name: str) -> credentials.Store: if name not in self._stores: self._stores[name] = credentials.Store( name, environment=self._credstore_env ) return self._stores[name] - def get_credential_store(self, registry: str) -> str: + def get_credential_store(self, registry: Optional[str]) -> Optional[str]: if not registry or registry == INDEX_NAME: registry = INDEX_URL diff --git a/docker/errors.py b/docker/errors.py index 8959aaa3..7f185f8e 100644 --- a/docker/errors.py +++ b/docker/errors.py @@ -47,7 +47,7 @@ class APIError(requests.exceptions.HTTPError, DockerException): """ An HTTP error from the API. """ - def __init__(self, message: str, response: Optional[requests.Response] = None, explanation: Optional[str] = None) -> None: + def __init__(self, message: Any, response: Optional[requests.Response] = None, explanation: Optional[str] = None) -> None: # requests 1.2 supports response as a keyword argument, but # requests 1.1 doesn't super().__init__(message) @@ -78,6 +78,7 @@ class APIError(requests.exceptions.HTTPError, DockerException): def status_code(self) -> Optional[int]: if self.response is not None: return self.response.status_code + return None def is_error(self) -> bool: return self.is_client_error() or self.is_server_error() diff --git a/docker/tls.py b/docker/tls.py index 4c762dfa..2091616d 100644 --- a/docker/tls.py +++ b/docker/tls.py @@ -79,7 +79,7 @@ class TLSConfig: """ Configure a client with these TLS options. """ - client.ssl_version = self.ssl_version + client.ssl_version = self.ssl_version # type: ignore[attr-defined] if self.verify and self.ca_cert: client.verify = self.ca_cert diff --git a/docker/utils/typing.py b/docker/utils/typing.py index fe21dcdf..101502c8 100644 --- a/docker/utils/typing.py +++ b/docker/utils/typing.py @@ -1,3 +1,3 @@ from typing import Any, Dict, TypeVar -BytesOrDict = TypeVar("BytesOrDict", bytes, Dict[str, Any]) +StrOrDict = TypeVar("StrOrDict", str, Dict[str, Any]) diff --git a/docker/version.py b/docker/version.py index 44eac8c5..8224dbca 100644 --- a/docker/version.py +++ b/docker/version.py @@ -1,5 +1,5 @@ try: - from ._version import __version__ + from ._version import __version__ # type: ignore[import] except ImportError: try: # importlib.metadata available in Python 3.8+, the fallback (0.0.0) diff --git a/pyproject.toml b/pyproject.toml index 0a672796..089985cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,3 +18,22 @@ ignore = [ [tool.ruff.per-file-ignores] "**/__init__.py" = ["F401"] + +[tool.mypy] +python_version = "3.8" + +[[tool.mypy.overrides]] +module = [ + "docker.api.*", + "docker.context.*", + "docker.credentials.*", + "docker.models.*", + "docker.transport.*", + "docker.types.*", + "docker.utils.*", +] +ignore_errors = true + +[[tool.mypy.overrides]] +module = "docker.api.client" +ignore_errors = false diff --git a/requirements.txt b/requirements.txt index 637f3968..e8181d7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,4 @@ pywin32==304; sys_platform == 'win32' requests==2.31.0 urllib3==1.26.11 websocket-client==1.3.3 -typing_extensions>=3.10.0.0; python_version < '3.8' +typing_extensions>=3.10.0.0; python_version < '3.11' diff --git a/setup.py b/setup.py index c6f26618..d38de2d1 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ requirements = [ 'requests >= 2.26.0', 'urllib3 >= 1.26.0', 'websocket-client >= 0.32.0', - 'typing_extensions>=3.10.0.0; python_version < "3.8"', + 'typing_extensions>=3.10.0.0; python_version < "3.11"', ] extras_require = { diff --git a/test-requirements.txt b/test-requirements.txt index 951b3be9..14b8cb7a 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,6 +1,7 @@ setuptools==65.5.1 coverage==6.4.2 ruff==0.0.284 +mypy==1.5.1 pytest==7.1.2 pytest-cov==3.0.0 pytest-timeout==2.1.0