resforge 0.3.0__tar.gz → 0.4.0__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 (33) hide show
  1. {resforge-0.3.0 → resforge-0.4.0}/.github/workflows/ci.yml +14 -16
  2. {resforge-0.3.0 → resforge-0.4.0}/LICENSE +1 -1
  3. {resforge-0.3.0 → resforge-0.4.0}/PKG-INFO +54 -41
  4. {resforge-0.3.0 → resforge-0.4.0}/README.md +50 -35
  5. {resforge-0.3.0 → resforge-0.4.0}/pyproject.toml +26 -44
  6. resforge-0.4.0/src/resforge/__init__.py +4 -0
  7. {resforge-0.3.0/resforge/codegen → resforge-0.4.0/src/resforge/_codegen}/kotlin.py +18 -9
  8. resforge-0.4.0/src/resforge/_utils.py +82 -0
  9. {resforge-0.3.0 → resforge-0.4.0/src}/resforge/android/__init__.py +2 -1
  10. {resforge-0.3.0 → resforge-0.4.0/src}/resforge/android/compose.py +55 -24
  11. {resforge-0.3.0 → resforge-0.4.0/src}/resforge/android/types.py +8 -3
  12. {resforge-0.3.0 → resforge-0.4.0/src}/resforge/android/values.py +29 -18
  13. {resforge-0.3.0 → resforge-0.4.0/src}/resforge/apple/__init__.py +1 -2
  14. {resforge-0.3.0 → resforge-0.4.0/src}/resforge/apple/_base.py +10 -7
  15. {resforge-0.3.0 → resforge-0.4.0/src}/resforge/apple/_colorset.py +12 -17
  16. {resforge-0.3.0 → resforge-0.4.0/src}/resforge/apple/catalog.py +7 -8
  17. resforge-0.4.0/src/resforge/apple/types.py +98 -0
  18. resforge-0.4.0/src/resforge/color.py +128 -0
  19. {resforge-0.3.0 → resforge-0.4.0}/tests/test_android_values.py +17 -9
  20. {resforge-0.3.0 → resforge-0.4.0}/tests/test_apple_catalog.py +6 -5
  21. resforge-0.4.0/tests/test_atomic_write.py +36 -0
  22. {resforge-0.3.0 → resforge-0.4.0}/tests/test_codegen_kotlin.py +9 -4
  23. resforge-0.4.0/tests/test_color.py +75 -0
  24. resforge-0.3.0/resforge/__init__.py +0 -3
  25. resforge-0.3.0/resforge/_utils.py +0 -24
  26. resforge-0.3.0/resforge/apple/types.py +0 -77
  27. resforge-0.3.0/resforge/types.py +0 -74
  28. resforge-0.3.0/tests/test_types.py +0 -53
  29. {resforge-0.3.0 → resforge-0.4.0}/.github/workflows/publish.yml +0 -0
  30. {resforge-0.3.0 → resforge-0.4.0}/.gitignore +0 -0
  31. {resforge-0.3.0/resforge/codegen → resforge-0.4.0/src/resforge/_codegen}/__init__.py +0 -0
  32. {resforge-0.3.0 → resforge-0.4.0}/tests/__init__.py +0 -0
  33. {resforge-0.3.0 → resforge-0.4.0}/tests/test_android_compose.py +0 -0
@@ -16,35 +16,33 @@ jobs:
16
16
  steps:
17
17
  - uses: actions/checkout@v5
18
18
 
19
- - name: Set up Python ${{ matrix.python-version }}
19
+ - name: Set up Python
20
20
  uses: actions/setup-python@v5
21
21
  with:
22
22
  python-version: ${{ matrix.python-version }}
23
- cache: "pip"
24
23
 
25
- - name: Install dependencies
26
- run: |
27
- pip install -e ".[dev]"
24
+ - name: Install Hatch
25
+ run: pip install hatch
28
26
 
29
27
  - name: Run tests
30
- run: pytest tests/ -v
28
+ run: hatch run pytest
31
29
 
32
- lint-package:
30
+ lint-and-check:
33
31
  runs-on: ubuntu-latest
34
32
  steps:
35
33
  - uses: actions/checkout@v5
36
34
 
37
35
  - name: Set up Python
38
36
  uses: actions/setup-python@v5
39
- with:
40
- python-version: "3.12"
41
- cache: "pip"
42
37
 
43
- - name: Install build tools
44
- run: pip install build twine
38
+ - name: Install Hatch
39
+ run: pip install hatch
40
+
41
+ - name: Lint
42
+ run: hatch run ruff check .
45
43
 
46
- - name: Build distribution
47
- run: python -m build
44
+ - name: Type check
45
+ run: hatch run basedpyright
48
46
 
49
- - name: Verify package metadata
50
- run: twine check dist/*
47
+ - name: Verify Build Metadata
48
+ run: hatch build
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Ondrej Kipila
3
+ Copyright (c) 2026 Kipila Ltd
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: resforge
3
- Version: 0.3.0
3
+ Version: 0.4.0
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
7
- Author-email: Ondrej Kipila <ondrej@kipila.dev>
6
+ Project-URL: Repository, https://github.com/kipila-dev/resforge
7
+ Author-email: Kipila Ltd <hello@kipila.dev>
8
8
  License: MIT
9
9
  License-File: LICENSE
10
10
  Keywords: android-xml,automation,code-generation,design-tokens,dsl,mobile-infra,type-safety,white-label,xcassets
@@ -16,21 +16,21 @@ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Programming Language :: Python :: 3.14
17
17
  Classifier: Topic :: Software Development :: Build Tools
18
18
  Requires-Python: >=3.12
19
- Provides-Extra: dev
20
- Requires-Dist: defusedxml; extra == 'dev'
21
- Requires-Dist: pytest; extra == 'dev'
19
+ Requires-Dist: coloraide
22
20
  Description-Content-Type: text/markdown
23
21
 
24
22
  # resforge
25
23
 
26
- A type-safe Python DSL for generating native Android and iOS resources from design tokens.
24
+ A type-safe Python DSL for generating native Android and iOS resources from
25
+ design tokens.
27
26
 
28
27
  ## Features
29
28
 
30
29
  - Fluent, Pythonic API
31
- - Jetpack Compose theme generation with type-safe color and dimension properties
30
+ - Jetpack Compose theme generation for colors and dimensions
32
31
  - Supports all Android `res/values/` types
33
- - Native Apple Asset Catalog (`.xcassets`) with dark mode support
32
+ - Native Apple Asset Catalog (`.xcassets`) support
33
+ - Wide Gamut support with automatic sRGB fallbacks
34
34
  - Built-in validation for resource names and color formats
35
35
 
36
36
  ## Installation
@@ -44,27 +44,30 @@ pip install resforge
44
44
  ### Android (Jetpack Compose)
45
45
 
46
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")
47
+ from resforge.android import ComposeWriter
48
+
49
+ with ComposeWriter("Color.kt", "com.example.myapplication.ui.theme") as compose:
50
+ compose.color(
51
+ Purple80="#D0BCFF",
52
+ PurpleGrey80="#CCC2DC",
53
+ Pink80="#EFB8C8",
54
+ Purple40="#6650a4",
55
+ PurpleGrey40="#625b71",
56
+ Pink40="#7D5260",
57
+ )
53
58
  ```
54
59
 
55
60
  ```kotlin
56
- package dev.kipila.example
61
+ package com.example.myapplication.ui.theme
57
62
 
58
63
  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
 
64
- object AppColors {
65
- val primary: Color = Color(0xFFFF0000)
66
- val background: Color = Color(0xFFFFFFFF)
67
- }
65
+ val Purple80: Color = Color(0xFFD0BCFF)
66
+ val PurpleGrey80: Color = Color(0xFFCCC2DC)
67
+ val Pink80: Color = Color(0xFFEFB8C8)
68
+ val Purple40: Color = Color(0xFF6650A4)
69
+ val PurpleGrey40: Color = Color(0xFF625B71)
70
+ val Pink40: Color = Color(0xFF7D5260)
68
71
  ```
69
72
 
70
73
  ### Android (XML Resources)
@@ -77,12 +80,10 @@ with ValuesWriter("res/values/resources.xml") as res:
77
80
  app_name="My App",
78
81
  welcome_message="Welcome!",
79
82
  )
80
-
81
83
  res.color(
82
- primary="#FF6200EE",
83
- secondary="#FF03DAC5",
84
+ primary="#6200EE",
85
+ secondary="#03DAC5",
84
86
  )
85
-
86
87
  res.dimension(
87
88
  padding_small=dp(8),
88
89
  text_body=sp(16),
@@ -90,13 +91,12 @@ with ValuesWriter("res/values/resources.xml") as res:
90
91
  ```
91
92
 
92
93
  ```xml
94
+ <?xml version='1.0' encoding='utf-8'?>
93
95
  <resources>
94
96
  <string name="app_name">My App</string>
95
97
  <string name="welcome_message">Welcome!</string>
96
-
97
98
  <color name="primary">#FF6200EE</color>
98
- <color name="secondary">#FF03DAC5</color>
99
-
99
+ <color name="secondary">#FF00FF00</color>
100
100
  <dimen name="padding_small">8dp</dimen>
101
101
  <dimen name="text_body">16sp</dimen>
102
102
  </resources>
@@ -105,15 +105,15 @@ with ValuesWriter("res/values/resources.xml") as res:
105
105
  ### Asset Catalog (iOS)
106
106
 
107
107
  ```python
108
+ from resforge import Color
108
109
  from resforge.apple import Appearance, AppleColor, AssetCatalog
109
110
 
110
- with AssetCatalog("App", "Assets") as ac:
111
- ac.colorset(
112
- "Background",
113
- "#ffffff",
111
+ with AssetCatalog("App", "Assets") as catalog:
112
+ catalog.colorset(
113
+ "MyColor",
114
+ AppleColor(Color.p3(0.0, 1.0, 0.0)),
114
115
  AppleColor("#000000", appearances=[Appearance.Dark]),
115
116
  )
116
-
117
117
  ```
118
118
 
119
119
  ```json
@@ -127,14 +127,27 @@ with AssetCatalog("App", "Assets") as ac:
127
127
  "idiom": "universal",
128
128
  "color": {
129
129
  "components": {
130
- "red": "1.000",
130
+ "red": "0.000",
131
131
  "green": "1.000",
132
- "blue": "1.000",
132
+ "blue": "0.000",
133
133
  "alpha": "1.000"
134
134
  },
135
135
  "color-space": "srgb"
136
136
  }
137
137
  },
138
+ {
139
+ "idiom": "universal",
140
+ "color": {
141
+ "components": {
142
+ "red": "0.000",
143
+ "green": "1.000",
144
+ "blue": "0.000",
145
+ "alpha": "1.000"
146
+ },
147
+ "color-space": "display-p3"
148
+ },
149
+ "display-gamut": "display-P3"
150
+ },
138
151
  {
139
152
  "idiom": "universal",
140
153
  "color": {
@@ -159,9 +172,9 @@ with AssetCatalog("App", "Assets") as ac:
159
172
 
160
173
  ## Roadmap
161
174
 
162
- - SwiftUI color extension generation (`SwiftUIWriter`)
163
- - Asset Catalog image support (ImageSet, IconSet)
164
- - Android `res/drawable` vector asset support
175
+ - SwiftUI dimensions
176
+ - Typography
177
+ - Images (ImageSet, IconSet, `res/drawable` vectors)
165
178
 
166
179
  ## License
167
180
 
@@ -1,13 +1,15 @@
1
1
  # resforge
2
2
 
3
- A type-safe Python DSL for generating native Android and iOS resources from design tokens.
3
+ A type-safe Python DSL for generating native Android and iOS resources from
4
+ design tokens.
4
5
 
5
6
  ## Features
6
7
 
7
8
  - Fluent, Pythonic API
8
- - Jetpack Compose theme generation with type-safe color and dimension properties
9
+ - Jetpack Compose theme generation for colors and dimensions
9
10
  - Supports all Android `res/values/` types
10
- - Native Apple Asset Catalog (`.xcassets`) with dark mode support
11
+ - Native Apple Asset Catalog (`.xcassets`) support
12
+ - Wide Gamut support with automatic sRGB fallbacks
11
13
  - Built-in validation for resource names and color formats
12
14
 
13
15
  ## Installation
@@ -21,27 +23,30 @@ pip install resforge
21
23
  ### Android (Jetpack Compose)
22
24
 
23
25
  ```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")
26
+ from resforge.android import ComposeWriter
27
+
28
+ with ComposeWriter("Color.kt", "com.example.myapplication.ui.theme") as compose:
29
+ compose.color(
30
+ Purple80="#D0BCFF",
31
+ PurpleGrey80="#CCC2DC",
32
+ Pink80="#EFB8C8",
33
+ Purple40="#6650a4",
34
+ PurpleGrey40="#625b71",
35
+ Pink40="#7D5260",
36
+ )
30
37
  ```
31
38
 
32
39
  ```kotlin
33
- package dev.kipila.example
40
+ package com.example.myapplication.ui.theme
34
41
 
35
42
  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
43
 
41
- object AppColors {
42
- val primary: Color = Color(0xFFFF0000)
43
- val background: Color = Color(0xFFFFFFFF)
44
- }
44
+ val Purple80: Color = Color(0xFFD0BCFF)
45
+ val PurpleGrey80: Color = Color(0xFFCCC2DC)
46
+ val Pink80: Color = Color(0xFFEFB8C8)
47
+ val Purple40: Color = Color(0xFF6650A4)
48
+ val PurpleGrey40: Color = Color(0xFF625B71)
49
+ val Pink40: Color = Color(0xFF7D5260)
45
50
  ```
46
51
 
47
52
  ### Android (XML Resources)
@@ -54,12 +59,10 @@ with ValuesWriter("res/values/resources.xml") as res:
54
59
  app_name="My App",
55
60
  welcome_message="Welcome!",
56
61
  )
57
-
58
62
  res.color(
59
- primary="#FF6200EE",
60
- secondary="#FF03DAC5",
63
+ primary="#6200EE",
64
+ secondary="#03DAC5",
61
65
  )
62
-
63
66
  res.dimension(
64
67
  padding_small=dp(8),
65
68
  text_body=sp(16),
@@ -67,13 +70,12 @@ with ValuesWriter("res/values/resources.xml") as res:
67
70
  ```
68
71
 
69
72
  ```xml
73
+ <?xml version='1.0' encoding='utf-8'?>
70
74
  <resources>
71
75
  <string name="app_name">My App</string>
72
76
  <string name="welcome_message">Welcome!</string>
73
-
74
77
  <color name="primary">#FF6200EE</color>
75
- <color name="secondary">#FF03DAC5</color>
76
-
78
+ <color name="secondary">#FF00FF00</color>
77
79
  <dimen name="padding_small">8dp</dimen>
78
80
  <dimen name="text_body">16sp</dimen>
79
81
  </resources>
@@ -82,15 +84,15 @@ with ValuesWriter("res/values/resources.xml") as res:
82
84
  ### Asset Catalog (iOS)
83
85
 
84
86
  ```python
87
+ from resforge import Color
85
88
  from resforge.apple import Appearance, AppleColor, AssetCatalog
86
89
 
87
- with AssetCatalog("App", "Assets") as ac:
88
- ac.colorset(
89
- "Background",
90
- "#ffffff",
90
+ with AssetCatalog("App", "Assets") as catalog:
91
+ catalog.colorset(
92
+ "MyColor",
93
+ AppleColor(Color.p3(0.0, 1.0, 0.0)),
91
94
  AppleColor("#000000", appearances=[Appearance.Dark]),
92
95
  )
93
-
94
96
  ```
95
97
 
96
98
  ```json
@@ -104,14 +106,27 @@ with AssetCatalog("App", "Assets") as ac:
104
106
  "idiom": "universal",
105
107
  "color": {
106
108
  "components": {
107
- "red": "1.000",
109
+ "red": "0.000",
108
110
  "green": "1.000",
109
- "blue": "1.000",
111
+ "blue": "0.000",
110
112
  "alpha": "1.000"
111
113
  },
112
114
  "color-space": "srgb"
113
115
  }
114
116
  },
117
+ {
118
+ "idiom": "universal",
119
+ "color": {
120
+ "components": {
121
+ "red": "0.000",
122
+ "green": "1.000",
123
+ "blue": "0.000",
124
+ "alpha": "1.000"
125
+ },
126
+ "color-space": "display-p3"
127
+ },
128
+ "display-gamut": "display-P3"
129
+ },
115
130
  {
116
131
  "idiom": "universal",
117
132
  "color": {
@@ -136,9 +151,9 @@ with AssetCatalog("App", "Assets") as ac:
136
151
 
137
152
  ## Roadmap
138
153
 
139
- - SwiftUI color extension generation (`SwiftUIWriter`)
140
- - Asset Catalog image support (ImageSet, IconSet)
141
- - Android `res/drawable` vector asset support
154
+ - SwiftUI dimensions
155
+ - Typography
156
+ - Images (ImageSet, IconSet, `res/drawable` vectors)
142
157
 
143
158
  ## License
144
159
 
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "resforge"
7
- version = "0.3.0"
7
+ dynamic = ["version"]
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"
11
11
  license = { text = "MIT" }
12
- authors = [{ name = "Ondrej Kipila", email = "ondrej@kipila.dev" }]
12
+ authors = [{ name = "Kipila Ltd", email = "hello@kipila.dev" }]
13
13
  keywords = [
14
14
  "dsl",
15
15
  "design-tokens",
@@ -30,60 +30,42 @@ classifiers = [
30
30
  "Intended Audience :: Developers",
31
31
  "Topic :: Software Development :: Build Tools",
32
32
  ]
33
-
34
- [project.optional-dependencies]
35
- dev = ["pytest", "defusedxml"]
33
+ dependencies = ["coloraide"]
36
34
 
37
35
  [project.urls]
38
36
  Homepage = "https://kipila.dev"
39
- Repository = "https://github.com/ok100/resforge"
37
+ Repository = "https://github.com/kipila-dev/resforge"
38
+
39
+ [tool.hatch.version]
40
+ path = "src/resforge/__init__.py"
41
+
42
+ [tool.hatch.envs.default]
43
+ dependencies = ["basedpyright", "defusedxml", "pytest", "ruff"]
40
44
 
41
45
  [tool.ruff]
42
46
  target-version = "py312"
43
- line-length = 120
44
47
 
45
48
  [tool.ruff.lint]
46
49
  select = ["ALL"]
47
- ignore = [
50
+ ignore = ["D100", "D104", "D105", "ANN401", "PLR2004", "E741"]
51
+
52
+ [tool.ruff.lint.extend-per-file-ignores]
53
+ "tests/**/*.py" = [
48
54
  "ANN001",
49
- "ANN002",
50
- "ANN003",
51
- "COM812",
52
- "COM819",
53
- "D100",
55
+ "ANN201",
56
+ "D101",
54
57
  "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",
58
+ "D103",
59
+ "EM101",
60
+ "S101",
61
+ "TRY301",
73
62
  ]
74
- fixable = ["ALL"]
75
63
 
76
- [tool.ruff.lint.per-file-ignores]
77
- "tests/**/*.py" = [
78
- "ANN201",
79
- "D101",
80
- "D103",
81
- "EM101",
82
- "S101",
83
- "TRY301",
84
- ]
64
+ [tool.ruff.lint.pydocstyle]
65
+ convention = "google"
85
66
 
86
67
  [tool.basedpyright]
87
- venvPath = "."
88
- venv = ".venv"
89
- typeCheckingMode = "standard"
68
+ include = ["src"]
69
+ typeCheckingMode = "recommended"
70
+ pythonVersion = "3.12"
71
+ reportUnusedCallResult = "none"
@@ -0,0 +1,4 @@
1
+ from .color import Color
2
+
3
+ __all__ = ["Color"]
4
+ __version__ = "0.4.0"
@@ -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, final
4
4
 
5
5
  PropertyMutability = Literal["val", "var"]
6
6
 
@@ -18,6 +18,7 @@ class KotlinProperty:
18
18
  return f"{self.mutability} {self.name}: {self.type_} = {self.value}"
19
19
 
20
20
 
21
+ @final
21
22
  class KotlinObject:
22
23
  """A Kotlin object declaration."""
23
24
 
@@ -33,7 +34,7 @@ class KotlinObject:
33
34
  mutability: PropertyMutability = "val",
34
35
  ) -> Self:
35
36
  self._properties.append(
36
- KotlinProperty(name=name, type_=type_, value=value, mutability=mutability)
37
+ KotlinProperty(name=name, type_=type_, value=value, mutability=mutability),
37
38
  )
38
39
  return self
39
40
 
@@ -47,6 +48,7 @@ class KotlinObject:
47
48
  return "\n".join(lines)
48
49
 
49
50
 
51
+ @final
50
52
  class KotlinFile:
51
53
  """A Kotlin file declaration."""
52
54
 
@@ -60,7 +62,11 @@ class KotlinFile:
60
62
  return self
61
63
 
62
64
  def property(
63
- self, name: str, type_: str, value: str, mutability: PropertyMutability = "val"
65
+ self,
66
+ name: str,
67
+ type_: str,
68
+ value: str,
69
+ mutability: PropertyMutability = "val",
64
70
  ) -> Self:
65
71
  self._members.append(KotlinProperty(name, type_, value, mutability))
66
72
  return self
@@ -75,7 +81,7 @@ class KotlinFile:
75
81
  if self._imports:
76
82
  sections.append("\n".join(f"import {fqn}" for fqn in sorted(self._imports)))
77
83
 
78
- current_props = []
84
+ current_props: list[str] = []
79
85
  for member in self._members:
80
86
  if isinstance(member, KotlinProperty):
81
87
  current_props.append(member.render())
@@ -90,8 +96,11 @@ class KotlinFile:
90
96
 
91
97
  return "\n\n".join(sections) + "\n"
92
98
 
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())
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())
@@ -0,0 +1,82 @@
1
+ import os
2
+ import shutil
3
+ from collections.abc import Callable, Generator
4
+ from contextlib import contextmanager, suppress
5
+ from functools import wraps
6
+ from pathlib import Path
7
+ from tempfile import NamedTemporaryFile
8
+ from typing import IO, Concatenate, Protocol, runtime_checkable
9
+
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
37
+
38
+
39
+ @contextmanager
40
+ def atomic_write(target_path: Path) -> Generator[IO[bytes], None, None]:
41
+ """Yields a temporary file, then atomically replaces target_path on success.
42
+
43
+ Preserves file permissions of target_path, if target_path already exists.
44
+ """
45
+ target_path = Path(target_path)
46
+ target_path.parent.mkdir(parents=True, exist_ok=True)
47
+
48
+ temp_path = None
49
+ try:
50
+ with NamedTemporaryFile(
51
+ dir=target_path.parent,
52
+ delete=False,
53
+ suffix=".tmp",
54
+ ) as tf:
55
+ temp_path = Path(tf.name)
56
+ yield tf
57
+
58
+ tf.flush()
59
+ os.fsync(tf.fileno())
60
+
61
+ try:
62
+ shutil.copymode(target_path, temp_path)
63
+ except OSError:
64
+ mask = os.umask(0)
65
+ os.umask(mask)
66
+ temp_path.chmod(0o666 & ~mask)
67
+
68
+ temp_path.replace(target_path)
69
+
70
+ if os.name == "posix":
71
+ flags = os.O_RDONLY
72
+ if hasattr(os, "O_DIRECTORY"):
73
+ flags |= os.O_DIRECTORY
74
+ dir_fd = os.open(target_path.parent, flags)
75
+ try:
76
+ os.fsync(dir_fd)
77
+ finally:
78
+ os.close(dir_fd)
79
+ finally:
80
+ if temp_path and temp_path.exists():
81
+ with suppress(OSError):
82
+ temp_path.unlink()
@@ -1,5 +1,5 @@
1
1
  from .compose import ComposeWriter
2
- from .types import Dimension, PluralValues, dp, inch, mm, pt, px, sp
2
+ from .types import Dimension, PluralValues, dp, em, inch, mm, pt, px, sp
3
3
  from .values import ValuesWriter
4
4
 
5
5
  __all__ = [
@@ -8,6 +8,7 @@ __all__ = [
8
8
  "PluralValues",
9
9
  "ValuesWriter",
10
10
  "dp",
11
+ "em",
11
12
  "inch",
12
13
  "mm",
13
14
  "pt",