resforge 0.4.0__tar.gz → 0.4.2__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 (31) hide show
  1. {resforge-0.4.0 → resforge-0.4.2}/PKG-INFO +1 -1
  2. {resforge-0.4.0 → resforge-0.4.2}/pyproject.toml +1 -1
  3. {resforge-0.4.0 → resforge-0.4.2}/src/resforge/__init__.py +1 -1
  4. {resforge-0.4.0 → resforge-0.4.2}/src/resforge/_codegen/kotlin.py +2 -9
  5. resforge-0.4.2/src/resforge/_utils.py +31 -0
  6. {resforge-0.4.0 → resforge-0.4.2}/src/resforge/android/compose.py +11 -8
  7. {resforge-0.4.0 → resforge-0.4.2}/src/resforge/android/values.py +16 -12
  8. resforge-0.4.2/src/resforge/apple/_base.py +33 -0
  9. {resforge-0.4.0 → resforge-0.4.2}/src/resforge/apple/_colorset.py +4 -2
  10. {resforge-0.4.0 → resforge-0.4.2}/src/resforge/apple/catalog.py +33 -16
  11. resforge-0.4.0/src/resforge/_utils.py → resforge-0.4.2/src/resforge/io.py +33 -31
  12. resforge-0.4.2/tests/__init__.py +0 -0
  13. {resforge-0.4.0 → resforge-0.4.2}/tests/test_atomic_write.py +1 -1
  14. {resforge-0.4.0 → resforge-0.4.2}/tests/test_codegen_kotlin.py +0 -18
  15. resforge-0.4.0/src/resforge/apple/_base.py +0 -33
  16. {resforge-0.4.0 → resforge-0.4.2}/.github/workflows/ci.yml +0 -0
  17. {resforge-0.4.0 → resforge-0.4.2}/.github/workflows/publish.yml +0 -0
  18. {resforge-0.4.0 → resforge-0.4.2}/.gitignore +0 -0
  19. {resforge-0.4.0 → resforge-0.4.2}/LICENSE +0 -0
  20. {resforge-0.4.0 → resforge-0.4.2}/README.md +0 -0
  21. {resforge-0.4.0 → resforge-0.4.2}/src/resforge/_codegen/__init__.py +0 -0
  22. {resforge-0.4.0 → resforge-0.4.2}/src/resforge/android/__init__.py +0 -0
  23. {resforge-0.4.0 → resforge-0.4.2}/src/resforge/android/types.py +0 -0
  24. {resforge-0.4.0 → resforge-0.4.2}/src/resforge/apple/__init__.py +0 -0
  25. {resforge-0.4.0 → resforge-0.4.2}/src/resforge/apple/types.py +0 -0
  26. {resforge-0.4.0 → resforge-0.4.2}/src/resforge/color.py +0 -0
  27. /resforge-0.4.0/tests/__init__.py → /resforge-0.4.2/src/resforge/py.typed +0 -0
  28. {resforge-0.4.0 → resforge-0.4.2}/tests/test_android_compose.py +0 -0
  29. {resforge-0.4.0 → resforge-0.4.2}/tests/test_android_values.py +0 -0
  30. {resforge-0.4.0 → resforge-0.4.2}/tests/test_apple_catalog.py +0 -0
  31. {resforge-0.4.0 → resforge-0.4.2}/tests/test_color.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: resforge
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: A type-safe Python DSL for cross-platform mobile resource generation
5
5
  Project-URL: Homepage, https://kipila.dev
6
6
  Project-URL: Repository, https://github.com/kipila-dev/resforge
@@ -47,7 +47,7 @@ target-version = "py312"
47
47
 
48
48
  [tool.ruff.lint]
49
49
  select = ["ALL"]
50
- ignore = ["D100", "D104", "D105", "ANN401", "PLR2004", "E741"]
50
+ ignore = ["D100", "D104", "D105", "D107", "ANN401", "PLR2004", "E741"]
51
51
 
52
52
  [tool.ruff.lint.extend-per-file-ignores]
53
53
  "tests/**/*.py" = [
@@ -1,4 +1,4 @@
1
1
  from .color import Color
2
2
 
3
3
  __all__ = ["Color"]
4
- __version__ = "0.4.0"
4
+ __version__ = "0.4.2"
@@ -1,5 +1,4 @@
1
1
  from dataclasses import dataclass
2
- from pathlib import Path
3
2
  from typing import IO, Literal, Self, final
4
3
 
5
4
  PropertyMutability = Literal["val", "var"]
@@ -96,11 +95,5 @@ class KotlinFile:
96
95
 
97
96
  return "\n\n".join(sections) + "\n"
98
97
 
99
- def write(self, dest: str | Path | IO[bytes]) -> None:
100
- if isinstance(dest, (str, Path)):
101
- path = Path(dest)
102
- path.parent.mkdir(parents=True, exist_ok=True)
103
- with path.open("wb") as f:
104
- f.write(self.render().encode())
105
- else:
106
- dest.write(self.render().encode())
98
+ def write(self, dest: IO[bytes]) -> None:
99
+ dest.write(self.render().encode())
@@ -0,0 +1,31 @@
1
+ from collections.abc import Callable
2
+ from functools import wraps
3
+ from typing import Concatenate, Protocol, runtime_checkable
4
+
5
+ __all__ = ["require_context"]
6
+
7
+
8
+ @runtime_checkable
9
+ class _HasActiveContext(Protocol):
10
+ _active: bool
11
+
12
+
13
+ def require_context[T: _HasActiveContext, **P, R](
14
+ func: Callable[Concatenate[T, P], R],
15
+ ) -> Callable[Concatenate[T, P], R]:
16
+ """Ensures a method is only called within an active context.
17
+
18
+ Raises:
19
+ RuntimeError: If the method is called while the instance's
20
+ `_active` attribute is False.
21
+
22
+ """
23
+
24
+ @wraps(func)
25
+ def wrapper(self: T, *args: P.args, **kwargs: P.kwargs) -> R:
26
+ if not self._active: # pyright: ignore[reportPrivateUsage]
27
+ msg = f"'{func.__name__}' requires an active 'with' context."
28
+ raise RuntimeError(msg)
29
+ return func(self, *args, **kwargs)
30
+
31
+ return wrapper
@@ -1,11 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from io import BytesIO
3
4
  from pathlib import Path
4
5
  from typing import TYPE_CHECKING, Self, final, override
5
6
 
6
7
  from resforge import Color
7
8
  from resforge._codegen.kotlin import KotlinFile, KotlinObject
8
- from resforge._utils import atomic_write, require_context
9
+ from resforge._utils import require_context
10
+ from resforge.io import FileSystemSink, WriteSink
9
11
 
10
12
  if TYPE_CHECKING:
11
13
  from resforge.android.types import Dimension
@@ -73,7 +75,6 @@ class _BaseComposeScope:
73
75
 
74
76
  Raises:
75
77
  ValueError: If an unsupported unit (not dp, sp, or em) is provided.
76
-
77
78
  """
78
79
  mapping = {"dp": "Dp", "sp": "Sp", "em": "TextUnit"}
79
80
  for name, dimen in values.items():
@@ -110,24 +111,26 @@ class ComposeWriter(_BaseComposeScope):
110
111
  object_name="AppColors"
111
112
  ) as compose:
112
113
  ... compose.color(primary="#6200EE", background="#FFFFFF")
113
-
114
114
  """
115
115
 
116
116
  def __init__( # pyright: ignore[reportMissingSuperCall]
117
117
  self,
118
118
  path: str | Path,
119
119
  package: str,
120
+ sink: WriteSink | None = None,
120
121
  ) -> None:
121
122
  """Initializes the ComposeWriter.
122
123
 
123
124
  Args:
124
125
  path: The filesystem path where the Kotlin file will be saved.
125
126
  package: The Kotlin package declaration.
126
-
127
+ sink: The custom output to write data to. If None,
128
+ defaults to a standard file write.
127
129
  """
130
+ self._active = False
128
131
  self._path = Path(path)
129
132
  self._package = package
130
- self._active = False
133
+ self._sink = sink or FileSystemSink()
131
134
  self._kotlin_file = KotlinFile(package=self._package)
132
135
 
133
136
  @override
@@ -149,8 +152,9 @@ class ComposeWriter(_BaseComposeScope):
149
152
  ) -> None:
150
153
  try:
151
154
  if exc_type is None:
152
- with atomic_write(self._path) as tf:
153
- self._kotlin_file.write(tf)
155
+ buf = BytesIO()
156
+ self._kotlin_file.write(buf)
157
+ self._sink.write(self._path, buf.getvalue())
154
158
  finally:
155
159
  self._active = False
156
160
 
@@ -163,7 +167,6 @@ class ComposeWriter(_BaseComposeScope):
163
167
 
164
168
  Returns:
165
169
  A new context tied to the generated object.
166
-
167
170
  """
168
171
  obj = KotlinObject(name=name)
169
172
  self._kotlin_file.member(obj)
@@ -1,11 +1,13 @@
1
1
  import re
2
2
  import xml.etree.ElementTree as ET
3
+ from io import BytesIO
3
4
  from pathlib import Path
4
5
  from re import Pattern
5
6
  from typing import Self, final
6
7
 
7
8
  from resforge import Color
8
- from resforge._utils import atomic_write, require_context
9
+ from resforge._utils import require_context
10
+ from resforge.io import FileSystemSink, WriteSink
9
11
 
10
12
  from .types import Dimension, PluralValues
11
13
 
@@ -28,15 +30,17 @@ class ValuesWriter:
28
30
 
29
31
  """
30
32
 
31
- def __init__(self, path: str | Path) -> None:
33
+ def __init__(self, path: str | Path, sink: WriteSink | None = None) -> None:
32
34
  """Initializes the ValuesWriter.
33
35
 
34
36
  Args:
35
37
  path: The filesystem path where the XML will be saved.
36
-
38
+ sink: The custom output to write data to. If None,
39
+ defaults to a standard file write.
37
40
  """
38
- self._path = Path(path)
39
41
  self._active = False
42
+ self._path = Path(path)
43
+ self._sink = sink or FileSystemSink()
40
44
 
41
45
  # Structure: {"string": ["app_name"], ...}
42
46
  self._names: dict[str, set[str]] = {}
@@ -52,14 +56,14 @@ class ValuesWriter:
52
56
  try:
53
57
  if exc_type is None:
54
58
  ET.indent(self._root, space=" " * 4, level=0)
55
- tree = ET.ElementTree(self._root)
56
- with atomic_write(self._path) as tf:
57
- tree.write(
58
- tf,
59
- encoding="utf-8",
60
- xml_declaration=True,
61
- short_empty_elements=True,
62
- )
59
+ buf = BytesIO()
60
+ ET.ElementTree(self._root).write(
61
+ buf,
62
+ encoding="utf-8",
63
+ xml_declaration=True,
64
+ short_empty_elements=True,
65
+ )
66
+ self._sink.write(self._path, buf.getvalue())
63
67
  finally:
64
68
  self._active = False
65
69
 
@@ -0,0 +1,33 @@
1
+ import json
2
+ from abc import ABC, abstractmethod
3
+ from io import BytesIO
4
+ from pathlib import Path
5
+ from typing import Self
6
+
7
+ from resforge.io import WriteSink
8
+
9
+
10
+ class AssetNode(ABC):
11
+ def __init__(
12
+ self,
13
+ path: str | Path,
14
+ name: str,
15
+ extension: str,
16
+ sink: WriteSink,
17
+ ) -> None:
18
+ self._path: Path = Path(path) / f"{name}.{extension}"
19
+ self._sink: WriteSink = sink
20
+
21
+ def __enter__(self) -> Self:
22
+ return self
23
+
24
+ def __exit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
25
+ if exc_type is None:
26
+ contents = self._create_contents()
27
+ buf = BytesIO()
28
+ buf.write(json.dumps(contents, indent=2).encode())
29
+ path = Path(self._path) / "Contents.json"
30
+ self._sink.write(path, buf.getvalue())
31
+
32
+ @abstractmethod
33
+ def _create_contents(self) -> dict[str, object]: ...
@@ -1,13 +1,15 @@
1
1
  from pathlib import Path
2
2
  from typing import Self, override
3
3
 
4
+ from resforge.io import WriteSink
5
+
4
6
  from ._base import AssetNode
5
7
  from .types import AppleColor
6
8
 
7
9
 
8
10
  class ColorSet(AssetNode):
9
- def __init__(self, path: str | Path, name: str) -> None:
10
- super().__init__(path, name, "colorset")
11
+ def __init__(self, path: str | Path, name: str, sink: WriteSink) -> None:
12
+ super().__init__(path, name, "colorset", sink)
11
13
  self._colors: list[AppleColor] = []
12
14
 
13
15
  def color(self, *colors: AppleColor) -> Self:
@@ -1,10 +1,12 @@
1
+ import json
1
2
  import shutil
3
+ from io import BytesIO
2
4
  from pathlib import Path
3
5
  from typing import Self, final
4
6
 
5
7
  from resforge._utils import require_context
8
+ from resforge.io import FileSystemSink, WriteSink
6
9
 
7
- from ._base import write_contents
8
10
  from ._colorset import ColorSet
9
11
  from .types import AppleColor
10
12
 
@@ -21,39 +23,54 @@ class AssetCatalog:
21
23
  Example:
22
24
  >>> with AssetCatalog("App", "Assets") as assets:
23
25
  ... assets.colorset("primary", "#FF0000")
24
-
25
26
  """
26
27
 
27
- def __init__(self, path: str | Path, name: str) -> None:
28
+ def __init__(
29
+ self,
30
+ path: str | Path,
31
+ name: str,
32
+ sink: WriteSink | None = None,
33
+ ) -> None:
28
34
  """Initializes the AssetCatalog.
29
35
 
30
36
  Args:
31
37
  path: The filesystem path where the asset catalog will be saved.
32
38
  name: The name of the catalog (without .xcassets extension).
33
-
39
+ sink: The custom output to write data to. If None,
40
+ defaults to a standard file write.
34
41
  """
35
- output_dir = Path(path).resolve()
36
- self._temp_path = output_dir / f".tmp_{name}.xcassets"
37
- self._final_path = output_dir / f"{name}.xcassets"
38
42
  self._active = False
43
+ output_dir = Path(path)
44
+ self._final_path = output_dir / f"{name}.xcassets"
45
+ self._sink = sink or FileSystemSink()
46
+ self._temp_path = (
47
+ (output_dir / f".tmp_{name}.xcassets")
48
+ if isinstance(self._sink, FileSystemSink)
49
+ else self._final_path
50
+ )
39
51
 
40
52
  def __enter__(self) -> Self:
41
53
  self._active = True
42
- if self._temp_path.exists():
43
- shutil.rmtree(self._temp_path)
44
- self._temp_path.mkdir(parents=True)
54
+ if isinstance(self._sink, FileSystemSink):
55
+ if self._temp_path.exists():
56
+ shutil.rmtree(self._temp_path)
57
+ self._temp_path.mkdir(parents=True)
45
58
  return self
46
59
 
47
60
  def __exit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
48
61
  try:
49
62
  if exc_type is None:
63
+ buf = BytesIO()
50
64
  contents = {"info": {"author": "xcode", "version": 1}}
51
- write_contents(self._temp_path, contents)
52
- if self._final_path.exists():
53
- shutil.rmtree(self._final_path)
54
- self._temp_path.rename(self._final_path)
65
+ buf.write(json.dumps(contents, indent=2).encode())
66
+ path = Path(self._temp_path) / "Contents.json"
67
+ self._sink.write(path, buf.getvalue())
68
+ if isinstance(self._sink, FileSystemSink):
69
+ if self._final_path.exists():
70
+ shutil.rmtree(self._final_path)
71
+ self._temp_path.rename(self._final_path)
55
72
  finally:
56
- if self._temp_path.exists():
73
+ if isinstance(self._sink, FileSystemSink) and self._temp_path.exists():
57
74
  shutil.rmtree(self._temp_path)
58
75
  self._active = False
59
76
 
@@ -66,6 +83,6 @@ class AssetCatalog:
66
83
  *colors: One or more AppleColor definitions.
67
84
 
68
85
  """
69
- with ColorSet(self._temp_path, name) as colorset:
86
+ with ColorSet(self._temp_path, name, self._sink) as colorset:
70
87
  colorset.color(*colors)
71
88
  return self
@@ -1,43 +1,16 @@
1
1
  import os
2
2
  import shutil
3
- from collections.abc import Callable, Generator
3
+ from collections.abc import Generator
4
4
  from contextlib import contextmanager, suppress
5
- from functools import wraps
6
5
  from pathlib import Path
7
6
  from tempfile import NamedTemporaryFile
8
- from typing import IO, Concatenate, Protocol, runtime_checkable
7
+ from typing import IO, Protocol, runtime_checkable
9
8
 
10
- __all__ = ["atomic_write", "require_context"]
11
-
12
-
13
- @runtime_checkable
14
- class _HasActiveContext(Protocol):
15
- _active: bool
16
-
17
-
18
- def require_context[T: _HasActiveContext, **P, R](
19
- func: Callable[Concatenate[T, P], R],
20
- ) -> Callable[Concatenate[T, P], R]:
21
- """Ensures a method is only called within an active context.
22
-
23
- Raises:
24
- RuntimeError: If the method is called while the instance's
25
- `_active` attribute is False.
26
-
27
- """
28
-
29
- @wraps(func)
30
- def wrapper(self: T, *args: P.args, **kwargs: P.kwargs) -> R:
31
- if not self._active: # pyright: ignore[reportPrivateUsage]
32
- msg = f"'{func.__name__}' requires an active 'with' context."
33
- raise RuntimeError(msg)
34
- return func(self, *args, **kwargs)
35
-
36
- return wrapper
9
+ __all__ = ["FileSystemSink", "MemorySink", "WriteSink", "atomic_write"]
37
10
 
38
11
 
39
12
  @contextmanager
40
- def atomic_write(target_path: Path) -> Generator[IO[bytes], None, None]:
13
+ def atomic_write(target_path: str | Path) -> Generator[IO[bytes], None, None]:
41
14
  """Yields a temporary file, then atomically replaces target_path on success.
42
15
 
43
16
  Preserves file permissions of target_path, if target_path already exists.
@@ -80,3 +53,32 @@ def atomic_write(target_path: Path) -> Generator[IO[bytes], None, None]:
80
53
  if temp_path and temp_path.exists():
81
54
  with suppress(OSError):
82
55
  temp_path.unlink()
56
+
57
+
58
+ @runtime_checkable
59
+ class WriteSink(Protocol):
60
+ """An interface for writing binary content to a file path."""
61
+
62
+ def write(self, path: Path, content: bytes) -> None:
63
+ """Write the given binary content to the specified path."""
64
+ ...
65
+
66
+
67
+ class FileSystemSink:
68
+ """A write sink that writes directly to the file system."""
69
+
70
+ def write(self, path: Path, content: bytes) -> None:
71
+ """Atomically write binary content to a physical file path."""
72
+ with atomic_write(path) as f:
73
+ f.write(content)
74
+
75
+
76
+ class MemorySink:
77
+ """A write sink that stores files in a dictionary."""
78
+
79
+ def __init__(self) -> None:
80
+ self.files: dict[str, bytes] = {}
81
+
82
+ def write(self, path: Path, content: bytes) -> None:
83
+ """Store the binary content in memory using the string path as a key."""
84
+ self.files[str(path)] = content
File without changes
@@ -1,6 +1,6 @@
1
1
  import pytest
2
2
 
3
- from resforge._utils import atomic_write
3
+ from resforge.io import atomic_write
4
4
 
5
5
 
6
6
  def test_atomic_replace_existing(tmp_path):
@@ -102,21 +102,3 @@ def test_top_level_property():
102
102
  assert f.render() == (
103
103
  "package com.example\n\nval primary: Color = Color(0xFF6200EE)\n"
104
104
  )
105
-
106
-
107
- def test_file_write(tmp_path):
108
- f = KotlinFile(package="com.example")
109
- f.import_("androidx.compose.ui.graphics.Color")
110
- f.member(
111
- KotlinObject("AppColors").property(
112
- "primary",
113
- type_="Color",
114
- value="Color(0xFF6200EE)",
115
- ),
116
- )
117
-
118
- output = tmp_path / "ui" / "theme" / "Color.kt"
119
- f.write(output)
120
-
121
- assert output.exists()
122
- assert output.read_text() == f.render()
@@ -1,33 +0,0 @@
1
- import json
2
- import shutil
3
- import typing
4
- from abc import ABC, abstractmethod
5
- from pathlib import Path
6
- from typing import Self
7
-
8
- from resforge._utils import atomic_write
9
-
10
-
11
- def write_contents(path: str | Path, contents: dict[str, typing.Any]) -> None:
12
- path = Path(path) / "Contents.json"
13
- with atomic_write(path) as tf:
14
- tf.write(json.dumps(contents, indent=2).encode())
15
-
16
-
17
- class AssetNode(ABC):
18
- def __init__(self, path: str | Path, name: str, extension: str) -> None:
19
- self._path: Path = Path(path) / f"{name}.{extension}"
20
-
21
- def __enter__(self) -> Self:
22
- self._path.mkdir(parents=True, exist_ok=True)
23
- return self
24
-
25
- def __exit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
26
- if exc_type is None:
27
- contents = self._create_contents()
28
- write_contents(self._path, contents)
29
- elif self._path.exists():
30
- shutil.rmtree(self._path)
31
-
32
- @abstractmethod
33
- def _create_contents(self) -> dict[str, object]: ...
File without changes
File without changes
File without changes
File without changes
File without changes