Skip to content

Commit cf949cb

Browse files
authored
Merge pull request #830 from opt12/sub-attribute-selection-for-structured-attributes
Support sub-attribute selection for structured attributes in publish configuration
2 parents 7270ca3 + 6760f11 commit cf949cb

4 files changed

Lines changed: 602 additions & 8 deletions

File tree

docs/reference/item.md

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,7 @@ Introduced in v2.2, Doorstop can include extended attributes in published output
487487

488488
Edit the document configuration file `.doorstop.yml` by hand to include the desired attributes.
489489

490-
For example, to include the `invented-by` extended attribute key and value in the published output:
490+
For example, to include the `invented-by` extended attribute in the published output:
491491

492492
```yaml
493493
settings:
@@ -497,4 +497,70 @@ settings:
497497
attributes:
498498
publish:
499499
- invented-by
500-
```
500+
```
501+
502+
For simple scalar attributes (strings, numbers), the value is rendered directly in the output table.
503+
504+
For list attributes, the values are joined with `<br>` as separator:
505+
506+
```yaml
507+
# Item attribute:
508+
verification-method:
509+
- system test
510+
- analysis
511+
512+
# Rendered as:
513+
# | verification-method | system test<br>analysis |
514+
```
515+
516+
### Publishing sub-attributes of structured attributes
517+
518+
When an extended attribute contains a **list of dictionaries** (a structured attribute), you can select specific sub-attributes for publishing instead of rendering the raw object.
519+
520+
Use the attribute name directly as key with a `fields` configuration:
521+
522+
```yaml
523+
attributes:
524+
publish:
525+
- invented-by # simple attribute – unchanged behavior
526+
- spec-refs-from: # structured attribute – select sub-attributes
527+
fields:
528+
- url: section # {url_key: label_key} → renders as a hyperlink
529+
- spec-refs-to:
530+
fields:
531+
- url: section
532+
```
533+
534+
The `fields` list supports three entry formats:
535+
536+
| Format | Example | Result |
537+
| ------------------------------------------- | -------------- | -------------------------------------- |
538+
| `{url_key: label_key}` | `url: section` | single field as link text |
539+
| `{url_key: {label: [...], separator: ...}}` | see below | multiple fields combined as link text |
540+
| `fieldname` | `section` | plain text value of that sub-attribute |
541+
542+
**Simple label (single field):**
543+
```yaml
544+
attributes:
545+
publish:
546+
- spec-refs-from:
547+
fields:
548+
- url: section
549+
# → [Stop Functions](https://...)
550+
```
551+
552+
**Combined label (multiple fields):**
553+
```yaml
554+
attributes:
555+
publish:
556+
- spec-refs-from:
557+
fields:
558+
- url:
559+
label: [file, section]
560+
separator: ": "
561+
# → [System_Safety_Concept: Stop Functions](https://...)
562+
```
563+
564+
The `label` key accepts a list of sub-attribute names. The `separator` key is optional and defaults to `": "` if omitted.
565+
566+
Multiple entries in the structured attribute list are separated by `<br>` in the published output.

doorstop/core/publishers/markdown.py

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,69 @@ def _generate_heading_from_item(self, item, to_html=False):
257257
result = standard + attr_list
258258
return result
259259

260+
@staticmethod
261+
def _parse_publish_entry(entry):
262+
"""Parse a publish entry from .doorstop.yml.
263+
264+
Backward compatible:
265+
- str → {'attr': entry, 'fields': None}
266+
- dict with single key → {'attr': key, 'fields': config.get('fields')}
267+
"""
268+
if isinstance(entry, str):
269+
return {"attr": entry, "fields": None}
270+
elif isinstance(entry, dict):
271+
if len(entry) == 1:
272+
attr = next(iter(entry))
273+
config = entry[attr]
274+
if isinstance(config, dict):
275+
return {"attr": attr, "fields": config.get("fields", None)}
276+
else:
277+
return {"attr": attr, "fields": None}
278+
return None
279+
280+
@staticmethod
281+
def _render_fields(refs: list, fields: list) -> str:
282+
results = []
283+
for ref in refs:
284+
parts = []
285+
for field_entry in fields:
286+
if isinstance(field_entry, dict):
287+
for url_key, label_spec in field_entry.items():
288+
url = ref.get(url_key, "").strip()
289+
290+
# label_spec may be one of:
291+
# - str: label: section → single field
292+
# - dict: label: [file, section] → combined label
293+
if isinstance(label_spec, str):
294+
# single field
295+
label = ref.get(label_spec, url_key).strip()
296+
elif isinstance(label_spec, dict):
297+
# combined label
298+
label_fields = label_spec.get("label", [])
299+
separator = label_spec.get("separator", ": ")
300+
if isinstance(label_fields, str):
301+
label_fields = [label_fields]
302+
label = separator.join(
303+
str(ref.get(f, "")).strip()
304+
for f in label_fields
305+
if ref.get(f, "").strip()
306+
)
307+
if not label:
308+
label = url_key
309+
else:
310+
label = url_key
311+
312+
if url:
313+
parts.append(f"[{label}]({url})")
314+
else:
315+
parts.append(label)
316+
317+
elif isinstance(field_entry, str):
318+
parts.append(str(ref.get(field_entry, "")).strip())
319+
320+
results.append(" ".join(parts))
321+
return "<br>".join(results)
322+
260323
def _lines_markdown(self, obj, **kwargs):
261324
"""Yield lines for a Markdown report.
262325
@@ -269,7 +332,7 @@ def _lines_markdown(self, obj, **kwargs):
269332
linkify = kwargs.get("linkify", False)
270333
to_html = kwargs.get("to_html", False)
271334
for item in iter_items(obj):
272-
# Create iten heading.
335+
# Create item heading.
273336
complete_heading = self._generate_heading_from_item(item, to_html=to_html)
274337
yield complete_heading
275338

@@ -313,17 +376,44 @@ def _lines_markdown(self, obj, **kwargs):
313376
# Add custom publish attributes
314377
if item.document and item.document.publish:
315378
header_printed = False
316-
for attr in item.document.publish:
317-
if not item.attribute(attr):
379+
for entry in item.document.publish:
380+
parsed = self._parse_publish_entry(entry)
381+
attr = parsed.get("attr") if parsed else None
382+
fields = parsed.get("fields") if parsed else None
383+
if not attr: # catches None AND missing 'attr'
318384
continue
385+
386+
value = item.attribute(attr)
387+
if not value:
388+
continue
389+
319390
if not header_printed:
320391
header_printed = True
321392
yield ""
322393
yield "| Attribute | Value |"
323394
yield "| --------- | ----- |"
324-
yield "| {} | {} |".format(attr, item.attribute(attr))
325-
yield ""
326395

396+
# Sub-Attribute-Selection: fields given and value is a list of dicts
397+
if (
398+
fields
399+
and isinstance(fields, list)
400+
and isinstance(value, list)
401+
and value
402+
and isinstance(value[0], dict)
403+
):
404+
rendered = self._render_fields(value, fields)
405+
yield "| {} | {} |".format(attr, rendered)
406+
407+
# Fallback: standard case (backward compatible)
408+
else:
409+
if isinstance(value, list):
410+
yield "| {} | {} |".format(
411+
attr, "<br>".join(str(v) for v in value)
412+
)
413+
else:
414+
yield "| {} | {} |".format(attr, value)
415+
if header_printed:
416+
yield ""
327417
yield "" # break between items
328418

329419

doorstop/core/publishers/tests/helpers.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- REQ002: # Hello, world! !["...
1616
- REQ2-001: # Hello, world!
1717
"""
18+
1819
YAML_CUSTOM_ATTRIBUTES = """
1920
settings:
2021
digits: 3
@@ -26,6 +27,65 @@
2627
- CUSTOM-ATTRIB
2728
- invented-by
2829
"""
30+
31+
YAML_STRUCTURED_ATTRIBUTES = """\
32+
settings:
33+
digits: 3
34+
prefix: REQ
35+
sep: ''
36+
attributes:
37+
publish:
38+
- type
39+
- verification-method
40+
- spec-refs-from:
41+
fields:
42+
- url: section
43+
"""
44+
45+
YAML_LIST_ATTRIBUTE = """\
46+
settings:
47+
digits: 3
48+
prefix: REQ
49+
sep: ''
50+
attributes:
51+
publish:
52+
- verification-method
53+
"""
54+
55+
YAML_INVALID_PUBLISH_ENTRY = """\
56+
settings:
57+
digits: 3
58+
prefix: REQ
59+
sep: ''
60+
attributes:
61+
publish:
62+
- ~
63+
"""
64+
65+
YAML_COMBINED_LABEL_ATTRIBUTES = """\
66+
settings:
67+
digits: 3
68+
prefix: REQ
69+
sep: ''
70+
attributes:
71+
publish:
72+
- spec-refs-from:
73+
fields:
74+
- url:
75+
label: [file, section]
76+
separator: ": "
77+
"""
78+
79+
YAML_SINGLE_ATTRIBUTE = """\
80+
settings:
81+
digits: 3
82+
prefix: REQ
83+
sep: ''
84+
attributes:
85+
publish:
86+
- type
87+
"""
88+
2989
HTML_TEMPLATE_WALK = """
3090
template/
3191
bootstrap.bundle.min.js

0 commit comments

Comments
 (0)