kspl 1.1.0__py3-none-any.whl → 1.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.
kspl/kconfig.py CHANGED
@@ -1,242 +1,228 @@
1
- import os
2
- import re
3
- from contextlib import contextmanager
4
- from dataclasses import dataclass
5
- from enum import Enum, auto
6
- from pathlib import Path
7
- from typing import Any, Generator, List, Optional
8
-
9
- import kconfiglib
10
- from guiconfig import menuconfig
11
- from kconfiglib import MenuNode
12
-
13
-
14
- class TriState(Enum):
15
- Y = auto()
16
- M = auto()
17
- N = auto()
18
-
19
-
20
- class ConfigElementType(Enum):
21
- UNKNOWN = auto()
22
- BOOL = auto()
23
- TRISTATE = auto()
24
- STRING = auto()
25
- INT = auto()
26
- HEX = auto()
27
- MENU = auto()
28
-
29
-
30
- @dataclass
31
- class ConfigElement:
32
- type: ConfigElementType
33
- name: str
34
- value: Any
35
-
36
- @property
37
- def is_menu(self) -> bool:
38
- return self.type == ConfigElementType.MENU
39
-
40
-
41
- @dataclass
42
- class EditableConfigElement(ConfigElement):
43
- original_value: Any
44
-
45
- #: The level of the menu this element is in. 0 is the top level.
46
- level: int = 0
47
- #: Is determined when the value is calculated. This is a hidden function call due to property magic.
48
- write_to_conf: bool = True
49
-
50
- @property
51
- def id(self) -> str:
52
- return self.name
53
-
54
- @property
55
- def has_been_changed(self) -> bool:
56
- return self.original_value != self.value
57
-
58
-
59
- @dataclass
60
- class ConfigurationData:
61
- """Holds the variant configuration data which is relevant for the code generation
62
- Requires no variable substitution (this should have been already done)"""
63
-
64
- elements: List[ConfigElement]
65
-
66
-
67
- @contextmanager
68
- def working_directory(some_directory: Path) -> Generator[None, Any, None]:
69
- current_directory = Path().absolute()
70
- try:
71
- os.chdir(some_directory)
72
- yield
73
- finally:
74
- os.chdir(current_directory)
75
-
76
-
77
- class KConfig:
78
- def __init__(
79
- self,
80
- k_config_model_file: Path,
81
- k_config_file: Optional[Path] = None,
82
- k_config_root_directory: Optional[Path] = None,
83
- ):
84
- """
85
- :param k_config_model_file: Feature model definition (KConfig format)
86
- :param k_config_file: User feature selection configuration file
87
- :param k_config_root_directory: all paths for the included configuration paths shall be relative to this folder
88
- """
89
- if not k_config_model_file.is_file():
90
- raise FileNotFoundError(f"File {k_config_model_file} does not exist.")
91
- self.k_config_root_directory = (
92
- k_config_root_directory or k_config_model_file.parent
93
- )
94
- with working_directory(self.k_config_root_directory):
95
- self.config = kconfiglib.Kconfig(k_config_model_file.absolute().as_posix())
96
- self.parsed_files: List[Path] = self._collect_parsed_files()
97
- self.k_config_file: Optional[Path] = k_config_file
98
- if self.k_config_file:
99
- if not self.k_config_file.is_file():
100
- raise FileNotFoundError(f"File {self.k_config_file} does not exist.")
101
- self.config.load_config(self.k_config_file, replace=False)
102
- self.parsed_files.append(self.k_config_file)
103
- self.elements = self._collect_elements()
104
- self._elements_dict = {element.id: element for element in self.elements}
105
-
106
- def get_parsed_files(self) -> List[Path]:
107
- return self.parsed_files
108
-
109
- def collect_config_data(self) -> ConfigurationData:
110
- """- creates the ConfigurationData from the KConfig configuration"""
111
- elements = self.elements
112
- elements_dict = {element.id: element for element in elements}
113
-
114
- # replace text in KConfig with referenced variables (string type only)
115
- # KConfig variables get replaced like: ${VARIABLE_NAME}, e.g. ${CONFIG_FOO}
116
- for element in elements:
117
- if element.type == ConfigElementType.STRING:
118
- element.value = re.sub(
119
- r"\$\{([A-Za-z0-9_]+)\}",
120
- lambda m: str(elements_dict[m.group(1)].value),
121
- element.value,
122
- )
123
- element.value = re.sub(
124
- r"\$\{ENV:([A-Za-z0-9_]+)\}",
125
- lambda m: str(os.environ.get(m.group(1), "")),
126
- element.value,
127
- )
128
-
129
- return ConfigurationData(
130
- [
131
- ConfigElement(elem.type, elem.name, elem.value)
132
- for elem in elements
133
- if elem.type != ConfigElementType.MENU
134
- ]
135
- )
136
-
137
- def menu_config(self) -> None:
138
- if self.k_config_file:
139
- # The environment variable KCONFIG_CONFIG is used by kconfiglib to determine
140
- # the configuration file to load.
141
- os.environ["KCONFIG_CONFIG"] = self.k_config_file.absolute().as_posix()
142
- menuconfig(self.config)
143
-
144
- def _collect_elements(self) -> List[EditableConfigElement]:
145
- elements: List[EditableConfigElement] = []
146
-
147
- def convert_to_element(
148
- node: MenuNode, level: int
149
- ) -> Optional[EditableConfigElement]:
150
- # TODO: Symbols like 'choice' and 'comment' shall be ignored.
151
- element = None
152
- sym = node.item
153
- if isinstance(sym, kconfiglib.Symbol):
154
- if sym.config_string:
155
- val = sym.str_value
156
- type = ConfigElementType.STRING
157
- if sym.type in [kconfiglib.BOOL, kconfiglib.TRISTATE]:
158
- val = getattr(TriState, str(val).upper())
159
- type = (
160
- ConfigElementType.BOOL
161
- if sym.type == kconfiglib.BOOL
162
- else ConfigElementType.TRISTATE
163
- )
164
- elif sym.type == kconfiglib.HEX:
165
- val = int(str(val), 16)
166
- type = ConfigElementType.HEX
167
- elif sym.type == kconfiglib.INT:
168
- val = int(val)
169
- type = ConfigElementType.INT
170
- element = EditableConfigElement(
171
- type=type,
172
- name=sym.name,
173
- value=val,
174
- original_value=val,
175
- level=level,
176
- write_to_conf=sym._write_to_conf,
177
- )
178
- else:
179
- if isinstance(node, kconfiglib.MenuNode):
180
- element = EditableConfigElement(
181
- type=ConfigElementType.MENU,
182
- name=node.prompt[0],
183
- value=None,
184
- original_value=None,
185
- level=level,
186
- write_to_conf=False,
187
- )
188
- return element
189
-
190
- def _shown_full_nodes(node: MenuNode) -> List[MenuNode]:
191
- # Returns the list of menu nodes shown in 'menu' (a menu node for a menu)
192
- # for full-tree mode. A tricky detail is that invisible items need to be
193
- # shown if they have visible children.
194
-
195
- def rec(node: MenuNode) -> List[MenuNode]:
196
- res = []
197
-
198
- while node:
199
- res.append(node)
200
- if node.list and isinstance(node.item, kconfiglib.Symbol):
201
- # Nodes from menu created from dependencies
202
- res += rec(node.list)
203
- node = node.next
204
-
205
- return res
206
-
207
- return rec(node.list)
208
-
209
- def create_elements_tree(
210
- node: MenuNode, collected_nodes: List[EditableConfigElement], level: int = 0
211
- ) -> None:
212
- # Updates the tree starting from menu.list, in full-tree mode. To speed
213
- # things up, only open menus are updated. The menu-at-a-time logic here is
214
- # to deal with invisible items that can show up outside show-all mode (see
215
- # _shown_full_nodes()).
216
-
217
- for node in _shown_full_nodes(node):
218
- element = convert_to_element(node, level)
219
- if element:
220
- collected_nodes.append(element)
221
- # _shown_full_nodes() includes nodes from menus rooted at symbols, so
222
- # we only need to check "real" menus/choices here
223
- if node.list and not isinstance(node.item, kconfiglib.Symbol):
224
- create_elements_tree(node, collected_nodes, level + 1)
225
-
226
- create_elements_tree(self.config.top_node, elements)
227
- return elements
228
-
229
- def find_element(self, name: str) -> Optional[EditableConfigElement]:
230
- return self._elements_dict.get(name, None)
231
-
232
- def _collect_parsed_files(self) -> List[Path]:
233
- """Collects all parsed files from the KConfig instance and returns them as a list of absolute paths"""
234
- parsed_files: List[Path] = []
235
- for file in self.config.kconfig_filenames:
236
- file_path = Path(file)
237
- parsed_files.append(
238
- file_path
239
- if file_path.is_absolute()
240
- else self.k_config_root_directory / file_path
241
- )
242
- return parsed_files
1
+ import os
2
+ import re
3
+ from collections.abc import Generator
4
+ from contextlib import contextmanager
5
+ from dataclasses import dataclass
6
+ from enum import Enum, auto
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ import kconfiglib
11
+ from guiconfig import menuconfig
12
+ from kconfiglib import MenuNode
13
+
14
+
15
+ class TriState(Enum):
16
+ Y = auto()
17
+ M = auto()
18
+ N = auto()
19
+
20
+
21
+ class ConfigElementType(Enum):
22
+ UNKNOWN = auto()
23
+ BOOL = auto()
24
+ TRISTATE = auto()
25
+ STRING = auto()
26
+ INT = auto()
27
+ HEX = auto()
28
+ MENU = auto()
29
+
30
+
31
+ @dataclass
32
+ class ConfigElement:
33
+ type: ConfigElementType
34
+ name: str
35
+ value: Any
36
+
37
+ @property
38
+ def is_menu(self) -> bool:
39
+ return self.type == ConfigElementType.MENU
40
+
41
+
42
+ @dataclass
43
+ class EditableConfigElement(ConfigElement):
44
+ original_value: Any
45
+
46
+ #: The level of the menu this element is in. 0 is the top level.
47
+ level: int = 0
48
+ #: Is determined when the value is calculated. This is a hidden function call due to property magic.
49
+ write_to_conf: bool = True
50
+
51
+ @property
52
+ def id(self) -> str:
53
+ return self.name
54
+
55
+ @property
56
+ def has_been_changed(self) -> bool:
57
+ return self.original_value != self.value
58
+
59
+
60
+ @dataclass
61
+ class ConfigurationData:
62
+ """
63
+ Holds the variant configuration data which is relevant for the code generation.
64
+
65
+ Requires no variable substitution (this should have been already done)
66
+ """
67
+
68
+ elements: list[ConfigElement]
69
+
70
+
71
+ @contextmanager
72
+ def working_directory(some_directory: Path) -> Generator[None, Any, None]:
73
+ current_directory = Path().absolute()
74
+ try:
75
+ os.chdir(some_directory)
76
+ yield
77
+ finally:
78
+ os.chdir(current_directory)
79
+
80
+
81
+ class KConfig:
82
+ def __init__(
83
+ self,
84
+ k_config_model_file: Path,
85
+ k_config_file: Optional[Path] = None,
86
+ k_config_root_directory: Optional[Path] = None,
87
+ ):
88
+ """
89
+ Parameters.
90
+
91
+ - k_config_model_file: Feature model definition (KConfig format)
92
+ - k_config_file: User feature selection configuration file
93
+ - k_config_root_directory: all paths for the included configuration paths shall be relative to this folder
94
+ """
95
+ if not k_config_model_file.is_file():
96
+ raise FileNotFoundError(f"File {k_config_model_file} does not exist.")
97
+ self.k_config_root_directory = k_config_root_directory or k_config_model_file.parent
98
+ with working_directory(self.k_config_root_directory):
99
+ self.config = kconfiglib.Kconfig(k_config_model_file.absolute().as_posix())
100
+ self.parsed_files: list[Path] = self._collect_parsed_files()
101
+ self.k_config_file: Optional[Path] = k_config_file
102
+ if self.k_config_file:
103
+ if not self.k_config_file.is_file():
104
+ raise FileNotFoundError(f"File {self.k_config_file} does not exist.")
105
+ self.config.load_config(self.k_config_file, replace=False)
106
+ self.parsed_files.append(self.k_config_file)
107
+ self.elements = self._collect_elements()
108
+ self._elements_dict = {element.id: element for element in self.elements}
109
+
110
+ def get_parsed_files(self) -> list[Path]:
111
+ return self.parsed_files
112
+
113
+ def collect_config_data(self) -> ConfigurationData:
114
+ """- creates the ConfigurationData from the KConfig configuration."""
115
+ elements = self.elements
116
+ elements_dict = {element.id: element for element in elements}
117
+
118
+ # replace text in KConfig with referenced variables (string type only)
119
+ # KConfig variables get replaced like: ${VARIABLE_NAME}, e.g. ${CONFIG_FOO}
120
+ for element in elements:
121
+ if element.type == ConfigElementType.STRING:
122
+ element.value = re.sub(
123
+ r"\$\{([A-Za-z0-9_]+)\}",
124
+ lambda m: str(elements_dict[str(m.group(1))].value),
125
+ element.value,
126
+ )
127
+ element.value = re.sub(
128
+ r"\$\{ENV:([A-Za-z0-9_]+)\}",
129
+ lambda m: str(os.environ.get(str(m.group(1)), "")),
130
+ element.value,
131
+ )
132
+
133
+ return ConfigurationData([ConfigElement(elem.type, elem.name, elem.value) for elem in elements if elem.type != ConfigElementType.MENU])
134
+
135
+ def menu_config(self) -> None:
136
+ if self.k_config_file:
137
+ # The environment variable KCONFIG_CONFIG is used by kconfiglib to determine
138
+ # the configuration file to load.
139
+ os.environ["KCONFIG_CONFIG"] = self.k_config_file.absolute().as_posix()
140
+ menuconfig(self.config)
141
+
142
+ def _collect_elements(self) -> list[EditableConfigElement]:
143
+ elements: list[EditableConfigElement] = []
144
+
145
+ def convert_to_element(node: MenuNode, level: int) -> EditableConfigElement | None:
146
+ # TODO: Symbols like 'choice' and 'comment' shall be ignored.
147
+ element = None
148
+ sym = node.item
149
+ if isinstance(sym, kconfiglib.Symbol):
150
+ if sym.config_string:
151
+ val = sym.str_value
152
+ type = ConfigElementType.STRING
153
+ if sym.type in [kconfiglib.BOOL, kconfiglib.TRISTATE]:
154
+ val = getattr(TriState, str(val).upper())
155
+ type = ConfigElementType.BOOL if sym.type == kconfiglib.BOOL else ConfigElementType.TRISTATE
156
+ elif sym.type == kconfiglib.HEX:
157
+ val = int(str(val), 16)
158
+ type = ConfigElementType.HEX
159
+ elif sym.type == kconfiglib.INT:
160
+ val = int(val)
161
+ type = ConfigElementType.INT
162
+ element = EditableConfigElement(
163
+ type=type,
164
+ name=sym.name,
165
+ value=val,
166
+ original_value=val,
167
+ level=level,
168
+ write_to_conf=sym._write_to_conf,
169
+ )
170
+ else:
171
+ if isinstance(node, kconfiglib.MenuNode):
172
+ element = EditableConfigElement(
173
+ type=ConfigElementType.MENU,
174
+ name=node.prompt[0],
175
+ value=None,
176
+ original_value=None,
177
+ level=level,
178
+ write_to_conf=False,
179
+ )
180
+ return element
181
+
182
+ def _shown_full_nodes(node: MenuNode) -> list[MenuNode]:
183
+ # Returns the list of menu nodes shown in 'menu' (a menu node for a menu)
184
+ # for full-tree mode. A tricky detail is that invisible items need to be
185
+ # shown if they have visible children.
186
+
187
+ def rec(node: MenuNode) -> list[MenuNode]:
188
+ res = []
189
+
190
+ while node:
191
+ res.append(node)
192
+ if node.list and isinstance(node.item, kconfiglib.Symbol):
193
+ # Nodes from menu created from dependencies
194
+ res += rec(node.list)
195
+ node = node.next
196
+
197
+ return res
198
+
199
+ return rec(node.list)
200
+
201
+ def create_elements_tree(node: MenuNode, collected_nodes: list[EditableConfigElement], level: int = 0) -> None:
202
+ # Updates the tree starting from menu.list, in full-tree mode. To speed
203
+ # things up, only open menus are updated. The menu-at-a-time logic here is
204
+ # to deal with invisible items that can show up outside show-all mode (see
205
+ # _shown_full_nodes()).
206
+
207
+ for menu_node in _shown_full_nodes(node):
208
+ element = convert_to_element(menu_node, level)
209
+ if element:
210
+ collected_nodes.append(element)
211
+ # _shown_full_nodes() includes nodes from menus rooted at symbols, so
212
+ # we only need to check "real" menus/choices here
213
+ if menu_node.list and not isinstance(menu_node.item, kconfiglib.Symbol):
214
+ create_elements_tree(menu_node, collected_nodes, level + 1)
215
+
216
+ create_elements_tree(self.config.top_node, elements)
217
+ return elements
218
+
219
+ def find_element(self, name: str) -> EditableConfigElement | None:
220
+ return self._elements_dict.get(name, None)
221
+
222
+ def _collect_parsed_files(self) -> list[Path]:
223
+ """Collects all parsed files from the KConfig instance and returns them as a list of absolute paths."""
224
+ parsed_files: list[Path] = []
225
+ for file in self.config.kconfig_filenames:
226
+ file_path = Path(file)
227
+ parsed_files.append(file_path if file_path.is_absolute() else self.k_config_root_directory / file_path)
228
+ return parsed_files
kspl/main.py CHANGED
@@ -1,39 +1,35 @@
1
- import sys
2
- from argparse import ArgumentParser
3
- from sys import argv
4
-
5
- from py_app_dev.core.cmd_line import CommandLineHandlerBuilder
6
- from py_app_dev.core.exceptions import UserNotificationException
7
- from py_app_dev.core.logging import logger, setup_logger
8
-
9
- from kspl import __version__
10
- from kspl.edit import EditCommand
11
- from kspl.generate import GenerateCommand
12
- from kspl.gui import GuiCommand
13
-
14
-
15
- def do_run() -> None:
16
- parser = ArgumentParser(
17
- prog="kspl", description="kconfig for SPL", exit_on_error=False
18
- )
19
- parser.add_argument(
20
- "-v", "--version", action="version", version=f"%(prog)s {__version__}"
21
- )
22
- builder = CommandLineHandlerBuilder(parser)
23
- builder.add_commands([GuiCommand(), GenerateCommand(), EditCommand()])
24
- handler = builder.create()
25
- handler.run(argv[1:])
26
-
27
-
28
- def main() -> int:
29
- try:
30
- setup_logger()
31
- do_run()
32
- except UserNotificationException as e:
33
- logger.error(f"{e}")
34
- return 1
35
- return 0
36
-
37
-
38
- if __name__ == "__main__":
39
- sys.exit(main())
1
+ import sys
2
+ from argparse import ArgumentParser
3
+ from sys import argv
4
+
5
+ from py_app_dev.core.cmd_line import CommandLineHandlerBuilder
6
+ from py_app_dev.core.exceptions import UserNotificationException
7
+ from py_app_dev.core.logging import logger, setup_logger
8
+
9
+ from kspl import __version__
10
+ from kspl.edit import EditCommand
11
+ from kspl.generate import GenerateCommand
12
+ from kspl.gui import GuiCommand
13
+
14
+
15
+ def do_run() -> None:
16
+ parser = ArgumentParser(prog="kspl", description="kconfig for SPL", exit_on_error=False)
17
+ parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
18
+ builder = CommandLineHandlerBuilder(parser)
19
+ builder.add_commands([GuiCommand(), GenerateCommand(), EditCommand()])
20
+ handler = builder.create()
21
+ handler.run(argv[1:])
22
+
23
+
24
+ def main() -> int:
25
+ try:
26
+ setup_logger()
27
+ do_run()
28
+ except UserNotificationException as e:
29
+ logger.error(f"{e}")
30
+ return 1
31
+ return 0
32
+
33
+
34
+ if __name__ == "__main__":
35
+ sys.exit(main())
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.3
2
+ Name: kspl
3
+ Version: 1.2.0
4
+ Summary: KConfig GUI for Software Product Lines with multiple variants.
5
+ License: MIT
6
+ Author: Cuinixam
7
+ Author-email: cuinixam@me.com
8
+ Requires-Python: <4.0,>=3.10
9
+ Classifier: Development Status :: 2 - Pre-Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Natural Language :: English
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Requires-Dist: customtkinter (>=5,<6)
20
+ Requires-Dist: kconfiglib (>=14,<15)
21
+ Requires-Dist: py-app-dev (>=2,<3)
22
+ Project-URL: Bug Tracker, https://github.com/cuinixam/kspl/issues
23
+ Project-URL: Changelog, https://github.com/cuinixam/kspl/blob/main/CHANGELOG.md
24
+ Project-URL: Repository, https://github.com/cuinixam/kspl
25
+ Description-Content-Type: text/markdown
26
+
27
+ # SPL KConfig GUI
28
+
29
+ <p align="center">
30
+ <a href="https://github.com/cuinixam/kspl/actions/workflows/ci.yml?query=branch%3Amain">
31
+ <img src="https://img.shields.io/github/actions/workflow/status/cuinixam/kspl/ci.yml?branch=main&label=CI&logo=github&style=flat-square" alt="CI Status" >
32
+ </a>
33
+ <a href="https://codecov.io/gh/cuinixam/kspl">
34
+ <img src="https://img.shields.io/codecov/c/github/cuinixam/kspl.svg?logo=codecov&logoColor=fff&style=flat-square" alt="Test coverage percentage">
35
+ </a>
36
+ </p>
37
+ <p align="center">
38
+ <a href="https://github.com/astral-sh/uv">
39
+ <img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json" alt="uv">
40
+ </a>
41
+ <a href="https://github.com/astral-sh/ruff">
42
+ <img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff">
43
+ </a>
44
+ <a href="https://github.com/pre-commit/pre-commit">
45
+ <img src="https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white&style=flat-square" alt="pre-commit">
46
+ </a>
47
+ </p>
48
+ <p align="center">
49
+ <a href="https://pypi.org/project/kspl/">
50
+ <img src="https://img.shields.io/pypi/v/kspl.svg?logo=python&logoColor=fff&style=flat-square" alt="PyPI Version">
51
+ </a>
52
+ <img src="https://img.shields.io/pypi/pyversions/kspl.svg?style=flat-square&logo=python&amp;logoColor=fff" alt="Supported Python versions">
53
+ <img src="https://img.shields.io/pypi/l/kspl.svg?style=flat-square" alt="License">
54
+ </p>
55
+
56
+ ---
57
+
58
+ **Source Code**: <a href="https://github.com/cuinixam/kspl" target="_blank">https://github.com/cuinixam/kspl </a>
59
+
60
+ ---
61
+
62
+ KConfig GUI for Software Product Lines with multiple variants.
63
+
64
+ ## Installation
65
+
66
+ Install this via pip (or your favourite package manager):
67
+
68
+ `pip install kspl`
69
+
70
+ ## Usage
71
+
72
+ Start by importing it:
73
+
74
+ ```python
75
+ import kspl
76
+ ```
77
+
78
+ ## Contributors ✨
79
+
80
+ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
81
+
82
+ <!-- prettier-ignore-start -->
83
+ <!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
84
+ <!-- markdownlint-disable -->
85
+ <!-- markdownlint-enable -->
86
+ <!-- ALL-CONTRIBUTORS-LIST:END -->
87
+ <!-- prettier-ignore-end -->
88
+
89
+ This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
90
+
91
+ ## Credits
92
+
93
+ [![Copier](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/copier-org/copier/master/img/badge/badge-grayscale-inverted-border-orange.json)](https://github.com/copier-org/copier)
94
+
95
+ This package was created with
96
+ [Copier](https://copier.readthedocs.io/) and the
97
+ [browniebroke/pypackage-template](https://github.com/browniebroke/pypackage-template)
98
+ project template.
99
+