diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c98275122..1aef854ad 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -46,6 +46,7 @@ jobs: name: Unit tests runs-on: ubuntu-latest strategy: + fail-fast: false matrix: python: - "3.8" diff --git a/.readthedocs.yaml b/.readthedocs.yaml index f861b63a7..4da2dbd76 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -2,12 +2,18 @@ version: 2 python: install: - - requirements: docs/requirements.txt + - method: pip + path: . + extra_requirements: + - dev + - docs build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: - python: "3.10" + # Older Shpinx uses imghdr that was removed in Python 3.13 + # See e.g. https://github.com/python/cpython/issues/104818 + python: "3.12" sphinx: configuration: docs/conf.py diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 2ee6d2000..000000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,16 +0,0 @@ -pytz -pymacaroons -sphinx==5.3.0 -sphinxcontrib-asyncio -sphinx_rtd_theme -websockets -typing-inspect -pyyaml -pyasn1 -pyrfc3339 -paramiko -macaroonbakery -toposort -python-dateutil -kubernetes -packaging diff --git a/juju/client/connection.py b/juju/client/connection.py index e79f2ea7e..88c31c2ab 100644 --- a/juju/client/connection.py +++ b/juju/client/connection.py @@ -27,7 +27,7 @@ from .facade_versions import client_facade_versions, known_unsupported_facades SpecifiedFacades: TypeAlias = "dict[str, dict[Literal['versions'], Sequence[int]]]" -_WebSocket: TypeAlias = "websockets.legacy.client.WebSocketClientProtocol" +_WebSocket: TypeAlias = websockets.WebSocketClientProtocol LEVELS = ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"] log = logging.getLogger("juju.client.connection") @@ -291,7 +291,7 @@ def is_using_old_client(self): def is_open(self): return self.monitor.status == Monitor.CONNECTED - def _get_ssl(self, cert=None): + def _get_ssl(self, cert: str | None = None) -> ssl.SSLContext: context = ssl.create_default_context( purpose=ssl.Purpose.SERVER_AUTH, cadata=cert ) @@ -305,7 +305,9 @@ def _get_ssl(self, cert=None): context.check_hostname = False return context - async def _open(self, endpoint, cacert) -> tuple[_WebSocket, str, str, str]: + async def _open( + self, endpoint: str, cacert: str + ) -> tuple[_WebSocket, str, str, str]: if self.is_debug_log_connection: assert self.uuid url = f"wss://user-{self.username}:{self.password}@{endpoint}/model/{self.uuid}/log" @@ -323,10 +325,6 @@ async def _open(self, endpoint, cacert) -> tuple[_WebSocket, str, str, str]: sock = self.proxy.socket() server_hostname = "juju-app" - def _exit_tasks(): - for task in jasyncio.all_tasks(): - task.cancel() - return ( ( await websockets.connect( @@ -342,7 +340,7 @@ def _exit_tasks(): cacert, ) - async def close(self, to_reconnect=False): + async def close(self, to_reconnect: bool = False): if not self._ws: return self.monitor.close_called.set() @@ -380,11 +378,7 @@ async def close(self, to_reconnect=False): async def _recv(self, request_id: int) -> dict[str, Any]: if not self.is_open: - raise websockets.exceptions.ConnectionClosed( - websockets.frames.Close( - websockets.frames.CloseCode.NORMAL_CLOSURE, "websocket closed" - ) - ) + raise websockets.exceptions.ConnectionClosedOK(None, None) try: return await self.messages.get(request_id) except GeneratorExit: @@ -626,7 +620,7 @@ async def rpc( return result - def _http_headers(self): + def _http_headers(self) -> dict[str, str]: """Return dictionary of http headers necessary for making an http connection to the endpoint of this Connection. @@ -640,7 +634,7 @@ def _http_headers(self): token = base64.b64encode(creds.encode()) return {"Authorization": f"Basic {token.decode()}"} - def https_connection(self): + def https_connection(self) -> tuple[HTTPSConnection, dict[str, str], str]: """Return an https connection to this Connection's endpoint. Returns a 3-tuple containing:: diff --git a/juju/client/connector.py b/juju/client/connector.py index c9be0cda4..cc3079028 100644 --- a/juju/client/connector.py +++ b/juju/client/connector.py @@ -50,7 +50,7 @@ def __init__( self.model_name = None self.jujudata = jujudata or FileJujuData() - def is_connected(self): + def is_connected(self) -> bool: """Report whether there is a currently connected controller or not""" return self._connection is not None @@ -60,6 +60,7 @@ def connection(self) -> Connection: """ if not self.is_connected(): raise NoConnectionException("not connected") + assert self._connection return self._connection async def connect(self, **kwargs): diff --git a/juju/model.py b/juju/model.py index ada4ac29b..8dd7d747d 100644 --- a/juju/model.py +++ b/juju/model.py @@ -19,17 +19,17 @@ from datetime import datetime, timedelta from functools import partial from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any, Literal, Mapping, overload import websockets import yaml +from typing_extensions import deprecated from . import jasyncio, provisioner, tag, utils from .annotationhelper import _get_annotations, _set_annotations from .bundle import BundleHandler, get_charm_series, is_local_charm from .charmhub import CharmHub -from .client import client, connector -from .client.connection import Connection +from .client import client, connection, connector from .client.overrides import Caveat, Macaroon from .constraints import parse as parse_constraints from .controller import ConnectedController, Controller @@ -58,6 +58,14 @@ from .url import URL, Schema from .version import DEFAULT_ARCHITECTURE +if TYPE_CHECKING: + from .application import Application + from .client._definitions import FullStatus + from .machine import Machine + from .relation import Relation + from .remoteapplication import ApplicationOffer, RemoteApplication + from .unit import Unit + log = logging.getLogger(__name__) @@ -134,7 +142,35 @@ def __init__(self, model): self.model = model self.state = dict() - def _live_entity_map(self, entity_type): + @overload + def _live_entity_map( + self, entity_type: Literal["application"] + ) -> dict[str, Application]: ... + + @overload + def _live_entity_map( + self, entity_type: Literal["applicationOffer"] + ) -> dict[str, ApplicationOffer]: ... + + @overload + def _live_entity_map( + self, entity_type: Literal["machine"] + ) -> dict[str, Machine]: ... + + @overload + def _live_entity_map( + self, entity_type: Literal["relation"] + ) -> dict[str, Relation]: ... + + @overload + def _live_entity_map( + self, entity_type: Literal["remoteApplication"] + ) -> dict[str, RemoteApplication]: ... + + @overload + def _live_entity_map(self, entity_type: Literal["unit"]) -> dict[str, Unit]: ... + + def _live_entity_map(self, entity_type: str) -> Mapping[str, ModelEntity]: """Return an id:Entity map of all the living entities of type ``entity_type``. @@ -146,7 +182,7 @@ def _live_entity_map(self, entity_type): } @property - def applications(self): + def applications(self) -> dict[str, Application]: """Return a map of application-name:Application for all applications currently in the model. @@ -154,7 +190,7 @@ def applications(self): return self._live_entity_map("application") @property - def remote_applications(self): + def remote_applications(self) -> dict[str, RemoteApplication]: """Return a map of application-name:Application for all remote applications currently in the model. @@ -162,14 +198,14 @@ def remote_applications(self): return self._live_entity_map("remoteApplication") @property - def application_offers(self): + def application_offers(self) -> dict[str, ApplicationOffer]: """Return a map of application-name:Application for all applications offers currently in the model. """ return self._live_entity_map("applicationOffer") @property - def machines(self): + def machines(self) -> dict[str, Machine]: """Return a map of machine-id:Machine for all machines currently in the model. @@ -177,7 +213,7 @@ def machines(self): return self._live_entity_map("machine") @property - def units(self): + def units(self) -> dict[str, Unit]: """Return a map of unit-id:Unit for all units currently in the model. @@ -185,12 +221,12 @@ def units(self): return self._live_entity_map("unit") @property - def subordinate_units(self): + def subordinate_units(self) -> dict[str, Unit]: """Return a map of unit-id:Unit for all subordinate units""" return {u_name: u for u_name, u in self.units.items() if u.is_subordinate} @property - def relations(self): + def relations(self) -> dict[str, Relation]: """Return a map of relation-id:Relation for all relations currently in the model. @@ -232,7 +268,9 @@ def apply_delta(self, delta): entity = self.get_entity(delta.entity, delta.get_id()) return entity.previous(), entity - def get_entity(self, entity_type, entity_id, history_index=-1, connected=True): + def get_entity( + self, entity_type, entity_id, history_index=-1, connected=True + ) -> ModelEntity | None: """Return an object instance for the given entity_type and id. By default the object state matches the most recent state from @@ -260,6 +298,11 @@ class ModelEntity: """An object in the Model tree""" entity_id: str + model: Model + _history_index: int + connected: bool + connection: connection.Connection + _status: str def __init__( self, @@ -581,6 +624,9 @@ async def resolve( class Model: """The main API for interacting with a Juju model.""" + connector: connector.Connector + state: ModelState + def __init__( self, max_frame_size=None, @@ -625,7 +671,7 @@ def is_connected(self): """Reports whether the Model is currently connected.""" return self._connector.is_connected() - def connection(self) -> Connection: + def connection(self) -> connection.Connection: """Return the current Connection object. It raises an exception if the Model is disconnected """ @@ -756,13 +802,14 @@ async def connect_current(self): """ return await self.connect() + @deprecated("Model.connect_to() is deprecated and will be removed soon") async def connect_to(self, connection): conn_params = connection.connect_params() await self._connect_direct(**conn_params) async def _connect_direct(self, **kwargs): - if self.info: - uuid = self.info.uuid + if self._info: + uuid = self._info.uuid elif "uuid" in kwargs: uuid = kwargs["uuid"] else: @@ -1110,7 +1157,7 @@ def tag(self): return tag.model(self.uuid) @property - def applications(self): + def applications(self) -> dict[str, Application]: """Return a map of application-name:Application for all applications currently in the model. @@ -1118,7 +1165,7 @@ def applications(self): return self.state.applications @property - def remote_applications(self): + def remote_applications(self) -> dict[str, RemoteApplication]: """Return a map of application-name:Application for all remote applications currently in the model. @@ -1126,14 +1173,14 @@ def remote_applications(self): return self.state.remote_applications @property - def application_offers(self): + def application_offers(self) -> dict[str, ApplicationOffer]: """Return a map of application-name:Application for all applications offers currently in the model. """ return self.state.application_offers @property - def machines(self): + def machines(self) -> dict[str, Machine]: """Return a map of machine-id:Machine for all machines currently in the model. @@ -1141,7 +1188,7 @@ def machines(self): return self.state.machines @property - def units(self): + def units(self) -> dict[str, Unit]: """Return a map of unit-id:Unit for all units currently in the model. @@ -1149,7 +1196,7 @@ def units(self): return self.state.units @property - def subordinate_units(self): + def subordinate_units(self) -> dict[str, Unit]: """Return a map of unit-id:Unit for all subordiante units currently in the model. @@ -1157,7 +1204,7 @@ def subordinate_units(self): return self.state.subordinate_units @property - def relations(self): + def relations(self) -> list[Relation]: """Return a list of all Relations currently in the model.""" return list(self.state.relations.values()) @@ -1177,11 +1224,15 @@ def name(self): return self._info.name @property - def info(self): + def info(self) -> ModelInfo: """Return the cached client.ModelInfo object for this Model. - If Model.get_info() has not been called, this will return None. + If Model.get_info() has not been called, this will raise an error. """ + if not self.is_connected(): + raise JujuModelError("Model is not connected") + + assert self._info is not None return self._info @property @@ -1271,12 +1322,14 @@ async def _all_watcher(): del allwatcher.Id continue except websockets.ConnectionClosed: - monitor = self.connection().monitor - if monitor.status == monitor.ERROR: + if self.connection().monitor.status == connection.Monitor.ERROR: # closed unexpectedly, try to reopen log.warning("Watcher: connection closed, reopening") await self.connection().reconnect() - if monitor.status != monitor.CONNECTED: + if ( + self.connection().monitor.status + != connection.Monitor.CONNECTED + ): # reconnect failed; abort and shutdown log.error( "Watcher: automatic reconnect " @@ -2589,7 +2642,7 @@ async def get_action_status(self, uuid_or_prefix=None, name=None): results[tag.untag("action-", a.action.tag)] = a.status return results - async def get_status(self, filters=None, utc=False): + async def get_status(self, filters=None, utc=False) -> FullStatus: """Return the status of the model. :param str filters: Optional list of applications, units, or machines @@ -2924,15 +2977,15 @@ async def _get_source_api(self, url): async def wait_for_idle( self, apps: list[str] | None = None, - raise_on_error=True, - raise_on_blocked=False, - wait_for_active=False, - timeout=10 * 60, - idle_period=15, - check_freq=0.5, - status=None, - wait_for_at_least_units=None, - wait_for_exact_units=None, + raise_on_error: bool = True, + raise_on_blocked: bool = False, + wait_for_active: bool = False, + timeout: float | None = 10 * 60, + idle_period: float = 15, + check_freq: float = 0.5, + status: str | None = None, + wait_for_at_least_units: int | None = None, + wait_for_exact_units: int | None = None, ) -> None: """Wait for applications in the model to settle into an idle state. @@ -3000,12 +3053,12 @@ async def wait_for_idle( raise JujuError(f"Expected a List[str] for apps, given {apps}") apps = apps or self.applications - idle_times = {} - units_ready = set() # The units that are in the desired state - last_log_time = None + idle_times: dict[str, datetime] = {} + units_ready: set[str] = set() # The units that are in the desired state + last_log_time: datetime | None = None log_interval = timedelta(seconds=30) - def _raise_for_status(entities, status): + def _raise_for_status(entities: dict[str, list[str]], status: Any): if not entities: return for entity_name, error_type in ( diff --git a/juju/status.py b/juju/status.py index 81ebbd9b4..12336d47b 100644 --- a/juju/status.py +++ b/juju/status.py @@ -1,43 +1,72 @@ # Copyright 2023 Canonical Ltd. # Licensed under the Apache V2, see LICENCE file for details. +from __future__ import annotations import logging +import sys import warnings +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from backports.strenum import StrEnum + from .client import client log = logging.getLogger(__name__) -""" derive_status is used to determine the application status from a set of unit -status values. - -:param statues: list of known unit workload statues -""" +class StatusStr(StrEnum): + """Recognised status values. + Please keep this set exact same as the severity map below. + """ -def derive_status(statues): - current = "unknown" - for status in statues: - if status in severities and severities[status] > severities[current]: - current = status - return current + error = "error" + blocked = "blocked" + waiting = "waiting" + maintenance = "maintenance" + active = "active" + terminated = "terminated" + unknown = "unknown" -""" severities holds status values with a severity measure. +""" severity_map holds status values with a severity measure. Status values with higher severity are used in preference to others. """ -severities = { - "error": 100, - "blocked": 90, - "waiting": 80, - "maintenance": 70, - "active": 60, - "terminated": 50, - "unknown": 40, +severity_map: dict[StatusStr, int] = { + # FIXME: Juju defines a lot more status values #1204 + StatusStr.error: 100, + StatusStr.blocked: 90, + StatusStr.waiting: 80, + StatusStr.maintenance: 70, + StatusStr.active: 60, + StatusStr.terminated: 50, + StatusStr.unknown: 40, } +def derive_status(statuses: list[str | StatusStr]) -> StatusStr: + """Derive status from a set. + + derive_status is used to determine the application status from a set of unit + status values. + + :param statuses: list of known unit workload statuses + """ + current: StatusStr = StatusStr.unknown + for status in statuses: + try: + status = StatusStr(status) + except ValueError: + # Unknown Juju status, let's assume it's least important + continue + + if severity_map[status] > severity_map[current]: + current = status + return current + + async def formatted_status(model, target=None, raw=False, filters=None): """Returns a string that mimics the content of the information returned in the juju status command. If the raw parameter is diff --git a/juju/unit.py b/juju/unit.py index b0d66a49e..4cb8e2591 100644 --- a/juju/unit.py +++ b/juju/unit.py @@ -15,6 +15,8 @@ class Unit(model.ModelEntity): + name: str + @property def agent_status(self): """Returns the current agent status string.""" diff --git a/juju/url.py b/juju/url.py index 1f915bff0..3ad905465 100644 --- a/juju/url.py +++ b/juju/url.py @@ -1,5 +1,6 @@ # Copyright 2023 Canonical Ltd. # Licensed under the Apache V2, see LICENCE file for details. +from __future__ import annotations from enum import Enum from urllib.parse import urlparse @@ -8,6 +9,8 @@ class Schema(Enum): + """Charm URL schema kinds.""" + LOCAL = "local" CHARM_STORE = "cs" CHARM_HUB = "ch" @@ -20,18 +23,26 @@ def __str__(self): class URL: + """Private URL class for this library internals only. + + Should be instantiated by `URL.parse` constructor. + """ + + name: str + def __init__( self, schema, user=None, - name=None, + name: str | None = None, revision=None, series=None, architecture=None, ): self.schema = schema self.user = user - self.name = name + # the parse method will set the correct value later + self.name = name # type: ignore self.series = series # 0 can be a valid revision, hence the more verbose check. @@ -41,7 +52,7 @@ def __init__( self.architecture = architecture @staticmethod - def parse(s, default_store=Schema.CHARM_HUB): + def parse(s: str, default_store=Schema.CHARM_HUB) -> URL: """Parse parses the provided charm URL string into its respective structure. @@ -103,7 +114,7 @@ def __str__(self): return f"{self.schema!s}:{self.path()}" -def parse_v1_url(schema, u, s): +def parse_v1_url(schema, u, s) -> URL: c = URL(schema) parts = u.path.split("/") @@ -135,7 +146,7 @@ def parse_v1_url(schema, u, s): return c -def parse_v2_url(u, s, default_store): +def parse_v2_url(u, s, default_store) -> URL: if not u.scheme: c = URL(default_store) elif Schema.CHARM_HUB.matches(u.scheme): diff --git a/pyproject.toml b/pyproject.toml index 722cbf920..f3c1118ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Python library for Juju" readme = "docs/readme.rst" license = { file = "LICENSE" } maintainers = [{name = "Juju Ecosystem Engineering", email = "juju@lists.ubuntu.com"}] -requires-python = ">=3.8" +requires-python = ">=3.8.6" classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", @@ -25,7 +25,7 @@ dependencies = [ "macaroonbakery>=1.1,<2.0", "pyRFC3339>=1.0,<2.0", "pyyaml>=5.1.2", - "websockets>=8.1,<14.0", + "websockets>=13.0.1,<14.0", "paramiko>=2.4.0", "pyasn1>=0.4.4", "toposort>=1.5,<2", @@ -33,7 +33,20 @@ dependencies = [ "kubernetes>=12.0.1,<31.0.0", "hvac", "packaging", - "typing-extensions>=4.5.0" + "typing-extensions>=4.5.0", + 'backports.strenum>=1.3.1; python_version < "3.11"', +] +[project.optional-dependencies] +dev = [ + "typing-inspect", + "pytest", + "pytest-asyncio", + "Twine", +] +docs = [ + "sphinx==5.3.0", + "sphinxcontrib-asyncio", + "sphinx_rtd_theme", ] [project.urls] @@ -212,7 +225,7 @@ ignore = [ [tool.pyright] # These are tentative include = ["**/*.py"] -pythonVersion = "3.8" +pythonVersion = "3.8.6" typeCheckingMode = "strict" useLibraryCodeForTypes = true reportGeneralTypeIssues = true diff --git a/setup.py b/setup.py index 0c22a6e05..5ee1fe8c1 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ "macaroonbakery>=1.1,<2.0", "pyRFC3339>=1.0,<2.0", "pyyaml>=5.1.2", - "websockets>=8.1,<14.0", + "websockets>=13.0.1,<14.0", "paramiko>=2.4.0", "pyasn1>=0.4.4", "toposort>=1.5,<2", @@ -32,7 +32,16 @@ "hvac", "packaging", "typing-extensions>=4.5.0", + 'backports.strenum>=1.3.1; python_version < "3.11"', ], + extras_require={ + "dev": [ + "typing-inspect", + "pytest", + "pytest-asyncio", + "Twine", + ] + }, include_package_data=True, maintainer="Juju Ecosystem Engineering", maintainer_email="juju@lists.ubuntu.com", diff --git a/tests/integration/test_model.py b/tests/integration/test_model.py index e843a774d..e46594807 100644 --- a/tests/integration/test_model.py +++ b/tests/integration/test_model.py @@ -10,7 +10,6 @@ from unittest import mock import paramiko -import pylxd import pytest from juju import jasyncio, tag, url @@ -29,6 +28,11 @@ from ..utils import GB, INTEGRATION_TEST_DIR, MB, OVERLAYS_DIR, SSH_KEY, TESTS_DIR +@pytest.fixture +def pylxd(): + return pytest.importorskip("pylxd") + + @base.bootstrapped async def test_model_name(): model = Model() @@ -532,7 +536,7 @@ async def test_add_machine(): assert len(model.machines) == 0 -async def add_manual_machine_ssh(is_root=False): +async def add_manual_machine_ssh(pylxd, is_root=False): # Verify controller is localhost async with base.CleanController() as controller: cloud = await controller.get_cloud() @@ -677,7 +681,7 @@ def wait_for_network(container, timeout=30): @base.bootstrapped -async def test_add_manual_machine_ssh(): +async def test_add_manual_machine_ssh(pylxd): """Test manual machine provisioning with a non-root user. Tests manual machine provisioning using a randomized username with @@ -687,9 +691,9 @@ async def test_add_manual_machine_ssh(): @base.bootstrapped -async def test_add_manual_machine_ssh_root(): +async def test_add_manual_machine_ssh_root(pylxd): """Test manual machine provisioning with the root user.""" - await add_manual_machine_ssh(is_root=True) + await add_manual_machine_ssh(pylxd, is_root=True) @base.bootstrapped diff --git a/tests/validate/test_status_severity.py b/tests/validate/test_status_severity.py new file mode 100644 index 000000000..beec25885 --- /dev/null +++ b/tests/validate/test_status_severity.py @@ -0,0 +1,10 @@ +from juju.status import StatusStr, severity_map + + +def test_status_str_values(): + for name, value in StatusStr._member_map_.items(): + assert name == value + + +def test_severity_map(): + assert set(StatusStr._member_names_) == set(severity_map) diff --git a/tox.ini b/tox.ini index 0e97e6c8e..1e6071c0c 100644 --- a/tox.ini +++ b/tox.ini @@ -9,33 +9,19 @@ envlist = py3,py38,py39,py310,py311,docs skipsdist=True [testenv] -usedevelop=True -commands = - pip install urllib3<2 - pip install pylxd - pytest --tb native -s -k 'not integration' -m 'not serial' {posargs} +use_develop = True +# This should work, but doesn't. Hence the deps= below +# extras = dev +deps = + .[dev] passenv = HOME TEST_AGENTS LXD_DIR -deps = - macaroonbakery - toposort - typing-inspect - paramiko - ipdb - pytest - pytest-asyncio - Twine - websockets<14.0 - kubernetes<31.0.0 - hvac - packaging - setuptools [testenv:docs] deps = - -r docs/requirements.txt + .[dev,docs] allowlist_externals = rm commands = @@ -45,8 +31,6 @@ commands = [testenv:integration] envdir = {toxworkdir}/py3 commands = - pip install urllib3<2 - pip install pylxd pytest \ --tb native \ -k 'integration' \ @@ -58,8 +42,6 @@ commands = [testenv:integration-quarantine] envdir = {toxworkdir}/py3 commands = - pip install urllib3<2 - pip install pylxd pytest \ --tb native \ -m 'not serial' \ @@ -70,8 +52,6 @@ commands = [testenv:unit] envdir = {toxworkdir}/py3 commands = - pip install urllib3<2 - pip install pylxd pytest {toxinidir}/tests/unit {posargs} [testenv:serial] @@ -80,8 +60,6 @@ commands = # it doesn't get run in CI envdir = {toxworkdir}/py3 commands = - pip install urllib3<2 - pip install pylxd pytest --tb native -s {posargs:-m 'serial'} [testenv:validate]