Skip to content

Commit d9f342d

Browse files
ben-ednaspoorcc
authored andcommitted
Switch from pykwalify to StrictYAML
1 parent 70403e3 commit d9f342d

8 files changed

Lines changed: 102 additions & 70 deletions

File tree

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ Release 0.12.0 (unreleased)
22
====================================
33

44
* Internal refactoring: introduce superproject & subproject (#896)
5+
* Switch from pykwalify to StrictYAML (#0)
6+
* Show line number when manifest validation fails (#36)
57

68
Release 0.11.0 (released 2026-01-03)
79
====================================

dfetch/manifest/schema.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""StrictYAML schema for the manifest."""
2+
3+
from strictyaml import Bool, Enum, Float, Int, Map, Optional, Seq, Str
4+
5+
NUMBER = Int() | Float()
6+
7+
REMOTE_SCHEMA = Map(
8+
{
9+
"name": Str(),
10+
"url-base": Str(),
11+
Optional("default"): Bool(),
12+
}
13+
)
14+
15+
PROJECT_SCHEMA = Map(
16+
{
17+
"name": Str(),
18+
Optional("dst"): Str(),
19+
Optional("branch"): Str(),
20+
Optional("tag"): Str(),
21+
Optional("revision"): Str(),
22+
Optional("url"): Str(),
23+
Optional("repo-path"): Str(),
24+
Optional("remote"): Str(),
25+
Optional("patch"): Str(),
26+
Optional("vcs"): Enum(["git", "svn"]),
27+
Optional("src"): Str(),
28+
Optional("ignore"): Seq(Str()),
29+
}
30+
)
31+
32+
MANIFEST_SCHEMA = Map(
33+
{
34+
"manifest": Map(
35+
{
36+
"version": NUMBER,
37+
Optional("remotes"): Seq(REMOTE_SCHEMA),
38+
"projects": Seq(PROJECT_SCHEMA),
39+
}
40+
)
41+
}
42+
)

dfetch/manifest/validate.py

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,50 @@
1-
"""Validate manifests."""
1+
"""Validate manifests using StrictYAML."""
2+
3+
from __future__ import annotations
24

35
import logging
6+
from collections.abc import Mapping
7+
from typing import Any, cast
8+
9+
from strictyaml import StrictYAMLError, YAMLValidationError, load
10+
11+
from dfetch.manifest.schema import MANIFEST_SCHEMA
412

5-
import pykwalify
6-
from pykwalify.core import Core, SchemaError
7-
from yaml.scanner import ScannerError
813

9-
import dfetch.resources
14+
def _ensure_unique(seq: list[dict[str, Any]], key: str, context: str) -> None:
15+
"""Ensure values for `key` are unique within a sequence of dicts."""
16+
values = [item.get(key) for item in seq if key in item]
17+
seen = set()
18+
dups = {v for v in values if (v in seen) or seen.add(v)} # type: ignore
19+
if dups:
20+
dup_list = ", ".join(sorted(map(str, dups)))
21+
raise RuntimeError(f"Duplicate {context}.{key} value(s): {dup_list}")
1022

1123

1224
def validate(path: str) -> None:
13-
"""Validate the given manifest."""
14-
logging.getLogger(pykwalify.__name__).setLevel(logging.CRITICAL)
25+
"""Validate the given manifest file against the StrictYAML schema.
1526
16-
with dfetch.resources.schema_path() as schema_path:
17-
try:
18-
validator = Core(source_file=path, schema_files=[str(schema_path)])
19-
except ScannerError as err:
20-
raise RuntimeError(f"{schema_path} is not a valid YAML file!") from err
27+
Raises:
28+
RuntimeError: if the file is not valid YAML or violates the schema/uniqueness constraints.
29+
"""
30+
logging.getLogger("strictyaml").setLevel(logging.CRITICAL)
2131

2232
try:
23-
validator.validate(raise_exception=True)
24-
except SchemaError as err:
25-
raise RuntimeError(
26-
str(err.msg) # pyright: ignore[reportAttributeAccessIssue, reportCallIssue]
27-
) from err
33+
with open(path, encoding="utf-8") as f:
34+
loaded_manifest = load(f.read(), schema=MANIFEST_SCHEMA)
35+
except YAMLValidationError as err:
36+
# More specific: schema mismatch (missing required keys, wrong types, unknown keys).
37+
raise RuntimeError(str(err)) from err
38+
except StrictYAMLError as err:
39+
# Broader: parsing errors and strictness violations (e.g., forbidden constructs).
40+
raise RuntimeError(f"{path} is not a valid YAML file: {err}") from err
41+
42+
data: dict[str, Any] = cast(dict[str, Any], loaded_manifest.data)
43+
manifest: Mapping[str, Any] = data["manifest"] # required
44+
projects: list[dict[str, Any]] = manifest["projects"] # required
45+
remotes: list[dict[str, Any]] = manifest.get("remotes", []) or [] # option
46+
47+
# Enforce cross-item uniqueness constraints originally in pykwalify:
48+
_ensure_unique(remotes, "name", "manifest.remotes")
49+
_ensure_unique(projects, "name", "manifest.projects")
50+
_ensure_unique(projects, "dst", "manifest.projects") # only those that have 'dst'

dfetch/resources/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ def _resource_path(filename: str) -> ContextManager[Path]:
1717
)
1818

1919

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

2424

2525
TEMPLATE_PATH = _resource_path("template.yaml")

dfetch/resources/schema.yaml

Lines changed: 0 additions & 39 deletions
This file was deleted.

features/updated-project-has-dependencies.feature

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,11 @@ Feature: Updated project has dependencies
9090
Dfetch (0.11.0)
9191
SomeProject : Fetched v1
9292
SomeProject/dfetch.yaml: Schema validation failed:
93-
- Value 'very-invalid-manifest' is not a dict. Value path: ''.
93+
94+
"very-invalid-manifest\n"
95+
^ (line: 1)
96+
97+
found arbitrary text
9498
"""
9599
And 'MyProject' looks like:
96100
"""

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ classifiers = [
4141
dependencies = [
4242
"PyYAML==6.0.3",
4343
"coloredlogs==15.0.1",
44-
"pykwalify==1.8.0",
44+
"strictyaml==1.7.3",
4545
"halo==0.0.31",
4646
"colorama==0.4.6",
4747
"typing-extensions==4.15.0",

tests/test_resources.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,18 @@
88
import dfetch.resources
99

1010

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

14-
with dfetch.resources.schema_path() as schema_path:
15-
assert os.path.isfile(schema_path)
14+
with dfetch.resources.template_path() as template_path:
15+
assert os.path.isfile(template_path)
1616

1717

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

21-
with dfetch.resources.schema_path() as schema_path:
22-
assert os.path.isfile(schema_path)
21+
with dfetch.resources.template_path() as template_path:
22+
assert os.path.isfile(template_path)
2323

24-
with dfetch.resources.schema_path() as schema_path:
25-
assert os.path.isfile(schema_path)
24+
with dfetch.resources.template_path() as template_path:
25+
assert os.path.isfile(template_path)

0 commit comments

Comments
 (0)