Add mypy CI, fix errors

Signed-off-by: Viicos <65306057+Viicos@users.noreply.github.com>
This commit is contained in:
Viicos 2023-09-08 19:22:26 +02:00
parent 85ce456a2b
commit 0eb9d94e9c
13 changed files with 221 additions and 49 deletions

View File

@ -13,9 +13,11 @@ jobs:
- uses: actions/setup-python@v4 - uses: actions/setup-python@v4
with: with:
python-version: '3.11' 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 - name: Run ruff
run: ruff docker tests run: ruff docker tests
- name: Run mypy
run: mypy docker
unit-tests: unit-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest

171
.gitignore vendored
View File

@ -1,22 +1,163 @@
build # Byte-compiled / optimized / DLL files
dist __pycache__/
*.egg-info *.py[cod]
*.egg/ *$py.class
*.pyc
*.swp
.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 .coverage
html/* .coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Compiled Documentation # Translations
_build/ *.mo
README.rst *.pot
# setuptools_scm # Django stuff:
_version.py *.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/ env/
venv/ venv/
.idea/ ENV/
*.iml 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/

View File

@ -46,7 +46,7 @@ build-dind-certs:
docker build -t dpy-dind-certs -f tests/Dockerfile-dind-certs . docker build -t dpy-dind-certs -f tests/Dockerfile-dind-certs .
.PHONY: test .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 .PHONY: unit-test-py3
unit-test-py3: build-py3 unit-test-py3: build-py3
@ -167,6 +167,10 @@ integration-dind-ssl: build-dind-certs build-py3 setup-network
ruff: build-py3 ruff: build-py3
docker run -t --rm docker-sdk-python3 ruff docker tests 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 .PHONY: docs
docs: build-docs docs: build-docs
docker run --rm -t -v `pwd`:/src docker-sdk-python-docs sphinx-build docs docs/_build docker run --rm -t -v `pwd`:/src docker-sdk-python-docs sphinx-build docs docs/_build

View File

@ -4,7 +4,7 @@ import urllib
import ssl import ssl
import sys import sys
from functools import partial 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): if sys.version_info >= (3, 8):
from typing import Literal from typing import Literal
@ -25,11 +25,12 @@ from ..errors import (DockerException, InvalidVersion, TLSParameterError,
create_api_error_from_http_exception) create_api_error_from_http_exception)
from ..tls import TLSConfig from ..tls import TLSConfig
from ..transport import SSLHTTPAdapter, UnixHTTPAdapter from ..transport import SSLHTTPAdapter, UnixHTTPAdapter
from ..transport.basehttpadapter import BaseHTTPAdapter
from ..utils import check_resource, config, update_headers, utils from ..utils import check_resource, config, update_headers, utils
from ..utils.json_stream import json_stream from ..utils.json_stream import json_stream
from ..utils.proxy import ProxyConfig from ..utils.proxy import ProxyConfig
from ..utils.socket import consume_socket_output, demux_adaptor, frames_iter 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 .build import BuildApiMixin
from .config import ConfigApiMixin from .config import ConfigApiMixin
from .container import ContainerApiMixin from .container import ContainerApiMixin
@ -112,6 +113,8 @@ class APIClient(
'base_url', 'base_url',
'timeout'] 'timeout']
_custom_adapter: BaseHTTPAdapter
def __init__(self, base_url: Optional[str] = None, version: Optional[str] = None, def __init__(self, base_url: Optional[str] = None, version: Optional[str] = None,
timeout: int = DEFAULT_TIMEOUT_SECONDS, tls: Optional[Union[bool, TLSConfig]] = False, timeout: int = DEFAULT_TIMEOUT_SECONDS, tls: Optional[Union[bool, TLSConfig]] = False,
user_agent: str = DEFAULT_USER_AGENT, num_pools: Optional[int] = None, 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.' 'If using TLS, the base_url argument must be provided.'
) )
self.base_url = base_url
self.timeout = timeout self.timeout = timeout
self.headers['User-Agent'] = user_agent self.headers['User-Agent'] = user_agent
@ -143,9 +145,7 @@ class APIClient(
) )
self.credstore_env = credstore_env self.credstore_env = credstore_env
base_url = utils.parse_host( base_url = cast(str, utils.parse_host(base_url, IS_WINDOWS_PLATFORM, tls=bool(tls)))
base_url, IS_WINDOWS_PLATFORM, tls=bool(tls)
)
# SSH has a different default for num_pools to all other adapters # SSH has a different default for num_pools to all other adapters
num_pools = num_pools or DEFAULT_NUM_POOLS_SSH if \ num_pools = num_pools or DEFAULT_NUM_POOLS_SSH if \
base_url.startswith('ssh://') else DEFAULT_NUM_POOLS base_url.startswith('ssh://') else DEFAULT_NUM_POOLS
@ -261,9 +261,9 @@ class APIClient(
) )
quote_f = partial(urllib.parse.quote, safe="/:") 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): if kwargs.get('versioned_api', True):
return f'{self.base_url}/v{self._version}{formatted_path}' return f'{self.base_url}/v{self._version}{formatted_path}'
else: else:
@ -281,7 +281,7 @@ class APIClient(
... ...
@overload @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 @overload
@ -329,7 +329,7 @@ class APIClient(
def _attach_websocket(self, container: str, params: Optional[Dict[str, Any]] = None) -> websocket.WebSocket: def _attach_websocket(self, container: str, params: Optional[Dict[str, Any]] = None) -> websocket.WebSocket:
url = self._url("/containers/{0}/attach/ws", container) url = self._url("/containers/{0}/attach/ws", container)
req = requests.Request("POST", url, params=self._attach_params(params)) 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("http://", "ws://", 1)
full_url = full_url.replace("https://", "wss://", 1) full_url = full_url.replace("https://", "wss://", 1)
return self._create_websocket_connection(full_url) return self._create_websocket_connection(full_url)
@ -364,10 +364,10 @@ class APIClient(
... ...
@overload @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.""" """Generator for data coming from a chunked-encoded HTTP response."""
if response.raw._fp.chunked: if response.raw._fp.chunked:
@ -386,7 +386,7 @@ class APIClient(
else: else:
# Response isn't chunked, meaning we probably # Response isn't chunked, meaning we probably
# encountered an error immediately # 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]: def _multiplexed_buffer_helper(self, response: requests.Response) -> Iterator[bytes]:
"""A generator of multiplexed data blocks read from a buffered """A generator of multiplexed data blocks read from a buffered
@ -426,14 +426,18 @@ class APIClient(
yield data yield data
@overload @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 @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 = ..., 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''' ''' Stream result for TTY-enabled container and raw binary data'''
self._raise_for_status(response) self._raise_for_status(response)
@ -489,13 +493,13 @@ class APIClient(
timeout = -1 timeout = -1
if hasattr(s, 'gettimeout'): 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. # Don't change the timeout if it is already disabled.
if timeout is None or timeout == 0.0: if timeout is None or timeout == 0.0:
continue continue
s.settimeout(None) s.settimeout(None) # type: ignore[union-attr]
@check_resource('container') @check_resource('container')
def _check_is_tty(self, container: str) -> bool: 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: 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)) return self._get_result_tty(stream, res, self._check_is_tty(container))
@overload @overload

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import base64 import base64
import json import json
import logging 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 credentials
from . import errors 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) # This is a docker index repo (ex: username/foobar or ubuntu)
return INDEX_NAME, repo_name return INDEX_NAME, repo_name
return tuple(parts) return tuple(parts) # type: ignore[return-value]
def get_credential_store(authconfig, registry): def get_credential_store(authconfig, registry):
@ -82,7 +82,7 @@ class AuthConfig(dict):
dct['auths'] = {} dct['auths'] = {}
self.update(dct) self.update(dct)
self._credstore_env = credstore_env self._credstore_env = credstore_env
self._stores = {} self._stores: Dict[str, credentials.Store] = {}
@classmethod @classmethod
def parse_auth(cls, entries: Dict[str, Any], raise_on_error: bool = False) -> Dict[str, Any]: 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 return conf
@classmethod @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 Loads authentication data from a Docker configuration file in the given
root directory or if config_path is passed use given path. root directory or if config_path is passed use given path.
@ -161,7 +161,7 @@ class AuthConfig(dict):
return cls({}, credstore_env) return cls({}, credstore_env)
try: try:
with open(config_file) as f: 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: except (OSError, KeyError, ValueError) as e:
# Likely missing new Docker config file or it's in an # Likely missing new Docker config file or it's in an
# unknown format, continue to attempt to read old location # unknown format, continue to attempt to read old location
@ -245,7 +245,7 @@ class AuthConfig(dict):
log.debug("No entry found") log.debug("No entry found")
return None 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: if not registry or registry == INDEX_NAME:
# The ecosystem is a little schizophrenic with index.docker.io VS # The ecosystem is a little schizophrenic with index.docker.io VS
# docker.io - in that case, it seems the full URL is necessary. # 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)}' f'Credentials store error: {repr(e)}'
) from 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: if name not in self._stores:
self._stores[name] = credentials.Store( self._stores[name] = credentials.Store(
name, environment=self._credstore_env name, environment=self._credstore_env
) )
return self._stores[name] 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: if not registry or registry == INDEX_NAME:
registry = INDEX_URL registry = INDEX_URL

View File

@ -47,7 +47,7 @@ class APIError(requests.exceptions.HTTPError, DockerException):
""" """
An HTTP error from the API. 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.2 supports response as a keyword argument, but
# requests 1.1 doesn't # requests 1.1 doesn't
super().__init__(message) super().__init__(message)
@ -78,6 +78,7 @@ class APIError(requests.exceptions.HTTPError, DockerException):
def status_code(self) -> Optional[int]: def status_code(self) -> Optional[int]:
if self.response is not None: if self.response is not None:
return self.response.status_code return self.response.status_code
return None
def is_error(self) -> bool: def is_error(self) -> bool:
return self.is_client_error() or self.is_server_error() return self.is_client_error() or self.is_server_error()

View File

@ -79,7 +79,7 @@ class TLSConfig:
""" """
Configure a client with these TLS options. 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: if self.verify and self.ca_cert:
client.verify = self.ca_cert client.verify = self.ca_cert

View File

@ -1,3 +1,3 @@
from typing import Any, Dict, TypeVar from typing import Any, Dict, TypeVar
BytesOrDict = TypeVar("BytesOrDict", bytes, Dict[str, Any]) StrOrDict = TypeVar("StrOrDict", str, Dict[str, Any])

View File

@ -1,5 +1,5 @@
try: try:
from ._version import __version__ from ._version import __version__ # type: ignore[import]
except ImportError: except ImportError:
try: try:
# importlib.metadata available in Python 3.8+, the fallback (0.0.0) # importlib.metadata available in Python 3.8+, the fallback (0.0.0)

View File

@ -18,3 +18,22 @@ ignore = [
[tool.ruff.per-file-ignores] [tool.ruff.per-file-ignores]
"**/__init__.py" = ["F401"] "**/__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

View File

@ -4,4 +4,4 @@ pywin32==304; sys_platform == 'win32'
requests==2.31.0 requests==2.31.0
urllib3==1.26.11 urllib3==1.26.11
websocket-client==1.3.3 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'

View File

@ -14,7 +14,7 @@ requirements = [
'requests >= 2.26.0', 'requests >= 2.26.0',
'urllib3 >= 1.26.0', 'urllib3 >= 1.26.0',
'websocket-client >= 0.32.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 = { extras_require = {

View File

@ -1,6 +1,7 @@
setuptools==65.5.1 setuptools==65.5.1
coverage==6.4.2 coverage==6.4.2
ruff==0.0.284 ruff==0.0.284
mypy==1.5.1
pytest==7.1.2 pytest==7.1.2
pytest-cov==3.0.0 pytest-cov==3.0.0
pytest-timeout==2.1.0 pytest-timeout==2.1.0