[ssci] Defined basic metadata fields
Bug: b:277147404 Change-Id: Iabba43add61ce5090be50ac8f0245f624028c44b Reviewed-on: https://chromium-review.googlesource.com/c/chromium/tools/depot_tools/+/4776728 Reviewed-by: Rachael Newitt <renewitt@google.com> Reviewed-by: Josip Sokcevic <sokcevic@chromium.org> Commit-Queue: Anne Redulla <aredulla@google.com>changes/28/4776728/4
parent
4e60071d3d
commit
51690612da
@ -0,0 +1,6 @@
|
||||
# Software Supply Chain Integrity/SBOM
|
||||
aredulla@google.com
|
||||
dlf@google.com
|
||||
jsca@google.com
|
||||
renewitt@google.com
|
||||
sumakasa@google.com
|
||||
@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2023 The Chromium Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Union
|
||||
|
||||
_THIS_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
# The repo's root directory.
|
||||
_ROOT_DIR = os.path.abspath(os.path.join(_THIS_DIR, "..", ".."))
|
||||
|
||||
# Add the repo's root directory for clearer imports.
|
||||
sys.path.insert(0, _ROOT_DIR)
|
||||
|
||||
import metadata.fields.types as field_types
|
||||
|
||||
# Freeform text fields.
|
||||
NAME = field_types.FreeformTextField("Name")
|
||||
SHORT_NAME = field_types.FreeformTextField("Short Name")
|
||||
REVISION = field_types.FreeformTextField("Revision")
|
||||
DESCRIPTION = field_types.FreeformTextField("Description", one_liner=False)
|
||||
LOCAL_MODIFICATIONS = field_types.FreeformTextField("Local Modifications",
|
||||
one_liner=False)
|
||||
|
||||
# Yes/no fields.
|
||||
SECURITY_CRITICAL = field_types.YesNoField("Security Critical")
|
||||
SHIPPED = field_types.YesNoField("Shipped")
|
||||
LICENSE_ANDROID_COMPATIBLE = field_types.YesNoField(
|
||||
"License Android Compatible")
|
||||
|
||||
ALL_FIELDS = (
|
||||
NAME,
|
||||
SHORT_NAME,
|
||||
REVISION,
|
||||
SECURITY_CRITICAL,
|
||||
SHIPPED,
|
||||
LICENSE_ANDROID_COMPATIBLE,
|
||||
DESCRIPTION,
|
||||
LOCAL_MODIFICATIONS,
|
||||
)
|
||||
ALL_FIELD_NAMES = {field.get_name() for field in ALL_FIELDS}
|
||||
FIELD_MAPPING = {field.get_name().lower(): field for field in ALL_FIELDS}
|
||||
|
||||
|
||||
def get_field(label: str) -> Union[field_types.MetadataField, None]:
|
||||
return FIELD_MAPPING.get(label.lower())
|
||||
@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2023 The Chromium Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Union
|
||||
|
||||
_THIS_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
# The repo's root directory.
|
||||
_ROOT_DIR = os.path.abspath(os.path.join(_THIS_DIR, "..", ".."))
|
||||
|
||||
# Add the repo's root directory for clearer imports.
|
||||
sys.path.insert(0, _ROOT_DIR)
|
||||
|
||||
import metadata.fields.util as util
|
||||
import metadata.validation_result as vr
|
||||
|
||||
# Pattern used to check if the entire string is either "yes" or "no",
|
||||
# case-insensitive.
|
||||
_PATTERN_YES_OR_NO = re.compile(r"^(yes|no)$", re.IGNORECASE)
|
||||
|
||||
# Pattern used to check if the string starts with "yes" or "no",
|
||||
# case-insensitive. e.g. "No (test only)", "Yes?"
|
||||
_PATTERN_STARTS_WITH_YES_OR_NO = re.compile(r"^(yes|no)", re.IGNORECASE)
|
||||
|
||||
|
||||
class MetadataField:
|
||||
"""Base class for all metadata fields."""
|
||||
def __init__(self, name: str, one_liner: bool = True):
|
||||
self._name = name
|
||||
self._one_liner = one_liner
|
||||
|
||||
def get_name(self):
|
||||
return self._name
|
||||
|
||||
def validate(self, value: str) -> Union[vr.ValidationResult, None]:
|
||||
"""Checks the given value is acceptable for the field.
|
||||
|
||||
Raises: NotImplementedError if called. This method must be overridden with
|
||||
the actual validation of the field.
|
||||
"""
|
||||
raise NotImplementedError(f"{self._name} field validation not defined.")
|
||||
|
||||
|
||||
class FreeformTextField(MetadataField):
|
||||
"""Field where the value is freeform text."""
|
||||
def validate(self, value: str) -> Union[vr.ValidationResult, None]:
|
||||
"""Checks the given value has at least one non-whitespace character."""
|
||||
if util.is_empty(value):
|
||||
return vr.ValidationError(f"{self._name} is empty.")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class YesNoField(MetadataField):
|
||||
"""Field where the value must be yes or no."""
|
||||
def __init__(self, name: str):
|
||||
super().__init__(name=name, one_liner=True)
|
||||
|
||||
def validate(self, value: str) -> Union[vr.ValidationResult, None]:
|
||||
"""Checks the given value is either yes or no."""
|
||||
if util.matches(_PATTERN_YES_OR_NO, value):
|
||||
return None
|
||||
|
||||
if util.matches(_PATTERN_STARTS_WITH_YES_OR_NO, value):
|
||||
return vr.ValidationWarning(
|
||||
f"{self._name} is '{value}' - should be only {util.YES} or {util.NO}."
|
||||
)
|
||||
|
||||
return vr.ValidationError(
|
||||
f"{self._name} is '{value}' - must be {util.YES} or {util.NO}.")
|
||||
@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2023 The Chromium Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
# Preferred values for yes/no fields (i.e. all lowercase).
|
||||
YES = "yes"
|
||||
NO = "no"
|
||||
|
||||
# Pattern used to check if the entire string is "unknown", case-insensitive.
|
||||
_PATTERN_UNKNOWN = re.compile(r"^unknown$", re.IGNORECASE)
|
||||
|
||||
# Pattern used to check if the entire string is functionally empty, i.e.
|
||||
# empty string, or all characters are only whitespace.
|
||||
_PATTERN_ONLY_WHITESPACE = re.compile(r"^\s*$")
|
||||
|
||||
|
||||
def matches(pattern: re.Pattern, value: str) -> bool:
|
||||
"""Returns whether the value matches the pattern."""
|
||||
return pattern.match(value) is not None
|
||||
|
||||
|
||||
def is_empty(value: str) -> bool:
|
||||
"""Returns whether the value is functionally empty."""
|
||||
return matches(_PATTERN_ONLY_WHITESPACE, value)
|
||||
|
||||
|
||||
def is_unknown(value: str) -> bool:
|
||||
"""Returns whether the value is 'unknown' (case insensitive)."""
|
||||
return matches(_PATTERN_UNKNOWN, value)
|
||||
|
||||
|
||||
def quoted(values: List[str]) -> str:
|
||||
"""Returns a string of the given values, each being individually quoted."""
|
||||
return ", ".join([f"'{entry}'" for entry in values])
|
||||
@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env vpython3
|
||||
# Copyright (c) 2023 The Chromium Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import List
|
||||
import unittest
|
||||
|
||||
_THIS_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
# The repo's root directory.
|
||||
_ROOT_DIR = os.path.abspath(os.path.join(_THIS_DIR, "..", ".."))
|
||||
|
||||
# Add the repo's root directory for clearer imports.
|
||||
sys.path.insert(0, _ROOT_DIR)
|
||||
|
||||
import metadata.fields.types as field_types
|
||||
import metadata.validation_result as vr
|
||||
|
||||
|
||||
class FieldValidationTest(unittest.TestCase):
|
||||
def _run_field_validation(self,
|
||||
field: field_types.MetadataField,
|
||||
valid_values: List[str],
|
||||
error_values: List[str],
|
||||
warning_values: List[str] = []):
|
||||
"""Helper to run a field's validation for different values."""
|
||||
for value in valid_values:
|
||||
self.assertIsNone(field.validate(value))
|
||||
|
||||
for value in error_values:
|
||||
self.assertIsInstance(field.validate(value), vr.ValidationError)
|
||||
|
||||
for value in warning_values:
|
||||
self.assertIsInstance(field.validate(value), vr.ValidationWarning)
|
||||
|
||||
def test_freeform_text_validation(self):
|
||||
# Check validation of a freeform text field that should be on one line.
|
||||
self._run_field_validation(
|
||||
field=field_types.FreeformTextField("Freeform single", one_liner=True),
|
||||
valid_values=["Text on single line", "a", "1"],
|
||||
error_values=["", "\n", " "],
|
||||
)
|
||||
|
||||
# Check validation of a freeform text field that can span multiple lines.
|
||||
self._run_field_validation(
|
||||
field=field_types.FreeformTextField("Freeform multi", one_liner=False),
|
||||
valid_values=[
|
||||
"This is text spanning multiple lines:\n"
|
||||
" * with this point\n"
|
||||
" * and this other point",
|
||||
"Text on single line",
|
||||
"a",
|
||||
"1",
|
||||
],
|
||||
error_values=["", "\n", " "],
|
||||
)
|
||||
|
||||
def test_yes_no_field_validation(self):
|
||||
self._run_field_validation(
|
||||
field=field_types.YesNoField("Yes/No test"),
|
||||
valid_values=["yes", "no", "No", "YES"],
|
||||
error_values=["", "\n", "Probably yes"],
|
||||
warning_values=["Yes?", "not"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2023 The Chromium Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
|
||||
import textwrap
|
||||
from typing import Dict, Union
|
||||
|
||||
|
||||
class ValidationResult:
|
||||
"""Base class for validation issues."""
|
||||
def __init__(self, message: str, fatal: bool, **tags: Dict[str, str]):
|
||||
self._fatal = fatal
|
||||
self._message = message
|
||||
self._tags = tags
|
||||
|
||||
def __str__(self) -> str:
|
||||
prefix = "ERROR" if self._fatal else "[non-fatal]"
|
||||
return f"{prefix} - {self._message}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return str(self)
|
||||
|
||||
def is_fatal(self) -> bool:
|
||||
return self._fatal
|
||||
|
||||
def set_tag(self, tag: str, value: str) -> bool:
|
||||
self._tags[tag] = value
|
||||
|
||||
def get_tag(self, tag: str) -> Union[str, None]:
|
||||
return self._tags.get(tag)
|
||||
|
||||
def get_all_tags(self) -> Dict[str, str]:
|
||||
return dict(self._tags)
|
||||
|
||||
def get_message(self, width: int = 0) -> str:
|
||||
if width > 0:
|
||||
return textwrap.fill(text=self._message, width=width)
|
||||
|
||||
return self._message
|
||||
|
||||
|
||||
class ValidationError(ValidationResult):
|
||||
"""Fatal validation issue. Presubmit should fail."""
|
||||
def __init__(self, message: str, **tags: Dict[str, str]):
|
||||
super().__init__(message=message, fatal=True, **tags)
|
||||
|
||||
|
||||
class ValidationWarning(ValidationResult):
|
||||
"""Non-fatal validation issue. Presubmit should pass."""
|
||||
def __init__(self, message: str, **tags: Dict[str, str]):
|
||||
super().__init__(message=message, fatal=False, **tags)
|
||||
Loading…
Reference in New Issue