Last active
March 5, 2025 10:56
-
-
Save albertocavalcante/a2210c4eada089015be03f98e3284301 to your computer and use it in GitHub Desktop.
Dev Container Feature: Documentation Generator
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# /// script | |
# requires-python = ">=3.7" | |
# /// | |
""" | |
DevContainer Documentation Generator | |
This script generates README files for DevContainer features and templates. | |
Based on https://raw.githubusercontent.com/devcontainers/action/c40c27d8968de19aa7799e0b5d11d1b19621784b/src/generateDocs.ts | |
""" | |
import os | |
import json | |
import asyncio | |
import logging | |
# Constants for README templates | |
FEATURES_README_TEMPLATE = """ | |
# #{Name} | |
#{Description} | |
## Example Usage | |
```json | |
"features": { | |
"#{Registry}/#{Namespace}/#{Id}:#{Version}": {} | |
} | |
``` | |
#{OptionsTable} | |
#{Customizations} | |
#{Notes} | |
--- | |
_Note: This file was auto-generated from the [devcontainer-feature.json](#{RepoUrl}). Add additional notes to a `NOTES.md`._ | |
""" | |
TEMPLATE_README_TEMPLATE = """ | |
# #{Name} | |
#{Description} | |
#{OptionsTable} | |
#{Notes} | |
--- | |
_Note: This file was auto-generated from the [devcontainer-template.json](#{RepoUrl}). Add additional notes to a `NOTES.md`._ | |
""" | |
def get_github_metadata(): | |
"""Get GitHub repository metadata from environment variables""" | |
# This is a simplified version - in practice you might get this from | |
# environment variables like GITHUB_REPOSITORY or API calls | |
owner = os.environ.get("GITHUB_REPOSITORY_OWNER", "") | |
repo = os.environ.get("GITHUB_REPOSITORY", "").split("/")[-1] if os.environ.get("GITHUB_REPOSITORY") else "" | |
return {"owner": owner, "repo": repo} | |
async def generate_features_documentation(base_path: str, oci_registry: str, namespace: str): | |
"""Generate documentation for DevContainer features""" | |
await _generate_documentation(base_path, FEATURES_README_TEMPLATE, 'devcontainer-feature.json', oci_registry, namespace) | |
async def generate_template_documentation(base_path: str): | |
"""Generate documentation for DevContainer templates""" | |
await _generate_documentation(base_path, TEMPLATE_README_TEMPLATE, 'devcontainer-template.json') | |
async def _generate_documentation( | |
base_path: str, | |
readme_template: str, | |
metadata_file: str, | |
oci_registry: str = '', | |
namespace: str = '' | |
): | |
""" | |
Generate documentation for DevContainer features or templates | |
Args: | |
base_path: Base directory path containing feature/template subdirectories | |
readme_template: Template string for README | |
metadata_file: Filename of the metadata JSON file | |
oci_registry: OCI registry name for features | |
namespace: Namespace for features | |
""" | |
directories = [f for f in os.listdir(base_path) if not f.startswith('.') and os.path.isdir(os.path.join(base_path, f))] | |
# Create tasks for each directory | |
tasks = [ | |
process_directory( | |
base_path, | |
directory, | |
readme_template, | |
metadata_file, | |
oci_registry, | |
namespace | |
) for directory in directories | |
] | |
# Run all tasks concurrently | |
await asyncio.gather(*tasks) | |
async def process_directory( | |
base_path: str, | |
directory: str, | |
readme_template: str, | |
metadata_file: str, | |
oci_registry: str = '', | |
namespace: str = '' | |
): | |
"""Process a single directory to generate its README""" | |
readme_path = os.path.join(base_path, directory, "README.md") | |
json_path = os.path.join(base_path, directory, metadata_file) | |
# Check if metadata file exists | |
if not os.path.exists(json_path): | |
logging.warning(f"(!) Warning: {metadata_file} not found at path '{json_path}'. Skipping...") | |
return | |
# Parse the metadata file | |
try: | |
with open(json_path, 'r', encoding='utf-8') as f: | |
# Use standard json to parse JSON | |
parsed_json = json.loads(f.read()) | |
except Exception as err: | |
logging.error(f"Failed to parse {json_path}: {err}") | |
return | |
# Validate the parsed JSON | |
if not parsed_json or 'id' not in parsed_json: | |
logging.error(f"{metadata_file} for '{directory}' does not contain an 'id'") | |
return | |
# Get repository metadata | |
src_info = get_github_metadata() | |
# Determine version | |
version = 'latest' | |
parsed_version = parsed_json.get('version') | |
if parsed_version: | |
# Example - 1.0.0 | |
split_version = parsed_version.split('.') | |
version = split_version[0] | |
# Generate options markdown | |
def generate_options_markdown(): | |
options = parsed_json.get('options') | |
if not options: | |
return '' | |
keys = options.keys() | |
rows = [] | |
for k in keys: | |
val = options[k] | |
desc = val.get('description', '-') | |
type_val = val.get('type', '-') | |
default = val.get('default', '') | |
default = default if default != '' else '-' | |
rows.append(f"| {k} | {desc} | {type_val} | {default} |") | |
contents = '\n'.join(rows) | |
return '## Options\n\n' + '| Options Id | Description | Type | Default Value |\n' + '|-----|-----|-----|-----|\n' + contents | |
# Generate notes markdown | |
def generate_notes_markdown(): | |
notes_path = os.path.join(base_path, directory, 'NOTES.md') | |
if os.path.exists(notes_path): | |
with open(notes_path, 'r', encoding='utf-8') as f: | |
return f.read() | |
return '' | |
# Build URL to config | |
url_to_config = metadata_file | |
base_path_trimmed = base_path[2:] if base_path.startswith('./') else base_path | |
if src_info['owner'] and src_info['repo']: | |
url_to_config = f"https://github.com/{src_info['owner']}/{src_info['repo']}/blob/main/{base_path_trimmed}/{directory}/{metadata_file}" | |
# Build header for deprecated or legacy features | |
header = "" | |
is_deprecated = parsed_json.get('deprecated', False) | |
has_legacy_ids = parsed_json.get('legacyIds') and len(parsed_json.get('legacyIds', [])) > 0 | |
if is_deprecated or has_legacy_ids: | |
header = '### **IMPORTANT NOTE**\n' | |
if is_deprecated: | |
header += "- **This Feature is deprecated, and will no longer receive any further updates/support.**\n" | |
if has_legacy_ids: | |
formatted_legacy_ids = [f"'{legacy_id}'" for legacy_id in parsed_json.get('legacyIds', [])] | |
header += f"- **Ids used to publish this Feature in the past - {', '.join(formatted_legacy_ids)}**\n" | |
# Build extensions section | |
extensions = "" | |
extensions_list = parsed_json.get('customizations', {}).get('vscode', {}).get('extensions', []) | |
if extensions_list and len(extensions_list) > 0: | |
extensions_markdown = '\n'.join([f"- `{ext}`" for ext in extensions_list]) | |
extensions = f"\n## Customizations\n\n### VS Code Extensions\n\n{extensions_markdown}\n" | |
# Generate the new README content by replacing template placeholders | |
new_readme = readme_template | |
# Templates & Features | |
new_readme = new_readme.replace('#{Id}', parsed_json['id']) | |
new_readme = new_readme.replace('#{Name}', f"{parsed_json.get('name', '')} ({parsed_json['id']})" if parsed_json.get('name') else f"{parsed_json['id']}") | |
new_readme = new_readme.replace('#{Description}', parsed_json.get('description', '')) | |
new_readme = new_readme.replace('#{OptionsTable}', generate_options_markdown()) | |
new_readme = new_readme.replace('#{Notes}', generate_notes_markdown()) | |
new_readme = new_readme.replace('#{RepoUrl}', url_to_config) | |
# Features Only | |
new_readme = new_readme.replace('#{Registry}', oci_registry) | |
new_readme = new_readme.replace('#{Namespace}', namespace) | |
new_readme = new_readme.replace('#{Version}', version) | |
new_readme = new_readme.replace('#{Customizations}', extensions) | |
# Add header if needed | |
if header: | |
new_readme = header + new_readme | |
# Remove previous readme if it exists | |
if os.path.exists(readme_path): | |
os.remove(readme_path) | |
# Write new readme | |
with open(readme_path, 'w', encoding='utf-8') as f: | |
f.write(new_readme) | |
# Main function to run the script | |
async def main(): | |
# Example usage | |
await generate_features_documentation('./features', 'ghcr.io', 'myorg') | |
await generate_template_documentation('./templates') | |
if __name__ == '__main__': | |
# Run the main function with asyncio | |
asyncio.run(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment