Repository: kvankova/code-embedder

Issue:
Add recognition of section and object name 
Update tag to
- `language:path/to/script:o:object_name`
- `language:path/to/script:s:section_name`


Discussion:

Patch:
diff --git a/README.md b/README.md
index 70971fa..2d41adc 100644
--- a/README.md
+++ b/README.md
@@ -35,9 +35,12 @@ In the `README` (or other markdown) file, the full script is marked with the fol
 ### 📂 **Section** updates
 In the `README` (or other markdown) file, the section of the script is marked with the following tag:
 ````md
- ```language:path/to/script:section_name
+ ```language:path/to/script:s:section_name
  ```
 ````
+> [!Note]
+>Notice that the `path/to/script` is followed by `s:` in the tag to indicate that the section `section_name` is being updated.
+
 You must also add the following comment tags in the script file `path/to/script`, where the section is located:
 ```
 [Comment sign] code_embedder:section_name start
@@ -49,15 +52,14 @@ The comment sign is the one that is used in the script file, e.g. `#` for Python
 ### 🧩 **Object** updates
 In the `README` (or other markdown) file, the object of the script is marked with the following tag:
 ````md
- ```language:path/to/script:object_name
+ ```language:path/to/script:o:object_name
  ```
 ````
-
 > [!Note]
-> The object name must match exactly the name of the object (function, class) in the script file. Currently, only 🐍 Python objects are supported.
+> Notice that the `path/to/script` is followed by `o:` in the tag to indicate that the object `object_name` is being updated.
 
-> [!Warning]
-> If there is a section with the same name as any object, the object definition will be used in the `README` instead of the section. To avoid this, **use unique names for sections and objects!**
+> [!Note]
+> The object name must match exactly the name of the object (function, class) in the script file, including the case (e.g. `Person` not `person`). Currently, only 🐍 Python objects are supported.
 
 ## 🔧 Setup
 To use this action, you need to configure a yaml workflow file in `.github/workflows` folder (e.g. `.github/workflows/code-embedder.yaml`) with the following content:
@@ -124,7 +126,7 @@ Now we have the following `README` file:
 
 This is a readme.
 
-```python:main.py:A
+```python:main.py:s:A
 ```
 ````
 The `main.py` file contains the following code:
@@ -143,7 +145,7 @@ Once the workflow runs, the code block section will be updated in the `README` f
 
 This is a readme.
 
-```python:main.py:A
+```python:main.py:s:A
 print("Embedding successful")
 ```
 ````
@@ -151,7 +153,9 @@ print("Embedding successful")
 With any changes to the section `A` in `main.py`, the code block section is updated in the `README` file with the next workflow run.
 
 ### 🧩 Object update
-The tag used for object update follows the same convention as the tag for section update, but you provide `object_name` instead of `section_name`. The object name can be a function name or a class name.
+The tag used for object update follows the same convention as the tag for section update with the following changes:
+- use `o:` instead of `s:`
+- use `object_name`
 
 > [!Warning]
 > The `object_name` must match exactly the name of the object (function, class) in the script file, including the case. If you define class `Person` in the script, you must use `Person` as the object name in the `README`, not lowercase `person`.
@@ -163,11 +167,11 @@ For example, let's say we have the following `README` file:
 This is a readme.
 
 Function `print_hello` is defined as follows:
-```python:main.py:print_hello
+```python:main.py:o:print_hello
 ```
 
 Class `Person` is defined as follows:
-```python:main.py:Person
+```python:main.py:o:Person
 ```
 ````
 
@@ -194,13 +198,13 @@ Once the workflow runs, the code block section will be updated in the `README` f
 This is a readme.
 
 Function `print_hello` is defined as follows:
-```python:main.py:print_hello
+```python:main.py:o:print_hello
 def print_hello():
     print("Hello, world!")
 ```
 
 Class `Person` is defined as follows:
-```python:main.py:Person
+```python:main.py:o:Person
 class Person:
     def __init__(self, name):
         self.name = name
diff --git a/src/script_content_reader.py b/src/script_content_reader.py
index 16d39b1..09c0dda 100644
--- a/src/script_content_reader.py
+++ b/src/script_content_reader.py
@@ -37,14 +37,14 @@ class ScriptContentReader:
 
     def _process_scripts(self, scripts: list[ScriptMetadata]) -> list[ScriptMetadata]:
         full_scripts = [script for script in scripts if not script.extraction_part]
-        scripts_with_extraction_part = [script for script in scripts if script.extraction_part]
+        scripts_with_extraction = [script for script in scripts if script.extraction_part]
 
-        if scripts_with_extraction_part:
-            scripts_with_extraction_part = self._update_script_content_with_extraction_part(
-                scripts_with_extraction_part
+        if scripts_with_extraction:
+            scripts_with_extraction = self._update_script_content_with_extraction_part(
+                scripts_with_extraction
             )
 
-        return full_scripts + scripts_with_extraction_part
+        return full_scripts + scripts_with_extraction
 
     def _update_script_content_with_extraction_part(
         self, scripts: list[ScriptMetadata]
@@ -53,6 +53,7 @@ class ScriptContentReader:
             ScriptMetadata(
                 path=script.path,
                 extraction_part=script.extraction_part,
+                extraction_type=script.extraction_type,
                 readme_start=script.readme_start,
                 readme_end=script.readme_end,
                 content=self._extract_part(script),
@@ -63,27 +64,50 @@ class ScriptContentReader:
     def _extract_part(self, script: ScriptMetadata) -> str:
         lines = script.content.split("\n")
 
-        # Try extracting as object first, then fall back to section
-        is_object, start, end = self._find_object_bounds(script)
-        if is_object:
-            return "\n".join(lines[start:end])
+        if script.extraction_type == "object":
+            start, end = self._extract_object_part(script)
 
-        # Extract section if not an object
-        start, end = self._find_section_bounds(lines)
-        if not self._validate_section_bounds(start, end, script):
+        elif script.extraction_type == "section":
+            start, end = self._extract_section_part(lines)
+            if not self._validate_section_bounds(start, end, script):
+                return ""
+
+        if not start and not end:
+            logger.error(
+                f"Part {script.extraction_part} not found in {script.path}. Skipping."
+            )
             return ""
 
         return "\n".join(lines[start:end])
 
+    def _extract_object_part(self, script: ScriptMetadata) -> tuple[int | None, int | None]:
+        tree = ast.parse(script.content)
+
+        for node in ast.walk(tree):
+            if (
+                isinstance(node, ast.FunctionDef)
+                | isinstance(node, ast.AsyncFunctionDef)
+                | isinstance(node, ast.ClassDef)
+            ):
+                if script.extraction_part == getattr(node, "name", None):
+                    start = getattr(node, "lineno", None)
+                    end = getattr(node, "end_lineno", None)
+                    return start - 1 if start else None, end
+
+        return None, None
+
+    def _extract_section_part(self, lines: list[str]) -> tuple[int | None, int | None]:
+        for i, line in enumerate(lines):
+            if re.search(self._section_start_regex, line):
+                start = i + 1
+            elif re.search(self._section_end_regex, line):
+                return start, i
+
+        return None, None
+
     def _validate_section_bounds(
         self, start: int | None, end: int | None, script: ScriptMetadata
     ) -> bool:
-        if not start and not end:
-            logger.error(
-                f"Part {script.extraction_part} not found in {script.path}. Skipping."
-            )
-            return False
-
         if not start:
             logger.error(
                 f"Start of section {script.extraction_part} not found in {script.path}. "
@@ -99,30 +123,3 @@ class ScriptContentReader:
             return False
 
         return True
-
-    def _find_section_bounds(self, lines: list[str]) -> tuple[int | None, int | None]:
-        for i, line in enumerate(lines):
-            if re.search(self._section_start_regex, line):
-                start = i + 1
-            elif re.search(self._section_end_regex, line):
-                return start, i
-
-        return None, None
-
-    def _find_object_bounds(
-        self, script: ScriptMetadata
-    ) -> tuple[bool, int | None, int | None]:
-        tree = ast.parse(script.content)
-
-        for node in ast.walk(tree):
-            if (
-                isinstance(node, ast.FunctionDef)
-                | isinstance(node, ast.AsyncFunctionDef)
-                | isinstance(node, ast.ClassDef)
-            ):
-                if script.extraction_part == getattr(node, "name", None):
-                    start = getattr(node, "lineno", None)
-                    end = getattr(node, "end_lineno", None)
-                    return True, start - 1 if start else None, end
-
-        return False, None, None
diff --git a/src/script_metadata.py b/src/script_metadata.py
index 93f38ce..c7b61b5 100644
--- a/src/script_metadata.py
+++ b/src/script_metadata.py
@@ -1,4 +1,5 @@
 from dataclasses import dataclass
+from typing import Literal
 
 
 @dataclass
@@ -6,5 +7,6 @@ class ScriptMetadata:
     readme_start: int
     readme_end: int
     path: str
+    extraction_type: Literal["section", "object", "full"] = "full"
     extraction_part: str | None = None
     content: str = ""
diff --git a/src/script_metadata_extractor.py b/src/script_metadata_extractor.py
index cf67451..8613655 100644
--- a/src/script_metadata_extractor.py
+++ b/src/script_metadata_extractor.py
@@ -1,6 +1,8 @@
 import re
 from typing import Protocol
 
+from loguru import logger
+
 from src.script_metadata import ScriptMetadata
 
 
@@ -13,6 +15,13 @@ class ScriptMetadataExtractor:
         self._code_block_start_regex = r"^```.*?:"
         self._code_block_end = "```"
         self._path_separator = ":"
+        self._object_separator = "o"
+        self._section_separator = "s"
+        self.extraction_type_mapping = {
+            self._object_separator: "object",
+            self._section_separator: "section",
+            "full": "full",
+        }
 
     def extract(self, readme_content: list[str]) -> list[ScriptMetadata]:
         scripts = []
@@ -33,17 +42,41 @@ class ScriptMetadataExtractor:
     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:
+    def _start_new_block(self, line: str, row: int) -> dict | None:
         tag_items = line.split(self._path_separator)
         path = tag_items[1].strip()
-        extraction_part = tag_items[2].strip() if len(tag_items) > 2 else None
-        return {"start": row, "path": path, "extraction_part": extraction_part}
+        extraction_type = tag_items[2].strip() if len(tag_items) > 2 else "full"
+
+        if extraction_type not in self.extraction_type_mapping.keys():
+            logger.error(
+                f"Unknown extraction type {extraction_type}. Allowed are only "
+                f"`{self._object_separator}` for object and "
+                f"`{self._section_separator}` for section. Skipping."
+            )
+            return None
+
+        extraction_part = tag_items[3].strip() if len(tag_items) > 3 else None
+
+        if not extraction_part and extraction_type != "full":
+            logger.error(
+                f"Extraction part is not provided for {extraction_type} extraction type. "
+                "Skipping."
+            )
+            return None
+
+        return {
+            "start": row,
+            "path": path,
+            "extraction_type": self.extraction_type_mapping[extraction_type],
+            "extraction_part": extraction_part,
+        }
 
     def _finish_current_block(self, block: dict, end_row: int) -> ScriptMetadata:
         return ScriptMetadata(
             readme_start=block["start"],
             readme_end=end_row,
             path=block["path"],
+            extraction_type=block["extraction_type"],
             extraction_part=block["extraction_part"],
             content="",
         )


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index caa664b..64391fb 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,13 +1,21 @@
+from typing import Literal
+
 from src.script_metadata import ScriptMetadata
 
 
 def create_script_metadata(
-    start: int, end: int, path: str, extraction_part: str | None = None, content: str = ""
+    start: int,
+    end: int,
+    path: str,
+    extraction_type: Literal["section", "object", "full"] = "full",
+    extraction_part: str | None = None,
+    content: str = "",
 ) -> ScriptMetadata:
     return ScriptMetadata(
         readme_start=start,
         readme_end=end,
         path=path,
+        extraction_type=extraction_type,
         extraction_part=extraction_part,
         content=content,
     )
diff --git a/tests/data/expected_readme1.md b/tests/data/expected_readme1.md
index 64bb410..b57b749 100644
--- a/tests/data/expected_readme1.md
+++ b/tests/data/expected_readme1.md
@@ -3,7 +3,7 @@
 This is a test readme.
 
 This will be filled:
-```python:tests/data/example_section.py:A
+```python:tests/data/example_section.py:s:A
 print("Printing only section A")
 ```
 
diff --git a/tests/data/expected_readme3.md b/tests/data/expected_readme3.md
index 16abd7e..0f63241 100644
--- a/tests/data/expected_readme3.md
+++ b/tests/data/expected_readme3.md
@@ -6,12 +6,12 @@ This is a test README file for testing the code embedding process.
 
 This section contains examples of Python objects.
 
-```python:tests/data/example_python_objects.py:verify_email
+```python:tests/data/example_python_objects.py:o:verify_email
 def verify_email(email: str) -> bool:
     return re.match(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", email) is not None
 ```
 
-```python:tests/data/example_python_objects.py:Person
+```python:tests/data/example_python_objects.py:o:Person
 class Person:
     def __init__(self, name: str, age: int):
         self.name = name
diff --git a/tests/data/readme1.md b/tests/data/readme1.md
index c5eb82b..9eb576e 100644
--- a/tests/data/readme1.md
+++ b/tests/data/readme1.md
@@ -3,7 +3,7 @@
 This is a test readme.
 
 This will be filled:
-```python:tests/data/example_section.py:A
+```python:tests/data/example_section.py:s:A
 print("Printing only section A")
 ```
 
diff --git a/tests/data/readme3.md b/tests/data/readme3.md
index 6421b91..456e1ef 100644
--- a/tests/data/readme3.md
+++ b/tests/data/readme3.md
@@ -6,8 +6,8 @@ This is a test README file for testing the code embedding process.
 
 This section contains examples of Python objects.
 
-```python:tests/data/example_python_objects.py:verify_email
+```python:tests/data/example_python_objects.py:o:verify_email
 ```
 
-```python:tests/data/example_python_objects.py:Person
+```python:tests/data/example_python_objects.py:o:Person
 ```
diff --git a/tests/test_script_content_reader.py b/tests/test_script_content_reader.py
index 4e8163c..762bf54 100644
--- a/tests/test_script_content_reader.py
+++ b/tests/test_script_content_reader.py
@@ -12,6 +12,7 @@ from src.script_metadata import ScriptMetadata
                 ScriptMetadata(
                     path="tests/data/example.py",
                     extraction_part="",
+                    extraction_type="full",
                     readme_start=0,
                     readme_end=0,
                     content="",
@@ -19,6 +20,7 @@ from src.script_metadata import ScriptMetadata
                 ScriptMetadata(
                     path="tests/data/example_section.py",
                     extraction_part="A",
+                    extraction_type="section",
                     readme_start=0,
                     readme_end=0,
                     content="",
@@ -26,6 +28,7 @@ from src.script_metadata import ScriptMetadata
                 ScriptMetadata(
                     path="tests/data/example_python_objects.py",
                     extraction_part="",
+                    extraction_type="full",
                     readme_start=0,
                     readme_end=0,
                     content="",
@@ -35,6 +38,7 @@ from src.script_metadata import ScriptMetadata
                 ScriptMetadata(
                     path="tests/data/example.py",
                     extraction_part="",
+                    extraction_type="full",
                     readme_start=0,
                     readme_end=0,
                     content='print("Hello, World! from script")\n',
@@ -42,6 +46,7 @@ from src.script_metadata import ScriptMetadata
                 ScriptMetadata(
                     path="tests/data/example_section.py",
                     extraction_part="A",
+                    extraction_type="section",
                     readme_start=0,
                     readme_end=0,
                     content=(
@@ -54,6 +59,7 @@ from src.script_metadata import ScriptMetadata
                 ScriptMetadata(
                     path="tests/data/example_python_objects.py",
                     extraction_part="",
+                    extraction_type="full",
                     readme_start=0,
                     readme_end=0,
                     content=(
@@ -96,6 +102,7 @@ def test_read_full_script(
                 ScriptMetadata(
                     path="tests/data/example.py",
                     extraction_part="no_section",
+                    extraction_type="section",
                     readme_start=0,
                     readme_end=0,
                     content='print("Hello, World! from script")\n',
@@ -103,6 +110,7 @@ def test_read_full_script(
                 ScriptMetadata(
                     path="tests/data/example_section.py",
                     extraction_part="A",
+                    extraction_type="section",
                     readme_start=0,
                     readme_end=0,
                     content=(
@@ -115,6 +123,7 @@ def test_read_full_script(
                 ScriptMetadata(
                     path="tests/data/example_python_objects.py",
                     extraction_part="verify_email",
+                    extraction_type="object",
                     readme_start=0,
                     readme_end=0,
                     content=(
@@ -137,6 +146,7 @@ def test_read_full_script(
                 ScriptMetadata(
                     path="tests/data/example_python_objects.py",
                     extraction_part="Person",
+                    extraction_type="object",
                     readme_start=0,
                     readme_end=0,
                     content=(
@@ -161,6 +171,7 @@ def test_read_full_script(
                 ScriptMetadata(
                     path="tests/data/example.py",
                     extraction_part="no_section",
+                    extraction_type="section",
                     readme_start=0,
                     readme_end=0,
                     content="",
@@ -168,6 +179,7 @@ def test_read_full_script(
                 ScriptMetadata(
                     path="tests/data/example_section.py",
                     extraction_part="A",
+                    extraction_type="section",
                     readme_start=0,
                     readme_end=0,
                     content='print("Printing only section A")',
@@ -175,6 +187,7 @@ def test_read_full_script(
                 ScriptMetadata(
                     path="tests/data/example_python_objects.py",
                     extraction_part="verify_email",
+                    extraction_type="object",
                     readme_start=0,
                     readme_end=0,
                     content=(
@@ -186,6 +199,7 @@ def test_read_full_script(
                 ScriptMetadata(
                     path="tests/data/example_python_objects.py",
                     extraction_part="Person",
+                    extraction_type="object",
                     readme_start=0,
                     readme_end=0,
                     content=(
diff --git a/tests/test_script_metadata_extractor.py b/tests/test_script_metadata_extractor.py
index b7bd807..5cfb7c3 100644
--- a/tests/test_script_metadata_extractor.py
+++ b/tests/test_script_metadata_extractor.py
@@ -50,13 +50,13 @@ test_cases = [
         id="two_tagged_scripts_one_untagged_script",
     ),
     pytest.param(
-        ["```python:main.py:section_name", "print('Hello, World!')", "```"],
-        [create_script_metadata(0, 2, "main.py", "section_name")],
+        ["```python:main.py:s:section_name", "print('Hello, World!')", "```"],
+        [create_script_metadata(0, 2, "main.py", "section", "section_name")],
         id="one_tagged_script_with_section_name",
     ),
     pytest.param(
         [
-            "```python:main.py:section_name",
+            "```python:main.py:s:section_name",
             "print('Hello, World!')",
             "```",
             "```python:example.py",
@@ -64,26 +64,38 @@ test_cases = [
             "```",
         ],
         [
-            create_script_metadata(0, 2, "main.py", "section_name"),
+            create_script_metadata(0, 2, "main.py", "section", "section_name"),
             create_script_metadata(3, 5, "example.py"),
         ],
         id="one_tagged_script_with_section_name_one_tagged_script",
     ),
     pytest.param(
         [
-            "```python:main.py:A",
+            "```python:main.py:s:A",
             "print('Hello, World!')",
             "```",
-            "```python:example.py:B",
+            "```python:example.py:s:B",
             "print('Hello, World!')",
             "```",
         ],
         [
-            create_script_metadata(0, 2, "main.py", "A"),
-            create_script_metadata(3, 5, "example.py", "B"),
+            create_script_metadata(0, 2, "main.py", "section", "A"),
+            create_script_metadata(3, 5, "example.py", "section", "B"),
         ],
         id="two_tagged_scripts_with_section_names",
     ),
+    pytest.param(
+        [
+            "```python:main.py:o:print_hello",
+            "def print_hello() -> None:",
+            "    print('Hello, World!')",
+            "```",
+        ],
+        [
+            create_script_metadata(0, 3, "main.py", "object", "print_hello"),
+        ],
+        id="one_tagged_script_with_object_name",
+    ),
 ]