diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..0ab2976f1 --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +PROJECT=craft_providers + +include common.mk + +.PHONY: format +format: format-ruff format-codespell ## Run all automatic formatters + +.PHONY: lint +lint: lint-ruff lint-codespell lint-mypy lint-pyright lint-shellcheck lint-yaml lint-docs lint-twine ## Run all linters + +.PHONY: pack +pack: pack-pip ## Build all packages + +.PHONY: publish +publish: publish-pypi ## Publish packages + +.PHONY: publish-pypi +publish-pypi: clean package-pip lint-twine ##- Publish Python packages to pypi + uv tool run twine upload dist/* + +# Used for installing build dependencies in CI. +.PHONY: install-build-deps +install-build-deps: install-lint-build-deps +ifeq ($(shell which apt-get),) + $(warning Cannot install build dependencies without apt.) +else ifeq ($(wildcard /usr/include/libxml2/libxml/xpath.h),) + sudo $(APT) install libxml2-dev libxslt1-dev python3-venv +else ifeq ($(wildcard /usr/include/libxslt/xslt.h),) + sudo $(APT) install libxslt1-dev python3-venv +else ifeq ($(wildcard /usr/share/doc/python3-venv/copyright),) + sudo $(APT) install python3-venv +endif + +# If additional build dependencies need installing in order to build the linting env. +.PHONY: install-lint-build-deps +install-lint-build-deps: diff --git a/common.mk b/common.mk new file mode 100644 index 000000000..621c11af3 --- /dev/null +++ b/common.mk @@ -0,0 +1,269 @@ +# Common items for all Starcraft Makefiles. Should only be edited in the `starbase` repository: +# https://github.com/canonical/starbase + +SOURCES=$(wildcard *.py) $(PROJECT) tests +DOCS=docs + +ifneq ($(OS),Windows_NT) + OS := $(shell uname) +endif +ifdef CI + APT := apt-get --yes +else + APT := apt-get +endif + +.DEFAULT_GOAL := help + +.ONESHELL: + +.SHELLFLAGS = -ec + +.PHONY: help +help: ## Show this help. + @printf "\e[1m%-30s\e[0m | \e[1m%s\e[0m\n" "Target" "Description" + printf "\e[2m%-30s + %-41s\e[0m\n" "------------------------------" "------------------------------------------------" + egrep '^[^:]+\: [^#]*##' $$(echo $(MAKEFILE_LIST) | tac --separator=' ') | sed -e 's/^[^:]*://' -e 's/:[^#]*/ /' | sort -V| awk -F '[: ]*' \ + '{ + if ($$2 == "##") + { + $$1=sprintf(" %-28s", $$1); + $$2=" | "; + print $$0; + } + else + { + $$1=sprintf(" └ %-25s", $$1); + $$2=" | "; + $$3=sprintf(" └ %s", $$3); + print $$0; + } + }' + +.PHONY: setup +setup: install-uv setup-precommit ## Set up a development environment + uv sync --frozen --all-extras + +.PHONY: setup-tests +setup-tests: install-uv install-build-deps ##- Set up a testing environment without linters + uv sync --frozen + +.PHONY: setup-lint +setup-lint: install-uv install-shellcheck install-pyright install-lint-build-deps ##- Set up a linting-only environment + uv sync --frozen --no-install-workspace --extra lint --extra types + +.PHONY: setup-docs +setup-docs: install-uv ##- Set up a documentation-only environment + uv sync --frozen --no-dev --no-install-workspace --extra docs + +.PHONY: setup-precommit +setup-precommit: install-uv ##- Set up pre-commit hooks in this repository. +ifeq ($(shell which pre-commit),) + uv tool install pre-commit +endif +ifeq ($(shell which pre-commit),) + uv tool run pre-commit install +else + pre-commit install +endif + +.PHONY: clean +clean: ## Clean up the development environment + uv tool run pyclean . + rm -rf dist/ build/ docs/_build/ *.snap .coverage* + +.PHONY: autoformat +autoformat: format # Hidden alias for 'format' + +.PHONY: format-ruff +format-ruff: install-ruff ##- Automatically format with ruff + success=true + ruff check --fix $(SOURCES) || success=false + ruff format $(SOURCES) + $$success || exit 1 + +.PHONY: format-codespell +format-codespell: ##- Fix spelling issues with codespell + uv run codespell --toml pyproject.toml --write-changes $(SOURCES) + +.PHONY: lint-ruff +lint-ruff: install-ruff ##- Lint with ruff +ifneq ($(CI),) + @echo ::group::$@ +endif + ruff check $(SOURCES) + ruff format --diff $(SOURCES) +ifneq ($(CI),) + @echo ::endgroup:: +endif + +.PHONY: lint-codespell +lint-codespell: ##- Check spelling with codespell +ifneq ($(CI),) + @echo ::group::$@ +endif + uv run codespell --toml pyproject.toml $(SOURCES) +ifneq ($(CI),) + @echo ::endgroup:: +endif + +.PHONY: lint-mypy +lint-mypy: ##- Check types with mypy +ifneq ($(CI),) + @echo ::group::$@ +endif + uv run mypy --show-traceback --show-error-codes $(PROJECT) +ifneq ($(CI),) + @echo ::endgroup:: +endif + +.PHONY: lint-pyright +lint-pyright: ##- Check types with pyright +ifneq ($(CI),) + @echo ::group::$@ +endif +ifneq ($(shell which pyright),) # Prefer the system pyright + pyright --pythonpath .venv/bin/python +else + uv tool run pyright --pythonpath .venv/bin/python +endif +ifneq ($(CI),) + @echo ::endgroup:: +endif + +.PHONY: lint-shellcheck +lint-shellcheck: ##- Lint shell scripts +ifneq ($(CI),) + @echo ::group::$@ +endif + git ls-files | file --mime-type -Nnf- | grep shellscript | cut -f1 -d: | xargs -r shellcheck +ifneq ($(CI),) + @echo ::endgroup:: +endif + +.PHONY: lint-yaml +lint-yaml: ##- Lint YAML files with yamllint +ifneq ($(CI),) + @echo ::group::$@ +endif + uv run --extra lint yamllint . +ifneq ($(CI),) + @echo ::endgroup:: +endif + +.PHONY: lint-docs +lint-docs: ##- Lint the documentation +ifneq ($(CI),) + @echo ::group::$@ +endif + uv run --extra docs sphinx-lint --max-line-length 88 --enable all $(DOCS) +ifneq ($(CI),) + @echo ::endgroup:: +endif + +.PHONY: lint-twine +lint-twine: pack-pip ##- Lint Python packages with twine +ifneq ($(CI),) + @echo ::group::$@ +endif + uv tool run twine check dist/* +ifneq ($(CI),) + @echo ::endgroup:: +endif + +.PHONY: test +test: ## Run all tests + uv run pytest + +.PHONY: test-fast +test-fast: ##- Run fast tests + uv run pytest -m 'not slow' + +.PHONY: test-slow +test-slow: ##- Run slow tests + uv run pytest -m 'slow' + +.PHONY: test-coverage +test-coverage: ## Generate coverage report + uv run coverage run --source $(PROJECT) -m pytest + uv run coverage xml -o coverage.xml + uv run coverage report -m + uv run coverage html + +.PHONY: docs +docs: ## Build documentation + uv run --extra docs sphinx-build -b html -W $(DOCS) $(DOCS)/_build + +.PHONY: docs-auto +docs-auto: ## Build and host docs with sphinx-autobuild + uv run --extra docs sphinx-autobuild -b html --open-browser --port=8080 --watch $(PROJECT) -W $(DOCS) $(DOCS)/_build + +.PHONY: pack-pip +pack-pip: ##- Build packages for pip (sdist, wheel) +ifneq ($(CI),) + @echo ::group::$@ +endif + uv build . +ifneq ($(CI),) + @echo ::endgroup:: +endif + +# Below are intermediate targets for setup. They are not included in help as they should +# not be used independently. + +.PHONY: install-uv +install-uv: +ifneq ($(shell which uv),) +else ifneq ($(shell which snap),) + sudo snap install --classic astral-uv +else ifneq ($(shell which brew),) + brew install uv +else ifeq ($(OS),Windows_NT) + pwsh -c "irm https://astral.sh/uv/install.ps1 | iex" +else + curl -LsSf https://astral.sh/uv/install.sh | sh +endif + +.PHONY: install-codespell +install-codespell: +ifneq ($(shell which codespell),) +else ifneq ($(shell which snap),) + sudo snap install codespell +else ifneq ($(shell which brew),) + make install-uv + uv tool install codespell +else + $(warning Codespell not installed. Please install it yourself.) +endif + +.PHONY: install-pyright +install-pyright: install-uv +ifneq ($(shell which pyright),) +else ifneq ($(shell which snap),) + sudo snap install --classic pyright +else + # Workaround for a bug in npm + [ -d "$(HOME)/.npm/_cacache" ] && chown -R `id -u`:`id -g` "$(HOME)/.npm" || true + uv tool install pyright +endif + +.PHONY: install-ruff +install-ruff: +ifneq ($(shell which ruff),) +else ifneq ($(shell which snap),) + sudo snap install ruff +else + make install-uv + uv tool install ruff +endif + +.PHONY: install-shellcheck +install-shellcheck: +ifneq ($(shell which shellcheck),) +else ifneq ($(shell which snap),) + sudo snap install shellcheck +else ifneq ($(shell which brew),) + brew install shellcheck +else + $(warning Shellcheck not installed. Please install it yourself.) +endif diff --git a/craft_providers/bases/__init__.py b/craft_providers/bases/__init__.py index 7fa2283ae..5077f2d8d 100644 --- a/craft_providers/bases/__init__.py +++ b/craft_providers/bases/__init__.py @@ -1,5 +1,5 @@ # -# Copyright 2021-2023 Canonical Ltd. +# Copyright 2021-2025 Canonical Ltd. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -20,6 +20,9 @@ # Backward compatible, will be removed in 2.0 import sys from typing import Dict, Literal, NamedTuple, Tuple, Type, Union, overload +from typing_extensions import Self + +import craft_platforms from craft_providers.errors import BaseCompatibilityError, BaseConfigurationError from craft_providers.base import Base @@ -48,11 +51,24 @@ class BaseName(NamedTuple): - """A base image, by distribution and version.""" + """A base image, by distribution and version. + + DEPRECATED: This class is deprecated and will be replaced with the craft_platforms + DistroBase class in a future major release. + """ name: str version: str + @classmethod + def from_distro_base(cls, distro_base: craft_platforms.DistroBase) -> Self: + """Convert a DistroBase from craft-platforms to a craft-providers BaseName.""" + return cls(name=distro_base.distribution, version=distro_base.series) + + def to_distro_base(self) -> craft_platforms.DistroBase: + """Convert this to a craft-platforms DistroBase.""" + return craft_platforms.DistroBase(distribution=self.name, series=self.version) + BASE_NAME_TO_BASE_ALIAS: Dict[BaseName, BaseAlias] = { BaseName("ubuntu", "16.04"): ubuntu.BuilddBaseAlias.XENIAL, @@ -80,9 +96,16 @@ def get_base_alias( base_name: Tuple[Literal["almalinux"], str] ) -> almalinux.AlmaLinuxBaseAlias: ... @overload -def get_base_alias(base_name: BaseName) -> BaseAlias: ... +def get_base_alias(base_name: BaseName | craft_platforms.DistroBase) -> BaseAlias: ... def get_base_alias(base_name): - """Return a Base alias from a base (name, version) tuple.""" + """Return a Base alias from a base (name, version) tuple. + + :param: base_name: A tuple of (distribution, series), a BaseName, or a DistroBase + of the same. + :returns: A BaseAlias corresponding to the base name. + """ + if isinstance(base_name, craft_platforms.DistroBase): + base_name = BaseName.from_distro_base(base_name) base_name = BaseName(*base_name) if base_name.name == "ubuntu" and base_name in BASE_NAME_TO_BASE_ALIAS: return BASE_NAME_TO_BASE_ALIAS[base_name] @@ -114,4 +137,4 @@ def get_base_from_alias(alias: BaseAlias) -> Type[Base]: if isinstance(alias, almalinux.AlmaLinuxBaseAlias): return almalinux.AlmaLinuxBase - raise BaseConfigurationError(f"Base not found for alias {alias}") + raise BaseConfigurationError(f"Base not found for alias {alias!r}") diff --git a/docs/.custom_wordlist.txt b/docs/.custom_wordlist.txt index f926b9706..e6b3ce2eb 100644 --- a/docs/.custom_wordlist.txt +++ b/docs/.custom_wordlist.txt @@ -71,3 +71,4 @@ urllib VM VMs warmup +YYYY diff --git a/docs/reference/changelog.rst b/docs/reference/changelog.rst index ac0409358..0efd91f42 100644 --- a/docs/reference/changelog.rst +++ b/docs/reference/changelog.rst @@ -4,6 +4,11 @@ Changelog See the `Releases page`_ on GitHub for a complete list of commits that are included in each version. +X.Y.Z (YYYY-Mon-DD) +------------------- + +- ``bases.BaseName`` is now compatible with the ``craft-platforms.DistroBase``. + 2.2.0 (2025-Jan-16) ------------------- - ``hookutil.py`` now available for dependent projects to clean up lxd diff --git a/pyproject.toml b/pyproject.toml index 665769b8b..51fea00a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,11 +2,13 @@ name = "craft-providers" dynamic = ["version", "readme"] dependencies = [ + "craft-platforms~=0.5", "packaging>=14.1", "pydantic~=2.4", "pyyaml", "requests>=2.31", "requests_unixsocket2>=0.4.0", + "typing-extensions~=4.0", ] classifiers = [ "Development Status :: 4 - Beta", diff --git a/tests/unit/bases/test_base_name.py b/tests/unit/bases/test_base_name.py new file mode 100644 index 000000000..376366d70 --- /dev/null +++ b/tests/unit/bases/test_base_name.py @@ -0,0 +1,56 @@ +# +# Copyright 2021-2025 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +"""Tests for the BaseName class.""" + +import pytest +from craft_platforms import DistroBase +from craft_providers.bases import BaseName + +BASE_MAPPING = { + BaseName("ubuntu", "16.04"): DistroBase("ubuntu", "16.04"), + BaseName("ubuntu", "18.04"): DistroBase("ubuntu", "18.04"), + BaseName("ubuntu", "20.04"): DistroBase("ubuntu", "20.04"), + BaseName("ubuntu", "22.04"): DistroBase("ubuntu", "22.04"), + BaseName("ubuntu", "24.04"): DistroBase("ubuntu", "24.04"), + BaseName("ubuntu", "devel"): DistroBase("ubuntu", "devel"), + BaseName("centos", "7"): DistroBase("centos", "7"), +} + + +@pytest.mark.parametrize(("base_name", "distro_base"), [*BASE_MAPPING.items()]) +def test_from_distro_base(base_name: BaseName, distro_base: DistroBase): + assert BaseName.from_distro_base(distro_base) == base_name + + +@pytest.mark.parametrize(("base_name", "distro_base"), [*BASE_MAPPING.items()]) +def test_to_distro_base(base_name: BaseName, distro_base: DistroBase): + assert base_name.to_distro_base() == distro_base + + +@pytest.mark.parametrize(("base_name"), [*BASE_MAPPING.keys()]) +def test_round_trip_to_from(base_name: BaseName): + assert BaseName.from_distro_base(base_name.to_distro_base()) == base_name + + +@pytest.mark.parametrize(("distro_base"), [*BASE_MAPPING.values()]) +def test_round_trip_from_to(distro_base: DistroBase): + assert BaseName.from_distro_base(distro_base).to_distro_base() == distro_base + + +@pytest.mark.parametrize(("base_name", "distro_base"), [*BASE_MAPPING.items()]) +def test_equality(base_name, distro_base): + assert base_name == distro_base diff --git a/tests/unit/bases/test_get_base.py b/tests/unit/bases/test_get_base.py index df642cdfb..665d88f33 100644 --- a/tests/unit/bases/test_get_base.py +++ b/tests/unit/bases/test_get_base.py @@ -16,25 +16,58 @@ # import pytest -from craft_providers.bases import get_base_alias, get_base_from_alias, ubuntu +from craft_platforms import DistroBase +from craft_providers.bases import ( + BaseName, + almalinux, + centos, + get_base_alias, + get_base_from_alias, + ubuntu, +) from craft_providers.errors import BaseConfigurationError -def test_get_base_alias(): - assert get_base_alias(("ubuntu", "22.04")) == ubuntu.BuilddBaseAlias.JAMMY +@pytest.mark.parametrize( + ("base_name", "expected"), + [ + (("ubuntu", "22.04"), ubuntu.BuilddBaseAlias.JAMMY), + (("ubuntu", "24.04"), ubuntu.BuilddBaseAlias.NOBLE), + (BaseName("ubuntu", "devel"), ubuntu.BuilddBaseAlias.DEVEL), + (DistroBase("centos", "7"), centos.CentOSBaseAlias.SEVEN), + (DistroBase("centos", "7.4"), centos.CentOSBaseAlias.SEVEN), + ], +) +def test_get_base_alias(base_name, expected): + assert get_base_alias(base_name) == expected -def test_get_base_alias_does_not_exist(): - with pytest.raises(BaseConfigurationError) as exc_info: - get_base_alias(("ubuntu", "8.04")) - - assert exc_info.value == BaseConfigurationError( - brief="Base alias not found for BaseName(name='ubuntu', version='8.04')" - ) +@pytest.mark.parametrize( + "base_name", + [ + ("ubuntu", "8.04"), + ("debian", "1"), + ("ubuntu", "24"), + ("centos", "6"), + ], +) +def test_get_base_alias_does_not_exist(base_name): + with pytest.raises( + BaseConfigurationError, match=r"^Base alias not found for BaseName\(name='" + ): + get_base_alias(base_name) -def test_get_base_from_alias(): - assert get_base_from_alias(ubuntu.BuilddBaseAlias.JAMMY) == ubuntu.BuilddBase +@pytest.mark.parametrize( + ("alias", "expected"), + [ + *((alias, ubuntu.BuilddBase) for alias in ubuntu.BuilddBaseAlias), + *((alias, centos.CentOSBase) for alias in centos.CentOSBaseAlias), + *((alias, almalinux.AlmaLinuxBase) for alias in almalinux.AlmaLinuxBaseAlias), + ], +) +def test_get_base_from_alias(alias, expected): + assert get_base_from_alias(alias) == expected def test_get_base_from_alias_does_not_exist(): @@ -42,5 +75,5 @@ def test_get_base_from_alias_does_not_exist(): get_base_from_alias("ubuntu 8") # type: ignore assert exc_info.value == BaseConfigurationError( - brief="Base not found for alias ubuntu 8" + brief="Base not found for alias 'ubuntu 8'" )