forje 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.
- forje/__init__.py +1 -0
- forje/backend/__init__.py +3 -0
- forje/backend/android.py +49 -0
- forje/backend/apple.py +55 -0
- forje/backend/backend.py +22 -0
- forje/backend/css.py +96 -0
- forje/cli/__init__.py +0 -0
- forje/cli/main.py +125 -0
- forje/cli/ui.py +13 -0
- forje/cli/utils.py +12 -0
- forje/core/__init__.py +0 -0
- forje/core/context.py +51 -0
- forje/core/driver.py +40 -0
- forje/core/environment.py +22 -0
- forje/core/errors.py +27 -0
- forje/core/frontend.py +118 -0
- forje/core/loader.py +66 -0
- forje/core/pass_.py +18 -0
- forje/dsl/__init__.py +3 -0
- forje/dsl/core.py +31 -0
- forje/dsl/core.star +126 -0
- forje/dsl/module.py +87 -0
- forje/dsl/stdlib.py +58 -0
- forje/dsl/stdlib.star +20 -0
- forje/ir/__init__.py +3 -0
- forje/ir/models.py +66 -0
- forje/passes/__init__.py +0 -0
- forje/passes/codegen.py +21 -0
- forje/passes/color_norm.py +51 -0
- forje/passes/validation.py +70 -0
- forje/py.typed +0 -0
- forje/wcag/__init__.py +0 -0
- forje/wcag/dsl.py +5 -0
- forje/wcag/dsl.star +39 -0
- forje/wcag/models.py +23 -0
- forje/wcag/passes.py +108 -0
- forje-0.1.0.dist-info/METADATA +20 -0
- forje-0.1.0.dist-info/RECORD +40 -0
- forje-0.1.0.dist-info/WHEEL +4 -0
- forje-0.1.0.dist-info/entry_points.txt +15 -0
forje/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
forje/backend/android.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import final, override
|
|
3
|
+
|
|
4
|
+
from resforge import Color
|
|
5
|
+
from resforge.android import ValuesWriter
|
|
6
|
+
from resforge.io import MemorySink, WriteSink
|
|
7
|
+
|
|
8
|
+
from forje.backend import Backend
|
|
9
|
+
from forje.ir import ArtifactNode, TargetNode, TokenMapping
|
|
10
|
+
from forje.ir.models import ColorNode
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _to_color(node: ColorNode) -> Color:
|
|
14
|
+
return Color(
|
|
15
|
+
x=node.coords[0],
|
|
16
|
+
y=node.coords[1],
|
|
17
|
+
z=node.coords[2],
|
|
18
|
+
alpha=node.alpha,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _write_tokens(sink: WriteSink, path: Path, nodes: dict[str, ColorNode]) -> None:
|
|
23
|
+
with ValuesWriter(path, sink=sink) as res:
|
|
24
|
+
colors = {name: _to_color(node) for name, node in nodes.items()}
|
|
25
|
+
res.color(**colors)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@final
|
|
29
|
+
class Android(Backend):
|
|
30
|
+
"""Generates Android XML resource files for colors from design tokens."""
|
|
31
|
+
|
|
32
|
+
@override
|
|
33
|
+
def codegen(self, target: TargetNode, artifact: ArtifactNode) -> dict[str, bytes]:
|
|
34
|
+
colors = [t for t in target.tokens.values() if t.kind == "color"]
|
|
35
|
+
|
|
36
|
+
def get_mapping(mode: TokenMapping) -> dict[str, ColorNode]:
|
|
37
|
+
return {t.name: t.mapping[mode] for t in colors if mode in t.mapping}
|
|
38
|
+
|
|
39
|
+
light = get_mapping("light")
|
|
40
|
+
dark = get_mapping("dark")
|
|
41
|
+
|
|
42
|
+
sink = MemorySink()
|
|
43
|
+
base_path = Path(artifact.path)
|
|
44
|
+
stem = artifact.stem or "colors"
|
|
45
|
+
|
|
46
|
+
_write_tokens(sink, base_path / "values" / f"{stem}.xml", light)
|
|
47
|
+
_write_tokens(sink, base_path / "values-night" / f"{stem}.xml", dark)
|
|
48
|
+
|
|
49
|
+
return sink.files
|
forje/backend/apple.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import final, override
|
|
3
|
+
|
|
4
|
+
from resforge import Color
|
|
5
|
+
from resforge.apple import Appearance, AppleColor, AssetCatalog
|
|
6
|
+
from resforge.io import MemorySink
|
|
7
|
+
|
|
8
|
+
from forje.backend import Backend
|
|
9
|
+
from forje.ir import ArtifactNode, ColorNode, TargetNode, TokenMapping
|
|
10
|
+
|
|
11
|
+
_APPEARANCE_MAP: dict[TokenMapping, list[Appearance]] = {
|
|
12
|
+
"light": [],
|
|
13
|
+
"dark": [Appearance.Dark],
|
|
14
|
+
"high_contrast_light": [Appearance.HighContrast, Appearance.Light],
|
|
15
|
+
"high_contrast_dark": [Appearance.HighContrast, Appearance.Dark],
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _to_pascal_case(value: str) -> str:
|
|
20
|
+
words = re.split(r"[-_]+", value)
|
|
21
|
+
return "".join(w.capitalize() for w in words if w)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _to_color(node: ColorNode) -> Color:
|
|
25
|
+
return Color(
|
|
26
|
+
x=node.coords[0],
|
|
27
|
+
y=node.coords[1],
|
|
28
|
+
z=node.coords[2],
|
|
29
|
+
alpha=node.alpha,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@final
|
|
34
|
+
class Apple(Backend):
|
|
35
|
+
"""Generates Apple Asset Catalogs from design tokens."""
|
|
36
|
+
|
|
37
|
+
@override
|
|
38
|
+
def codegen(self, target: TargetNode, artifact: ArtifactNode) -> dict[str, bytes]:
|
|
39
|
+
color_tokens = (t for t in target.tokens.values() if t.kind == "color")
|
|
40
|
+
sink = MemorySink()
|
|
41
|
+
|
|
42
|
+
with AssetCatalog(
|
|
43
|
+
artifact.path,
|
|
44
|
+
artifact.stem or "Assets",
|
|
45
|
+
sink=sink,
|
|
46
|
+
) as catalog:
|
|
47
|
+
for token in color_tokens:
|
|
48
|
+
name = _to_pascal_case(token.name)
|
|
49
|
+
apple_colors = [
|
|
50
|
+
AppleColor(_to_color(node), appearances=_APPEARANCE_MAP[mode])
|
|
51
|
+
for mode, node in token.mapping.items()
|
|
52
|
+
]
|
|
53
|
+
catalog.colorset(name, *apple_colors)
|
|
54
|
+
|
|
55
|
+
return sink.files
|
forje/backend/backend.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from typing import Protocol, runtime_checkable
|
|
2
|
+
|
|
3
|
+
from forje.ir import ArtifactNode, TargetNode
|
|
4
|
+
|
|
5
|
+
__all__ = ["Backend"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class Backend(Protocol):
|
|
10
|
+
"""Interface for platform-specific code generators."""
|
|
11
|
+
|
|
12
|
+
def codegen(
|
|
13
|
+
self,
|
|
14
|
+
target: TargetNode,
|
|
15
|
+
artifact: ArtifactNode,
|
|
16
|
+
) -> dict[str, bytes]:
|
|
17
|
+
"""Generates platform assets for a target.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
File paths relative to the base directory mapped to their contents.
|
|
21
|
+
"""
|
|
22
|
+
...
|
forje/backend/css.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import final, override
|
|
4
|
+
|
|
5
|
+
from resforge import Color
|
|
6
|
+
from resforge.io import MemorySink
|
|
7
|
+
|
|
8
|
+
from forje.backend import Backend
|
|
9
|
+
from forje.ir import ArtifactNode, ColorNode, TargetNode, TokenMapping
|
|
10
|
+
|
|
11
|
+
__all__ = ["CSS"]
|
|
12
|
+
|
|
13
|
+
_QUERY_CONTRAST = "(prefers-contrast: more)"
|
|
14
|
+
_QUERY_LIGHT = "(prefers-color-scheme: light)"
|
|
15
|
+
_QUERY_DARK = "(prefers-color-scheme: dark)"
|
|
16
|
+
_QUERIES: dict[TokenMapping, str | None] = {
|
|
17
|
+
"light": None,
|
|
18
|
+
"dark": f"@media {_QUERY_DARK}",
|
|
19
|
+
"high_contrast_light": f"@media {_QUERY_CONTRAST} and {_QUERY_LIGHT}",
|
|
20
|
+
"high_contrast_dark": f"@media {_QUERY_CONTRAST} and {_QUERY_DARK}",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _to_color(node: ColorNode) -> Color:
|
|
25
|
+
return Color(
|
|
26
|
+
x=node.coords[0],
|
|
27
|
+
y=node.coords[1],
|
|
28
|
+
z=node.coords[2],
|
|
29
|
+
alpha=node.alpha,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _to_css_vars(name: str, node: ColorNode) -> tuple[str, str]:
|
|
34
|
+
name = f"--color-{name.lower().strip().replace('_', '-')}"
|
|
35
|
+
color = _to_color(node)
|
|
36
|
+
|
|
37
|
+
r, g, b, a_srgb = color.to_srgb_components()
|
|
38
|
+
r = round(r * 255)
|
|
39
|
+
g = round(g * 255)
|
|
40
|
+
b = round(b * 255)
|
|
41
|
+
|
|
42
|
+
l, c, h, a_oklch = color.to_oklch_components()
|
|
43
|
+
h = "none" if math.isnan(h) else round(h, 3)
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
f"{name}: rgb({r} {g} {b} / {a_srgb:.3f});",
|
|
47
|
+
f"{name}: oklch({l:.3f} {c:.3f} {h} / {a_oklch:.3f});",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _add_indent(lines: list[str], level: int = 1) -> list[str]:
|
|
52
|
+
return [f"{' ' * level}{l}" for l in lines]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _to_css_block(vars_: list[str], query: str | None = None) -> str:
|
|
56
|
+
query = (query or "").strip()
|
|
57
|
+
|
|
58
|
+
block = [":root {", *_add_indent(vars_), "}"]
|
|
59
|
+
|
|
60
|
+
if query:
|
|
61
|
+
block = [f"{query} {{", *_add_indent(block), "}"]
|
|
62
|
+
|
|
63
|
+
return "\n".join(block)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@final
|
|
67
|
+
class CSS(Backend):
|
|
68
|
+
"""Generates CSS custom properties from design tokens."""
|
|
69
|
+
|
|
70
|
+
@override
|
|
71
|
+
def codegen(self, target: TargetNode, artifact: ArtifactNode) -> dict[str, bytes]:
|
|
72
|
+
vars_: dict[TokenMapping, list[str]] = {
|
|
73
|
+
"light": [],
|
|
74
|
+
"dark": [],
|
|
75
|
+
"high_contrast_light": [],
|
|
76
|
+
"high_contrast_dark": [],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
color_tokens = (t for t in target.tokens.values() if t.kind == "color")
|
|
80
|
+
|
|
81
|
+
for token in color_tokens:
|
|
82
|
+
for mode, node in token.mapping.items():
|
|
83
|
+
vars_[mode].extend(v for v in _to_css_vars(token.name, node))
|
|
84
|
+
|
|
85
|
+
blocks: list[str] = [
|
|
86
|
+
_to_css_block(v, _QUERIES[m]) for m, v in vars_.items() if v
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
css = "\n\n".join(blocks) + "\n"
|
|
90
|
+
|
|
91
|
+
sink = MemorySink()
|
|
92
|
+
sink.write(
|
|
93
|
+
Path(artifact.path) / f"{artifact.stem or 'tokens'}.css",
|
|
94
|
+
css.encode(),
|
|
95
|
+
)
|
|
96
|
+
return sink.files
|
forje/cli/__init__.py
ADDED
|
File without changes
|
forje/cli/main.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from collections.abc import Generator
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from resforge.io import atomic_write
|
|
9
|
+
from rich import print # noqa: A004
|
|
10
|
+
from rich.logging import RichHandler
|
|
11
|
+
|
|
12
|
+
from forje import __version__
|
|
13
|
+
from forje.cli.ui import error, success
|
|
14
|
+
from forje.cli.utils import format_elapsed
|
|
15
|
+
from forje.core.driver import Driver
|
|
16
|
+
from forje.core.environment import Environment
|
|
17
|
+
from forje.core.errors import ForjeError
|
|
18
|
+
from forje.core.loader import load_plugins
|
|
19
|
+
from forje.passes.codegen import Codegen
|
|
20
|
+
from forje.passes.color_norm import ColorCanonicalizer
|
|
21
|
+
from forje.passes.validation import PlatformSupport, TargetFilter, TargetValidation
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from forje.core.pass_ import Pass
|
|
25
|
+
|
|
26
|
+
logging.basicConfig(
|
|
27
|
+
level=logging.INFO,
|
|
28
|
+
format="%(message)s",
|
|
29
|
+
datefmt="[%X]",
|
|
30
|
+
handlers=[RichHandler(rich_tracebacks=True)],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(
|
|
34
|
+
name="forje",
|
|
35
|
+
no_args_is_help=True,
|
|
36
|
+
add_completion=False,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _find_build_file() -> Path | None:
|
|
41
|
+
candidate = Path.cwd() / "build.forje"
|
|
42
|
+
return candidate if candidate.exists() else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _version_callback(*, value: bool) -> None:
|
|
46
|
+
if value:
|
|
47
|
+
print(f"forje {__version__}")
|
|
48
|
+
raise typer.Exit
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.callback()
|
|
52
|
+
def main(
|
|
53
|
+
_: Annotated[
|
|
54
|
+
bool | None,
|
|
55
|
+
typer.Option(
|
|
56
|
+
"--version",
|
|
57
|
+
callback=_version_callback,
|
|
58
|
+
is_eager=True,
|
|
59
|
+
help="Show version and exit.",
|
|
60
|
+
),
|
|
61
|
+
] = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Build design system resources from Starlark definitions."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command()
|
|
67
|
+
def build(
|
|
68
|
+
targets: Annotated[
|
|
69
|
+
list[str] | None,
|
|
70
|
+
typer.Option("--target", help="Target to build."),
|
|
71
|
+
] = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Build design system resources."""
|
|
74
|
+
build_file = _find_build_file()
|
|
75
|
+
|
|
76
|
+
if build_file is None:
|
|
77
|
+
error("build.forje not found in current directory.")
|
|
78
|
+
raise typer.Exit(code=1)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
source = build_file.read_text(encoding="utf-8")
|
|
82
|
+
except OSError as e:
|
|
83
|
+
error(f"Could not read build.forje: {e.strerror}")
|
|
84
|
+
raise typer.Exit(code=1) from e
|
|
85
|
+
|
|
86
|
+
start = time.perf_counter()
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
env = Environment(*load_plugins())
|
|
90
|
+
|
|
91
|
+
pipeline: list[Pass] = [
|
|
92
|
+
TargetFilter(targets),
|
|
93
|
+
TargetValidation(),
|
|
94
|
+
PlatformSupport(env),
|
|
95
|
+
ColorCanonicalizer(),
|
|
96
|
+
*env.passes,
|
|
97
|
+
Codegen(env),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
outputs = Driver(env).build(source, pipeline)
|
|
101
|
+
|
|
102
|
+
for _, _, file_path, file_bytes in _walk_files(outputs):
|
|
103
|
+
with atomic_write(file_path) as f:
|
|
104
|
+
_ = f.write(file_bytes)
|
|
105
|
+
except* ForjeError as eg:
|
|
106
|
+
for e in eg.exceptions:
|
|
107
|
+
notes = " ".join(getattr(e, "__notes__", []))
|
|
108
|
+
error(f"{e} {notes}".strip())
|
|
109
|
+
raise typer.Exit(code=1) from eg
|
|
110
|
+
|
|
111
|
+
elapsed = time.perf_counter() - start
|
|
112
|
+
success(f"Build succeeded in {format_elapsed(elapsed)}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _walk_files(
|
|
116
|
+
results: dict[str, dict[str, dict[str, bytes]]],
|
|
117
|
+
) -> Generator[tuple[str, str, str, bytes]]:
|
|
118
|
+
for target, platforms in results.items():
|
|
119
|
+
for platform, files in platforms.items():
|
|
120
|
+
for file_path, file_bytes in files.items():
|
|
121
|
+
yield target, platform, file_path, file_bytes
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
app()
|
forje/cli/ui.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
__all__ = ["error", "success"]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def error(message: str) -> None:
|
|
7
|
+
"""Prints a formatted error message."""
|
|
8
|
+
typer.secho(f"Error: {message}", fg=typer.colors.RED, err=True)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def success(message: str) -> None:
|
|
12
|
+
"""Prints a formatted success message."""
|
|
13
|
+
typer.secho(message, fg=typer.colors.GREEN)
|
forje/cli/utils.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
__all__ = ["format_elapsed"]
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def format_elapsed(seconds: float) -> str:
|
|
5
|
+
"""Formats a duration in seconds into a human-readable string."""
|
|
6
|
+
if seconds >= 60:
|
|
7
|
+
m = int(seconds // 60)
|
|
8
|
+
s = int(seconds % 60)
|
|
9
|
+
return f"{m}m {s}s"
|
|
10
|
+
if seconds >= 1:
|
|
11
|
+
return f"{seconds:.1f}s"
|
|
12
|
+
return f"{seconds:.3f}s"
|
forje/core/__init__.py
ADDED
|
File without changes
|
forje/core/context.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextvars import ContextVar, Token
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from threading import RLock
|
|
6
|
+
from typing import TYPE_CHECKING, final, override
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from forje.ir import IR
|
|
10
|
+
|
|
11
|
+
__all__ = ["Context", "context_proxy"]
|
|
12
|
+
|
|
13
|
+
_ctx: ContextVar[Context] = ContextVar("ctx")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@final
|
|
17
|
+
@dataclass
|
|
18
|
+
class Context:
|
|
19
|
+
"""Mutable build state for a single Forje evaluation."""
|
|
20
|
+
|
|
21
|
+
ir: IR
|
|
22
|
+
lock: RLock = field(init=False, default_factory=RLock)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _ContextProxy:
|
|
26
|
+
def __getattr__(self, name: str) -> object:
|
|
27
|
+
try:
|
|
28
|
+
return getattr(_ctx.get(), name) # pyright: ignore[reportAny]
|
|
29
|
+
except LookupError as e:
|
|
30
|
+
msg = "No build context active."
|
|
31
|
+
raise RuntimeError(msg) from e
|
|
32
|
+
|
|
33
|
+
@override
|
|
34
|
+
def __repr__(self) -> str:
|
|
35
|
+
try:
|
|
36
|
+
return repr(_ctx.get())
|
|
37
|
+
except LookupError:
|
|
38
|
+
return "<empty build context proxy>"
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def set_context(cls, ctx: Context) -> Token[Context]:
|
|
42
|
+
"""Sets the current context."""
|
|
43
|
+
return _ctx.set(ctx)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def reset_context(cls, token: Token[Context]) -> None:
|
|
47
|
+
"""Resets the context to the state before set_context was called."""
|
|
48
|
+
_ctx.reset(token)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
context_proxy = _ContextProxy()
|
forje/core/driver.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, final
|
|
4
|
+
|
|
5
|
+
from forje.core.frontend import evaluate
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from forje.core.environment import Environment
|
|
9
|
+
from forje.core.pass_ import Pass
|
|
10
|
+
|
|
11
|
+
__all__ = ["Driver"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@final
|
|
15
|
+
class Driver:
|
|
16
|
+
"""Orchestrates the compilation and artifact generation pipeline."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, env: Environment) -> None:
|
|
19
|
+
self._env = env
|
|
20
|
+
|
|
21
|
+
def build(
|
|
22
|
+
self,
|
|
23
|
+
source: str,
|
|
24
|
+
pipeline: list[Pass] | None = None,
|
|
25
|
+
) -> dict[str, dict[str, dict[str, bytes]]]:
|
|
26
|
+
"""Evaluates the build script and runs the pass pipeline.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
source: Contents of a build.forje file.
|
|
30
|
+
pipeline: Ordered list of passes to execute. Defaults to empty.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Nested dict keyed by target id -> platform -> file path -> bytes.
|
|
34
|
+
"""
|
|
35
|
+
ir = evaluate(self._env, source)
|
|
36
|
+
|
|
37
|
+
for pass_ in pipeline or []:
|
|
38
|
+
pass_.run(ir)
|
|
39
|
+
|
|
40
|
+
return ir.outputs
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import final
|
|
3
|
+
|
|
4
|
+
from forje.backend import Backend
|
|
5
|
+
from forje.core.pass_ import Pass
|
|
6
|
+
from forje.dsl import Module
|
|
7
|
+
|
|
8
|
+
__all__ = ["Environment"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@final
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Environment:
|
|
14
|
+
"""Global configuration and available plugins for a Forje build.
|
|
15
|
+
|
|
16
|
+
It is intended to be initialized once by the build system and passed into
|
|
17
|
+
the `Driver` and `Pass` objects.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
modules: list[Module]
|
|
21
|
+
passes: list[Pass]
|
|
22
|
+
backends: dict[str, Backend]
|
forje/core/errors.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
"ForjeError",
|
|
3
|
+
"ForjeEvalError",
|
|
4
|
+
"ForjeParseError",
|
|
5
|
+
"ForjePluginLoadError",
|
|
6
|
+
"ForjeValidationError",
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ForjeError(Exception):
|
|
11
|
+
"""Base class for all Forje errors."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ForjePluginLoadError(ForjeError):
|
|
15
|
+
"""Error while loading external plugin."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ForjeParseError(ForjeError):
|
|
19
|
+
"""Starlark parse error in build.forje."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ForjeEvalError(ForjeError):
|
|
23
|
+
"""Starlark evaluation error in build.forje."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ForjeValidationError(ForjeError):
|
|
27
|
+
"""Domain constraint violation."""
|
forje/core/frontend.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import starlark
|
|
6
|
+
|
|
7
|
+
from forje.core.context import Context, context_proxy
|
|
8
|
+
from forje.core.errors import ForjeEvalError, ForjeParseError
|
|
9
|
+
from forje.ir import IR
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
|
|
14
|
+
from forje.core.environment import Environment
|
|
15
|
+
from forje.dsl.module import Module
|
|
16
|
+
|
|
17
|
+
__all__ = ["evaluate"]
|
|
18
|
+
|
|
19
|
+
_DIALECT = starlark.Dialect.extended()
|
|
20
|
+
_DIALECT.enable_f_strings = True
|
|
21
|
+
|
|
22
|
+
_GLOBALS = starlark.Globals.standard().extended_by(
|
|
23
|
+
[
|
|
24
|
+
starlark.LibraryExtension.EnumType,
|
|
25
|
+
starlark.LibraryExtension.Filter,
|
|
26
|
+
starlark.LibraryExtension.Json,
|
|
27
|
+
starlark.LibraryExtension.Map,
|
|
28
|
+
starlark.LibraryExtension.Partial,
|
|
29
|
+
starlark.LibraryExtension.Pprint,
|
|
30
|
+
starlark.LibraryExtension.Print,
|
|
31
|
+
starlark.LibraryExtension.RecordType,
|
|
32
|
+
starlark.LibraryExtension.RustDecimal,
|
|
33
|
+
starlark.LibraryExtension.StructType,
|
|
34
|
+
starlark.LibraryExtension.Typing,
|
|
35
|
+
],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _build_module(
|
|
40
|
+
module: Module,
|
|
41
|
+
loader: Callable[..., starlark.FrozenModule],
|
|
42
|
+
into: starlark.Module | None = None,
|
|
43
|
+
) -> starlark.Module | starlark.FrozenModule:
|
|
44
|
+
mod = starlark.Module() if into is None else into
|
|
45
|
+
|
|
46
|
+
for name, value in module.python.items():
|
|
47
|
+
if callable(value):
|
|
48
|
+
mod.add_callable(name, value)
|
|
49
|
+
else:
|
|
50
|
+
mod[name] = value
|
|
51
|
+
|
|
52
|
+
for script in module.starlark:
|
|
53
|
+
_parse_and_eval(module.name or "build.forje", script, mod, loader)
|
|
54
|
+
|
|
55
|
+
return mod.freeze() if into is None else mod
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _build_dsl(
|
|
59
|
+
env: Environment,
|
|
60
|
+
) -> tuple[starlark.Module, Callable[..., starlark.FrozenModule]]:
|
|
61
|
+
|
|
62
|
+
modules: dict[str, starlark.FrozenModule] = {}
|
|
63
|
+
default_module = starlark.Module()
|
|
64
|
+
|
|
65
|
+
def loader(name: str) -> starlark.FrozenModule:
|
|
66
|
+
if name in modules:
|
|
67
|
+
return modules[name]
|
|
68
|
+
raise FileNotFoundError
|
|
69
|
+
|
|
70
|
+
for module in env.modules:
|
|
71
|
+
if module.name is None:
|
|
72
|
+
_ = _build_module(module, loader, into=default_module)
|
|
73
|
+
else:
|
|
74
|
+
mod = _build_module(module, loader)
|
|
75
|
+
if isinstance(mod, starlark.FrozenModule):
|
|
76
|
+
modules[module.name] = mod
|
|
77
|
+
|
|
78
|
+
return default_module, loader
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_and_eval(
|
|
82
|
+
name: str,
|
|
83
|
+
source: str,
|
|
84
|
+
module: starlark.Module,
|
|
85
|
+
loader: Callable[..., starlark.FrozenModule],
|
|
86
|
+
) -> None:
|
|
87
|
+
try:
|
|
88
|
+
ast = starlark.parse(name, source, dialect=_DIALECT)
|
|
89
|
+
except starlark.StarlarkError as e:
|
|
90
|
+
raise ForjeParseError(str(e)) from e
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
_ = starlark.eval(module, ast, _GLOBALS, starlark.FileLoader(loader))
|
|
94
|
+
except starlark.StarlarkError as e:
|
|
95
|
+
raise ForjeEvalError(str(e)) from e
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def evaluate(env: Environment, source: str) -> IR:
|
|
99
|
+
"""Evaluate a Forje build script.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
env: The build environment instance containing the standard library
|
|
103
|
+
and all successfully discovered external modules.
|
|
104
|
+
source: The contents of a build.forje file as a string.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
ForjeParseError: If the source contains a Starlark syntax error.
|
|
108
|
+
ForjeEvalError: If the source fails during Starlark evaluation.
|
|
109
|
+
"""
|
|
110
|
+
ctx = Context(ir=IR())
|
|
111
|
+
token = context_proxy.set_context(ctx)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
module, loader = _build_dsl(env)
|
|
115
|
+
_parse_and_eval("build.forje", source, module, loader)
|
|
116
|
+
return ctx.ir
|
|
117
|
+
finally:
|
|
118
|
+
context_proxy.reset_context(token)
|