Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 63 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -24,6 +11,8 @@ pip install resforge

## Quick Example

### Android

```python
from resforge.android import ValuesWriter, dp, sp

Expand All @@ -44,8 +33,6 @@ with ValuesWriter("res/values/resources.xml") as res:
)
```

### Output

```xml
<resources>
<string name="app_name">My App</string>
Expand All @@ -59,15 +46,69 @@ with ValuesWriter("res/values/resources.xml") as res:
</resources>
```

### 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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion resforge/android/dimension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 39 additions & 40 deletions resforge/android/values.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -25,21 +25,24 @@ 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.
"""
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)
Expand Down Expand Up @@ -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:
Expand All @@ -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 <string> resources.
Expand All @@ -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 <bool> resources.
Expand All @@ -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 <color> 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 <dimen> resources using Dimension objects.
Expand All @@ -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 <item type="id"> resources.
Expand All @@ -174,37 +177,32 @@ 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 <integer> 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 <array> (Typed Array).
Used for arrays of references (e.g., drawables or colors).
"""
return self._array("array", name, values)

@require_context
def integer_array(self, name: str, values: list[int]) -> Self:
"""Appends an <integer-array> resource."""
return self._array("integer-array", name, values)

@require_context
def string_array(self, name: str, values: list[str]) -> Self:
"""Appends a <string-array> resource."""
return self._array("string-array", name, values)

@require_context
def plurals(self, **values: PluralValues) -> Self:
"""
Appends a <plurals> resource with quantity-specific strings.
Expand All @@ -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 <style> resource.
Expand Down
13 changes: 13 additions & 0 deletions resforge/apple/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from .catalog import AssetCatalog
from .types import (Appearance, AppleColor, ColorSpace, DisplayGamut, Idiom,
Subtype)

__all__ = [
"AssetCatalog",
"Appearance",
"AppleColor",
"ColorSpace",
"DisplayGamut",
"Idiom",
"Subtype",
]
31 changes: 31 additions & 0 deletions resforge/apple/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import json
import shutil
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, Dict, Self


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]: ...
Loading
Loading