Skip to content

Commit d53652b

Browse files
authored
Refactor: extract shared regeneration logic into regenerate-common.ts (#3340)
* Refactor: extract shared regeneration logic into regenerate-common.ts Extract shared constants, interfaces, and utility functions from regenerate.ts into a new regenerate-common.ts module to enable code reuse across repos. Extracted: - SKIP_SPECS, SpecialFlags, BASE_AZURE_EMITTER_OPTIONS, BASE_EMITTER_OPTIONS - TspCommand, RegenerateFlagsInput, RegenerateFlags, ProcessedEmitterOption, RegenerateConfig interfaces - toPosix, getEmitterOption, getSubdirectories, defaultPackageName, buildOptions, runTaskPool, regenerate utility functions regenerate.ts now imports from regenerate-common.ts and only contains repo-specific configuration and overrides. * Add changelog entry * fix lint * Organize requirements.txt with shared section markers and sorting * Add test files and minor test fixes - Add test_model_base_flatten_compatibility.py for flatten model tests - Add test_typetest_union_discriminated.py for discriminated union tests - Fix 'cadl-ranch' -> 'spector' in flatten test comments - Add cspell ignore directives for property test files - Fix import ordering and blank lines in test files * Make generation-subdir test assertions conditional generation-subdir is autorest-only, so guard its assertions with existence checks to avoid failures in shared test runs. * Add sync_from_typespec.py script for syncing shared files Script syncs regenerate-common.ts, requirements.txt marker sections, and test files from the typespec repo into autorest.python.
1 parent 390b917 commit d53652b

21 files changed

Lines changed: 1344 additions & 451 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: internal
3+
packages:
4+
- "@azure-tools/typespec-python"
5+
---
6+
7+
Extract shared regeneration logic into `regenerate-common.ts` for code reuse across repos

eng/scripts/sync_from_typespec.py

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
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

Comments
 (0)