Skip to content

Commit 9b7eace

Browse files
authored
feat: Add support for 🐍 Python objects to be updated in README
feat: Add support for 🐍 Python objects to be updated in README
2 parents ba27426 + c662b57 commit 9b7eace

7 files changed

+332
-55
lines changed

β€ŽREADME.md

+98-24
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,20 @@
55
## **Code Embedder**
66
Seamlessly update code snippets in your **README** files! πŸ”„πŸ“πŸš€
77

8-
[Description](#-description) β€’ [How it works](#-how-it-works) β€’ [Examples](#-examples) β€’ [Setup](#-setup) β€’ [Under the hood](#-under-the-hood)
8+
[Description](#-description) β€’ [How it works](#-how-it-works) β€’ [Setup](#-setup) β€’ [Examples](#-examples) β€’ [Under the hood](#-under-the-hood)
99
</div>
1010

1111

1212
## πŸ“š Description
1313

1414
**Code Embedder** is a GitHub Action that automatically updates code snippets in your markdown (`README`) files. It finds code blocks in your `README` that reference specific scripts, then replaces these blocks with the current content of those scripts. This keeps your documentation in sync with your code.
1515

16-
✨ **Key features**
16+
### ✨ Key features
1717
- πŸ”„ **Automatic synchronization**: Keep your `README` code examples up-to-date without manual intervention.
18-
- πŸ“ **Section support**: Update specific sections of the script in the `README`.
1918
- πŸ› οΈ **Easy setup**: Simply add the action to your GitHub workflow and format your `README` code blocks.
20-
- 🌐 **Language agnostic**: Works with any programming language or file type.
19+
- πŸ“ **Section support**: Update only specific sections of the script in the `README`.
20+
- 🧩 **Object support**: Update only specific objects (functions, classes) in the `README`. *The latest version supports only 🐍 Python objects (other languages to be added soon).*
21+
2122

2223
By using **Code Embedder**, you can focus on writing and updating your actual code πŸ’», while letting the action take care of keeping your documentation current πŸ“šπŸ”„. This reduces the risk of outdated or incorrect code examples in your project documentation.
2324

@@ -43,9 +44,46 @@ You must also add the following comment tags in the script file `path/to/script`
4344
...
4445
[Comment sign] code_embedder:section_name end
4546
```
46-
The comment sign is the one that is used in the script file, e.g. `#` for Python, or `//` for JavaScript. The `section_name` must be unique in the file, otherwise the action will not be able to identify the section.
47+
The comment sign is the one that is used in the script file, e.g. `#` for Python, or `//` for JavaScript. The `section_name` must be unique in the file, otherwise the action will use the first section found.
48+
49+
### 🧩 **Object** updates
50+
In the `README` (or other markdown) file, the object of the script is marked with the following tag:
51+
````md
52+
```language:path/to/script:object_name
53+
```
54+
````
4755

56+
> [!Note]
57+
> The object name must match exactly the name of the object (function, class) in the script file. Currently, only 🐍 Python objects are supported.
4858
59+
> [!Note]
60+
> 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!**
61+
62+
## πŸ”§ Setup
63+
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:
64+
65+
```yaml
66+
name: Code Embedder
67+
68+
on: pull_request
69+
70+
permissions:
71+
contents: write
72+
73+
jobs:
74+
code_embedder:
75+
name: "Code embedder"
76+
runs-on: ubuntu-latest
77+
steps:
78+
- name: Checkout
79+
uses: actions/checkout@v3
80+
81+
- name: Run code embedder
82+
uses: kvankova/[email protected]
83+
env:
84+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
85+
86+
```
4987

5088
## πŸ’‘ Examples
5189

@@ -112,34 +150,70 @@ print("Embedding successful")
112150

113151
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.
114152

115-
## πŸ”§ Setup
116-
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:
153+
### 🧩 Object update
154+
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.
117155

118-
```yaml
119-
name: Code Embedder
156+
> [!Note]
157+
> 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`.
120158
121-
on: pull_request
159+
For example, let's say we have the following `README` file:
160+
````md
161+
# README
122162

123-
permissions:
124-
contents: write
163+
This is a readme.
125164

126-
jobs:
127-
code_embedder:
128-
name: "Code embedder"
129-
runs-on: ubuntu-latest
130-
steps:
131-
- name: Checkout
132-
uses: actions/checkout@v3
165+
Function `print_hello` is defined as follows:
166+
```python:main.py:print_hello
167+
```
133168

134-
- name: Run code embedder
135-
uses: kvankova/[email protected]
136-
env:
137-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
169+
Class `Person` is defined as follows:
170+
```python:main.py:Person
171+
```
172+
````
138173

174+
The `main.py` file contains the following code:
175+
```python
176+
...
177+
def print_hello():
178+
print("Hello, world!")
179+
...
180+
181+
class Person:
182+
def __init__(self, name):
183+
self.name = name
184+
def say_hello(self):
185+
print(f"Hello, {self.name}!")
186+
...
187+
```
188+
189+
Once the workflow runs, the code block section will be updated in the `README` file with the content of the function `print_hello` and class `Person` from the script located at `main.py` and pushed to the repository πŸš€.
190+
191+
````md
192+
# README
193+
194+
This is a readme.
195+
196+
Function `print_hello` is defined as follows:
197+
```python:main.py:print_hello
198+
def print_hello():
199+
print("Hello, world!")
139200
```
140201

202+
Class `Person` is defined as follows:
203+
```python:main.py:Person
204+
class Person:
205+
def __init__(self, name):
206+
self.name = name
207+
def say_hello(self):
208+
print(f"Hello, {self.name}!")
209+
```
210+
````
211+
212+
With any changes to the function `print_hello` or class `Person` in `main.py`, the code block sections are updated in the `README` file with the next workflow run.
213+
214+
141215
## πŸ”¬ Under the hood
142216
This action performs the following steps:
143-
1. πŸ”Ž Scans through the markdown (`README`) files to identify referenced script files (full script or section).
217+
1. πŸ”Ž Scans through the markdown (`README`) files to identify referenced script files (full script, section or 🐍 Python object).
144218
1. πŸ“ Extracts the contents from those script files and updates the corresponding code blocks in the markdown (`README`) files.
145219
1. πŸš€ Commits and pushes the updated documentation back to the repository.

β€Žsrc/script_content_reader.py

+72-27
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import ast
12
import re
23
from typing import Protocol
34

@@ -16,68 +17,112 @@ def __init__(self) -> None:
1617
self._section_end_regex = r".*code_embedder:.*end"
1718

1819
def read(self, scripts: list[ScriptMetadata]) -> list[ScriptMetadata]:
19-
script_contents = self._read_full_script(scripts)
20-
return self._process_scripts(script_contents)
20+
scripts_with_full_contents = self._read_full_script(scripts)
21+
return self._process_scripts(scripts_with_full_contents)
2122

2223
def _read_full_script(self, scripts: list[ScriptMetadata]) -> list[ScriptMetadata]:
23-
script_contents: list[ScriptMetadata] = []
24+
scripts_with_full_contents: list[ScriptMetadata] = []
2425

2526
for script in scripts:
2627
try:
2728
with open(script.path) as script_file:
2829
script.content = script_file.read()
2930

30-
script_contents.append(script)
31+
scripts_with_full_contents.append(script)
3132

3233
except FileNotFoundError:
3334
logger.error(f"Error: {script.path} not found. Skipping.")
3435

35-
return script_contents
36+
return scripts_with_full_contents
3637

3738
def _process_scripts(self, scripts: list[ScriptMetadata]) -> list[ScriptMetadata]:
3839
full_scripts = [script for script in scripts if not script.extraction_part]
39-
scripts_with_sections = [script for script in scripts if script.extraction_part]
40+
scripts_with_extraction_part = [script for script in scripts if script.extraction_part]
4041

41-
if scripts_with_sections:
42-
scripts_with_sections = self._read_script_section(scripts_with_sections)
42+
if scripts_with_extraction_part:
43+
scripts_with_extraction_part = self._update_script_content_with_extraction_part(
44+
scripts_with_extraction_part
45+
)
4346

44-
return full_scripts + scripts_with_sections
47+
return full_scripts + scripts_with_extraction_part
4548

46-
def _read_script_section(self, scripts: list[ScriptMetadata]) -> list[ScriptMetadata]:
49+
def _update_script_content_with_extraction_part(
50+
self, scripts: list[ScriptMetadata]
51+
) -> list[ScriptMetadata]:
4752
return [
4853
ScriptMetadata(
4954
path=script.path,
5055
extraction_part=script.extraction_part,
5156
readme_start=script.readme_start,
5257
readme_end=script.readme_end,
53-
content=self._extract_section(script),
58+
content=self._extract_part(script),
5459
)
5560
for script in scripts
5661
]
5762

58-
def _extract_section(self, script: ScriptMetadata) -> str:
63+
def _extract_part(self, script: ScriptMetadata) -> str:
5964
lines = script.content.split("\n")
60-
section_bounds = self._find_section_bounds(lines)
6165

62-
if not section_bounds:
63-
logger.error(f"Section {script.extraction_part} not found in {script.path}")
66+
# Try extracting as object first, then fall back to section
67+
is_object, start, end = self._find_object_bounds(script)
68+
if is_object:
69+
return "\n".join(lines[start:end])
70+
71+
# Extract section if not an object
72+
start, end = self._find_section_bounds(lines)
73+
if not self._validate_section_bounds(start, end, script):
6474
return ""
6575

66-
start, end = section_bounds
6776
return "\n".join(lines[start:end])
6877

69-
def _find_section_bounds(self, lines: list[str]) -> tuple[int, int] | None:
70-
section_start = None
71-
section_end = None
78+
def _validate_section_bounds(
79+
self, start: int | None, end: int | None, script: ScriptMetadata
80+
) -> bool:
81+
if not start and not end:
82+
logger.error(
83+
f"Part {script.extraction_part} not found in {script.path}. Skipping."
84+
)
85+
return False
86+
87+
if not start:
88+
logger.error(
89+
f"Start of section {script.extraction_part} not found in {script.path}. "
90+
"Skipping."
91+
)
92+
return False
93+
94+
if not end:
95+
logger.error(
96+
f"End of section {script.extraction_part} not found in {script.path}. "
97+
"Skipping."
98+
)
99+
return False
100+
101+
return True
72102

103+
def _find_section_bounds(self, lines: list[str]) -> tuple[int | None, int | None]:
73104
for i, line in enumerate(lines):
74105
if re.search(self._section_start_regex, line):
75-
section_start = i + 1
106+
start = i + 1
76107
elif re.search(self._section_end_regex, line):
77-
section_end = i
78-
break
79-
80-
if section_start is None or section_end is None:
81-
return None
82-
83-
return section_start, section_end
108+
return start, i
109+
110+
return None, None
111+
112+
def _find_object_bounds(
113+
self, script: ScriptMetadata
114+
) -> tuple[bool, int | None, int | None]:
115+
tree = ast.parse(script.content)
116+
117+
for node in ast.walk(tree):
118+
if (
119+
isinstance(node, ast.FunctionDef)
120+
| isinstance(node, ast.AsyncFunctionDef)
121+
| isinstance(node, ast.ClassDef)
122+
):
123+
if script.extraction_part == getattr(node, "name", None):
124+
start = getattr(node, "lineno", None)
125+
end = getattr(node, "end_lineno", None)
126+
return True, start - 1 if start else None, end
127+
128+
return False, None, None
+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import re
2+
3+
4+
# Function verifying an email is valid
5+
def verify_email(email: str) -> bool:
6+
return re.match(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", email) is not None
7+
8+
9+
class Person:
10+
def __init__(self, name: str, age: int):
11+
self.name = name
12+
self.age = age
13+
14+
# String representation of the class
15+
def __str__(self) -> str:
16+
return f"Person(name={self.name}, age={self.age})"

β€Žtests/data/expected_readme3.md

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# README 3
2+
3+
This is a test README file for testing the code embedding process.
4+
5+
## Python objects
6+
7+
This section contains examples of Python objects.
8+
9+
```python:tests/data/example_python_objects.py:verify_email
10+
def verify_email(email: str) -> bool:
11+
return re.match(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", email) is not None
12+
```
13+
14+
```python:tests/data/example_python_objects.py:Person
15+
class Person:
16+
def __init__(self, name: str, age: int):
17+
self.name = name
18+
self.age = age
19+
20+
# String representation of the class
21+
def __str__(self) -> str:
22+
return f"Person(name={self.name}, age={self.age})"
23+
```

β€Žtests/data/readme3.md

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# README 3
2+
3+
This is a test README file for testing the code embedding process.
4+
5+
## Python objects
6+
7+
This section contains examples of Python objects.
8+
9+
```python:tests/data/example_python_objects.py:verify_email
10+
```
11+
12+
```python:tests/data/example_python_objects.py:Person
13+
```

β€Žtests/test_code_embedding.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ def test_code_embedder(tmp_path) -> None:
88
"tests/data/readme0.md",
99
"tests/data/readme1.md",
1010
"tests/data/readme2.md",
11+
"tests/data/readme3.md",
1112
]
1213
expected_paths = [
1314
"tests/data/expected_readme0.md",
1415
"tests/data/expected_readme1.md",
1516
"tests/data/expected_readme2.md",
17+
"tests/data/expected_readme3.md",
1618
]
1719

1820
# Create a temporary copy of the original file
@@ -36,4 +38,4 @@ def test_code_embedder(tmp_path) -> None:
3638
with open(temp_readme_path) as updated_file:
3739
updated_readme_content = updated_file.readlines()
3840

39-
assert expected_readme_content == updated_readme_content
41+
assert updated_readme_content == expected_readme_content

0 commit comments

Comments
Β (0)