Skip to content

Commit

Permalink
Add collection-meta.yaml linter.
Browse files Browse the repository at this point in the history
  • Loading branch information
felixfontein committed Aug 26, 2024
1 parent 2b55457 commit 8f246ee
Show file tree
Hide file tree
Showing 8 changed files with 286 additions and 37 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/antsibull-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ jobs:
options: '-e antsibull_ansible_version=8.99.0'
python: '3.9'
antsibull_changelog_ref: 0.24.0
antsibull_core_ref: stable-2
antsibull_core_ref: main
antsibull_docutils_ref: 1.0.0 # isn't used by antsibull-changelog 0.24.0
- name: Ansible 9
options: '-e antsibull_ansible_version=9.99.0'
python: '3.11'
antsibull_changelog_ref: main
antsibull_core_ref: stable-2
antsibull_core_ref: main
antsibull_docutils_ref: main
- name: Ansible 10
options: '-e antsibull_ansible_version=10.99.0'
Expand Down
4 changes: 4 additions & 0 deletions changelogs/fragments/617-collection-meta-linter.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
minor_changes:
- "Add subcommand ``lint-collection-meta`` for linting ``collection-meta.yaml`` files in ``ansible-build-data`` (https://github.com/ansible-community/antsibull/pull/617)."
breaking_changes:
- "Antsibull now depends on antsibull-core >= 3.0.0 and pydantic >= 2.0.0 (https://github.com/ansible-community/antsibull/pull/617)."
5 changes: 2 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ classifiers = [
requires-python = ">=3.9"
dependencies = [
"antsibull-changelog >= 0.24.0",
"antsibull-core >= 2.0.0, < 4.0.0",
"antsibull-core >= 3.0.0, < 4.0.0",
"asyncio-pool",
"build",
"jinja2",
Expand All @@ -36,8 +36,7 @@ dependencies = [
"aiofiles",
"aiohttp >= 3.0.0",
"twiggy",
# We rely on deprecated features to maintain compat btw. pydantic v1 and v2
"pydantic < 3",
"pydantic >= 2, < 3",
# pydantic already pulls it in, but we use it for TypedDict
"typing_extensions",
]
Expand Down
2 changes: 1 addition & 1 deletion src/antsibull/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@ def get_changelog(
PypiVer(ansible_ancestor_version_str) if ansible_ancestor_version_str else None
)

collection_metadata = CollectionsMetadata(deps_dir)
collection_metadata = CollectionsMetadata.load_from(deps_dir)

if deps_dir is not None:
for path in glob.glob(os.path.join(deps_dir, "*.deps"), recursive=False):
Expand Down
38 changes: 35 additions & 3 deletions src/antsibull/cli/antsibull_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
rebuild_single_command,
)
from ..build_changelog import build_changelog # noqa: E402
from ..collection_meta_lint import lint_collection_meta # noqa: E402
from ..constants import MINIMUM_ANSIBLE_VERSION, SANITY_TESTS_DEFAULT # noqa: E402
from ..dep_closure import validate_dependencies_command # noqa: E402
from ..from_source import verify_upstream_command # noqa: E402
Expand Down Expand Up @@ -84,6 +85,7 @@
"sanity-tests": sanity_tests_command,
"announcements": announcements_command,
"send-announcements": send_announcements_command,
"lint-collection-meta": lint_collection_meta,
}
DISABLE_VERIFY_UPSTREAMS_IGNORES_SENTINEL = "NONE"
DEFAULT_ANNOUNCEMENTS_DIR = Path("build/announce")
Expand All @@ -107,7 +109,11 @@ def _normalize_build_options(args: argparse.Namespace) -> None:
):
return

if args.ansible_version < MINIMUM_ANSIBLE_VERSION:
if (
(args.ansible_version < MINIMUM_ANSIBLE_VERSION)
if args.command != "lint-collection-meta"
else (args.ansible_major_version < MINIMUM_ANSIBLE_VERSION.major)
):
raise InvalidArgumentError(
f"Ansible < {MINIMUM_ANSIBLE_VERSION} is not supported"
" by this antsibull version."
Expand Down Expand Up @@ -136,8 +142,8 @@ def _normalize_build_write_data_options(args: argparse.Namespace) -> None:
)


def _normalize_new_release_options(args: argparse.Namespace) -> None:
if args.command != "new-ansible":
def _normalize_pieces_file_options(args: argparse.Namespace) -> None:
if args.command not in ("new-ansible", "lint-collection-meta"):
return

if args.pieces_file is None:
Expand All @@ -151,6 +157,11 @@ def _normalize_new_release_options(args: argparse.Namespace) -> None:
" per line"
)


def _normalize_new_release_options(args: argparse.Namespace) -> None:
if args.command != "new-ansible":
return

compat_version_part = f"{args.ansible_version.major}"

if args.build_file is None:
Expand Down Expand Up @@ -769,6 +780,26 @@ def parse_args(program_name: str, args: list[str]) -> argparse.Namespace:
dest="send_actions",
)

lint_collection_meta_parser = subparsers.add_parser(
"lint-collection-meta",
description="Lint the collection-meta.yaml file.",
)
lint_collection_meta_parser.add_argument(
"ansible_major_version",
type=int,
help="The X major version of Ansible that this will be for",
)
lint_collection_meta_parser.add_argument(
"--data-dir", default=".", help="Directory to read .build and .deps files from"
)
lint_collection_meta_parser.add_argument(
"--pieces-file",
default=None,
help="File containing a list of collections to include. This is"
" considered to be relative to --data-dir. The default is"
f" {DEFAULT_PIECES_FILE}",
)

# This must come after all parser setup
if HAS_ARGCOMPLETE:
argcomplete.autocomplete(parser)
Expand All @@ -780,6 +811,7 @@ def parse_args(program_name: str, args: list[str]) -> argparse.Namespace:
_normalize_commands(parsed_args)
_normalize_build_options(parsed_args)
_normalize_build_write_data_options(parsed_args)
_normalize_pieces_file_options(parsed_args)
_normalize_new_release_options(parsed_args)
_normalize_release_build_options(parsed_args)
_normalize_validate_tags_options(parsed_args)
Expand Down
86 changes: 60 additions & 26 deletions src/antsibull/collection_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,50 +13,84 @@

import os
import typing as t
from collections.abc import Mapping

import pydantic as p
from antsibull_core.yaml import load_yaml_file
from packaging.version import Version as PypiVer
from pydantic.functional_validators import BeforeValidator
from typing_extensions import Annotated


class CollectionMetadata:
def _convert_pypi_version(v: t.Any) -> t.Any:
if not isinstance(v, str):
raise ValueError(f"must be a string, got {v!r}")
if not v:
raise ValueError(f"must be a non-trivial string, got {v!r}")
version = PypiVer(v)
if len(version.release) != 3:
raise ValueError(
f"must be a version with three release numbers (e.g. 1.2.3, 2.3.4a1), got {v!r}"
)
return version


PydanticPypiVersion = Annotated[PypiVer, BeforeValidator(_convert_pypi_version)]


class RemovalInformation(p.BaseModel):
"""
Stores metadata on when and why a collection will get removed.
"""

model_config = p.ConfigDict(extra="ignore", arbitrary_types_allowed=True)

version: t.Union[int, t.Literal["TBD"]]
reason: t.Literal["deprecated", "considered-unmaintained", "renamed"]
announce_version: t.Optional[PydanticPypiVersion] = None
new_name: t.Optional[str] = None
discussion: t.Optional[p.HttpUrl] = None
redirect_replacement_version: t.Optional[int] = None


class CollectionMetadata(p.BaseModel):
"""
Stores metadata about one collection.
"""

changelog_url: str | None
collection_directory: str | None
repository: str | None
tag_version_regex: str | None
model_config = p.ConfigDict(extra="ignore")

def __init__(self, source: Mapping[str, t.Any] | None = None):
if source is None:
source = {}
self.changelog_url = source.get("changelog-url")
self.collection_directory = source.get("collection-directory")
self.repository = source.get("repository")
self.tag_version_regex = source.get("tag_version_regex")
changelog_url: t.Optional[str] = p.Field(alias="changelog-url", default=None)
collection_directory: t.Optional[str] = p.Field(
alias="collection-directory", default=None
)
repository: t.Optional[str] = None
tag_version_regex: t.Optional[str] = None
maintainers: list[str] = []
removal: t.Optional[RemovalInformation] = None


class CollectionsMetadata:
class CollectionsMetadata(p.BaseModel):
"""
Stores metadata about a set of collections.
"""

data: dict[str, CollectionMetadata]
model_config = p.ConfigDict(extra="ignore")

collections: dict[str, CollectionMetadata]

def __init__(self, deps_dir: str | None):
self.data = {}
if deps_dir is not None:
collection_meta_path = os.path.join(deps_dir, "collection-meta.yaml")
if os.path.exists(collection_meta_path):
data = load_yaml_file(collection_meta_path)
if data and "collections" in data:
for collection_name, collection_data in data["collections"].items():
self.data[collection_name] = CollectionMetadata(collection_data)
@staticmethod
def load_from(deps_dir: str | None):
if deps_dir is None:
return CollectionsMetadata(collections={})
collection_meta_path = os.path.join(deps_dir, "collection-meta.yaml")
if not os.path.exists(collection_meta_path):
return CollectionsMetadata(collections={})
data = load_yaml_file(collection_meta_path)
return CollectionsMetadata.parse_obj(data)

def get_meta(self, collection_name: str) -> CollectionMetadata:
result = self.data.get(collection_name)
result = self.collections.get(collection_name)
if result is None:
result = CollectionMetadata()
self.data[collection_name] = result
self.collections[collection_name] = result
return result
Loading

0 comments on commit 8f246ee

Please sign in to comment.