Skip to content

Commit 3cb09e9

Browse files
authored
🏗️ Use conan hal docs for doc generation (#113)
1 parent b469030 commit 3cb09e9

2 files changed

Lines changed: 391 additions & 5 deletions

File tree

.github/scripts/api_deploy.py

Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
1+
#!/usr/bin/env python3
2+
3+
# Copyright 2024 - 2025 Khalil Estell and the libhal contributors
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
"""
18+
libhal API Documentation Builder
19+
20+
This script handles the building and deployment of API documentation for libhal
21+
repositories. It can run locally or in CI environments, and performs the following:
22+
23+
1. Checks for required dependencies (doxygen, sphinx)
24+
2. Builds documentation for the current repository
25+
3. Optionally creates a PR to an `api` repository with the generated docs
26+
27+
Usage:
28+
python3 api.py build --version 1.2.3
29+
python3 api.py deploy --version 1.2.3 --repo-name libhal-arm-mcu
30+
"""
31+
32+
from packaging import version
33+
import argparse
34+
import json
35+
import os
36+
import shutil
37+
import subprocess
38+
import sys
39+
import tempfile
40+
from pathlib import Path
41+
import re
42+
import requests
43+
try:
44+
from git import Repo, GitCommandError
45+
HAS_GITPYTHON = True
46+
except ImportError:
47+
HAS_GITPYTHON = False
48+
49+
50+
def sort_versions_and_branches(items):
51+
"""
52+
Sort a mixed list of semantic versions and branch names.
53+
Branches appear at the top, followed by semantic versions in descending order.
54+
55+
Args:
56+
items: List of strings containing branch names and semantic versions
57+
58+
Returns:
59+
Sorted list with branches at the top followed by semantic versions
60+
"""
61+
branches = []
62+
versions = []
63+
64+
# Regex pattern to identify semantic versions (matches patterns like
65+
# '1.2.3', '1.2.3', etc.)
66+
semver_pattern = re.compile(r'^(\d+(\.\d+)*)(-.*)?$')
67+
68+
for item in items:
69+
if semver_pattern.match(item):
70+
versions.append(item)
71+
else:
72+
branches.append(item)
73+
74+
# Sort branches alphabetically
75+
branches.sort()
76+
77+
# Sort versions using packaging.version for proper semantic versioning rules
78+
# Convert version strings to Version objects for comparison
79+
versions.sort(key=lambda x: version.parse(x))
80+
81+
# Combine with branches first, then versions
82+
return branches + versions
83+
84+
85+
def generate_switcher_json(repo_dir: str,
86+
repo_name: str,
87+
organization: str = "libhal") -> bool:
88+
"""
89+
Generate the switcher.json file by scanning the repository directory.
90+
91+
Args:
92+
repo_dir: Path to the repository directory
93+
repo_name: Name of the repository
94+
organization: GitHub organization name
95+
96+
Returns:
97+
bool: True if successful, False otherwise
98+
"""
99+
try:
100+
# Path to the repository directory in the API repo
101+
repo_path = Path(repo_dir)
102+
103+
# Get all subdirectories (versions)
104+
version_dirs = [d for d in repo_path.iterdir() if d.is_dir()
105+
and d.name != '.git']
106+
versions = [d.name for d in version_dirs]
107+
versions = sort_versions_and_branches(versions)
108+
109+
# Create entries for switcher.json
110+
entries = []
111+
for version in versions:
112+
entries.append({
113+
"version": version,
114+
"url": f"https://{organization}.github.io/api/{repo_name}/{version}"
115+
})
116+
117+
# Write the switcher.json file
118+
switcher_path = repo_path / "switcher.json"
119+
with open(switcher_path, "w") as f:
120+
json.dump(entries, f, indent=4)
121+
122+
print(
123+
f"Generated switcher.json for {repo_name} with {len(entries)} versions")
124+
return True
125+
126+
except Exception as e:
127+
print(f"Error generating switcher.json: {e}")
128+
return False
129+
130+
131+
def check_existing_pr(token: str,
132+
repo: str,
133+
head: str,
134+
base: str = "main") -> dict:
135+
"""
136+
Check if a PR already exists for the given head branch.
137+
138+
Args:
139+
token: GitHub Personal Access Token
140+
repo: Repository (format: owner/repo)
141+
head: Branch containing changes
142+
base: Branch to merge into
143+
144+
Returns:
145+
dict: PR data if exists, None if no PR exists
146+
"""
147+
url = f"https://api.github.com/repos/{repo}/pulls"
148+
headers = {
149+
"Authorization": f"token {token}",
150+
"Accept": "application/vnd.github.v3+json"
151+
}
152+
params = {
153+
"head": head,
154+
"base": base,
155+
"state": "open"
156+
}
157+
158+
response = requests.get(url, headers=headers, params=params)
159+
response.raise_for_status()
160+
161+
prs = response.json()
162+
return prs[0] if prs else None
163+
164+
165+
def create_pr_or_update_branch_on_api_repo(
166+
version: str,
167+
repo_name: str,
168+
docs_dir: str = "build/api",
169+
api_repo_url: str = "https://github.com/libhal/api.git",
170+
organization: str = "libhal",
171+
branch_name: str = None
172+
) -> bool:
173+
"""
174+
Create a pull request to the centralized API docs repository or update existing branch.
175+
176+
Args:
177+
version: The version tag (e.g. 1.2.3)
178+
repo_name: Name of the current repository (e.g. libhal-arm)
179+
docs_dir: Directory containing the built documentation
180+
api_repo_url: URL of the API docs repository
181+
organization: GitHub organization name
182+
branch_name: Optional branch name, defaults to f"{repo_name}-{version}"
183+
184+
Returns:
185+
bool: True if successful, False otherwise
186+
"""
187+
if not HAS_GITPYTHON:
188+
print("Error: gitpython is required to create PRs.")
189+
print("Install with: pip install gitpython")
190+
return False
191+
192+
# Generate a branch name if not provided
193+
if not branch_name:
194+
branch_name = f"{repo_name}-{version}"
195+
196+
# Create PR using GitHub API (requires GitHub token)
197+
github_token = os.environ.get('GITHUB_TOKEN')
198+
199+
if not github_token:
200+
print("GitHub token not found. Branch pushed but PR not created.")
201+
print(f"Create a PR manually from branch: {branch_name}")
202+
return False
203+
204+
# Create a temporary directory to clone the API repo
205+
with tempfile.TemporaryDirectory() as temp_dir:
206+
try:
207+
print(f"Cloning {api_repo_url} into temporary directory...")
208+
api_repo = Repo.clone_from(api_repo_url, temp_dir)
209+
210+
# Checkout existing branch or create a new branch
211+
print(f"Switching to branch: {branch_name}")
212+
api_repo.git.checkout('-B', branch_name)
213+
214+
# Create repo directory if it doesn't exist
215+
repo_dir = os.path.join(temp_dir, repo_name)
216+
os.makedirs(repo_dir, exist_ok=True)
217+
218+
# Copy documentation to the API repo
219+
source_path = os.path.join(docs_dir, version)
220+
dest_path = os.path.join(repo_dir, version)
221+
222+
if not os.path.exists(source_path):
223+
print(f"Error: Documentation not found at {source_path}")
224+
return False
225+
226+
print(f"Copying documentation from {source_path} to {dest_path}")
227+
shutil.copytree(source_path, dest_path, dirs_exist_ok=True)
228+
229+
# Generate the switcher.json file
230+
generate_switcher_json(repo_dir, repo_name, organization)
231+
232+
# Commit changes
233+
api_repo.git.add(A=True)
234+
api_repo.git.config('user.name', 'libhal-bot')
235+
api_repo.git.config(
236+
'user.email', 'libhal-bot@users.noreply.github.com')
237+
238+
commit_message = f"Add {repo_name} {version} API documentation"
239+
api_repo.git.commit('-m', commit_message)
240+
241+
# Format the URL with the token authentication
242+
auth_url = f"https://x-access-token:{github_token}@github.com/libhal/api.git"
243+
244+
origin = api_repo.remote("origin")
245+
if origin.exists():
246+
print("Updating API repo's 'origin' to use access token")
247+
origin.set_url(auth_url)
248+
else:
249+
print("Adding remote 'origin' with access token")
250+
origin = api_repo.create_remote("origin", auth_url)
251+
252+
# Force Push because we allow APIs for a specific version to
253+
# reflect the latest representation of the version/ref.
254+
print(f"Pushing branch to remote...")
255+
api_repo.git.push('--force', '--set-upstream',
256+
'origin', branch_name)
257+
258+
# Check if PR already exists
259+
existing_pr = check_existing_pr(
260+
token=github_token,
261+
repo=f"{organization}/api",
262+
head=branch_name,
263+
base="main"
264+
)
265+
266+
if existing_pr:
267+
print(
268+
f"Pull request already exists: {existing_pr['html_url']}")
269+
print(
270+
f"Updated existing PR with new documentation for {repo_name} {version}")
271+
else:
272+
create_github_pr(
273+
token=github_token,
274+
repo=f"{organization}/api",
275+
title=commit_message,
276+
body=f"Adds API documentation for {repo_name} version {version}",
277+
head=branch_name,
278+
base="main"
279+
)
280+
print(
281+
f"Pull request created successfully for {repo_name} {version}")
282+
283+
return True
284+
285+
except GitCommandError as e:
286+
print(f"Git error: {e}")
287+
return False
288+
except Exception as e:
289+
print(f"Error creating PR: {e}")
290+
return False
291+
292+
293+
def create_github_pr(
294+
token: str,
295+
repo: str,
296+
title: str,
297+
body: str,
298+
head: str,
299+
base: str = "main"
300+
) -> dict:
301+
"""
302+
Create a pull request using the GitHub API.
303+
304+
Args:
305+
token: GitHub Personal Access Token
306+
repo: Repository (format: owner/repo)
307+
title: PR title
308+
body: PR description
309+
head: Branch containing changes
310+
base: Branch to merge into
311+
312+
Returns:
313+
dict: Response from GitHub API
314+
"""
315+
url = f"https://api.github.com/repos/{repo}/pulls"
316+
headers = {
317+
"Authorization": f"token {token}",
318+
"Accept": "application/vnd.github.v3+json"
319+
}
320+
data = {
321+
"title": title,
322+
"body": body,
323+
"head": head,
324+
"base": base
325+
}
326+
327+
response = requests.post(url, headers=headers, json=data)
328+
response.raise_for_status()
329+
return response.json()
330+
331+
332+
def main():
333+
parser = argparse.ArgumentParser(
334+
description="libhal API Documentation Builder")
335+
subparsers = parser.add_subparsers(
336+
dest="command", help="Command to execute")
337+
338+
# Deploy command
339+
deploy_parser = subparsers.add_parser(
340+
"deploy",
341+
help="Deploy documentation to API repo")
342+
deploy_parser.add_argument(
343+
"--version",
344+
required=True,
345+
help="Version tag (e.g. 1.2.3)")
346+
deploy_parser.add_argument(
347+
"--repo-name",
348+
required=True,
349+
help="Repository name (e.g. libhal, strong_ptr)")
350+
deploy_parser.add_argument(
351+
"--docs-dir",
352+
default="docs/build/",
353+
help="Directory containing built docs")
354+
deploy_parser.add_argument("--api-repo",
355+
default="https://github.com/libhal/api.git",
356+
help="URL of the API documentation repository")
357+
deploy_parser.add_argument("--organization", default="libhal",
358+
help="GitHub organization name")
359+
360+
args = parser.parse_args()
361+
362+
# Check dependencies first
363+
if args.command == "deploy":
364+
# For deploy, we need gitpython
365+
if not HAS_GITPYTHON:
366+
print("Error: gitpython is required for deployment.")
367+
print("Install with: pip install gitpython")
368+
return 1
369+
370+
success = create_pr_or_update_branch_on_api_repo(
371+
args.version,
372+
args.repo_name,
373+
args.docs_dir,
374+
args.api_repo,
375+
args.organization
376+
)
377+
else:
378+
parser.print_help()
379+
return 1
380+
381+
return 0 if success else 1
382+
383+
384+
if __name__ == "__main__":
385+
sys.exit(main())

0 commit comments

Comments
 (0)