Skip to content

Commit c0e5766

Browse files
spoorccben-edna
authored andcommitted
Load manifest only once
1 parent 716f0ea commit c0e5766

File tree

14 files changed

+144
-149
lines changed

14 files changed

+144
-149
lines changed

dfetch/commands/common.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
import yaml
66

77
from dfetch.log import get_logger
8-
from dfetch.manifest.manifest import Manifest, get_childmanifests
8+
from dfetch.manifest.manifest import Manifest
9+
from dfetch.manifest.parse import get_childmanifests
910
from dfetch.manifest.project import ProjectEntry
1011

1112
logger = get_logger(__name__)

dfetch/commands/update.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
import dfetch.commands.command
3535
import dfetch.manifest.manifest
3636
import dfetch.manifest.project
37-
import dfetch.manifest.validate
3837
import dfetch.project.git
3938
import dfetch.project.svn
4039
from dfetch.commands.common import check_child_manifests

dfetch/commands/validate.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@
1111

1212
import dfetch.commands.command
1313
from dfetch.log import get_logger
14-
from dfetch.manifest.manifest import find_manifest
15-
from dfetch.manifest.validate import validate
14+
from dfetch.manifest.parse import find_manifest, parse
1615

1716
logger = get_logger(__name__)
1817

@@ -34,6 +33,6 @@ def __call__(self, args: argparse.Namespace) -> None:
3433
del args # unused
3534

3635
manifest_path = find_manifest()
37-
validate(manifest_path)
36+
parse(manifest_path)
3837
manifest_path = os.path.relpath(manifest_path, os.getcwd())
3938
logger.print_info_line(manifest_path, "valid")

dfetch/manifest/manifest.py

Lines changed: 3 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,17 @@
2121
import difflib
2222
import io
2323
import os
24-
import pathlib
2524
import re
2625
from collections.abc import Sequence
2726
from dataclasses import dataclass
2827
from typing import IO, Any, Optional, Union
2928

3029
import yaml
31-
from typing_extensions import TypedDict
30+
from typing_extensions import NotRequired, TypedDict
3231

33-
from dfetch import DEFAULT_MANIFEST_NAME
3432
from dfetch.log import get_logger
3533
from dfetch.manifest.project import ProjectEntry, ProjectEntryDict
3634
from dfetch.manifest.remote import Remote, RemoteDict
37-
from dfetch.manifest.validate import validate
38-
from dfetch.util.util import find_file, prefix_runtime_exceptions
3935

4036
logger = get_logger(__name__)
4137

@@ -96,13 +92,11 @@ def _guess_project(self, names: Sequence[str]) -> Sequence[str]:
9692
]
9793

9894

99-
class ManifestDict( # pylint: disable=too-many-ancestors
100-
TypedDict, total=False
101-
): # When https://www.python.org/dev/peps/pep-0655/ is accepted, only make remotes optional
95+
class ManifestDict(TypedDict, total=True): # pylint: disable=too-many-ancestors
10296
"""Serialized dict types."""
10397

10498
version: Union[int, str]
105-
remotes: Sequence[Union[RemoteDict, Remote]]
99+
remotes: NotRequired[Sequence[Union[RemoteDict, Remote]]]
106100
projects: Sequence[Union[ProjectEntryDict, ProjectEntry, dict[str, str]]]
107101

108102

@@ -362,39 +356,6 @@ def find_name_in_manifest(self, name: str) -> ManifestEntryLocation:
362356
raise RuntimeError(f"{name} was not found in the manifest!")
363357

364358

365-
def find_manifest() -> str:
366-
"""Find a manifest."""
367-
paths = find_file(DEFAULT_MANIFEST_NAME, ".")
368-
369-
if len(paths) == 0:
370-
raise RuntimeError("No manifests were found!")
371-
if len(paths) != 1:
372-
logger.warning(
373-
f"Multiple manifests found, using {pathlib.Path(paths[0]).as_posix()}"
374-
)
375-
376-
return os.path.realpath(paths[0])
377-
378-
379-
def get_childmanifests(skip: Optional[list[str]] = None) -> list[Manifest]:
380-
"""Get manifest and its path."""
381-
skip = skip or []
382-
logger.debug("Looking for sub-manifests")
383-
384-
childmanifests: list[Manifest] = []
385-
for path in find_file(DEFAULT_MANIFEST_NAME, "."):
386-
path = os.path.realpath(path)
387-
if path not in skip:
388-
logger.debug(f"Found sub-manifests {path}")
389-
with prefix_runtime_exceptions(
390-
pathlib.Path(path).relative_to(os.path.dirname(os.getcwd())).as_posix()
391-
):
392-
validate(path)
393-
childmanifests += [Manifest.from_file(path)]
394-
395-
return childmanifests
396-
397-
398359
class ManifestDumper(yaml.SafeDumper): # pylint: disable=too-many-ancestors
399360
"""Dump a manifest YAML.
400361

dfetch/manifest/parse.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Validate manifests using StrictYAML."""
2+
3+
import os
4+
import pathlib
5+
from typing import Any, Optional, cast
6+
7+
from strictyaml import StrictYAMLError, YAMLValidationError, load
8+
9+
from dfetch import DEFAULT_MANIFEST_NAME
10+
from dfetch.log import get_logger
11+
from dfetch.manifest.manifest import Manifest, ManifestDict
12+
from dfetch.manifest.schema import MANIFEST_SCHEMA
13+
from dfetch.util.util import find_file, prefix_runtime_exceptions
14+
15+
logger = get_logger(__name__)
16+
17+
18+
def _ensure_unique(seq: list[dict[str, Any]], key: str, context: str) -> None:
19+
"""Ensure values for `key` are unique within a sequence of dicts."""
20+
values = [item.get(key) for item in seq if key in item]
21+
seen: set[Any] = set()
22+
dups: set[Any] = set()
23+
for val in values:
24+
if val in seen:
25+
dups.add(val)
26+
else:
27+
seen.add(val)
28+
29+
if dups:
30+
dup_list = ", ".join(sorted(map(str, dups)))
31+
raise RuntimeError(
32+
f"Schema validation failed:\nDuplicate {context}.{key} value(s): {dup_list}"
33+
)
34+
35+
36+
def parse(path: str) -> Manifest:
37+
"""Parse & validate the given manifest file against the StrictYAML schema.
38+
39+
Raises:
40+
RuntimeError: if the file is not valid YAML or violates the schema/uniqueness constraints.
41+
"""
42+
try:
43+
manifest_text = pathlib.Path(path).read_text(encoding="UTF-8")
44+
loaded_manifest = load(manifest_text, schema=MANIFEST_SCHEMA)
45+
except (YAMLValidationError, StrictYAMLError) as err:
46+
raise RuntimeError(
47+
"\n".join(
48+
[
49+
"Schema validation failed:",
50+
"",
51+
err.context_mark.get_snippet(),
52+
"",
53+
err.problem,
54+
]
55+
)
56+
) from err
57+
58+
data: dict[str, Any] = cast(dict[str, Any], loaded_manifest.data)
59+
manifest: ManifestDict = data["manifest"] # required
60+
61+
remotes = manifest.get("remotes", []) or [] # optional
62+
projects = manifest["projects"] # required
63+
64+
_ensure_unique(remotes, "name", "manifest.remotes") # type: ignore
65+
_ensure_unique(projects, "name", "manifest.projects") # type: ignore
66+
_ensure_unique(projects, "dst", "manifest.projects") # type: ignore
67+
68+
return Manifest(manifest, text=manifest_text, path=path)
69+
70+
71+
def find_manifest() -> str:
72+
"""Find a manifest."""
73+
paths = find_file(DEFAULT_MANIFEST_NAME, ".")
74+
75+
if len(paths) == 0:
76+
raise RuntimeError("No manifests were found!")
77+
if len(paths) != 1:
78+
logger.warning(
79+
f"Multiple manifests found, using {pathlib.Path(paths[0]).as_posix()}"
80+
)
81+
82+
return os.path.realpath(paths[0])
83+
84+
85+
def get_childmanifests(skip: Optional[list[str]] = None) -> list[Manifest]:
86+
"""Get manifest and its path."""
87+
skip = skip or []
88+
logger.debug("Looking for sub-manifests")
89+
90+
childmanifests: list[Manifest] = []
91+
for path in find_file(DEFAULT_MANIFEST_NAME, "."):
92+
path = os.path.realpath(path)
93+
if path not in skip:
94+
logger.debug(f"Found sub-manifests {path}")
95+
with prefix_runtime_exceptions(
96+
pathlib.Path(path).relative_to(os.path.dirname(os.getcwd())).as_posix()
97+
):
98+
childmanifests += [parse(path)]
99+
100+
return childmanifests

dfetch/manifest/project.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -274,15 +274,15 @@
274274
from collections.abc import Sequence
275275
from typing import Optional, Union
276276

277-
from typing_extensions import TypedDict
277+
from typing_extensions import Required, TypedDict
278278

279279
from dfetch.manifest.remote import Remote
280280
from dfetch.manifest.version import Version
281281

282282
ProjectEntryDict = TypedDict(
283283
"ProjectEntryDict",
284284
{
285-
"name": str,
285+
"name": Required[str],
286286
"revision": str,
287287
"remote": str,
288288
"src": str,
@@ -337,7 +337,9 @@ def from_yaml(
337337
Returns:
338338
ProjectEntry: An immutable ProjectEntry
339339
"""
340-
kwargs: ProjectEntryDict = {}
340+
kwargs: ProjectEntryDict = (
341+
{} # type: ignore # the Required name key is checked in __init__
342+
)
341343
for key in ProjectEntryDict.__annotations__.keys(): # pylint: disable=no-member
342344
try:
343345
kwargs[str(key)] = yamldata[key] # type: ignore

dfetch/manifest/validate.py

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

dfetch/project/superproject.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
from collections.abc import Sequence
1414

1515
from dfetch.log import get_logger
16-
from dfetch.manifest.manifest import Manifest, find_manifest
16+
from dfetch.manifest.manifest import Manifest
17+
from dfetch.manifest.parse import find_manifest, parse
1718
from dfetch.manifest.project import ProjectEntry
18-
from dfetch.manifest.validate import validate
1919
from dfetch.project.git import GitSubProject
2020
from dfetch.project.subproject import SubProject
2121
from dfetch.project.svn import SvnSubProject
@@ -37,10 +37,9 @@ def __init__(self) -> None:
3737
"""Create a SuperProject by looking for a manifest file."""
3838
logger.debug("Looking for manifest")
3939
manifest_path = find_manifest()
40-
validate(manifest_path)
4140

4241
logger.debug(f"Using manifest {manifest_path}")
43-
self._manifest = Manifest.from_file(manifest_path)
42+
self._manifest = parse(manifest_path)
4443
self._root_directory = os.path.dirname(self._manifest.path)
4544

4645
@property

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,8 @@ skip = "*.cast,./venv,**/plantuml-c4/**,./example,.mypy_cache,./doc/_build/**,./
189189
"features/steps/*" = ["F811"]
190190

191191
[tool.pyright]
192-
exclude = ["doc/static/uml/generate_diagram.py", "./doc/_ext/sphinxcontrib_asciinema/", "./doc/_build"]
193-
standard = ["dfetch", "features"]
192+
exclude = ["doc/static/uml/generate_diagram.py", "./doc/_ext/sphinxcontrib_asciinema/", "./doc/_build", "**/node_modules", "**/__pycache__", "**/.*", "venv"]
193+
# standard = ["dfetch", "features"]
194194
reportMissingImports = false
195195
reportMissingModuleSource = false
196196
pythonVersion = "3.9"

script/create_sbom.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,11 @@
1515

1616
DEPS = f"{PROJECT_DIR}[sbom]"
1717

18-
19-
PLATFORM_NAME = "nix"
20-
21-
if sys.platform.startswith("darwin"):
22-
PLATFORM_NAME = "osx"
23-
elif sys.platform.startswith("win"):
24-
PLATFORM_NAME = "win"
18+
PLATFORM_NAME = (
19+
"osx"
20+
if sys.platform.startswith("darwin")
21+
else "win" if sys.platform.startswith("win") else "nix"
22+
)
2523

2624

2725
@contextlib.contextmanager

0 commit comments

Comments
 (0)