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,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,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .package_writer import generate_project
4
+ from .project_model import build_project
5
+
6
+ __all__ = ["build_project", "generate_project"]
@@ -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