Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ Release 0.12.0 (unreleased)
====================================

* Internal refactoring: introduce superproject & subproject (#896)
* Switch from pykwalify to StrictYAML (#922)
* Show line number when manifest validation fails (#36)

Release 0.11.0 (released 2026-01-03)
====================================
Expand Down
42 changes: 42 additions & 0 deletions dfetch/manifest/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""StrictYAML schema for the manifest."""

from strictyaml import Bool, Enum, Float, Int, Map, Optional, Seq, Str

NUMBER = Int() | Float()

REMOTE_SCHEMA = Map(
{
"name": Str(),
"url-base": Str(),
Optional("default"): Bool(),
}
)

PROJECT_SCHEMA = Map(
{
"name": Str(),
Optional("dst"): Str(),
Optional("branch"): Str(),
Optional("tag"): Str(),
Optional("revision"): Str(),
Optional("url"): Str(),
Optional("repo-path"): Str(),
Optional("remote"): Str(),
Optional("patch"): Str(),
Optional("vcs"): Enum(["git", "svn"]),
Optional("src"): Str(),
Optional("ignore"): Seq(Str()),
}
)

MANIFEST_SCHEMA = Map(
{
"manifest": Map(
{
"version": NUMBER,
Optional("remotes"): Seq(REMOTE_SCHEMA),
"projects": Seq(PROJECT_SCHEMA),
}
)
}
)
68 changes: 51 additions & 17 deletions dfetch/manifest/validate.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,61 @@
"""Validate manifests."""
"""Validate manifests using StrictYAML."""

import logging
import pathlib
from collections.abc import Mapping
from typing import Any, cast

import pykwalify
from pykwalify.core import Core, SchemaError
from yaml.scanner import ScannerError
from strictyaml import StrictYAMLError, YAMLValidationError, load

import dfetch.resources
from dfetch.manifest.schema import MANIFEST_SCHEMA


def validate(path: str) -> None:
"""Validate the given manifest."""
logging.getLogger(pykwalify.__name__).setLevel(logging.CRITICAL)
def _ensure_unique(seq: list[dict[str, Any]], key: str, context: str) -> None:
"""Ensure values for `key` are unique within a sequence of dicts."""
values = [item.get(key) for item in seq if key in item]
seen: set[Any] = set()
dups: set[Any] = set()
for val in values:
if val in seen:
dups.add(val)
else:
seen.add(val)

if dups:
dup_list = ", ".join(sorted(map(str, dups)))
raise RuntimeError(
f"Schema validation failed:\nDuplicate {context}.{key} value(s): {dup_list}"
)


with dfetch.resources.schema_path() as schema_path:
try:
validator = Core(source_file=path, schema_files=[str(schema_path)])
except ScannerError as err:
raise RuntimeError(f"{schema_path} is not a valid YAML file!") from err
def validate(path: str) -> None:
"""Validate the given manifest file against the StrictYAML schema.

Raises:
RuntimeError: if the file is not valid YAML or violates the schema/uniqueness constraints.
"""
try:
validator.validate(raise_exception=True)
except SchemaError as err:

loaded_manifest = load(
pathlib.Path(path).read_text(encoding="UTF-8"), schema=MANIFEST_SCHEMA
)
except (YAMLValidationError, StrictYAMLError) as err:
raise RuntimeError(
str(err.msg) # pyright: ignore[reportAttributeAccessIssue, reportCallIssue]
"\n".join(
[
"Schema validation failed:",
"",
err.context_mark.get_snippet(),
"",
err.problem,
]
)
) from err

data: dict[str, Any] = cast(dict[str, Any], loaded_manifest.data)
manifest: Mapping[str, Any] = data["manifest"] # required
projects: list[dict[str, Any]] = manifest["projects"] # required
remotes: list[dict[str, Any]] = manifest.get("remotes", []) or [] # optional

_ensure_unique(remotes, "name", "manifest.remotes")
_ensure_unique(projects, "name", "manifest.projects")
_ensure_unique(projects, "dst", "manifest.projects")
6 changes: 3 additions & 3 deletions dfetch/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ def _resource_path(filename: str) -> ContextManager[Path]:
)


def schema_path() -> ContextManager[Path]:
"""Get path to schema."""
return _resource_path("schema.yaml")
def template_path() -> ContextManager[Path]:
"""Get path to template."""
return _resource_path("template.yaml")


TEMPLATE_PATH = _resource_path("template.yaml")
39 changes: 0 additions & 39 deletions dfetch/resources/schema.yaml

This file was deleted.

6 changes: 5 additions & 1 deletion features/updated-project-has-dependencies.feature
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,11 @@ Feature: Updated project has dependencies
Dfetch (0.11.0)
SomeProject : Fetched v1
SomeProject/dfetch.yaml: Schema validation failed:
- Value 'very-invalid-manifest' is not a dict. Value path: ''.

"very-invalid-manifest\n"
^ (line: 1)

found arbitrary text
"""
And 'MyProject' looks like:
"""
Expand Down
27 changes: 25 additions & 2 deletions features/validate-manifest.feature
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,29 @@ Feature: Validate a manifest
"""
Dfetch (0.11.0)
Schema validation failed:
- Cannot find required key 'manifest'. Path: ''.
- Key 'manifest-wrong' was not defined. Path: ''.

manifest-wrong:
^ (line: 1)

unexpected key not in schema 'manifest-wrong'
"""

Scenario: A manifest with duplicate project names
Given the manifest 'dfetch.yaml'
"""
manifest:
version: '0.0'
remotes:
- name: github-com-dfetch-org
url-base: https://github.com/dfetch-org/test-repo
projects:
- name: ext/test-repo-rev-only
- name: ext/test-repo-rev-only
"""
When I run "dfetch validate"
Then the output shows
"""
Dfetch (0.11.0)
Schema validation failed:
Duplicate manifest.projects.name value(s): ext/test-repo-rev-only
"""
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ classifiers = [
dependencies = [
"PyYAML==6.0.3",
"coloredlogs==15.0.1",
"pykwalify==1.8.0",
"strictyaml==1.7.3",
"halo==0.0.31",
"colorama==0.4.6",
"typing-extensions==4.15.0",
Expand Down
18 changes: 9 additions & 9 deletions tests/test_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@
import dfetch.resources


def test_schema_path() -> None:
"""Test that schema path can be used as context manager."""
def test_template_path() -> None:
"""Test that template path can be used as context manager."""

with dfetch.resources.schema_path() as schema_path:
assert os.path.isfile(schema_path)
with dfetch.resources.template_path() as template_path:
assert os.path.isfile(template_path)


def test_call_schema_path_twice() -> None:
def test_call_template_path_twice() -> None:
"""Had a lot of problems with calling contextmanager twice."""

with dfetch.resources.schema_path() as schema_path:
assert os.path.isfile(schema_path)
with dfetch.resources.template_path() as template_path:
assert os.path.isfile(template_path)

with dfetch.resources.schema_path() as schema_path:
assert os.path.isfile(schema_path)
with dfetch.resources.template_path() as template_path:
assert os.path.isfile(template_path)
Loading