msra-codegen 0.1.0__py3-none-any.whl

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 (68) hide show
  1. msra_codegen/README.md +23 -0
  2. msra_codegen/__init__.py +6 -0
  3. msra_codegen/__main__.py +5 -0
  4. msra_codegen/bridge.py +29 -0
  5. msra_codegen/cli.py +105 -0
  6. msra_codegen/codegen_context.py +1690 -0
  7. msra_codegen/config.toml +164 -0
  8. msra_codegen/core_naming.py +155 -0
  9. msra_codegen/docs_generator.py +346 -0
  10. msra_codegen/file_utils.py +8 -0
  11. msra_codegen/funcresult.py +156 -0
  12. msra_codegen/generator.py +6 -0
  13. msra_codegen/generator_config.py +35 -0
  14. msra_codegen/github_workflows.py +129 -0
  15. msra_codegen/gitignore.py +31 -0
  16. msra_codegen/issue_templates.py +100 -0
  17. msra_codegen/logo_assets.py +99 -0
  18. msra_codegen/msra_serializer.py +205 -0
  19. msra_codegen/node_export.js +296 -0
  20. msra_codegen/package_metadata.py +306 -0
  21. msra_codegen/package_writer.py +175 -0
  22. msra_codegen/project_model.py +490 -0
  23. msra_codegen/python_formatting.py +88 -0
  24. msra_codegen/python_render.py +242 -0
  25. msra_codegen/readme_pipeline.py +519 -0
  26. msra_codegen/requirements.txt +5 -0
  27. msra_codegen/template_engine.py +26 -0
  28. msra_codegen/templates/Makefile.tpl +44 -0
  29. msra_codegen/templates/README.md.tpl +55 -0
  30. msra_codegen/templates/abstraction/__init__.py.tpl +188 -0
  31. msra_codegen/templates/abstraction/regexes.py.tpl +25 -0
  32. msra_codegen/templates/docs/requirements.txt.tpl +3 -0
  33. msra_codegen/templates/docs/source/Makefile.tpl +20 -0
  34. msra_codegen/templates/docs/source/api.rst.tpl +9 -0
  35. msra_codegen/templates/docs/source/conf.py.tpl +88 -0
  36. msra_codegen/templates/docs/source/index.rst.tpl +14 -0
  37. msra_codegen/templates/docs/source/module.rst.tpl +34 -0
  38. msra_codegen/templates/docs/source/quick_start.rst.tpl +19 -0
  39. msra_codegen/templates/endpoints_init.py.tpl +15 -0
  40. msra_codegen/templates/example.py.tpl +1 -0
  41. msra_codegen/templates/function.py.tpl +364 -0
  42. msra_codegen/templates/github/issue_templates/bug_report.yml.tpl +55 -0
  43. msra_codegen/templates/github/issue_templates/config.yml.tpl +8 -0
  44. msra_codegen/templates/github/issue_templates/documentation_issue.yml.tpl +33 -0
  45. msra_codegen/templates/github/issue_templates/feature_request.yml.tpl +36 -0
  46. msra_codegen/templates/github/workflows/publish.yml.tpl +100 -0
  47. msra_codegen/templates/github/workflows/source-sync.yml.tpl +177 -0
  48. msra_codegen/templates/github/workflows/tests.yml.tpl +69 -0
  49. msra_codegen/templates/gitignore.tpl +3 -0
  50. msra_codegen/templates/group.py.tpl +56 -0
  51. msra_codegen/templates/group_init.py.tpl +14 -0
  52. msra_codegen/templates/init.py.tpl +4 -0
  53. msra_codegen/templates/licenses/GPL-3.0-or-later.txt.tpl +674 -0
  54. msra_codegen/templates/licenses/MIT.txt.tpl +21 -0
  55. msra_codegen/templates/manager.py.tpl +257 -0
  56. msra_codegen/templates/pyproject.toml.tpl +38 -0
  57. msra_codegen/templates/tests/api_test.py.tpl +49 -0
  58. msra_codegen/templates/tests/conftest.py.tpl +21 -0
  59. msra_codegen/templates/variable.py.tpl +54 -0
  60. msra_codegen/tests_generator.py +988 -0
  61. msra_codegen/typespec.py +275 -0
  62. msra_codegen/validation.py +118 -0
  63. msra_codegen-0.1.0.dist-info/METADATA +47 -0
  64. msra_codegen-0.1.0.dist-info/RECORD +68 -0
  65. msra_codegen-0.1.0.dist-info/WHEEL +5 -0
  66. msra_codegen-0.1.0.dist-info/entry_points.txt +2 -0
  67. msra_codegen-0.1.0.dist-info/licenses/LICENSE +674 -0
  68. msra_codegen-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,306 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from datetime import datetime
6
+ from functools import lru_cache
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from urllib.request import urlopen
10
+
11
+ from packaging.version import Version
12
+ from jinja2 import TemplateNotFound
13
+
14
+ from .core_naming import root_client_class_name
15
+ from .file_utils import write_text
16
+ from .generator_config import config_section
17
+ from .template_engine import render_template
18
+
19
+
20
+ def render_pyproject(project: dict[str, Any], package_name: str) -> str:
21
+ client_class_name = root_client_class_name(project)
22
+ authors = project["app"].get("authors", [])
23
+ min_required_python = str(project["app"].get("min_required_python", "3.10") or "3.10").strip()
24
+ description = str(project["app"].get("description", "") or "").strip()
25
+ runtime_dependencies = collect_runtime_dependencies(project)
26
+ licenses_config = config_section("licenses")
27
+ return render_template(
28
+ "pyproject.toml.tpl",
29
+ {
30
+ "authors_block": render_authors_block(authors),
31
+ "description": json.dumps(description, ensure_ascii=False),
32
+ "license": project["app"].get("license", licenses_config.get("default", "MIT")),
33
+ "keywords_block": render_keywords_block(project["app"].get("keywords", [])),
34
+ "classifiers_block": render_classifiers_block(min_required_python),
35
+ "requires_python": f">={min_required_python}",
36
+ "dependencies_block": render_toml_string_list("dependencies", runtime_dependencies),
37
+ "package_name": package_name,
38
+ "autotest_start_class": f"{package_name}.{client_class_name}",
39
+ "mypy_block": render_mypy_block(),
40
+ "ruff_block": render_ruff_block(),
41
+ },
42
+ )
43
+
44
+
45
+ def render_authors_block(authors: Any) -> str:
46
+ items: list[str] = []
47
+ if isinstance(authors, list):
48
+ for author in authors:
49
+ if not isinstance(author, dict):
50
+ continue
51
+ fields = [f'name = {json.dumps(str(author.get("name", "")))}']
52
+ email = str(author.get("email", "")).strip()
53
+ if email:
54
+ fields.append(f'email = {json.dumps(email)}')
55
+ items.append(" { " + ", ".join(fields) + " }")
56
+ if not items:
57
+ return "authors = []"
58
+ return "authors = [\n" + ",\n".join(items) + "\n]"
59
+
60
+
61
+ def render_keywords_block(keywords: Any) -> str:
62
+ return render_toml_string_list("keywords", keywords)
63
+
64
+
65
+ def render_ruff_block() -> str:
66
+ ruff_config = config_section("ruff")
67
+ line_length = ruff_config.get("line_length")
68
+ if not isinstance(line_length, int):
69
+ raise RuntimeError("ruff.line_length must be an integer.")
70
+
71
+ lint_config = ruff_config.get("lint")
72
+ if not isinstance(lint_config, dict):
73
+ raise RuntimeError("ruff.lint must be a table.")
74
+
75
+ select = lint_config.get("select")
76
+ if not isinstance(select, list) or not select:
77
+ raise RuntimeError("ruff.lint.select must be a non-empty list.")
78
+
79
+ ignore = lint_config.get("ignore", [])
80
+ if not isinstance(ignore, list):
81
+ raise RuntimeError("ruff.lint.ignore must be a list.")
82
+
83
+ return "\n".join(
84
+ [
85
+ "[tool.ruff]",
86
+ f"line-length = {line_length}",
87
+ "",
88
+ "[tool.ruff.lint]",
89
+ render_toml_string_list("select", select),
90
+ render_toml_string_list("ignore", ignore),
91
+ ]
92
+ )
93
+
94
+
95
+ def render_mypy_block() -> str:
96
+ mypy_config = config_section("mypy")
97
+ if not isinstance(mypy_config, dict):
98
+ raise RuntimeError("mypy must be a table.")
99
+
100
+ items: list[str] = []
101
+ for key, value in mypy_config.items():
102
+ if isinstance(value, bool):
103
+ rendered_value = "true" if value else "false"
104
+ elif isinstance(value, (int, float)) and not isinstance(value, bool):
105
+ rendered_value = str(value)
106
+ elif isinstance(value, str):
107
+ rendered_value = json.dumps(value)
108
+ elif isinstance(value, list):
109
+ rendered_items: list[str] = []
110
+ for item in value:
111
+ if isinstance(item, bool):
112
+ rendered_items.append("true" if item else "false")
113
+ elif isinstance(item, (int, float)) and not isinstance(item, bool):
114
+ rendered_items.append(str(item))
115
+ elif isinstance(item, str):
116
+ rendered_items.append(json.dumps(item))
117
+ else:
118
+ raise RuntimeError(f"mypy.{key} contains an unsupported value type.")
119
+ rendered_value = "["
120
+ if rendered_items:
121
+ rendered_value += ", ".join(rendered_items)
122
+ rendered_value += "]"
123
+ else:
124
+ raise RuntimeError(f"mypy.{key} contains an unsupported value type.")
125
+ items.append(f"{key} = {rendered_value}")
126
+
127
+ if not items:
128
+ raise RuntimeError("mypy must not be empty.")
129
+
130
+ return "\n".join(["[tool.mypy]", *items])
131
+
132
+
133
+ @lru_cache(maxsize=1)
134
+ def latest_supported_python_minor() -> int:
135
+ families = load_python_release_families()
136
+ if len(families) < 2:
137
+ raise RuntimeError(
138
+ "Could not determine the penultimate Python 3 minor family from the python.org releases API."
139
+ )
140
+ return families[1][1]
141
+
142
+
143
+ def load_python_release_families() -> list[tuple[int, int]]:
144
+ python_config = config_section("python")
145
+ try:
146
+ with urlopen(
147
+ str(python_config.get("releases_api_url", "https://www.python.org/api/v2/downloads/release/")),
148
+ timeout=float(python_config.get("releases_api_timeout_seconds", 15.0)),
149
+ ) as response:
150
+ data = json.load(response)
151
+ except OSError as exc: # pragma: no cover - depends on network availability
152
+ raise RuntimeError(
153
+ "Failed to load python.org releases API."
154
+ ) from exc
155
+ if not isinstance(data, list):
156
+ raise RuntimeError("Unexpected response format from python.org releases API.")
157
+
158
+ families = {
159
+ (version.major, version.minor)
160
+ for version in extract_python_release_versions(data)
161
+ }
162
+ return sorted(families, reverse=True)
163
+
164
+
165
+ def extract_python_release_versions(data: Any) -> list[Version]:
166
+ versions: list[Version] = []
167
+ for item in data:
168
+ if not isinstance(item, dict):
169
+ continue
170
+ name = str(item.get("name", ""))
171
+ if not name.startswith("Python "):
172
+ continue
173
+ raw_version = name.removeprefix("Python ").strip()
174
+ try:
175
+ version = Version(raw_version)
176
+ except Exception:
177
+ continue
178
+ if version.major != 3:
179
+ continue
180
+ if version.is_prerelease or version.is_devrelease:
181
+ continue
182
+ versions.append(version)
183
+ return versions
184
+
185
+
186
+ def render_classifiers_block(min_required_python: str) -> str:
187
+ classifiers_config = config_section("classifiers")
188
+ match = re.fullmatch(r"(\d+)\.(\d+)", min_required_python.strip())
189
+ if not match:
190
+ version_labels = [f"Programming Language :: Python :: {min_required_python.strip()}"]
191
+ else:
192
+ major = int(match.group(1))
193
+ start_minor = int(match.group(2))
194
+ version_labels = [f"Programming Language :: Python :: {major}"]
195
+ if major == 3:
196
+ end_minor = max(start_minor, latest_supported_python_minor())
197
+ version_labels.extend(
198
+ f"Programming Language :: Python :: 3.{minor}"
199
+ for minor in range(start_minor, end_minor + 1)
200
+ )
201
+ else:
202
+ version_labels.append(f"Programming Language :: Python :: {major}.{start_minor}")
203
+ version_labels.extend(
204
+ [
205
+ *[str(item) for item in classifiers_config.get("static", []) if str(item).strip()],
206
+ ]
207
+ )
208
+ return render_toml_string_list("classifiers", version_labels)
209
+
210
+
211
+ def render_toml_string_list(key: str, values: Any) -> str:
212
+ items: list[str] = []
213
+ if isinstance(values, list):
214
+ for value in values:
215
+ text = str(value).strip()
216
+ if not text:
217
+ continue
218
+ items.append(json.dumps(text))
219
+ if not items:
220
+ return f"{key} = []"
221
+ if len(items) == 1:
222
+ return f"{key} = [{items[0]}]"
223
+ return f"{key} = [\n " + ",\n ".join(items) + "\n]"
224
+
225
+
226
+ def collect_runtime_dependencies(project: dict[str, Any]) -> list[str]:
227
+ runtime_config = config_section("runtime_dependencies")
228
+ dependencies = [str(item).strip() for item in runtime_config.get("base", []) if str(item).strip()]
229
+ if any(func.get("transport") == "direct" for func in project.get("functions", [])):
230
+ dependencies.extend(
231
+ str(item).strip() for item in runtime_config.get("direct", []) if str(item).strip()
232
+ )
233
+ if project.get("app", {}).get("abstractions"):
234
+ dependencies.append("pydantic")
235
+ return dependencies
236
+
237
+
238
+ def collect_dev_dependencies() -> list[str]:
239
+ dev_config = config_section("development_dependencies")
240
+ return [str(item).strip() for item in dev_config.get("base", []) if str(item).strip()]
241
+
242
+
243
+ def render_requirements_txt(project: dict[str, Any]) -> str:
244
+ return "\n".join(collect_runtime_dependencies(project)) + "\n"
245
+
246
+
247
+ def render_requirements_dev_txt(project: dict[str, Any]) -> str:
248
+ lines = [
249
+ "-r requirements.txt",
250
+ "-r docs/requirements.txt",
251
+ *collect_dev_dependencies(),
252
+ ]
253
+ return "\n".join(lines) + "\n"
254
+
255
+
256
+ def write_root_license(output_dir: Path, project: dict[str, Any]) -> None:
257
+ licenses_config = config_section("licenses")
258
+ license_name = resolve_license_template_name(
259
+ str(project["app"].get("license", licenses_config.get("default", "MIT")) or "").strip()
260
+ or str(licenses_config.get("default", "MIT"))
261
+ )
262
+ authors = project["app"].get("authors", [])
263
+ license_text = render_license_text(license_name, authors)
264
+ write_text(output_dir / "LICENSE", license_text)
265
+
266
+
267
+ def resolve_license_template_name(license_name: str) -> str:
268
+ normalized = license_name.strip()
269
+ aliases = config_section("licenses").get("aliases", {})
270
+ if isinstance(aliases, dict) and normalized in aliases:
271
+ return str(aliases[normalized]).strip()
272
+ return normalized
273
+
274
+
275
+ def render_license_text(license_name: str, authors: Any) -> str:
276
+ context = {}
277
+ if license_name == "MIT":
278
+ context = {
279
+ "copyright_holders": format_copyright_holders(authors),
280
+ "year": datetime.now().year,
281
+ }
282
+ template_name = f"licenses/{license_name}.txt.tpl"
283
+ try:
284
+ return render_template(template_name, context)
285
+ except TemplateNotFound as exc: # pragma: no cover - guardrail for unsupported licenses
286
+ raise RuntimeError(
287
+ f'Missing local license template "{template_name}". Add it under msra_codegen/templates/licenses/.'
288
+ ) from exc
289
+
290
+
291
+ def format_copyright_holders(authors: Any) -> str:
292
+ names: list[str] = []
293
+ if isinstance(authors, list):
294
+ for author in authors:
295
+ if not isinstance(author, dict):
296
+ continue
297
+ name = str(author.get("name", "")).strip()
298
+ if name:
299
+ names.append(name)
300
+ if not names:
301
+ return "The authors"
302
+ if len(names) == 1:
303
+ return names[0]
304
+ if len(names) == 2:
305
+ return " and ".join(names)
306
+ return ", ".join(names[:-1]) + ", and " + names[-1]
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .codegen_context import (
8
+ build_abstraction_package_context,
9
+ collect_abstraction_scripts,
10
+ collect_extractor_scripts,
11
+ collect_goto_pipeline_scripts,
12
+ collect_warmup_scripts,
13
+ render_endpoints_init,
14
+ render_init,
15
+ render_manager_template,
16
+ write_group_package,
17
+ )
18
+ from .gitignore import generate_gitignore_project
19
+ from .github_workflows import generate_github_workflows_project
20
+ from .issue_templates import generate_github_issue_templates_project
21
+ from .core_naming import abstraction_module_name_from_path, normalize_abstraction_path, normalize_script_path
22
+ from .file_utils import write_text
23
+ from .python_formatting import format_python_tree
24
+ from .package_metadata import render_pyproject, render_requirements_dev_txt, render_requirements_txt, write_root_license
25
+ from .project_model import build_group_tree, top_level_groups
26
+ from .tests_generator import build_tests_project_context, generate_tests_project
27
+ from .template_engine import render_template
28
+
29
+
30
+ def ensure_package_inits(target_dir: Path, package_root: Path) -> None:
31
+ current = target_dir
32
+ while current != package_root and package_root in current.parents:
33
+ init_file = current / "__init__.py"
34
+ if not init_file.exists():
35
+ write_text(init_file, "")
36
+ current = current.parent
37
+
38
+
39
+ def generate_project(
40
+ project: dict[str, Any],
41
+ output_dir: Path,
42
+ source_root: Path | None = None,
43
+ ) -> None:
44
+ output_dir = output_dir.resolve()
45
+ source_root = source_root.resolve() if source_root is not None else Path(project["source_path"]).resolve().parent
46
+ package_name = str(project["app"].get("package_name") or "").strip()
47
+ if not package_name:
48
+ raise ValueError('app.package_name is required and must be set explicitly in the source MSRA file.')
49
+ group_tree = build_group_tree(project)
50
+
51
+ package_root = output_dir / package_name
52
+ abstraction_root = package_root / "abstraction"
53
+ endpoints_root = package_root / "endpoints"
54
+ pipelines_root = package_root / "pipelines"
55
+ legacy_postprocess_root = package_root / "postprocess"
56
+ extractors_root = package_root / "extractors"
57
+ package_root.mkdir(parents=True, exist_ok=True)
58
+ if abstraction_root.exists():
59
+ shutil.rmtree(abstraction_root)
60
+ abstraction_root.mkdir(parents=True, exist_ok=True)
61
+ if pipelines_root.exists():
62
+ shutil.rmtree(pipelines_root)
63
+ pipelines_root.mkdir(parents=True, exist_ok=True)
64
+ if legacy_postprocess_root.exists():
65
+ shutil.rmtree(legacy_postprocess_root)
66
+ if extractors_root.exists():
67
+ shutil.rmtree(extractors_root)
68
+ extractors_root.mkdir(parents=True, exist_ok=True)
69
+ for stale_pipeline_file in [package_root / "warmup.py", package_root / "goto_pipeline.py"]:
70
+ if stale_pipeline_file.exists():
71
+ stale_pipeline_file.unlink()
72
+
73
+ tests_context = build_tests_project_context(project, package_name)
74
+
75
+ write_text(
76
+ output_dir / "pyproject.toml",
77
+ render_pyproject(project, package_name),
78
+ )
79
+ write_text(
80
+ output_dir / "requirements.txt",
81
+ render_requirements_txt(project),
82
+ )
83
+ write_text(
84
+ output_dir / "requirements-dev.txt",
85
+ render_requirements_dev_txt(project),
86
+ )
87
+ write_text(package_root / "__init__.py", render_init(project, package_name))
88
+ stale_abstraction_file = package_root / "abstraction.py"
89
+ if stale_abstraction_file.exists():
90
+ stale_abstraction_file.unlink()
91
+ abstraction_context = build_abstraction_package_context(project)
92
+ copied_abstraction_modules: set[str] = set()
93
+ for abstraction_script in collect_abstraction_scripts(project):
94
+ normalized_abstraction = normalize_abstraction_path(abstraction_script)
95
+ source = Path(normalized_abstraction)
96
+ if not source.is_absolute():
97
+ source = source_root / source
98
+ module_name = abstraction_module_name_from_path(normalized_abstraction)
99
+ if module_name in {"__init__", "regexes"}:
100
+ raise ValueError(
101
+ f'Abstraction file "{abstraction_script}" resolves to reserved module name "{module_name}".'
102
+ )
103
+ if module_name in copied_abstraction_modules:
104
+ raise ValueError(f'Duplicate abstraction module name "{module_name}" derived from "{abstraction_script}".')
105
+ copied_abstraction_modules.add(module_name)
106
+ target = abstraction_root / f"{module_name}.py"
107
+ shutil.copyfile(source, target)
108
+ write_text(
109
+ abstraction_root / "__init__.py",
110
+ render_template("abstraction/__init__.py.tpl", abstraction_context),
111
+ )
112
+ write_text(
113
+ abstraction_root / "regexes.py",
114
+ render_template("abstraction/regexes.py.tpl", abstraction_context),
115
+ )
116
+ write_text(
117
+ package_root / "manager.py",
118
+ render_manager_template(
119
+ project,
120
+ package_name,
121
+ group_tree,
122
+ autotest_function_ids=tests_context["autotest_function_ids"],
123
+ ),
124
+ )
125
+ if endpoints_root.exists():
126
+ shutil.rmtree(endpoints_root)
127
+ endpoints_root.mkdir(parents=True, exist_ok=True)
128
+ write_text(endpoints_root / "__init__.py", render_endpoints_init(project, package_name, group_tree))
129
+ for group_node in top_level_groups(group_tree):
130
+ write_group_package(
131
+ group_node,
132
+ project,
133
+ package_name,
134
+ endpoints_root,
135
+ autotest_function_ids=tests_context["autotest_function_ids"],
136
+ )
137
+
138
+ for script in dict.fromkeys(collect_warmup_scripts(project) + collect_goto_pipeline_scripts(project)):
139
+ source = source_root / script
140
+ target = pipelines_root / normalize_script_path(script)
141
+ target.parent.mkdir(parents=True, exist_ok=True)
142
+ ensure_package_inits(target.parent, package_root)
143
+ shutil.copyfile(source, target)
144
+
145
+ for script in dict.fromkeys(collect_extractor_scripts(project)):
146
+ source = source_root / script
147
+ target = package_root / normalize_script_path(script)
148
+ target.parent.mkdir(parents=True, exist_ok=True)
149
+ shutil.copyfile(source, target)
150
+
151
+ legacy_license_dir = output_dir / "LICENSES"
152
+ if legacy_license_dir.exists():
153
+ shutil.rmtree(legacy_license_dir)
154
+ write_root_license(output_dir, project)
155
+ generate_gitignore_project(output_dir)
156
+ generate_github_workflows_project(project, output_dir, package_name)
157
+ generate_github_issue_templates_project(project, output_dir)
158
+
159
+ from .docs_generator import generate_docs_project
160
+
161
+ generate_docs_project(
162
+ project,
163
+ output_dir,
164
+ package_name,
165
+ group_tree,
166
+ tests_context=tests_context,
167
+ )
168
+ generate_tests_project(
169
+ project,
170
+ output_dir,
171
+ package_name,
172
+ group_tree,
173
+ tests_context=tests_context,
174
+ )
175
+ format_python_tree(output_dir)