Skip to content

Commit 978fdac

Browse files
committed
Add manifest generation script
1 parent 70e6fe5 commit 978fdac

1 file changed

Lines changed: 204 additions & 0 deletions

File tree

scripts/generate_manifest.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Generates instrumentation-manifest.json by parsing instrumentation source files
4+
using the Python AST (no code execution).
5+
6+
This approach mirrors the Node SDK's generateManifest.ts script.
7+
8+
## How it works (high level)
9+
10+
- Scans `drift/instrumentation/<library>/instrumentation.py` (excluding internal dirs).
11+
- Parses each file with Python's AST and finds `super().__init__(...)` calls.
12+
- Extracts `module_name` and `supported_versions` from the call.
13+
- Outputs a JSON manifest matching the Node SDK format.
14+
15+
## Maintainer notes (adding new instrumentations)
16+
17+
- Keep `module_name` and `supported_versions` as string literals in the __init__ call.
18+
- Ensure the library folder has an `instrumentation.py`; that's what this script scans.
19+
- If an instrumentation is internal (e.g., `socket`, `datetime`), add it to INTERNAL_INSTRUMENTATIONS.
20+
21+
Run: python scripts/generate_manifest.py [--dry-run] [--output PATH]
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import argparse
27+
import ast
28+
import json
29+
import sys
30+
from datetime import UTC, datetime
31+
from pathlib import Path
32+
33+
# Script location
34+
SCRIPT_DIR = Path(__file__).parent
35+
PROJECT_ROOT = SCRIPT_DIR.parent
36+
INSTRUMENTATION_DIR = PROJECT_ROOT / "drift" / "instrumentation"
37+
38+
# Internal instrumentations that shouldn't be in the public manifest.
39+
# These patch Python built-ins or provide internal SDK functionality.
40+
INTERNAL_INSTRUMENTATIONS = {
41+
"socket", # Internal TCP-level patching
42+
"datetime", # Internal time mocking
43+
"wsgi", # Base class for framework instrumentations
44+
"http", # Transform engine, not a standalone instrumentation
45+
"e2e_common", # Test infrastructure
46+
}
47+
48+
49+
def get_sdk_version() -> str:
50+
"""Read version from pyproject.toml."""
51+
pyproject_path = PROJECT_ROOT / "pyproject.toml"
52+
53+
with open(pyproject_path) as f:
54+
for line in f:
55+
if line.startswith("version = "):
56+
# Extract version from: version = "0.1.6"
57+
return line.split('"')[1]
58+
59+
raise RuntimeError("Could not find version in pyproject.toml")
60+
61+
62+
def discover_instrumentation_files() -> list[Path]:
63+
"""Discover all instrumentation.py files in the instrumentation directory."""
64+
files: list[Path] = []
65+
66+
for entry in INSTRUMENTATION_DIR.iterdir():
67+
if entry.is_dir() and entry.name not in INTERNAL_INSTRUMENTATIONS:
68+
instrumentation_file = entry / "instrumentation.py"
69+
if instrumentation_file.exists():
70+
files.append(instrumentation_file)
71+
72+
return sorted(files)
73+
74+
75+
def extract_string_from_node(node: ast.expr) -> str | None:
76+
"""Extract a string value from an AST node (handles Constant nodes)."""
77+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
78+
return node.value
79+
return None
80+
81+
82+
def parse_instrumentation_file(file_path: Path) -> dict[str, str] | None:
83+
"""
84+
Parse a Python file and extract module_name and supported_versions
85+
from the InstrumentationBase.__init__() call.
86+
87+
Returns dict with 'module_name' and 'supported_versions', or None if not found.
88+
"""
89+
with open(file_path) as f:
90+
source = f.read()
91+
92+
try:
93+
tree = ast.parse(source, filename=str(file_path))
94+
except SyntaxError as e:
95+
print(f" Warning: Syntax error in {file_path}: {e}", file=sys.stderr)
96+
return None
97+
98+
# Find super().__init__(...) calls
99+
for node in ast.walk(tree):
100+
if not isinstance(node, ast.Call):
101+
continue
102+
103+
# Check if it's super().__init__(...)
104+
if not isinstance(node.func, ast.Attribute):
105+
continue
106+
if node.func.attr != "__init__":
107+
continue
108+
if not isinstance(node.func.value, ast.Call):
109+
continue
110+
func_value = node.func.value
111+
if not isinstance(func_value.func, ast.Name):
112+
continue
113+
if func_value.func.id != "super":
114+
continue
115+
116+
# Found a super().__init__() call, extract keyword arguments
117+
module_name: str | None = None
118+
supported_versions: str | None = None
119+
120+
for keyword in node.keywords:
121+
if keyword.arg == "module_name":
122+
module_name = extract_string_from_node(keyword.value)
123+
elif keyword.arg == "supported_versions":
124+
supported_versions = extract_string_from_node(keyword.value)
125+
126+
if module_name:
127+
return {
128+
"module_name": module_name,
129+
"supported_versions": supported_versions or "*",
130+
}
131+
132+
return None
133+
134+
135+
def main() -> int:
136+
parser = argparse.ArgumentParser(description="Generate instrumentation manifest for the Python SDK")
137+
parser.add_argument(
138+
"--dry-run",
139+
action="store_true",
140+
help="Print manifest to stdout without writing to file",
141+
)
142+
parser.add_argument(
143+
"--output",
144+
type=Path,
145+
default=PROJECT_ROOT / "instrumentation-manifest.json",
146+
help="Output file path (default: instrumentation-manifest.json in project root)",
147+
)
148+
args = parser.parse_args()
149+
150+
print("Discovering instrumentation files...")
151+
files = discover_instrumentation_files()
152+
print(f"Found {len(files)} instrumentation files (excluding internal)")
153+
154+
instrumentations: list[dict[str, str | list[str]]] = []
155+
156+
for file_path in files:
157+
relative_path = file_path.relative_to(INSTRUMENTATION_DIR)
158+
result = parse_instrumentation_file(file_path)
159+
160+
if result is None:
161+
print(f" Warning: No instrumentation found in {relative_path}", file=sys.stderr)
162+
else:
163+
print(f" {relative_path}: {result['module_name']}@{result['supported_versions']}")
164+
instrumentations.append(
165+
{
166+
"packageName": result["module_name"],
167+
"supportedVersions": [result["supported_versions"]],
168+
}
169+
)
170+
171+
# Sort by package name for consistent output
172+
instrumentations.sort(key=lambda x: x["packageName"]) # type: ignore[arg-type, return-value]
173+
174+
sdk_version = get_sdk_version()
175+
176+
manifest = {
177+
"sdkVersion": sdk_version,
178+
"language": "python",
179+
"generatedAt": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
180+
"instrumentations": instrumentations,
181+
}
182+
183+
manifest_json = json.dumps(manifest, indent=2) + "\n"
184+
185+
if args.dry_run:
186+
print("\n--- Manifest (dry run) ---")
187+
print(manifest_json)
188+
else:
189+
args.output.parent.mkdir(parents=True, exist_ok=True)
190+
with open(args.output, "w") as f:
191+
f.write(manifest_json)
192+
print(f"\nGenerated {args.output}")
193+
194+
print(f"SDK version: {sdk_version}")
195+
print(f"Instrumentations: {len(instrumentations)}")
196+
for entry in instrumentations:
197+
versions = entry["supportedVersions"]
198+
print(f" - {entry['packageName']}: {', '.join(versions)}") # type: ignore[arg-type]
199+
200+
return 0
201+
202+
203+
if __name__ == "__main__":
204+
sys.exit(main())

0 commit comments

Comments
 (0)