resforge 0.2.0__tar.gz → 0.3.1__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.
- {resforge-0.2.0 → resforge-0.3.1}/PKG-INFO +41 -80
- {resforge-0.2.0 → resforge-0.3.1}/README.md +38 -78
- {resforge-0.2.0 → resforge-0.3.1}/pyproject.toml +53 -3
- resforge-0.3.1/resforge/_utils.py +62 -0
- {resforge-0.2.0 → resforge-0.3.1}/resforge/android/__init__.py +5 -4
- resforge-0.3.1/resforge/android/compose.py +140 -0
- resforge-0.3.1/resforge/android/types.py +75 -0
- {resforge-0.2.0 → resforge-0.3.1}/resforge/android/values.py +38 -26
- resforge-0.2.0/resforge/apple/base.py → resforge-0.3.1/resforge/apple/_base.py +10 -9
- resforge-0.2.0/resforge/apple/colorset.py → resforge-0.3.1/resforge/apple/_colorset.py +15 -14
- {resforge-0.2.0 → resforge-0.3.1}/resforge/apple/catalog.py +13 -9
- {resforge-0.2.0 → resforge-0.3.1}/resforge/apple/types.py +21 -5
- resforge-0.3.1/resforge/codegen/kotlin.py +100 -0
- resforge-0.3.1/resforge/types.py +74 -0
- resforge-0.3.1/tests/__init__.py +0 -0
- resforge-0.3.1/tests/test_android_compose.py +130 -0
- resforge-0.2.0/tests/test_android.py → resforge-0.3.1/tests/test_android_values.py +12 -23
- resforge-0.2.0/tests/test_ios_catalog.py → resforge-0.3.1/tests/test_apple_catalog.py +1 -1
- resforge-0.3.1/tests/test_atomic_write.py +35 -0
- resforge-0.3.1/tests/test_codegen_kotlin.py +117 -0
- resforge-0.3.1/tests/test_types.py +53 -0
- resforge-0.2.0/resforge/android/dimension.py +0 -61
- resforge-0.2.0/resforge/android/plural.py +0 -22
- resforge-0.2.0/resforge/types.py +0 -56
- resforge-0.2.0/resforge/utils.py +0 -19
- resforge-0.2.0/tests/test_types.py +0 -58
- {resforge-0.2.0 → resforge-0.3.1}/.github/workflows/ci.yml +0 -0
- {resforge-0.2.0 → resforge-0.3.1}/.github/workflows/publish.yml +0 -0
- {resforge-0.2.0 → resforge-0.3.1}/.gitignore +0 -0
- {resforge-0.2.0 → resforge-0.3.1}/LICENSE +0 -0
- {resforge-0.2.0 → resforge-0.3.1}/resforge/__init__.py +0 -0
- {resforge-0.2.0 → resforge-0.3.1}/resforge/apple/__init__.py +1 -1
- {resforge-0.2.0/tests → resforge-0.3.1/resforge/codegen}/__init__.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: resforge
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A type-safe Python DSL for cross-platform mobile resource generation
|
|
5
5
|
Project-URL: Homepage, https://kipila.dev
|
|
6
|
-
Project-URL: Repository, https://github.com/
|
|
6
|
+
Project-URL: Repository, https://github.com/kipila-dev/resforge
|
|
7
7
|
Author-email: Ondrej Kipila <ondrej@kipila.dev>
|
|
8
8
|
License: MIT
|
|
9
9
|
License-File: LICENSE
|
|
@@ -17,13 +17,21 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
17
17
|
Classifier: Topic :: Software Development :: Build Tools
|
|
18
18
|
Requires-Python: >=3.12
|
|
19
19
|
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: defusedxml; extra == 'dev'
|
|
20
21
|
Requires-Dist: pytest; extra == 'dev'
|
|
21
22
|
Description-Content-Type: text/markdown
|
|
22
23
|
|
|
23
24
|
# resforge
|
|
24
25
|
|
|
25
|
-
A type-safe Python DSL for generating Android
|
|
26
|
-
|
|
26
|
+
A type-safe Python DSL for generating native Android and iOS resources from design tokens.
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
30
|
+
- Fluent, Pythonic API
|
|
31
|
+
- Jetpack Compose theme generation with type-safe color and dimension properties
|
|
32
|
+
- Supports all Android `res/values/` types
|
|
33
|
+
- Native Apple Asset Catalog (`.xcassets`) with dark mode support
|
|
34
|
+
- Built-in validation for resource names and color formats
|
|
27
35
|
|
|
28
36
|
## Installation
|
|
29
37
|
|
|
@@ -33,7 +41,33 @@ pip install resforge
|
|
|
33
41
|
|
|
34
42
|
## Quick Example
|
|
35
43
|
|
|
36
|
-
### Android
|
|
44
|
+
### Android (Jetpack Compose)
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from resforge.android import ComposeWriter, dp
|
|
48
|
+
|
|
49
|
+
with ComposeWriter("Theme.kt", "dev.kipila.example") as compose:
|
|
50
|
+
compose.dimension(border=dp(8))
|
|
51
|
+
with compose.object_("AppColors") as colors:
|
|
52
|
+
colors.color(primary="#FF0000", background="#FFFFFF")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```kotlin
|
|
56
|
+
package dev.kipila.example
|
|
57
|
+
|
|
58
|
+
import androidx.compose.ui.graphics.Color
|
|
59
|
+
import androidx.compose.ui.unit.Dp
|
|
60
|
+
import androidx.compose.ui.unit.dp
|
|
61
|
+
|
|
62
|
+
val border: Dp = 8.dp
|
|
63
|
+
|
|
64
|
+
object AppColors {
|
|
65
|
+
val primary: Color = Color(0xFFFF0000)
|
|
66
|
+
val background: Color = Color(0xFFFFFFFF)
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Android (XML Resources)
|
|
37
71
|
|
|
38
72
|
```python
|
|
39
73
|
from resforge.android import ValuesWriter, dp, sp
|
|
@@ -123,83 +157,10 @@ with AssetCatalog("App", "Assets") as ac:
|
|
|
123
157
|
}
|
|
124
158
|
```
|
|
125
159
|
|
|
126
|
-
## Features
|
|
127
|
-
|
|
128
|
-
- Fluent, Pythonic API
|
|
129
|
-
- Supports all Android `res/values/` types
|
|
130
|
-
- Built-in validation for Asset Catalog logic
|
|
131
|
-
|
|
132
|
-
## Full Android Example
|
|
133
|
-
|
|
134
|
-
```python
|
|
135
|
-
from resforge.android import PluralValues, ValuesWriter, dp, sp
|
|
136
|
-
|
|
137
|
-
with ValuesWriter("res/values/resources.xml") as res:
|
|
138
|
-
res.comment("Strings")
|
|
139
|
-
res.string(
|
|
140
|
-
app_name="My App",
|
|
141
|
-
welcome_message="Welcome to My App!",
|
|
142
|
-
)
|
|
143
|
-
|
|
144
|
-
res.comment("Booleans")
|
|
145
|
-
res.boolean(
|
|
146
|
-
feature_enabled=True,
|
|
147
|
-
dark_mode=False,
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
res.comment("Colors")
|
|
151
|
-
res.color(
|
|
152
|
-
primary="#FF6200EE",
|
|
153
|
-
secondary="#FF03DAC5",
|
|
154
|
-
accent="#6200EE",
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
res.comment("Dimensions")
|
|
158
|
-
res.dimension(
|
|
159
|
-
padding_small=dp(8),
|
|
160
|
-
padding_large=dp(24),
|
|
161
|
-
text_body=sp(16),
|
|
162
|
-
text_heading=sp(24),
|
|
163
|
-
)
|
|
164
|
-
|
|
165
|
-
res.comment("Integers")
|
|
166
|
-
res.integer(
|
|
167
|
-
max_retries=3,
|
|
168
|
-
timeout_seconds=30,
|
|
169
|
-
)
|
|
170
|
-
|
|
171
|
-
res.comment("Resource IDs")
|
|
172
|
-
res.res_id("btn_submit", "tv_title", "iv_logo")
|
|
173
|
-
|
|
174
|
-
res.comment("String arrays")
|
|
175
|
-
res.string_array("planets", ["Mercury", "Venus", "Earth", "Mars"])
|
|
176
|
-
|
|
177
|
-
res.comment("Integer arrays")
|
|
178
|
-
res.integer_array("fibonacci", [1, 1, 2, 3, 5, 8, 13])
|
|
179
|
-
|
|
180
|
-
res.comment("Typed arrays")
|
|
181
|
-
res.typed_array(
|
|
182
|
-
"icons", ["@drawable/home", "@drawable/settings", "@drawable/logout"]
|
|
183
|
-
)
|
|
184
|
-
|
|
185
|
-
res.comment("Plurals")
|
|
186
|
-
res.plurals(
|
|
187
|
-
item_count=PluralValues(one="%d item", other="%d items"),
|
|
188
|
-
file_count=PluralValues(one="%d file", other="%d files"),
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
-
res.comment("Styles")
|
|
192
|
-
res.style(
|
|
193
|
-
"AppTheme",
|
|
194
|
-
parent="Theme.MaterialComponents.DayNight",
|
|
195
|
-
colorPrimary="@color/primary",
|
|
196
|
-
colorSecondary="@color/secondary",
|
|
197
|
-
)
|
|
198
|
-
```
|
|
199
|
-
|
|
200
160
|
## Roadmap
|
|
201
161
|
|
|
202
|
-
-
|
|
162
|
+
- Asset Catalog image support (ImageSet, IconSet)
|
|
163
|
+
- Android `res/drawable` vector asset support
|
|
203
164
|
|
|
204
165
|
## License
|
|
205
166
|
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
# resforge
|
|
2
2
|
|
|
3
|
-
A type-safe Python DSL for generating Android
|
|
4
|
-
|
|
3
|
+
A type-safe Python DSL for generating native Android and iOS resources from design tokens.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Fluent, Pythonic API
|
|
8
|
+
- Jetpack Compose theme generation with type-safe color and dimension properties
|
|
9
|
+
- Supports all Android `res/values/` types
|
|
10
|
+
- Native Apple Asset Catalog (`.xcassets`) with dark mode support
|
|
11
|
+
- Built-in validation for resource names and color formats
|
|
5
12
|
|
|
6
13
|
## Installation
|
|
7
14
|
|
|
@@ -11,7 +18,33 @@ pip install resforge
|
|
|
11
18
|
|
|
12
19
|
## Quick Example
|
|
13
20
|
|
|
14
|
-
### Android
|
|
21
|
+
### Android (Jetpack Compose)
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from resforge.android import ComposeWriter, dp
|
|
25
|
+
|
|
26
|
+
with ComposeWriter("Theme.kt", "dev.kipila.example") as compose:
|
|
27
|
+
compose.dimension(border=dp(8))
|
|
28
|
+
with compose.object_("AppColors") as colors:
|
|
29
|
+
colors.color(primary="#FF0000", background="#FFFFFF")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```kotlin
|
|
33
|
+
package dev.kipila.example
|
|
34
|
+
|
|
35
|
+
import androidx.compose.ui.graphics.Color
|
|
36
|
+
import androidx.compose.ui.unit.Dp
|
|
37
|
+
import androidx.compose.ui.unit.dp
|
|
38
|
+
|
|
39
|
+
val border: Dp = 8.dp
|
|
40
|
+
|
|
41
|
+
object AppColors {
|
|
42
|
+
val primary: Color = Color(0xFFFF0000)
|
|
43
|
+
val background: Color = Color(0xFFFFFFFF)
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Android (XML Resources)
|
|
15
48
|
|
|
16
49
|
```python
|
|
17
50
|
from resforge.android import ValuesWriter, dp, sp
|
|
@@ -101,83 +134,10 @@ with AssetCatalog("App", "Assets") as ac:
|
|
|
101
134
|
}
|
|
102
135
|
```
|
|
103
136
|
|
|
104
|
-
## Features
|
|
105
|
-
|
|
106
|
-
- Fluent, Pythonic API
|
|
107
|
-
- Supports all Android `res/values/` types
|
|
108
|
-
- Built-in validation for Asset Catalog logic
|
|
109
|
-
|
|
110
|
-
## Full Android Example
|
|
111
|
-
|
|
112
|
-
```python
|
|
113
|
-
from resforge.android import PluralValues, ValuesWriter, dp, sp
|
|
114
|
-
|
|
115
|
-
with ValuesWriter("res/values/resources.xml") as res:
|
|
116
|
-
res.comment("Strings")
|
|
117
|
-
res.string(
|
|
118
|
-
app_name="My App",
|
|
119
|
-
welcome_message="Welcome to My App!",
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
res.comment("Booleans")
|
|
123
|
-
res.boolean(
|
|
124
|
-
feature_enabled=True,
|
|
125
|
-
dark_mode=False,
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
res.comment("Colors")
|
|
129
|
-
res.color(
|
|
130
|
-
primary="#FF6200EE",
|
|
131
|
-
secondary="#FF03DAC5",
|
|
132
|
-
accent="#6200EE",
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
res.comment("Dimensions")
|
|
136
|
-
res.dimension(
|
|
137
|
-
padding_small=dp(8),
|
|
138
|
-
padding_large=dp(24),
|
|
139
|
-
text_body=sp(16),
|
|
140
|
-
text_heading=sp(24),
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
res.comment("Integers")
|
|
144
|
-
res.integer(
|
|
145
|
-
max_retries=3,
|
|
146
|
-
timeout_seconds=30,
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
res.comment("Resource IDs")
|
|
150
|
-
res.res_id("btn_submit", "tv_title", "iv_logo")
|
|
151
|
-
|
|
152
|
-
res.comment("String arrays")
|
|
153
|
-
res.string_array("planets", ["Mercury", "Venus", "Earth", "Mars"])
|
|
154
|
-
|
|
155
|
-
res.comment("Integer arrays")
|
|
156
|
-
res.integer_array("fibonacci", [1, 1, 2, 3, 5, 8, 13])
|
|
157
|
-
|
|
158
|
-
res.comment("Typed arrays")
|
|
159
|
-
res.typed_array(
|
|
160
|
-
"icons", ["@drawable/home", "@drawable/settings", "@drawable/logout"]
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
res.comment("Plurals")
|
|
164
|
-
res.plurals(
|
|
165
|
-
item_count=PluralValues(one="%d item", other="%d items"),
|
|
166
|
-
file_count=PluralValues(one="%d file", other="%d files"),
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
res.comment("Styles")
|
|
170
|
-
res.style(
|
|
171
|
-
"AppTheme",
|
|
172
|
-
parent="Theme.MaterialComponents.DayNight",
|
|
173
|
-
colorPrimary="@color/primary",
|
|
174
|
-
colorSecondary="@color/secondary",
|
|
175
|
-
)
|
|
176
|
-
```
|
|
177
|
-
|
|
178
137
|
## Roadmap
|
|
179
138
|
|
|
180
|
-
-
|
|
139
|
+
- Asset Catalog image support (ImageSet, IconSet)
|
|
140
|
+
- Android `res/drawable` vector asset support
|
|
181
141
|
|
|
182
142
|
## License
|
|
183
143
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "resforge"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "A type-safe Python DSL for cross-platform mobile resource generation"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -32,8 +32,58 @@ classifiers = [
|
|
|
32
32
|
]
|
|
33
33
|
|
|
34
34
|
[project.optional-dependencies]
|
|
35
|
-
dev = ["pytest"]
|
|
35
|
+
dev = ["pytest", "defusedxml"]
|
|
36
36
|
|
|
37
37
|
[project.urls]
|
|
38
38
|
Homepage = "https://kipila.dev"
|
|
39
|
-
Repository = "https://github.com/
|
|
39
|
+
Repository = "https://github.com/kipila-dev/resforge"
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
target-version = "py312"
|
|
43
|
+
line-length = 120
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint]
|
|
46
|
+
select = ["ALL"]
|
|
47
|
+
ignore = [
|
|
48
|
+
"ANN001",
|
|
49
|
+
"ANN002",
|
|
50
|
+
"ANN003",
|
|
51
|
+
"COM812",
|
|
52
|
+
"COM819",
|
|
53
|
+
"D100",
|
|
54
|
+
"D102",
|
|
55
|
+
"D104",
|
|
56
|
+
"D105",
|
|
57
|
+
"D107",
|
|
58
|
+
"D206",
|
|
59
|
+
"D300",
|
|
60
|
+
"D401",
|
|
61
|
+
"E111",
|
|
62
|
+
"E114",
|
|
63
|
+
"E117",
|
|
64
|
+
"FIX",
|
|
65
|
+
"I001",
|
|
66
|
+
"PLR2004",
|
|
67
|
+
"Q000",
|
|
68
|
+
"Q001",
|
|
69
|
+
"Q002",
|
|
70
|
+
"Q003",
|
|
71
|
+
"TD",
|
|
72
|
+
"W191",
|
|
73
|
+
]
|
|
74
|
+
fixable = ["ALL"]
|
|
75
|
+
|
|
76
|
+
[tool.ruff.lint.per-file-ignores]
|
|
77
|
+
"tests/**/*.py" = [
|
|
78
|
+
"ANN201",
|
|
79
|
+
"D101",
|
|
80
|
+
"D103",
|
|
81
|
+
"EM101",
|
|
82
|
+
"S101",
|
|
83
|
+
"TRY301",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
[tool.basedpyright]
|
|
87
|
+
venvPath = "."
|
|
88
|
+
venv = ".venv"
|
|
89
|
+
typeCheckingMode = "standard"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import Callable, Generator
|
|
3
|
+
from contextlib import contextmanager, suppress
|
|
4
|
+
from functools import wraps
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from tempfile import NamedTemporaryFile
|
|
7
|
+
from typing import IO, Concatenate
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def require_context[T, **P, R](
|
|
11
|
+
func: Callable[Concatenate[T, P], R],
|
|
12
|
+
) -> Callable[Concatenate[T, P], R]:
|
|
13
|
+
"""Ensures a method is only called within an active context.
|
|
14
|
+
|
|
15
|
+
Raises:
|
|
16
|
+
RuntimeError: If the method is called while the instance's
|
|
17
|
+
`_active` attribute is False or missing.
|
|
18
|
+
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@wraps(func)
|
|
22
|
+
def wrapper(self: T, *args: P.args, **kwargs: P.kwargs) -> R:
|
|
23
|
+
if not getattr(self, "_active", False):
|
|
24
|
+
msg = f"'{func.__name__}' requires an active 'with' context."
|
|
25
|
+
raise RuntimeError(msg)
|
|
26
|
+
return func(self, *args, **kwargs)
|
|
27
|
+
|
|
28
|
+
return wrapper
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@contextmanager
|
|
32
|
+
def atomic_write(target_path: Path) -> Generator[IO[bytes], None, None]:
|
|
33
|
+
"""Yields a temporary file, then atomically replaces target_path on success."""
|
|
34
|
+
target_path = Path(target_path)
|
|
35
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
temp_path = None
|
|
38
|
+
try:
|
|
39
|
+
with NamedTemporaryFile(
|
|
40
|
+
dir=target_path.parent, delete=False, suffix=".tmp"
|
|
41
|
+
) as tf:
|
|
42
|
+
temp_path = Path(tf.name)
|
|
43
|
+
yield tf
|
|
44
|
+
|
|
45
|
+
tf.flush()
|
|
46
|
+
os.fsync(tf.fileno())
|
|
47
|
+
|
|
48
|
+
temp_path.replace(target_path)
|
|
49
|
+
|
|
50
|
+
if os.name == "posix":
|
|
51
|
+
flags = os.O_RDONLY
|
|
52
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
53
|
+
flags |= os.O_DIRECTORY
|
|
54
|
+
dir_fd = os.open(target_path.parent, flags)
|
|
55
|
+
try:
|
|
56
|
+
os.fsync(dir_fd)
|
|
57
|
+
finally:
|
|
58
|
+
os.close(dir_fd)
|
|
59
|
+
finally:
|
|
60
|
+
if temp_path and temp_path.exists():
|
|
61
|
+
with suppress(OSError):
|
|
62
|
+
temp_path.unlink()
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
from .
|
|
2
|
-
from .
|
|
1
|
+
from .compose import ComposeWriter
|
|
2
|
+
from .types import Dimension, PluralValues, dp, inch, mm, pt, px, sp
|
|
3
3
|
from .values import ValuesWriter
|
|
4
4
|
|
|
5
5
|
__all__ = [
|
|
6
|
+
"ComposeWriter",
|
|
6
7
|
"Dimension",
|
|
8
|
+
"PluralValues",
|
|
9
|
+
"ValuesWriter",
|
|
7
10
|
"dp",
|
|
8
11
|
"inch",
|
|
9
12
|
"mm",
|
|
10
13
|
"pt",
|
|
11
14
|
"px",
|
|
12
15
|
"sp",
|
|
13
|
-
"PluralValues",
|
|
14
|
-
"ValuesWriter",
|
|
15
16
|
]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING, Self
|
|
5
|
+
|
|
6
|
+
from resforge._utils import atomic_write, require_context
|
|
7
|
+
from resforge.codegen.kotlin import KotlinFile, KotlinObject
|
|
8
|
+
from resforge.types import Color
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from resforge.android.types import Dimension
|
|
12
|
+
|
|
13
|
+
__all__ = ["ComposeWriter"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _ComposeContext:
|
|
17
|
+
def __init__(self, file: KotlinFile) -> None:
|
|
18
|
+
self._file = file
|
|
19
|
+
|
|
20
|
+
def add_imports(self, *fqns: str) -> None:
|
|
21
|
+
for fqn in fqns:
|
|
22
|
+
self._file.import_(fqn)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _BaseComposeScope:
|
|
26
|
+
def __init__(self, ctx: _ComposeContext, target: KotlinFile | KotlinObject) -> None:
|
|
27
|
+
self._ctx = ctx
|
|
28
|
+
self._target = target
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def _to_compose_color_literal(color: Color) -> str:
|
|
32
|
+
return f"Color({color.hex.replace('#', '0x')})"
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _to_compose_dimen_literal(dimen: Dimension) -> str:
|
|
36
|
+
return f"{dimen.value}.{dimen.unit}"
|
|
37
|
+
|
|
38
|
+
@require_context
|
|
39
|
+
def color(self, **values: str | Color) -> Self:
|
|
40
|
+
"""Appends one or more Color properties to the Kotlin object."""
|
|
41
|
+
if values:
|
|
42
|
+
self._ctx.add_imports("androidx.compose.ui.graphics.Color")
|
|
43
|
+
for name, color in values.items():
|
|
44
|
+
resolved = Color(color)
|
|
45
|
+
self._target.property(
|
|
46
|
+
name=name,
|
|
47
|
+
type_="Color",
|
|
48
|
+
value=self._to_compose_color_literal(resolved),
|
|
49
|
+
)
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
@require_context
|
|
53
|
+
def dimension(self, **values: Dimension) -> Self:
|
|
54
|
+
"""Appends one or more Dimension properties to the Kotlin object. Supports dp, sp and em.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
ValueError: If an unsupported unit (not dp, sp, or em) is provided.
|
|
58
|
+
|
|
59
|
+
"""
|
|
60
|
+
mapping = {"dp": "Dp", "sp": "Sp", "em": "TextUnit"}
|
|
61
|
+
for name, dimen in values.items():
|
|
62
|
+
if dimen.unit not in mapping:
|
|
63
|
+
msg = f"Unsupported dimension type. Only dp, sp and em are supported (got {dimen.unit})."
|
|
64
|
+
raise ValueError(msg)
|
|
65
|
+
self._ctx.add_imports(
|
|
66
|
+
f"androidx.compose.ui.unit.{dimen.unit}",
|
|
67
|
+
f"androidx.compose.ui.unit.{mapping[dimen.unit]}",
|
|
68
|
+
)
|
|
69
|
+
self._target.property(
|
|
70
|
+
name=name,
|
|
71
|
+
type_=mapping[dimen.unit],
|
|
72
|
+
value=self._to_compose_dimen_literal(dimen),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _ObjectScope(_BaseComposeScope):
|
|
79
|
+
def __enter__(self) -> Self:
|
|
80
|
+
self._active = True
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
def __exit__(self, *_) -> None:
|
|
84
|
+
self._active = False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ComposeWriter(_BaseComposeScope):
|
|
88
|
+
"""A fluent context manager for generating Jetpack Compose color definitions.
|
|
89
|
+
|
|
90
|
+
Writes a Kotlin file containing typed Color or Dimension properties.
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
>>> with ComposeWriter("ui/theme/Color.kt", package="com.example.theme", object_name="AppColors") as compose:
|
|
94
|
+
... compose.color(primary="#6200EE", background="#FFFFFF")
|
|
95
|
+
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, path: str | Path, package: str) -> None:
|
|
99
|
+
"""Initializes the ComposeWriter.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
path: The filesystem path where the Kotlin file will be saved.
|
|
103
|
+
package: The Kotlin package declaration.
|
|
104
|
+
|
|
105
|
+
"""
|
|
106
|
+
self._path = Path(path)
|
|
107
|
+
self._package = package
|
|
108
|
+
self._active = False
|
|
109
|
+
|
|
110
|
+
def __enter__(self) -> Self:
|
|
111
|
+
self._active = True
|
|
112
|
+
self._kotlin_file = KotlinFile(package=self._package)
|
|
113
|
+
|
|
114
|
+
ctx = _ComposeContext(self._kotlin_file)
|
|
115
|
+
super().__init__(ctx, self._kotlin_file)
|
|
116
|
+
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def __exit__(self, exc_type, *_) -> None:
|
|
120
|
+
try:
|
|
121
|
+
if exc_type is None:
|
|
122
|
+
with atomic_write(self._path) as tf:
|
|
123
|
+
self._kotlin_file.write(tf)
|
|
124
|
+
finally:
|
|
125
|
+
self._active = False
|
|
126
|
+
|
|
127
|
+
@require_context
|
|
128
|
+
def object_(self, name: str) -> _ObjectScope:
|
|
129
|
+
"""Creates a new Kotlin object within the file.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
name: The name of the Kotlin object to generate.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
A new context tied to the generated object.
|
|
136
|
+
|
|
137
|
+
"""
|
|
138
|
+
obj = KotlinObject(name=name)
|
|
139
|
+
self._kotlin_file.member(obj)
|
|
140
|
+
return _ObjectScope(self._ctx, obj)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Literal, NotRequired, TypedDict
|
|
4
|
+
|
|
5
|
+
DimensionUnit = Literal["dp", "sp", "px", "pt", "mm", "in", "em"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Dimension:
|
|
10
|
+
"""Represents an Android dimension value (e.g., '16dp', '12sp').
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
value: The numeric dimension value. Negative values are permitted
|
|
14
|
+
to support negative margins/offsets, though they are
|
|
15
|
+
typically ignored by padding and size attributes.
|
|
16
|
+
unit: The unit of measure (dp, sp, px, pt, mm, in, em).
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
value: int | float
|
|
21
|
+
unit: DimensionUnit
|
|
22
|
+
|
|
23
|
+
def __str__(self) -> str:
|
|
24
|
+
return f"{self.value:g}{self.unit}"
|
|
25
|
+
|
|
26
|
+
def __repr__(self) -> str:
|
|
27
|
+
return f"Dimension(value={self.value!r}, unit={self.unit!r})"
|
|
28
|
+
|
|
29
|
+
def __mul__(self, other: float) -> "Dimension":
|
|
30
|
+
return Dimension(self.value * other, self.unit)
|
|
31
|
+
|
|
32
|
+
__rmul__ = __mul__
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _make_unit_func(
|
|
36
|
+
unit: DimensionUnit, doc: str
|
|
37
|
+
) -> Callable[[int | float], Dimension]:
|
|
38
|
+
def f(value: float) -> Dimension:
|
|
39
|
+
return Dimension(value, unit)
|
|
40
|
+
|
|
41
|
+
f.__name__ = unit
|
|
42
|
+
f.__doc__ = doc
|
|
43
|
+
return f
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
dp = _make_unit_func("dp", "Create a dimension in density-independent pixels (dp).")
|
|
47
|
+
sp = _make_unit_func(
|
|
48
|
+
"sp", "Create a dimension in scale-independent pixels (sp). Use for text sizes."
|
|
49
|
+
)
|
|
50
|
+
px = _make_unit_func("px", "Create a dimension in pixels (px).")
|
|
51
|
+
pt = _make_unit_func("pt", "Create a dimension in points (pt).")
|
|
52
|
+
mm = _make_unit_func("mm", "Create a dimension in millimeters (mm).")
|
|
53
|
+
inch = _make_unit_func("in", "Create a dimension in inches (in).")
|
|
54
|
+
em = _make_unit_func("em", "Create a dimension using a relative font size (em).")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PluralValues(TypedDict):
|
|
58
|
+
"""Represents the quantity-based strings for an Android plurals resource.
|
|
59
|
+
|
|
60
|
+
Attributes:
|
|
61
|
+
zero: String for quantity 0 (optional).
|
|
62
|
+
one: String for quantity 1 (optional).
|
|
63
|
+
two: String for quantity 2 (optional).
|
|
64
|
+
few: String for quantity 'few' (optional).
|
|
65
|
+
many: String for quantity 'many' (optional).
|
|
66
|
+
other: The default fallback string (required).
|
|
67
|
+
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
zero: NotRequired[str]
|
|
71
|
+
one: NotRequired[str]
|
|
72
|
+
two: NotRequired[str]
|
|
73
|
+
few: NotRequired[str]
|
|
74
|
+
many: NotRequired[str]
|
|
75
|
+
other: str
|