ci/package_checks: Improve linter feedback and bump check (#2038)

**Summary**

This PR:

- Improves the output format when not running in CI.
- Shows this output when being called via a hook.
- Extends the package bump check so that pspec files must always be
bumped.

Resolves #1888.

**Test Plan**

Run CI checks locally using `go-task check` in a fairly dirty working
directory:


![image](817d8e39-a269-44d2-8e19-80d75e4698a2)

Try to commit a changed pspec file:

```
$ gotopkg nano
$ go-task build
$ git add pspec_x86_64.xml
$ git commit
ERR packages/n/nano/pspec_x86_64.xml:1: Package release is not incremented by 1
Package checks failed: <snip>/packages/common/CI/package_checks.py packages/n/nano/pspec_x86_64.xml
```

**Checklist**

- [x] ~~Package was built and tested against unstable~~
This commit is contained in:
Rune Morling 2024-04-02 19:08:28 +02:00 committed by GitHub
commit efc83c3119
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 160 additions and 43 deletions

View file

@ -2,18 +2,77 @@
import argparse
import glob
import json
import logging
import os.path
import re
import subprocess
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Dict, List, Optional, TextIO, Tuple, Union
from typing import Any, Callable, Dict, List, Optional, TextIO, Tuple, Union
from urllib import request
from xml.etree import ElementTree
import yaml
"""Package is either a Package YML file or Pspec XML file."""
Package = Union['PackageYML', 'PspecXML']
def in_ci() -> bool:
"""Returns true if running in GitHub Actions or GitLab CI."""
return os.environ.get('CI') == 'true'
class PackageYML:
"""Represents a Package YML file."""
def __init__(self, stream: Any):
self._data = dict(yaml.safe_load(stream))
@property
def name(self) -> str:
return str(self._data['name'])
@property
def version(self) -> str:
return str(self._data['version'])
@property
def release(self) -> int:
return int(self._data['release'])
@property
def homepage(self) -> Optional[str]:
return self._data.get('homepage')
def get(self, key: str, default: Any = None) -> Any:
return self._data.get(key, default)
class PspecXML:
"""Represents a Pspec XML file."""
def __init__(self, data: str):
self._xml = ElementTree.fromstring(data)
@property
def release(self) -> int:
return int(self._xml.findall('.//Update')[0].attrib['release'])
@property
def homepage(self) -> Optional[str]:
xml_homepage_element = self._xml.find('.//Homepage')
if xml_homepage_element is not None:
return xml_homepage_element.text
return None
@property
def files(self) -> List[str]:
return [str(element.text) for element in self._xml.findall('.//Path')]
@dataclass
class FreezeConfig:
@ -89,6 +148,28 @@ class Git:
return self.run_lines('diff', '--name-only', '--diff-filter=AM')
class LogFormatter(logging.Formatter):
fmt = '\033[0m \033[94m%(pathname)s:%(lineno)d:\033[0m %(message)s'
formatters = {
logging.DEBUG: logging.Formatter('\033[1;30mDBG' + fmt),
logging.INFO: logging.Formatter('\033[34mINF' + fmt),
logging.WARNING: logging.Formatter('\033[33mWRN' + fmt),
logging.ERROR: logging.Formatter('\033[31mERR' + fmt),
logging.CRITICAL: logging.Formatter('\033[31mCRI' + fmt),
}
def format(self, record: logging.LogRecord) -> str:
return self.formatters[record.levelno].format(record)
@staticmethod
def handler() -> logging.Handler:
handler = logging.StreamHandler(sys.stderr)
handler.setLevel(logging.DEBUG)
handler.setFormatter(LogFormatter())
return handler
class Level(str, Enum):
__str__ = Enum.__str__
DEBUG = 'debug'
@ -96,6 +177,20 @@ class Level(str, Enum):
ERROR = 'error'
WARNING = 'warning'
@property
def log_level(self) -> int:
match self:
case Level.DEBUG:
return logging.DEBUG
case Level.NOTICE:
return logging.INFO
case Level.WARNING:
return logging.WARNING
case Level.ERROR:
return logging.ERROR
case _:
return 0
@dataclass
class Result:
@ -109,7 +204,14 @@ class Result:
endLine: Optional[int] = None
def __str__(self) -> str:
return f'::{self.level}{self._meta}::{self._message}'
return f'::{self.level.value}{self._meta}::{self._message}'
def log(self) -> None:
if in_ci():
print(str(self))
return
logging.getLogger(__name__).handle(self._record)
@property
def _message(self) -> str:
@ -132,6 +234,15 @@ class Result:
return f'{key}={value}'
@property
def _record(self) -> logging.LogRecord:
return logging.LogRecord('',
self.level.log_level,
self.file or '',
self.line or 1,
self.message,
None, None)
class PullRequestCheck:
_package_files = ['package.yml']
@ -169,12 +280,22 @@ class PullRequestCheck:
def _open(self, path: str) -> TextIO:
return open(self._path(path), 'r')
def load_package_yml(self, file: str) -> Dict[str, Any]:
with self._open(file) as f:
return dict(yaml.safe_load(f))
def _read(self, path: str) -> str:
with self._open(path) as f:
return str(f.read())
def load_package_yml_from_commit(self, ref: str, file: str) -> Dict[str, Any]:
return dict(yaml.safe_load(self.git.file_from_commit(ref, file)))
def load_package_yml(self, file: str) -> PackageYML:
with self._open(file) as f:
return PackageYML(f)
def load_package_yml_from_commit(self, ref: str, file: str) -> PackageYML:
return PackageYML(self.git.file_from_commit(ref, file))
def load_pspec_xml(self, file: str) -> PspecXML:
return PspecXML(self._read(file))
def load_pspec_xml_from_commit(self, ref: str, file: str) -> PspecXML:
return PspecXML(self.git.file_from_commit(ref, file))
def file_line(self, file: str, expr: str) -> Optional[int]:
with self._open(file) as f:
@ -280,27 +401,35 @@ class Homepage(PullRequestCheck):
class PackageBumped(PullRequestCheck):
_msg = 'Package release is not incremented by 1'
_msg_new = 'Package release is not 1'
_level = Level.WARNING
def run(self) -> List[Result]:
results = [self._check_commit(self.base or 'HEAD', file)
for file in self.package_files]
for file in self.files]
return [result for result in results if result is not None]
def _check_commit(self, base: str, file: str) -> Optional[Result]:
new = self.load_package_yml(file)
match os.path.basename(file):
case 'package.yml':
return self._check(base, file, PackageYML, Level.WARNING)
case 'pspec_x86_64.xml':
return self._check(base, file, PspecXML, Level.ERROR)
case _:
return None
def _check(self, base: str, file: str, loader: Callable[[str], Package], level: Level) -> Optional[Result]:
new = loader(self._read(file))
try:
old = self.load_package_yml_from_commit(base, file)
if int(old['release']) + 1 != int(new['release']):
return Result(level=self._level, file=file, message=self._msg)
old = loader(self.git.file_from_commit(base, file))
if old.release + 1 != new.release:
return Result(level=level, file=file, message=self._msg)
return None
except Exception as e:
if 'exists on disk, but not in' in str(e):
if int(new['release']) != 1:
return Result(level=self._level, file=file, message=self._msg_new)
if new.release != 1:
return Result(level=level, file=file, message=self._msg_new)
return None
@ -383,9 +512,7 @@ class PackageVersion(PullRequestCheck):
if not self._check_version(path)]
def _check_version(self, path: str) -> bool:
version = self.load_package_yml(path)['version']
return isinstance(version, str)
return isinstance(self.load_package_yml(path).version, str)
class Patch(PullRequestCheck):
@ -422,7 +549,7 @@ class SPDXLicense(PullRequestCheck):
for r in self._validate_spdx(f) if r]
def _validate_spdx(self, file: str) -> List[Optional[Result]]:
license = self.load_package_yml(file)['license']
license = self.load_package_yml(file).get('license')
if isinstance(license, list):
return [self._validate_license(file, id) for id in license]
@ -492,26 +619,16 @@ class Pspec(PullRequestCheck):
if not self._check_consistent(path)]
def _check_consistent(self, package_dir: str) -> bool:
xml = ElementTree.parse(self._xml_file(package_dir))
xml_release = int(xml.findall('.//Update')[0].attrib['release'])
xml_homepage: str = ''
xml_homepage_element = xml.find('.//Homepage')
xml = self._xml_file(package_dir)
yml = self._yml_file(package_dir)
if xml_homepage_element is not None:
xml_homepage = xml_homepage_element.text or ''
return bool(yml.release == xml.release and yml.homepage == xml.homepage)
with open(self._yml_file(package_dir), 'r') as f:
yml = yaml.safe_load(f)
yml_release = yml.get('release', '')
yml_homepage = yml.get('homepage', '')
def _yml_file(self, package_dir: str) -> PackageYML:
return self.load_package_yml(os.path.join(package_dir, 'package.yml'))
return bool(yml_release == xml_release and yml_homepage == xml_homepage)
def _yml_file(self, package_dir: str) -> str:
return self._path(os.path.join(package_dir, 'package.yml'))
def _xml_file(self, package_dir: str) -> str:
return self._path(os.path.join(package_dir, 'pspec_x86_64.xml'))
def _xml_file(self, package_dir: str) -> PspecXML:
return self.load_pspec_xml(os.path.join(package_dir, 'pspec_x86_64.xml'))
class SystemDependencies(PullRequestCheck):
@ -529,7 +646,7 @@ class SystemDependencies(PullRequestCheck):
return self._validate_deps(pkg) if self._components_match(pkg_components) else []
def _package_components(self, pkg: str) -> List[str]:
return self._unwrap_component(self.load_package_yml(self.package_yml_path(pkg))['component'])
return self._unwrap_component(self.load_package_yml(self.package_yml_path(pkg)).get('component'))
def _unwrap_component(self, component: Any) -> List[str]:
if isinstance(component, dict):
@ -587,9 +704,9 @@ class SummaryGenerator(PullRequestCheck):
if package is None:
return None
return f"{package['name']}-{package['version']}-{package['release']}"
return f"{package.name}-{package.version}-{package.release}"
def _commit_package_yaml(self, ref: str) -> Optional[Dict[str, Any]]:
def _commit_package_yaml(self, ref: str) -> Optional[PackageYML]:
files = [f for f in self.git.files_in_commit(ref) if os.path.basename(f) == 'package.yml']
if len(files) == 0:
return None
@ -644,7 +761,7 @@ class Checker:
print(f"Found {len(results)} result(s), {len(errors)} error(s)")
for result in results:
print(result)
result.log()
self.write_summary()
@ -663,6 +780,8 @@ class Checker:
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, handlers=[LogFormatter.handler()])
parser = argparse.ArgumentParser()
parser.add_argument('--base', type=str,
help='Optional reference to the base branch')

View file

@ -8,11 +8,9 @@ package_checks = os.path.join(common_dir, 'common', 'CI', 'package_checks.py')
def _run(name: str, *args: str) -> bool:
res = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
res = subprocess.run(args, stdout=subprocess.PIPE, text=True)
if res.returncode != 0:
print(f'{name} failed: {" ".join(args)}')
print('Output:')
print("\n".join([' ' + line for line in res.stdout.split("\n")]))
return res.returncode == 0