-
Notifications
You must be signed in to change notification settings - Fork 85
Expand file tree
/
Copy pathlist2need.py
More file actions
295 lines (247 loc) · 10.9 KB
/
list2need.py
File metadata and controls
295 lines (247 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
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
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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
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
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
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
from __future__ import annotations
import hashlib
import re
from contextlib import suppress
from typing import Any, Sequence
from docutils import nodes
from docutils.parsers.rst import directives
from jinja2 import Template
from sphinx.errors import SphinxError, SphinxWarning
from sphinx.util.docutils import SphinxDirective
from sphinx_needs.config import NeedsSphinxConfig
NEED_TEMPLATE = """.. {{type}}:: {{title}}
{% if need_id is not none %}:id: {{need_id}}{%endif%}
{% if set_links_down %}:{{links_down_type}}: {{ links_down|join(', ') }}{%endif%}
{%- for name, value in options.items() %}:{{name}}: {{value}}
{% endfor %}
{{content}}
"""
LINE_REGEX = re.compile(
r"(?P<indent>[^\S\n]*)\*\s*(?P<text>.*)|[\S\*]*(?P<more_text>.*)"
)
ID_REGEX = re.compile(
r"(\((?P<need_id>[^\"'=\n]+)?\))"
) # Exclude some chars, which are used by option list
OPTION_AREA_REGEX = re.compile(r"\(\((.*)\)\)")
OPTIONS_REGEX = re.compile(r"([^=,\s]*)=[\"']([^\"]*)[\"']")
class List2Need(nodes.General, nodes.Element):
pass
class List2NeedDirective(SphinxDirective):
"""Create need objects out ouf a given list,
where each list entry is used to create a single need.
"""
has_content = True
required_arguments = 0
optional_arguments = 0
final_argument_whitespace = True
@staticmethod
def presentation(argument: str) -> Any:
return directives.choice(argument, ("nested", "standalone"))
option_spec = {
"types": directives.unchanged,
"delimiter": directives.unchanged,
"presentation": directives.unchanged,
"links-down": directives.unchanged,
"tags": directives.unchanged,
"list-options": directives.unchanged,
}
def run(self) -> Sequence[nodes.Node]:
env = self.env
needs_config = NeedsSphinxConfig(env.config)
presentation = self.options.get("presentation")
if not presentation:
presentation = "nested"
if presentation not in ["nested", "standalone"]:
raise SphinxWarning("'presentation' must be 'nested' or 'standalone'")
delimiter = self.options.get("delimiter")
if not delimiter:
delimiter = "."
content_raw = "\n".join(self.content)
types_raw = self.options.get("types")
if not types_raw:
raise SphinxWarning("types must be set.")
# Create a dict, which delivers the need-type for the later level
types = {}
types_raw_list = [x.strip() for x in types_raw.split(",")]
conf_types = [x["directive"] for x in needs_config.types]
for x in range(0, len(types_raw_list)):
types[x] = types_raw_list[x]
if types[x] not in conf_types:
raise SphinxError(
f"Unknown type configured: {types[x]}. Allowed are {', '.join(conf_types)}"
)
down_links_raw = self.options.get("links-down")
if down_links_raw is None or down_links_raw == "":
down_links_raw = ""
# Create a dict, which delivers the need-link for the later level
down_links_types = {}
if down_links_raw is None or down_links_raw == "":
down_links_raw_list = []
else:
down_links_raw_list = [x.strip() for x in down_links_raw.split(",")]
link_types = [x["option"] for x in needs_config.extra_links]
for i, down_link_raw in enumerate(down_links_raw_list):
down_links_types[i] = down_link_raw
if down_link_raw not in link_types:
raise SphinxError(
f"Unknown link configured: {down_link_raw}. "
f"Allowed are {', '.join(link_types)}"
)
# Retrieve tags defined at list level
tags = self.options.get("tags", "")
list_options = self.options.get("list-options", "")
list_needs = []
# Storing the data in a sorted list
for content_line in content_raw.split("\n"):
# for groups in line.findall(content_raw):
match = LINE_REGEX.search(content_line)
if not match:
continue
indent, text, more_text = match.groups()
if text:
indent = len(indent)
if not indent % 2 == 0:
raise IndentationError(
"Indentation for list must be always a multiply of 2."
)
level = int(indent / 2)
if level not in types:
raise SphinxWarning(
f"No need type defined for indentation level {level}."
f" Defined types {types}"
)
if down_links_types and level > len(down_links_types):
raise SphinxWarning(
f"Not enough links-down defined for indentation level {level}."
)
splitted_text = text.split(delimiter)
title = splitted_text[0]
content = ""
with suppress(IndexError):
content = delimiter.join(
splitted_text[1:]
) # Put the content together again
need_id_result = ID_REGEX.search(title)
if need_id_result:
need_id = need_id_result.group(2)
title = ID_REGEX.sub("", title)
else:
# Calculate the hash value, so that we can later reuse it
prefix = ""
needs_id_length = needs_config.id_length
for need_type in needs_config.types:
if need_type["directive"] == types[level]:
prefix = need_type["prefix"]
break
need_id = self.make_hashed_id(prefix, title, needs_id_length)
need = {
"title": title,
"need_id": need_id,
"type": types[level],
"content": content.lstrip(),
"level": level,
"options": {},
"list_options": {},
}
list_needs.append(need)
else:
more_text = more_text.lstrip()
if more_text.startswith(":"):
more_text = f" {more_text}"
list_needs[-1]["content"] = (
f"{list_needs[-1]['content']}\n {more_text}"
)
# Finally creating the rst code
overall_text = []
for index, list_need in enumerate(list_needs):
# Search for meta data in the complete title/content
search_string = list_need["title"] + list_need["content"]
result = OPTION_AREA_REGEX.search(search_string)
if result is not None: # An option was found
option_str = result.group(1) # We only deal with the first finding
option_result = OPTIONS_REGEX.findall(option_str)
list_need["options"] = {x[0]: x[1] for x in option_result}
# Remove possible option-strings from title and content
list_need["title"] = OPTION_AREA_REGEX.sub("", list_need["title"])
list_need["content"] = OPTION_AREA_REGEX.sub("", list_need["content"])
# Add tags defined at list level (if exists) to the ones potentially defined in the content
if tags:
if "options" not in list_need:
list_need["options"] = {}
current_tags = list_need["options"].get("tags", "")
if current_tags:
list_need["options"]["tags"] = current_tags + "," + tags
else:
list_need["options"]["tags"] = tags
if list_options:
pattern = r":(\w+):\s*([^\n:]*)"
matches = re.findall(pattern, list_options)
for key, value in matches:
if "options" not in list_need:
list_need["options"] = {}
current_key = list_need["options"].get(key, "")
if current_key:
list_need["options"][key] = current_key + "," + value.strip()
else:
list_need["options"][key] = value.strip()
# if "options" not in list_need:
# list_need["options"] = {}
# current_list_options = list_need["options"]
#
# if current_list_options:
# list_need["options"] = current_list_options + "," + list_options
# else:
# list_need["options"] = list_options
template = Template(NEED_TEMPLATE, autoescape=True)
data = list_need
need_links_down = self.get_down_needs(list_needs, index)
if (
down_links_types
and list_need["level"] in down_links_types
and need_links_down
):
data["links_down"] = need_links_down
data["links_down_type"] = down_links_types[list_need["level"]]
data["set_links_down"] = True
else:
data["set_links_down"] = False
text = template.render(**list_need)
text_list = text.split("\n")
if presentation == "nested":
indented_text_list = [" " * list_need["level"] + x for x in text_list]
text_list = indented_text_list
overall_text += text_list
self.state_machine.insert_input(
overall_text, self.state_machine.document.attributes["source"]
)
return []
def make_hashed_id(self, type_prefix: str, title: str, id_length: int) -> str:
hashable_content = title
return "{}{}".format(
type_prefix,
hashlib.sha1(hashable_content.encode("UTF-8"))
.hexdigest()
.upper()[:id_length],
)
def get_down_needs(self, list_needs: list[Any], index: int) -> list[str]:
"""
Return all needs which are directly under the one given by the index
"""
current_level = list_needs[index]["level"]
down_links = []
next_index = index + 1
try:
next_need = list_needs[next_index]
except IndexError:
return []
while next_need:
if next_need["level"] == current_level + 1:
down_links.append(next_need["need_id"])
if next_need["level"] == current_level:
break # No further needs below this need
next_index += 1
try:
next_need = list_needs[next_index]
except IndexError:
next_need = None
return down_links