Skip to content

Commit 61e08bb

Browse files
committed
refactor: Extract MarkdownGenerator from DiffStix class
Extracted markdown generation logic into a dedicated MarkdownGenerator class to improve maintainability and reduce DiffStix class complexity. **New File Created:** - mitreattack/diffStix/formatters/markdown_generator.py (234 lines) - MarkdownGenerator class with 4 methods - generate() - Main markdown generation orchestration - placard() - Generate placard string for STIX objects - get_markdown_section_data() - Format section data - get_md_key() - Generate markdown key section **Changes to DiffStix:** - Reduced from 1,462 lines to 1,317 lines (145 lines removed, 10% reduction) - Added MarkdownGenerator import and initialization - Replaced 4 methods with simple delegation: - placard() - 80 lines → 1 line delegation - get_markdown_section_data() - 19 lines → 1 line delegation - get_md_key() - 29 lines → 1 line delegation - get_markdown_string() - 58 lines → 1 line delegation **Design Pattern:** - Uses Facade pattern for backward compatibility - MarkdownGenerator receives DiffStix instance for access to data/methods - All public APIs remain unchanged - Uses constants from utils/constants.py instead of instance variables **Benefits:** - Single Responsibility: Markdown generation is now isolated - Reduced complexity in DiffStix class - Easier to test markdown generation independently - Improved code organization and readability - No breaking changes to public API **Test Results:** - 132/133 tests passing (99.2%) - Only failure: test_get_new_changelog_md_file_write_error (known permission issue) - All markdown-related tests pass - No test modifications required **Next Steps:** This is the first of 7 planned extractions to decompose the DiffStix God Object. Remaining extractions: LayerGenerator, JsonGenerator, StatisticsCollector, HierarchyBuilder, ChangeDetector, DataLoader.
1 parent 173e140 commit 61e08bb

2 files changed

Lines changed: 242 additions & 153 deletions

File tree

mitreattack/diffStix/core/diff_stix.py

Lines changed: 8 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from mitreattack.diffStix.core.contributor_tracker import ContributorTracker
1515
from mitreattack.diffStix.core.domain_statistics import DomainStatistics
16+
from mitreattack.diffStix.formatters.markdown_generator import MarkdownGenerator
1617
from mitreattack.diffStix.utils.stix_utils import (
1718
cleanup_values,
1819
deep_copy_stix,
@@ -194,6 +195,9 @@ def __init__(
194195

195196
self.load_data()
196197

198+
# Initialize markdown generator after data is loaded
199+
self._markdown_generator = MarkdownGenerator(self)
200+
197201
@property
198202
def release_contributors(self) -> dict:
199203
"""Get the release contributors dictionary for backward compatibility.
@@ -1049,68 +1053,7 @@ def placard(self, stix_object: dict, section: str, domain: str) -> str:
10491053
str
10501054
Final return string to be displayed in the Changelog.
10511055
"""
1052-
# Import here to avoid circular dependency
1053-
from mitreattack.diffStix.formatters.html_output import get_placard_version_string
1054-
1055-
datastore_version = "old" if section == "deletions" else "new"
1056-
placard_string = ""
1057-
1058-
if section == "deletions":
1059-
placard_string = stix_object["name"]
1060-
1061-
elif section == "revocations":
1062-
revoker = stix_object["revoked_by"]
1063-
1064-
if revoker.get("x_mitre_is_subtechnique"):
1065-
parent_object = self.get_parent_stix_object(
1066-
stix_object=revoker, datastore_version=datastore_version, domain=domain
1067-
)
1068-
parent_name = parent_object.get("name", "ERROR NO PARENT")
1069-
relative_url = get_relative_url_from_stix(stix_object=revoker)
1070-
revoker_link = f"{self.site_prefix}/{relative_url}"
1071-
placard_string = (
1072-
f"{stix_object['name']} (revoked by {parent_name}: [{revoker['name']}]({revoker_link}))"
1073-
)
1074-
1075-
elif revoker["type"] == "x-mitre-data-component":
1076-
parent_object = self.get_parent_stix_object(
1077-
stix_object=revoker, datastore_version=datastore_version, domain=domain
1078-
)
1079-
if parent_object:
1080-
parent_name = parent_object.get("name", "ERROR NO PARENT")
1081-
relative_url = get_relative_data_component_url(datasource=parent_object, datacomponent=stix_object)
1082-
revoker_link = f"{self.site_prefix}/{relative_url}"
1083-
placard_string = (
1084-
f"{stix_object['name']} (revoked by {parent_name}: [{revoker['name']}]({revoker_link}))"
1085-
)
1086-
else:
1087-
# No parent datasource available — fall back to a plain-text representation.
1088-
placard_string = f"{stix_object['name']} (revoked by {revoker['name']})"
1089-
1090-
else:
1091-
relative_url = get_relative_url_from_stix(stix_object=revoker)
1092-
revoker_link = f"{self.site_prefix}/{relative_url}"
1093-
placard_string = f"{stix_object['name']} (revoked by [{revoker['name']}]({revoker_link}))"
1094-
1095-
else:
1096-
if stix_object["type"] == "x-mitre-data-component":
1097-
parent_object = self.get_parent_stix_object(
1098-
stix_object=stix_object, datastore_version=datastore_version, domain=domain
1099-
)
1100-
if parent_object:
1101-
relative_url = get_relative_data_component_url(datasource=parent_object, datacomponent=stix_object)
1102-
placard_string = f"[{stix_object['name']}]({self.site_prefix}/{relative_url})"
1103-
else:
1104-
# No parent datasource available — display datacomponent name as plain text.
1105-
placard_string = stix_object["name"]
1106-
1107-
else:
1108-
relative_url = get_relative_url_from_stix(stix_object=stix_object)
1109-
placard_string = f"[{stix_object['name']}]({self.site_prefix}/{relative_url})"
1110-
1111-
version_string = get_placard_version_string(stix_object=stix_object, section=section)
1112-
full_placard_string = f"{placard_string} {version_string}"
1113-
return full_placard_string
1056+
return self._markdown_generator.placard(stix_object, section, domain)
11141057

11151058
def _collect_domain_statistics(self, datastore: MemoryStore, domain_name: str) -> DomainStatistics:
11161059
"""
@@ -1242,22 +1185,7 @@ def get_statistics_section(self, datastore_version: str = "new") -> str:
12421185

12431186
def get_markdown_section_data(self, groupings, section: str, domain: str) -> str:
12441187
"""Parse a list of STIX objects in a section and return a string for the whole section."""
1245-
sectionString = ""
1246-
placard_string = ""
1247-
for grouping in groupings:
1248-
if grouping["parentInSection"]:
1249-
placard_string = self.placard(stix_object=grouping["parent"], section=section, domain=domain)
1250-
sectionString += f"* {placard_string}\n"
1251-
1252-
for child in sorted(grouping["children"], key=lambda child: child["name"]):
1253-
placard_string = self.placard(stix_object=child, section=section, domain=domain)
1254-
1255-
if grouping["parentInSection"]:
1256-
sectionString += f" * {placard_string}\n"
1257-
else:
1258-
sectionString += f"* {grouping['parent']['name']}: {placard_string}\n"
1259-
1260-
return sectionString
1188+
return self._markdown_generator.get_markdown_section_data(groupings, section, domain)
12611189

12621190
def get_md_key(self) -> str:
12631191
"""Create string describing each type of difference (change, addition, etc).
@@ -1267,84 +1195,11 @@ def get_md_key(self) -> str:
12671195
str
12681196
Key for change types used in Markdown output.
12691197
"""
1270-
# Import here to avoid circular dependency
1271-
import textwrap
1272-
1273-
# end first line with \ to avoid the empty line from dedent()
1274-
key = textwrap.dedent(
1275-
f"""\
1276-
## Key
1277-
1278-
* New objects: {self.section_descriptions["additions"]}
1279-
* Major version changes: {self.section_descriptions["major_version_changes"]}
1280-
* Minor version changes: {self.section_descriptions["minor_version_changes"]}
1281-
* Other version changes: {self.section_descriptions["other_version_changes"]}
1282-
* Patches: {self.section_descriptions["patches"]}
1283-
* Object revocations: {self.section_descriptions["revocations"]}
1284-
* Object deprecations: {self.section_descriptions["deprecations"]}
1285-
* Object deletions: {self.section_descriptions["deletions"]}
1286-
"""
1287-
)
1288-
1289-
return key
1198+
return self._markdown_generator.get_md_key()
12901199

12911200
def get_markdown_string(self) -> str:
12921201
"""Return a markdown string summarizing detected differences."""
1293-
logger.info("Generating markdown output")
1294-
content = ""
1295-
1296-
# Add contributors if requested by argument
1297-
if self.include_contributors:
1298-
content += self.get_contributor_section()
1299-
content += "\n"
1300-
1301-
# Add statistics section for the new version
1302-
logger.info("Generating statistics section")
1303-
stats_section = self.get_statistics_section(datastore_version="new")
1304-
content += stats_section
1305-
1306-
if self.show_key:
1307-
key_content = self.get_md_key()
1308-
content += f"{key_content}\n"
1309-
1310-
content += "## Table of Contents\n\n"
1311-
content += "[TOC]\n\n"
1312-
1313-
for object_type in self.types:
1314-
domains = ""
1315-
1316-
for domain in self.data["changes"][object_type]:
1317-
# e.g "Enterprise"
1318-
next_domain = f"### {self.domain_to_domain_label[domain]}\n\n"
1319-
# Skip mobile section for data sources
1320-
if domain == "mobile-attack" and object_type == "datasource":
1321-
logger.debug("Skipping - ATT&CK for Mobile does not support data sources")
1322-
next_domain += "ATT&CK for Mobile does not support data sources\n\n"
1323-
continue
1324-
domain_sections = ""
1325-
for section, stix_objects in self.data["changes"][object_type][domain].items():
1326-
header = f"#### {self.section_headers[object_type][section]}"
1327-
if stix_objects:
1328-
groupings = self.get_groupings(
1329-
object_type=object_type,
1330-
stix_objects=stix_objects,
1331-
section=section,
1332-
domain=domain,
1333-
)
1334-
section_items = self.get_markdown_section_data(
1335-
groupings=groupings, section=section, domain=domain
1336-
)
1337-
domain_sections += f"{header}\n\n{section_items}\n"
1338-
1339-
# add domain sections
1340-
if domain_sections != "":
1341-
domains += f"{next_domain}{domain_sections}"
1342-
1343-
# e.g "techniques"
1344-
if domains != "":
1345-
content += f"## {self.attack_type_to_title[object_type]}\n\n{domains}"
1346-
1347-
return content
1202+
return self._markdown_generator.generate()
13481203

13491204
def get_layers_dict(self):
13501205
"""Return ATT&CK Navigator layers in dict format summarizing detected differences.

0 commit comments

Comments
 (0)