Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 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
11de13c
Apply suggestions from code review
ckunki Apr 16, 2026
3374c43
Added support for numbered items in the changelog
ckunki Apr 16, 2026
9a4dba7
Fixed review finding on bool(line)
ckunki Apr 17, 2026
9420bfd
Merge branch 'main' into refactoring/763-Parse_and_Manipulate_Changes…
ckunki Apr 17, 2026
c6afb8b
nox -s format:fix
ckunki Apr 17, 2026
ccac9f8
FIxed sonar finding
ckunki Apr 17, 2026
3dfac16
Merge branch 'main' into refactoring/763-Parse_and_Manipulate_Changes…
ckunki Apr 17, 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
1 change: 1 addition & 0 deletions doc/changes/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@

* #731: Reduced costly `test-python-environment.yml` to run when triggered on `main` or when the files related to the action are altered
* #785: Remove nox session `project:report` and metrics-schema, as superseded by Sonar usage
* #763: Parse and Manipulate Changes Files
196 changes: 196 additions & 0 deletions exasol/toolbox/util/release/markdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
"""
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, i.e. 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
import re
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 (line != "") and line.startswith("#")


def is_list_item(line: str) -> bool:
return bool(re.match(r"^([*-]|\d+\.)", line))


def is_intro(line: str) -> bool:
return (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(
Comment thread
ArBridgeman marked this conversation as resolved.
f'Found additional line "{line}" after top-level section "{section.title}".'
Comment thread
ArBridgeman marked this conversation as resolved.
)

@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 cls(title, intro.strip("\n"), items.strip("\n"), children), line
Loading