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.
Files changed (29) hide show
  1. {resforge-0.3.0 → resforge-0.3.1}/PKG-INFO +2 -3
  2. {resforge-0.3.0 → resforge-0.3.1}/README.md +0 -1
  3. {resforge-0.3.0 → resforge-0.3.1}/pyproject.toml +2 -2
  4. resforge-0.3.1/resforge/_utils.py +62 -0
  5. {resforge-0.3.0 → resforge-0.3.1}/resforge/android/compose.py +3 -2
  6. {resforge-0.3.0 → resforge-0.3.1}/resforge/android/values.py +8 -8
  7. {resforge-0.3.0 → resforge-0.3.1}/resforge/apple/_base.py +4 -2
  8. {resforge-0.3.0 → resforge-0.3.1}/resforge/codegen/kotlin.py +9 -6
  9. resforge-0.3.1/tests/test_atomic_write.py +35 -0
  10. resforge-0.3.0/resforge/_utils.py +0 -24
  11. {resforge-0.3.0 → resforge-0.3.1}/.github/workflows/ci.yml +0 -0
  12. {resforge-0.3.0 → resforge-0.3.1}/.github/workflows/publish.yml +0 -0
  13. {resforge-0.3.0 → resforge-0.3.1}/.gitignore +0 -0
  14. {resforge-0.3.0 → resforge-0.3.1}/LICENSE +0 -0
  15. {resforge-0.3.0 → resforge-0.3.1}/resforge/__init__.py +0 -0
  16. {resforge-0.3.0 → resforge-0.3.1}/resforge/android/__init__.py +0 -0
  17. {resforge-0.3.0 → resforge-0.3.1}/resforge/android/types.py +0 -0
  18. {resforge-0.3.0 → resforge-0.3.1}/resforge/apple/__init__.py +0 -0
  19. {resforge-0.3.0 → resforge-0.3.1}/resforge/apple/_colorset.py +0 -0
  20. {resforge-0.3.0 → resforge-0.3.1}/resforge/apple/catalog.py +0 -0
  21. {resforge-0.3.0 → resforge-0.3.1}/resforge/apple/types.py +0 -0
  22. {resforge-0.3.0 → resforge-0.3.1}/resforge/codegen/__init__.py +0 -0
  23. {resforge-0.3.0 → resforge-0.3.1}/resforge/types.py +0 -0
  24. {resforge-0.3.0 → resforge-0.3.1}/tests/__init__.py +0 -0
  25. {resforge-0.3.0 → resforge-0.3.1}/tests/test_android_compose.py +0 -0
  26. {resforge-0.3.0 → resforge-0.3.1}/tests/test_android_values.py +0 -0
  27. {resforge-0.3.0 → resforge-0.3.1}/tests/test_apple_catalog.py +0 -0
  28. {resforge-0.3.0 → resforge-0.3.1}/tests/test_codegen_kotlin.py +0 -0
  29. {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.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/ok100/resforge
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
 
@@ -136,7 +136,6 @@ with AssetCatalog("App", "Assets") as ac:
136
136
 
137
137
  ## Roadmap
138
138
 
139
- - SwiftUI color extension generation (`SwiftUIWriter`)
140
139
  - Asset Catalog image support (ImageSet, IconSet)
141
140
  - Android `res/drawable` vector asset support
142
141
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "resforge"
7
- version = "0.3.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"
@@ -36,7 +36,7 @@ dev = ["pytest", "defusedxml"]
36
36
 
37
37
  [project.urls]
38
38
  Homepage = "https://kipila.dev"
39
- Repository = "https://github.com/ok100/resforge"
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
- self._kotlin_file.write(self._path)
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
- tree.write(
56
- self._path,
57
- encoding="utf-8",
58
- xml_declaration=True,
59
- short_empty_elements=True,
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.open("w") as f:
11
- json.dump(contents, f, indent=2)
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, path: str | Path) -> None:
94
- path = Path(path)
95
- path.parent.mkdir(parents=True, exist_ok=True)
96
- with path.open("w") as f:
97
- f.write(self.render())
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