|
| 1 | +#!/usr/bin/env python |
| 2 | + |
| 3 | +# -------------------------------------------------------------------------------------------- |
| 4 | +# Copyright (c) Microsoft Corporation. All rights reserved. |
| 5 | +# Licensed under the MIT License. See License.txt in the project root for license information. |
| 6 | +# -------------------------------------------------------------------------------------------- |
| 7 | + |
| 8 | +"""Sync shared files from the typespec repo (http-client-python) into this repo. |
| 9 | +
|
| 10 | +The typespec repo is the source of truth for: |
| 11 | + 1. regenerate-common.ts — shared regeneration logic |
| 12 | + 2. requirements.txt — common test dependencies (delimited by marker comments) |
| 13 | + 3. Test files — mock API tests and test data |
| 14 | +
|
| 15 | +Marker convention in requirements.txt: |
| 16 | + # === common azure dependencies across repos === |
| 17 | + ... |
| 18 | + # === end common azure dependencies across repos === |
| 19 | + # === common test dependencies across repos === |
| 20 | + ... |
| 21 | + # === end common test dependencies across repos === |
| 22 | +
|
| 23 | +Usage: |
| 24 | + python sync_from_typespec.py <local-typespec-repo-path> |
| 25 | +""" |
| 26 | + |
| 27 | +import argparse |
| 28 | +import os |
| 29 | +import re |
| 30 | +import shutil |
| 31 | +import subprocess |
| 32 | +import sys |
| 33 | +from pathlib import Path |
| 34 | +from typing import Dict, List, Set |
| 35 | + |
| 36 | +# --- Path configuration (relative to each repo root) --- |
| 37 | + |
| 38 | +TYPESPEC_COMMON_TS = Path("packages/http-client-python/eng/scripts/ci/regenerate-common.ts") |
| 39 | +AUTOREST_COMMON_TS = Path("packages/typespec-python/scripts/eng/regenerate-common.ts") |
| 40 | + |
| 41 | +TYPESPEC_TEST_DIR = Path("packages/http-client-python/generator/test") |
| 42 | +AUTOREST_TEST_DIR = Path("packages/typespec-python/test") |
| 43 | + |
| 44 | +# --- Marker patterns for requirements.txt sync --- |
| 45 | + |
| 46 | +_MARKER_PATTERN = re.compile(r"^# === (common .+ across repos) ===$") |
| 47 | +_END_MARKER_PATTERN = re.compile(r"^# === end (common .+ across repos) ===$") |
| 48 | + |
| 49 | +# --- Test file sync configuration --- |
| 50 | + |
| 51 | +_SKIP_DIRS: Set[str] = {"__pycache__", "generated", ".venv", "node_modules", ".tox"} |
| 52 | + |
| 53 | +_TEST_SUBDIRS = [ |
| 54 | + "generic_mock_api_tests", |
| 55 | + os.path.join("azure", "mock_api_tests"), |
| 56 | + os.path.join("unbranded", "mock_api_tests"), |
| 57 | +] |
| 58 | + |
| 59 | +# Files that remain repo-specific (different relative paths between repo layouts) |
| 60 | +_SKIP_FILES: Set[str] = { |
| 61 | + os.path.join("generic_mock_api_tests", "conftest.py"), |
| 62 | + os.path.join("azure", "mock_api_tests", "conftest.py"), |
| 63 | + os.path.join("unbranded", "mock_api_tests", "conftest.py"), |
| 64 | +} |
| 65 | + |
| 66 | +_SKIP_EXTENSIONS: Set[str] = {".pyc"} |
| 67 | +_SKIP_FILENAMES: Set[str] = {"tox.ini", "requirements.txt", "dev_requirements.txt"} |
| 68 | + |
| 69 | + |
| 70 | +# --------------------------------------------------------------------------- |
| 71 | +# Requirements.txt marker-based sync |
| 72 | +# --------------------------------------------------------------------------- |
| 73 | + |
| 74 | + |
| 75 | +def _extract_marker_sections(filepath: Path) -> Dict[str, List[str]]: |
| 76 | + """Extract content between marker comment pairs from a file. |
| 77 | +
|
| 78 | + Returns a dict mapping marker name to the lines between begin/end markers |
| 79 | + (inclusive of both marker lines). |
| 80 | + """ |
| 81 | + sections: Dict[str, List[str]] = {} |
| 82 | + lines = filepath.read_text(encoding="utf-8").splitlines() |
| 83 | + |
| 84 | + current_marker = None |
| 85 | + current_lines: List[str] = [] |
| 86 | + for line in lines: |
| 87 | + begin_match = _MARKER_PATTERN.match(line.strip()) |
| 88 | + end_match = _END_MARKER_PATTERN.match(line.strip()) |
| 89 | + if begin_match and current_marker is None: |
| 90 | + current_marker = begin_match.group(1) |
| 91 | + current_lines = [line] |
| 92 | + elif end_match and current_marker is not None: |
| 93 | + current_lines.append(line) |
| 94 | + sections[current_marker] = current_lines |
| 95 | + current_marker = None |
| 96 | + current_lines = [] |
| 97 | + elif current_marker is not None: |
| 98 | + current_lines.append(line) |
| 99 | + |
| 100 | + return sections |
| 101 | + |
| 102 | + |
| 103 | +def _replace_marker_sections(filepath: Path, source_sections: Dict[str, List[str]]) -> None: |
| 104 | + """Replace marker sections in a file with content from source_sections.""" |
| 105 | + lines = filepath.read_text(encoding="utf-8").splitlines() |
| 106 | + |
| 107 | + result: List[str] = [] |
| 108 | + current_marker = None |
| 109 | + for line in lines: |
| 110 | + begin_match = _MARKER_PATTERN.match(line.strip()) |
| 111 | + end_match = _END_MARKER_PATTERN.match(line.strip()) |
| 112 | + if begin_match and current_marker is None: |
| 113 | + current_marker = begin_match.group(1) |
| 114 | + if current_marker in source_sections: |
| 115 | + result.extend(source_sections[current_marker]) |
| 116 | + else: |
| 117 | + result.append(line) |
| 118 | + elif end_match and current_marker is not None: |
| 119 | + if current_marker not in source_sections: |
| 120 | + result.append(line) |
| 121 | + current_marker = None |
| 122 | + elif current_marker is None: |
| 123 | + result.append(line) |
| 124 | + |
| 125 | + filepath.write_text("\n".join(result) + "\n", encoding="utf-8", newline="\n") |
| 126 | + |
| 127 | + |
| 128 | +def sync_requirements(source: Path, target: Path) -> None: |
| 129 | + """Sync common marker sections from source to target requirements.txt.""" |
| 130 | + source_sections = _extract_marker_sections(source) |
| 131 | + if not source_sections: |
| 132 | + print(f" WARNING: no marker sections found in {source}, skipping") |
| 133 | + return |
| 134 | + _replace_marker_sections(target, source_sections) |
| 135 | + print(f" Synced requirements: {source.name} ({source.parent.name}/)") |
| 136 | + |
| 137 | + |
| 138 | +# --------------------------------------------------------------------------- |
| 139 | +# Test file sync |
| 140 | +# --------------------------------------------------------------------------- |
| 141 | + |
| 142 | + |
| 143 | +def _should_skip_file(rel_path: str, filename: str) -> bool: |
| 144 | + """Return True if a file should not be synced.""" |
| 145 | + if filename in _SKIP_FILENAMES: |
| 146 | + return True |
| 147 | + if os.path.splitext(filename)[1] in _SKIP_EXTENSIONS: |
| 148 | + return True |
| 149 | + return rel_path in _SKIP_FILES |
| 150 | + |
| 151 | + |
| 152 | +def sync_test_files(source_root: Path, target_root: Path) -> None: |
| 153 | + """Copy test files from typespec to autorest, skipping repo-specific files. |
| 154 | +
|
| 155 | + Only overwrites files that actually differ. Never deletes target-only files. |
| 156 | + """ |
| 157 | + copied = 0 |
| 158 | + skipped = 0 |
| 159 | + |
| 160 | + for subdir in _TEST_SUBDIRS: |
| 161 | + src_dir = source_root / subdir |
| 162 | + if not src_dir.is_dir(): |
| 163 | + print(f" WARNING: {src_dir} not found, skipping") |
| 164 | + continue |
| 165 | + |
| 166 | + for dirpath, dirnames, filenames in os.walk(src_dir): |
| 167 | + dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS] |
| 168 | + |
| 169 | + for filename in filenames: |
| 170 | + src_file = Path(dirpath) / filename |
| 171 | + rel_from_root = src_file.relative_to(source_root).as_posix() |
| 172 | + rel_native = str(src_file.relative_to(source_root)) |
| 173 | + |
| 174 | + if _should_skip_file(rel_native, filename): |
| 175 | + skipped += 1 |
| 176 | + continue |
| 177 | + |
| 178 | + dst_file = target_root / rel_from_root |
| 179 | + dst_file.parent.mkdir(parents=True, exist_ok=True) |
| 180 | + |
| 181 | + if dst_file.is_file() and src_file.read_bytes() == dst_file.read_bytes(): |
| 182 | + continue |
| 183 | + |
| 184 | + shutil.copy2(src_file, dst_file) |
| 185 | + copied += 1 |
| 186 | + print(f" Copied: {rel_from_root}") |
| 187 | + |
| 188 | + print(f" Test files: {copied} copied, {skipped} skipped") |
| 189 | + |
| 190 | + |
| 191 | +# --------------------------------------------------------------------------- |
| 192 | +# Main |
| 193 | +# --------------------------------------------------------------------------- |
| 194 | + |
| 195 | + |
| 196 | +def main() -> int: |
| 197 | + parser = argparse.ArgumentParser( |
| 198 | + description="Sync shared files from the typespec repo (http-client-python) into autorest.python.", |
| 199 | + ) |
| 200 | + parser.add_argument( |
| 201 | + "typespec_repo", |
| 202 | + type=Path, |
| 203 | + help="Path to the local typespec repo root (e.g. C:/dev/typespec)", |
| 204 | + ) |
| 205 | + args = parser.parse_args() |
| 206 | + |
| 207 | + typespec_repo: Path = args.typespec_repo.resolve() |
| 208 | + autorest_repo: Path = Path(__file__).resolve().parents[2] # eng/scripts/.. -> repo root |
| 209 | + |
| 210 | + if not typespec_repo.is_dir(): |
| 211 | + print(f"ERROR: typespec repo not found: {typespec_repo}", file=sys.stderr) |
| 212 | + return 1 |
| 213 | + |
| 214 | + # 1. Sync regenerate-common.ts |
| 215 | + src_ts = typespec_repo / TYPESPEC_COMMON_TS |
| 216 | + dst_ts = autorest_repo / AUTOREST_COMMON_TS |
| 217 | + if not src_ts.is_file(): |
| 218 | + print(f"ERROR: {src_ts} not found", file=sys.stderr) |
| 219 | + return 1 |
| 220 | + shutil.copy2(src_ts, dst_ts) |
| 221 | + print(f"Synced regenerate-common.ts") |
| 222 | + |
| 223 | + # 2. Sync requirements.txt marker sections |
| 224 | + for flavor in ("azure", "unbranded"): |
| 225 | + src_req = typespec_repo / TYPESPEC_TEST_DIR / flavor / "requirements.txt" |
| 226 | + dst_req = autorest_repo / AUTOREST_TEST_DIR / flavor / "requirements.txt" |
| 227 | + if src_req.is_file() and dst_req.is_file(): |
| 228 | + sync_requirements(src_req, dst_req) |
| 229 | + else: |
| 230 | + print(f" WARNING: requirements.txt not found for {flavor}, skipping") |
| 231 | + |
| 232 | + # 3. Sync test files |
| 233 | + print("Syncing test files...") |
| 234 | + sync_test_files( |
| 235 | + typespec_repo / TYPESPEC_TEST_DIR, |
| 236 | + autorest_repo / AUTOREST_TEST_DIR, |
| 237 | + ) |
| 238 | + |
| 239 | + # 4. Format TypeScript files |
| 240 | + ts_python_dir = autorest_repo / "packages" / "typespec-python" |
| 241 | + print("Running pnpm format...") |
| 242 | + result = subprocess.run( |
| 243 | + ["pnpm", "format"], |
| 244 | + cwd=ts_python_dir, |
| 245 | + capture_output=True, |
| 246 | + text=True, |
| 247 | + shell=(os.name == "nt"), |
| 248 | + ) |
| 249 | + if result.returncode != 0: |
| 250 | + print(f"WARNING: pnpm format failed:\n{result.stderr}", file=sys.stderr) |
| 251 | + else: |
| 252 | + print("pnpm format succeeded.") |
| 253 | + |
| 254 | + return 0 |
| 255 | + |
| 256 | + |
| 257 | +if __name__ == "__main__": |
| 258 | + sys.exit(main()) |
0 commit comments