From d444e2ddc4f6729494760bcd475414aa5ca51945 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Mon, 27 Oct 2025 09:58:20 +1300 Subject: [PATCH] Add a read-only context manager for YAML files to prevent round-trip failures --- .../_integrations/ci/bitbucket/cache.py | 3 ++- src/usethis/_integrations/ci/bitbucket/io_.py | 16 ++++++++++++- .../_integrations/ci/bitbucket/steps.py | 7 ++++-- src/usethis/_integrations/file/yaml/io_.py | 21 ++++++++++++---- src/usethis/_integrations/pre_commit/hooks.py | 7 ++++-- src/usethis/_integrations/pre_commit/io_.py | 14 ++++++++++- .../ci/bitbucket/test_bitbucket_io_.py | 22 +++++++++++++++++ .../pre_commit/test_pre_commit_io_.py | 24 ++++++++++++++++++- 8 files changed, 101 insertions(+), 13 deletions(-) diff --git a/src/usethis/_integrations/ci/bitbucket/cache.py b/src/usethis/_integrations/ci/bitbucket/cache.py index 1edb0325..4a7b68dd 100644 --- a/src/usethis/_integrations/ci/bitbucket/cache.py +++ b/src/usethis/_integrations/ci/bitbucket/cache.py @@ -6,6 +6,7 @@ from usethis._integrations.ci.bitbucket.dump import bitbucket_fancy_dump from usethis._integrations.ci.bitbucket.io_ import ( edit_bitbucket_pipelines_yaml, + read_bitbucket_pipelines_yaml, ) from usethis._integrations.ci.bitbucket.schema import Definitions from usethis._integrations.file.yaml.update import update_ruamel_yaml_map @@ -16,7 +17,7 @@ def get_cache_by_name() -> dict[str, Cache]: - with edit_bitbucket_pipelines_yaml() as doc: + with read_bitbucket_pipelines_yaml() as doc: config = doc.model if config.definitions is None: diff --git a/src/usethis/_integrations/ci/bitbucket/io_.py b/src/usethis/_integrations/ci/bitbucket/io_.py index 3e763200..55b409e3 100644 --- a/src/usethis/_integrations/ci/bitbucket/io_.py +++ b/src/usethis/_integrations/ci/bitbucket/io_.py @@ -9,7 +9,7 @@ from usethis._config import usethis_config from usethis._console import tick_print from usethis._integrations.ci.bitbucket.schema import PipelinesConfiguration -from usethis._integrations.file.yaml.io_ import edit_yaml +from usethis._integrations.file.yaml.io_ import edit_yaml, read_yaml from usethis.errors import FileConfigError if TYPE_CHECKING: @@ -66,6 +66,20 @@ def edit_bitbucket_pipelines_yaml() -> Generator[ _validate_config(doc.content) +@contextmanager +def read_bitbucket_pipelines_yaml() -> Generator[ + BitbucketPipelinesYAMLDocument, None, None +]: + """A context manager to read 'bitbucket-pipelines.yml'.""" + name = "bitbucket-pipelines.yml" + path = usethis_config.cpd() / name + + with read_yaml(path) as doc: + config = _validate_config(doc.content) + yield BitbucketPipelinesYAMLDocument(content=doc.content, model=config) + _validate_config(doc.content) + + def _validate_config(ruamel_content: YAMLLiteral) -> PipelinesConfiguration: try: return PipelinesConfiguration.model_validate(ruamel_content) diff --git a/src/usethis/_integrations/ci/bitbucket/steps.py b/src/usethis/_integrations/ci/bitbucket/steps.py index d2b5f8f0..5ad05ab6 100644 --- a/src/usethis/_integrations/ci/bitbucket/steps.py +++ b/src/usethis/_integrations/ci/bitbucket/steps.py @@ -18,7 +18,10 @@ from usethis._integrations.ci.bitbucket.cache import _add_caches_via_doc, remove_cache from usethis._integrations.ci.bitbucket.dump import bitbucket_fancy_dump from usethis._integrations.ci.bitbucket.errors import UnexpectedImportPipelineError -from usethis._integrations.ci.bitbucket.io_ import edit_bitbucket_pipelines_yaml +from usethis._integrations.ci.bitbucket.io_ import ( + edit_bitbucket_pipelines_yaml, + read_bitbucket_pipelines_yaml, +) from usethis._integrations.ci.bitbucket.pipeweld import ( apply_pipeweld_instruction_via_doc, get_pipeweld_pipeline_from_default, @@ -403,7 +406,7 @@ def get_steps_in_default() -> list[Step]: if not (usethis_config.cpd() / "bitbucket-pipelines.yml").exists(): return [] - with edit_bitbucket_pipelines_yaml() as doc: + with read_bitbucket_pipelines_yaml() as doc: config = doc.model if config.pipelines is None: diff --git a/src/usethis/_integrations/file/yaml/io_.py b/src/usethis/_integrations/file/yaml/io_.py index 055816e3..4b9a9c12 100644 --- a/src/usethis/_integrations/file/yaml/io_.py +++ b/src/usethis/_integrations/file/yaml/io_.py @@ -468,6 +468,22 @@ def edit_yaml( guess_indent: bool = True, ) -> Generator[YAMLDocument, None, None]: """A context manager to modify a YAML file in-place, with managed read and write.""" + with read_yaml(yaml_path, guess_indent=guess_indent) as yaml_document: + yield yaml_document + start_empty = not yaml_document.content + if start_empty and not yaml_document.content: + # No change + return + yaml_document.roundtripper.dump(yaml_document.content, stream=yaml_path) + + +@contextmanager +def read_yaml( + yaml_path: Path, + *, + guess_indent: bool = True, +) -> Generator[YAMLDocument, None, None]: + """A context manager to read a YAML file.""" with yaml_path.open(mode="r") as f: try: yaml_document = _get_yaml_document(f, guess_indent=guess_indent) @@ -475,12 +491,7 @@ def edit_yaml( msg = f"Error reading '{yaml_path}':\n{err}" raise YAMLDecodeError(msg) from None - start_empty = not yaml_document.content yield yaml_document - if start_empty and not yaml_document.content: - # No change - return - yaml_document.roundtripper.dump(yaml_document.content, stream=yaml_path) def _get_yaml_document( diff --git a/src/usethis/_integrations/pre_commit/hooks.py b/src/usethis/_integrations/pre_commit/hooks.py index 101931c3..4140ad28 100644 --- a/src/usethis/_integrations/pre_commit/hooks.py +++ b/src/usethis/_integrations/pre_commit/hooks.py @@ -6,7 +6,10 @@ from usethis._console import box_print, tick_print from usethis._integrations.file.yaml.update import update_ruamel_yaml_map from usethis._integrations.pre_commit.dump import pre_commit_fancy_dump -from usethis._integrations.pre_commit.io_ import edit_pre_commit_config_yaml +from usethis._integrations.pre_commit.io_ import ( + edit_pre_commit_config_yaml, + read_pre_commit_config_yaml, +) from usethis._integrations.pre_commit.schema import ( HookDefinition, Language, @@ -232,7 +235,7 @@ def get_hook_ids() -> list[str]: if not path.exists(): return [] - with edit_pre_commit_config_yaml() as doc: + with read_pre_commit_config_yaml() as doc: return extract_hook_ids(doc.model) diff --git a/src/usethis/_integrations/pre_commit/io_.py b/src/usethis/_integrations/pre_commit/io_.py index f8b11a87..ed43e4b6 100644 --- a/src/usethis/_integrations/pre_commit/io_.py +++ b/src/usethis/_integrations/pre_commit/io_.py @@ -9,7 +9,7 @@ from usethis._config import usethis_config from usethis._console import tick_print -from usethis._integrations.file.yaml.io_ import edit_yaml +from usethis._integrations.file.yaml.io_ import edit_yaml, read_yaml from usethis._integrations.pre_commit.schema import JsonSchemaForPreCommitConfigYaml from usethis.errors import FileConfigError @@ -60,6 +60,18 @@ def edit_pre_commit_config_yaml() -> Generator[PreCommitConfigYAMLDocument, None _validate_config(doc.content) +@contextmanager +def read_pre_commit_config_yaml() -> Generator[PreCommitConfigYAMLDocument, None, None]: + """A context manager to read '.pre-commit-config.yaml'.""" + name = ".pre-commit-config.yaml" + path = usethis_config.cpd() / name + + with read_yaml(path) as doc: + config = _validate_config(doc.content) + yield PreCommitConfigYAMLDocument(content=doc.content, model=config) + _validate_config(doc.content) + + def _validate_config(ruamel_content: YAMLLiteral) -> JsonSchemaForPreCommitConfigYaml: if isinstance(ruamel_content, CommentedMap) and not ruamel_content: ruamel_content = CommentedMap({"repos": []}) diff --git a/tests/usethis/_integrations/ci/bitbucket/test_bitbucket_io_.py b/tests/usethis/_integrations/ci/bitbucket/test_bitbucket_io_.py index b63d739a..c7549814 100644 --- a/tests/usethis/_integrations/ci/bitbucket/test_bitbucket_io_.py +++ b/tests/usethis/_integrations/ci/bitbucket/test_bitbucket_io_.py @@ -5,6 +5,7 @@ from usethis._integrations.ci.bitbucket.io_ import ( BitbucketPipelinesYAMLConfigError, edit_bitbucket_pipelines_yaml, + read_bitbucket_pipelines_yaml, ) from usethis._test import change_cwd @@ -147,3 +148,24 @@ def test_invalid_contents(self, tmp_path: Path): edit_bitbucket_pipelines_yaml() as _, ): pass + + +class TestReadBitbucketPipelinesYAML: + def test_quote_style_preserved(self, tmp_path: Path): + # Arrange + content_str = """\ +pipelines: + default: + - step: + script: + - 'echo' +""" + + (tmp_path / "bitbucket-pipelines.yml").write_text(content_str) + + # Act + with change_cwd(tmp_path), read_bitbucket_pipelines_yaml(): + pass + + # Assert + assert (tmp_path / "bitbucket-pipelines.yml").read_text() == content_str diff --git a/tests/usethis/_integrations/pre_commit/test_pre_commit_io_.py b/tests/usethis/_integrations/pre_commit/test_pre_commit_io_.py index ab1850d6..7612b784 100644 --- a/tests/usethis/_integrations/pre_commit/test_pre_commit_io_.py +++ b/tests/usethis/_integrations/pre_commit/test_pre_commit_io_.py @@ -1,6 +1,9 @@ from pathlib import Path -from usethis._integrations.pre_commit.io_ import edit_pre_commit_config_yaml +from usethis._integrations.pre_commit.io_ import ( + edit_pre_commit_config_yaml, + read_pre_commit_config_yaml, +) from usethis._test import change_cwd @@ -54,3 +57,22 @@ def test_empty_valid_but_unchanged(self, tmp_path: Path): # Assert assert (tmp_path / ".pre-commit-config.yaml").read_text() == "" + + +class TestReadPreCommitConfigYAML: + def test_quote_style_preserved(self, tmp_path: Path): + # Arrange + content_str = """\ +repos: + - repo: 'https://github.com/abravalheri/validate-pyproject' + rev: 'v0.23' +""" + + (tmp_path / ".pre-commit-config.yaml").write_text(content_str) + + # Act + with change_cwd(tmp_path), read_pre_commit_config_yaml(): + pass + + # Assert + assert (tmp_path / ".pre-commit-config.yaml").read_text() == content_str