diff --git a/README.md b/README.md index 04565d3..bec69f2 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/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..99d25b1 100644 --- a/resforge/android/values.py +++ b/resforge/android/values.py @@ -3,14 +3,14 @@ from pathlib import Path from typing import Pattern, Self +from resforge.types import Color +from resforge.utils import require_context + 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: @@ -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. @@ -33,13 +33,16 @@ def __init__(self, path: str | Path): 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) @@ -95,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: @@ -103,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. @@ -111,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. @@ -120,43 +136,29 @@ def boolean(self, **values: bool) -> Self: self._append("bool", name, str(val).lower()) return self - def color(self, **values: str | int) -> Self: + @require_context + 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 + @require_context def dimension(self, **values: Dimension) -> Self: """ Appends one or more resources using Dimension objects. @@ -165,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. @@ -174,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). @@ -197,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. @@ -220,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