diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c6590ec..85acfc1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,8 @@ repos: # Class cannot subclass "ConfigSchema" (has type "Any") - --allow-subclassing-any - repo: https://github.com/flakeheaven/flakeheaven - rev: 0.11.1 + rev: 3.2.1 hooks: - name: Run flakeheaven static analysis tool id: flakeheaven + files: ^(src|tests)/.+?\.(ipynb|md|py|rst|yaml|yml)$ diff --git a/docs/index.md b/docs/index.md index ce2f5b6..15b0f93 100644 --- a/docs/index.md +++ b/docs/index.md @@ -200,6 +200,18 @@ export YAMLFIX_CONFIG_PATH="/etc/yamlfix/" Configure the base config-path that `maison` will look for a `pyproject.toml` configuration file. This path is traversed upwards until such a file is found. +### Document Fix ID + +Default: `document_fix_id: str = uuid.uuid4().hex`
+Environment variable override: +```bash +export YAMLFIX_DOCUMENT_FIX_ID="myid" +``` + +Warning: You should not modify this value, if you're not sure you need to. + +This generates a UUID in hex-string-representation (no delimiters), which is used internally to generate a temporary top-level mapping node, where any lists or comments can be attached, so ruyaml is not removing comments, and comment-only documents can be formatted properly. + ### Explicit Document Start Default: `explicit_start: bool = True`
diff --git a/src/yamlfix/adapters.py b/src/yamlfix/adapters.py index 3b292e7..7db4529 100644 --- a/src/yamlfix/adapters.py +++ b/src/yamlfix/adapters.py @@ -224,6 +224,13 @@ def patch_sequence_style(key_node: Node, value_node: Node) -> None: if not sequence_node.value: return + # if this key_node is the `yamlfix_document_fix_id` node, + # the sequence_node is the top-level list, which has to be forced + # into block-mode, so the key_node can be removed afterwards + force_block_style = force_block_style or self._seq_is_in_top_level_node( + key_node + ) + # if this sequence contains non-scalar nodes (i.e. dicts, lists, etc.), # force block-style force_block_style = ( @@ -252,6 +259,9 @@ def patch_sequence_style(key_node: Node, value_node: Node) -> None: self.patch_functions.append(patch_sequence_style) + def _seq_is_in_top_level_node(self, key_node: Node) -> bool: + return str(key_node.value) == f"yamlfix_{self.config.document_fix_id}" + @staticmethod def _seq_contains_non_scalar_nodes(seq_node: Node) -> bool: return any(not isinstance(node, ScalarNode) for node in seq_node.value) @@ -342,9 +352,11 @@ def fix(self, source_code: str) -> str: log.debug("Running source code fixers...") fixers = [ + self._fix_comment_only_files, self._fix_truthy_strings, self._fix_jinja_variables, self._ruamel_yaml_fixer, + self._restore_comment_only_files, self._restore_truthy_strings, self._restore_jinja_variables, self._restore_double_exclamations, @@ -693,3 +705,33 @@ def _restore_jinja_variables(source_code: str) -> str: fixed_source_lines.append(line) return "\n".join(fixed_source_lines) + + def _fix_comment_only_files(self, source_code: str) -> str: + """Add a mapping key with an id to the start of the document\ + to preserve comments.""" + fixed_source_lines = [] + yamlfix_document_id_line = f"yamlfix_{self.config.document_fix_id}:" + + # Add the document id line after each document start + has_start_indicator = False + for line in source_code.splitlines(): + fixed_source_lines.append(line) + if line.startswith("---"): + has_start_indicator = True + fixed_source_lines.append(yamlfix_document_id_line) + + # if the document has no start indicator, the document id as the first line + if not has_start_indicator: + fixed_source_lines.insert(0, yamlfix_document_id_line) + + return "\n".join(fixed_source_lines) + + def _restore_comment_only_files(self, source_code: str) -> str: + """Remove the document start id from the document again.""" + fixed_source_lines = [] + + for line in source_code.splitlines(): + if self.config.document_fix_id not in line: + fixed_source_lines.append(line) + + return "\n".join(fixed_source_lines) diff --git a/src/yamlfix/model.py b/src/yamlfix/model.py index e969cac..364fa3b 100644 --- a/src/yamlfix/model.py +++ b/src/yamlfix/model.py @@ -1,5 +1,6 @@ """Define program entities like configuration value entities.""" +import uuid from typing import Optional from maison.schema import ConfigSchema @@ -12,6 +13,7 @@ class YamlfixConfig(ConfigSchema): comments_min_spaces_from_content: int = 2 comments_require_starting_space: bool = True config_path: Optional[str] = None + document_fix_id: str = uuid.uuid4().hex explicit_start: bool = True flow_style_sequence: Optional[bool] = True indent_mapping: int = 2 diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index c5fa3fd..82eb089 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -184,6 +184,51 @@ def test_fix_code_preserves_comments(self) -> None: assert result == source + def test_fix_code_preserves_comments_without_start_indication(self) -> None: + """Don't delete comments without yaml explicit start indictor.""" + source = dedent( + """\ + # Keep comments! + program: yamlfix + """ + ) + config = YamlfixConfig() + config.explicit_start = False + + result = fix_code(source, config) + + assert result == source + + def test_fix_code_preserves_comment_only_file(self) -> None: + """Don't delete comments even if the file is only comments.""" + source = dedent( + """\ + --- + # Keep comments! + """ + ) + + result = fix_code(source) + + assert result == source + + def test_fix_code_preserves_comment_only_files_without_start_indication( + self, + ) -> None: + """Don't delete comments even if the file is only comments, without\ + start indication.""" + source = dedent( + """\ + # Keep comments! + """ + ) + config = YamlfixConfig() + config.explicit_start = False + + result = fix_code(source, config) + + assert result == source + def test_fix_code_respects_parent_lists_with_comments(self) -> None: """Do not indent lists at the first level even if there is a comment.""" source = dedent(