diff --git a/chatmastermind/tags.py b/chatmastermind/tags.py new file mode 100644 index 0000000..6360636 --- /dev/null +++ b/chatmastermind/tags.py @@ -0,0 +1,128 @@ +""" +Module implementing tag related functions and classes. +""" +from typing import Type, TypeVar + +TagInst = TypeVar('TagInst', bound='Tag') +TagLineInst = TypeVar('TagLineInst', bound='TagLine') + + +class TagError(Exception): + pass + + +class Tag(str): + """ + A single tag. A string that can contain anything but the default separator (' '). + """ + # default separator + default_separator = ' ' + # alternative separators (e. g. for backwards compatibility) + alternative_separators = [','] + + def __new__(cls: Type[TagInst], string: str) -> TagInst: + """ + Make sure the tag string does not contain the default separator. + """ + if cls.default_separator in string: + raise TypeError(f"Tag '{string}' contains the separator char '{cls.default_separator}'") + instance = super().__new__(cls, string) + return instance + + +class TagLine(str): + """ + A line of tags. It starts with a prefix ('TAGS:'), followed by a list of tags, + separated by the defaut separator (' '). Any operations on a TagLine will sort + the tags. + """ + # the prefix + prefix = 'TAGS:' + + def __new__(cls: Type[TagLineInst], string: str) -> TagLineInst: + """ + Make sure the tagline string starts with the prefix. + """ + if not string.startswith(cls.prefix): + raise TagError(f"TagLine '{string}' is missing prefix '{cls.prefix}'") + instance = super().__new__(cls, string) + return instance + + @classmethod + def from_set(cls: Type[TagLineInst], tags: set[Tag]) -> TagLineInst: + """ + Create a new TagLine from a set of tags. + """ + return cls(' '.join([TagLine.prefix] + sorted([t for t in tags]))) + + def tags(self) -> set[Tag]: + """ + Returns all tags contained in this line as a set. + """ + tagstr = self[len(self.prefix):].strip() + separator = Tag.default_separator + # look for alternative separators and use the first one found + # -> we don't support different separators in the same TagLine + for s in Tag.alternative_separators: + if s in tagstr: + separator = s + break + return set(sorted([Tag(t.strip()) for t in tagstr.split(separator)])) + + def merge(self, taglines: set['TagLine']) -> 'TagLine': + """ + Merges the tags of all given taglines into the current one + and returns a new TagLine. + """ + merged_tags = self.tags() + for tl in taglines: + merged_tags |= tl.tags() + return self.from_set(set(sorted(merged_tags))) + + def delete_tags(self, tags: set[Tag]) -> 'TagLine': + """ + Deletes the given tags and returns a new TagLine. + """ + return self.from_set(self.tags().difference(tags)) + + def add_tags(self, tags: set[Tag]) -> 'TagLine': + """ + Adds the given tags and returns a new TagLine. + """ + return self.from_set(set(sorted(self.tags() | tags))) + + def rename_tags(self, tags: set[tuple[Tag, Tag]]) -> 'TagLine': + """ + Renames the given tags and returns a new TagLine. The first + tuple element is the old name, the second one is the new name. + """ + new_tags = self.tags() + for t in tags: + if t[0] in new_tags: + new_tags.remove(t[0]) + new_tags.add(t[1]) + return self.from_set(set(sorted(new_tags))) + + def match_tags(self, tags_or: set[Tag], tags_and: set[Tag], tags_not: set[Tag]) -> bool: + """ + Checks if the current TagLine matches the given tag requirements: + - 'tags_or' : matches if this TagLine contains ANY of those tags + - 'tags_and': matches if this TagLine contains ALL of those tags + - 'tags_not': matches if this TagLine contains NONE of those tags + + Note that it's sufficient if the TagLine matches one of 'tags_or' or 'tags_and', + i. e. you can select a TagLine if it either contains one of the tags in 'tags_or' + or all of the tags in 'tags_and' but it must never contain any of the tags in + 'tags_not'. If a given tag set is 'None', it matches all tags. + """ + tag_set = self.tags() + required_tags_present = False + excluded_tags_missing = False + if ((tags_or is None and tags_and is None) + or (tags_or and any(tag in tag_set for tag in tags_or)) # noqa: W503 + or (tags_and and all(tag in tag_set for tag in tags_and))): # noqa: W503 + required_tags_present = True + if ((tags_not is None) + or (not any(tag in tag_set for tag in tags_not))): # noqa: W503 + excluded_tags_missing = True + return required_tags_present and excluded_tags_missing