From 42cb11dbc79230be5d781898d1b15e68fa2b9571 Mon Sep 17 00:00:00 2001 From: Ondrej Kipila Date: Tue, 31 Mar 2026 20:15:27 +0100 Subject: [PATCH 1/4] feat(apple): implement asset catalog foundation --- resforge/android/dimension.py | 2 +- resforge/android/values.py | 2 +- resforge/apple/__init__.py | 3 + resforge/apple/catalog.py | 129 ++++++++++++++++++++++++++++++++++ resforge/apple/colorset.py | 76 ++++++++++++++++++++ resforge/apple/types.py | 18 +++++ 6 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 resforge/apple/__init__.py create mode 100644 resforge/apple/catalog.py create mode 100644 resforge/apple/colorset.py create mode 100644 resforge/apple/types.py diff --git a/resforge/android/dimension.py b/resforge/android/dimension.py index d56a43e..ec44582 100644 --- a/resforge/android/dimension.py +++ b/resforge/android/dimension.py @@ -9,7 +9,7 @@ class Dimension: value: int | float unit: DimensionUnit - def __init__(self, value: int | float, unit: DimensionUnit): + def __init__(self, value: int | float, unit: DimensionUnit) -> None: """ Args: value: The numeric dimension value. Negative values are permitted diff --git a/resforge/android/values.py b/resforge/android/values.py index dfc2d21..c8749f3 100644 --- a/resforge/android/values.py +++ b/resforge/android/values.py @@ -25,7 +25,7 @@ class ValuesWriter: ... res.dimension(padding_small=dp(8)).color(primary=0xFF0000) """ - def __init__(self, path: str | Path): + def __init__(self, path: str | Path) -> None: """ Args: path: The filesystem path where the XML will be saved. diff --git a/resforge/apple/__init__.py b/resforge/apple/__init__.py new file mode 100644 index 0000000..ed8b211 --- /dev/null +++ b/resforge/apple/__init__.py @@ -0,0 +1,3 @@ +from .catalog import AssetCatalog + +__all__ = ["AssetCatalog"] diff --git a/resforge/apple/catalog.py b/resforge/apple/catalog.py new file mode 100644 index 0000000..b9c8579 --- /dev/null +++ b/resforge/apple/catalog.py @@ -0,0 +1,129 @@ +import json +import shutil +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, Dict, Self + +from .colorset import Color, ColorSet + + +def _write_contents(path: str | Path, contents: Dict[str, Any]) -> None: + path = Path(path) + with open(path / "Contents.json", "w") as f: + json.dump(contents, f, indent=2) + + +class AssetNode(ABC): + def __init__(self, path: str | Path, name: str, extension: str) -> None: + self._path = Path(path) / f"{name}.{extension}" + + def __enter__(self) -> Self: + self._path.mkdir(parents=True, exist_ok=True) + return self + + def __exit__(self, exc_type, *_) -> None: + if exc_type is None: + contents = self._create_contents() + _write_contents(self._path, contents) + else: + if self._path.exists(): + shutil.rmtree(self._path) + + @abstractmethod + def _create_contents(self) -> Dict[str, Any]: ... + + +class AssetCatalog: + def __init__(self, path: str | Path, name: str) -> None: + output_dir = Path(path).resolve() + self._temp_path = output_dir / f".tmp_{name}.xcassets" + self._final_path = output_dir / f"{name}.xcassets" + + def __enter__(self) -> Self: + if self._temp_path.exists(): + shutil.rmtree(self._temp_path) + self._temp_path.mkdir(parents=True) + return self + + def __exit__(self, exc_type, *_) -> None: + try: + if exc_type is None: + contents = {"info": {"author": "xcode", "version": 1}} + _write_contents(self._temp_path, contents) + if self._final_path.exists(): + shutil.rmtree(self._final_path) + self._temp_path.rename(self._final_path) + finally: + if self._temp_path.exists(): + shutil.rmtree(self._temp_path) + + def appiconset(self, name: str) -> Self: + return self + + def arimageset(self, name: str) -> Self: + return self + + def arresourcegroup(self, name: str) -> Self: + return self + + def brandassets(self, name: str) -> Self: + return self + + def cubetextureset(self, name: str) -> Self: + return self + + def dataset(self, name: str) -> Self: + return self + + def gcdashboardimage(self, name: str) -> Self: + return self + + def gcleaderboard(self, name: str) -> Self: + return self + + def gcleaderboardset(self, name: str) -> Self: + return self + + def group(self, name: str) -> Self: + return self + + def iconset(self, name: str) -> Self: + return self + + def imageset(self, name: str) -> Self: + return self + + def imagestack(self, name: str) -> Self: + return self + + def imagestacklayer(self, name: str) -> Self: + return self + + def launchimage(self, name: str) -> Self: + return self + + def mipmapset(self, name: str) -> Self: + return self + + def colorset(self, name: str, *color: Color) -> Self: + with ColorSet(self._temp_path, name) as cs: + cs.color(*color) + return self + + def spriteatlas(self, name: str) -> Self: + return self + + def sticker(self, name: str) -> Self: + return self + + def stickerpack(self, name: str) -> Self: + return self + + def stickersequence(self, name: str) -> Self: + return self + + def textureset(self, name: str) -> Self: + return self + + def complicationset(self, name: str) -> Self: + return self diff --git a/resforge/apple/colorset.py b/resforge/apple/colorset.py new file mode 100644 index 0000000..373071f --- /dev/null +++ b/resforge/apple/colorset.py @@ -0,0 +1,76 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Literal + +from .catalog import AssetNode +from .types import ColorSpace, DisplayGamut, Idiom + + +@dataclass +class Color: + red: float + green: float + blue: float + alpha: float = 1.0 + color_space: ColorSpace | None = None + appearance: Literal["light", "dark"] | None = None + display_gamut: DisplayGamut | None = None + idiom: Idiom = "universal" + + def __post_init__(self) -> None: + for field in (self.red, self.green, self.blue, self.alpha): + if not 0.0 <= field <= 1.0: + raise ValueError( + f"Color components must be between 0 and 1 (got {field})" + ) + + def to_dict(self) -> Dict[str, Any]: + result: dict[str, Any] = { + "color": { + "components": { + "red": self.red, + "green": self.green, + "blue": self.blue, + "alpha": self.alpha, + }, + } + } + + if self.color_space is not None: + result["color"]["color-space"] = self.color_space + + if self.appearance is not None: + result["appearances"] = [ + {"appearance": "luminosity", "value": self.appearance} + ] + + if self.display_gamut is not None: + result["display-gamut"] = self.display_gamut + + if self.idiom is not None: + result["idiom"] = self.idiom + + return result + + +class ColorSet(AssetNode): + def __init__(self, path: str | Path, name: str) -> None: + super().__init__(path, name, "colorset") + self._colors: list[Color] = [] + + def color(self, *color: Color) -> None: + self._colors.extend(color) + + def _create_contents(self) -> Dict[str, Any]: + if not self._colors: + raise ValueError("ColorSet requires at least one color") + + appearances = {c.appearance for c in self._colors} + + if "dark" in appearances and appearances.isdisjoint({None, "light"}): + raise ValueError("ColorSet with a dark color requires a light color") + + return { + "info": {"author": "xcode", "version": 1}, + "colors": [color.to_dict() for color in self._colors], + } diff --git a/resforge/apple/types.py b/resforge/apple/types.py new file mode 100644 index 0000000..9ec269b --- /dev/null +++ b/resforge/apple/types.py @@ -0,0 +1,18 @@ +from typing import Literal + +ColorSpace = Literal["srgb", "display-p3"] +DisplayGamut = Literal["sRGB", "display-P3"] +Idiom = Literal[ + "appLauncher", + "companionSettings", + "ios-marketing", + "iphone", + "ipad", + "mac", + "notificationCenter", + "quickLook", + "tv", + "universal", + "watch", + "watch-marketing", +] From a98a09f5e2de3db6fcbc0d299818586e0f77a2fa Mon Sep 17 00:00:00 2001 From: Ondrej Kipila Date: Thu, 2 Apr 2026 16:37:48 +0100 Subject: [PATCH 2/4] refactor: extract color logic into a separate Color type --- resforge/android/values.py | 41 +++++++--------------- resforge/apple/__init__.py | 3 +- resforge/apple/catalog.py | 9 +++-- resforge/apple/colorset.py | 71 +++++++++----------------------------- resforge/apple/types.py | 40 ++++++++++++++++++++- resforge/types.py | 58 +++++++++++++++++++++++++++++++ tests/test_android.py | 17 ++------- tests/test_types.py | 58 +++++++++++++++++++++++++++++++ 8 files changed, 195 insertions(+), 102 deletions(-) create mode 100644 resforge/types.py create mode 100644 tests/test_types.py diff --git a/resforge/android/values.py b/resforge/android/values.py index c8749f3..82e377f 100644 --- a/resforge/android/values.py +++ b/resforge/android/values.py @@ -3,14 +3,13 @@ from pathlib import Path from typing import Pattern, Self +from resforge.types import Color + from .dimension import Dimension from .plural import PluralValues _NAME_PATTERN = re.compile(r"^[a-z_][a-z0-9_]*$") _STYLE_NAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_\.]*$") -_COLOR_PATTERN = re.compile( - r"^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{4}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$" -) class ValuesWriter: @@ -120,41 +119,25 @@ def boolean(self, **values: bool) -> Self: self._append("bool", name, str(val).lower()) return self - def color(self, **values: str | int) -> Self: + def color(self, **values: str | Color) -> Self: """ Appends one or more resources. - Supports hex integers (0xAARRGGBB) and - standard Android hex strings (#RGB, #ARGB, #RRGGBB, #AARRGGBB). + Supports standard Android hex strings (#RGB, #ARGB, #RRGGBB, #AARRGGBB). Raises: - ValueError: If the integer range is invalid or string format is incorrect. + ValueError: If the string format is incorrect. """ - for name, val in values.items(): - if isinstance(val, int): - if 0 <= val <= 0xFFFFFF: - color_str = f"#FF{val:06X}" - elif 0xFFFFFF < val <= 0xFFFFFFFF: - color_str = f"#{val:08X}" - else: - raise ValueError( - f"Color '{name}' has invalid integer value: {val:#x}" - ) - - elif isinstance(val, str): - if not _COLOR_PATTERN.match(val): - raise ValueError( - f"Color '{name}' has invalid format: '{val}'. " - "Expected #RGB, #ARGB, #RRGGBB, or #AARRGGBB." - ) - color_str = val - - else: + for name, color in values.items(): + if not isinstance(color, str | Color): raise TypeError( - f"Color '{name}' must be str or int, got {type(val).__name__}" + f"Color '{name}' must be str or Color, got {type(color).__name__}" ) - self._append("color", name, color_str.upper()) + if isinstance(color, str): + color = Color.from_hex(color) + + self._append("color", name, color.to_hex) return self def dimension(self, **values: Dimension) -> Self: diff --git a/resforge/apple/__init__.py b/resforge/apple/__init__.py index ed8b211..f735bff 100644 --- a/resforge/apple/__init__.py +++ b/resforge/apple/__init__.py @@ -1,3 +1,4 @@ from .catalog import AssetCatalog +from .types import AppleColor -__all__ = ["AssetCatalog"] +__all__ = ["AssetCatalog", "AppleColor"] diff --git a/resforge/apple/catalog.py b/resforge/apple/catalog.py index b9c8579..6de6151 100644 --- a/resforge/apple/catalog.py +++ b/resforge/apple/catalog.py @@ -4,7 +4,10 @@ from pathlib import Path from typing import Any, Dict, Self -from .colorset import Color, ColorSet +from resforge.types import Color + +from .colorset import ColorSet +from .types import AppleColor def _write_contents(path: str | Path, contents: Dict[str, Any]) -> None: @@ -105,9 +108,9 @@ def launchimage(self, name: str) -> Self: def mipmapset(self, name: str) -> Self: return self - def colorset(self, name: str, *color: Color) -> Self: + def colorset(self, name: str, *colors: str | Color | AppleColor) -> Self: with ColorSet(self._temp_path, name) as cs: - cs.color(*color) + cs.color(*colors) return self def spriteatlas(self, name: str) -> Self: diff --git a/resforge/apple/colorset.py b/resforge/apple/colorset.py index 373071f..edaf304 100644 --- a/resforge/apple/colorset.py +++ b/resforge/apple/colorset.py @@ -1,65 +1,28 @@ -from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, Literal +from typing import Any, Dict, assert_never -from .catalog import AssetNode -from .types import ColorSpace, DisplayGamut, Idiom - - -@dataclass -class Color: - red: float - green: float - blue: float - alpha: float = 1.0 - color_space: ColorSpace | None = None - appearance: Literal["light", "dark"] | None = None - display_gamut: DisplayGamut | None = None - idiom: Idiom = "universal" - - def __post_init__(self) -> None: - for field in (self.red, self.green, self.blue, self.alpha): - if not 0.0 <= field <= 1.0: - raise ValueError( - f"Color components must be between 0 and 1 (got {field})" - ) - - def to_dict(self) -> Dict[str, Any]: - result: dict[str, Any] = { - "color": { - "components": { - "red": self.red, - "green": self.green, - "blue": self.blue, - "alpha": self.alpha, - }, - } - } +from resforge.types import Color - if self.color_space is not None: - result["color"]["color-space"] = self.color_space - - if self.appearance is not None: - result["appearances"] = [ - {"appearance": "luminosity", "value": self.appearance} - ] - - if self.display_gamut is not None: - result["display-gamut"] = self.display_gamut - - if self.idiom is not None: - result["idiom"] = self.idiom - - return result +from .catalog import AssetNode +from .types import AppleColor class ColorSet(AssetNode): def __init__(self, path: str | Path, name: str) -> None: super().__init__(path, name, "colorset") - self._colors: list[Color] = [] - - def color(self, *color: Color) -> None: - self._colors.extend(color) + self._colors: list[AppleColor] = [] + + def color(self, *colors: str | Color | AppleColor) -> None: + for c in colors: + match c: + case str(): + self._colors.append(AppleColor(components=Color.from_hex(c))) + case Color(): + self._colors.append(AppleColor(components=c)) + case AppleColor(): + self._colors.append(c) + case _: + assert_never(c) def _create_contents(self) -> Dict[str, Any]: if not self._colors: diff --git a/resforge/apple/types.py b/resforge/apple/types.py index 9ec269b..9591dec 100644 --- a/resforge/apple/types.py +++ b/resforge/apple/types.py @@ -1,4 +1,7 @@ -from typing import Literal +from dataclasses import dataclass +from typing import Any, Dict, Literal + +from resforge.types import Color ColorSpace = Literal["srgb", "display-p3"] DisplayGamut = Literal["sRGB", "display-P3"] @@ -16,3 +19,38 @@ "watch", "watch-marketing", ] + + +@dataclass +class AppleColor: + components: Color + color_space: ColorSpace | None = None + appearance: Literal["light", "dark"] | None = None + display_gamut: DisplayGamut | None = None + idiom: Idiom = "universal" + + def to_dict(self) -> Dict[str, Any]: + result: dict[str, Any] = { + "idiom": self.idiom, + "color": { + "components": { + "red": f"{self.components.red:.3f}", + "green": f"{self.components.green:.3f}", + "blue": f"{self.components.blue:.3f}", + "alpha": f"{self.components.alpha:.3f}", + }, + }, + } + + if self.color_space is not None: + result["color"]["color-space"] = self.color_space + + if self.appearance is not None: + result["appearances"] = [ + {"appearance": "luminosity", "value": self.appearance} + ] + + if self.display_gamut is not None: + result["display-gamut"] = self.display_gamut + + return result diff --git a/resforge/types.py b/resforge/types.py new file mode 100644 index 0000000..aeda6ee --- /dev/null +++ b/resforge/types.py @@ -0,0 +1,58 @@ +import re +from dataclasses import dataclass +from typing import Self + +__all__ = ["Color"] + +_HEX_COLOR_RE = re.compile(r"#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})") + + +def _parse_hex(value: str) -> tuple[float, float, float, float]: + if not _HEX_COLOR_RE.fullmatch(value): + raise ValueError(f"Invalid hex color: {value!r}") + + hex_str = value[1:] + + if len(hex_str) in (3, 4): + hex_str = "".join(c * 2 for c in hex_str) + + if len(hex_str) == 6: + hex_str = "FF" + hex_str + + a, r, g, b = bytes.fromhex(hex_str) + + return a / 255, r / 255, g / 255, b / 255 + + +@dataclass(frozen=True) +class Color: + """ + Represents a color using float components (0.0 to 1.0). + """ + + red: float + green: float + blue: float + alpha: float = 1.0 + + def __post_init__(self) -> None: + for field in (self.red, self.green, self.blue, self.alpha): + if not 0.0 <= field <= 1.0: + raise ValueError( + f"Color components must be between 0 and 1 (got {field})" + ) + + @classmethod + def from_hex(cls, value: str) -> Self: + """Accepts #RGB, #ARGB, #RRGGBB, or #AARRGGBB.""" + a, r, g, b = _parse_hex(value) + return cls(red=r, green=g, blue=b, alpha=a) + + @property + def to_hex(self) -> str: + """Returns the color as a #AARRGGBB string.""" + a = round(self.alpha * 255) + r = round(self.red * 255) + g = round(self.green * 255) + b = round(self.blue * 255) + return f"#{a:02x}{r:02x}{g:02x}{b:02x}".upper() diff --git a/tests/test_android.py b/tests/test_android.py index 22770e3..4906d08 100644 --- a/tests/test_android.py +++ b/tests/test_android.py @@ -5,6 +5,7 @@ from resforge.android import (PluralValues, ValuesWriter, dp, inch, mm, pt, px, sp) +from resforge.types import Color class TestDimension: @@ -153,16 +154,9 @@ def test_hex_string(self, xml_path: Path): assert elem is not None assert elem.text == "#FF6200EE" - def test_int_rgb(self, xml_path: Path): + def test_color(self, xml_path: Path): with ValuesWriter(xml_path) as res: - res.color(primary=0x6200EE) - elem = parse(xml_path).find("color[@name='primary']") - assert elem is not None - assert elem.text == "#FF6200EE" - - def test_int_argb(self, xml_path: Path): - with ValuesWriter(xml_path) as res: - res.color(primary=0xFF6200EE) + res.color(primary=Color.from_hex("#FF6200EE")) elem = parse(xml_path).find("color[@name='primary']") assert elem is not None assert elem.text == "#FF6200EE" @@ -172,11 +166,6 @@ def test_invalid_string_raises(self, xml_path: Path): with ValuesWriter(xml_path) as res: res.color(primary="notacolor") - def test_invalid_int_raises(self, xml_path: Path): - with pytest.raises(ValueError): - with ValuesWriter(xml_path) as res: - res.color(primary=-1) - class TestDimensionWriter: def test_dp(self, xml_path: Path): diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..1cbeafe --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,58 @@ +import pytest + +from resforge.types import Color + + +class TestColor: + def test_from_components(self): + c = Color(red=0.5, green=0.5, blue=0.5) + assert c.red == 0.5 and c.green == 0.5 and c.blue == 0.5 + assert c.alpha == 1.0 + + def test_component_out_of_range_raises(self): + with pytest.raises(ValueError, match="between 0 and 1"): + Color(red=1.1, green=-1.1, blue=2.0) + + def test_from_hex_rgb(self): + c = Color.from_hex("#FFF") + assert c.red == 1.0 and c.green == 1.0 and c.blue == 1.0 + assert c.alpha == 1.0 + + def test_from_hex_argb(self): + c = Color.from_hex("#8FFF") + assert c.red == 1.0 and c.green == 1.0 and c.blue == 1.0 + assert c.alpha == pytest.approx(0x88 / 255) + + def test_from_hex_rrggbb(self): + c = Color.from_hex("#FFFFFF") + assert c.red == 1.0 and c.green == 1.0 and c.blue == 1.0 + assert c.alpha == 1.0 + + def test_from_hex_aarrggbb(self): + c = Color.from_hex("#80FFFFFF") + assert c.red == 1.0 and c.green == 1.0 and c.blue == 1.0 + assert c.alpha == pytest.approx(0x80 / 255) + + @pytest.mark.parametrize("invalid_hex", ["#FF", "#FFFFF", "#GGGGGG", "FFF"]) + def test_from_hex_invalid_raises(self, invalid_hex): + with pytest.raises(ValueError, match="Invalid hex color"): + Color.from_hex(invalid_hex) + + def test_equality(self): + assert Color(0.1, 0.2, 0.3) == Color(0.1, 0.2, 0.3) + + def test_hex_case_insensitive(self): + assert Color.from_hex("#abc") == Color.from_hex("#ABC") + + def test_to_hex(self): + c = Color(red=1.0, green=0.0, blue=0.0) + assert c.to_hex == "#FFFF0000" + + def test_to_hex_zero_padding(self): + c = Color(red=0.0, green=0.0, blue=0.0, alpha=0.0) + assert c.to_hex == "#00000000" + + def test_to_hex_round_trip(self): + hex = "#12345678" + c = Color.from_hex(hex) + assert c.to_hex == hex From cfeab3deaa427f9871c2638fdd944ec88790fcd4 Mon Sep 17 00:00:00 2001 From: Ondrej Kipila Date: Fri, 3 Apr 2026 01:05:07 +0100 Subject: [PATCH 3/4] feat(apple): implement colorset support --- README.md | 85 ++++++++++++++++++------- resforge/android/values.py | 36 ++++++++--- resforge/apple/__init__.py | 13 +++- resforge/apple/base.py | 31 ++++++++++ resforge/apple/catalog.py | 124 +++++++++---------------------------- resforge/apple/colorset.py | 36 ++++++++--- resforge/apple/types.py | 48 +++++++------- resforge/utils.py | 19 ++++++ tests/test_ios_catalog.py | 66 ++++++++++++++++++++ tests/test_types.py | 6 +- 10 files changed, 302 insertions(+), 162 deletions(-) create mode 100644 resforge/apple/base.py create mode 100644 resforge/utils.py create mode 100644 tests/test_ios_catalog.py diff --git a/README.md b/README.md index 04565d3..07cb930 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,7 @@ # resforge -A fluent Python library for generating Android XML resource files. - -Generate Android resource files programmatically instead of writing XML by hand. -Perfect for CI pipelines, design system syncing, and large-scale apps. - -## Why resforge? - -Writing Android XML manually doesn’t scale well when: - -- Resources are generated from data (APIs, JSON, design tokens) -- You maintain multiple brands / environments -- Values need to stay consistent across files -- You want automation in CI/CD - -**resforge** lets you define everything in Python and generate clean, valid XML automatically. +A fluent Python library for generating Android XML resources and Xcode Asset +Catalogs (.xcassets). ## Installation @@ -24,6 +11,8 @@ pip install resforge ## Quick Example +### Android + ```python from resforge.android import ValuesWriter, dp, sp @@ -44,8 +33,6 @@ with ValuesWriter("res/values/resources.xml") as res: ) ``` -### Output - ```xml My App @@ -59,15 +46,69 @@ with ValuesWriter("res/values/resources.xml") as res: ``` +### Asset Catalog (iOS) + +```python +from resforge.apple import Appearance, AppleColor, AssetCatalog +from resforge.types import Color + +with AssetCatalog("iOS/App", "Assets") as ac: + ac.colorset( + "Background", + "#ffffff", + AppleColor(Color.from_hex("#000000"), appearances=[Appearance.Dark]), + ) + +``` + +```json +{ + "info": { + "author": "xcode", + "version": 1 + }, + "colors": [ + { + "idiom": "universal", + "color": { + "components": { + "red": "1.000", + "green": "1.000", + "blue": "1.000", + "alpha": "1.000" + }, + "color-space": "srgb" + } + }, + { + "idiom": "universal", + "color": { + "components": { + "red": "0.000", + "green": "0.000", + "blue": "0.000", + "alpha": "1.000" + }, + "color-space": "srgb" + }, + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ] +} +``` + ## Features - Fluent, Pythonic API - Supports all Android `res/values/` types -- Automatic XML formatting -- Clean grouping with comments -- CI/CD friendly +- Built-in validation for Asset Catalog logic -## Full Example +## Full Android Example ```python from resforge.android import PluralValues, ValuesWriter, dp, sp @@ -137,7 +178,7 @@ with ValuesWriter("res/values/resources.xml") as res: ## Roadmap -- iOS support (`xcassets`) +- Support for more Asset Catalog types (ImageSet, IconSet) ## License diff --git a/resforge/android/values.py b/resforge/android/values.py index 82e377f..99d25b1 100644 --- a/resforge/android/values.py +++ b/resforge/android/values.py @@ -4,6 +4,7 @@ from typing import Pattern, Self from resforge.types import Color +from resforge.utils import require_context from .dimension import Dimension from .plural import PluralValues @@ -32,13 +33,16 @@ def __init__(self, path: str | Path) -> None: self._path = Path(path) self._root: ET.Element | None = None self._seen_names: dict[str, set[str]] = {} + self._active = False def __enter__(self) -> Self: + self._active = True self._root = ET.Element("resources") self._seen_names = {} return self def __exit__(self, exc_type, *_) -> None: + self._active = False try: if exc_type is None and self._root is not None: self._path.parent.mkdir(parents=True, exist_ok=True) @@ -94,6 +98,17 @@ def _append( elem.text = text return elem + def _array( + self, tag: str, name: str, values: list[int] | list[str], escape: bool = False + ) -> Self: + parent = self._append(tag, name) + for val in values: + item = ET.SubElement(parent, "item") + sanitized = self._prepare_text(str(val)) if escape else str(val) + item.text = str(val).lower() if isinstance(val, bool) else sanitized + return self + + @require_context def comment(self, text: str) -> Self: """Appends an XML comment to group or annotate resources.""" if self._root is None: @@ -102,6 +117,7 @@ def comment(self, text: str) -> Self: self._root.append(ET.Comment(f" {sanitized} ")) return self + @require_context def string(self, **values: str) -> Self: """ Appends one or more resources. @@ -110,6 +126,7 @@ def string(self, **values: str) -> Self: self._append("string", name, self._prepare_text(val)) return self + @require_context def boolean(self, **values: bool) -> Self: """ Appends one or more resources. @@ -119,6 +136,7 @@ def boolean(self, **values: bool) -> Self: self._append("bool", name, str(val).lower()) return self + @require_context def color(self, **values: str | Color) -> Self: """ Appends one or more resources. @@ -140,6 +158,7 @@ def color(self, **values: str | Color) -> Self: self._append("color", name, color.to_hex) return self + @require_context def dimension(self, **values: Dimension) -> Self: """ Appends one or more resources using Dimension objects. @@ -148,6 +167,7 @@ def dimension(self, **values: Dimension) -> Self: self._append("dimen", name, str(val)) return self + @require_context def res_id(self, *values: str) -> Self: """ Appends one or more resources. @@ -157,22 +177,14 @@ def res_id(self, *values: str) -> Self: self._append("item", name, attrs={"type": "id"}) return self + @require_context def integer(self, **values: int) -> Self: """Appends one or more resources.""" for name, val in values.items(): self._append("integer", name, str(val)) return self - def _array( - self, tag: str, name: str, values: list[int] | list[str], escape: bool = False - ) -> Self: - parent = self._append(tag, name) - for val in values: - item = ET.SubElement(parent, "item") - sanitized = self._prepare_text(str(val)) if escape else str(val) - item.text = str(val).lower() if isinstance(val, bool) else sanitized - return self - + @require_context def typed_array(self, name: str, values: list[str]) -> Self: """ Appends a generic (Typed Array). @@ -180,14 +192,17 @@ def typed_array(self, name: str, values: list[str]) -> Self: """ return self._array("array", name, values) + @require_context def integer_array(self, name: str, values: list[int]) -> Self: """Appends an resource.""" return self._array("integer-array", name, values) + @require_context def string_array(self, name: str, values: list[str]) -> Self: """Appends a resource.""" return self._array("string-array", name, values) + @require_context def plurals(self, **values: PluralValues) -> Self: """ Appends a resource with quantity-specific strings. @@ -203,6 +218,7 @@ def plurals(self, **values: PluralValues) -> Self: item.text = self._prepare_text(str(text)) return self + @require_context def style(self, name: str, parent: str | None = None, **items: str) -> Self: """ Appends a