diff --git a/djlint/__init__.py b/djlint/__init__.py index 0597b3f7..adb1dd3d 100644 --- a/djlint/__init__.py +++ b/djlint/__init__.py @@ -23,6 +23,7 @@ from djlint.reformat import reformat_file from djlint.settings import Config from djlint.src import get_src +from djlint.github_output import print_github_output if TYPE_CHECKING: from djlint.types import ProcessResult @@ -70,9 +71,7 @@ help="Indent spacing. [default: 4]", show_default=False, ) -@click.option( - "--quiet", is_flag=True, help="Do not print diff when reformatting." -) +@click.option("--quiet", is_flag=True, help="Do not print diff when reformatting.") @click.option( "--profile", type=str, @@ -83,9 +82,7 @@ is_flag=True, help="Only format or lint files that starts with a comment with the text 'djlint:on'", ) -@click.option( - "--lint", is_flag=True, help="Lint for common issues. [default option]" -) +@click.option("--lint", is_flag=True, help="Lint for common issues. [default option]") @click.option( "--use-gitignore", is_flag=True, @@ -133,9 +130,7 @@ help='Codes to include. ex: "H014,H017"', show_default=False, ) -@click.option( - "--ignore-case", is_flag=True, help="Do not fix case on known html tags." -) +@click.option("--ignore-case", is_flag=True, help="Do not fix case on known html tags.") @click.option( "--ignore-blocks", type=str, @@ -215,9 +210,7 @@ @click.option( "--indent-css", type=int, help="Set CSS indent level.", show_default=False ) -@click.option( - "--indent-js", type=int, help="Set JS indent level.", show_default=False -) +@click.option("--indent-js", type=int, help="Set JS indent level.", show_default=False) @click.option( "--close-void-tags", is_flag=True, @@ -244,6 +237,13 @@ help="Consolidate blank lines down to x lines. [default: 0]", show_default=False, ) +@click.option( + "--github-output", + is_flag=True, + default=bool(os.getenv("GITHUB_ACTIONS")), + help="Output GitHub-compatible formatting.", + show_default=True, +) @colorama_text(autoreset=True) def main( *, @@ -287,8 +287,10 @@ def main( no_function_formatting: bool, no_set_formatting: bool, max_blank_lines: int | None, + github_output: bool = False, ) -> None: """djLint · HTML template linter and formatter.""" + config = Config( src[0], extension=extension, @@ -383,7 +385,7 @@ def main( Fore.GREEN + Style.BRIGHT, Style.RESET_ALL + " ", ) - if not config.stdin and not config.quiet: + if not config.stdin and not config.quiet and not github_output: echo() progress_char = " »" if sys.platform == "win32" else "┈━" @@ -409,13 +411,12 @@ def main( colour="BLUE", ascii=progress_char, leave=False, + disable=github_output, ) as pbar: for future in as_completed(futures): file_errors.append(future.result()) pbar.update() - elapsed = pbar.format_interval( - pbar.format_dict["elapsed"] - ) + elapsed = pbar.format_interval(pbar.format_dict["elapsed"]) finished_bar_message = f"{Fore.BLUE + Style.BRIGHT}{message}{Style.RESET_ALL} {Fore.GREEN + Style.BRIGHT}{{n_fmt}}/{{total_fmt}}{Style.RESET_ALL} {Fore.BLUE + Style.BRIGHT}files{Style.RESET_ALL} {{bar}} {Fore.GREEN + Style.BRIGHT}{elapsed}{Style.RESET_ALL} " @@ -426,12 +427,11 @@ def main( colour="GREEN", ascii=progress_char, leave=True, + disable=github_output, ): pass else: - file_errors = [ - future.result() for future in as_completed(futures) - ] + file_errors = [future.result() for future in as_completed(futures)] if temp_file and (config.reformat or config.check): # if using stdin, only give back formatted code. @@ -448,6 +448,13 @@ def main( finally: Path(temp_file.name).unlink(missing_ok=True) + if ( + github_output + and print_github_output(config, file_errors, len(file_list)) + and not config.warn + ): + sys.exit(1) + if print_output(config, file_errors, len(file_list)) and not config.warn: sys.exit(1) diff --git a/djlint/github_output.py b/djlint/github_output.py new file mode 100644 index 00000000..2a1c5d85 --- /dev/null +++ b/djlint/github_output.py @@ -0,0 +1,71 @@ +"""Build djLint GitHub workflow command output.""" + +from __future__ import annotations +from pathlib import Path +from typing import TYPE_CHECKING +from click import echo + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping, Sequence + from djlint.settings import Config + from djlint.types import LintError, ProcessResult + + +def print_github_output( + config: Config, file_errors: Iterable[ProcessResult], file_count: int +) -> int: + """Print results as GitHub workflow commands.""" + lint_error_count = 0 + format_error_count = 0 + + for error in sorted( + file_errors, + key=lambda x: next(iter(next(iter(x.values())))), + ): + if error.get("format_message") and not config.stdin: + format_error_count += print_format_errors(error["format_message"], config) + if error.get("lint_message"): + lint_error_count += print_lint_errors(error["lint_message"], config) + + return lint_error_count + format_error_count + + +def print_lint_errors(error: Mapping[str, Iterable[LintError]], config: Config) -> int: + """Print lint errors in GitHub format.""" + errors = sorted( + next(iter(error.values())), + key=lambda x: tuple(int(i) for i in x["line"].split(":")), + ) + if not errors: + return 0 + + filename = build_relative_path(next(iter(error)), config.project_root) + + for message_dict in errors: + line = message_dict["line"].split(":")[0] + level = "error" if message_dict["code"].startswith("E") else "warning" + echo( + f"::{level} file={filename},line={line}::{message_dict['code']} {message_dict['message']}" + ) + + return len(errors) + + +def print_format_errors(errors: Mapping[str, Sequence[str]], config: Config) -> int: + """Print format errors in GitHub format.""" + if not errors: + return 0 + + filename = build_relative_path(next(iter(errors)), config.project_root) + if bool(next(iter(errors.values()))): + echo(f"::error file={filename}::Formatting changes required") + + return sum(1 for v in errors.values() if v) + + +def build_relative_path(url: str, project_root: Path) -> str: + """Get path relative to project.""" + url_path = Path(url) + if project_root != url_path and project_root in url_path.parents: + return str(url_path.relative_to(project_root)) + return url diff --git a/djlint/settings.py b/djlint/settings.py index 9a1391f7..8c0f5385 100644 --- a/djlint/settings.py +++ b/djlint/settings.py @@ -145,17 +145,13 @@ def load_project_settings(src: Path, config: Path | None) -> dict[str, Any]: else: djlint_content.update(load_djlintrc_config(config)) except Exception as error: - logger.error( - "%sFailed to load config file %s. %s", Fore.RED, config, error - ) + logger.error("%sFailed to load config file %s. %s", Fore.RED, config, error) if pyproject_file := find_pyproject(src): try: content = load_pyproject_config(pyproject_file) except Exception as error: - logger.error( - "%sFailed to load pyproject.toml file. %s", Fore.RED, error - ) + logger.error("%sFailed to load pyproject.toml file. %s", Fore.RED, error) else: if content: djlint_content.update(content) @@ -165,9 +161,7 @@ def load_project_settings(src: Path, config: Path | None) -> dict[str, Any]: try: djlint_content.update(load_djlint_toml_config(djlint_toml_file)) except Exception as error: - logger.error( - "%sFailed to load djlint.toml file. %s", Fore.RED, error - ) + logger.error("%sFailed to load djlint.toml file. %s", Fore.RED, error) elif djlintrc_file := find_djlintrc(src): try: @@ -188,10 +182,7 @@ def validate_rules( if "name" not in rule["rule"]: warning = True echo(Fore.RED + "Warning: A rule is missing a name! 😢") - if ( - "patterns" not in rule["rule"] - and "python_module" not in rule["rule"] - ): + if "patterns" not in rule["rule"] and "python_module" not in rule["rule"]: warning = True echo( Fore.RED @@ -289,6 +280,7 @@ def __init__( no_function_formatting: bool = False, no_set_formatting: bool = False, max_blank_lines: int | None = None, + github_output: bool = False, ) -> None: self.reformat = reformat self.check = check @@ -300,9 +292,7 @@ def __init__( else: self.project_root = find_project_root(Path(src).resolve()) - djlint_settings = load_project_settings( - self.project_root, configuration - ) + djlint_settings = load_project_settings(self.project_root, configuration) self.gitignore = load_gitignore(self.project_root) # custom configuration options @@ -310,26 +300,20 @@ def __init__( self.use_gitignore: bool = use_gitignore or djlint_settings.get( "use_gitignore", False ) - self.extension: str = str( - extension or djlint_settings.get("extension", "html") - ) + self.extension: str = str(extension or djlint_settings.get("extension", "html")) self.quiet: bool = quiet or djlint_settings.get("quiet", False) self.require_pragma: bool = ( require_pragma - or str(djlint_settings.get("require_pragma", "false")).lower() - == "true" + or str(djlint_settings.get("require_pragma", "false")).lower() == "true" ) self.custom_blocks: str = str( - build_custom_blocks( - custom_blocks or djlint_settings.get("custom_blocks") - ) + build_custom_blocks(custom_blocks or djlint_settings.get("custom_blocks")) or "" ) self.custom_html: str = str( - build_custom_html(custom_html or djlint_settings.get("custom_html")) - or "" + build_custom_html(custom_html or djlint_settings.get("custom_html")) or "" ) self.format_attribute_template_tags: bool = ( @@ -345,30 +329,21 @@ def __init__( ignore_blocks or djlint_settings.get("ignore_blocks", "") ) - self.preserve_blank_lines: bool = ( - preserve_blank_lines - or djlint_settings.get("preserve_blank_lines", False) + self.preserve_blank_lines: bool = preserve_blank_lines or djlint_settings.get( + "preserve_blank_lines", False ) - self.format_js: bool = format_js or djlint_settings.get( - "format_js", False - ) + self.format_js: bool = format_js or djlint_settings.get("format_js", False) self.js_config = ( - {"indent_size": indent_js} - if indent_js - else djlint_settings.get("js") + {"indent_size": indent_js} if indent_js else djlint_settings.get("js") ) or {} self.css_config = ( - {"indent_size": indent_css} - if indent_css - else djlint_settings.get("css") + {"indent_size": indent_css} if indent_css else djlint_settings.get("css") ) or {} - self.format_css: bool = format_css or djlint_settings.get( - "format_css", False - ) + self.format_css: bool = format_css or djlint_settings.get("format_css", False) self.ignore_case: bool = ignore_case or djlint_settings.get( "ignore_case", False @@ -377,9 +352,8 @@ def __init__( self.close_void_tags: bool = close_void_tags or djlint_settings.get( "close_void_tags", False ) - self.no_line_after_yaml: bool = ( - no_line_after_yaml - or djlint_settings.get("no_line_after_yaml", False) + self.no_line_after_yaml: bool = no_line_after_yaml or djlint_settings.get( + "no_line_after_yaml", False ) self.no_set_formatting: bool = no_set_formatting or djlint_settings.get( "no_set_formatting", False @@ -414,20 +388,15 @@ def __init__( profile or djlint_settings.get("profile", "all") ).lower() - self.linter_output_format: str = ( - linter_output_format - or djlint_settings.get( - "linter_output_format", "{code} {line} {message} {match}" - ) + self.linter_output_format: str = linter_output_format or djlint_settings.get( + "linter_output_format", "{code} {line} {message} {match}" ) # load linter rules rule_set = validate_rules( chain( yaml.safe_load( - (Path(__file__).parent / "rules.yaml").read_text( - encoding="utf-8" - ) + (Path(__file__).parent / "rules.yaml").read_text(encoding="utf-8") ), load_custom_rules(self.project_root), ) @@ -502,13 +471,9 @@ def __init__( | venv """ - self.exclude: str = exclude or djlint_settings.get( - "exclude", default_exclude - ) + self.exclude: str = exclude or djlint_settings.get("exclude", default_exclude) - extend_exclude = extend_exclude or djlint_settings.get( - "extend_exclude", "" - ) + extend_exclude = extend_exclude or djlint_settings.get("extend_exclude", "") if extend_exclude: self.exclude += r" | " + r" | ".join( @@ -523,14 +488,12 @@ def __init__( # add blank line after load tags self.blank_line_after_tag: str | None = ( - blank_line_after_tag - or djlint_settings.get("blank_line_after_tag", None) + blank_line_after_tag or djlint_settings.get("blank_line_after_tag", None) ) # add blank line before load tags self.blank_line_before_tag: str | None = ( - blank_line_before_tag - or djlint_settings.get("blank_line_before_tag", None) + blank_line_before_tag or djlint_settings.get("blank_line_before_tag", None) ) # add line break after multi-line tags @@ -673,9 +636,7 @@ def __init__( try: self.max_attribute_length = max_attribute_length or int( - djlint_settings.get( - "max_attribute_length", self.max_attribute_length - ) + djlint_settings.get("max_attribute_length", self.max_attribute_length) ) except ValueError: echo( @@ -764,20 +725,20 @@ def __init__( (\s*?/?>) """ - self.attribute_style_pattern: str = ( - r"^(.*?)(style=)([\"|'])(([^\"']+?;)+?)\3" + self.attribute_style_pattern: str = r"^(.*?)(style=)([\"|'])(([^\"']+?;)+?)\3" + + self.ignored_attributes = frozenset( + { + "href", + "action", + "data-url", + "src", + "url", + "srcset", + "data-src", + } ) - self.ignored_attributes = frozenset({ - "href", - "action", - "data-url", - "src", - "url", - "srcset", - "data-src", - }) - self.start_template_tags: str = ( (rf"(?!{self.ignore_blocks})" if self.ignore_blocks else "") + r"""