@typespec/http-client-python 0.28.3 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/emitter/code-model.d.ts.map +1 -1
  2. package/dist/emitter/code-model.js +1 -0
  3. package/dist/emitter/code-model.js.map +1 -1
  4. package/dist/emitter/constants.d.ts +6 -0
  5. package/dist/emitter/constants.d.ts.map +1 -0
  6. package/dist/emitter/constants.js +27 -0
  7. package/dist/emitter/constants.js.map +1 -0
  8. package/dist/emitter/emitter.d.ts.map +1 -1
  9. package/dist/emitter/emitter.js +169 -94
  10. package/dist/emitter/emitter.js.map +1 -1
  11. package/dist/emitter/http.d.ts.map +1 -1
  12. package/dist/emitter/http.js +33 -1
  13. package/dist/emitter/http.js.map +1 -1
  14. package/dist/emitter/lib.d.ts +33 -2
  15. package/dist/emitter/lib.d.ts.map +1 -1
  16. package/dist/emitter/lib.js +17 -0
  17. package/dist/emitter/lib.js.map +1 -1
  18. package/dist/emitter/run-python3.js +1 -1
  19. package/dist/emitter/run-python3.js.map +1 -1
  20. package/dist/emitter/types.d.ts.map +1 -1
  21. package/dist/emitter/types.js +31 -5
  22. package/dist/emitter/types.js.map +1 -1
  23. package/emitter/src/code-model.ts +1 -0
  24. package/emitter/src/constants.ts +27 -0
  25. package/emitter/src/emitter.ts +216 -96
  26. package/emitter/src/http.ts +29 -1
  27. package/emitter/src/lib.ts +20 -0
  28. package/emitter/src/run-python3.ts +1 -1
  29. package/emitter/src/types.ts +35 -5
  30. package/emitter/temp/tsconfig.tsbuildinfo +1 -1
  31. package/emitter/test/utils.test.ts +3 -0
  32. package/emitter/tsconfig.build.json +2 -1
  33. package/emitter/tsconfig.json +2 -1
  34. package/eng/scripts/ci/config/eslint-ci.config.mjs +10 -0
  35. package/eng/scripts/ci/config/mypy.ini +1 -1
  36. package/eng/scripts/ci/config/pylintrc +1 -1
  37. package/eng/scripts/ci/config/pyrightconfig.json +1 -1
  38. package/eng/scripts/ci/dev_requirements.txt +1 -1
  39. package/eng/scripts/ci/lint.ts +11 -4
  40. package/eng/scripts/ci/regenerate-common.ts +407 -136
  41. package/eng/scripts/ci/regenerate.ts +103 -194
  42. package/eng/scripts/ci/run-tests.ts +153 -48
  43. package/eng/scripts/ci/run_apiview.py +22 -16
  44. package/eng/scripts/ci/run_mypy.py +28 -18
  45. package/eng/scripts/ci/run_pylint.py +23 -16
  46. package/eng/scripts/ci/run_pyright.py +5 -2
  47. package/eng/scripts/ci/run_sphinx_build.py +24 -9
  48. package/eng/scripts/ci/util.py +61 -25
  49. package/eng/scripts/setup/__pycache__/package_manager.cpython-311.pyc +0 -0
  50. package/eng/scripts/setup/__pycache__/venvtools.cpython-311.pyc +0 -0
  51. package/eng/scripts/setup/build_pygen_wheel.py +3 -3
  52. package/eng/scripts/setup/install.py +3 -3
  53. package/eng/scripts/setup/install.ts +1 -1
  54. package/eng/scripts/setup/prepare.py +2 -2
  55. package/eng/scripts/setup/run-python3.ts +1 -1
  56. package/eng/scripts/setup/run_batch.py +197 -0
  57. package/eng/scripts/setup/venvtools.py +0 -1
  58. package/generator/build/lib/pygen/__init__.py +1 -3
  59. package/generator/build/lib/pygen/codegen/__init__.py +0 -1
  60. package/generator/build/lib/pygen/codegen/models/base.py +0 -1
  61. package/generator/build/lib/pygen/codegen/models/client.py +2 -1
  62. package/generator/build/lib/pygen/codegen/models/code_model.py +1 -0
  63. package/generator/build/lib/pygen/codegen/models/enum_type.py +0 -1
  64. package/generator/build/lib/pygen/codegen/models/model_type.py +1 -7
  65. package/generator/build/lib/pygen/codegen/models/parameter.py +16 -14
  66. package/generator/build/lib/pygen/codegen/serializers/__init__.py +27 -12
  67. package/generator/build/lib/pygen/codegen/serializers/builder_serializer.py +12 -3
  68. package/generator/build/lib/pygen/codegen/serializers/general_serializer.py +6 -2
  69. package/generator/build/lib/pygen/codegen/serializers/import_serializer.py +17 -4
  70. package/generator/build/lib/pygen/codegen/serializers/parameter_serializer.py +17 -11
  71. package/generator/build/lib/pygen/codegen/templates/model_base.py.jinja2 +19 -7
  72. package/generator/build/lib/pygen/codegen/templates/packaging_templates/README.md.jinja2 +11 -3
  73. package/generator/build/lib/pygen/codegen/templates/packaging_templates/pyproject.toml.jinja2 +1 -1
  74. package/generator/build/lib/pygen/codegen/templates/serialization.py.jinja2 +5 -1
  75. package/generator/build/lib/pygen/preprocess/__init__.py +37 -30
  76. package/generator/component-detection-pip-report.json +7 -7
  77. package/generator/dist/pygen-0.1.0-py3-none-any.whl +0 -0
  78. package/generator/pygen/__init__.py +1 -3
  79. package/generator/pygen/codegen/__init__.py +0 -1
  80. package/generator/pygen/codegen/models/base.py +0 -1
  81. package/generator/pygen/codegen/models/client.py +2 -1
  82. package/generator/pygen/codegen/models/code_model.py +1 -0
  83. package/generator/pygen/codegen/models/enum_type.py +0 -1
  84. package/generator/pygen/codegen/models/model_type.py +1 -7
  85. package/generator/pygen/codegen/models/parameter.py +16 -14
  86. package/generator/pygen/codegen/serializers/__init__.py +27 -12
  87. package/generator/pygen/codegen/serializers/builder_serializer.py +12 -3
  88. package/generator/pygen/codegen/serializers/general_serializer.py +6 -2
  89. package/generator/pygen/codegen/serializers/import_serializer.py +17 -4
  90. package/generator/pygen/codegen/serializers/parameter_serializer.py +17 -11
  91. package/generator/pygen/codegen/templates/model_base.py.jinja2 +19 -7
  92. package/generator/pygen/codegen/templates/packaging_templates/README.md.jinja2 +11 -3
  93. package/generator/pygen/codegen/templates/packaging_templates/pyproject.toml.jinja2 +1 -1
  94. package/generator/pygen/codegen/templates/serialization.py.jinja2 +5 -1
  95. package/generator/pygen/preprocess/__init__.py +37 -30
  96. package/generator/pygen.egg-info/PKG-INFO +1 -2
  97. package/generator/pygen.egg-info/requires.txt +1 -1
  98. package/generator/setup.py +1 -2
  99. package/package.json +24 -9
@@ -2,24 +2,32 @@
2
2
  /**
3
3
  * Regenerates Python SDK code from TypeSpec definitions.
4
4
  *
5
- * Uses in-process TypeSpec compilation to avoid subprocess spawning overhead.
6
- * This is significantly faster than spawning `tsp compile` for each spec.
5
+ * Two-phase pipeline:
6
+ * 1. TypeSpec compile (in-process, parallel) -> emits per-spec YAML only.
7
+ * 2. Single batched Python subprocess reads all YAMLs and writes the
8
+ * final `.py` files. Amortizes Python-startup cost across many specs.
9
+ *
10
+ * Shared helpers/data live in `regenerate-common.ts` (kept identical with the
11
+ * `@azure-tools/typespec-python` wrapper copy).
7
12
  */
8
13
 
9
- import { compile, NodeHost } from "@typespec/compiler";
10
- import { promises, rmSync } from "fs";
14
+ import { execSync } from "child_process";
15
+ import { existsSync } from "fs";
16
+ import { access, readdir } from "fs/promises";
11
17
  import { platform } from "os";
12
- import { dirname, join, relative, resolve } from "path";
18
+ import { dirname, join, resolve } from "path";
13
19
  import pc from "picocolors";
14
20
  import { fileURLToPath } from "url";
15
21
  import { parseArgs } from "util";
22
+
16
23
  import {
17
- BASE_AZURE_EMITTER_OPTIONS,
18
- BASE_EMITTER_OPTIONS,
24
+ buildTaskGroups,
19
25
  getSubdirectories,
20
- SpecialFlags,
21
- toPosix,
22
- type RegenerateFlags,
26
+ prepareBaselineOfGeneratedCode,
27
+ preprocess,
28
+ RegenerateContext,
29
+ RegenerateFlags,
30
+ runParallel,
23
31
  } from "./regenerate-common.js";
24
32
 
25
33
  // Parse arguments
@@ -93,193 +101,66 @@ const GENERATED_FOLDER = argv.values.generatedFolder
93
101
  : resolve(PLUGIN_DIR, "generator");
94
102
  const EMITTER_NAME = argv.values.emitterName || "@typespec/http-client-python";
95
103
 
96
- // Emitter options
97
- const AZURE_EMITTER_OPTIONS: Record<string, Record<string, string> | Record<string, string>[]> = {
98
- ...BASE_AZURE_EMITTER_OPTIONS,
99
- "client/structure/client-operation-group": {
100
- "package-name": "client-structure-clientoperationgroup",
101
- namespace: "client.structure.clientoperationgroup",
102
- },
103
- };
104
-
105
- const EMITTER_OPTIONS: Record<string, Record<string, string> | Record<string, string>[]> = {
106
- ...BASE_EMITTER_OPTIONS,
107
- "type/array": {
108
- "package-name": "typetest-array",
109
- namespace: "typetest.array",
110
- },
111
- "type/model/inheritance/recursive": {
112
- "package-name": "typetest-model-recursive",
113
- namespace: "typetest.model.recursive",
114
- },
104
+ const ctx: RegenerateContext = {
105
+ pluginDir: PLUGIN_DIR,
106
+ azureHttpSpecs: AZURE_HTTP_SPECS,
107
+ httpSpecs: HTTP_SPECS,
108
+ generatedFolder: GENERATED_FOLDER,
109
+ emitterName: EMITTER_NAME,
115
110
  };
116
111
 
117
- interface CompileTask {
118
- spec: string;
119
- outputDir: string;
120
- options: Record<string, unknown>;
121
- }
122
-
123
- // Group of tasks for the same spec that must run sequentially
124
- interface TaskGroup {
125
- spec: string;
126
- tasks: CompileTask[];
127
- }
128
-
129
- function defaultPackageName(spec: string): string {
130
- const specDir = spec.includes("azure") ? AZURE_HTTP_SPECS : HTTP_SPECS;
131
- return toPosix(relative(specDir, dirname(spec)))
132
- .replace(/\//g, "-")
133
- .toLowerCase();
134
- }
135
-
136
- function getEmitterOptions(spec: string, flavor: string): Record<string, string>[] {
137
- const specDir = spec.includes("azure") ? AZURE_HTTP_SPECS : HTTP_SPECS;
138
- const relativeSpec = toPosix(relative(specDir, spec));
139
- const key = relativeSpec.includes("resiliency/srv-driven/old.tsp")
140
- ? relativeSpec
141
- : dirname(relativeSpec);
142
- const emitterOpts = EMITTER_OPTIONS[key] ||
143
- (flavor === "azure" ? AZURE_EMITTER_OPTIONS[key] : [{}]) || [{}];
144
- return Array.isArray(emitterOpts) ? emitterOpts : [emitterOpts];
145
- }
146
-
147
- function buildTaskGroups(specs: string[], flags: RegenerateFlags): TaskGroup[] {
148
- const groups: TaskGroup[] = [];
149
-
150
- for (const spec of specs) {
151
- const tasks: CompileTask[] = [];
152
-
153
- for (const emitterConfig of getEmitterOptions(spec, flags.flavor)) {
154
- const options: Record<string, unknown> = { ...emitterConfig };
155
-
156
- // Add flavor-specific options
157
- options["flavor"] = flags.flavor;
158
- for (const [k, v] of Object.entries(SpecialFlags[flags.flavor] ?? {})) {
159
- options[k] = v;
160
- }
161
-
162
- // Set output directory - use tests/generated/<flavor>/<package> structure
163
- const packageName = (options["package-name"] as string) || defaultPackageName(spec);
164
- const outputDir =
165
- (options["emitter-output-dir"] as string) ||
166
- toPosix(`${GENERATED_FOLDER}/../tests/generated/${flags.flavor}/${packageName}`);
167
- options["emitter-output-dir"] = outputDir;
168
-
169
- // Debug mode
170
- if (flags.debug) {
171
- options["debug"] = true;
172
- }
173
-
174
- // Examples directory
175
- options["examples-dir"] = toPosix(join(dirname(spec), "examples"));
176
-
177
- tasks.push({ spec, outputDir, options });
178
- }
179
-
180
- groups.push({ spec, tasks });
181
- }
182
-
183
- return groups;
184
- }
185
-
186
- async function compileSpec(task: CompileTask): Promise<{ success: boolean; error?: string }> {
187
- const { spec, outputDir, options } = task;
188
-
112
+ async function collectConfigFiles(generatedDir: string, flavor: string): Promise<string[]> {
113
+ const flavorDir = join(generatedDir, "..", "tests", "generated", flavor);
189
114
  try {
190
- // Build compiler options
191
- const compilerOptions = {
192
- emit: [PLUGIN_DIR],
193
- options: {
194
- [EMITTER_NAME]: options,
195
- },
196
- };
197
-
198
- // Compile using TypeSpec compiler directly (no subprocess)
199
- const program = await compile(NodeHost, spec, compilerOptions);
200
-
201
- if (program.hasError()) {
202
- const errors = program.diagnostics
203
- .filter((d) => d.severity === "error")
204
- .map((d) => d.message)
205
- .join("\n");
206
- return { success: false, error: errors };
207
- }
208
-
209
- return { success: true };
210
- } catch (err) {
211
- // Clean up on error
212
- rmSync(outputDir, { recursive: true, force: true });
213
- return { success: false, error: String(err) };
115
+ await access(flavorDir);
116
+ } catch {
117
+ return [];
214
118
  }
215
- }
216
-
217
- async function runParallel(groups: TaskGroup[], maxJobs: number): Promise<Map<string, boolean>> {
218
- const results = new Map<string, boolean>();
219
- const executing: Set<Promise<void>> = new Set();
220
119
 
221
- // Count total tasks for progress
222
- const totalTasks = groups.reduce((sum, g) => sum + g.tasks.length, 0);
223
- let completed = 0;
224
-
225
- for (const group of groups) {
226
- // Each group runs as a unit - tasks within a group run sequentially
227
- // But different groups can run in parallel
228
- const runGroup = async () => {
229
- const specDir = group.spec.includes("azure") ? AZURE_HTTP_SPECS : HTTP_SPECS;
230
- const shortName = toPosix(relative(specDir, dirname(group.spec)));
231
-
232
- // Run all tasks in this group sequentially to avoid state pollution
233
- let groupSuccess = true;
234
- for (const task of group.tasks) {
235
- const packageName = (task.options["package-name"] as string) || shortName;
236
- console.log(pc.blue(`[${completed + 1}/${totalTasks}] Compiling ${packageName}...`));
237
-
238
- const result = await compileSpec(task);
239
- completed++;
240
-
241
- if (result.success) {
242
- console.log(pc.green(`[${completed}/${totalTasks}] ${packageName} succeeded`));
243
- } else {
244
- console.log(
245
- pc.red(`[${completed}/${totalTasks}] ${packageName} failed: ${result.error}`),
246
- );
247
- groupSuccess = false;
120
+ const configFiles: string[] = [];
121
+ for (const pkg of await readdir(flavorDir, { withFileTypes: true })) {
122
+ if (pkg.isDirectory()) {
123
+ const pkgDir = join(flavorDir, pkg.name);
124
+ for (const file of await readdir(pkgDir)) {
125
+ if (file.startsWith(".tsp-codegen-") && file.endsWith(".json")) {
126
+ configFiles.push(join(pkgDir, file));
248
127
  }
249
128
  }
129
+ }
130
+ }
131
+ return configFiles;
132
+ }
250
133
 
251
- results.set(group.spec, groupSuccess);
252
- };
134
+ function runBatchPythonProcessing(flavor: string, configCount: number, jobs: number): boolean {
135
+ if (configCount === 0) return true;
253
136
 
254
- const p = runGroup().finally(() => executing.delete(p));
255
- executing.add(p);
137
+ console.log(pc.cyan(`\nRunning batch Python processing on ${configCount} specs...`));
256
138
 
257
- if (executing.size >= maxJobs) {
258
- await Promise.race(executing);
259
- }
139
+ // Find Python venv
140
+ let venvPath = join(PLUGIN_DIR, "venv");
141
+ if (existsSync(join(venvPath, "bin"))) {
142
+ venvPath = join(venvPath, "bin", "python");
143
+ } else if (existsSync(join(venvPath, "Scripts"))) {
144
+ venvPath = join(venvPath, "Scripts", "python.exe");
145
+ } else {
146
+ console.error(pc.red("Python venv not found"));
147
+ return false;
260
148
  }
261
149
 
262
- await Promise.all(executing);
263
- return results;
264
- }
150
+ const batchScript = join(PLUGIN_DIR, "eng", "scripts", "setup", "run_batch.py");
265
151
 
266
- // Preprocess: create files that should be deleted after regeneration (for testing)
267
- async function preprocess(flavor: string): Promise<void> {
268
- if (flavor === "azure") {
269
- // Use tests/generated/<flavor>/<package> structure (same as output)
270
- const testsGeneratedDir = resolve(GENERATED_FOLDER, "../tests/generated");
271
- const folderParts = [
272
- "azure",
273
- "authentication-api-key",
274
- "authentication",
275
- "apikey",
276
- "_operations",
277
- ];
278
- await promises.mkdir(join(testsGeneratedDir, ...folderParts), { recursive: true });
279
- await promises.writeFile(
280
- join(testsGeneratedDir, ...folderParts, "to_be_deleted.py"),
281
- "# This file is to be deleted after regeneration",
152
+ try {
153
+ // Pass directory and flavor instead of individual config files to avoid command line length limits on Windows
154
+ execSync(
155
+ `"${venvPath}" "${batchScript}" --generated-dir "${PLUGIN_DIR}" --flavor ${flavor} --jobs ${jobs}`,
156
+ {
157
+ stdio: "inherit",
158
+ cwd: PLUGIN_DIR,
159
+ },
282
160
  );
161
+ return true;
162
+ } catch {
163
+ return false;
283
164
  }
284
165
  }
285
166
 
@@ -296,35 +177,61 @@ async function regenerateFlavor(
296
177
  const flags: RegenerateFlags = { flavor, debug, name };
297
178
 
298
179
  // Preprocess
299
- await preprocess(flavor);
180
+ await preprocess(flavor, GENERATED_FOLDER);
300
181
 
301
182
  // Collect specs
302
183
  const azureSpecs = flavor === "azure" ? await getSubdirectories(AZURE_HTTP_SPECS, flags) : [];
303
184
  const standardSpecs = await getSubdirectories(HTTP_SPECS, flags);
304
185
  const allSpecs = [...azureSpecs, ...standardSpecs];
305
186
 
306
- // Build task groups (tasks for same spec run sequentially to avoid state pollution)
307
- const groups = buildTaskGroups(allSpecs, flags);
187
+ // Build task groups (tasks for same spec run sequentially to avoid state pollution).
188
+ // emitYamlOnly: true -> phase 1 emits YAML only; phase 2 (runBatchPythonProcessing) writes .py files.
189
+ const groups = buildTaskGroups(allSpecs, flags, ctx, { emitYamlOnly: true });
308
190
  const totalTasks = groups.reduce((sum, g) => sum + g.tasks.length, 0);
309
191
 
310
192
  console.log(pc.cyan(`Found ${allSpecs.length} specs (${totalTasks} total tasks) to compile`));
311
193
  console.log(pc.cyan(`Using ${jobs} parallel jobs\n`));
312
194
 
313
- // Run compilation
195
+ // Run compilation (emits YAML only)
314
196
  const startTime = performance.now();
315
- const results = await runParallel(groups, jobs);
316
- const duration = (performance.now() - startTime) / 1000;
197
+ const results = await runParallel(groups, jobs, ctx);
198
+ const compileTime = (performance.now() - startTime) / 1000;
317
199
 
318
- // Summary
200
+ // Summary for TypeSpec compilation
319
201
  const succeeded = Array.from(results.values()).filter((v) => v).length;
320
- const failed = results.size - succeeded;
202
+ const compileFailed = results.size - succeeded;
203
+
204
+ console.log(
205
+ pc.cyan(
206
+ `\nTypeSpec compilation: ${succeeded} succeeded, ${compileFailed} failed (${compileTime.toFixed(1)}s)`,
207
+ ),
208
+ );
209
+
210
+ if (compileFailed > 0) {
211
+ console.log(pc.red(`Skipping Python processing due to compilation failures`));
212
+ return false;
213
+ }
214
+
215
+ // Batch process all specs with Python
216
+ const pyStartTime = performance.now();
217
+ const configCount = (await collectConfigFiles(GENERATED_FOLDER, flavor)).length;
218
+ // Use fewer Python jobs since Python processing is heavier
219
+ const pyJobs = Math.max(4, jobs);
220
+ const pySuccess = runBatchPythonProcessing(flavor, configCount, pyJobs);
221
+ const pyTime = (performance.now() - pyStartTime) / 1000;
222
+
223
+ const totalTime = (performance.now() - startTime) / 1000;
321
224
 
322
225
  console.log(pc.cyan(`\n${"=".repeat(60)}`));
323
- console.log(pc.cyan(`Results: ${succeeded} succeeded, ${failed} failed`));
324
- console.log(pc.cyan(`Time: ${duration.toFixed(1)}s`));
226
+ console.log(pc.cyan(`Results: ${succeeded} specs processed`));
227
+ console.log(
228
+ pc.cyan(
229
+ ` TypeSpec: ${compileTime.toFixed(1)}s | Python: ${pyTime.toFixed(1)}s | Total: ${totalTime.toFixed(1)}s`,
230
+ ),
231
+ );
325
232
  console.log(pc.cyan(`${"=".repeat(60)}\n`));
326
233
 
327
- return failed === 0;
234
+ return pySuccess;
328
235
  }
329
236
 
330
237
  async function main() {
@@ -349,6 +256,8 @@ async function main() {
349
256
  const startTime = performance.now();
350
257
  let success: boolean;
351
258
 
259
+ await prepareBaselineOfGeneratedCode(GENERATED_FOLDER);
260
+
352
261
  if (flavor) {
353
262
  success = await regenerateFlavor(flavor, name, debug, jobs);
354
263
  } else {
@@ -35,7 +35,7 @@ Options:
35
35
  -f, --flavor <azure|unbranded> SDK flavor to test (only applies to --generator)
36
36
  If not specified, tests both flavors
37
37
  --env <env1,env2,...> Specific tox environments to run
38
- Available: test, lint, mypy, pyright, docs, ci, unittest
38
+ Available: test, lint, mypy, pyright, apiview, sphinx, docs, ci, unittest
39
39
  -j, --jobs <n> Number of parallel jobs (default: CPU cores - 2)
40
40
  -n, --name <pattern> Filter tests by name pattern
41
41
  -q, --quiet Suppress test output (only show pass/fail summary)
@@ -46,8 +46,10 @@ Environments (for --generator):
46
46
  lint Run pylint on generated packages
47
47
  mypy Run mypy type checking on generated packages
48
48
  pyright Run pyright type checking on generated packages
49
- docs Run documentation validation (apiview, sphinx)
50
- ci Run all checks (test + lint + mypy + pyright)
49
+ apiview Run apiview validation on generated packages
50
+ sphinx Run sphinx docstring validation on generated packages
51
+ docs Run apiview + sphinx (split into parallel envs)
52
+ ci Run all checks (test + lint + mypy + pyright + apiview + sphinx)
51
53
  unittest Run unit tests for pygen internals
52
54
 
53
55
  Examples:
@@ -56,8 +58,8 @@ Examples:
56
58
  run-tests.ts --generator # Run generator tests for all flavors
57
59
  run-tests.ts --generator --flavor=azure # Run generator tests for azure only
58
60
  run-tests.ts -g -f azure --env=test # Run pytest for azure only
59
- run-tests.ts -g --env=mypy # Run mypy for all flavors
60
- run-tests.ts -g -f unbranded --env=lint # Run pylint for unbranded only
61
+ run-tests.ts -g --env=lint # Run pylint for all flavors
62
+ run-tests.ts -g -f unbranded --env=mypy # Run mypy for unbranded only
61
63
  `);
62
64
  process.exit(0);
63
65
  }
@@ -78,9 +80,39 @@ interface ToxResult {
78
80
  success: boolean;
79
81
  duration: number;
80
82
  error?: string;
83
+ failedTests?: string[];
81
84
  }
82
85
 
83
- async function runToxEnv(env: string, pythonPath: string, name?: string): Promise<ToxResult> {
86
+ interface RunningTask {
87
+ env: string;
88
+ proc: ChildProcess;
89
+ promise: Promise<ToxResult>;
90
+ }
91
+
92
+ function extractFailedTests(output: string): string[] {
93
+ const lines = output.split("\n");
94
+ const failedTests: string[] = [];
95
+ let inSummary = false;
96
+
97
+ for (const line of lines) {
98
+ if (line.includes("short test summary info")) {
99
+ inSummary = true;
100
+ continue;
101
+ }
102
+ if (inSummary) {
103
+ const match = line.match(/^FAILED\s+(.+)/);
104
+ if (match) {
105
+ failedTests.push(match[1].trim());
106
+ } else if (line.match(/^={3,}/) || line.match(/^\d+ failed/)) {
107
+ break;
108
+ }
109
+ }
110
+ }
111
+
112
+ return failedTests;
113
+ }
114
+
115
+ function startToxEnv(env: string, pythonPath: string, name?: string): RunningTask {
84
116
  const startTime = Date.now();
85
117
  const toxIniPath = join(testsDir, "tox.ini");
86
118
 
@@ -92,20 +124,34 @@ async function runToxEnv(env: string, pythonPath: string, name?: string): Promis
92
124
 
93
125
  console.log(`${pc.blue("[START]")} ${env}`);
94
126
 
95
- return new Promise((resolve) => {
96
- const proc: ChildProcess = spawn(pythonPath, args, {
97
- cwd: testsDir,
98
- stdio: !argv.values.quiet ? "inherit" : "pipe",
99
- env: { ...process.env, FOLDER: env.split("-")[1] || "azure" },
100
- });
127
+ const proc: ChildProcess = spawn(pythonPath, args, {
128
+ cwd: testsDir,
129
+ stdio: "pipe",
130
+ env: { ...process.env, FOLDER: env.split("-")[1] || "azure" },
131
+ });
101
132
 
102
- let stderr = "";
103
- if (argv.values.quiet && proc.stderr) {
104
- proc.stderr.on("data", (data) => {
105
- stderr += data.toString();
106
- });
107
- }
133
+ let stderr = "";
134
+ let stdout = "";
135
+ if (proc.stdout) {
136
+ proc.stdout.on("data", (data) => {
137
+ const chunk = data.toString();
138
+ stdout += chunk;
139
+ if (!argv.values.quiet) {
140
+ process.stdout.write(chunk);
141
+ }
142
+ });
143
+ }
144
+ if (proc.stderr) {
145
+ proc.stderr.on("data", (data) => {
146
+ const chunk = data.toString();
147
+ stderr += chunk;
148
+ if (!argv.values.quiet) {
149
+ process.stderr.write(chunk);
150
+ }
151
+ });
152
+ }
108
153
 
154
+ const promise = new Promise<ToxResult>((resolve) => {
109
155
  proc.on("close", (code) => {
110
156
  const duration = (Date.now() - startTime) / 1000;
111
157
  const success = code === 0;
@@ -116,11 +162,14 @@ async function runToxEnv(env: string, pythonPath: string, name?: string): Promis
116
162
  console.log(`${pc.red("[FAIL]")} ${env} (${duration.toFixed(1)}s)`);
117
163
  }
118
164
 
165
+ const failedTests = extractFailedTests(stdout);
166
+
119
167
  resolve({
120
168
  env,
121
169
  success,
122
170
  duration,
123
171
  error: success ? undefined : stderr || `Exit code: ${code}`,
172
+ failedTests: failedTests.length > 0 ? failedTests : undefined,
124
173
  });
125
174
  });
126
175
 
@@ -135,6 +184,16 @@ async function runToxEnv(env: string, pythonPath: string, name?: string): Promis
135
184
  });
136
185
  });
137
186
  });
187
+
188
+ return { env, proc, promise };
189
+ }
190
+
191
+ function killTask(task: RunningTask): void {
192
+ try {
193
+ task.proc.kill("SIGTERM");
194
+ } catch {
195
+ // Process may have already exited
196
+ }
138
197
  }
139
198
 
140
199
  async function runParallel(
@@ -144,24 +203,51 @@ async function runParallel(
144
203
  name?: string,
145
204
  ): Promise<ToxResult[]> {
146
205
  const results: ToxResult[] = [];
147
- const running: Map<string, Promise<ToxResult>> = new Map();
206
+ const running: Map<string, RunningTask> = new Map();
148
207
 
149
208
  for (const env of envs) {
150
209
  // Wait if we're at max capacity
151
- if (running.size >= maxJobs) {
152
- const completed = await Promise.race(running.values());
210
+ while (running.size >= maxJobs) {
211
+ const promises = Array.from(running.values()).map((t) => t.promise);
212
+ const completed = await Promise.race(promises);
153
213
  results.push(completed);
154
214
  running.delete(completed.env);
215
+
216
+ // Fail-fast: kill all running tasks and exit
217
+ if (!completed.success) {
218
+ console.log(pc.red(`\n[FAIL-FAST] ${completed.env} failed, killing remaining tasks...`));
219
+ for (const task of running.values()) {
220
+ killTask(task);
221
+ }
222
+ // Wait briefly for processes to terminate
223
+ await Promise.all(Array.from(running.values()).map((t) => t.promise));
224
+ return results;
225
+ }
155
226
  }
156
227
 
157
228
  // Start new task
158
- const task = runToxEnv(env, pythonPath, name);
229
+ const task = startToxEnv(env, pythonPath, name);
159
230
  running.set(env, task);
160
231
  }
161
232
 
162
- // Wait for remaining tasks
163
- const remaining = await Promise.all(running.values());
164
- results.push(...remaining);
233
+ // Wait for remaining tasks, checking for failures
234
+ while (running.size > 0) {
235
+ const promises = Array.from(running.values()).map((t) => t.promise);
236
+ const completed = await Promise.race(promises);
237
+ results.push(completed);
238
+ running.delete(completed.env);
239
+
240
+ // Fail-fast: kill all running tasks and exit
241
+ if (!completed.success) {
242
+ console.log(pc.red(`\n[FAIL-FAST] ${completed.env} failed, killing remaining tasks...`));
243
+ for (const task of running.values()) {
244
+ killTask(task);
245
+ }
246
+ // Wait briefly for processes to terminate
247
+ await Promise.all(Array.from(running.values()).map((t) => t.promise));
248
+ return results;
249
+ }
250
+ }
165
251
 
166
252
  return results;
167
253
  }
@@ -173,7 +259,8 @@ async function _runSequential(
173
259
  ): Promise<ToxResult[]> {
174
260
  const results: ToxResult[] = [];
175
261
  for (const env of envs) {
176
- const result = await runToxEnv(env, pythonPath, name);
262
+ const task = startToxEnv(env, pythonPath, name);
263
+ const result = await task.promise;
177
264
  results.push(result);
178
265
  }
179
266
  return results;
@@ -206,7 +293,11 @@ function printSummary(results: ToxResult[]): void {
206
293
  console.log(pc.red("Failed environments:"));
207
294
  for (const result of failed) {
208
295
  console.log(` - ${result.env}`);
209
- if (result.error && !argv.values.quiet) {
296
+ if (result.failedTests && result.failedTests.length > 0) {
297
+ for (const test of result.failedTests) {
298
+ console.log(` ${pc.red("FAILED")} ${test}`);
299
+ }
300
+ } else if (result.error && !argv.values.quiet) {
210
301
  console.log(` ${result.error.split("\n").slice(0, 5).join("\n ")}`);
211
302
  }
212
303
  }
@@ -316,12 +407,15 @@ async function main(): Promise<void> {
316
407
  baseEnvs = ["test"];
317
408
  }
318
409
 
319
- // Expand 'ci' into its component environments for parallel execution
410
+ // Expand 'ci' and 'docs' into component environments for parallel execution
320
411
  const expandedEnvs: string[] = [];
321
412
  for (const env of baseEnvs) {
322
413
  if (env === "ci") {
323
- // Run test first (sequential), then lint/mypy/pyright/docs in parallel
324
- expandedEnvs.push("test", "lint", "mypy", "pyright", "docs");
414
+ // All envs run in parallel — pre-built wheels make installs cheap
415
+ expandedEnvs.push("test", "lint", "mypy", "pyright", "apiview", "sphinx");
416
+ } else if (env === "docs") {
417
+ // Split docs into apiview + sphinx for parallelism
418
+ expandedEnvs.push("apiview", "sphinx");
325
419
  } else {
326
420
  expandedEnvs.push(env);
327
421
  }
@@ -348,37 +442,48 @@ async function main(): Promise<void> {
348
442
  process.exit(1);
349
443
  }
350
444
 
351
- // Separate test environments from other environments
352
- // Test environments must run sequentially (they share port 3000)
353
- // Other environments (lint, mypy, pyright, docs) can run in parallel
354
- const testEnvs = envs.filter((e) => e.startsWith("test-") || e === "unittest");
355
- const otherEnvs = envs.filter((e) => !e.startsWith("test-") && e !== "unittest");
356
-
357
445
  const maxJobs = argv.values.jobs
358
446
  ? parseInt(argv.values.jobs, 10)
359
447
  : Math.max(2, cpus().length - 2);
360
448
 
361
449
  console.log(` Flavors: ${flavors.join(", ")}`);
362
450
  console.log(` Environments: ${envs.join(", ")}`);
363
- console.log(` Jobs: ${maxJobs} (test envs run sequentially, others in parallel)`);
451
+ console.log(` Jobs: ${maxJobs}`);
364
452
  if (argv.values.name) {
365
453
  console.log(` Filter: ${argv.values.name}`);
366
454
  }
367
455
  console.log();
368
456
 
369
- // Run test environments first (sequentially)
370
- let results: ToxResult[] = [];
371
- if (testEnvs.length > 0) {
372
- console.log(pc.cyan("Running test environments (sequential)..."));
373
- results = await runParallel(testEnvs, pythonPath, 1, argv.values.name);
457
+ // Pre-build wheels for each flavor so tox envs install from pre-built
458
+ // wheels instead of rebuilding from source (~2min build once vs ~2min × N envs)
459
+ console.log(pc.cyan("Pre-building wheels for all flavors..."));
460
+ const installScript = join(testsDir, "install_packages.py");
461
+ for (const flavor of flavors) {
462
+ const startTime = Date.now();
463
+ const proc = spawn(pythonPath, [installScript, "build", flavor, testsDir], {
464
+ cwd: testsDir,
465
+ stdio: "inherit",
466
+ });
467
+ await new Promise<void>((resolve) => {
468
+ proc.on("close", (code) => {
469
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1);
470
+ if (code === 0) {
471
+ console.log(`${pc.green("[PASS]")} wheel build ${flavor} (${duration}s)`);
472
+ } else {
473
+ console.log(
474
+ `${pc.yellow("[WARN]")} wheel build ${flavor} failed (${duration}s), tox envs will build from source`,
475
+ );
476
+ }
477
+ resolve();
478
+ });
479
+ });
374
480
  }
481
+ console.log();
375
482
 
376
- // Run other environments in parallel
377
- if (otherEnvs.length > 0) {
378
- console.log(pc.cyan("\nRunning lint/typecheck environments (parallel)..."));
379
- const otherResults = await runParallel(otherEnvs, pythonPath, maxJobs, argv.values.name);
380
- results = results.concat(otherResults);
381
- }
483
+ // Run all environments in parallel
484
+ // The mock server serves both azure and unbranded specs, so all tests can run together
485
+ console.log(pc.cyan("Running all environments in parallel..."));
486
+ const results = await runParallel(envs, pythonPath, maxJobs, argv.values.name);
382
487
 
383
488
  allResults.push(...results);
384
489
  }