| 
 | 1 | +#!/usr/bin/env python3  | 
 | 2 | +# License: MIT  | 
 | 3 | +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH  | 
 | 4 | + | 
 | 5 | +"""Script to migrate existing projects to new versions of the cookiecutter template.  | 
 | 6 | +
  | 
 | 7 | +This script migrates existing projects to new versions of the cookiecutter  | 
 | 8 | +template, removing the need to completely regenerate the project from  | 
 | 9 | +scratch.  | 
 | 10 | +
  | 
 | 11 | +To run it, the simplest way is to fetch it from GitHub and run it directly:  | 
 | 12 | +
  | 
 | 13 | +    curl -sSL https://raw.githubusercontent.com/frequenz-floss/frequenz-repo-config-python/<tag>/cookiecutter/migrate.py | python3  | 
 | 14 | +
  | 
 | 15 | +Make sure to replace the `<tag>` to the version you want to migrate to in the URL.  | 
 | 16 | +
  | 
 | 17 | +For jumping multiple versions you should run the script multiple times, once  | 
 | 18 | +for each version.  | 
 | 19 | +
  | 
 | 20 | +And remember to follow any manual instructions for each run.  | 
 | 21 | +"""  # noqa: E501  | 
 | 22 | + | 
 | 23 | +import os  | 
 | 24 | +import subprocess  | 
 | 25 | +import tempfile  | 
 | 26 | +from pathlib import Path  | 
 | 27 | +from typing import SupportsIndex  | 
 | 28 | + | 
 | 29 | + | 
 | 30 | +def apply_patch(patch_content: str) -> None:  | 
 | 31 | +    """Apply a patch using the patch utility."""  | 
 | 32 | +    subprocess.run(["patch", "-p1"], input=patch_content.encode(), check=True)  | 
 | 33 | + | 
 | 34 | + | 
 | 35 | +def replace_file_contents_atomically(  # noqa; DOC501  | 
 | 36 | +    filepath: str | Path,  | 
 | 37 | +    old: str,  | 
 | 38 | +    new: str,  | 
 | 39 | +    count: SupportsIndex = -1,  | 
 | 40 | +    *,  | 
 | 41 | +    content: str | None = None,  | 
 | 42 | +) -> None:  | 
 | 43 | +    """Replace a file atomically with new content.  | 
 | 44 | +
  | 
 | 45 | +    Args:  | 
 | 46 | +        filepath: The path to the file to replace.  | 
 | 47 | +        old: The string to replace.  | 
 | 48 | +        new: The string to replace it with.  | 
 | 49 | +        count: The maximum number of occurrences to replace. If negative, all occurrences are  | 
 | 50 | +            replaced.  | 
 | 51 | +        content: The content to replace. If not provided, the file is read from disk.  | 
 | 52 | +
  | 
 | 53 | +    The replacement is done atomically by writing to a temporary file and  | 
 | 54 | +    then moving it to the target location.  | 
 | 55 | +    """  | 
 | 56 | +    if isinstance(filepath, str):  | 
 | 57 | +        filepath = Path(filepath)  | 
 | 58 | + | 
 | 59 | +    if content is None:  | 
 | 60 | +        content = filepath.read_text(encoding="utf-8")  | 
 | 61 | + | 
 | 62 | +    content = content.replace(old, new, count)  | 
 | 63 | + | 
 | 64 | +    # Create temporary file in the same directory to ensure atomic move  | 
 | 65 | +    tmp_dir = filepath.parent  | 
 | 66 | + | 
 | 67 | +    # pylint: disable-next=consider-using-with  | 
 | 68 | +    tmp = tempfile.NamedTemporaryFile(mode="w", dir=tmp_dir, delete=False)  | 
 | 69 | + | 
 | 70 | +    try:  | 
 | 71 | +        # Copy original file permissions  | 
 | 72 | +        st = os.stat(filepath)  | 
 | 73 | + | 
 | 74 | +        # Write the new content  | 
 | 75 | +        tmp.write(content)  | 
 | 76 | + | 
 | 77 | +        # Ensure all data is written to disk  | 
 | 78 | +        tmp.flush()  | 
 | 79 | +        os.fsync(tmp.fileno())  | 
 | 80 | +        tmp.close()  | 
 | 81 | + | 
 | 82 | +        # Copy original file permissions to the new file  | 
 | 83 | +        os.chmod(tmp.name, st.st_mode)  | 
 | 84 | + | 
 | 85 | +        # Perform atomic replace  | 
 | 86 | +        os.rename(tmp.name, filepath)  | 
 | 87 | + | 
 | 88 | +    except BaseException:  | 
 | 89 | +        # Clean up the temporary file in case of errors  | 
 | 90 | +        tmp.close()  | 
 | 91 | +        os.unlink(tmp.name)  | 
 | 92 | +        raise  | 
 | 93 | + | 
 | 94 | + | 
 | 95 | +def main() -> None:  | 
 | 96 | +    """Run the migration steps."""  | 
 | 97 | +    # Dependabot patch  | 
 | 98 | +    dependabot_yaml = Path(".github/dependabot.yml")  | 
 | 99 | +    print(f"{dependabot_yaml}: Add new grouping for actions/*-artifact updates.")  | 
 | 100 | +    if dependabot_yaml.read_text(encoding="utf-8").find("actions/*-artifact") == -1:  | 
 | 101 | +        apply_patch(  | 
 | 102 | +            """\  | 
 | 103 | +--- a/.github/dependabot.yml  | 
 | 104 | ++++ b/.github/dependabot.yml  | 
 | 105 | +@@ -39,3 +39,11 @@ updates:  | 
 | 106 | +     labels:  | 
 | 107 | +       - "part:tooling"  | 
 | 108 | +       - "type:tech-debt"  | 
 | 109 | ++    groups:  | 
 | 110 | ++      compatible:  | 
 | 111 | ++        update-types:  | 
 | 112 | ++          - "minor"  | 
 | 113 | ++          - "patch"  | 
 | 114 | ++      artifacts:  | 
 | 115 | ++        patterns:  | 
 | 116 | ++          - "actions/*-artifact"  | 
 | 117 | +"""  | 
 | 118 | +        )  | 
 | 119 | +    else:  | 
 | 120 | +        print(f"{dependabot_yaml}: seems to be already up-to-date.")  | 
 | 121 | +    print("=" * 72)  | 
 | 122 | + | 
 | 123 | +    # Fix labeler configuration  | 
 | 124 | +    labeler_yml = ".github/labeler.yml"  | 
 | 125 | +    print(f"{labeler_yml}: Fix the labeler configuration example.")  | 
 | 126 | +    replace_file_contents_atomically(  | 
 | 127 | +        labeler_yml, "all-glob-to-all-file", "all-globs-to-all-files"  | 
 | 128 | +    )  | 
 | 129 | +    print("=" * 72)  | 
 | 130 | + | 
 | 131 | +    # Add a separation line like this one after each migration step.  | 
 | 132 | +    print("=" * 72)  | 
 | 133 | + | 
 | 134 | + | 
 | 135 | +def manual_step(message: str) -> None:  | 
 | 136 | +    """Print a manual step message in yellow."""  | 
 | 137 | +    print(f"\033[0;33m>>> {message}\033[0m")  | 
 | 138 | + | 
 | 139 | + | 
 | 140 | +if __name__ == "__main__":  | 
 | 141 | +    main()  | 
0 commit comments