diff --git a/.github/workflows/install_run.yml b/.github/workflows/install_run.yml index cae758848b..f58ee2c25d 100644 --- a/.github/workflows/install_run.yml +++ b/.github/workflows/install_run.yml @@ -99,9 +99,9 @@ jobs: || exit 0 shell: bash - - name: Install `ufo-sources` extra + - name: Install `ufo_sources` extra run: | - python -m pip install '.[ufo-sources]' + python -m pip install '.[ufo_sources]' - name: Run UFO Sources checks run: >- diff --git a/.github/workflows/lint_test.yml b/.github/workflows/lint_test.yml index f995770de0..f27bcb869d 100644 --- a/.github/workflows/lint_test.yml +++ b/.github/workflows/lint_test.yml @@ -37,7 +37,9 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -r requirements.txt -r requirements-tests.txt -r requirements-docs.txt + # For pytype we need everything + python -m pip install .[tests,docs] + python -m pip install glyphsLib python -m pip install pytype # Not in requirements as it doesn't work on Windows - name: Run black, pylint and pytype @@ -70,7 +72,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -r requirements.txt -r requirements-tests.txt -r requirements-docs.txt + python -m pip install .[tests,docs] python -m pip freeze --all - name: Install FontBakery diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc776f657..5ff508e6f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,12 @@ A more detailed list of changes is available in the corresponding milestones for - v0.12.0a2 (2024-Feb-21) ### Changes to existing checks +#### On the Universal profile + - **DISABLED - [com.google.fonts/check/legacy_accents]:** This is one of the checks that we probably should run on the sources instead of binaries. (https://github.com/fonttools/fontbakery/issues/3959#issuecomment-1822913547) + #### On the Open Type Profile - **[com.google.fonts/check/layout_valid_feature_tags]:** Updated the check to allow valid private-use feature tags. (issue #4544) + - **[com.google.fonts/check/varfont/family_axis_ranges]:** Updated the check to skip fonts without fvar tables. (issue #4554) - **[com.google.fonts/check/mac_style]:** Skip if font style can not be determined. (issue #4349) diff --git a/Lib/fontbakery/checkrunner.py b/Lib/fontbakery/checkrunner.py index 02f34db2a7..bc8726a604 100644 --- a/Lib/fontbakery/checkrunner.py +++ b/Lib/fontbakery/checkrunner.py @@ -113,7 +113,7 @@ def get_iterarg(self, name, index): def _get(self, name, iterargs, condition=False): # Is this a property of the whole collection? - if hasattr(self.context, name): + if name in dir(self.context): return getattr(self.context, name) # Is it a property of the file we're testing? for thing, index in iterargs: @@ -121,7 +121,7 @@ def _get(self, name, iterargs, condition=False): # Allow "font" to return the Font object itself if name == thing: return specific_thing - if not hasattr(specific_thing, name): + if name not in dir(specific_thing): continue return getattr(specific_thing, name) if condition: @@ -243,7 +243,7 @@ def order(self) -> Tuple[Identity, ...]: ): continue args = set(check.args) - context_args = set(arg for arg in args if hasattr(self.context, arg)) + context_args = set(arg for arg in args if arg in dir(self.context)) # Either this is a check which runs on the whole collection # (i.e. all of its arguments can be called as methods on the @@ -257,7 +257,7 @@ def order(self) -> Tuple[Identity, ...]: individual_args = args - context_args if ( all( - hasattr(file, arg) + arg in dir(file) for arg in individual_args for file in files ) diff --git a/Lib/fontbakery/checks/opentype/fvar.py b/Lib/fontbakery/checks/opentype/fvar.py index 08cdc873ff..56e5ac26a1 100644 --- a/Lib/fontbakery/checks/opentype/fvar.py +++ b/Lib/fontbakery/checks/opentype/fvar.py @@ -673,6 +673,7 @@ def com_adobe_fonts_check_varfont_foundry_defined_tag_name(ttFont): """, proposal="https://github.com/fonttools/fontbakery/issues/4445", experimental="Since 2024/Jan/30", + conditions=["VFs"], ) def com_google_fonts_check_varfont_family_axis_ranges(ttFonts): """Check that family axis ranges are indentical""" diff --git a/Lib/fontbakery/checks/universal/__init__.py b/Lib/fontbakery/checks/universal/__init__.py index 9d63bbca7f..155ee05da6 100644 --- a/Lib/fontbakery/checks/universal/__init__.py +++ b/Lib/fontbakery/checks/universal/__init__.py @@ -741,6 +741,7 @@ def com_google_fonts_check_whitespace_ink(ttFont): yield PASS, "There is no whitespace glyph with ink." +@disable # https://github.com/fonttools/fontbakery/issues/3959#issuecomment-1822913547 @check( id="com.google.fonts/check/legacy_accents", proposal=[ diff --git a/Lib/fontbakery/cli.py b/Lib/fontbakery/cli.py index 4f2e58a3e4..8a6271329d 100644 --- a/Lib/fontbakery/cli.py +++ b/Lib/fontbakery/cli.py @@ -1,14 +1,39 @@ +#!/usr/bin/env python +# usage: +# $ fontbakery check-profile fontbakery.profiles.googlefonts -h import argparse -import pkgutil -import runpy -import signal +from collections import OrderedDict +import os import sys -from importlib import import_module +import signal from fontbakery import __version__ -import fontbakery.commands -from fontbakery.commands.check_profile import main as check_profile_main -from fontbakery.fonts_profile import profile_factory +from fontbakery.checkrunner import CheckRunner +from fontbakery.status import ( + DEBUG, + ERROR, + FATAL, + FAIL, + INFO, + PASS, + SKIP, + WARN, +) +from fontbakery.configuration import Configuration +from fontbakery.errors import ValueValidationError +from fontbakery.fonts_profile import ( + profile_factory, + get_module, + setup_context, + ITERARGS, +) +from fontbakery.reporters.terminal import TerminalReporter +from fontbakery.reporters.serialize import JSONReporter +from fontbakery.reporters.badge import BadgeReporter +from fontbakery.reporters.ghmarkdown import GHMarkdownReporter +from fontbakery.reporters.html import HTMLReporter +from fontbakery.utils import get_theme + CLI_PROFILES = [ "adobefonts", @@ -26,12 +51,24 @@ ] -def run_profile_check(profilename): - from fontbakery.utils import set_profile_name +log_levels = OrderedDict( + (s.name, s) for s in sorted((DEBUG, INFO, FATAL, WARN, ERROR, SKIP, PASS, FAIL)) +) + +DEFAULT_LOG_LEVEL = WARN +DEFAULT_ERROR_CODE_ON = FAIL + - set_profile_name(profilename) - module = import_module(f"fontbakery.profiles.{profilename}") - sys.exit(check_profile_main(profile_factory(module))) +class AddReporterAction(argparse.Action): + def __init__(self, option_strings, dest, nargs=None, **kwargs): + self.cls = kwargs["cls"] + del kwargs["cls"] + super().__init__(option_strings, dest, **kwargs) + + def __call__(self, parser, namespace, values, option_string=None): + if not hasattr(namespace, "reporters"): + namespace.reporters = [] + namespace.reporters.append((self.cls, values)) def signal_handler(sig, frame): @@ -39,55 +76,462 @@ def signal_handler(sig, frame): sys.exit(-1) +def ArgumentParser(): + argument_parser = argparse.ArgumentParser( + description="Check TTF files against a profile.", + formatter_class=argparse.RawTextHelpFormatter, + ) + + argument_parser.add_argument("--version", action="version", version=__version__) + subcommands = ["check-profile"] + ["check_" + prof for prof in CLI_PROFILES] + subcommands = [command.replace("_", "-") for command in sorted(subcommands)] + + subparsers = argument_parser.add_subparsers( + dest="command", help="sub-command help", required=True + ) + + for subcommand in subcommands: + subparser = subparsers.add_parser( + subcommand, help="Run the " + subcommand + " subcommand." + ) + if subcommand == "check-profile": + subparser.add_argument( + "profile", + help="File/Module name, must define an fontbakery 'profile'.", + metavar="PROFILE", + ) + add_profile_arguments(subparser) + return argument_parser + + +def add_profile_arguments(argument_parser): + argument_parser.add_argument( + "-L", + "--list-checks", + default=False, + action="store_true", + help="List the checks available in the selected profile.", + ) + + argument_parser.add_argument( + "--configuration", + dest="configfile", + help="Read configuration file (TOML/YAML).\n", + ) + + argument_parser.add_argument( + "-c", + "--checkid", + action="append", + help=( + "Explicit check-ids (or parts of their name) to be executed.\n" + "Use this option multiple times to select multiple checks." + ), + ) + + argument_parser.add_argument( + "-x", + "--exclude-checkid", + action="append", + help=( + "Exclude check-ids (or parts of their name) from execution.\n" + "Use this option multiple times to exclude multiple checks." + ), + ) + + logging_group = argument_parser.add_argument_group( + "Logging", "Options which control the amount and order of output" + ) + + valid_keys = ", ".join(log_levels.keys()) + + def log_levels_get(key): + if key in log_levels: + return log_levels[key] + raise argparse.ArgumentTypeError(f'Key "{key}" must be one of: {valid_keys}.') + + logging_group.add_argument( + "-v", + "--verbose", + dest="loglevels", + const=PASS, + action="append_const", + help="Shortcut for '-l PASS'.\n", + ) + + logging_group.add_argument( + "-l", + "--loglevel", + dest="loglevels", + type=log_levels_get, + action="append", + metavar="LOGLEVEL", + help=f"Report checks with a result of this status or higher.\n" + f"One of: {valid_keys}.\n" + f"(default: {DEFAULT_LOG_LEVEL.name})", + ) + + logging_group.add_argument( + "-m", + "--loglevel-messages", + default=None, + type=log_levels_get, + help=f"Report log messages of this status or higher.\n" + f"Messages are all status lines within a check.\n" + f"One of: {valid_keys}.\n" + f"(default: LOGLEVEL)", + ) + + logging_group.add_argument( + "--succinct", + action="store_true", + help="This is a slightly more compact and succint output layout.", + ) + + logging_group.add_argument( + "-F", + "--full-lists", + default=False, + action="store_true", + help="Do not shorten lists of items.", + ) + + logging_group.add_argument( + "-S", + "--show-sections", + default=False, + action="store_true", + help="Show section summaries.", + ) + + iterargs = sorted(ITERARGS.keys()) + + gather_by_choices = iterargs + ["*check"] + comma_separated = ", ".join(gather_by_choices) + logging_group.add_argument( + "-g", + "--gather-by", + default=None, + metavar="ITERATED_ARG", + choices=gather_by_choices, + type=str, + help=f"Optional: collect results by ITERATED_ARG\n" + f"In terminal output: create a summary counter for each ITERATED_ARG.\n" + f"In json output: structure the document by ITERATED_ARG.\n" + f"One of: {comma_separated}", + ) + + def parse_order(arg): + order = list(filter(len, [n.strip() for n in arg.split(",")])) + return order or None + + comma_separated = ", ".join(iterargs) + logging_group.add_argument( + "-o", + "--order", + default=None, + type=parse_order, + help=f"Comma separated list of order arguments.\n" + f"The execution order is determined by the order of the check\n" + f"definitions and by the order of the iterable arguments.\n" + f"A section defines its own order. `--order` can be used to\n" + f"override the order of *all* sections.\n" + f"Despite the ITERATED_ARGS there are two special\n" + f"values available:\n" + f'"*iterargs" -- all remainig ITERATED_ARGS\n' + f'"*check" -- order by check\n' + f"ITERATED_ARGS: {comma_separated}\n" + f'A sections default is equivalent to: "*iterargs, *check".\n' + f'A common use case is `-o "*check"` when checking the whole \n' + f"collection against a selection of checks picked with `--checkid`.", + ) + + terminal_group = argument_parser.add_argument_group( + "Terminal", "Options related to terminal output" + ) + + terminal_group.add_argument( + "-q", + "--quiet", + action="store_true", + help="Be quiet, donโ€™t report anything on the terminal.", + ) + + terminal_group.add_argument( + "-n", + "--no-progress", + default=False, + action="store_true", + help="Suppress the progress indicators in the console output.", + ) + + terminal_group.add_argument( + "-C", + "--no-colors", + default=False, + action="store_true", + help="Suppress the coloring theme in the console output.", + ) + + theme_group = terminal_group.add_mutually_exclusive_group() + theme_group.add_argument( + "--dark-theme", + default=False, + action="store_true", + help="Use a color theme with dark colors.", + ) + theme_group.add_argument( + "--light-theme", + default=False, + action="store_true", + help="Use a color theme with light colors.", + ) + + network_group = argument_parser.add_argument_group( + "Network", "Network related options" + ) + network_group = network_group.add_mutually_exclusive_group() + + network_group.add_argument( + "--timeout", + default=10, + type=int, + help="Timeout (in seconds) for network operations.", + ) + + network_group.add_argument( + "--skip-network", + default=False, + action="store_true", + help="Skip network checks", + ) + + report_group = argument_parser.add_argument_group( + "Reports", "Options which control report generation" + ) + + report_group.add_argument( + "--json", + default=False, + action=AddReporterAction, + cls=JSONReporter, + metavar="JSON_FILE", + help="Write a json formatted report to JSON_FILE.", + ) + + report_group.add_argument( + "--badges", + default=False, + action=AddReporterAction, + cls=BadgeReporter, + metavar="DIRECTORY", + help="Write a set of shields.io badge files to DIRECTORY.", + ) + + report_group.add_argument( + "--ghmarkdown", + default=False, + action=AddReporterAction, + cls=GHMarkdownReporter, + metavar="MD_FILE", + help="Write a GitHub-Markdown formatted report to MD_FILE.", + ) + + report_group.add_argument( + "--html", + default=False, + action=AddReporterAction, + cls=HTMLReporter, + metavar="HTML_FILE", + help="Write a HTML report to HTML_FILE.", + ) + + def positive_int(value): + int_value = int(value) + if int_value < 0: + raise argparse.ArgumentTypeError( + f'Invalid value "{value}" must be' f" zero or a positive integer value." + ) + return int_value + + argument_parser.add_argument( + "-J", + "--jobs", + default=1, + type=positive_int, + metavar="JOBS", + dest="multiprocessing", + help=f"Use multi-processing to run the checks. The argument is the number\n" + f"of worker processes. A sensible number is the cpu count of your\n" + f"system, detected: {os.cpu_count()}." + f" As an automated shortcut see -j/--auto-jobs.\n" + f"Use 1 to run in single-processing mode (default %(default)s).", + ) + argument_parser.add_argument( + "-j", + "--auto-jobs", + const=os.cpu_count(), + action="store_const", + dest="multiprocessing", + help="Use the auto detected cpu count (= %(const)s)" + " as number of worker processes\n" + "in multi-processing. This is equivalent to : `--jobs %(const)s`", + ) + argument_parser.add_argument( + "-e", + "--error-code-on", + dest="error_code_on", + type=log_levels_get, + default=DEFAULT_ERROR_CODE_ON, + help="Threshold for emitting process error code 1. (Useful for" + " deciding the criteria for breaking a continuous integration job)\n" + f"One of: {valid_keys}.\n" + f"(default: {DEFAULT_ERROR_CODE_ON.name})", + ) + + argument_parser.add_argument( + "files", + nargs="*", # allow no input files; needed for -L/--list-checks option + help="file path(s) to check. Wildcards like *.ttf are allowed.", + ) + + return argument_parser + + +class ArgumentParserError(Exception): + pass + + def main(): signal.signal(signal.SIGINT, signal_handler) - subcommands = [ - pkg[1] for pkg in pkgutil.walk_packages(fontbakery.commands.__path__) - ] + ["check_" + prof for prof in CLI_PROFILES] + argument_parser = ArgumentParser() + try: + args = argument_parser.parse_args() + except ValueValidationError as e: + print(e) + argument_parser.print_usage() + sys.exit(1) - subcommands = [command.replace("_", "-") for command in sorted(subcommands)] + theme = get_theme(args) - if len(sys.argv) >= 2 and sys.argv[1] in subcommands: - # Relay to subcommand. - subcommand = sys.argv[1] - subcommand_module = subcommand.replace("-", "_") - sys.argv[0] += " " + subcommand - del sys.argv[1] # Make this indirection less visible for subcommands. - if ( - subcommand_module.startswith("check_") - and subcommand_module[6:] in CLI_PROFILES - ): - run_profile_check(subcommand_module[6:]) - else: - runpy.run_module( - "fontbakery.commands." + subcommand_module, run_name="__main__" - ) + if args.command != "check-profile": + args.profile = "fontbakery.profiles." + args.command.replace( + "check-", "" + ).replace("-", "_") + + profile = profile_factory(get_module(args.profile)) + + if args.list_checks: + # the most verbose loglevel wins + loglevel = min(args.loglevels) if args.loglevels else DEFAULT_LOG_LEVEL + list_checks(profile, theme, verbose=loglevel > DEFAULT_LOG_LEVEL) + + if args.configfile: + configuration = Configuration.from_config_file(args.configfile) else: - description = ( - "Run fontbakery subcommands. Subcommands have their own help messages;\n" - "to view them add the '-h' (or '--help') option after the subcommand,\n" - "like in this example:\n fontbakery universal -h" - ) + configuration = Configuration() + + # Since version 0.8.10, we established a convention of never using a dash/hyphen + # on check IDs. The existing ones were replaced by underscores. + # All new checks will use underscores when needed. + # Here we accept dashes to ensure backwards compatibility with the older + # check IDs that may still be referenced on scripts of our users. + explicit_checks = None + exclude_checks = None + if args.checkid: + explicit_checks = [c.replace("-", "_") for c in list(args.checkid)] + if args.exclude_checkid: + exclude_checks = [x.replace("-", "_") for x in list(args.exclude_checkid)] - parser = argparse.ArgumentParser( - formatter_class=argparse.RawTextHelpFormatter, description=description + # Command line args overrides config, but only if given + configuration.maybe_override( + Configuration( + custom_order=args.order, + explicit_checks=explicit_checks, + exclude_checks=exclude_checks, + full_lists=args.full_lists, + skip_network=args.skip_network, ) - parser.add_argument( - "subcommand", - help="The subcommand to execute", - nargs="?", - choices=subcommands, + ) + + context = setup_context(args.files) + try: + runner = CheckRunner( + profile, jobs=args.multiprocessing, context=context, config=configuration ) - parser.add_argument( - "--list-subcommands", - action="store_true", - help="print list of supported subcommands", + except ValueValidationError as e: + print(e) + argument_parser.print_usage() + sys.exit(1) + + is_async = args.multiprocessing != 0 + if not args.loglevels: + args.loglevels = [ + status + for status in log_levels.values() + if status.weight >= DEFAULT_LOG_LEVEL.weight + ] + + tr = TerminalReporter( + is_async=is_async, + runner=runner, + loglevels=args.loglevels, + succinct=args.succinct, + collect_results_by=args.gather_by, + theme=theme, + print_progress=not args.no_progress, + quiet=args.quiet, + ) + reporters = [tr] + + if "reporters" not in args: + args.reporters = [] + + for reporter_class, output_file in args.reporters: + reporters.append( + reporter_class( + is_async=is_async, + runner=runner, + loglevels=args.loglevels, + succinct=args.succinct, + collect_results_by=args.gather_by, + output_file=output_file, + quiet=args.quiet, + ) ) - parser.add_argument("--version", action="version", version=__version__) - args = parser.parse_args() - if args.list_subcommands: - print(" ".join(subcommands)) - else: - parser.print_help() + runner.run(reporters) + + for reporter in reporters: + reporter.write() + + # Fail and error let the command fail + return ( + 1 + if tr.worst_check_status is not None + and tr.worst_check_status.weight >= args.error_code_on.weight + else 0 + ) + + +def list_checks(profile, theme, verbose=False): + if verbose: + for section in profile.sections: + print(theme["list-checks: section"]("\nSection:") + " " + section.name) + for check in section.checks: + print( + theme["list-checks: check-id"](check.id) + + "\n" + + theme["list-checks: description"](f'"{check.description}"') + + "\n" + ) + else: + for section in profile.sections: + for check in section.checks: + print(check.id) + sys.exit() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Lib/fontbakery/codetesting.py b/Lib/fontbakery/codetesting.py index f690f273df..495d544c98 100644 --- a/Lib/fontbakery/codetesting.py +++ b/Lib/fontbakery/codetesting.py @@ -51,6 +51,9 @@ def make_mock(basecls, name): def __init__(self, **kwargs): self.__dict__.update(kwargs) + def __dir__(self): + return dir(basecls) + list(self.__dict__.keys()) + def __getattr__(self, name): prop = getattr(basecls, name) if isinstance(prop, cached_property): @@ -61,6 +64,7 @@ def __getattr__(self, name): cls.__init__ = __init__ cls.__getattr__ = __getattr__ + cls.__dir__ = __dir__ cls.mocked = True return cls diff --git a/Lib/fontbakery/commands/__init__.py b/Lib/fontbakery/commands/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/Lib/fontbakery/commands/check_profile.py b/Lib/fontbakery/commands/check_profile.py deleted file mode 100644 index 83539f1592..0000000000 --- a/Lib/fontbakery/commands/check_profile.py +++ /dev/null @@ -1,494 +0,0 @@ -#!/usr/bin/env python -# usage: -# $ fontbakery check-profile fontbakery.profiles.googlefonts -h -import argparse -from collections import OrderedDict -import os -import sys - -from fontbakery.checkrunner import CheckRunner -from fontbakery.status import ( - DEBUG, - ERROR, - FATAL, - FAIL, - INFO, - PASS, - SKIP, - WARN, -) -from fontbakery.configuration import Configuration -from fontbakery.errors import ValueValidationError -from fontbakery.fonts_profile import ( - profile_factory, - get_module, - setup_context, - ITERARGS, -) -from fontbakery.reporters.terminal import TerminalReporter -from fontbakery.reporters.serialize import SerializeReporter -from fontbakery.reporters.badge import BadgeReporter -from fontbakery.reporters.ghmarkdown import GHMarkdownReporter -from fontbakery.reporters.html import HTMLReporter -from fontbakery.utils import get_theme - - -log_levels = OrderedDict( - (s.name, s) for s in sorted((DEBUG, INFO, FATAL, WARN, ERROR, SKIP, PASS, FAIL)) -) - -DEFAULT_LOG_LEVEL = WARN -DEFAULT_ERROR_CODE_ON = FAIL - - -class AddReporterAction(argparse.Action): - def __init__(self, option_strings, dest, nargs=None, **kwargs): - self.cls = kwargs["cls"] - del kwargs["cls"] - super().__init__(option_strings, dest, **kwargs) - - def __call__(self, parser, namespace, values, option_string=None): - if not hasattr(namespace, "reporters"): - namespace.reporters = [] - namespace.reporters.append((self.cls, values)) - - -def ArgumentParser(profile_arg=True): - argument_parser = argparse.ArgumentParser( - description="Check TTF files against a profile.", - formatter_class=argparse.RawTextHelpFormatter, - ) - - if profile_arg: - argument_parser.add_argument( - "profile", help="File/Module name, must define an fontbakery 'profile'." - ) - - argument_parser.add_argument( - "--configuration", - dest="configfile", - help="Read configuration file (TOML/YAML).\n", - ) - - argument_parser.add_argument( - "-c", - "--checkid", - action="append", - help=( - "Explicit check-ids (or parts of their name) to be executed.\n" - "Use this option multiple times to select multiple checks." - ), - ) - - argument_parser.add_argument( - "-x", - "--exclude-checkid", - action="append", - help=( - "Exclude check-ids (or parts of their name) from execution.\n" - "Use this option multiple times to exclude multiple checks." - ), - ) - - valid_keys = ", ".join(log_levels.keys()) - - def log_levels_get(key): - if key in log_levels: - return log_levels[key] - raise argparse.ArgumentTypeError(f'Key "{key}" must be one of: {valid_keys}.') - - argument_parser.add_argument( - "-v", - "--verbose", - dest="loglevels", - const=PASS, - action="append_const", - help="Shortcut for '-l PASS'.\n", - ) - - argument_parser.add_argument( - "-q", - "--quiet", - action="store_true", - help="Be quiet, Donโ€™t report anything on the terminal.", - ) - - argument_parser.add_argument( - "-l", - "--loglevel", - dest="loglevels", - type=log_levels_get, - action="append", - metavar="LOGLEVEL", - help=f"Report checks with a result of this status or higher.\n" - f"One of: {valid_keys}.\n" - f"(default: {DEFAULT_LOG_LEVEL.name})", - ) - - argument_parser.add_argument( - "-m", - "--loglevel-messages", - default=None, - type=log_levels_get, - help=f"Report log messages of this status or higher.\n" - f"Messages are all status lines within a check.\n" - f"One of: {valid_keys}.\n" - f"(default: LOGLEVEL)", - ) - - argument_parser.add_argument( - "--succinct", - action="store_true", - help="This is a slightly more compact and succint output layout.", - ) - - argument_parser.add_argument( - "-n", - "--no-progress", - default=False, - action="store_true", - help="Suppress the progress indicators in the console output.", - ) - - argument_parser.add_argument( - "-C", - "--no-colors", - default=False, - action="store_true", - help="Suppress the coloring theme in the console output.", - ) - - argument_parser.add_argument( - "-S", - "--show-sections", - default=False, - action="store_true", - help="Show section summaries.", - ) - - argument_parser.add_argument( - "-L", - "--list-checks", - default=False, - action="store_true", - help="List the checks available in the selected profile.", - ) - - argument_parser.add_argument( - "-F", - "--full-lists", - default=False, - action="store_true", - help="Do not shorten lists of items.", - ) - - argument_parser.add_argument( - "--dark-theme", - default=False, - action="store_true", - help="Use a color theme with dark colors.", - ) - - argument_parser.add_argument( - "--light-theme", - default=False, - action="store_true", - help="Use a color theme with light colors.", - ) - - argument_parser.add_argument( - "--timeout", - default=10, - type=int, - help="Timeout (in seconds) for network operations.", - ) - - argument_parser.add_argument( - "--skip-network", - default=False, - action="store_true", - help="Skip network checks", - ) - - argument_parser.add_argument( - "--json", - default=False, - action=AddReporterAction, - cls=SerializeReporter, - metavar="JSON_FILE", - help="Write a json formatted report to JSON_FILE.", - ) - - argument_parser.add_argument( - "--badges", - default=False, - action=AddReporterAction, - cls=BadgeReporter, - metavar="DIRECTORY", - help="Write a set of shields.io badge files to DIRECTORY.", - ) - - argument_parser.add_argument( - "--ghmarkdown", - default=False, - action=AddReporterAction, - cls=GHMarkdownReporter, - metavar="MD_FILE", - help="Write a GitHub-Markdown formatted report to MD_FILE.", - ) - - argument_parser.add_argument( - "--html", - default=False, - action=AddReporterAction, - cls=HTMLReporter, - metavar="HTML_FILE", - help="Write a HTML report to HTML_FILE.", - ) - - iterargs = sorted(ITERARGS.keys()) - - gather_by_choices = iterargs + ["*check"] - comma_separated = ", ".join(gather_by_choices) - argument_parser.add_argument( - "-g", - "--gather-by", - default=None, - metavar="ITERATED_ARG", - choices=gather_by_choices, - type=str, - help=f"Optional: collect results by ITERATED_ARG\n" - f"In terminal output: create a summary counter for each ITERATED_ARG.\n" - f"In json output: structure the document by ITERATED_ARG.\n" - f"One of: {comma_separated}", - ) - - def parse_order(arg): - order = list(filter(len, [n.strip() for n in arg.split(",")])) - return order or None - - comma_separated = ", ".join(iterargs) - argument_parser.add_argument( - "-o", - "--order", - default=None, - type=parse_order, - help=f"Comma separated list of order arguments.\n" - f"The execution order is determined by the order of the check\n" - f"definitions and by the order of the iterable arguments.\n" - f"A section defines its own order. `--order` can be used to\n" - f"override the order of *all* sections.\n" - f"Despite the ITERATED_ARGS there are two special\n" - f"values available:\n" - f'"*iterargs" -- all remainig ITERATED_ARGS\n' - f'"*check" -- order by check\n' - f"ITERATED_ARGS: {comma_separated}\n" - f'A sections default is equivalent to: "*iterargs, *check".\n' - f'A common use case is `-o "*check"` when checking the whole \n' - f"collection against a selection of checks picked with `--checkid`.", - ) - - def positive_int(value): - int_value = int(value) - if int_value < 0: - raise argparse.ArgumentTypeError( - f'Invalid value "{value}" must be' f" zero or a positive integer value." - ) - return int_value - - argument_parser.add_argument( - "-J", - "--jobs", - default=1, - type=positive_int, - metavar="JOBS", - dest="multiprocessing", - help=f"Use multi-processing to run the checks. The argument is the number\n" - f"of worker processes. A sensible number is the cpu count of your\n" - f"system, detected: {os.cpu_count()}." - f" As an automated shortcut see -j/--auto-jobs.\n" - f"Use 1 to run in single-processing mode (default %(default)s).", - ) - argument_parser.add_argument( - "-j", - "--auto-jobs", - const=os.cpu_count(), - action="store_const", - dest="multiprocessing", - help="Use the auto detected cpu count (= %(const)s)" - " as number of worker processes\n" - "in multi-processing. This is equivalent to : `--jobs %(const)s`", - ) - argument_parser.add_argument( - "-e", - "--error-code-on", - dest="error_code_on", - type=log_levels_get, - default=DEFAULT_ERROR_CODE_ON, - help="Threshold for emitting process error code 1. (Useful for" - " deciding the criteria for breaking a continuous integration job)\n" - f"One of: {valid_keys}.\n" - f"(default: {DEFAULT_ERROR_CODE_ON.name})", - ) - - argument_parser.add_argument( - "files", - nargs="*", # allow no input files; needed for -L/--list-checks option - help="file path(s) to check. Wildcards like *.ttf are allowed.", - ) - - return argument_parser - - -class ArgumentParserError(Exception): - pass - - -def get_profile(): - """Prefetch the profile module, to fill some holes in the help text.""" - argument_parser = ArgumentParser(profile_arg=True) - - # monkey patching will do here - def error(message): - raise ArgumentParserError(message) - - argument_parser.error = error - - args, _ = argument_parser.parse_known_args() - imported = get_module(args.profile) - profile = profile_factory(imported) - if not profile: - raise Exception(f"Can't get a profile from {imported}.") - return profile - - -def main(profile=None, values=None): - # profile can be injected by e.g. check-googlefonts injects it's own profile - add_profile_arg = False - if profile is None: - profile = get_profile() - add_profile_arg = True - - argument_parser = ArgumentParser(profile_arg=add_profile_arg) - try: - args = argument_parser.parse_args() - except ValueValidationError as e: - print(e) - argument_parser.print_usage() - sys.exit(1) - - theme = get_theme(args) - - if args.list_checks: - # the most verbose loglevel wins - loglevel = min(args.loglevels) if args.loglevels else DEFAULT_LOG_LEVEL - list_checks(profile, theme, verbose=loglevel > DEFAULT_LOG_LEVEL) - - if args.configfile: - configuration = Configuration.from_config_file(args.configfile) - else: - configuration = Configuration() - - # Since version 0.8.10, we established a convention of never using a dash/hyphen - # on check IDs. The existing ones were replaced by underscores. - # All new checks will use underscores when needed. - # Here we accept dashes to ensure backwards compatibility with the older - # check IDs that may still be referenced on scripts of our users. - explicit_checks = None - exclude_checks = None - if args.checkid: - explicit_checks = [c.replace("-", "_") for c in list(args.checkid)] - if args.exclude_checkid: - exclude_checks = [x.replace("-", "_") for x in list(args.exclude_checkid)] - - # Command line args overrides config, but only if given - configuration.maybe_override( - Configuration( - custom_order=args.order, - explicit_checks=explicit_checks, - exclude_checks=exclude_checks, - full_lists=args.full_lists, - skip_network=args.skip_network, - ) - ) - - context = setup_context(args.files) - try: - runner = CheckRunner( - profile, jobs=args.multiprocessing, context=context, config=configuration - ) - except ValueValidationError as e: - print(e) - argument_parser.print_usage() - sys.exit(1) - - is_async = args.multiprocessing != 0 - if not args.loglevels: - args.loglevels = [ - status - for status in log_levels.values() - if status.weight >= DEFAULT_LOG_LEVEL.weight - ] - - tr = TerminalReporter( - is_async=is_async, - runner=runner, - loglevels=args.loglevels, - succinct=args.succinct, - collect_results_by=args.gather_by, - theme=theme, - print_progress=not args.no_progress, - quiet=args.quiet, - ) - reporters = [tr] - - if "reporters" not in args: - args.reporters = [] - - for reporter_class, output_file in args.reporters: - reporters.append( - reporter_class( - is_async=is_async, - runner=runner, - loglevels=args.loglevels, - succinct=args.succinct, - collect_results_by=args.gather_by, - output_file=output_file, - quiet=args.quiet, - ) - ) - - runner.run(reporters) - - for reporter in reporters: - reporter.write() - - # Fail and error let the command fail - return ( - 1 - if tr.worst_check_status is not None - and tr.worst_check_status.weight >= args.error_code_on.weight - else 0 - ) - - -def list_checks(profile, theme, verbose=False): - if verbose: - for section in profile.sections: - print(theme["list-checks: section"]("\nSection:") + " " + section.name) - for check in section.checks: - print( - theme["list-checks: check-id"](check.id) - + "\n" - + theme["list-checks: description"](f'"{check.description}"') - + "\n" - ) - else: - for section in profile.sections: - for check in section.checks: - print(check.id) - sys.exit() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/Lib/fontbakery/profiles/universal.py b/Lib/fontbakery/profiles/universal.py index a514f520dc..2beed29b52 100644 --- a/Lib/fontbakery/profiles/universal.py +++ b/Lib/fontbakery/profiles/universal.py @@ -16,7 +16,7 @@ "com.google.fonts/check/whitespace_glyphs", "com.google.fonts/check/whitespace_glyphnames", "com.google.fonts/check/whitespace_ink", - "com.google.fonts/check/legacy_accents", + # "com.google.fonts/check/legacy_accents", # Disabled: issue #3595 / PR #4567 "com.google.fonts/check/required_tables", "com.google.fonts/check/unwanted_tables", "com.google.fonts/check/valid_glyphnames", diff --git a/Lib/fontbakery/reporters/ghmarkdown.py b/Lib/fontbakery/reporters/ghmarkdown.py index caa01d2564..72393998b0 100644 --- a/Lib/fontbakery/reporters/ghmarkdown.py +++ b/Lib/fontbakery/reporters/ghmarkdown.py @@ -1,112 +1,25 @@ import os -from fontbakery.reporters.serialize import SerializeReporter -from fontbakery.utils import html5_collapsible +from fontbakery.reporters.html import HTMLReporter from fontbakery import __version__ as version LOGLEVELS = ["ERROR", "FATAL", "FAIL", "WARN", "SKIP", "INFO", "PASS", "DEBUG"] -class GHMarkdownReporter(SerializeReporter): - def write(self): - with open(self.output_file, "w", encoding="utf8") as fh: - fh.write(self.get_markdown()) - if not self.quiet: - print( - f"A report in GitHub Markdown format which can be useful\n" - f" for posting issues on a GitHub issue tracker has been\n" - f' saved to "{self.output_file}"' - ) - - @staticmethod - def emoticon(name): - return { - "ERROR": "\U0001F494", # ๐Ÿ’” :broken_heart: - "FATAL": "\U00002620", # โ˜ ๏ธ :skull_and_crossbones: - "FAIL": "\U0001F525", # ๐Ÿ”ฅ :fire: - "WARN": "\U000026A0", # โš ๏ธ :warning: - "INFO": "\U00002139", # โ„น๏ธ :information_source: - "SKIP": "\U0001F4A4", # ๐Ÿ’ค :zzz: - "PASS": "\U0001F35E", # ๐Ÿž :bread - "DEBUG": "\U0001F50E", # ๐Ÿ”Ž :mag_right: - }[name] - - def log_md(self, log): - if not self.omit_loglevel(log["status"]): - msg = log["message"] - message = msg["message"] - if "code" in msg and msg["code"]: - message += f" [code: {msg['code']}]" - return "* {} **{}** {}\n".format( - self.emoticon(log["status"]), log["status"], message - ) - else: - return "" - - def render_rationale(self, check, checkid): - if self.succinct or not check.get("rationale"): - return "" - - from fontbakery.utils import unindent_and_unwrap_rationale - - content = unindent_and_unwrap_rationale(check["rationale"], checkid) - return "\n".join([">" + line for line in content.split("\n")]) - - def check_md(self, check): - checkid = check["key"][1].split(":")[1].split(">")[0] - profile = check["profile"] - - check["logs"].sort(key=lambda c: c["status"]) - logs = "".join(map(self.log_md, check["logs"])) - github_search_url = ( - '' - f"{checkid}" - ) - - rationale = self.render_rationale(check, checkid) - - return html5_collapsible( - "{} {}: {} ({})".format( - self.emoticon(check["result"]), - check["result"], - check["description"], - github_search_url, - ), - f"\n\n{rationale}\n{logs}", - ) - - @staticmethod - def deduce_profile_from_section_name(section): - # This is very hacky! - # We should have a much better way of doing it... - if "Google Fonts" in section: - return "googlefonts" - if "Adobe" in section: - return "adobefonts" - if "Font Bureau" in section: - return "fontbureau" - if "Universal" in section: - return "universal" - if "Basic UFO checks" in section: - return "ufo_sources" - if "Checks inherited from Microsoft Font Validator" in section: - return "fontval" - if "fontbakery.profiles." in section: - return section.split("fontbakery.profiles.")[1].split(">")[0] - return section +class GHMarkdownReporter(HTMLReporter): + format_name = "GitHub Markdown" + format = "markdown" @staticmethod def result_is_all_same(cluster): first_check = cluster[0] return all(check["logs"] == first_check["logs"] for check in cluster[1:]) - def get_markdown(self): + def template(self, data): fatal_checks = {} experimental_checks = {} other_checks = {} - data = self.getdoc() num_checks = 0 for section in data["sections"]: for cluster in section["checks"]: @@ -122,10 +35,6 @@ def get_markdown(self): for check in cluster: if self.omit_loglevel(check["result"]): continue - check["profile"] = self.deduce_profile_from_section_name( - section["key"][0] - ) - if "filename" in check.keys(): key = os.path.basename(check["filename"]) else: @@ -147,71 +56,13 @@ def get_markdown(self): other_checks[key] = [] other_checks[key].append(check) - md = f"## FontBakery report\n\nfontbakery version: {version}\n\n" - - if fatal_checks: - md += ( - "

Checks with FATAL results

" - "

These must be addressed first.

" - ) - for filename in fatal_checks.keys(): - md += html5_collapsible( - "[{}] {}".format(len(fatal_checks[filename]), filename), - "".join(map(self.check_md, fatal_checks[filename])) + "
", - ) - - if experimental_checks: - md += ( - "

Experimental checks

" - "

These won't break the CI job for now, but will become effective" - " after some time if nobody raises any concern.

" - ) - for filename in experimental_checks.keys(): - experimental_checks[filename].sort( - key=lambda c: LOGLEVELS.index(c["result"]) - ) - md += html5_collapsible( - "[{}] {}".format( - len(experimental_checks[filename]), filename - ), - "".join(map(self.check_md, experimental_checks[filename])) + "
", - ) - - if other_checks: - if experimental_checks or fatal_checks: - md += "

All other checks

" - else: - md += "

Check results

" - - for filename in other_checks.keys(): - other_checks[filename].sort(key=lambda c: LOGLEVELS.index(c["result"])) - md += html5_collapsible( - "[{}] {}".format(len(other_checks[filename]), filename), - "".join(map(self.check_md, other_checks[filename])) + "
", - ) - - if num_checks != 0: - summary_table = ( - "\n### Summary\n\n" - + ("| {} " + " | {} ".join(LOGLEVELS) + " |\n").format( - *[self.emoticon(k) for k in LOGLEVELS] - ) - + ( - "|" + "|".join([":-----:"] * len(LOGLEVELS)) + "|\n" - "|" + "|".join([" {} "] * len(LOGLEVELS)) + "|\n" - ).format(*[data["result"][k] for k in LOGLEVELS]) - + ("|" + "|".join([" {:.0f}% "] * len(LOGLEVELS)) + "|\n").format( - *[100 * data["result"][k] / num_checks for k in LOGLEVELS] - ) - ) - md += "\n" + summary_table - - omitted = [loglvl for loglvl in LOGLEVELS if self.omit_loglevel(loglvl)] - if omitted: - md += ( - "\n" - + "**Note:** The following loglevels were omitted in this report:\n" - + "".join(map("* **{}**\n".format, omitted)) - ) - - return md + return self.template_engine().render( + fb_version=version, + summary={k: data["result"][k] for k in LOGLEVELS}, + omitted=[loglvl for loglvl in LOGLEVELS if self.omit_loglevel(loglvl)], + fatal_checks=fatal_checks, + experimental_checks=experimental_checks, + other_checks=other_checks, + succinct=self.succinct, + total=num_checks, + ) diff --git a/Lib/fontbakery/reporters/html.py b/Lib/fontbakery/reporters/html.py index fc7359ef82..2cc5aa85f6 100644 --- a/Lib/fontbakery/reporters/html.py +++ b/Lib/fontbakery/reporters/html.py @@ -4,7 +4,7 @@ import os import cmarkgfm from cmarkgfm.cmark import Options as cmarkgfmOptions -from jinja2 import ChoiceLoader, Environment, PackageLoader, select_autoescape +from jinja2 import ChoiceLoader, Environment, PackageLoader, Template, select_autoescape from markupsafe import Markup from fontbakery.reporters.serialize import SerializeReporter @@ -45,22 +45,18 @@ def markdown(message): class HTMLReporter(SerializeReporter): """Renders a report as a HTML document.""" - def write(self): - with open(self.output_file, "w", encoding="utf-8") as fh: - fh.write(self.get_html()) - if not self.quiet: - print(f'A report in HTML format has been saved to "{self.output_file}"') + format_name = "HTML" + format = "html" - def get_html(self) -> str: - """Returns complete report as a HTML string.""" - data = self.getdoc() - total = 0 - loaders = [PackageLoader("fontbakery.reporters", "templates/html")] + def template_engine(self) -> Template: + loaders = [PackageLoader("fontbakery.reporters", f"templates/{self.format}")] try: profile = self.runner.profile.name loaders.insert( 0, - PackageLoader("fontbakery.reporters", "templates/" + profile + "/html"), + PackageLoader( + "fontbakery.reporters", f"templates/{profile}/{self.format}" + ), ) except ValueError: pass # No special templates for this profile @@ -68,20 +64,6 @@ def get_html(self) -> str: loader=ChoiceLoader(loaders), autoescape=select_autoescape() ) - # Rearrange the data so that checks in each section are clustered - # by id - for section in data["sections"]: - checks = section["checks"] - total += len(checks) - checks_by_id = defaultdict(list) - for check in checks: - checks_by_id[check["key"][1]].append(check) - section["clustered_checks"] = list(checks_by_id.values()) - section["status_summary"] = sorted( - (e for e in section["result"].elements() if e != "PASS"), - key=LOGLEVELS.index, - ) - def omitted(result): # This is horribly polymorphic, sorry if isinstance(result, list): # I am cluster of checks @@ -107,8 +89,27 @@ def omitted(result): environment.filters["basename"] = os.path.basename environment.filters["unwrap"] = unindent_and_unwrap_rationale - template = environment.get_template("main.html") - return template.render( + return environment.get_template("main." + self.format.lower()) + + def template(self, data) -> str: + """Returns complete report as a HTML string.""" + total = 0 + + # Rearrange the data so that checks in each section are clustered + # by id + for section in data["sections"]: + checks = section["checks"] + total += len(checks) + checks_by_id = defaultdict(list) + for check in checks: + checks_by_id[check["key"][1]].append(check) + section["clustered_checks"] = list(checks_by_id.values()) + section["status_summary"] = sorted( + (e for e in section["result"].elements() if e != "PASS"), + key=LOGLEVELS.index, + ) + + return self.template_engine().render( sections=data["sections"], ISSUE_URL=ISSUE_URL, fb_version=fb_version, diff --git a/Lib/fontbakery/reporters/serialize.py b/Lib/fontbakery/reporters/serialize.py index eed45ab70e..055eb3c1ef 100644 --- a/Lib/fontbakery/reporters/serialize.py +++ b/Lib/fontbakery/reporters/serialize.py @@ -15,13 +15,7 @@ class SerializeReporter(FontbakeryReporter): - """ - usage: - >> sr = SerializeReporter(runner=runner, collect_results_by='font') - >> sr.run() - >> import json - >> print(json.dumps(sr.getdoc(), sort_keys=True, indent=4)) - """ + format = "unknown" def __post_init__(self): super().__post_init__() @@ -59,9 +53,23 @@ def getdoc(self): } def write(self): - import json - with open(self.output_file, "w", encoding="utf-8") as fh: - json.dump(self.getdoc(), fh, sort_keys=True, indent=4) + fh.write(self.template(self.getdoc())) if not self.quiet: - print(f'A report in JSON format has been saved to "{self.output_file}"') + print( + f'A report in {self.format} format has been saved to "{self.output_file}"' + ) + + def template(self, _data): + raise NotImplementedError( + "Subclasses of SerializeReporter must implement the template method" + ) + + +class JSONReporter(SerializeReporter): + format = "JSON" + + def template(self, doc): + import json + + return json.dumps(doc, sort_keys=True, indent=4) diff --git a/Lib/fontbakery/reporters/templates/html/check.html b/Lib/fontbakery/reporters/templates/html/check.html index 47fcb1dcef..8f4e8d1ca7 100644 --- a/Lib/fontbakery/reporters/templates/html/check.html +++ b/Lib/fontbakery/reporters/templates/html/check.html @@ -9,7 +9,17 @@

{% if cluster[0].rationale and not succinct %} {{ cluster[0].rationale | unwrap | markdown }} {% endif %} - +{% if cluster[0].proposal and not succinct %} + +{% endif %}
{% for check in cluster %} {% if check['result'] == "FATAL" %} diff --git a/Lib/fontbakery/reporters/templates/markdown/check.markdown b/Lib/fontbakery/reporters/templates/markdown/check.markdown new file mode 100644 index 0000000000..44d44237fd --- /dev/null +++ b/Lib/fontbakery/reporters/templates/markdown/check.markdown @@ -0,0 +1,23 @@ +
+ {{check.result | emoticon}} **{{check.result}}** {{check.description}} {{check.id}} +
+ +{% if not succinct and check.rationale %} +{% for line in check.rationale.split("\n") %}> {{line | unwrap | replace("\n", "") }} +{% endfor %} +{% endif %} + +{% if check.proposal and not succinct %} +{% for proposal in check.proposal %}{% if loop.index == 1 %}> Original proposal: {{proposal}} +{% else %}> See also: {{proposal}} +{%endif%}{% endfor %} +{% endif %} + +{% for result in check.logs |sort(attribute="status") %} +{% if not result is omitted %} +* {{result.status | emoticon }} **{{result.status}}** {{result.message.message}} {%if result.message.code%}[code: {{result.message.code}}]{%endif%} +{% endif %} +{% endfor %} + +
+
\ No newline at end of file diff --git a/Lib/fontbakery/reporters/templates/markdown/checks.markdown b/Lib/fontbakery/reporters/templates/markdown/checks.markdown new file mode 100644 index 0000000000..6c800c66da --- /dev/null +++ b/Lib/fontbakery/reporters/templates/markdown/checks.markdown @@ -0,0 +1,8 @@ +
+ [{{checks |length}}] {{filename}} +
+ {% for check in checks %} + {% include "check.markdown" %} + {% endfor %} +
+
\ No newline at end of file diff --git a/Lib/fontbakery/reporters/templates/markdown/main.markdown b/Lib/fontbakery/reporters/templates/markdown/main.markdown new file mode 100644 index 0000000000..c05ea93d33 --- /dev/null +++ b/Lib/fontbakery/reporters/templates/markdown/main.markdown @@ -0,0 +1,49 @@ +## FontBakery report + +fontbakery version: {{fb_version}} + +{% if fatal_checks %} +## Checks with FATAL results + +These must be addressed first. + +{% for filename, checks in fatal_checks.items() %} +{% include "checks.markdown" %} +{% endfor %} +{% endif %} +{% if experimental_checks %} +## Experimental checks + +These won't break the CI job for now, but will become effective after some time if nobody raises any concern. + +{% for filename, checks in experimental_checks.items() %} +{% include "checks.markdown" %} +{% endfor %} +{% endif %} +{% if other_checks %} +{% if experimental_checks or fatal_checks %} +## All other checks +{% else %} +## Check results +{% endif %} + +{% for filename, checks in other_checks.items() %} +{% include "checks.markdown" %} +{% endfor %} +{% endif %} + +{% if total > 0 %} +### Summary + +| {%for level in summary.keys() %}{{level | emoticon }} {{level}} | {%endfor%} | +|---|---| +| {%for level in summary.keys() %}{{summary[level]}} | {%endfor%} | +| {%for level in summary.keys() %}{{summary[level] | percent_of(total )}} | {%endfor%} | +{% endif %} + +{% if omitted %} +**Note:** The following loglevels were omitted in this report: + +{% for level in omitted %} +* {{level}}{% endfor %} +{% endif %} diff --git a/Lib/fontbakery/result.py b/Lib/fontbakery/result.py index c22b07c110..f375b3a463 100644 --- a/Lib/fontbakery/result.py +++ b/Lib/fontbakery/result.py @@ -74,14 +74,21 @@ def summary_status(self): def getData(self, runner): """Return the result as a dictionary with data suitable for serialization.""" check = self.identity.check + module = check.__module__.replace("fontbakery.checks.", "") + if not isinstance(check.proposal, list): + proposal = [check.proposal] + else: + proposal = check.proposal json = { "key": self.identity.key, "description": check.description, "documentation": check.documentation, "rationale": check.rationale, "experimental": check.experimental, + "proposal": proposal, "severity": check.severity, "result": self.summary_status.name, + "module": module, "logs": [], } # This is a big hack diff --git a/Lib/fontbakery/utils.py b/Lib/fontbakery/utils.py index 759fea2096..d8f2078d2e 100644 --- a/Lib/fontbakery/utils.py +++ b/Lib/fontbakery/utils.py @@ -124,11 +124,6 @@ def unindent_and_unwrap_rationale(rationale, checkid=None): return f"\n{content.strip()}\n" -def html5_collapsible(summary, details) -> str: - """Return nestable, collapsible tag for check grouping and sub-results.""" - return f"
{summary}
{details}
" - - def split_camel_case(camelcase): chars = [] for i, char in enumerate(camelcase): diff --git a/pyproject.toml b/pyproject.toml index 3ca64b3f2d..df091e0230 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,176 @@ +[build-system] +requires = ["setuptools >= 61.0", "setuptools_scm[toml]>=6.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "fontbakery" +dynamic = ["version"] +description = "A font quality assurance tool for everyone" +readme = { file = "README.md", content-type = "text/markdown" } +authors = [ + { name = "Chris Simpkins", email = "csimpkins@google.com" }, + { name = "Dave Crossland", email = "dcrossland@google.com" }, + { name = "Felipe Sanches", email = "juca@members.fsf.org" }, + { name = "Jens Kutilek" }, + { name = "Lasse Fister" }, + { name = "Marc Foley" }, + { name = "Miguel Sousa" }, + { name = "Nikolaus Waxweiler" }, + { name = "Rosalie Wagner" }, + { name = "Simon Cozens" }, + { name = "Vitaly Volkov" }, + { name = "Viviana Monsalve" }, + { name = "Yanone" }, +] +maintainers = [ + { name = "Felipe Sanches", email = "juca@members.fsf.org" } +] + +dependencies = [ + "fontTools >= 4.39.0, == 4.*", + "freetype-py < 2.4.0", + "opentypespec", + "opentype-sanitizer >= 9.1.0, == 9.*", + "munkres", + "PyYAML", + "toml", + "cmarkgfm >= 0.4", + "rich", + "Jinja2", + "packaging", + "pip-api", + "requests", + "beziers >=0.5.0, == 0.5.*", + "uharfbuzz", + "vharfbuzz >=0.2.0, == 0.2.*", +] + +[project.optional-dependencies] + +shaping = [ + "collidoscope>=0.5.2, == 0.5.*", + "stringbrewer", + "ufo2ft >=2.25.2, == 2.*", + "ufolint", +] +googlefonts = [ + "axisregistry >=0.4.5, == 0.4.*", + "beautifulsoup4 >=4.7.1, == 4.*", + "collidoscope>=0.5.2, == 0.5.*", + "dehinter>=3.1.0, == 3.*", + "font-v>=0.6.0", + "fontTools[lxml,unicode] >= 4.39.0, == 4.*", + "gflanguages >=0.5.17, == 0.5.*", + "gfsubsets >=2024.1.22.post2", + "glyphsets >=0.6.11, == 0.6.*", + "protobuf>=3.7.0, == 3.*", + "shaperglot>=0.3.1", + "stringbrewer", + "ufo2ft >=2.25.2, == 2.*", + "ufolint", +] +fontwerk = [ + "axisregistry >=0.4.5, == 0.4.*", + "beautifulsoup4 >=4.7.1, == 4.*", + "collidoscope>=0.5.2, == 0.5.*", + "dehinter>=3.1.0, == 3.*", + "font-v>=0.6.0", + "fontTools[lxml,unicode] >= 4.39.0, == 4.*", + "gflanguages >=0.5.17, == 0.5.*", + "gfsubsets >=2024.1.22.post2", + "glyphsets >=0.6.11, == 0.6.*", + "protobuf>=3.7.0, == 3.*", + "shaperglot>=0.3.1", + "stringbrewer", + "ufo2ft >=2.25.2, == 2.*", + "ufolint", +] +notofonts = [ + "axisregistry >=0.4.5, == 0.4.*", + "beautifulsoup4 >=4.7.1, == 4.*", + "collidoscope>=0.5.2, == 0.5.*", + "dehinter>=3.1.0, == 3.*", + "font-v>=0.6.0", + "fontTools[lxml,unicode] >= 4.39.0, == 4.*", + "gflanguages >=0.5.17, == 0.5.*", + "gfsubsets >=2024.1.22.post2", + "glyphsets >=0.6.11, == 0.6.*", + "protobuf>=3.7.0, == 3.*", + "shaperglot>=0.3.1", + "stringbrewer", + "ufo2ft >=2.25.2, == 2.*", + "ufolint", +] +ufo_sources = [ + "defcon", + "fontTools[ufo] >= 4.39.0, == 4.*", + "ufo2ft >=2.25.2, == 2.*", + "ufolint", +] +typenetwork = [ + "beautifulsoup4 >=4.7.1, == 4.*", + "ufo2ft >=2.25.2, == 2.*", + "shaperglot>=0.4.2", +] +fontval = ["lxml"] +docs = [ + "myst-parser >= 2.0.0, == 2.*", + "sphinx >= 7.1.2, == 7.1.*", + "sphinx_rtd_theme >= 2.0.0, == 2.*", + "m2r >= 0.3.1, == 0.3.*", +] +tests = [ + # Requirements for *testing* + "black == 23.12.1", + "pylint == 3.0.3", + "pytest-cov == 4.1.0", + "pytest-xdist == 3.5.0", + + # Requirements for tests + "axisregistry >=0.4.5, == 0.4.*", + "beautifulsoup4 >=4.7.1, == 4.*", + "collidoscope>=0.5.2, == 0.5.*", + "defcon", + "dehinter>=3.1.0, == 3.*", + "font-v>=0.6.0", + "fontTools[lxml,unicode,ufo] >= 4.39.0, == 4.*", + "gflanguages >=0.5.17, == 0.5.*", + "gfsubsets >=2024.1.22.post2", + "glyphsets >=0.6.11, == 0.6.*", + "protobuf>=3.7.0, == 3.*", + "shaperglot>=0.3.1", + "stringbrewer", + "ufo2ft >=2.25.2, == 2.*", + "ufolint", +] +all = [ + "axisregistry >=0.4.5, == 0.4.*", + "beautifulsoup4 >=4.7.1, == 4.*", + "collidoscope>=0.5.2, == 0.5.*", + "defcon", + "dehinter>=3.1.0, == 3.*", + "fontTools[lxml,ufo,unicode] >= 4.39.0, == 4.*", + "font-v>=0.6.0", + "gflanguages >=0.5.17, == 0.5.*", + "gfsubsets >=2024.1.22.post2", + "glyphsets >=0.6.11, == 0.6.*", + "lxml", + "protobuf>=3.7.0, == 3.*", + "shaperglot>=0.4.2", + "stringbrewer", + "ufo2ft >=2.25.2, == 2.*", + "ufolint" +] + +[project.scripts] +fontbakery = "fontbakery.cli:main" + +[tool.setuptools.packages.find] +where =["Lib"] + +[tool.setuptools_scm] +write_to = "Lib/fontbakery/_version.py" + # ============================================================================ [tool.black] line-length = 88 @@ -17,14 +190,14 @@ minversion = "6.0" testpaths = ["tests"] addopts = "--color=yes --verbose" filterwarnings = [ - "ignore:pkg_resources is deprecated as an API:DeprecationWarning", - "ignore:Deprecated call to `pkg_resources.declare_namespace:DeprecationWarning", + "ignore:pkg_resources is deprecated as an API:DeprecationWarning", + "ignore:Deprecated call to `pkg_resources.declare_namespace:DeprecationWarning", ] # ============================================================================ [tool.pylint.master] ignore-patterns = ".*_pb2.py" -jobs = 0 # Specifying 0 will auto-detect the number of processors available to use +jobs = 0 # Specifying 0 will auto-detect the number of processors available to use logging-format-style = "new" msg-template = "{msg_id} ({symbol}) {path} @ {line} โ€” {msg}" output-format = "colorized" @@ -94,9 +267,5 @@ disable = [ ] [tool.pytype] -inputs = [ "Lib" ] -exclude = [ - "Lib/fontbakery/*_pb2.py", - "Lib/fontbakery/sphinx_extensions", -] - +inputs = ["Lib"] +exclude = ["Lib/fontbakery/*_pb2.py", "Lib/fontbakery/sphinx_extensions"] diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000000..8a9cb96242 --- /dev/null +++ b/renovate.json @@ -0,0 +1,3 @@ +{ + "rangeStrategy": "bump" +} diff --git a/requirements-docs.txt b/requirements-docs.txt deleted file mode 100644 index 6750848217..0000000000 --- a/requirements-docs.txt +++ /dev/null @@ -1,5 +0,0 @@ -sphinx == 7.1.2 -sphinx_rtd_theme == 2.0.0 -myst-parser == 2.0.0 -m2r == 0.3.1 --e .[all] diff --git a/requirements-tests.txt b/requirements-tests.txt deleted file mode 100644 index 35d77e9e56..0000000000 --- a/requirements-tests.txt +++ /dev/null @@ -1,5 +0,0 @@ ---index-url https://pypi.python.org/simple/ -black==23.12.1 -pylint==3.0.3 -pytest-cov==4.1.0 -pytest-xdist==3.5.0 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index bae18a802b..0000000000 --- a/requirements.txt +++ /dev/null @@ -1,30 +0,0 @@ ---index-url https://pypi.python.org/simple/ -axisregistry==0.4.8 -beautifulsoup4==4.12.3 -beziers==0.5.0 -cmarkgfm==2024.1.14 -collidoscope==0.6.5 -defcon==0.10.3 -dehinter==4.0.0 -fontTools[ufo,lxml,unicode]==4.49.0 -freetype-py==2.3.0 # pinned for now. See: https://github.com/fonttools/fontbakery/issues/4143 -gflanguages==0.5.17 -gfsubsets==2024.2.5 -glyphsets==0.6.14 -Jinja2==3.1.3 -lxml==5.1.0 -munkres==1.1.4 # This should be a dependency of fonttools instead! -opentype-sanitizer==9.1.0 -opentypespec==1.9.1 -packaging==23.2 -pip-api==0.0.33 -PyYAML==6.0.1 -protobuf==3.20.3 # pinned to 3.x series which was used to generate our textproto files -requests==2.31.0 -rich==13.7.0 -shaperglot==0.4.2 -stringbrewer==0.0.1 -toml==0.10.2 -ufolint==1.2.0 -ufo2ft==3.0.1 -vharfbuzz==0.3.0 diff --git a/setup.py b/setup.py deleted file mode 100644 index 19c6d97b06..0000000000 --- a/setup.py +++ /dev/null @@ -1,238 +0,0 @@ -# Copyright 2017 The Font Bakery Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# See AUTHORS.txt for the list of Authors and LICENSE.txt for the License. - -from setuptools import setup - -try: - readme = open("README.md", encoding="utf8").read() -except IOError: - readme = "" - - -FONTTOOLS_VERSION = ">=4.39.0" # Python 3.8+ required -UFO2FT_VERSION = ">=2.25.2" # 2.25.2 updated the script lists for Unicode 14.0 -BEAUTIFULSOUP4_VERSION = ">=4.7.1" # For parsing registered vendor IDs -# com.google.fonts/check/vendor_id produces an ERROR if Beautiful Soup 4 -# version 4.0.1 to 4.6.1 or 4.7.0 is installed because of bugs in Beautiful Soup -# (see https://github.com/fonttools/fontbakery/issues/4270) - - -# Profile-specific dependencies: -shaping_extras = [ - "collidoscope>=0.5.2", # 0.5.2 added Python 3.11 wheels - # (see https://github.com/fonttools/fontbakery/issues/3970) - "stringbrewer", - f"ufo2ft{UFO2FT_VERSION}", - "shaperglot>=0.4.2", # versions prior to v0.3.0 had too stric dependency rules - # for other deps such as protobuf, making it harder satisfy all dependencies. -] - -ufo_sources_extras = [ - "defcon", - f"fontTools[ufo]{FONTTOOLS_VERSION}", - f"ufo2ft{UFO2FT_VERSION}", - "ufolint", -] - -adobefonts_extras = [] - - -# These Google Fonts profile dependencies contain data that is critical to -# always be up-to-date, so we treat any update to these deps the same way we would -# deal with API-breaking updates. Only the latest released version is acceptable: -googlefonts_always_latest = [ - "axisregistry==0.4.8", - "gflanguages>=0.5.17", - "gfsubsets>=2024.2.5", - "glyphsets>=0.6.14", - "shaperglot>=0.4.2", -] - - -googlefonts_extras = ( - [ - f"beautifulsoup4{BEAUTIFULSOUP4_VERSION}", - "dehinter>=3.1.0", # 3.1.0 added dehinter.font.hint function - f"fontTools[lxml,unicode]{FONTTOOLS_VERSION}", - # (see https://github.com/googlefonts/gflanguages/pull/7) - "protobuf>=3.7.0, <4", # 3.7.0 fixed a bug on parsing some METADATA.pb files. - # We cannot use v4 because our protobuf files have been compiled with v3. - # (see https://github.com/fonttools/fontbakery/issues/2200) - ] - + googlefonts_always_latest - + shaping_extras - + ufo_sources_extras -) - -fontwerk_extras = googlefonts_extras - -notofonts_extras = googlefonts_extras - -typenetwork_extras = [ - f"beautifulsoup4{BEAUTIFULSOUP4_VERSION}", - f"ufo2ft{UFO2FT_VERSION}", - "shaperglot>=0.4.2", -] - -iso15008_extras = [] - -fontval_extras = [ - "lxml", -] - -docs_extras = [ - "myst-parser", - "sphinx >= 1.4", - "sphinx_rtd_theme", -] - -all_extras = set( - docs_extras - + adobefonts_extras - + fontval_extras - + fontwerk_extras - + googlefonts_extras - + iso15008_extras - + notofonts_extras - + shaping_extras - + ufo_sources_extras -) - -setup( - name="fontbakery", - use_scm_version={"write_to": "Lib/fontbakery/_version.py"}, - url="https://github.com/fonttools/fontbakery/", - description="A font quality assurance tool for everyone", - long_description=readme, - long_description_content_type="text/markdown", - author=( - "FontBakery authors and contributors:" - " Chris Simpkins," - " Dave Crossland," - " Felipe Sanches," - " Jens Kutilek," - " Lasse Fister," - " Marc Foley," - " Miguel Sousa," - " Nikolaus Waxweiler," - " Rosalie Wagner," - " Simon Cozens," - " Vitaly Volkov," - " Viviana Monsalve," - " Yanone" - ), - author_email="juca@members.fsf.org", - package_dir={"": "Lib"}, - packages=[ - "fontbakery", - "fontbakery.checks", - "fontbakery.checks.googlefonts", - "fontbakery.checks.opentype", - "fontbakery.checks.universal", - "fontbakery.reporters", - "fontbakery.profiles", - "fontbakery.commands", - "fontbakery.sphinx_extensions", # for FontBakery's documentation at ReadTheDocs - ], - package_data={ - "fontbakery": [ - "data/*.cache", - "data/*.base64", - "data/googlefonts/*_exceptions.txt", - ], - "fontbakery.reporters": [ - "templates/*/*", - ], - }, - classifiers=[ - "Environment :: Console", - "Intended Audience :: Developers", - "License :: OSI Approved :: Apache Software License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - ], - python_requires=">=3.8", - setup_requires=[ - "setuptools>=61.2", - "setuptools_scm[toml]>=6.2, !=8.0.0", # version 8.0.0 had a bug as described at - # https://github.com/pypa/setuptools_scm/issues/905 - ], - install_requires=[ - # --- - # core dependencies - f"fontTools{FONTTOOLS_VERSION}", - "freetype-py!=2.4.0", # Avoiding 2.4.0 due to seg-fault described at - # https://github.com/fonttools/fontbakery/issues/4143 - "opentypespec", - "opentype-sanitizer>=7.1.9", # 7.1.9 fixes caret value format = 3 bug - # (see https://github.com/khaledhosny/ots/pull/182) - # --- - # fontTools extra that is needed by 'interpolation_issues' check in - # Universal profile - "munkres", - # --- - # for parsing Configuration files (Lib/fontbakery/configuration.py) - "PyYAML", - "toml", - # --- - # used by Reporters (Lib/fontbakery/reporters) - "cmarkgfm>=0.4", # (html.py) Doing anything with Font Bakery with - # a version of cmarkgfm older than 0.4 fails with: - # ImportError: cannot import name 'Options' from 'cmarkgfm.cmark' - "rich", # (terminal.py) - "Jinja2", - # --- - # used by 'fontbakery_version' check in Universal profile - "packaging", - "pip-api", - "requests", # also used by Google Fonts profile - # --- - # used by 'italic_angle' check in OpenType profile ('post' table); - # also used by ISO 15008 profile - "beziers>=0.5.0", # 0.5.0 uses new fontTools glyph outline access - # --- - # uharfbuzz and uharfbuzz are used - # by 'tabular_kerning' check in Universal profile: - "uharfbuzz", - f"vharfbuzz>=0.2.0", # 0.2.0 had an API update - ], - extras_require={ - "all": all_extras, - "docs": docs_extras, - "adobefonts": adobefonts_extras, - "fontval": fontval_extras, - "fontwerk": fontwerk_extras, - "googlefonts": googlefonts_extras, - "notofonts": notofonts_extras, - "typenetwork": typenetwork_extras, - "iso15008": iso15008_extras, - "shaping": shaping_extras, - "ufo-sources": ufo_sources_extras, - }, - entry_points={ - "console_scripts": ["fontbakery=fontbakery.cli:main"], - }, - # TODO: review this and make it cross-platform: - # data_files=[ - # ('/etc/bash_completion.d', ['snippets/fontbakery.bash-completion']), - # ] -) diff --git a/tests/checks/googlefonts_test.py b/tests/checks/googlefonts_test.py index bcdef7104d..59b12cf5c8 100644 --- a/tests/checks/googlefonts_test.py +++ b/tests/checks/googlefonts_test.py @@ -5072,36 +5072,6 @@ def test_check_alt_caron(): assert_PASS(check(ttFont)) -def test_check_legacy_accents(): - """Check that legacy accents aren't used in composite glyphs.""" - check = CheckTester("com.google.fonts/check/legacy_accents") - - test_font = TTFont(TEST_FILE("montserrat/Montserrat-Regular.ttf")) - assert_PASS(check(test_font)) - - test_font = TTFont(TEST_FILE("mada/Mada-Regular.ttf")) - assert_results_contain( - check(test_font), - FAIL, - "legacy-accents-gdef", - "for legacy accents being defined in GDEF as marks.", - ) - - test_font = TTFont(TEST_FILE("lugrasimo/Lugrasimo-Regular.ttf")) - assert_results_contain( - check(test_font), - WARN, - "legacy-accents-component", - "for legacy accents being used in composites.", - ) - assert_results_contain( - check(test_font), - FAIL, - "legacy-accents-width", - "for legacy accents having zero width.", - ) - - def test_check_shape_languages(): """Shapes languages in all GF glyphsets.""" check = CheckTester("com.google.fonts/check/glyphsets/shape_languages") diff --git a/tests/checks/universal_test.py b/tests/checks/universal_test.py index 608ee5c074..2d7d6a14d5 100644 --- a/tests/checks/universal_test.py +++ b/tests/checks/universal_test.py @@ -595,7 +595,7 @@ def test_check_whitespace_ink(): ) -def test_check_legacy_accents(): +def DISABLED_test_check_legacy_accents(): """Check that legacy accents aren't used in composite glyphs.""" check = CheckTester("com.google.fonts/check/legacy_accents") diff --git a/tests/commands/test_usage.py b/tests/commands/test_usage.py index ddeb460e85..08afa51743 100644 --- a/tests/commands/test_usage.py +++ b/tests/commands/test_usage.py @@ -10,26 +10,6 @@ TOOL_NAME = "fontbakery" -def test_list_subcommands_has_all_scripts(): - """Tests if the output from running `fontbakery --list-subcommands` matches - the fontbakery scripts within the bin folder and the promoted profiles.""" - import fontbakery.commands - from fontbakery.cli import CLI_PROFILES - - commands_dir = os.path.dirname(fontbakery.commands.__file__) - - scripts = [ - f.rstrip(".py").replace("_", "-") - for f in os.listdir(commands_dir) - if (f.endswith(".py") and not f.startswith("_")) - ] - scripts = scripts + [("check-" + i).replace("_", "-") for i in CLI_PROFILES] - subcommands = ( - subprocess.check_output([TOOL_NAME, "--list-subcommands"]).decode().split() - ) - assert sorted(scripts) == sorted(subcommands) - - def test_list_checks_option(capfd): """Test if 'fontbakery --list-checks' can run successfully and output the expected content.""" @@ -82,7 +62,8 @@ def test_command_check_profile(subcommand): def test_tool_help(): """Test if just 'fontbakery' command can run successfully.""" assert subprocess.run([TOOL_NAME, "-h"]).returncode == 0 - assert subprocess.run([TOOL_NAME]).returncode == 0 + # A subcommand is now mandatory + assert subprocess.run([TOOL_NAME]).returncode == 2 @pytest.mark.xfail( diff --git a/tests/test_utils.py b/tests/test_utils.py index 6a32c1e379..3b6030f950 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -15,7 +15,6 @@ exit_with_install_instructions, get_apple_terminal_bg_color, get_theme, - html5_collapsible, is_negated, pretty_print_list, split_camel_case, @@ -174,13 +173,6 @@ def test_unindent_and_unwrap_rationale(): assert unindent_and_unwrap_rationale(rationale) == expected_rationale -def test_html5_collapsible(): - assert ( - html5_collapsible("abc", "ABC") - == "
abc
ABC
" - ) - - def test_split_camel_case(): assert split_camel_case("") == "" assert split_camel_case("abc") == "abc"