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