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.
- msra_codegen/README.md +23 -0
- msra_codegen/__init__.py +6 -0
- msra_codegen/__main__.py +5 -0
- msra_codegen/bridge.py +29 -0
- msra_codegen/cli.py +105 -0
- msra_codegen/codegen_context.py +1690 -0
- msra_codegen/config.toml +164 -0
- msra_codegen/core_naming.py +155 -0
- msra_codegen/docs_generator.py +346 -0
- msra_codegen/file_utils.py +8 -0
- msra_codegen/funcresult.py +156 -0
- msra_codegen/generator.py +6 -0
- msra_codegen/generator_config.py +35 -0
- msra_codegen/github_workflows.py +129 -0
- msra_codegen/gitignore.py +31 -0
- msra_codegen/issue_templates.py +100 -0
- msra_codegen/logo_assets.py +99 -0
- msra_codegen/msra_serializer.py +205 -0
- msra_codegen/node_export.js +296 -0
- msra_codegen/package_metadata.py +306 -0
- msra_codegen/package_writer.py +175 -0
- msra_codegen/project_model.py +490 -0
- msra_codegen/python_formatting.py +88 -0
- msra_codegen/python_render.py +242 -0
- msra_codegen/readme_pipeline.py +519 -0
- msra_codegen/requirements.txt +5 -0
- msra_codegen/template_engine.py +26 -0
- msra_codegen/templates/Makefile.tpl +44 -0
- msra_codegen/templates/README.md.tpl +55 -0
- msra_codegen/templates/abstraction/__init__.py.tpl +188 -0
- msra_codegen/templates/abstraction/regexes.py.tpl +25 -0
- msra_codegen/templates/docs/requirements.txt.tpl +3 -0
- msra_codegen/templates/docs/source/Makefile.tpl +20 -0
- msra_codegen/templates/docs/source/api.rst.tpl +9 -0
- msra_codegen/templates/docs/source/conf.py.tpl +88 -0
- msra_codegen/templates/docs/source/index.rst.tpl +14 -0
- msra_codegen/templates/docs/source/module.rst.tpl +34 -0
- msra_codegen/templates/docs/source/quick_start.rst.tpl +19 -0
- msra_codegen/templates/endpoints_init.py.tpl +15 -0
- msra_codegen/templates/example.py.tpl +1 -0
- msra_codegen/templates/function.py.tpl +364 -0
- msra_codegen/templates/github/issue_templates/bug_report.yml.tpl +55 -0
- msra_codegen/templates/github/issue_templates/config.yml.tpl +8 -0
- msra_codegen/templates/github/issue_templates/documentation_issue.yml.tpl +33 -0
- msra_codegen/templates/github/issue_templates/feature_request.yml.tpl +36 -0
- msra_codegen/templates/github/workflows/publish.yml.tpl +100 -0
- msra_codegen/templates/github/workflows/source-sync.yml.tpl +177 -0
- msra_codegen/templates/github/workflows/tests.yml.tpl +69 -0
- msra_codegen/templates/gitignore.tpl +3 -0
- msra_codegen/templates/group.py.tpl +56 -0
- msra_codegen/templates/group_init.py.tpl +14 -0
- msra_codegen/templates/init.py.tpl +4 -0
- msra_codegen/templates/licenses/GPL-3.0-or-later.txt.tpl +674 -0
- msra_codegen/templates/licenses/MIT.txt.tpl +21 -0
- msra_codegen/templates/manager.py.tpl +257 -0
- msra_codegen/templates/pyproject.toml.tpl +38 -0
- msra_codegen/templates/tests/api_test.py.tpl +49 -0
- msra_codegen/templates/tests/conftest.py.tpl +21 -0
- msra_codegen/templates/variable.py.tpl +54 -0
- msra_codegen/tests_generator.py +988 -0
- msra_codegen/typespec.py +275 -0
- msra_codegen/validation.py +118 -0
- msra_codegen-0.1.0.dist-info/METADATA +47 -0
- msra_codegen-0.1.0.dist-info/RECORD +68 -0
- msra_codegen-0.1.0.dist-info/WHEEL +5 -0
- msra_codegen-0.1.0.dist-info/entry_points.txt +2 -0
- msra_codegen-0.1.0.dist-info/licenses/LICENSE +674 -0
- msra_codegen-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .python_render import render_expr
|
|
6
|
+
|
|
7
|
+
FUNCRESULT_RESULT_TYPES = {"JSON", "TEXT", "IMAGE"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_funcresult_reference(expr: dict[str, Any]) -> tuple[str, str, str, list[dict[str, Any]]]:
|
|
11
|
+
parts = expr.get("parts", [])
|
|
12
|
+
if len(parts) < 4 or parts[0].get("kind") != "name" or str(parts[0].get("value")) != "FUNCRESULT":
|
|
13
|
+
raise ValueError("FUNCRESULT references must use the form <FUNCRESULT.<function>.<example>.<kind>>.")
|
|
14
|
+
|
|
15
|
+
function_part = parts[1]
|
|
16
|
+
example_part = parts[2]
|
|
17
|
+
result_part = parts[3]
|
|
18
|
+
if function_part.get("kind") != "name":
|
|
19
|
+
raise ValueError("FUNCRESULT references must name the source function immediately after FUNCRESULT.")
|
|
20
|
+
if example_part.get("kind") != "name":
|
|
21
|
+
raise ValueError(
|
|
22
|
+
"FUNCRESULT references must name the source example before the result kind, for example "
|
|
23
|
+
"<FUNCRESULT.<function>.<example>.<kind>>."
|
|
24
|
+
)
|
|
25
|
+
if result_part.get("kind") != "name":
|
|
26
|
+
raise ValueError("FUNCRESULT references must include a result kind after the source example: JSON, TEXT, or IMAGE.")
|
|
27
|
+
|
|
28
|
+
result_kind = str(result_part.get("value"))
|
|
29
|
+
if result_kind not in FUNCRESULT_RESULT_TYPES:
|
|
30
|
+
raise ValueError("FUNCRESULT references must use the result kind JSON, TEXT, or IMAGE.")
|
|
31
|
+
|
|
32
|
+
function_id = str(function_part.get("value"))
|
|
33
|
+
example_name = str(example_part.get("value"))
|
|
34
|
+
tail_parts = list(parts[4:])
|
|
35
|
+
if result_kind != "JSON" and tail_parts:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
f"FUNCRESULT.{function_id}.{example_name}.{result_kind} does not allow further path access. "
|
|
38
|
+
"Use JSON if you need to address nested elements."
|
|
39
|
+
)
|
|
40
|
+
for tail_part in tail_parts:
|
|
41
|
+
if tail_part.get("kind") == "key":
|
|
42
|
+
validate_funcresult_key_selector(tail_part.get("value"), function_id, example_name)
|
|
43
|
+
|
|
44
|
+
return function_id, example_name, result_kind, tail_parts
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def collect_funcresult_dependencies(expr: Any) -> set[str]:
|
|
48
|
+
dependencies: set[str] = set()
|
|
49
|
+
|
|
50
|
+
def walk(node: Any) -> None:
|
|
51
|
+
if isinstance(node, list):
|
|
52
|
+
for item in node:
|
|
53
|
+
walk(item)
|
|
54
|
+
return
|
|
55
|
+
if not isinstance(node, dict):
|
|
56
|
+
return
|
|
57
|
+
kind = node.get("kind")
|
|
58
|
+
if kind == "ref":
|
|
59
|
+
parts = node.get("parts", [])
|
|
60
|
+
if parts and parts[0].get("kind") == "name" and str(parts[0].get("value")) == "FUNCRESULT":
|
|
61
|
+
function_id, example_name, _result_kind, _tail_parts = parse_funcresult_reference(node)
|
|
62
|
+
dependencies.add(readme_example_key(function_id, example_name))
|
|
63
|
+
for part in parts:
|
|
64
|
+
walk(part.get("value"))
|
|
65
|
+
return
|
|
66
|
+
if kind == "inline_table":
|
|
67
|
+
for item in node.get("items", []):
|
|
68
|
+
walk(item.get("value"))
|
|
69
|
+
return
|
|
70
|
+
if kind == "array":
|
|
71
|
+
for item in node.get("items", []):
|
|
72
|
+
walk(item)
|
|
73
|
+
return
|
|
74
|
+
if kind == "sequence":
|
|
75
|
+
for item in node.get("items", []):
|
|
76
|
+
walk(item)
|
|
77
|
+
return
|
|
78
|
+
if kind == "merge":
|
|
79
|
+
for part in node.get("parts", []):
|
|
80
|
+
walk(part)
|
|
81
|
+
return
|
|
82
|
+
if kind == "call":
|
|
83
|
+
walk(node.get("callee"))
|
|
84
|
+
for arg in node.get("args", []):
|
|
85
|
+
walk(arg.get("value"))
|
|
86
|
+
return
|
|
87
|
+
if kind == "index":
|
|
88
|
+
walk(node.get("value"))
|
|
89
|
+
walk(node.get("index"))
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
walk(expr)
|
|
93
|
+
return dependencies
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def render_funcresult_reference(
|
|
97
|
+
expr: dict[str, Any],
|
|
98
|
+
base_expr: str,
|
|
99
|
+
*,
|
|
100
|
+
index_self_ref: str = "api",
|
|
101
|
+
) -> str:
|
|
102
|
+
function_id, example_name, _result_kind, tail_parts = parse_funcresult_reference(expr)
|
|
103
|
+
rendered = base_expr
|
|
104
|
+
for tail_part in tail_parts:
|
|
105
|
+
tail_kind = tail_part.get("kind")
|
|
106
|
+
if tail_kind == "index":
|
|
107
|
+
rendered += f"[{render_expr(tail_part.get('value'), self_ref=index_self_ref)}]"
|
|
108
|
+
elif tail_kind == "key":
|
|
109
|
+
rendered += f"[{render_funcresult_key_selector(tail_part.get('value'), rendered)}]"
|
|
110
|
+
elif tail_kind == "name":
|
|
111
|
+
rendered += f".{tail_part.get('value')}"
|
|
112
|
+
else:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"Unsupported FUNCRESULT tail part kind {tail_kind!r} for source example "
|
|
115
|
+
f"[app.func.{function_id}.examples.{example_name}]."
|
|
116
|
+
)
|
|
117
|
+
return rendered
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def render_funcresult_key_selector(index_expr: Any, data_expr: str) -> str:
|
|
121
|
+
index_value = readme_key_selector_number(index_expr)
|
|
122
|
+
if index_value is None:
|
|
123
|
+
raise ValueError("FUNCRESULT @Key selector requires an integer id greater than or equal to -1.")
|
|
124
|
+
if index_value == 0:
|
|
125
|
+
return f"next(iter({data_expr}))"
|
|
126
|
+
if index_value == -1:
|
|
127
|
+
return f"next(reversed({data_expr}))"
|
|
128
|
+
if index_value < -1:
|
|
129
|
+
raise ValueError("FUNCRESULT @Key selector requires an integer id greater than or equal to -1.")
|
|
130
|
+
return f"list({data_expr})[{index_value}]"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def validate_funcresult_key_selector(index_expr: Any, function_id: str, example_name: str) -> None:
|
|
134
|
+
index_value = readme_key_selector_number(index_expr)
|
|
135
|
+
if index_value is None or index_value < -1:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"FUNCRESULT.{function_id}.{example_name}.JSON uses @Key with an invalid id. "
|
|
138
|
+
f"Expected an integer greater than or equal to -1."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def readme_key_selector_number(index_expr: Any) -> int | None:
|
|
143
|
+
if not isinstance(index_expr, dict) or index_expr.get("kind") != "number":
|
|
144
|
+
return None
|
|
145
|
+
value = index_expr.get("value")
|
|
146
|
+
if isinstance(value, bool):
|
|
147
|
+
return None
|
|
148
|
+
if not isinstance(value, (int, float)):
|
|
149
|
+
return None
|
|
150
|
+
if not float(value).is_integer():
|
|
151
|
+
return None
|
|
152
|
+
return int(value)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def readme_example_key(function_id: str, example_name: str) -> str:
|
|
156
|
+
return f"{function_id}::{example_name}"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import tomllib
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
CONFIG_PATH = Path(__file__).with_name("config.toml")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@lru_cache(maxsize=1)
|
|
14
|
+
def load_generator_config() -> dict[str, Any]:
|
|
15
|
+
with CONFIG_PATH.open("rb") as handle:
|
|
16
|
+
data = tomllib.load(handle)
|
|
17
|
+
return data if isinstance(data, dict) else {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def config_section(*path: str) -> dict[str, Any]:
|
|
21
|
+
current: Any = load_generator_config()
|
|
22
|
+
for key in path:
|
|
23
|
+
if not isinstance(current, dict):
|
|
24
|
+
return {}
|
|
25
|
+
current = current.get(key, {})
|
|
26
|
+
return current if isinstance(current, dict) else {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def config_value(*path: str, default: Any = None) -> Any:
|
|
30
|
+
current: Any = load_generator_config()
|
|
31
|
+
for key in path:
|
|
32
|
+
if not isinstance(current, dict) or key not in current:
|
|
33
|
+
return default
|
|
34
|
+
current = current[key]
|
|
35
|
+
return current
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .file_utils import write_text
|
|
8
|
+
from .generator_config import config_section
|
|
9
|
+
from .template_engine import render_template
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_github_workflows_context(project: dict[str, Any], package_name: str) -> dict[str, Any]:
|
|
13
|
+
app = project.get("app", {})
|
|
14
|
+
github_config = config_section("github", "workflows")
|
|
15
|
+
tests_config = github_config["tests"]
|
|
16
|
+
publish_config = github_config["publish"]
|
|
17
|
+
source_sync_config = github_config["source_sync"]
|
|
18
|
+
app_sync_config = project.get("app", {}).get("sync", {})
|
|
19
|
+
package_name_slug = package_name.replace("_", "-")
|
|
20
|
+
source_msra_path = str(Path(project["source_path"]).name)
|
|
21
|
+
|
|
22
|
+
tests_python_version = str(tests_config["python_version"])
|
|
23
|
+
publish_python_version = str(publish_config["python_version"])
|
|
24
|
+
source_sync_python_version = str(source_sync_config["python_version"])
|
|
25
|
+
tests_run_command_shell = str(tests_config["run_command_shell"])
|
|
26
|
+
tests_headed_run_command_shell = str(tests_config["headed_run_command_shell"])
|
|
27
|
+
preserved_target_paths = app_sync_config.get("preserved_target_paths", [])
|
|
28
|
+
if not isinstance(preserved_target_paths, list):
|
|
29
|
+
raise RuntimeError("app.sync.preserved_target_paths must be a list.")
|
|
30
|
+
ignored_generated_patterns = app_sync_config.get("ignored_generated_patterns", [])
|
|
31
|
+
if not isinstance(ignored_generated_patterns, list):
|
|
32
|
+
raise RuntimeError("app.sync.ignored_generated_patterns must be a list.")
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
"package_name": package_name,
|
|
36
|
+
"package_name_slug": package_name_slug,
|
|
37
|
+
"tests": {
|
|
38
|
+
"name": str(tests_config["name"]),
|
|
39
|
+
"schedule_cron": str(tests_config["schedule_cron"]),
|
|
40
|
+
"runner": list(tests_config["runner"]),
|
|
41
|
+
"python_version": tests_python_version,
|
|
42
|
+
"python_version_expr": "${{ inputs.python-version || '" + tests_python_version + "' }}",
|
|
43
|
+
"checkout_action": str(tests_config["checkout_action"]),
|
|
44
|
+
"setup_python_action": str(tests_config["setup_python_action"]),
|
|
45
|
+
"install_commands": list(tests_config["install_commands"]),
|
|
46
|
+
"run_commands": list(tests_config["run_commands"]),
|
|
47
|
+
"run_command_shell": tests_run_command_shell,
|
|
48
|
+
"headed_run_command_shell": tests_headed_run_command_shell,
|
|
49
|
+
"requires_xvfb": bool(app["disallow_headless"]),
|
|
50
|
+
"report_playwright_failure_action": str(tests_config["report_playwright_failure_action"]),
|
|
51
|
+
"report_schema_action": str(tests_config["report_schema_action"]),
|
|
52
|
+
"github_token_expr": "${{ secrets." + str(tests_config["github_token_secret"]) + " }}",
|
|
53
|
+
"concurrency_group": str(tests_config["name"]) + "-${{ github.ref }}",
|
|
54
|
+
"log_path": str(tests_config["log_path"]),
|
|
55
|
+
"screenshot_path": str(tests_config["screenshot_path"]),
|
|
56
|
+
},
|
|
57
|
+
"publish": {
|
|
58
|
+
"name": str(publish_config["name"]),
|
|
59
|
+
"python_version": publish_python_version,
|
|
60
|
+
"branch": str(publish_config["branch"]),
|
|
61
|
+
"checkout_action": str(publish_config["checkout_action"]),
|
|
62
|
+
"setup_python_action": str(publish_config["setup_python_action"]),
|
|
63
|
+
"setup_python_cache": str(publish_config["setup_python_cache"]),
|
|
64
|
+
"workflow_tests_path": "./.github/workflows/tests.yml",
|
|
65
|
+
"concurrency_group": str(publish_config["name"]) + "-${{ github.ref }}",
|
|
66
|
+
"docs_condition_expr": (
|
|
67
|
+
"${{ github.event_name == 'push' || github.event.inputs.target == 'docs' || github.event.inputs.target == 'all' }}"
|
|
68
|
+
),
|
|
69
|
+
"package_condition_expr": (
|
|
70
|
+
"${{ github.event_name == 'push' || github.event.inputs.target == 'package' || github.event.inputs.target == 'all' }}"
|
|
71
|
+
),
|
|
72
|
+
"upload_pages_action": str(publish_config["upload_pages_action"]),
|
|
73
|
+
"deploy_pages_action": str(publish_config["deploy_pages_action"]),
|
|
74
|
+
"pypi_action": str(publish_config["pypi_action"]),
|
|
75
|
+
"target_options": list(publish_config["target_options"]),
|
|
76
|
+
"pages_environment_name": str(publish_config["pages_environment_name"]),
|
|
77
|
+
"pypi_environment_name": str(publish_config["pypi_environment_name"]),
|
|
78
|
+
"github_token_expr": "${{ secrets." + str(publish_config["github_token_secret"]) + " }}",
|
|
79
|
+
"page_url_expr": "${{ steps.deployment.outputs.page_url }}",
|
|
80
|
+
"pypi_url": f"https://pypi.org/project/{package_name_slug}/",
|
|
81
|
+
},
|
|
82
|
+
"source_sync": {
|
|
83
|
+
"name": str(source_sync_config["name"]),
|
|
84
|
+
"source_branch": str(source_sync_config["source_branch"]),
|
|
85
|
+
"target_branch": str(source_sync_config["target_branch"]),
|
|
86
|
+
"logic_repository": str(source_sync_config["logic_repository"]),
|
|
87
|
+
"logic_ref": str(source_sync_config["logic_ref"]),
|
|
88
|
+
"checkout_action": str(source_sync_config["checkout_action"]),
|
|
89
|
+
"setup_python_action": str(source_sync_config["setup_python_action"]),
|
|
90
|
+
"python_version": source_sync_python_version,
|
|
91
|
+
"generator_requirements_path": str(source_sync_config["generator_requirements_path"]),
|
|
92
|
+
"repo_token_secret": str(source_sync_config["repo_token_secret"]),
|
|
93
|
+
"repo_token_expr": "${{ secrets." + str(source_sync_config["repo_token_secret"]) + " }}",
|
|
94
|
+
"repository_expr": "${{ github.repository }}",
|
|
95
|
+
"commit_user_name": str(source_sync_config["commit_user_name"]),
|
|
96
|
+
"commit_user_email": str(source_sync_config["commit_user_email"]),
|
|
97
|
+
"source_msra_path": source_msra_path,
|
|
98
|
+
"preserved_target_paths": [str(item) for item in preserved_target_paths],
|
|
99
|
+
"ignored_generated_patterns": [str(item) for item in ignored_generated_patterns],
|
|
100
|
+
},
|
|
101
|
+
"makefile": {
|
|
102
|
+
"package_name": package_name,
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def generate_github_workflows_project(project: dict[str, Any], output_dir: Path, package_name: str) -> None:
|
|
108
|
+
context = build_github_workflows_context(project, package_name)
|
|
109
|
+
workflows_root = output_dir / ".github" / "workflows"
|
|
110
|
+
if workflows_root.exists():
|
|
111
|
+
shutil.rmtree(workflows_root)
|
|
112
|
+
workflows_root.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
|
|
114
|
+
write_text(
|
|
115
|
+
output_dir / "Makefile",
|
|
116
|
+
render_template("Makefile.tpl", context["makefile"]),
|
|
117
|
+
)
|
|
118
|
+
write_text(
|
|
119
|
+
workflows_root / "tests.yml",
|
|
120
|
+
render_template("github/workflows/tests.yml.tpl", context),
|
|
121
|
+
)
|
|
122
|
+
write_text(
|
|
123
|
+
workflows_root / "source-sync.yml",
|
|
124
|
+
render_template("github/workflows/source-sync.yml.tpl", context),
|
|
125
|
+
)
|
|
126
|
+
write_text(
|
|
127
|
+
workflows_root / "publish.yml",
|
|
128
|
+
render_template("github/workflows/publish.yml.tpl", context),
|
|
129
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .file_utils import write_text
|
|
7
|
+
from .generator_config import config_section
|
|
8
|
+
from .template_engine import render_template
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_gitignore_context() -> dict[str, Any]:
|
|
12
|
+
gitignore_config = config_section("gitignore")
|
|
13
|
+
patterns = gitignore_config.get("patterns")
|
|
14
|
+
if not isinstance(patterns, list) or not patterns:
|
|
15
|
+
raise RuntimeError("gitignore.patterns must be a non-empty list.")
|
|
16
|
+
|
|
17
|
+
normalized_patterns: list[str] = []
|
|
18
|
+
for index, pattern in enumerate(patterns):
|
|
19
|
+
if not isinstance(pattern, str):
|
|
20
|
+
raise TypeError(f"gitignore.patterns[{index}] must be a string.")
|
|
21
|
+
normalized_patterns.append(pattern.rstrip())
|
|
22
|
+
|
|
23
|
+
return {"patterns": normalized_patterns}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def generate_gitignore_project(output_dir: Path) -> None:
|
|
27
|
+
context = build_gitignore_context()
|
|
28
|
+
write_text(
|
|
29
|
+
output_dir / ".gitignore",
|
|
30
|
+
render_template("gitignore.tpl", context),
|
|
31
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .file_utils import write_text
|
|
9
|
+
from .template_engine import render_template
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_github_issue_templates_context(project: dict[str, Any]) -> dict[str, Any] | None:
|
|
13
|
+
app = project.get("app", {})
|
|
14
|
+
issue_templates = app.get("issue_templates")
|
|
15
|
+
if issue_templates is None:
|
|
16
|
+
return None
|
|
17
|
+
if not isinstance(issue_templates, dict):
|
|
18
|
+
raise RuntimeError("app.issue_templates must be a table.")
|
|
19
|
+
|
|
20
|
+
assignee = str(issue_templates.get("assignee", "")).strip()
|
|
21
|
+
if not assignee:
|
|
22
|
+
raise RuntimeError("app.issue_templates.assignee must be a non-empty string.")
|
|
23
|
+
|
|
24
|
+
contact_links = build_issue_template_contact_links(app)
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
"assignee": assignee,
|
|
28
|
+
"contact_links": contact_links,
|
|
29
|
+
"yaml_value": lambda value: json.dumps(value, ensure_ascii=False),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_issue_template_contact_links(app: dict[str, Any]) -> list[dict[str, str]]:
|
|
34
|
+
package_owner = str(app.get("package_owner", "")).strip()
|
|
35
|
+
package_name = str(app.get("package_name", "")).strip()
|
|
36
|
+
if not package_owner or not package_name:
|
|
37
|
+
raise RuntimeError("app.package_owner and app.package_name are required to generate issue templates.")
|
|
38
|
+
|
|
39
|
+
contact_links: list[dict[str, str]] = [
|
|
40
|
+
{
|
|
41
|
+
"name": "📖 Read the docs",
|
|
42
|
+
"url": f"https://{package_owner.lower()}.github.io/{package_name}/quick_start.html",
|
|
43
|
+
"about": "Start here for “how-to” questions.",
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
social = app.get("social")
|
|
48
|
+
if social is None:
|
|
49
|
+
return contact_links
|
|
50
|
+
if not isinstance(social, dict):
|
|
51
|
+
raise RuntimeError("app.social must be a table when issue templates are enabled.")
|
|
52
|
+
|
|
53
|
+
discord_url = str(social.get("discord", "")).strip()
|
|
54
|
+
if discord_url:
|
|
55
|
+
contact_links.append(
|
|
56
|
+
{
|
|
57
|
+
"name": "💬 Discord server (Discussions)",
|
|
58
|
+
"url": discord_url,
|
|
59
|
+
"about": "General Q&A and community support.",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
telegram_url = str(social.get("telegram", "")).strip()
|
|
64
|
+
if telegram_url:
|
|
65
|
+
contact_links.append(
|
|
66
|
+
{
|
|
67
|
+
"name": "💬 Telegram channel (Discussions)",
|
|
68
|
+
"url": telegram_url,
|
|
69
|
+
"about": "General Q&A and community support.",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return contact_links
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def generate_github_issue_templates_project(project: dict[str, Any], output_dir: Path) -> None:
|
|
77
|
+
issue_templates_root = output_dir / ".github" / "ISSUE_TEMPLATE"
|
|
78
|
+
context = build_github_issue_templates_context(project)
|
|
79
|
+
if issue_templates_root.exists():
|
|
80
|
+
shutil.rmtree(issue_templates_root)
|
|
81
|
+
if context is None:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
issue_templates_root.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
write_text(
|
|
86
|
+
issue_templates_root / "config.yml",
|
|
87
|
+
render_template("github/issue_templates/config.yml.tpl", context),
|
|
88
|
+
)
|
|
89
|
+
write_text(
|
|
90
|
+
issue_templates_root / "bug_report.yml",
|
|
91
|
+
render_template("github/issue_templates/bug_report.yml.tpl", context),
|
|
92
|
+
)
|
|
93
|
+
write_text(
|
|
94
|
+
issue_templates_root / "documentation_issue.yml",
|
|
95
|
+
render_template("github/issue_templates/documentation_issue.yml.tpl", context),
|
|
96
|
+
)
|
|
97
|
+
write_text(
|
|
98
|
+
issue_templates_root / "feature_request.yml",
|
|
99
|
+
render_template("github/issue_templates/feature_request.yml.tpl", context),
|
|
100
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from PIL import Image, ImageOps, UnidentifiedImageError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
LOGO_LIGHT_NAME = "logo-light.webp"
|
|
10
|
+
LOGO_DARK_NAME = "logo-dark.webp"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_logo_assets(
|
|
14
|
+
project: dict[str, Any],
|
|
15
|
+
source_root: Path,
|
|
16
|
+
static_root: Path,
|
|
17
|
+
) -> dict[str, str] | None:
|
|
18
|
+
logo_value = str(project.get("app", {}).get("logo") or "").strip()
|
|
19
|
+
if not logo_value:
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
source_path = resolve_logo_source_path(source_root, logo_value)
|
|
23
|
+
if not source_path.is_file():
|
|
24
|
+
raise FileNotFoundError(f'app.logo points to a missing file: {source_path}')
|
|
25
|
+
|
|
26
|
+
source_image = load_logo_image(source_path)
|
|
27
|
+
source_polarity = detect_logo_polarity(source_image, source_path)
|
|
28
|
+
light_image, dark_image = build_logo_variants(source_image, source_polarity)
|
|
29
|
+
|
|
30
|
+
light_target = static_root / LOGO_LIGHT_NAME
|
|
31
|
+
dark_target = static_root / LOGO_DARK_NAME
|
|
32
|
+
save_logo_variant(light_image, light_target)
|
|
33
|
+
save_logo_variant(dark_image, dark_target)
|
|
34
|
+
return {
|
|
35
|
+
"light_name": LOGO_LIGHT_NAME,
|
|
36
|
+
"dark_name": LOGO_DARK_NAME,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_logo_source_path(source_root: Path, logo_value: str) -> Path:
|
|
41
|
+
candidate = Path(logo_value).expanduser()
|
|
42
|
+
if candidate.is_absolute():
|
|
43
|
+
return candidate.resolve()
|
|
44
|
+
return (source_root / candidate).resolve()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_logo_image(source_path: Path) -> Image.Image:
|
|
48
|
+
try:
|
|
49
|
+
with Image.open(source_path) as image:
|
|
50
|
+
return image.convert("RGBA")
|
|
51
|
+
except (OSError, UnidentifiedImageError) as exc:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f'app.logo must point to a raster image Pillow can read: {source_path}'
|
|
54
|
+
) from exc
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def detect_logo_polarity(source_image: Image.Image, source_path: Path) -> str:
|
|
58
|
+
pixels = source_image.load()
|
|
59
|
+
width, height = source_image.size
|
|
60
|
+
visible_pixels = 0
|
|
61
|
+
visible_red_sum = 0
|
|
62
|
+
for y in range(height):
|
|
63
|
+
for x in range(width):
|
|
64
|
+
red, green, blue, alpha = pixels[x, y]
|
|
65
|
+
if alpha == 0:
|
|
66
|
+
continue
|
|
67
|
+
visible_pixels += 1
|
|
68
|
+
if red != green or green != blue:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"app.logo must be a monochrome black-and-white raster image; "
|
|
71
|
+
f"colored pixel at ({x}, {y}) is RGBA=({red}, {green}, {blue}, {alpha}): {source_path}"
|
|
72
|
+
)
|
|
73
|
+
visible_red_sum += red
|
|
74
|
+
if visible_pixels == 0:
|
|
75
|
+
raise ValueError(f"app.logo must contain at least one visible pixel: {source_path}")
|
|
76
|
+
return "white" if visible_red_sum * 2 >= visible_pixels * 255 else "black"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_logo_variants(source_image: Image.Image, source_polarity: str) -> tuple[Image.Image, Image.Image]:
|
|
80
|
+
black_variant = source_image if source_polarity == "black" else invert_logo_image(source_image)
|
|
81
|
+
white_variant = source_image if source_polarity == "white" else invert_logo_image(source_image)
|
|
82
|
+
return black_variant, white_variant
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def invert_logo_image(source_image: Image.Image) -> Image.Image:
|
|
86
|
+
red, green, blue, alpha = source_image.split()
|
|
87
|
+
rgb = Image.merge("RGB", (red, green, blue))
|
|
88
|
+
inverted_rgb = ImageOps.invert(rgb)
|
|
89
|
+
return Image.merge("RGBA", (*inverted_rgb.split(), alpha))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def save_logo_variant(source_image: Image.Image, target_path: Path) -> None:
|
|
93
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
try:
|
|
95
|
+
source_image.save(target_path, format="WEBP", lossless=True, method=6, exact=True)
|
|
96
|
+
except (OSError, ValueError) as exc:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"app.logo output requires WebP support in Pillow: {target_path}"
|
|
99
|
+
) from exc
|