typstpy 0.0.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 chenjunhan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
typstpy-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.1
2
+ Name: typstpy
3
+ Version: 0.0.1
4
+ Summary: Python interface to generate Typst code.
5
+ License: MIT
6
+ Author: chenjunhan
7
+ Author-email: beibingyangliuying@foxmail.com
8
+ Requires-Python: >=3.12,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Requires-Dist: attrs (>=24.2.0,<25.0.0)
13
+ Requires-Dist: cytoolz (>=0.12.3,<0.13.0)
14
+ Requires-Dist: pandas (>=2.2.3,<3.0.0)
15
+ Requires-Dist: pymonad (>=2.4.0,<3.0.0)
16
+ Description-Content-Type: text/markdown
17
+
18
+ # python-typst
19
+
20
+ `python-typst` is a library for generating executable typst code (See [typst repository](https://github.com/typst/typst) and [typst documentation](https://typst.app/docs/) for more information).
21
+ It is written primarily in functional programming paradigm with some OOP content.
22
+
23
+ This package provides the interfaces in a way that is as close as possible to typst's native functions.
24
+ Through `python-typst` and other data processing packages, you can generate data reports quickly.
25
+
26
+ Contributions are welcome.
27
+
28
+ ## Installation
29
+
30
+ ## Examples
31
+
32
+ ```python
33
+ >>> from typstpy import emph, figure, heading, image, par, strong, text, Block, Content, Label, Length, Ratio, Relative
34
+ >>> text("Hello, World!", font="Arial", fallback=True)
35
+ '#text(font: "Arial", fallback: true)[Hello, World!]'
36
+ >>> text("Hello, World!", font=("Arial", "Times New Roman"), fallback=True)
37
+ '#text(font: ("Arial", "Times New Roman"), fallback: true)[Hello, World!]'
38
+ >>> text("Hello, World!", size=Length(12, "pt"))
39
+ '#text(size: 12pt)[Hello, World!]'
40
+ >>> emph("Hello, World!")
41
+ '#emph[Hello, World!]'
42
+ >>> strong("Hello, World!")
43
+ '#strong[Hello, World!]'
44
+ >>> strong("Hello, World!", delta=300)
45
+ '#strong(delta: 300)[Hello, World!]'
46
+ >>> par("Hello, World!", leading=Length(1.5, "em"))
47
+ '#par(leading: 1.5em)[Hello, World!]'
48
+ >>> par("Hello, World!", justify=True)
49
+ '#par(justify: true)[Hello, World!]'
50
+ >>> par("Hello, World!")
51
+ 'Hello, World!'
52
+ >>> heading("Hello, World!", level=2, supplement=Content("Chapter"), label=Label("chap:chapter"))
53
+ '#heading(supplement: [Chapter], level: 2)[Hello, World!] <chap:chapter>'
54
+ >>> heading("Hello, World!", level=2)
55
+ '== Hello, World!'
56
+ >>> image("image.png")
57
+ '#image("image.png")'
58
+ >>> image("image.png", format="png")
59
+ '#image("image.png", format: "png")'
60
+ >>> figure(image("image.png"))
61
+ '#figure(image("image.png"))'
62
+ >>> figure(image("image.png"), caption=Content("This is a figure."))
63
+ '#figure(image("image.png"), caption: [This is a figure.])'
64
+ >>> figure(image("image.png"), caption=Content("This is a figure."), label=Label("fig:figure"))
65
+ '#figure(image("image.png"), caption: [This is a figure.]) <fig:figure>'
66
+ >>> figure(image("image.png"), caption=figure.caption("This is a figure.", separator=Content("---")))
67
+ '#figure(image("image.png"), caption: figure.caption(separator: [---])[This is a figure.])'
68
+ ```
69
+
70
+ ## ChangeLog
71
+
@@ -0,0 +1,53 @@
1
+ # python-typst
2
+
3
+ `python-typst` is a library for generating executable typst code (See [typst repository](https://github.com/typst/typst) and [typst documentation](https://typst.app/docs/) for more information).
4
+ It is written primarily in functional programming paradigm with some OOP content.
5
+
6
+ This package provides the interfaces in a way that is as close as possible to typst's native functions.
7
+ Through `python-typst` and other data processing packages, you can generate data reports quickly.
8
+
9
+ Contributions are welcome.
10
+
11
+ ## Installation
12
+
13
+ ## Examples
14
+
15
+ ```python
16
+ >>> from typstpy import emph, figure, heading, image, par, strong, text, Block, Content, Label, Length, Ratio, Relative
17
+ >>> text("Hello, World!", font="Arial", fallback=True)
18
+ '#text(font: "Arial", fallback: true)[Hello, World!]'
19
+ >>> text("Hello, World!", font=("Arial", "Times New Roman"), fallback=True)
20
+ '#text(font: ("Arial", "Times New Roman"), fallback: true)[Hello, World!]'
21
+ >>> text("Hello, World!", size=Length(12, "pt"))
22
+ '#text(size: 12pt)[Hello, World!]'
23
+ >>> emph("Hello, World!")
24
+ '#emph[Hello, World!]'
25
+ >>> strong("Hello, World!")
26
+ '#strong[Hello, World!]'
27
+ >>> strong("Hello, World!", delta=300)
28
+ '#strong(delta: 300)[Hello, World!]'
29
+ >>> par("Hello, World!", leading=Length(1.5, "em"))
30
+ '#par(leading: 1.5em)[Hello, World!]'
31
+ >>> par("Hello, World!", justify=True)
32
+ '#par(justify: true)[Hello, World!]'
33
+ >>> par("Hello, World!")
34
+ 'Hello, World!'
35
+ >>> heading("Hello, World!", level=2, supplement=Content("Chapter"), label=Label("chap:chapter"))
36
+ '#heading(supplement: [Chapter], level: 2)[Hello, World!] <chap:chapter>'
37
+ >>> heading("Hello, World!", level=2)
38
+ '== Hello, World!'
39
+ >>> image("image.png")
40
+ '#image("image.png")'
41
+ >>> image("image.png", format="png")
42
+ '#image("image.png", format: "png")'
43
+ >>> figure(image("image.png"))
44
+ '#figure(image("image.png"))'
45
+ >>> figure(image("image.png"), caption=Content("This is a figure."))
46
+ '#figure(image("image.png"), caption: [This is a figure.])'
47
+ >>> figure(image("image.png"), caption=Content("This is a figure."), label=Label("fig:figure"))
48
+ '#figure(image("image.png"), caption: [This is a figure.]) <fig:figure>'
49
+ >>> figure(image("image.png"), caption=figure.caption("This is a figure.", separator=Content("---")))
50
+ '#figure(image("image.png"), caption: figure.caption(separator: [---])[This is a figure.])'
51
+ ```
52
+
53
+ ## ChangeLog
@@ -0,0 +1,34 @@
1
+ [tool.ruff]
2
+ target-version = "py312"
3
+
4
+ [tool.mypy]
5
+ python_version = "3.12"
6
+ warn_unused_ignores = true
7
+ check_untyped_defs = true
8
+ show_column_numbers = true
9
+ disallow_incomplete_defs = true
10
+
11
+ [tool.poetry]
12
+ name = "typstpy"
13
+ version = "0.0.1"
14
+ description = "Python interface to generate Typst code."
15
+ authors = ["chenjunhan <beibingyangliuying@foxmail.com>"]
16
+ license = "MIT"
17
+ readme = "README.md"
18
+
19
+ [tool.poetry.dependencies]
20
+ python = "^3.12"
21
+ pandas = "^2.2.3"
22
+ cytoolz = "^0.12.3"
23
+ pymonad = "^2.4.0"
24
+ attrs = "^24.2.0"
25
+
26
+
27
+ [tool.poetry.group.dev.dependencies]
28
+ mypy = "^1.11.2"
29
+ ruff = "^0.6.6"
30
+ ipykernel = "^6.29.5"
31
+
32
+ [build-system]
33
+ requires = ["poetry-core"]
34
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,18 @@
1
+ from .functions import emph, figure, heading, image, par, strong, text
2
+ from .param_types import Block, Content, Label, Length, Ratio, Relative
3
+
4
+ __all__ = [
5
+ "emph",
6
+ "figure",
7
+ "heading",
8
+ "image",
9
+ "par",
10
+ "strong",
11
+ "text",
12
+ "Block",
13
+ "Content",
14
+ "Label",
15
+ "Length",
16
+ "Ratio",
17
+ "Relative",
18
+ ]
@@ -0,0 +1,269 @@
1
+ from typing import Optional
2
+
3
+ from cytoolz.curried import assoc, valfilter # type:ignore
4
+ from pymonad.reader import Pipe # type:ignore
5
+
6
+ from .param_types import Block, Content, Label, Length, Relative
7
+ from .utils import RenderType, attach_func, render
8
+
9
+
10
+ def text(
11
+ content: Block,
12
+ *,
13
+ font: Optional[str | tuple[str]] = None,
14
+ fallback: Optional[bool] = None,
15
+ size: Optional[Length] = None,
16
+ ) -> Block:
17
+ """Interface of `text` function in typst.
18
+
19
+ Args:
20
+ content (Block): Content in which all text is styled according to the other arguments.
21
+ font (Optional[str | tuple[str]], optional): A font family name or priority list of font family names. Defaults to None.
22
+ fallback (Optional[bool], optional): Whether to allow last resort font fallback when the primary font list contains no match. This lets Typst search through all available fonts for the most similar one that has the necessary glyphs. Defaults to None.
23
+ size (Optional[Length], optional): The size of the glyphs. Defaults to None.
24
+
25
+ Returns:
26
+ Block: Executable typst block.
27
+
28
+ Examples:
29
+ >>> text("Hello, World!", font="Arial", fallback=True)
30
+ '#text(font: "Arial", fallback: true)[Hello, World!]'
31
+ >>> text("Hello, World!", font=("Arial", "Times New Roman"), fallback=True)
32
+ '#text(font: ("Arial", "Times New Roman"), fallback: true)[Hello, World!]'
33
+ >>> text("Hello, World!", size=Length(12, "pt"))
34
+ '#text(size: 12pt)[Hello, World!]'
35
+ """
36
+ params = (
37
+ Pipe({"font": font, "fallback": fallback, "size": size})
38
+ .map(valfilter(lambda x: x is not None))
39
+ .flush()
40
+ )
41
+ if not params:
42
+ return content
43
+ return rf"#text({render(RenderType.DICT)(params)})[{content}]"
44
+
45
+
46
+ def emph(content: Block) -> Block:
47
+ """Interface of `emph` function in typst.
48
+
49
+ Args:
50
+ content (Block): The content to emphasize.
51
+
52
+ Returns:
53
+ Block: Executable typst block.
54
+
55
+ Examples:
56
+ >>> emph("Hello, World!")
57
+ '#emph[Hello, World!]'
58
+ """
59
+ return rf"#emph[{content}]"
60
+
61
+
62
+ def strong(content: Block, *, delta: Optional[int] = None) -> Block:
63
+ """Interface of `strong` function in typst.
64
+
65
+ Args:
66
+ content (Block): The content to strongly emphasize.
67
+ delta (Optional[int], optional): The delta to apply on the font weight. Defaults to None.
68
+
69
+ Returns:
70
+ Block: Executable typst block.
71
+
72
+ Examples:
73
+ >>> strong("Hello, World!")
74
+ '#strong[Hello, World!]'
75
+ >>> strong("Hello, World!", delta=300)
76
+ '#strong(delta: 300)[Hello, World!]'
77
+ """
78
+ params = Pipe({"delta": delta}).map(valfilter(lambda x: x is not None)).flush()
79
+ if not params:
80
+ return rf"#strong[{content}]"
81
+ return rf"#strong({render(RenderType.DICT)(params)})[{content}]"
82
+
83
+
84
+ def par(
85
+ content: Block,
86
+ *,
87
+ leading: Optional[Length] = None,
88
+ justify: Optional[bool] = None,
89
+ linebreaks: Optional[str] = None,
90
+ first_line_indent: Optional[Length] = None,
91
+ hanging_indent: Optional[Length] = None,
92
+ ) -> Block:
93
+ """Interface of `par` function in typst.
94
+
95
+ Args:
96
+ content (Block): The contents of the paragraph.
97
+ leading (Optional[Length], optional): The spacing between lines. Defaults to None.
98
+ justify (Optional[bool], optional): Whether to justify text in its line. Defaults to None.
99
+ linebreaks (Optional[str], optional): How to determine line breaks. Options are "simple" and "optimized". Defaults to None.
100
+ first_line_indent (Optional[Length], optional): The indent the first line of a paragraph should have. Defaults to None.
101
+ hanging_indent (Optional[Length], optional): The indent all but the first line of a paragraph should have. Defaults to None.
102
+
103
+ Returns:
104
+ Block: Executable typst block.
105
+
106
+ Examples:
107
+ >>> par("Hello, World!", leading=Length(1.5, "em"))
108
+ '#par(leading: 1.5em)[Hello, World!]'
109
+ >>> par("Hello, World!", justify=True)
110
+ '#par(justify: true)[Hello, World!]'
111
+ >>> par("Hello, World!")
112
+ 'Hello, World!'
113
+ """
114
+ if linebreaks and linebreaks not in ("simple", "optimized"):
115
+ raise ValueError(f"Invalid value for linebreaks: {linebreaks}.")
116
+ params = (
117
+ Pipe(
118
+ {
119
+ "leading": leading,
120
+ "justify": justify,
121
+ "linebreaks": linebreaks,
122
+ "first_line_indent": first_line_indent,
123
+ "hanging_indent": hanging_indent,
124
+ }
125
+ )
126
+ .map(valfilter(lambda x: x is not None))
127
+ .flush()
128
+ )
129
+ if not params:
130
+ return content
131
+ return rf"#par({render(RenderType.DICT)(params)})[{content}]"
132
+
133
+
134
+ def heading(
135
+ content: Block,
136
+ *,
137
+ level: int = 1,
138
+ supplement: Optional[Content] = None,
139
+ numbering: Optional[str] = None,
140
+ label: Optional[Label] = None,
141
+ ) -> Block:
142
+ """Interface of `heading` function in typst.
143
+
144
+ Args:
145
+ content (Block): The heading's title.
146
+ level (int, optional): The absolute nesting depth of the heading, starting from one. Defaults to 1.
147
+ supplement (Optional[Content], optional): A supplement for the heading. Defaults to None.
148
+ numbering (Optional[str], optional): How to number the heading. Defaults to None.
149
+ label (Optional[Label], optional): Cross-reference for the heading. Defaults to None.
150
+
151
+ Returns:
152
+ Block: Executable typst block.
153
+
154
+ Examples:
155
+ >>> heading("Hello, World!", level=2, supplement=Content("Chapter"), label=Label("chap:chapter"))
156
+ '#heading(supplement: [Chapter], level: 2)[Hello, World!] <chap:chapter>'
157
+ >>> heading("Hello, World!", level=2)
158
+ '== Hello, World!'
159
+ """
160
+ params = (
161
+ Pipe({"supplement": supplement, "numbering": numbering})
162
+ .map(valfilter(lambda x: x is not None))
163
+ .flush()
164
+ )
165
+ if not params:
166
+ result = rf"{"="*level} {content}"
167
+ else:
168
+ result = rf"#heading({render(RenderType.DICT)(assoc(params,'level',level))})[{content}]"
169
+ if label:
170
+ result += f" {label}"
171
+ return result
172
+
173
+
174
+ def image(
175
+ path: str,
176
+ *,
177
+ format: Optional[str] = None,
178
+ width: Optional[Relative] = None,
179
+ height: Optional[Relative] = None,
180
+ alt: Optional[str] = None,
181
+ fit: Optional[str] = None,
182
+ ) -> Block:
183
+ """Interface of `image` function in typst.
184
+
185
+ Args:
186
+ path (str): Path to an image file.
187
+ format (Optional[str], optional): The image's format. Detected automatically by default. Options are "png", "jpg", "gif", and "svg". Defaults to None.
188
+ width (Optional[Relative], optional): The width of the image. Defaults to None.
189
+ height (Optional[Relative], optional): The height of the image. Defaults to None.
190
+ alt (Optional[str], optional): A text describing the image. Defaults to None.
191
+ fit (Optional[str], optional): How the image should adjust itself to a given area (the area is defined by the width and height fields). Note that fit doesn't visually change anything if the area's aspect ratio is the same as the image's one. Options are "cover", "contain", and "stretch". Defaults to None.
192
+
193
+ Returns:
194
+ Block: Executable typst block.
195
+
196
+ Examples:
197
+ >>> image("image.png")
198
+ '#image("image.png")'
199
+ >>> image("image.png", format="png")
200
+ '#image("image.png", format: "png")'
201
+ """
202
+ if format and format not in ("png", "jpg", "gif", "svg"):
203
+ raise ValueError(f"Invalid value for format: {format}.")
204
+ if fit and fit not in ("cover", "contain", "stretch"):
205
+ raise ValueError(f"Invalid value for fit: {fit}.")
206
+ params = (
207
+ Pipe(
208
+ {"format": format, "width": width, "height": height, "alt": alt, "fit": fit}
209
+ )
210
+ .map(valfilter(lambda x: x is not None))
211
+ .flush()
212
+ )
213
+ if not params:
214
+ return rf"#image({render(RenderType.VALUE)(path)})"
215
+ return (
216
+ rf"#image({render(RenderType.VALUE)(path)}, {render(RenderType.DICT)(params)})"
217
+ )
218
+
219
+
220
+ def _figure_caption(content: Block, *, separator: Optional[Content] = None) -> Content:
221
+ """Interface of `figure.caption` function in typst.
222
+
223
+ Args:
224
+ content (Block): The caption's body.
225
+ separator (Optional[Content], optional): The separator which will appear between the number and body. Defaults to None.
226
+
227
+ Returns:
228
+ Content: The caption's content.
229
+ """
230
+ params = (
231
+ Pipe({"separator": separator}).map(valfilter(lambda x: x is not None)).flush()
232
+ )
233
+ if not params:
234
+ return Content(content)
235
+ return Content(rf"#figure.caption({render(RenderType.DICT)(params)})[{content}]")
236
+
237
+
238
+ @attach_func(_figure_caption, "caption")
239
+ def figure(
240
+ content: Block, *, caption: Optional[Content] = None, label: Optional[Label] = None
241
+ ) -> Block:
242
+ """Interface of `figure` function in typst.
243
+
244
+ Args:
245
+ content (Block): The content of the figure. Often, an image.
246
+ caption (Optional[Content], optional): The figure's caption. Defaults to None.
247
+ label (Optional[Label], optional): Cross-reference for the figure. Defaults to None.
248
+
249
+ Returns:
250
+ Block: Executable typst block.
251
+
252
+ Examples:
253
+ >>> figure(image("image.png"))
254
+ '#figure(image("image.png"))'
255
+ >>> figure(image("image.png"), caption=Content("This is a figure."))
256
+ '#figure(image("image.png"), caption: [This is a figure.])'
257
+ >>> figure(image("image.png"), caption=Content("This is a figure."), label=Label("fig:figure"))
258
+ '#figure(image("image.png"), caption: [This is a figure.]) <fig:figure>'
259
+ >>> figure(image("image.png"), caption=figure.caption("This is a figure.", separator=Content("---")))
260
+ '#figure(image("image.png"), caption: figure.caption(separator: [---])[This is a figure.])'
261
+ """
262
+ params = Pipe({"caption": caption}).map(valfilter(lambda x: x is not None)).flush()
263
+ if not params:
264
+ result = rf"#figure({Content.examine_sharp(content)})"
265
+ else:
266
+ result = rf"#figure({Content.examine_sharp(content)}, {render(RenderType.DICT)(params)})"
267
+ if label:
268
+ result += f" {label}"
269
+ return result
@@ -0,0 +1,178 @@
1
+ """Classes in this module should only be used as parameter types in the `functions` module."""
2
+
3
+ from itertools import starmap
4
+ from typing import TypeAlias, Union
5
+
6
+ from attrs import field, frozen
7
+ from cytoolz.curried import curry, map # type:ignore
8
+ from pymonad.reader import Pipe # type:ignore
9
+
10
+ from .utils import FormatType, format
11
+
12
+ Block: TypeAlias = str
13
+ """Executable typst block."""
14
+
15
+
16
+ @frozen
17
+ class Content:
18
+ content: Block = field()
19
+
20
+ @content.validator
21
+ def _check_content(self, attribute, value):
22
+ # todo: Check if the content is executable typst block.
23
+ pass
24
+
25
+ def _can_simplify(self) -> bool:
26
+ return self.content.startswith("#")
27
+
28
+ @staticmethod
29
+ def examine_sharp(content: Block) -> str:
30
+ return content.lstrip("#")
31
+
32
+ def __str__(self) -> str:
33
+ if self._can_simplify():
34
+ return Content.examine_sharp(self.content)
35
+ return f"[{self.content}]"
36
+
37
+
38
+ @frozen
39
+ class Label:
40
+ label: str = field()
41
+
42
+ @label.validator
43
+ def _check_label(self, attribute, value):
44
+ # todo: Check for illegal characters in label.
45
+ pass
46
+
47
+ def __str__(self) -> str:
48
+ return f"<{self.label}>"
49
+
50
+
51
+ @frozen
52
+ class Length:
53
+ value: float = field(repr=format(FormatType.FLOAT))
54
+ unit: str = field()
55
+
56
+ @unit.validator
57
+ def _check_unit(self, attribute, value):
58
+ if value not in ("pt", "mm", "cm", "em"):
59
+ raise ValueError(f"Invalid unit: {value}.")
60
+
61
+ def __pos__(self) -> "Length":
62
+ return self
63
+
64
+ def __neg__(self) -> "Length":
65
+ return Length(-self.value, self.unit)
66
+
67
+ def __add__(self, other: Union["Length", "Ratio", "_Relative"]) -> "_Relative":
68
+ if isinstance(other, (Length, Ratio)):
69
+ return _Relative((self, other), ("+", "+"))
70
+ elif isinstance(other, _Relative):
71
+ return _Relative((self,) + other.items, ("+",) + other.signs)
72
+ else:
73
+ raise TypeError(
74
+ f"Unsupported operand type(s) for +: 'Length' and '{type(other)}'."
75
+ )
76
+
77
+ def __sub__(self, other: Union["Length", "Ratio", "_Relative"]) -> "_Relative":
78
+ if isinstance(other, (Length, Ratio)):
79
+ return _Relative((self, other), ("+", "-"))
80
+ elif isinstance(other, _Relative):
81
+ return _Relative((self,) + other.items, ("+",) + other.inverse_signs)
82
+ else:
83
+ raise TypeError(
84
+ f"Unsupported operand type(s) for -: 'Length' and '{type(other)}'."
85
+ )
86
+
87
+ def __str__(self) -> str:
88
+ return f"{format(FormatType.FLOAT)(self.value)}{self.unit}"
89
+
90
+
91
+ @frozen
92
+ class Ratio:
93
+ value: float = field(repr=format(FormatType.FLOAT))
94
+
95
+ def __pos__(self) -> "Ratio":
96
+ return self
97
+
98
+ def __neg__(self) -> "Ratio":
99
+ return Ratio(-self.value)
100
+
101
+ def __add__(self, other: Union[Length, "Ratio", "_Relative"]) -> "_Relative":
102
+ if isinstance(other, (Length, Ratio)):
103
+ return _Relative((self, other), ("+", "+"))
104
+ elif isinstance(other, _Relative):
105
+ return _Relative((self,) + other.items, ("+",) + other.signs)
106
+ else:
107
+ raise TypeError(
108
+ f"Unsupported operand type(s) for +: 'Ratio' and '{type(other)}'."
109
+ )
110
+
111
+ def __sub__(self, other: Union[Length, "Ratio", "_Relative"]) -> "_Relative":
112
+ if isinstance(other, (Length, Ratio)):
113
+ return _Relative((self, other), ("+", "-"))
114
+ elif isinstance(other, _Relative):
115
+ return _Relative((self,) + other.items, ("+",) + other.inverse_signs)
116
+ else:
117
+ raise TypeError(
118
+ f"Unsupported operand type(s) for -: 'Ratio' and '{type(other)}'."
119
+ )
120
+
121
+ def __str__(self) -> str:
122
+ return f"{format(FormatType.FLOAT)(self.value)}%"
123
+
124
+
125
+ @frozen
126
+ class _Relative:
127
+ items: tuple[Length | Ratio, ...] = field()
128
+ signs: tuple[str, ...] = field()
129
+
130
+ @property
131
+ def inverse_signs(self) -> tuple[str, ...]:
132
+ def inverse(sign: str) -> str:
133
+ match sign:
134
+ case "+":
135
+ return "-"
136
+ case "-":
137
+ return "+"
138
+ case _:
139
+ raise ValueError(f"Invalid sign: {sign}.")
140
+
141
+ return Pipe(self.signs).map(map(inverse)).map(tuple).flush()
142
+
143
+ def __add__(self, other: Union[Length, Ratio, "_Relative"]) -> "_Relative":
144
+ if isinstance(other, (Length, Ratio)):
145
+ return _Relative(self.items + (other,), self.signs + ("+",))
146
+ elif isinstance(other, _Relative):
147
+ return _Relative(self.items + other.items, self.signs + other.signs)
148
+ else:
149
+ raise TypeError(
150
+ f"Unsupported operand type(s) for +: 'Relative' and '{type(other)}'."
151
+ )
152
+
153
+ def __sub__(self, other: Union[Length, Ratio, "_Relative"]) -> "_Relative":
154
+ if isinstance(other, (Length, Ratio)):
155
+ return _Relative(self.items + (other,), self.signs + ("-",))
156
+ elif isinstance(other, _Relative):
157
+ return _Relative(self.items + other.items, self.signs + other.inverse_signs)
158
+ else:
159
+ raise TypeError(
160
+ f"Unsupported operand type(s) for -: 'Relative' and '{type(other)}'."
161
+ )
162
+
163
+ def __str__(self) -> str:
164
+ return (
165
+ Pipe(zip(self.signs, self.items))
166
+ .map(curry(starmap)(lambda x, y: f"{x}{y}"))
167
+ .map(lambda x: ("".join(x)).lstrip("+"))
168
+ .map(
169
+ lambda x: x.replace("+-", "-")
170
+ .replace("--", "+")
171
+ .replace("-+", "-")
172
+ .replace("++", "+")
173
+ )
174
+ .flush()
175
+ )
176
+
177
+
178
+ Relative: TypeAlias = Length | Ratio | _Relative
@@ -0,0 +1,88 @@
1
+ from enum import Enum, auto
2
+ from typing import Any, Callable, Optional
3
+
4
+ from cytoolz.curried import curry, map # type:ignore
5
+
6
+ # region render
7
+
8
+
9
+ class RenderType(Enum):
10
+ KEY = auto()
11
+ VALUE = auto()
12
+ DICT = auto()
13
+
14
+
15
+ def _render(render_type: RenderType) -> Callable[[Any], str]:
16
+ def render_key(key: str) -> str:
17
+ return key.replace("_", "-")
18
+
19
+ def render_value(value: Any) -> str:
20
+ match value:
21
+ case bool():
22
+ return "true" if value else "false"
23
+ case str():
24
+ return f'"{value}"'
25
+ case tuple():
26
+ return f"({', '.join(map(render_value, value))})"
27
+ case _:
28
+ return str(value)
29
+
30
+ def render_dict(params: dict[str, Any]) -> str:
31
+ if not params:
32
+ return ""
33
+ return ", ".join(
34
+ f"{render_key(k)}: {render_value(v)}" for k, v in params.items()
35
+ )
36
+
37
+ match render_type:
38
+ case RenderType.KEY:
39
+ return render_key
40
+ case RenderType.VALUE:
41
+ return render_value
42
+ case RenderType.DICT:
43
+ return render_dict
44
+
45
+
46
+ @curry
47
+ def render(render_type: RenderType, target: Any) -> str:
48
+ return _render(render_type)(target)
49
+
50
+
51
+ # endregion
52
+ # region format
53
+
54
+
55
+ class FormatType(Enum):
56
+ FLOAT = auto()
57
+
58
+
59
+ def _format(format_type: FormatType) -> Callable[[Any], str]:
60
+ def format_float(value: float) -> str:
61
+ return f"{value:.2f}".rstrip("0").rstrip(".")
62
+
63
+ match format_type:
64
+ case FormatType.FLOAT:
65
+ return format_float
66
+
67
+
68
+ @curry
69
+ def format(format_type: FormatType, target: Any) -> str:
70
+ return _format(format_type)(target)
71
+
72
+
73
+ # endregion
74
+ # region decorator
75
+
76
+
77
+ def attach_func(func: Callable, name: Optional[str] = None) -> Callable:
78
+ def wrapper(_func: Callable) -> Callable:
79
+ _name = name if name else _func.__name__
80
+ if _name.startswith("_"):
81
+ raise ValueError(f"Invalid name: {_name}.")
82
+ setattr(_func, _name, func)
83
+ return _func
84
+
85
+ return wrapper
86
+
87
+
88
+ # endregion