st-creator 0.2.0__py3-none-any.whl

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.
st_creator/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ import click
2
+
3
+ from st.commands.theme import theme
4
+ from st.commands.version import version
5
+
6
+
7
+ @click.group()
8
+ def main():
9
+ """ST: A command line tool for creating Structurizr themes easily."""
10
+
11
+
12
+ main.add_command(version)
13
+ main.add_command(theme)
14
+
15
+ if __name__ == "__main__":
16
+ main()
File without changes
@@ -0,0 +1,137 @@
1
+ import os
2
+ import pathlib
3
+
4
+ import click
5
+
6
+ from st.core.model import Theme
7
+ from st.core.model import ThemeElement
8
+ from st.core.model import go_through_fs_tree
9
+ from st.core.rule_parser import RuleSet
10
+ from st.core.rule_parser import filter_elements
11
+ from st.utils.config import CONFIG_FILE
12
+ from st.utils.config import init_config
13
+ from st.utils.config import load_config
14
+ from st.utils.pretty_print import print_done
15
+ from st.utils.pretty_print import print_verbose
16
+ from st.utils.pretty_print import print_warning
17
+ from st.utils.str_utils import to_clean_string
18
+
19
+
20
+ @click.group()
21
+ def theme():
22
+ """Create a DSL theme."""
23
+
24
+
25
+ @theme.command()
26
+ @click.argument("path", type=click.Path(exists=True))
27
+ @click.option("--output", type=click.Path(), required=False, default=None)
28
+ @click.option(
29
+ "--rules",
30
+ type=click.Path(exists=True, dir_okay=False),
31
+ required=False,
32
+ default=None,
33
+ help="Path to a rules file (e.g. rules.st) filtering which icons to process.",
34
+ )
35
+ def clean(path, output, rules):
36
+ """Prepare a directory of icons for theme creation."""
37
+ full_path = pathlib.Path(path)
38
+ output_path = full_path.parent / (output if output else "cleaned")
39
+ if not output_path.exists():
40
+ output_path.mkdir(parents=True, exist_ok=True)
41
+ first_level = [item for item in os.listdir(full_path) if not item.startswith(".")]
42
+ first_level_dict = {item: to_clean_string(item) for item in first_level}
43
+ for item in first_level_dict.values():
44
+ if item.startswith("."):
45
+ print_verbose(f"Skipping hidden directory: {item}")
46
+ else:
47
+ if item is not None:
48
+ os.makedirs(os.path.join(output_path, item), exist_ok=True)
49
+ elements = go_through_fs_tree(
50
+ full_path=full_path, include_top_parent_directory=False
51
+ )
52
+ if rules is not None:
53
+ elements = filter_elements(elements, RuleSet.from_file(pathlib.Path(rules)))
54
+ for element in elements:
55
+ element.copy(
56
+ output_path=output_path
57
+ / element.top_parent_directory
58
+ / element.cleaned_file_name
59
+ if element.top_parent_directory
60
+ else output_path / element.cleaned_file_name
61
+ )
62
+ print_done("Cleaning done!")
63
+
64
+
65
+ @theme.command()
66
+ @click.argument("path", type=click.Path(exists=True))
67
+ @click.option("--name", type=str, required=True)
68
+ @click.option("--description", type=str, required=False, default=None)
69
+ @click.option(
70
+ "--rules",
71
+ type=click.Path(exists=True, dir_okay=False),
72
+ required=False,
73
+ default=None,
74
+ help="Path to a rules file (e.g. rules.st) filtering which icons to process.",
75
+ )
76
+ def create(path, name: str, description: str | None, rules: str) -> None:
77
+ if not CONFIG_FILE.exists():
78
+ print_warning("Configuration file not found. Creating one with default values.")
79
+ init_config(config_path=CONFIG_FILE)
80
+ config = load_config(config_path=CONFIG_FILE)
81
+ full_path = pathlib.Path(path).resolve()
82
+ output_path = full_path.parent / "-".join(name.lower().split(" "))
83
+
84
+ name = name.replace("'", "").replace('"', "")
85
+ description = (
86
+ description.replace("'", "").replace('"', "")
87
+ if description is not None
88
+ else None
89
+ )
90
+ os.makedirs(os.path.join(output_path), exist_ok=True)
91
+ theme = Theme(
92
+ name="Name here" if name is None else name,
93
+ description="Description here" if description is None else description,
94
+ )
95
+
96
+ elements = go_through_fs_tree(
97
+ full_path=full_path,
98
+ include_top_parent_directory=False,
99
+ )
100
+ if rules is not None:
101
+ elements = filter_elements(elements, RuleSet.from_file(pathlib.Path(rules)))
102
+
103
+ for element in elements:
104
+ element.copy(
105
+ output_path=output_path / (theme.file_prefix + "_" + element.theme_name),
106
+ )
107
+ element.load_properties_from_json()
108
+
109
+ theme_element = ThemeElement(
110
+ tag=element.get_theme_tag(prefix=theme.tag_prefix),
111
+ # stroke=color
112
+ # if color is not None
113
+ # else config["defaults.theme"]["stroke"],
114
+ # color=color if color is not None else config["defaults.theme"]["color"],
115
+ stroke="#232f3d",
116
+ color="#232f3d",
117
+ icon=theme.file_prefix + "_" + element.theme_name,
118
+ )
119
+ theme.add_element(theme_element)
120
+
121
+ theme.export(pathlib.Path(output_path) / "theme.json")
122
+
123
+ print_done("Theme created!")
124
+
125
+
126
+ # TODO: keep this for future releases maybe >^.^<
127
+ # @click.command()
128
+ # @click.option("--output", type=click.Path(), required=True)
129
+ # def scrape(output) -> None:
130
+ # print(f"Scraping icons from {TECH_ICON_URL}\n")
131
+ # time.sleep(1)
132
+ # if download_blob_image(
133
+ # base_url=TECH_ICON_URL, output_dir=pathlib.Path(output).resolve()
134
+ # ):
135
+ # print_done("\nAll images downloaded successfully!")
136
+ # else:
137
+ # print_warning("\nDownload failed")
@@ -0,0 +1,9 @@
1
+ import click
2
+
3
+ from st.utils.pretty_print import print_version
4
+
5
+
6
+ @click.command()
7
+ def version():
8
+ """Get CLI version."""
9
+ print_version()
File without changes
@@ -0,0 +1,355 @@
1
+ import json
2
+ import os
3
+ import pathlib
4
+ import shutil
5
+ from typing import Any
6
+
7
+ from st.core.theme_attributes import Border
8
+ from st.core.theme_attributes import Shape
9
+ from st.utils.pretty_print import print_ok
10
+ from st.utils.pretty_print import print_verbose
11
+ from st.utils.pretty_print import print_warning
12
+ from st.utils.str_utils import from_snake_to_camel_case
13
+ from st.utils.str_utils import to_clean_string
14
+
15
+
16
+ class IconElement:
17
+ """Class representing a icon in the file system.
18
+
19
+ The entire CLI is based on the file system structure and the icons found.
20
+ This class is the starting point for DSL theme elements, as it represents
21
+ the icons found.
22
+ """
23
+
24
+ def __init__(self, path: pathlib.Path, include_top_parent_directory: bool = True):
25
+ """Initialize a IconElement instance."""
26
+ self.path = path
27
+ self._include_top_parent_directory = include_top_parent_directory
28
+ self.top_parent_directory = (
29
+ pathlib.Path(to_clean_string(path.parts[1]))
30
+ if len(path.parts) > 2
31
+ else None
32
+ )
33
+
34
+ self.metadata: dict[str, Any] = {}
35
+
36
+ @property
37
+ def name(self) -> str:
38
+ """Get the name of the icon element without the file extension.
39
+
40
+ Returns:
41
+ str: The name of the icon element without the file extension.
42
+ """
43
+ return self.path.stem
44
+
45
+ @property
46
+ def full_name(self) -> str:
47
+ """Get the full name of the icon element with the file extension.
48
+
49
+ Returns:
50
+ str: The full name of the icon element with the file extension.
51
+ """
52
+ return self.path.name
53
+
54
+ @property
55
+ def extension(self) -> str:
56
+ """Get the file extension of the icon element.
57
+
58
+ Returns:
59
+ str: The file extension of the icon element.
60
+ """
61
+ return self.path.suffix
62
+
63
+ @property
64
+ def cleaned_file_name(self) -> str:
65
+ """Get the output file path for the icon element.
66
+
67
+ Returns:
68
+ str: The output file path for the icon element.
69
+ """
70
+ if (
71
+ self.top_parent_directory is not None
72
+ ) and self._include_top_parent_directory:
73
+ return (
74
+ self.top_parent_directory.name
75
+ + "_"
76
+ + to_clean_string(self.name)
77
+ + self.extension
78
+ )
79
+ else:
80
+ return to_clean_string(self.name) + self.extension
81
+
82
+ @property
83
+ def theme_name(self) -> str:
84
+ theme_name = self.path.parent.parts[-1].split("_")[0].lower() + "_" + self.name
85
+ return theme_name + self.extension
86
+
87
+ def get_theme_tag(self, prefix: str | None = None) -> str:
88
+ theme_name = self.theme_name.split(".")[0]
89
+ tag_name = (
90
+ theme_name.split("_")[0].capitalize()
91
+ + " - "
92
+ + " ".join([word.capitalize() for word in theme_name.split("_")[1:]])
93
+ )
94
+ if prefix is not None:
95
+ tag_name = prefix + " - " + tag_name
96
+
97
+ return tag_name
98
+
99
+ # TODO: find a way to manage this logic in a more elegant way
100
+ def load_properties_from_json(self):
101
+ """Load propertiers from a JSON file located alongside the element SVG file.
102
+
103
+ Args:
104
+ path (str): Path to the JSON file containing metadata.
105
+ """
106
+ try:
107
+ json_file = self.path.resolve().stem + ".json"
108
+ json_file_path = self.path.parent / json_file
109
+
110
+ with open(json_file_path) as f:
111
+ self.metadata = json.load(f)
112
+ print_verbose(f"Loaded metadata for {self.name}")
113
+ except FileNotFoundError:
114
+ self.metadata = {}
115
+
116
+ def copy(
117
+ self,
118
+ output_path: pathlib.Path,
119
+ ):
120
+ """Copy the icon element to a new location.
121
+
122
+ This is particularly to correctly manage icons for the DSL theme.
123
+
124
+ Args:
125
+ output_path (pathlib.Path): The path to copy the icon element to.
126
+ """
127
+ shutil.copyfile(
128
+ self.path,
129
+ output_path,
130
+ )
131
+ print_ok("Processed " + self.full_name)
132
+
133
+ def __str__(self) -> str:
134
+ """Return a string representation of the IconElement instance.
135
+
136
+ Returns:
137
+ str: A string representation of the IconElement instance.
138
+ """
139
+ return f"Element(name={self.name}, path={self.path})"
140
+
141
+ def __repr__(self) -> str:
142
+ """Return a string representation of the IconElement instance.
143
+
144
+ Returns:
145
+ str: A string representation of the IconElement instance.
146
+ """
147
+ repr_str = "IconElement("
148
+ for key, value in self.__class__.__dict__.items():
149
+ if not key.startswith("__") and not callable(value):
150
+ repr_str += f"{key}={getattr(self, key)}, "
151
+ repr_str = repr_str.rstrip(", ") + ")"
152
+ return repr_str
153
+
154
+
155
+ class ThemeElement:
156
+ """Class representing a theme element for the DSL theme."""
157
+
158
+ def __init__(
159
+ self,
160
+ tag: str | None = None,
161
+ stroke: str | None = None,
162
+ color: str | None = None,
163
+ icon: str | None = None,
164
+ border: Border | None = Border.SOLID,
165
+ shape: Shape | None = Shape.ROUDED_BOX,
166
+ width: int | None = None,
167
+ height: int | None = None,
168
+ background: str | None = None,
169
+ font_size: int | None = None,
170
+ opacity: int | None = None,
171
+ description: str | None = None,
172
+ ):
173
+ """Initialize a ThemeElement instance.
174
+
175
+ Args:
176
+ tag (str): The tag for the theme element.
177
+ stroke (str): The stroke color for the theme element.
178
+ color (str): The fill color for the theme element.
179
+ icon (str): The icon for the theme element.
180
+ shape (Shape): The shape of the theme element.
181
+ width (int): The width of the theme element.
182
+ height (int): The height of the theme element.
183
+ background (str): The background color for the theme element.
184
+ font_size (int): The font size for the theme element.
185
+ border (Border): The border style for the theme element.
186
+ opacity (int): The opacity for the theme element.
187
+ description (str): A brief description of the theme element.
188
+ """
189
+ self.tag = tag
190
+ self.stroke = stroke
191
+ self.color = color
192
+ self.icon = icon
193
+ self.shape = shape.value if shape is not None else None
194
+ self.width = width
195
+ self.height = height
196
+ self.background = background
197
+ self.font_size = font_size
198
+ self.border = border.value if border is not None else None
199
+ self.opacity = opacity
200
+ self.description = description
201
+
202
+ def as_dict(self) -> dict[str, Any]:
203
+ """Convert the ThemeElement instance to a dictionary. Skip None attribues.
204
+
205
+ Dictionary names are the final names used in the DSL theme.
206
+ To do so, snake_case attribute names are converted to camelCase.
207
+
208
+ Returns:
209
+ dict: A dictionary representation of the ThemeElement instance.
210
+ """
211
+ dict_representation = {}
212
+ # Iterate over class attributes
213
+ for attribute, value in self.__dict__.items():
214
+ if value is not None:
215
+ dict_representation[from_snake_to_camel_case(attribute)] = value
216
+ return dict_representation
217
+
218
+ @classmethod
219
+ def from_json_dict(cls, dict: dict[str, Any]) -> "ThemeElement":
220
+ """Create a ThemeElement instance from a dictionary.
221
+
222
+ Args:
223
+ dict (dict): A dictionary of the attributes of the ThemeElement.
224
+ As it is in the theme. Thus, the keys are in camelCase.
225
+
226
+ Returns:
227
+ ThemeElement: A new instance of ThemeElement.
228
+ """
229
+ theme_element = cls()
230
+ for key, value in dict.items():
231
+ if hasattr(theme_element, key) and key != "tag":
232
+ setattr(theme_element, key, value)
233
+ else:
234
+ print_warning(f"Unknown attribute {key} in ThemeElement JSON.")
235
+ return theme_element
236
+
237
+
238
+ def get_tag_theme_prefix(theme_name: str) -> str:
239
+ """Get the prefix for a theme based on its name.
240
+
241
+ The prefix is generated by taking the first letter of each word in the theme name,
242
+ converted to a clean string, that originally had more than 3 characters.
243
+
244
+ Args:
245
+ theme_name (str): The name of the theme.
246
+
247
+ Returns:
248
+ str: The prefix for the theme.
249
+ """
250
+ eligible_words = [
251
+ to_clean_string(word) for word in theme_name.split() if len(word) > 2
252
+ ]
253
+ prefix = (
254
+ "".join([word[0].lower() for word in eligible_words])
255
+ if len(eligible_words) > 2
256
+ else to_clean_string(theme_name)
257
+ )
258
+ return prefix
259
+
260
+
261
+ def get_file_theme_prefix(theme_name: str) -> str:
262
+ """Get the prefix for a theme based on its name.
263
+
264
+ The prefix is generated by taking the first letter of each word in the theme name,
265
+ converted to a clean string, that originally had more than 3 characters.
266
+
267
+ Args:
268
+ theme_name (str): The name of the theme.
269
+
270
+ Returns:
271
+ str: The prefix for the theme.
272
+ """
273
+ eligible_words = [
274
+ to_clean_string(word) for word in theme_name.split() if len(word) > 2
275
+ ]
276
+ prefix = (
277
+ "".join([word[0].lower() for word in eligible_words])
278
+ if len(eligible_words) > 2
279
+ else to_clean_string(theme_name)
280
+ )
281
+ return prefix
282
+
283
+
284
+ class Theme:
285
+ """Class representing a theme, which is a collection of theme elements."""
286
+
287
+ def __init__(self, name: str, description: str):
288
+ """Initialize a Theme instance.
289
+
290
+ Args:
291
+ name (str): The name of the theme.
292
+ description (str): A brief description of the theme.
293
+ """
294
+ self.name = name
295
+ self.description = description
296
+ self.tag_prefix = get_tag_theme_prefix(name)
297
+ self.file_prefix = get_file_theme_prefix(name)
298
+ self.elements: list[ThemeElement] = []
299
+
300
+ def add_element(self, element: ThemeElement) -> None:
301
+ """Add a theme element to the theme.
302
+
303
+ Args:
304
+ element (ThemeElement): The theme element to add.
305
+ """
306
+ self.elements.append(element)
307
+
308
+ def extend(self, elements: list[ThemeElement]) -> None:
309
+ """Extend the theme with a list of theme elements.
310
+
311
+ Args:
312
+ elements (list[ThemeElement]): A list of theme elements to add.
313
+ """
314
+ self.elements.extend(elements)
315
+
316
+ def export(self, path: pathlib.Path) -> None:
317
+ """Export the theme to a JSON file.
318
+
319
+ Args:
320
+ path (pathlib.Path): The path to the JSON file.
321
+ """
322
+ json_dict = {
323
+ "name": self.name,
324
+ "description": self.description,
325
+ "elements": [element.as_dict() for element in self.elements],
326
+ }
327
+
328
+ with open(path, "w") as f:
329
+ json.dump(json_dict, f, indent=4)
330
+
331
+
332
+ def go_through_fs_tree(
333
+ full_path: pathlib.Path,
334
+ include_top_parent_directory: bool,
335
+ ) -> list[IconElement]:
336
+ element_list = []
337
+ for item in os.listdir(full_path):
338
+ item_path = pathlib.Path(full_path) / item
339
+ if os.path.isfile(item_path):
340
+ if item.startswith("."):
341
+ print_verbose(
342
+ f"Skipping hidden file: {'/'.join(str(item_path).split('/')[-3:])}"
343
+ )
344
+ continue
345
+ element_list.append(IconElement(item_path, include_top_parent_directory))
346
+
347
+ elif os.path.isdir(item_path):
348
+ element_list.extend(
349
+ go_through_fs_tree(
350
+ full_path=item_path,
351
+ include_top_parent_directory=include_top_parent_directory,
352
+ )
353
+ )
354
+
355
+ return element_list