From 03c629545ea244f50fa139e84530767b515f6d9d Mon Sep 17 00:00:00 2001 From: juk0de Date: Wed, 16 Aug 2023 17:07:01 +0200 Subject: [PATCH] added new module 'tags.py' with classes 'Tag' and 'TagLine' --- chatmastermind/tags.py | 130 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 chatmastermind/tags.py diff --git a/chatmastermind/tags.py b/chatmastermind/tags.py new file mode 100644 index 0000000..1ba7af7 --- /dev/null +++ b/chatmastermind/tags.py @@ -0,0 +1,130 @@ +""" +Module implementing tag related functions and classes. +""" +from typing import Type, TypeVar, Optional + +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: Optional[set[Tag]], tags_and: Optional[set[Tag]], + tags_not: Optional[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 'tags_or' and 'tags_and' are 'None', they match all tags (tag + exclusion is still done if 'tags_not' is not 'None'). + """ + 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