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/__init__.py +1 -1
- kspl/_run.py +13 -9
- kspl/config_slurper.py +71 -85
- kspl/edit.py +71 -84
- kspl/generate.py +22 -43
- kspl/gui.py +204 -79
- kspl/kconfig.py +228 -242
- kspl/main.py +35 -39
- kspl-1.2.0.dist-info/METADATA +99 -0
- kspl-1.2.0.dist-info/RECORD +14 -0
- {kspl-1.1.0.dist-info → kspl-1.2.0.dist-info}/WHEEL +1 -1
- kspl-1.1.0.dist-info/METADATA +0 -127
- kspl-1.1.0.dist-info/RECORD +0 -14
- {kspl-1.1.0.dist-info → kspl-1.2.0.dist-info}/LICENSE +0 -0
- {kspl-1.1.0.dist-info → kspl-1.2.0.dist-info}/entry_points.txt +0 -0
kspl/kconfig.py
CHANGED
@@ -1,242 +1,228 @@
|
|
1
|
-
import os
|
2
|
-
import re
|
3
|
-
from
|
4
|
-
from
|
5
|
-
from
|
6
|
-
from
|
7
|
-
from
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
from
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
level
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
:
|
86
|
-
:
|
87
|
-
|
88
|
-
"""
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
self.
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
for element in elements
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
type
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
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
|
-
|
18
|
-
)
|
19
|
-
|
20
|
-
|
21
|
-
)
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
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&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
|
+
[](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
|
+
|