Browse Source

reimplement checks api

shut-new-model
Niklas Rosenstein 9 months ago
parent
commit
245b394535
No known key found for this signature in database GPG Key ID: 6D269B33D25F6C6
8 changed files with 378 additions and 166 deletions
  1. + 2
    - 0
      src/shut/__init__.py
  2. + 1
    - 1
      src/shut/commands/pkg/__init__.py
  3. + 89
    - 0
      src/shut/commands/pkg/checks.py
  4. + 0
    - 161
      src/shut/commands/pkg/sanity.py
  5. + 22
    - 0
      src/shut/impl/__init__.py
  6. + 105
    - 0
      src/shut/impl/setuptools.py
  7. + 113
    - 0
      src/shut/model/checks.py
  8. + 46
    - 4
      src/shut/model/package.py

+ 2
- 0
src/shut/__init__.py

@ -21,3 +21,5 @@
__author__ = 'Niklas Rosenstein <rosensteinniklas@gmail.com>'
__version__ = '0.1.0'
from . import impl

+ 1
- 1
src/shut/commands/pkg/__init__.py

@ -40,5 +40,5 @@ def pkg(ctx):
from . import bootstrap
from . import sanity
from . import checks
from . import status

+ 89
- 0
src/shut/commands/pkg/checks.py

@ -0,0 +1,89 @@
# -*- coding: utf8 -*-
# Copyright (c) 2020 Niklas Rosenstein
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from shut.commands.pkg import pkg, project
from shut.model import PackageModel, Project
from shut.model.checks import CheckStatus, get_checks
from nr.stream import Stream
from termcolor import colored
from typing import Iterable, Union
import click
import enum
import logging
import os
import sys
logger = logging.getLogger(__name__)
def print_package_checks(project: Project, package: PackageModel, warnings_as_errors: bool = False) -> int:
icons = {
CheckStatus.PASSED: '✔️',
CheckStatus.WARNING: '⚠️',
CheckStatus.ERROR: ''}
colors = {
CheckStatus.PASSED: 'green',
CheckStatus.WARNING: 'magenta',
CheckStatus.ERROR: 'red'}
package_name_c = colored(package.data.name, 'yellow')
checks = sorted(get_checks(project, package), key=lambda c: c.name)
print()
for check in checks:
print(' ', icons[check.result.status], check.name, end='')
if check.result.status != CheckStatus.PASSED:
print(':', check.result.message)
else:
print()
print()
print('run', len(checks), 'check(s) for package', package_name_c)
print()
max_level = max(x.result.status for x in checks)
if max_level == CheckStatus.PASSED:
status = 0
elif max_level == CheckStatus.WARNING:
status = 1 if warnings_as_errors else 0
elif max_level == CheckStatus.ERROR:
status = 1
else:
assert False, max_level
logger.debug('exiting with status %s', status)
return status
@pkg.command()
@click.option('-w', '--warnings-as-errors', is_flag=True)
def checks(warnings_as_errors):
"""
Sanity-check the package configuration and package files. Which checks are performed
depends on the features that are enabled in the package configuration. Usually that
will at least include the "setuptools" feature which will perform basic sanity checks
on the package configuration and entrypoint definition.
"""
package = project.load(expect=PackageModel)
sys.exit(print_package_checks(project, package, warnings_as_errors))

+ 0
- 161
src/shut/commands/pkg/sanity.py

@ -1,161 +0,0 @@
# -*- coding: utf8 -*-
# Copyright (c) 2020 Niklas Rosenstein
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from shore.util.classifiers import get_classifiers
from shut.commands.pkg import pkg, project
from shut.model import PackageModel, Project
from nr.stream import Stream
from termcolor import colored
from typing import Iterable, Union
import click
import enum
import logging
import os
import sys
logger = logging.getLogger(__name__)
class CheckResult:
"""
Represents a sanity check result.
"""
class Level(enum.IntEnum):
INFO = 0
WARNING = 1
ERROR = 2
def __init__(self, on: PackageModel, level: Union[str, Level], message: str):
if isinstance(level, str):
level = self.Level[level]
assert isinstance(level, self.Level)
self.on = on
self.level = level
self.message = message
def __repr__(self):
return 'CheckResult(on={!r}, level={}, message={!r})'.format(
self.on, self.level, self.message)
def sanity_check_package(project: Project, package: PackageModel) -> Iterable[CheckResult]:
for path in package.unknown_keys:
yield CheckResult(package, CheckResult.Level.WARNING, 'unknown key {}'.format(path))
if not package.get_readme():
yield CheckResult(package, 'WARNING', 'No README file found.')
classifiers = get_classifiers()
unknown_classifiers = [x for x in package.data.classifiers if x not in classifiers]
if unknown_classifiers:
yield CheckResult(package, 'WARNING',
'unknown $.classifiers: {}'.format(unknown_classifiers))
if not package.data.author:
yield CheckResult(package, 'WARNING', 'missing $.package.author')
if not package.data.license: #and not package.get_private():
yield CheckResult(package, 'WARNING', 'missing $.license')
if not package.data.url:
yield CheckResult(package, 'WARNING', 'missing $.url')
if package.data.license and project.monorepo and project.monorepo.license \
and project.monorepo.license != package.data.license:
yield CheckResult(package, 'ERROR', '$.license ({!r}) is inconsistent '
'with monorepo license ({!r})'.format(package.license, package.monorepo.license))
if package.data.license:
for name in ('LICENSE', 'LICENSE.txt', 'LICENSE.rst', 'LICENSE.md'):
filename = os.path.join(os.path.dirname(package.filename), name)
if os.path.isfile(filename):
break
else:
yield CheckResult(package, 'WARNING', 'No LICENSE file found.')
metadata = package.get_python_package_metadata()
if package.data.author and metadata.author != str(package.data.author):
yield CheckResult(package, 'ERROR',
'Inconsistent package author (package.yaml: {!r} != {}: {!r})'.format(
str(package.data.author), metadata.filename, metadata.author))
if package.data.version and metadata.version != str(package.data.version):
yield CheckResult(package, 'ERROR',
'Inconsistent package version (package.yaml: {!r} != {}: {!r})'.format(
str(package.data.version), metadata.filename, metadata.version))
try:
py_typed_file = os.path.join(metadata.package_directory, 'py.typed')
except ValueError:
if package.data.typed:
yield CheckResult(package, 'WARNING', '$.package.typed only works with packages, but this is a module')
else:
if os.path.isfile(py_typed_file) and not package.data.typed:
yield CheckResult(package, 'WARNING', 'file "py.typed" exists but $.typed is not set')
def print_package_checks(project: Project, package: PackageModel, warnings_as_errors: bool = False) -> bool:
"""
Formats the checks created with #sanity_check_package().
"""
package_name_c = colored(package.data.name, 'yellow')
checks = list(sanity_check_package(project, package))
if not checks:
print('✔ no checks triggered on package {}'.format(package_name_c))
return True
max_level = max(x.level for x in checks)
if max_level == CheckResult.Level.INFO:
status = 0
elif max_level == CheckResult.Level.WARNING:
status = 1 if warnings_as_errors else 0
elif max_level == CheckResult.Level.ERROR:
status = 1
else:
assert False, max_level
icon = '' if status != 0 else ''
print(icon, len(checks), 'check(s) triggered on package {}:'.format(package_name_c))
colors = {'ERROR': 'red', 'WARNING': 'magenta', 'INFO': None}
for check in checks:
level = colored(check.level.name, colors[check.level.name])
print('-', level, check.message)
logger.debug('exiting with status %s', status)
return False
@pkg.command()
@click.option('-w', '--warnings-as-errors', is_flag=True)
def sanity(warnings_as_errors):
"""
Sanity-check the package configuration and package files. Which checks are performed
depends on the features that are enabled in the package configuration. Usually that
will at least include the "setuptools" feature which will perform basic sanity checks
on the package configuration and entrypoint definition.
"""
package = project.load(expect=PackageModel)
result = print_package_checks(project, package, warnings_as_errors)
if not result:
sys.exit(1)

+ 22
- 0
src/shut/impl/__init__.py

@ -0,0 +1,22 @@
# -*- coding: utf8 -*-
# Copyright (c) 2020 Niklas Rosenstein
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from . import setuptools

+ 105
- 0
src/shut/impl/setuptools.py

@ -0,0 +1,105 @@
# -*- coding: utf8 -*-
# Copyright (c) 2020 Niklas Rosenstein
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from shore.util.classifiers import get_classifiers
from shut.model import Project
from shut.model.checks import CheckResult, CheckStatus, Checker, check, register_checker
from shut.model.monorepo import MonorepoModel
from shut.model.package import PackageModel
from typing import Iterable, Optional
import os
class SetuptoolsChecker(Checker[PackageModel]):
@check('readme')
def _check_readme(self, project: Project, package: PackageModel) -> Iterable[CheckResult]:
if package.get_readme():
yield CheckResult(CheckStatus.PASSED, 'No README file found.')
@check('license')
def _check_license(self, project: Project, package: PackageModel) -> Iterable[CheckResult]:
if package.data.license and not package.get_license():
yield CheckResult('license', CheckStatus.WARNING, 'No LICENSE file found.')
monorepo = project.monorepo
if package.data.license and monorepo and monorepo.license \
and monorepo.license != package.data.license:
yield CheckResult('license-consistency', CheckStatus.ERROR,
'License is not consistent with parent mono repository (package: {}, monorepo: {}).'
.format(package.license, monorepo.license))
@check('classifiers')
def _check_classifiers(self, project: Project, package: PackageModel) -> Iterable[CheckResult]:
classifiers = get_classifiers()
unknown_classifiers = [x for x in package.data.classifiers if x not in classifiers]
if unknown_classifiers:
yield CheckResult('classifiers', CheckStatus.WARNING,
'Unknown classifiers: ' + ', '.join(unknown_classifiers))
@check('package-config')
def _check_metadata_completeness(self, project: Project, package: PackageModel) -> Iterable[CheckResult]:
if not package.data.author:
yield CheckResult('metadata-completeness', CheckStatus.WARNING, 'no $.package.author')
if not package.data.license: #and not package.get_private():
yield CheckResult('metadata-completeness', CheckStatus.WARNING, 'no $.package.license')
if not package.data.url:
yield CheckResult('metadata-completeness', CheckStatus.WARNING, 'no $.package.url')
@check('consistent-author')
def _check_consistent_author(self, project: Project, package: PackageModel) -> Iterable[CheckResult]:
metadata = package.get_python_package_metadata()
if package.data.author and metadata.author != str(package.data.author):
yield CheckResult(
CheckStatus.ERROR,
'Inconsistent package author (package.yaml: {!r} != {}: {!r})'.format(
str(package.data.author), metadata.filename, metadata.author))
@check('consistent-version')
def _check_consistent_version(self, project: Project, package: PackageModel) -> Iterable[CheckResult]:
metadata = package.get_python_package_metadata()
if package.data.version and metadata.version != str(package.data.version):
yield CheckResult(
CheckStatus.ERROR,
'{!r} ({}) != {!r} ({})'.format(
str(package.data.version),
os.path.basename(package.filename),
metadata.version,
os.path.relpath(metadata.filename)))
@check('typed')
def _check_typed(self, project: Project, package: PackageModel) -> Iterable[CheckResult]:
metadata = package.get_python_package_metadata()
try:
py_typed_file = os.path.join(metadata.package_directory, 'py.typed')
except ValueError:
if package.data.typed:
yield CheckResult('typed', CheckStatus.WARNING,
'$.package.typed only works with packages, but this is a module')
else:
if os.path.isfile(py_typed_file) and not package.data.typed:
yield CheckResult('typed', CheckStatus.WARNING,
'file "py.typed" exists but $.typed is not set')
register_checker(SetuptoolsChecker, PackageModel)

+ 113
- 0
src/shut/model/checks.py

@ -0,0 +1,113 @@
# -*- coding: utf8 -*-
# Copyright (c) 2020 Niklas Rosenstein
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
"""
API for describing sanity checks around a package or monorepo configuration.
"""
from collections import namedtuple
from typing import Callable, Iterable, Generic, Type, TypeVar, Union
import enum
import types
T = TypeVar('T')
class CheckStatus(enum.IntEnum):
PASSED = enum.auto() #: The check has passed.
WARNING = enum.auto() #: The check is merely giving a warning.
ERROR = enum.auto() #: The check has an error, something is in a bad or invalid state.
CheckResult = namedtuple('CheckResult', 'status,message')
Check = namedtuple('Check', 'name,result')
def check(name: str) -> Callable[[Callable], Callable]:
"""
Decorator for methods on a #Checker instance.
"""
def decorator(func: Callable) -> Callable:
func.__check_name__ = name
return func
return decorator
class Checker(Generic[T]):
def get_checks(self, project: 'Project', subject: T) -> Iterable[Check]:
"""
Yield #Check objects for the *subject*. By default, all methods decorated with
#check() are called.
"""
for key in dir(self):
value = getattr(self, key)
check_value = value
if isinstance(value, types.MethodType):
check_value = value.__func__
if isinstance(check_value, types.FunctionType) and hasattr(check_value, '__check_name__'):
index = None
for index, result in enumerate(value(project, subject)):
yield Check(value.__check_name__, result)
if index is None:
yield Check(value.__check_name__, CheckResult(CheckStatus.PASSED, None))
registry = {}
def register_checker(checker: Type[Checker[T]], t: Type[T]) -> Type[Checker]:
"""
Decorator to register a #Checker subclass.
"""
registry.setdefault(t, []).append(checker)
def get_checks(project: 'Project', obj: T) -> Iterable[Check]:
"""
Returns all checks from the checkers registered for the type of *obj*.
"""
for checker in registry.get(type(obj), []):
yield from checker().get_checks(project, obj)
class BasicChecker(Checker):
@check('unknown-config')
def _check_unknown_keys(
self,
project: 'Project',
obj: Union['MonorepoModel', 'PackageModel'],
) -> Iterable[CheckResult]:
yield CheckResult(
CheckStatus.WARNING if obj.unknown_keys else CheckStatus.PASSED,
', '.join(map(str, obj.unknown_keys)) if obj.unknown_keys else None)
from shut.model import MonorepoModel, PackageModel, Project
register_checker(BasicChecker, MonorepoModel)
register_checker(BasicChecker, PackageModel)

+ 46
- 4
src/shut/model/package.py

@ -20,7 +20,6 @@
# IN THE SOFTWARE.
from shore.util.ast import load_module_members
from shore.plugins._util import find_readme_file
from .author import Author
from .changelog import ChangelogConfiguration
@ -29,11 +28,31 @@ from .release import ReleaseConfiguration
from .requirements import Requirement
from .version import Version
from nr.databind.core import Field, FieldName, Struct
from typing import Dict, List, Optional
from typing import Dict, Iterable, List, Optional
import ast
import os
def _get_file_in_directory(directory: str, prefix: str, preferred: List[str]) -> Optional[str]:
"""
Returns a file in *directory* that is either in the *preferred* list or starts with
specified *prefix*.
"""
choices = []
for name in sorted(os.listdir(directory)):
if name in preferred:
break
if name.startswith(prefix):
choices.append(name)
else:
if choices:
return choices[0]
return None
return os.path.join(directory, name)
class PackageData(Struct):
name = Field(str)
modulename = Field(str, default=None)
@ -79,6 +98,12 @@ class PackageModel(Struct):
release = Field(ReleaseConfiguration, default=Field.DEFAULT_CONSTRUCT)
def get_python_package_metadata(self) -> 'PythonPackageMetadata':
"""
Returns a #PythonPackageMetadata object for this #PackageModel. This object can be
used to inspect the author and version information that is defined in the package
source code.
"""
return PythonPackageMetadata(
os.path.join(os.path.dirname(self.filename), self.data.source_directory),
self.data.get_modulename())
@ -88,15 +113,32 @@ class PackageModel(Struct):
Returns the absolute path to the README for this package.
"""
directory = os.path.dirname(__file__)
directory = os.path.dirname(self.filename)
if self.data.readme:
return os.path.abspath(os.path.join(directory, self.readme))
return find_readme_file(directory)
return _get_file_in_directory(
directory=directory,
prefix='README.',
preferred=['README.md', 'README.rst', 'README.txt', 'README'])
def get_license(self) -> Optional[str]:
"""
Returns the absolute path to the LICENSE file for this package.
"""
return _get_file_in_directory(
directory=os.path.dirname(self.filename),
prefix='LICENSE.',
preferred=['LICENSE', 'LICENSE.txt', 'LICENSE.rst', 'LICENSE.md'])
class PythonPackageMetadata:
"""
Represents the metadata of a Python package on disk.
"""
def __init__(self, source_directory: str, modulename: str) -> None:
self.source_directory = source_directory

Loading…
Cancel
Save