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 += ( - "
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])) + "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])) + "