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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .backend import Backend
2
+
3
+ __all__ = ["Backend"]
@@ -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
@@ -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)