Skip to content

Instantly share code, notes, and snippets.

@albertocavalcante
Last active March 5, 2025 10:56
Show Gist options
  • Save albertocavalcante/a2210c4eada089015be03f98e3284301 to your computer and use it in GitHub Desktop.
Save albertocavalcante/a2210c4eada089015be03f98e3284301 to your computer and use it in GitHub Desktop.
Dev Container Feature: Documentation Generator
#!/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