Repository: kvankova/code-embedder

Issue:
Add possibility of updating multiple readme files
Currently, only one readme file can be updated. Add support for updating multiple readme files. 

Discussion:

Patch:
diff --git a/Makefile b/Makefile
index de0e6ec..bb61ffd 100644
--- a/Makefile
+++ b/Makefile
@@ -9,9 +9,3 @@ run:
 
 test:
 	poetry run pytest
-
-del:
-	gh release delete 0.0.1
-	git push origin --delete 0.0.1
-	git tag -d 0.0.1
-	gh release create 0.0.1 -t "Testing" -n "Testing"
diff --git a/README.md b/README.md
index f212817..4b46dc7 100644
--- a/README.md
+++ b/README.md
@@ -9,9 +9,9 @@ Code embedder is a GitHub action that updates your README.md with up-to-date cod
 
 ## Description
 This action:
-1. reads a README file from `readme_path` input,
-1. looks for specific script tags in the README,
-1. reads the content of the script and embeds it in the README at the corresponding tag,
+1. reads one or more README files from `readme_paths` input,
+1. looks for specific script tags in the READMEs,
+1. reads the content of the scripts and embeds it in the READMEs at the corresponding tags,
 1. pushes the changes to the repository.
 
 ## Workflow file structure
@@ -35,7 +35,7 @@ jobs:
       - name: Run code embedder
         uses: kvankova/code-embedder@v0.1.0
         with:
-          readme_path: README.md
+          readme_paths: README.md README2.md
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
@@ -43,8 +43,7 @@ jobs:
 It requires a secret `GITHUB_TOKEN` with write and repo permission.
 
 ## README file configuration
-You can specify which README file you want to update with `readme_path` (path to markdown). As of now,
-it supports only one readme file, and if not specified, it will by default look for it in the root `README.md`.
+You can specify which README files you want to update with `readme_paths` (paths to markdown files). If no README files are provided, it will by default look for it in the root `README.md`. Argument `readme_paths` accepts multiple paths separated by spaces, example: `README.md README2.md`.
 
 The action looks for the following sections in the readme file based on which it will update the code snippets:
 ````md
diff --git a/action.yml b/action.yml
index 4b48a6a..e6ee777 100644
--- a/action.yml
+++ b/action.yml
@@ -6,12 +6,13 @@ branding:
   color: "blue"
 
 inputs:
-  readme_path:
-    description: "Path to Readme. By default, it finds it in the root."
+  readme_paths:
+    description: "Path(s) to Readme file(s). Can be a single path or a list of paths. By default, it finds it in the root."
     required: false
+    default: "README.md"
 
 runs:
   using: docker
   image: Dockerfile
   args:
-    - ${{ inputs.readme_path || 'README.md' }}
+    - ${{ inputs.readme_paths }}
diff --git a/entrypoint.sh b/entrypoint.sh
index 4623dc1..4f6f2bc 100755
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -4,21 +4,22 @@ git config --global user.name "github-actions"
 git config --global user.email "github-actions@github.com"
 git remote set-url origin https://x-access-token:$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY.git
 
-README_PATH=${1:-README.md}
+README_PATHS=$1
+
 BRANCH_NAME=${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}
 
 git pull origin ${BRANCH_NAME}
 
-echo "Updating $README_PATH with code snippets..."
+echo "Updating $README_PATHS with code snippets..."
 
-poetry run python /app/src/main.py --readme-path "$README_PATH"
+poetry run python /app/src/main.py --readme-paths $README_PATHS
 
 if [ -n "$(git status -s)" ]; then
 
     echo "Changes to commit..."
-    git add "$README_PATH"
+    git add $README_PATHS
     git diff --staged
-    git commit -m "Update $README_PATH"
+    git commit -m "Update $README_PATHS"
     git push origin ${BRANCH_NAME}
 else
     echo "No changes to commit."
diff --git a/src/code_embedding.py b/src/code_embedding.py
index 9101593..fe29d1c 100644
--- a/src/code_embedding.py
+++ b/src/code_embedding.py
@@ -14,59 +14,89 @@ class ScriptMetadata:
 
 class ScriptPathExtractor:
     def __init__(self) -> None:
-        self._lookup_regex = r"^```.*?:"
+        self._code_block_start_regex = r"^```.*?:"
         self._code_block_end = "```"
-        self._split_path_mark = ":"
+        self._path_separator = ":"
 
     def extract(self, readme_content: list[str]) -> list[ScriptMetadata]:
         scripts = []
-        inside_code_block = False
+        current_block = None
 
         for row, line in enumerate(readme_content):
-            if re.search(self._lookup_regex, line):
-                inside_code_block = True
-                path = line.split(self._split_path_mark)[-1].strip()
-                start = row
-            elif (line.strip() == (self._code_block_end)) & inside_code_block:
-                end = row
-                scripts.append(
-                    ScriptMetadata(readme_start=start, readme_end=end, path=path, content="")
-                )
-                inside_code_block = False
+            if self._is_code_block_start(line):
+                current_block = self._start_new_block(line, row)
+            elif self._is_code_block_end(line) and current_block:
+                scripts.append(self._finish_current_block(current_block, row))
+                current_block = None
 
         return scripts
 
+    def _is_code_block_start(self, line: str) -> bool:
+        return re.search(self._code_block_start_regex, line) is not None
+
+    def _is_code_block_end(self, line: str) -> bool:
+        return line.strip() == self._code_block_end
+
+    def _start_new_block(self, line: str, row: int) -> dict:
+        path = line.split(self._path_separator)[-1].strip()
+        return {"start": row, "path": path}
+
+    def _finish_current_block(self, block: dict, end_row: int) -> ScriptMetadata:
+        return ScriptMetadata(
+            readme_start=block["start"], readme_end=end_row, path=block["path"], content=""
+        )
+
 
 class CodeEmbedder:
     def __init__(
         self,
-        readme_path: str,
+        readme_paths: list[str],
         script_path_extractor: ScriptPathExtractor,
     ) -> None:
-        self._readme_path = readme_path
+        self._readme_paths = readme_paths
         self._script_path_extractor = script_path_extractor
 
     def __call__(self) -> None:
-        with open(self._readme_path) as readme_file:
-            self.readme_content = readme_file.readlines()
+        for readme_path in self._readme_paths:
+            self._process_readme(readme_path)
 
-        if len(self.readme_content) == 0:
-            logger.info("Empty README. Skipping.")
+    def _process_readme(self, readme_path: str) -> None:
+        readme_content = self._read_readme(readme_path)
+        if not readme_content:
+            logger.info(f"Empty README in path {readme_path}. Skipping.")
             return
 
-        scripts = self._script_path_extractor.extract(readme_content=self.readme_content)
-
-        if len(scripts) == 0:
-            logger.info("No script paths found in README. Skipping.")
+        scripts = self._extract_scripts(readme_content=readme_content, readme_path=readme_path)
+        if not scripts:
             return
 
-        logger.info(
-            f"Found script paths in README: {set([script.path for script in scripts])}"
-        )
-
         script_contents = self._read_script_content(scripts=scripts)
+        self._update_readme(
+            script_contents=script_contents,
+            readme_content=readme_content,
+            readme_path=readme_path,
+        )
 
-        self.update_readme(script_contents=script_contents)
+    def _read_readme(self, readme_path: str) -> list[str]:
+        if not readme_path.endswith(".md"):
+            logger.error("README path must end with .md")
+            raise ValueError("README path must end with .md")
+
+        with open(readme_path) as readme_file:
+            return readme_file.readlines()
+
+    def _extract_scripts(
+        self, readme_content: list[str], readme_path: str
+    ) -> list[ScriptMetadata] | None:
+        scripts = self._script_path_extractor.extract(readme_content=readme_content)
+        if not scripts:
+            logger.info(f"No script paths found in README in path {readme_path}. Skipping.")
+            return None
+        logger.info(
+            f"""Found script paths in README in path {readme_path}:
+            {set(script.path for script in scripts)}"""
+        )
+        return scripts
 
     def _read_script_content(self, scripts: list[ScriptMetadata]) -> list[ScriptMetadata]:
         script_contents: list[ScriptMetadata] = []
@@ -79,23 +109,26 @@ class CodeEmbedder:
                 script_contents.append(script)
 
             except FileNotFoundError:
-                print(f"Error: {script.path} not found. Skipping.")
+                logger.error(f"Error: {script.path} not found. Skipping.")
 
         return script_contents
 
-    def update_readme(self, script_contents: list[ScriptMetadata]) -> None:
+    def _update_readme(
+        self,
+        script_contents: list[ScriptMetadata],
+        readme_content: list[str],
+        readme_path: str,
+    ) -> None:
         updated_readme = []
         readme_content_cursor = 0
 
         for script in script_contents:
-            updated_readme += self.readme_content[
-                readme_content_cursor : script.readme_start + 1
-            ]
+            updated_readme += readme_content[readme_content_cursor : script.readme_start + 1]
             updated_readme += script.content + "\n"
 
             readme_content_cursor = script.readme_end
 
-        updated_readme += self.readme_content[readme_content_cursor:]
+        updated_readme += readme_content[readme_content_cursor:]
 
-        with open(self._readme_path, "w") as readme_file:
+        with open(readme_path, "w") as readme_file:
             readme_file.writelines(updated_readme)
diff --git a/src/main.py b/src/main.py
index 4069270..34a5b4a 100644
--- a/src/main.py
+++ b/src/main.py
@@ -5,13 +5,15 @@ from loguru import logger
 from src.code_embedding import CodeEmbedder, ScriptPathExtractor
 
 parser = argparse.ArgumentParser()
-parser.add_argument("--readme-path", type=str, help="Path to Readme", default="README.md")
+parser.add_argument(
+    "--readme-paths", nargs="+", type=str, help="Paths to Readme files", default="README.md"
+)
 args = parser.parse_args()
 
 if __name__ == "__main__":
     script_path_extractor = ScriptPathExtractor()
     code_embedder = CodeEmbedder(
-        readme_path=args.readme_path,
+        readme_paths=args.readme_paths,
         script_path_extractor=script_path_extractor,
     )
     code_embedder()


Test patch:
diff --git a/.github/test_README.md b/.github/test_README.md
new file mode 100644
index 0000000..676bb7f
--- /dev/null
+++ b/.github/test_README.md
@@ -0,0 +1,83 @@
+# Testing README
+
+This is a test readme.
+
+This will update:
+```python:.github/workflows/test_code_embedder.yml
+name: Test Code Embedder
+
+on: pull_request
+
+jobs:
+  code_embedder:
+    name: "Code embedder"
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+        with:
+          ref: ${{ github.event.pull_request.head.ref }}
+
+      - name: Run code embedder
+        uses: ./
+        with:
+          readme_paths: .github/test_README.md .github/test_empty_README.md
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+```
+
+This will update:
+```yaml:.github/release-drafter-config.yml
+name-template: 'v$RESOLVED_VERSION 🌈'
+tag-template: 'v$RESOLVED_VERSION'
+change-template: '- $TITLE @$AUTHOR (#$NUMBER)'
+
+version-resolver:
+  major:
+    labels:
+      - 'major'
+      - 'breaking'
+      - 'breaking change'
+  minor:
+    labels:
+      - 'minor'
+  default: patch
+
+categories:
+  - title: 🏆 Highlights
+    labels: highlight
+  - title: ✨ Features
+    labels:
+      - feature
+      - enhancement
+  - title: 🚀 Performance improvements
+    labels: performance
+  - title: 🐛 Bug Fixes
+    labels:
+      - fix
+      - bugfix
+      - bug
+  - title: 📖 Documentation
+    labels: documentation
+  - title: 🛠️ Other improvements
+    labels: internal
+
+exclude-labels:
+  - skip changelog
+  - release
+
+replacers:
+  # Remove conventional commits from titles
+  - search: '/- (build|chore|ci|depr|docs|feat|fix|perf|refactor|release|test)(\(.*\))?(\!)?\: /g'
+    replace: '- '
+
+template: |
+  $CHANGES
+
+```
+
+This won't update:
+```python
+print("Hello, World!")
+```
diff --git a/.github/test_empty_README.md b/.github/test_empty_README.md
new file mode 100644
index 0000000..e69de29
diff --git a/.github/workflows/test_code_embedder.yml b/.github/workflows/test_code_embedder.yml
new file mode 100644
index 0000000..f3aa8ab
--- /dev/null
+++ b/.github/workflows/test_code_embedder.yml
@@ -0,0 +1,20 @@
+name: Test Code Embedder
+
+on: pull_request
+
+jobs:
+  code_embedder:
+    name: "Code embedder"
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+        with:
+          ref: ${{ github.event.pull_request.head.ref }}
+
+      - name: Run code embedder
+        uses: ./
+        with:
+          readme_paths: .github/test_README.md .github/test_empty_README.md
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/tests/data/expected_readme0.md b/tests/data/expected_readme0.md
new file mode 100644
index 0000000..4e86e93
--- /dev/null
+++ b/tests/data/expected_readme0.md
@@ -0,0 +1,12 @@
+# README
+
+This will be filled:
+```python:tests/data/example.py
+print("Hello, World! from script")
+
+```
+
+This will not find the script:
+```python:unknown_path.py
+print("Hello, World!")
+```
diff --git a/tests/data/expected_readme.md b/tests/data/expected_readme1.md
similarity index 100%
rename from tests/data/expected_readme.md
rename to tests/data/expected_readme1.md
diff --git a/tests/data/expected_readme2.md b/tests/data/expected_readme2.md
new file mode 100644
index 0000000..527b651
--- /dev/null
+++ b/tests/data/expected_readme2.md
@@ -0,0 +1,1 @@
+# This is a simple readme
diff --git a/tests/data/readme0.md b/tests/data/readme0.md
new file mode 100644
index 0000000..d580a7c
--- /dev/null
+++ b/tests/data/readme0.md
@@ -0,0 +1,10 @@
+# README
+
+This will be filled:
+```python:tests/data/example.py
+```
+
+This will not find the script:
+```python:unknown_path.py
+print("Hello, World!")
+```
diff --git a/tests/data/readme.md b/tests/data/readme1.md
similarity index 100%
rename from tests/data/readme.md
rename to tests/data/readme1.md
diff --git a/tests/data/readme2.md b/tests/data/readme2.md
new file mode 100644
index 0000000..527b651
--- /dev/null
+++ b/tests/data/readme2.md
@@ -0,0 +1,1 @@
+# This is a simple readme
diff --git a/tests/test_code_embedding.py b/tests/test_code_embedding.py
index 37c8339..569ea2c 100644
--- a/tests/test_code_embedding.py
+++ b/tests/test_code_embedding.py
@@ -57,12 +57,13 @@ def test_script_path_extractor(
     readme_content: list[str], expected: list[ScriptMetadata]
 ) -> None:
     script_path_extractor = ScriptPathExtractor()
-    assert script_path_extractor.extract(readme_content=readme_content) == expected
+    result = script_path_extractor.extract(readme_content=readme_content)
+    assert result == expected
 
 
 def test_code_embedder_read_script_content() -> None:
     code_embedder = CodeEmbedder(
-        readme_path="tests/data/readme.md",
+        readme_paths=["tests/data/readme.md"],
         script_path_extractor=ScriptPathExtractor(),
     )
 
@@ -84,25 +85,35 @@ def test_code_embedder_read_script_content() -> None:
 
 
 def test_code_embedder(tmp_path) -> None:
-    original_path = "tests/data/readme.md"
-    expected_path = "tests/data/expected_readme.md"
+    original_paths = [
+        "tests/data/readme0.md",
+        "tests/data/readme1.md",
+        "tests/data/readme2.md",
+    ]
+    expected_paths = [
+        "tests/data/expected_readme0.md",
+        "tests/data/expected_readme1.md",
+        "tests/data/expected_readme2.md",
+    ]
 
     # Create a temporary copy of the original file
-    temp_readme_path = tmp_path / "readme.md"
-    with open(original_path) as readme_file:
-        temp_readme_path.write_text(readme_file.read())
+    temp_readme_paths = [tmp_path / f"readme{i}.md" for i in range(len(original_paths))]
+    for original_path, temp_readme_path in zip(original_paths, temp_readme_paths):
+        with open(original_path) as readme_file:
+            temp_readme_path.write_text(readme_file.read())
 
     code_embedder = CodeEmbedder(
-        readme_path=str(temp_readme_path),
+        readme_paths=[str(temp_readme_path) for temp_readme_path in temp_readme_paths],
         script_path_extractor=ScriptPathExtractor(),
     )
 
     code_embedder()
 
-    with open(expected_path) as expected_file:
-        expected_readme_content = expected_file.readlines()
+    for expected_path, temp_readme_path in zip(expected_paths, temp_readme_paths):
+        with open(expected_path) as expected_file:
+            expected_readme_content = expected_file.readlines()
 
-    with open(temp_readme_path) as updated_file:
-        updated_readme_content = updated_file.readlines()
+        with open(temp_readme_path) as updated_file:
+            updated_readme_content = updated_file.readlines()
 
-    assert expected_readme_content == updated_readme_content
+        assert expected_readme_content == updated_readme_content