Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2ba062b
#402: Created nox task to detect resolved GitHub security issues
ckunki Apr 5, 2026
282591f
added typehint to get_vulnerabilities_from_latest_tag
ckunki Apr 6, 2026
8b1e7b8
Validated warning in test
ckunki Apr 6, 2026
91aebce
Renamed method resolved to resolved_vulnerabilities
ckunki Apr 6, 2026
8d097a5
Renamed nox task and class SecurityAudit once again
ckunki Apr 6, 2026
9bd15c9
Added integration test
ckunki Apr 6, 2026
ebfdff7
Added changes file parser
ckunki Apr 6, 2026
41a85c1
added docstring and renamed methods
ckunki Apr 7, 2026
e8b6d82
Added support for list items with dash
ckunki Apr 7, 2026
0d6242e
Updated docstring
ckunki Apr 7, 2026
7308052
Refactored and renamed changes_file.py
ckunki Apr 8, 2026
c008cf4
Enhanced class Markdown
ckunki Apr 8, 2026
9f7c93b
Refactored class Markdown
ckunki Apr 9, 2026
0f1b346
Fixed bug when parsing intro and items
ckunki Apr 9, 2026
fc12b43
merged changes from changelog.py
ckunki Apr 9, 2026
f71604c
Refactored tests
ckunki Apr 10, 2026
67fa82c
format:fix
ckunki Apr 10, 2026
2331366
format:fix
ckunki Apr 10, 2026
efc20f5
Merge branch 'main' into feature/402-Created_nox_task_to_detect_resol…
ckunki Apr 12, 2026
6e09021
Merge branch 'feature/402-Created_nox_task_to_detect_resolved_GitHub_…
ckunki Apr 12, 2026
f8b2d5b
Merge branch 'main' into sec-2-markdown
ckunki Apr 15, 2026
742ced0
Updated changelog
ckunki Apr 15, 2026
b1b9a77
Merge branch 'main' into refactoring/763-Parse_and_Manipulate_Changes…
ckunki Apr 15, 2026
d7c34a8
Removed unused import
ckunki Apr 15, 2026
dcb1003
Fixed type errors
ckunki Apr 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions doc/changes/unreleased.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Unreleased

## Summary

## Refactorings

* #763: Parse and Manipulate Changes Files
195 changes: 195 additions & 0 deletions exasol/toolbox/util/release/markdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"""
Class Markdown represents a file in markdown syntax with some additional
constraints:

* The file must start with a title in the first line.
* Each subsequent title must be of a higher level, ie. start with more "#"
characters than the top-level title.

Each title starts a section, optionally containing an additional intro and a
bullet list of items.

Each section can also contain subsections as children, hence sections can be
nested up to the top-level section representing the whole file.
"""

from __future__ import annotations

import io
from pathlib import Path


class ParseError(Exception):
"""
Indicates inconsistencies when parsing a changelog from raw
text. E.g. a section with a body but no title.
"""


class IllegalChild(Exception):
"""
When adding a child to a parent with higher level title.
"""


def is_title(line: str) -> bool:
return bool(line) and line.startswith("#")


def is_list_item(line: str) -> bool:
return bool(line) and (line.startswith("*") or line.startswith("-"))


def is_intro(line: str) -> bool:
return bool(line) and not is_title(line) and not is_list_item(line)


def level(title: str) -> int:
"""
Return the hierarchical level of the title, i.e. the number of "#"
chars at the beginning of the title.
"""
return len(title) - len(title.lstrip("#"))


class Markdown:
"""
Represents a Markdown file or a section within a Markdown file.
"""

def __init__(
self,
title: str,
intro: str = "",
items: str = "",
children: list[Markdown] | None = None,
):
self.title = title.rstrip("\n")
self.intro = intro
self.items = items
children = children or []
for child in children:
self._check(child)
self.children = children

def can_contain(self, child: Markdown) -> bool:
return level(self.title) < level(child.title)

def find(self, child_title: str) -> tuple[int, Markdown] | None:
"""
Return index and child having the specified title, or None if
there is none.
"""
for i, child in enumerate(self.children):
if child.title == child_title:
return i, child
return None

def child(self, title: str) -> Markdown | None:
"""
Retrieve the child with the specified title.
"""
return found[1] if (found := self.find(title)) else None

def _check(self, child: Markdown) -> Markdown:
if not self.can_contain(child):
raise IllegalChild(
f'Markdown section "{self.title}" cannot have "{child.title}" as child.'
)
return child

def add_child(self, child: Markdown, pos: int = 1) -> Markdown:
"""
Insert the specified section as child at the specified position.
"""

self.children.insert(pos, self._check(child))
return self

def replace_or_append_child(self, child: Markdown) -> Markdown:
"""
If there is a child with the same title then replace this child
otherwise append the specified child.
"""

self._check(child)
if found := self.find(child.title):
self.children[found[0]] = child
else:
self.children.append(child)
return self

@property
def rendered(self) -> str:
def elements():
yield from (self.title, self.intro, self.items)
yield from (c.rendered for c in self.children)

return "\n\n".join(e for e in elements() if e)

def __eq__(self, other) -> bool:
return (
isinstance(other, Markdown)
and other.title == self.title
and other.intro == self.intro
and other.items == self.items
and other.children == self.children
)

def __str__(self) -> str:
return self.rendered

@classmethod
def read(cls, file: Path) -> Markdown:
"""
Parse Markdown instance from the provided file.
"""

with file.open("r") as stream:
return cls.parse(stream)

@classmethod
def from_text(cls, text: str) -> Markdown:
"""
Parse Markdown instance from the provided text.
"""

return cls.parse(io.StringIO(text))

@classmethod
def parse(cls, stream: io.TextIOBase) -> Markdown:
"""
Parse Markdown instance from the provided stream.
"""

line = stream.readline()
if not is_title(line):
raise ParseError(
f'First line of markdown file must be a title, but is "{line}"'
)

section, line = cls._parse(stream, line)
if not line:
return section
raise ParseError(
f'Found additional line "{line}" after top-level section "{section.title}".'
)

@classmethod
def _parse(cls, stream: io.TextIOBase, title: str) -> tuple[Markdown, str]:
intro = ""
items = ""
children = []

line = stream.readline()
while is_intro(line):
intro += line
line = stream.readline()
if is_list_item(line):
while line and not is_title(line):
items += line
line = stream.readline()
while is_title(line) and level(title) < level(line):
child, line = Markdown._parse(stream, title=line)
children.append(child)
return Markdown(title, intro.strip("\n"), items.strip("\n"), children), line
Loading