resforge 0.3.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.3.0 → resforge-0.3.1}/PKG-INFO +2 -3
- {resforge-0.3.0 → resforge-0.3.1}/README.md +0 -1
- {resforge-0.3.0 → resforge-0.3.1}/pyproject.toml +2 -2
- resforge-0.3.1/resforge/_utils.py +62 -0
- {resforge-0.3.0 → resforge-0.3.1}/resforge/android/compose.py +3 -2
- {resforge-0.3.0 → resforge-0.3.1}/resforge/android/values.py +8 -8
- {resforge-0.3.0 → resforge-0.3.1}/resforge/apple/_base.py +4 -2
- {resforge-0.3.0 → resforge-0.3.1}/resforge/codegen/kotlin.py +9 -6
- resforge-0.3.1/tests/test_atomic_write.py +35 -0
- resforge-0.3.0/resforge/_utils.py +0 -24
- {resforge-0.3.0 → resforge-0.3.1}/.github/workflows/ci.yml +0 -0
- {resforge-0.3.0 → resforge-0.3.1}/.github/workflows/publish.yml +0 -0
- {resforge-0.3.0 → resforge-0.3.1}/.gitignore +0 -0
- {resforge-0.3.0 → resforge-0.3.1}/LICENSE +0 -0
- {resforge-0.3.0 → resforge-0.3.1}/resforge/__init__.py +0 -0
- {resforge-0.3.0 → resforge-0.3.1}/resforge/android/__init__.py +0 -0
- {resforge-0.3.0 → resforge-0.3.1}/resforge/android/types.py +0 -0
- {resforge-0.3.0 → resforge-0.3.1}/resforge/apple/__init__.py +0 -0
- {resforge-0.3.0 → resforge-0.3.1}/resforge/apple/_colorset.py +0 -0
- {resforge-0.3.0 → resforge-0.3.1}/resforge/apple/catalog.py +0 -0
- {resforge-0.3.0 → resforge-0.3.1}/resforge/apple/types.py +0 -0
- {resforge-0.3.0 → resforge-0.3.1}/resforge/codegen/__init__.py +0 -0
- {resforge-0.3.0 → resforge-0.3.1}/resforge/types.py +0 -0
- {resforge-0.3.0 → resforge-0.3.1}/tests/__init__.py +0 -0
- {resforge-0.3.0 → resforge-0.3.1}/tests/test_android_compose.py +0 -0
- {resforge-0.3.0 → resforge-0.3.1}/tests/test_android_values.py +0 -0
- {resforge-0.3.0 → resforge-0.3.1}/tests/test_apple_catalog.py +0 -0
- {resforge-0.3.0 → resforge-0.3.1}/tests/test_codegen_kotlin.py +0 -0
- {resforge-0.3.0 → resforge-0.3.1}/tests/test_types.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: resforge
|
|
3
|
-
Version: 0.3.
|
|
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
|
|
@@ -159,7 +159,6 @@ with AssetCatalog("App", "Assets") as ac:
|
|
|
159
159
|
|
|
160
160
|
## Roadmap
|
|
161
161
|
|
|
162
|
-
- SwiftUI color extension generation (`SwiftUIWriter`)
|
|
163
162
|
- Asset Catalog image support (ImageSet, IconSet)
|
|
164
163
|
- Android `res/drawable` vector asset support
|
|
165
164
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "resforge"
|
|
7
|
-
version = "0.3.
|
|
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"
|
|
@@ -36,7 +36,7 @@ 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
40
|
|
|
41
41
|
[tool.ruff]
|
|
42
42
|
target-version = "py312"
|
|
@@ -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()
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from typing import TYPE_CHECKING, Self
|
|
5
5
|
|
|
6
|
-
from resforge._utils import require_context
|
|
6
|
+
from resforge._utils import atomic_write, require_context
|
|
7
7
|
from resforge.codegen.kotlin import KotlinFile, KotlinObject
|
|
8
8
|
from resforge.types import Color
|
|
9
9
|
|
|
@@ -119,7 +119,8 @@ class ComposeWriter(_BaseComposeScope):
|
|
|
119
119
|
def __exit__(self, exc_type, *_) -> None:
|
|
120
120
|
try:
|
|
121
121
|
if exc_type is None:
|
|
122
|
-
|
|
122
|
+
with atomic_write(self._path) as tf:
|
|
123
|
+
self._kotlin_file.write(tf)
|
|
123
124
|
finally:
|
|
124
125
|
self._active = False
|
|
125
126
|
|
|
@@ -4,7 +4,7 @@ from pathlib import Path
|
|
|
4
4
|
from re import Pattern
|
|
5
5
|
from typing import Self
|
|
6
6
|
|
|
7
|
-
from resforge._utils import require_context
|
|
7
|
+
from resforge._utils import atomic_write, require_context
|
|
8
8
|
from resforge.types import Color
|
|
9
9
|
|
|
10
10
|
from .types import Dimension, PluralValues
|
|
@@ -49,15 +49,15 @@ class ValuesWriter:
|
|
|
49
49
|
def __exit__(self, exc_type, *_) -> None:
|
|
50
50
|
try:
|
|
51
51
|
if exc_type is None:
|
|
52
|
-
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
53
52
|
ET.indent(self._root, space=" " * 4, level=0)
|
|
54
53
|
tree = ET.ElementTree(self._root)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
with atomic_write(self._path) as tf:
|
|
55
|
+
tree.write(
|
|
56
|
+
tf,
|
|
57
|
+
encoding="utf-8",
|
|
58
|
+
xml_declaration=True,
|
|
59
|
+
short_empty_elements=True,
|
|
60
|
+
)
|
|
61
61
|
finally:
|
|
62
62
|
self._active = False
|
|
63
63
|
|
|
@@ -4,11 +4,13 @@ from abc import ABC, abstractmethod
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from typing import Any, Self
|
|
6
6
|
|
|
7
|
+
from resforge._utils import atomic_write
|
|
8
|
+
|
|
7
9
|
|
|
8
10
|
def write_contents(path: str | Path, contents: dict[str, Any]) -> None:
|
|
9
11
|
path = Path(path) / "Contents.json"
|
|
10
|
-
with path
|
|
11
|
-
json.
|
|
12
|
+
with atomic_write(path) as tf:
|
|
13
|
+
tf.write(json.dumps(contents, indent=2).encode())
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
class AssetNode(ABC):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from dataclasses import dataclass
|
|
2
2
|
from pathlib import Path
|
|
3
|
-
from typing import Literal, Self
|
|
3
|
+
from typing import IO, Literal, Self
|
|
4
4
|
|
|
5
5
|
PropertyMutability = Literal["val", "var"]
|
|
6
6
|
|
|
@@ -90,8 +90,11 @@ class KotlinFile:
|
|
|
90
90
|
|
|
91
91
|
return "\n\n".join(sections) + "\n"
|
|
92
92
|
|
|
93
|
-
def write(self,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
def write(self, dest: str | Path | IO[bytes]) -> None:
|
|
94
|
+
if isinstance(dest, (str, Path)):
|
|
95
|
+
path = Path(dest)
|
|
96
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
with path.open("wb") as f:
|
|
98
|
+
f.write(self.render().encode())
|
|
99
|
+
else:
|
|
100
|
+
dest.write(self.render().encode())
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from resforge._utils import atomic_write
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_atomic_replace_existing(tmp_path):
|
|
7
|
+
target = tmp_path / "output.bin"
|
|
8
|
+
target.write_bytes(b"old")
|
|
9
|
+
with atomic_write(target) as f:
|
|
10
|
+
f.write(b"new")
|
|
11
|
+
assert target.read_bytes() == b"new"
|
|
12
|
+
assert not any(tmp_path.glob("*.tmp"))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_creates_parent_dirs(tmp_path):
|
|
16
|
+
target = tmp_path / "a" / "b" / "c" / "output.bin"
|
|
17
|
+
with atomic_write(target) as f:
|
|
18
|
+
f.write(b"hello")
|
|
19
|
+
assert target.exists()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_no_corruption_on_exception(tmp_path):
|
|
23
|
+
target = tmp_path / "output.bin"
|
|
24
|
+
target.write_bytes(b"original")
|
|
25
|
+
|
|
26
|
+
def _broken_write() -> None:
|
|
27
|
+
with atomic_write(target) as f:
|
|
28
|
+
f.write(b"new corrupted data")
|
|
29
|
+
raise RuntimeError("boom")
|
|
30
|
+
|
|
31
|
+
with pytest.raises(RuntimeError):
|
|
32
|
+
_broken_write()
|
|
33
|
+
|
|
34
|
+
assert target.read_bytes() == b"original"
|
|
35
|
+
assert not any(tmp_path.glob("*.tmp"))
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
from collections.abc import Callable
|
|
2
|
-
from functools import wraps
|
|
3
|
-
from typing import Concatenate
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def require_context[T, **P, R](
|
|
7
|
-
func: Callable[Concatenate[T, P], R],
|
|
8
|
-
) -> Callable[Concatenate[T, P], R]:
|
|
9
|
-
"""Ensures a method is only called within an active context.
|
|
10
|
-
|
|
11
|
-
Raises:
|
|
12
|
-
RuntimeError: If the method is called while the instance's
|
|
13
|
-
`_active` attribute is False or missing.
|
|
14
|
-
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
@wraps(func)
|
|
18
|
-
def wrapper(self: T, *args: P.args, **kwargs: P.kwargs) -> R:
|
|
19
|
-
if not getattr(self, "_active", False):
|
|
20
|
-
msg = f"'{func.__name__}' requires an active 'with' context."
|
|
21
|
-
raise RuntimeError(msg)
|
|
22
|
-
return func(self, *args, **kwargs)
|
|
23
|
-
|
|
24
|
-
return wrapper
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|