forje 0.1.0__tar.gz

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 (40) hide show
  1. forje-0.1.0/.gitignore +224 -0
  2. forje-0.1.0/PKG-INFO +20 -0
  3. forje-0.1.0/README.md +4 -0
  4. forje-0.1.0/pyproject.toml +39 -0
  5. forje-0.1.0/src/forje/__init__.py +1 -0
  6. forje-0.1.0/src/forje/backend/__init__.py +3 -0
  7. forje-0.1.0/src/forje/backend/android.py +49 -0
  8. forje-0.1.0/src/forje/backend/apple.py +55 -0
  9. forje-0.1.0/src/forje/backend/backend.py +22 -0
  10. forje-0.1.0/src/forje/backend/css.py +96 -0
  11. forje-0.1.0/src/forje/cli/__init__.py +0 -0
  12. forje-0.1.0/src/forje/cli/main.py +125 -0
  13. forje-0.1.0/src/forje/cli/ui.py +13 -0
  14. forje-0.1.0/src/forje/cli/utils.py +12 -0
  15. forje-0.1.0/src/forje/core/__init__.py +0 -0
  16. forje-0.1.0/src/forje/core/context.py +51 -0
  17. forje-0.1.0/src/forje/core/driver.py +40 -0
  18. forje-0.1.0/src/forje/core/environment.py +22 -0
  19. forje-0.1.0/src/forje/core/errors.py +27 -0
  20. forje-0.1.0/src/forje/core/frontend.py +118 -0
  21. forje-0.1.0/src/forje/core/loader.py +66 -0
  22. forje-0.1.0/src/forje/core/pass_.py +18 -0
  23. forje-0.1.0/src/forje/dsl/__init__.py +3 -0
  24. forje-0.1.0/src/forje/dsl/core.py +31 -0
  25. forje-0.1.0/src/forje/dsl/core.star +126 -0
  26. forje-0.1.0/src/forje/dsl/module.py +87 -0
  27. forje-0.1.0/src/forje/dsl/stdlib.py +58 -0
  28. forje-0.1.0/src/forje/dsl/stdlib.star +20 -0
  29. forje-0.1.0/src/forje/ir/__init__.py +3 -0
  30. forje-0.1.0/src/forje/ir/models.py +66 -0
  31. forje-0.1.0/src/forje/passes/__init__.py +0 -0
  32. forje-0.1.0/src/forje/passes/codegen.py +21 -0
  33. forje-0.1.0/src/forje/passes/color_norm.py +51 -0
  34. forje-0.1.0/src/forje/passes/validation.py +70 -0
  35. forje-0.1.0/src/forje/py.typed +0 -0
  36. forje-0.1.0/src/forje/wcag/__init__.py +0 -0
  37. forje-0.1.0/src/forje/wcag/dsl.py +5 -0
  38. forje-0.1.0/src/forje/wcag/dsl.star +39 -0
  39. forje-0.1.0/src/forje/wcag/models.py +23 -0
  40. forje-0.1.0/src/forje/wcag/passes.py +108 -0
forje-0.1.0/.gitignore ADDED
@@ -0,0 +1,224 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
219
+
220
+ # Forje build files
221
+ *.forje
222
+
223
+ # macOS
224
+ .DS_Store
forje-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: forje
3
+ Version: 0.1.0
4
+ Summary: The build system for your design system
5
+ Project-URL: Homepage, https://kipila.dev
6
+ Project-URL: Repository, https://github.com/kipila-dev/forje
7
+ Author-email: Kipila Ltd <hello@kipila.dev>
8
+ License: MIT
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: coloraide
11
+ Requires-Dist: pydantic
12
+ Requires-Dist: resforge
13
+ Requires-Dist: starlark-pyo3
14
+ Requires-Dist: typer
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Forje
18
+
19
+ For full documentation, see the
20
+ [main repository README](https://github.com/kipila-dev/forje/blob/main/README.md).
forje-0.1.0/README.md ADDED
@@ -0,0 +1,4 @@
1
+ # Forje
2
+
3
+ For full documentation, see the
4
+ [main repository README](https://github.com/kipila-dev/forje/blob/main/README.md).
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "forje"
7
+ dynamic = ["version"]
8
+ description = "The build system for your design system"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Kipila Ltd", email = "hello@kipila.dev" }]
13
+ dependencies = ["coloraide", "pydantic", "resforge", "starlark-pyo3", "typer"]
14
+
15
+ [project.urls]
16
+ Homepage = "https://kipila.dev"
17
+ Repository = "https://github.com/kipila-dev/forje"
18
+
19
+ [project.scripts]
20
+ forje = "forje.cli.main:app"
21
+
22
+ [project.entry-points."forje.dsl"]
23
+ core = "forje.dsl.core:module"
24
+ stdlib = "forje.dsl.stdlib:module"
25
+ wcag = "forje.wcag.dsl:module"
26
+
27
+ [project.entry-points."forje.pass"]
28
+ wcag = "forje.wcag.passes:WCAGValidation"
29
+
30
+ [project.entry-points."forje.backend"]
31
+ android = "forje.backend.android:Android"
32
+ apple = "forje.backend.apple:Apple"
33
+ css = "forje.backend.css:CSS"
34
+
35
+ [tool.hatch.version]
36
+ path = "src/forje/__init__.py"
37
+
38
+ [tool.uv.sources]
39
+ resforge = { workspace = true }
@@ -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
@@ -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
+ ...
@@ -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
File without changes
@@ -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()
@@ -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)
@@ -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"
File without changes