@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
@@ -10,32 +10,38 @@
10
10
 
11
11
  import os
12
12
  import sys
13
- from subprocess import check_call, CalledProcessError
13
+ from subprocess import run, TimeoutExpired
14
14
  import logging
15
15
  from util import run_check
16
16
 
17
17
  logging.getLogger().setLevel(logging.INFO)
18
18
 
19
+ # Timeout for each apiview generation (seconds)
20
+ APIVIEW_TIMEOUT = 30
21
+
19
22
 
20
23
  def _single_dir_apiview(mod):
21
- loop = 0
22
- while True:
24
+ for attempt in range(2):
23
25
  try:
24
- check_call(
25
- [
26
- "apistubgen",
27
- "--pkg-path",
28
- str(mod.absolute()),
29
- ]
26
+ result = run(
27
+ ["apistubgen", "--pkg-path", str(mod.absolute())],
28
+ capture_output=True,
29
+ timeout=APIVIEW_TIMEOUT,
30
30
  )
31
- except CalledProcessError as e:
32
- if loop >= 2: # retry for maximum 3 times because sometimes the apistubgen has transient failure.
33
- logging.error("{} exited with apiview generation error {}".format(mod.stem, e.returncode))
31
+ if result.returncode == 0:
32
+ return True
33
+ if attempt == 1:
34
+ logging.error(f"{mod.stem} failed: {result.stderr.decode()[:200]}")
35
+ return False
36
+ except TimeoutExpired:
37
+ if attempt == 1:
38
+ logging.error(f"{mod.stem} timed out after {APIVIEW_TIMEOUT}s")
39
+ return False
40
+ except Exception as e:
41
+ if attempt == 1:
42
+ logging.error(f"{mod.stem} error: {e}")
34
43
  return False
35
- else:
36
- loop += 1
37
- continue
38
- return True
44
+ return False
39
45
 
40
46
 
41
47
  if __name__ == "__main__":
@@ -12,7 +12,7 @@ from subprocess import check_call, CalledProcessError
12
12
  import os
13
13
  import logging
14
14
  import sys
15
- from util import run_check
15
+ from util import run_check, get_package_namespace_dir
16
16
 
17
17
  logging.getLogger().setLevel(logging.INFO)
18
18
 
@@ -26,24 +26,34 @@ def get_config_file_location():
26
26
  return os.path.join(os.path.dirname(__file__), "config/mypy.ini")
27
27
 
28
28
 
29
- def _single_dir_mypy(mod):
30
- inner_class = next(d for d in mod.iterdir() if d.is_dir() and not str(d).endswith("egg-info"))
31
- try:
32
- check_call(
33
- [
34
- sys.executable,
35
- "-m",
36
- "mypy",
37
- "--config-file",
38
- get_config_file_location(),
39
- "--ignore-missing",
40
- str(inner_class.absolute()),
41
- ]
42
- )
29
+ def _single_dir_mypy(mod, retries=2):
30
+ inner_class = get_package_namespace_dir(mod)
31
+ if not inner_class:
32
+ logging.info(f"No package directory found in {mod}, skipping")
43
33
  return True
44
- except CalledProcessError as e:
45
- logging.error("{} exited with mypy error {}".format(inner_class.stem, e.returncode))
46
- return False
34
+ for attempt in range(1, retries + 2):
35
+ try:
36
+ check_call(
37
+ [
38
+ sys.executable,
39
+ "-m",
40
+ "mypy",
41
+ "--config-file",
42
+ get_config_file_location(),
43
+ "--ignore-missing",
44
+ str(inner_class.absolute()),
45
+ ]
46
+ )
47
+ return True
48
+ except CalledProcessError as e:
49
+ if attempt <= retries:
50
+ logging.warning(
51
+ "{} mypy attempt {} failed (exit {}), retrying...".format(inner_class.stem, attempt, e.returncode)
52
+ )
53
+ else:
54
+ logging.error("{} exited with mypy error {}".format(inner_class.stem, e.returncode))
55
+ return False
56
+ return False
47
57
 
48
58
 
49
59
  if __name__ == "__main__":
@@ -12,7 +12,7 @@ from subprocess import check_call, CalledProcessError
12
12
  import os
13
13
  import logging
14
14
  import sys
15
- from util import run_check
15
+ from util import run_check, get_package_namespace_dir
16
16
 
17
17
  logging.getLogger().setLevel(logging.INFO)
18
18
 
@@ -27,22 +27,29 @@ def get_rfc_file_location():
27
27
 
28
28
 
29
29
  def _single_dir_pylint(mod):
30
- inner_class = next(d for d in mod.iterdir() if d.is_dir() and not str(d).endswith("egg-info"))
30
+ inner_class = get_package_namespace_dir(mod)
31
+ if not inner_class:
32
+ logging.info(f"No package directory found in {mod}, skipping")
33
+ return True
34
+ # Only load the Azure pylint guidelines checker plugin for azure packages.
35
+ # The plugin (azure-pylint-guidelines-checker) is only installed in the
36
+ # lint-azure tox environment and is not available for unbranded packages.
37
+ is_azure = "azure" in mod.parts
38
+ pylint_args = [
39
+ sys.executable,
40
+ "-m",
41
+ "pylint",
42
+ "--rcfile={}".format(get_rfc_file_location()),
43
+ "--evaluation=(max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention + info)/ statement) * 10)))",
44
+ "--output-format=parseable",
45
+ "--recursive=y",
46
+ "--py-version=3.10",
47
+ ]
48
+ if is_azure:
49
+ pylint_args.append("--load-plugins=pylint_guidelines_checker")
50
+ pylint_args.append(str(inner_class.absolute()))
31
51
  try:
32
- check_call(
33
- [
34
- sys.executable,
35
- "-m",
36
- "pylint",
37
- "--rcfile={}".format(get_rfc_file_location()),
38
- "--evaluation=(max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention + info)/ statement) * 10)))",
39
- "--load-plugins=pylint_guidelines_checker",
40
- "--output-format=parseable",
41
- "--recursive=y",
42
- "--py-version=3.9",
43
- str(inner_class.absolute()),
44
- ]
45
- )
52
+ check_call(pylint_args)
46
53
  return True
47
54
  except CalledProcessError as e:
48
55
  logging.error("{} exited with linting error {}".format(str(inner_class.absolute()), e.returncode))
@@ -13,7 +13,7 @@ from subprocess import check_output, CalledProcessError
13
13
  import logging
14
14
  import sys
15
15
  import time
16
- from util import run_check
16
+ from util import run_check, get_package_namespace_dir
17
17
 
18
18
  logging.getLogger().setLevel(logging.INFO)
19
19
 
@@ -28,7 +28,10 @@ def get_pyright_config_file_location():
28
28
 
29
29
 
30
30
  def _single_dir_pyright(mod):
31
- inner_class = next(d for d in mod.iterdir() if d.is_dir() and not str(d).endswith("egg-info"))
31
+ inner_class = get_package_namespace_dir(mod)
32
+ if not inner_class:
33
+ logging.info(f"No package directory found in {mod}, skipping")
34
+ return True
32
35
  retries = 3
33
36
  while retries:
34
37
  try:
@@ -8,18 +8,21 @@
8
8
  # This script is used to execute sphinx documentation build within a tox environment.
9
9
  # It uses a central sphinx configuration and validates docstrings by running sphinx-build.
10
10
 
11
- from subprocess import check_call, CalledProcessError
11
+ from subprocess import run, TimeoutExpired
12
12
  import os
13
13
  import logging
14
14
  import sys
15
15
  from pathlib import Path
16
- from util import run_check
16
+ from util import run_check, SKIP_PACKAGE_DIRS
17
17
 
18
18
  logging.getLogger().setLevel(logging.INFO)
19
19
 
20
20
  # Get the central Sphinx config directory
21
21
  SPHINX_CONF_DIR = os.path.abspath(os.path.dirname(__file__))
22
22
 
23
+ # Timeout for each sphinx build (seconds)
24
+ SPHINX_TIMEOUT = 120
25
+
23
26
 
24
27
  def _create_minimal_index_rst(docs_dir, package_name, module_names):
25
28
  """Create a minimal index.rst file for sphinx to process."""
@@ -50,7 +53,12 @@ def _single_dir_sphinx(mod):
50
53
 
51
54
  # Find the actual Python package directories
52
55
  package_dirs = [
53
- d for d in mod.iterdir() if d.is_dir() and not d.name.startswith("_") and (d / "__init__.py").exists()
56
+ d
57
+ for d in mod.iterdir()
58
+ if d.is_dir()
59
+ and not d.name.startswith("_")
60
+ and d.name not in SKIP_PACKAGE_DIRS
61
+ and (d / "__init__.py").exists()
54
62
  ]
55
63
 
56
64
  if not package_dirs:
@@ -85,7 +93,7 @@ def _single_dir_sphinx(mod):
85
93
  sys.path.insert(0, str(mod.absolute()))
86
94
 
87
95
  try:
88
- result = check_call(
96
+ result = run(
89
97
  [
90
98
  sys.executable,
91
99
  "-m",
@@ -100,12 +108,19 @@ def _single_dir_sphinx(mod):
100
108
  "-q", # Quiet mode (only show warnings/errors)
101
109
  str(docs_dir.absolute()), # Source directory
102
110
  str(output_dir.absolute()), # Output directory
103
- ]
111
+ ],
112
+ capture_output=True,
113
+ timeout=SPHINX_TIMEOUT,
104
114
  )
105
- logging.info(f"Sphinx build completed successfully for {mod.stem}")
106
- return True
107
- except CalledProcessError as e:
108
- logging.error(f"{mod.stem} exited with sphinx build error {e.returncode}")
115
+ if result.returncode == 0:
116
+ return True
117
+ logging.error(f"{mod.stem} sphinx error: {result.stderr.decode()[:500]}")
118
+ return False
119
+ except TimeoutExpired:
120
+ logging.error(f"{mod.stem} timed out after {SPHINX_TIMEOUT}s")
121
+ return False
122
+ except Exception as e:
123
+ logging.error(f"{mod.stem} sphinx error: {e}")
109
124
  return False
110
125
  finally:
111
126
  # Remove from sys.path
@@ -8,14 +8,32 @@ import os
8
8
  import logging
9
9
  from pathlib import Path
10
10
  import argparse
11
- from multiprocessing import Pool
11
+ from concurrent.futures import ProcessPoolExecutor, as_completed
12
12
 
13
13
  logging.getLogger().setLevel(logging.INFO)
14
14
 
15
- ROOT_FOLDER = os.path.abspath(os.path.join(os.path.abspath(__file__), "..", "..", "..", "generator"))
15
+ # Root is the tests directory (4 levels up from this file: ci -> scripts -> eng -> package_root, then into tests)
16
+ ROOT_FOLDER = os.path.abspath(os.path.join(os.path.abspath(__file__), "..", "..", "..", "..", "tests"))
16
17
 
17
18
  IGNORE_FOLDER = []
18
19
 
20
+ # Directories inside each generated package that should be skipped by all CI checks.
21
+ # These are auto-generated test/sample scaffolding, not the actual SDK code.
22
+ SKIP_PACKAGE_DIRS = {"generated_tests", "generated_samples", "build", "__pycache__", ".pytest_cache"}
23
+
24
+
25
+ def get_package_namespace_dir(mod):
26
+ """Find the actual namespace directory inside a generated package, skipping non-SDK dirs."""
27
+ for d in mod.iterdir():
28
+ if (
29
+ d.is_dir()
30
+ and not d.name.startswith("_")
31
+ and not d.name.endswith("egg-info")
32
+ and d.name not in SKIP_PACKAGE_DIRS
33
+ ):
34
+ return d
35
+ return None
36
+
19
37
 
20
38
  def run_check(name, call_back, log_info):
21
39
  parser = argparse.ArgumentParser(
@@ -25,16 +43,9 @@ def run_check(name, call_back, log_info):
25
43
  "-t",
26
44
  "--test-folder",
27
45
  dest="test_folder",
28
- help="The test folder we're in. Can be 'azure' or 'vanilla'",
46
+ help="The test folder we're in. Can be 'azure' or 'unbranded'",
29
47
  required=True,
30
48
  )
31
- parser.add_argument(
32
- "-g",
33
- "--generator",
34
- dest="generator",
35
- help="The generator we're using. Can be 'legacy', 'version-tolerant'.",
36
- required=False,
37
- )
38
49
  parser.add_argument(
39
50
  "-f",
40
51
  "--file-name",
@@ -46,27 +57,52 @@ def run_check(name, call_back, log_info):
46
57
  "-s",
47
58
  "--subfolder",
48
59
  dest="subfolder",
49
- help="The specific sub folder to validate, default to Expected/AcceptanceTests. Optional.",
60
+ help="The subfolder containing generated code, default to 'generated'.",
50
61
  required=False,
51
- default="Expected/AcceptanceTests",
62
+ default="generated",
63
+ )
64
+ parser.add_argument(
65
+ "-j",
66
+ "--jobs",
67
+ dest="jobs",
68
+ help="Number of parallel jobs (default: CPU count)",
69
+ type=int,
70
+ required=False,
71
+ default=max(1, os.cpu_count()),
52
72
  )
53
73
 
54
74
  args = parser.parse_args()
55
75
 
56
- pkg_dir = Path(ROOT_FOLDER) / Path("test") / Path(args.test_folder)
57
- if args.generator:
58
- pkg_dir /= Path(args.generator)
59
- if args.subfolder:
60
- pkg_dir /= Path(args.subfolder)
76
+ # Path structure: tests/generated/{test_folder}/
77
+ pkg_dir = Path(ROOT_FOLDER) / Path(args.subfolder) / Path(args.test_folder)
61
78
  dirs = [d for d in pkg_dir.iterdir() if d.is_dir() and not d.stem.startswith("_") and d.stem not in IGNORE_FOLDER]
62
79
  if args.file_name:
63
80
  dirs = [d for d in dirs if args.file_name.lower() in d.stem.lower()]
64
- if len(dirs) > 1:
65
- with Pool() as pool:
66
- result = pool.map(call_back, dirs)
67
- response = all(result)
68
- else:
69
- response = call_back(dirs[0])
70
- if not response:
71
- logging.error("%s fails", log_info)
81
+
82
+ if not dirs:
83
+ logging.info("No directories to process")
84
+ return
85
+
86
+ logging.info(f"Processing {len(dirs)} packages with {args.jobs} parallel jobs...")
87
+
88
+ failed = []
89
+ succeeded = 0
90
+
91
+ with ProcessPoolExecutor(max_workers=args.jobs) as executor:
92
+ futures = {executor.submit(call_back, d): d for d in dirs}
93
+ for future in as_completed(futures):
94
+ pkg = futures[future]
95
+ try:
96
+ if future.result():
97
+ succeeded += 1
98
+ else:
99
+ failed.append(pkg.stem)
100
+ except Exception as e:
101
+ logging.error(f"{pkg.stem} raised exception: {e}")
102
+ failed.append(pkg.stem)
103
+
104
+ logging.info(f"{log_info}: {succeeded} succeeded, {len(failed)} failed")
105
+
106
+ if failed:
107
+ logging.error(f"{log_info} failed for: {', '.join(failed)}")
72
108
  exit(1)
@@ -7,8 +7,8 @@
7
7
  # --------------------------------------------------------------------------
8
8
  import sys
9
9
 
10
- if not sys.version_info >= (3, 9, 0):
11
- raise Exception("Autorest for Python extension requires Python 3.9 at least")
10
+ if not sys.version_info >= (3, 10, 0):
11
+ raise Exception("Autorest for Python extension requires Python 3.10 at least")
12
12
 
13
13
  try:
14
14
  from package_manager import detect_package_manager, PackageManagerNotFoundError
@@ -18,7 +18,7 @@ except (ImportError, ModuleNotFoundError, PackageManagerNotFoundError):
18
18
  raise Exception("Your Python installation doesn't have a suitable package manager (pip or uv) available")
19
19
 
20
20
 
21
- # Now we have a package manager (pip or uv) and Py >= 3.9, go to work
21
+ # Now we have a package manager (pip or uv) and Py >= 3.10, go to work
22
22
 
23
23
  from pathlib import Path
24
24
 
@@ -7,9 +7,9 @@
7
7
  # --------------------------------------------------------------------------
8
8
  import sys
9
9
 
10
- if not sys.version_info >= (3, 9, 0):
10
+ if not sys.version_info >= (3, 10, 0):
11
11
  print(
12
- "Warning: Autorest for Python extension requires Python 3.9 at least. We will run your code with Pyodide since your Python version isn't adequate."
12
+ "Warning: Autorest for Python extension requires Python 3.10 at least. We will run your code with Pyodide since your Python version isn't adequate."
13
13
  )
14
14
  sys.exit(2) # Exit code 2 for inadequate environment
15
15
 
@@ -32,7 +32,7 @@ except (ImportError, ModuleNotFoundError):
32
32
  sys.exit(2) # Exit code 2 for inadequate environment
33
33
 
34
34
 
35
- # Now we have a package manager (uv or pip) and Py >= 3.9, go to work
35
+ # Now we have a package manager (uv or pip) and Py >= 3.10, go to work
36
36
  # At this point, both Python and package manager are confirmed to be available
37
37
  # Any failures from here should fail the npm install, not fallback to Pyodide
38
38
 
@@ -7,7 +7,7 @@ async function main() {
7
7
  try {
8
8
  // First try to resolve Python path
9
9
  pythonCommand = await patchPythonPath(["python", "./eng/scripts/setup/install.py"], {
10
- version: ">=3.9",
10
+ version: ">=3.10",
11
11
  environmentVariable: "AUTOREST_PYTHON_EXE",
12
12
  });
13
13
  } catch (error) {
@@ -7,9 +7,9 @@
7
7
  # --------------------------------------------------------------------------
8
8
  import sys
9
9
 
10
- if not sys.version_info >= (3, 9, 0):
10
+ if not sys.version_info >= (3, 10, 0):
11
11
  raise Warning(
12
- "Autorest for Python extension requires Python 3.9 at least. We will run your code with Pyodide since your Python version isn't adequate."
12
+ "Autorest for Python extension requires Python 3.10 at least. We will run your code with Pyodide since your Python version isn't adequate."
13
13
  )
14
14
 
15
15
  from pathlib import Path
@@ -11,7 +11,7 @@ import { patchPythonPath } from "./system-requirements.js";
11
11
 
12
12
  export async function runPython3(...args: string[]) {
13
13
  const command = await patchPythonPath(["python", ...args], {
14
- version: ">=3.9",
14
+ version: ">=3.10",
15
15
  environmentVariable: "AUTOREST_PYTHON_EXE",
16
16
  });
17
17
  cp.execSync(command.join(" "), {
@@ -0,0 +1,197 @@
1
+ # -------------------------------------------------------------------------
2
+ # Copyright (c) Microsoft Corporation. All rights reserved.
3
+ # Licensed under the MIT License. See License.txt in the project root for
4
+ # license information.
5
+ # --------------------------------------------------------------------------
6
+ """
7
+ Batch process multiple TypeSpec YAML files in a single Python process.
8
+ This avoids the overhead of spawning a new Python process for each spec.
9
+ """
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+ import os
15
+ from pathlib import Path
16
+ from concurrent.futures import ProcessPoolExecutor, as_completed
17
+ from multiprocessing import freeze_support
18
+
19
+ # Add the generator to the path
20
+ _ROOT_DIR = Path(__file__).parent.parent.parent.parent
21
+ sys.path.insert(0, str(_ROOT_DIR / "generator"))
22
+
23
+
24
+ def process_single_spec(config_path_str: str) -> tuple[str, bool, str]:
25
+ """Process a single spec from its config file.
26
+
27
+ Returns: (output_dir, success, error_message)
28
+ """
29
+ # Import inside function for multiprocessing compatibility
30
+ from pygen import preprocess, codegen
31
+
32
+ config_path = Path(config_path_str)
33
+ try:
34
+ with open(config_path, "r", encoding="utf-8") as f:
35
+ config = json.load(f)
36
+
37
+ yaml_path = config["yamlPath"]
38
+ command_args = config["commandArgs"]
39
+ output_dir = config["outputDir"]
40
+
41
+ # Pass command args directly to pygen - pygen expects hyphenated keys
42
+ # Remove keys that shouldn't be passed to pygen.
43
+ # Also coerce the string values "true"/"false" to real booleans, matching the behavior
44
+ # of pygen.utils.parse_args (the CLI path). Without this, the emitter passes string
45
+ # "false" for flags like keep-setup-py, which is truthy in Python and causes pygen to
46
+ # take the wrong branch (e.g. generating setup.py instead of pyproject.toml).
47
+ def _coerce(value):
48
+ if value == "true":
49
+ return True
50
+ if value == "false":
51
+ return False
52
+ return value
53
+
54
+ pygen_args = {k: _coerce(v) for k, v in command_args.items() if k not in ["emit-yaml-only"]}
55
+
56
+ # Run preprocess and codegen (black is batched at the end for performance)
57
+ preprocess.PreProcessPlugin(output_folder=output_dir, tsp_file=yaml_path, **pygen_args).process()
58
+
59
+ codegen.CodeGenerator(output_folder=output_dir, tsp_file=yaml_path, **pygen_args).process()
60
+
61
+ # Clean up the config file
62
+ config_path.unlink()
63
+
64
+ return (output_dir, True, "")
65
+ except Exception as e:
66
+ return (str(config_path), False, str(e))
67
+
68
+
69
+ def render_progress_bar(completed: int, failed: int, total: int, width: int = 40) -> str:
70
+ """Render a progress bar with green for success and red for failures."""
71
+ success_count = completed - failed
72
+ success_width = round((success_count / total) * width) if total > 0 else 0
73
+ fail_width = round((failed / total) * width) if total > 0 else 0
74
+ empty_width = width - success_width - fail_width
75
+
76
+ # ANSI color codes
77
+ green_bg = "\033[42m"
78
+ red_bg = "\033[41m"
79
+ reset = "\033[0m"
80
+ dim = "\033[2m"
81
+ cyan = "\033[36m"
82
+
83
+ success_bar = f"{green_bg}{' ' * success_width}{reset}"
84
+ fail_bar = f"{red_bg}{' ' * fail_width}{reset}" if failed > 0 else ""
85
+ empty_bar = f"{dim}{'░' * max(0, empty_width)}{reset}"
86
+
87
+ percent = round((completed / total) * 100) if total > 0 else 0
88
+ return f"{success_bar}{fail_bar}{empty_bar} {cyan}{percent}%{reset} ({completed}/{total})"
89
+
90
+
91
+ def collect_config_files(generated_dir: str, flavor: str) -> list[str]:
92
+ """Collect all .tsp-codegen-*.json config files from the generated directory."""
93
+ flavor_dir = Path(generated_dir) / "tests" / "generated" / flavor
94
+ if not flavor_dir.exists():
95
+ return []
96
+
97
+ config_files = []
98
+ for pkg_dir in flavor_dir.iterdir():
99
+ if pkg_dir.is_dir():
100
+ for f in pkg_dir.iterdir():
101
+ if f.name.startswith(".tsp-codegen-") and f.name.endswith(".json"):
102
+ config_files.append(str(f))
103
+ return config_files
104
+
105
+
106
+ def main():
107
+ parser = argparse.ArgumentParser(description="Batch process TypeSpec YAML files")
108
+ parser.add_argument(
109
+ "--generated-dir",
110
+ required=True,
111
+ help="Path to the generator directory (config files are in ../tests/generated/<flavor>/)",
112
+ )
113
+ parser.add_argument(
114
+ "--flavor",
115
+ required=True,
116
+ help="Flavor to process (azure or unbranded)",
117
+ )
118
+ parser.add_argument(
119
+ "--jobs",
120
+ type=int,
121
+ default=4,
122
+ help="Number of parallel jobs (default: 4)",
123
+ )
124
+ args = parser.parse_args()
125
+
126
+ # Discover config files from the generated directory
127
+ config_files = collect_config_files(args.generated_dir, args.flavor)
128
+ total = len(config_files)
129
+
130
+ if total == 0:
131
+ print("No config files found, nothing to process")
132
+ return
133
+
134
+ print(f"Processing {total} specs with {args.jobs} parallel jobs...")
135
+
136
+ succeeded = 0
137
+ failed = 0
138
+ failed_specs = []
139
+ output_dirs = []
140
+ is_tty = sys.stdout.isatty()
141
+
142
+ def update_progress():
143
+ if is_tty:
144
+ sys.stdout.write(f"\r{render_progress_bar(succeeded + failed, failed, total)}")
145
+ sys.stdout.flush()
146
+
147
+ # Initial progress bar
148
+ update_progress()
149
+
150
+ # Use ProcessPoolExecutor for true parallelism (bypasses GIL)
151
+ with ProcessPoolExecutor(max_workers=args.jobs) as executor:
152
+ futures = {executor.submit(process_single_spec, cf): cf for cf in config_files}
153
+
154
+ for future in as_completed(futures):
155
+ output_dir, success, error = future.result()
156
+ if success:
157
+ succeeded += 1
158
+ output_dirs.append(output_dir)
159
+ else:
160
+ failed += 1
161
+ failed_specs.append(f"{output_dir}: {error}")
162
+ # Fail-fast: cancel pending futures on first failure
163
+ print(f"\n\033[31m[FAIL-FAST] Cancelling remaining tasks after failure...\033[0m")
164
+ for f in futures:
165
+ f.cancel()
166
+ break
167
+ update_progress()
168
+
169
+ # Clear progress bar line
170
+ if is_tty:
171
+ sys.stdout.write("\r" + " " * 60 + "\r")
172
+ sys.stdout.flush()
173
+
174
+ # Print failures at the end
175
+ if failed_specs:
176
+ print("\n\033[31mFailed specs:\033[0m")
177
+ for spec in failed_specs:
178
+ print(f" \033[31m•\033[0m {spec}")
179
+
180
+ print(f"\nBatch processing complete: {succeeded} succeeded, {failed} failed")
181
+
182
+ if failed > 0:
183
+ sys.exit(1)
184
+
185
+ # Run black formatting after all codegen completes. Running black separately
186
+ # avoids duplicating black's import/startup cost in each worker process.
187
+ if output_dirs:
188
+ from pygen.black import BlackScriptPlugin
189
+
190
+ print(f"Formatting {len(output_dirs)} packages with black...")
191
+ for d in output_dirs:
192
+ BlackScriptPlugin(output_folder=d).process()
193
+
194
+
195
+ if __name__ == "__main__":
196
+ freeze_support() # Required for Windows multiprocessing
197
+ main()
@@ -8,7 +8,6 @@ import venv
8
8
  import sys
9
9
  from pathlib import Path
10
10
 
11
-
12
11
  # eng/scripts/setup/venvtools.py -> need to go up 4 levels to get to package root
13
12
  _ROOT_DIR = Path(__file__).parent.parent.parent.parent
14
13