feat: add tag parser

This commit is contained in:
Deathspike
2026-05-30 20:51:24 +02:00
commit d300cfed09
2 changed files with 283 additions and 0 deletions
+272
View File
@@ -0,0 +1,272 @@
from re import search, sub
from typing import Iterable, Iterator
from .utils.typing import Typing
class _Group:
"""
Group used by `_TagBuilder` and `_TagListParser` to calculate `Tag` piece weights.
"""
def __init__(self, defaultWeight: float, parent: "_Group | None"):
self.defaultWeight = defaultWeight
self.parent = parent
self.weight = defaultWeight
def calculate_weight(self) -> float:
if self.parent:
return self.parent.calculate_weight() * self.weight
else:
return self.weight
class _TagBuilder:
"""
Builder that accumulates pieces, associates groups, and creates `Tag` objects.
"""
def __init__(self, group: _Group):
self._groups = [group]
self._pieces = [""]
def append(self, char: str):
self._pieces[-1] += char
def enter(self, group: _Group):
self._groups.append(group)
self._pieces.append("")
def validate_and_build(self):
index = 1
# Clean leading whitespace.
while True:
if not self._pieces:
return None
elif not self._pieces[0].strip():
self._groups.pop(0)
self._pieces.pop(0)
else:
break
# Clean and collapse whitespace.
while index < len(self._pieces):
if not self._pieces[index]:
self._groups.pop(index)
self._pieces.pop(index)
elif not self._pieces[index].strip():
self._pieces[index - 1] = self._pieces[index - 1].rstrip() + " "
self._groups.pop(index)
self._pieces.pop(index)
elif self._pieces[index][0].isspace():
self._pieces[index - 1] = self._pieces[index - 1].rstrip() + " "
self._pieces[index] = self._pieces[index].lstrip()
index += 1
else:
index += 1
# Clean whitespace on each edge.
self._pieces[0] = self._pieces[0].lstrip()
self._pieces[-1] = self._pieces[-1].rstrip()
# Create the validated tag.
name = "".join(self._pieces)
pieces = zip(self._pieces, (group.calculate_weight() for group in self._groups))
return Tag(True, name, list(pieces))
class _TagListParser:
"""
Iterator that parses an input string to `Tag` objects.
"""
def __init__(self, value: str):
self._value = value
def __iter__(self):
self._group = _Group(1, None)
self._index = 0
self._tags = [_TagBuilder(self._group)]
return self._parse()
def _parse(self):
while self._index < len(self._value):
current = self._value[self._index]
previous = self._value[self._index - 1] if self._index else None
# Parse each input character.
if current == "(" and previous != "\\":
self._group = _Group(1.1, self._group)
self._tags[-1].enter(self._group)
elif current == ")" and previous != "\\":
self._group = self._group.parent or _Group(1, None)
self._tags[-1].enter(self._group)
elif current == ":" and self._parse_weight():
self._group = _Group(self._group.defaultWeight, self._group.parent)
self._tags[-1].enter(self._group)
elif current == "," or current == "\n":
self._group = self._group if self._group.parent else _Group(1, None)
self._tags.append(_TagBuilder(self._group))
else:
self._tags[-1].append(current)
# Increment the index.
self._index += 1
# Validate and build tags.
for tag_builder in self._tags:
tag = tag_builder.validate_and_build()
if tag:
yield tag
def _parse_weight(self):
start = self._index + 1
end = start
# Get the weight candidate.
while end < len(self._value):
current = self._value[end]
if current in ".-+" or current.isdigit() or current.isspace():
end += 1
else:
break
# Find a valid weight.
value = self._value[start:end]
match = search(r"-?\s*(?:\d*\s*\.\s*\d+|\d+\s*\.\s*\d*|\d+)", value)
# Parse the weight.
if match:
numeric_match = sub(r"\s", "", match.group(0))
self._group.weight = float(numeric_match)
self._index += len(value.rstrip())
return True
else:
return False
class _TagListRenderer:
"""
Iterator that renders an iterable of `Tag` objects into a weighted-tag string.
"""
def __init__(self, enable_filter: bool, tags: Iterable["Tag"]):
self._enable_filter = enable_filter
self._tags = tags
def __iter__(self):
return self._render()
def _get_filtered(self):
if self._enable_filter:
return (tag for tag in self._tags if tag.enabled)
else:
return self._tags
def _get_group_end(self, weight: float):
if weight != 1.1:
return f":{weight:.2g})"
else:
return ")"
def _render(self):
separator = None
weight = 1
for tag in self._get_filtered():
# Emit each piece.
for piece_text, piece_weight in tag.pieces:
if piece_weight != weight:
if weight != 1:
yield self._get_group_end(weight)
if separator:
yield separator
separator = None
if piece_weight != 1:
yield "("
weight = piece_weight
elif separator:
yield separator
separator = None
yield piece_text
# Divide the next tag.
if tag.pieces[-1][0].endswith("."):
separator = " "
else:
separator = ", "
# Emit the trailing group.
if weight != 1:
yield self._get_group_end(weight)
class Tag:
"""
Tag with a name, weighted pieces, and a semantic weight.
"""
def __init__(self, enabled: bool, name: str, pieces: list[tuple[str, float]]):
self.enabled = enabled
self.name = name
self.pieces = pieces
self.weight = max(piece[1] for piece in pieces)
def __eq__(self, value: object):
if isinstance(value, str):
return value == self.name
elif isinstance(value, Tag):
return value.name == self.name
else:
return False
def __str__(self):
return "".join(_TagListRenderer(False, (self,)))
def uses_weight(self):
return any(piece[1] != 1 for piece in self.pieces)
class TagList(list[Tag]):
"""
Ordered list of `Tag` objects parsed from an input.
"""
def __init__(self, tags: list[object] | tuple[object] | str):
super().__init__(self._parse(tags))
def __eq__(self, value: object):
if isinstance(value, str):
return self._is_same(TagList(value))
elif isinstance(value, TagList):
return self._is_same(value)
else:
return False
def __str__(self):
return "".join(_TagListRenderer(True, self))
def _is_same(self, tags: "TagList"):
if len(self) != len(tags):
return False
else:
return all(tag == other_tag for tag, other_tag in zip(self, tags))
def _parse(self, tags: list[object] | tuple[object] | str):
if not isinstance(tags, str):
return self._parse_list(tags)
else:
return _TagListParser(tags)
def _parse_list(self, tags: list[object] | tuple[object]) -> Iterator[Tag]:
for value in tags:
if Typing.is_list(value):
yield from self._parse_list(value)
elif isinstance(value, (bool, float, int)):
yield from _TagListParser(str(value))
elif isinstance(value, str):
yield from _TagListParser(value)
def uses_weight(self):
return any(tag.uses_weight() for tag in self)
+11
View File
@@ -0,0 +1,11 @@
from typing import TypeGuard
class Typing:
@staticmethod
def is_dict(value: object) -> TypeGuard[dict[object, object]]:
return isinstance(value, dict)
@staticmethod
def is_list(value: object) -> TypeGuard[list[object] | tuple[object]]:
return isinstance(value, (list, tuple))