From dd0581ef4eac513251d62f5a1d6fb1b17fc81f42 Mon Sep 17 00:00:00 2001 From: Robert Kaye Date: Thu, 25 Jan 2024 10:45:17 +0100 Subject: [PATCH 01/12] New parser start --- troi/parser.py | 192 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100755 troi/parser.py diff --git a/troi/parser.py b/troi/parser.py new file mode 100755 index 00000000..a132ed9b --- /dev/null +++ b/troi/parser.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 + +import re +import sys + + +TIME_RANGES = ["week", "month", "quarter", "half_yearly", "year", "all_time", "this_week", "this_month", "this_year"] +OPTIONS = ["easy", "hard", "medium", "and", "or", "nosim", "listened", "unlistened"] + TIME_RANGES +ELEMENTS = ["artist", "tag", "collection", "playlist", "stats", "recs"] + +# TODO: Handle "rec:rob" better than artist(rec:rob) +# Escape ( ) and , + +class ParseError(Exception): + pass + + +class PromptParser: + + def __init__(self): + self.element_check = re.compile(r"([a-zA-Z]+):") + + + def identify_block(self, block): + + for element in ELEMENTS: + if block.startswith(element + ":"): + return element + + if block.startswith("#"): + return "hashtag" + + # check for malformed element names + m = self.element_check.match(block) + if m is not None: + raise ParseError("Unrecognized element name '%s'. Must be one of: %s" % (m.group(0), ",".join(ELEMENTS))) + + return "artistname" + + def parse_special_cases(self, prompt): + + block_type = self.identify_block(prompt) + if block_type == "hashtag": + return "tag:(%s)" % prompt[1:] + if block_type == "artistname": + return "artist:(%s)" % prompt + + return prompt + + def parse_query_into_blocks(self, prompt): + + blocks = [] + block = "" + parens = 0 + escaped = False + for i in range(len(prompt)): + if not escaped and prompt[i] == '\\': + escaped = True + continue + + if escaped: + if prompt[i] in ('(', ')', '\\'): + print("escaped ok") + escaped = False + block += prompt[i] + continue + escaped = False + + if prompt[i] == '(': + parens += 1 + block += prompt[i] + continue + + if prompt[i] == ')': + parens -= 1 + block += prompt[i] + continue + + if prompt[i] == ' ' and parens == 0: + if block == "": + continue + + blocks.append(block) + block = "" + + if prompt[i] == ' ' and block == '': + continue + + block += prompt[i] + + if block: + blocks.append(block) + + return blocks + + def parse_block(self, block): + + opts = [] + weight = None + + name = self.identify_block(block) + print(name) + block = block[len(name) + 1:] + + if name in ("hashtag", "artistname"): + raise ParseError("Unknown element '%s' at offset 1" % name) + + value = None + text = "" + parens = 0 + for i in range(len(block)): + if block[i] == '(': + if i > 0: + raise ParseError("() must start at the beginning of the value field.") + parens += 1 + continue + + if block[i] == ')': + parens -= 1 + if parens < 0: + raise ParseError("closing ) without matching opening ( near: '%s'. offset %d" % (block[i:], i + len(name) + 1)) + continue + + if block[i] == ':' and parens == 0 and value is None: + value = text + print("value: '%s'" % value) + text = "" + continue + + if block[i] == ':' and value is not None and weight is None: + if not text: + weight = 1 + else: + # TODO: Add error checking + weight = int(text) + + opts = block[i+1:].split(",") + if opts and opts[-1] == "": + raise ParseError("Trailing comma in options.") + + return name, value, weight, opts + + text += block[i] + + if parens > 0: + raise ParseError("Missing closing ).") + + if parens < 0: + raise ParseError("Missing opening (.") + + print(text) + if value is None and text: + value = text + text = "" + + if weight is None: + weight = 1 + + if opts and opts[-1] == "": + raise ParseError("Trailing comma in options.") + + return name, value, weight, opts + + + def parse(self, prompt): + + """ + Portishead + #pop + + artist:(portishead) + tag:(trip hop) + + 1. Break into list of elements + + """ + + prompt = prompt.strip() + prompt = self.parse_special_cases(prompt) + blocks = self.parse_query_into_blocks(prompt) + for block in blocks: + block = self.parse_special_cases(block) + name, value, weight, opts = self.parse_block(block) + print("name: '%s'"% name) + print("value: '%s'"% value) + print("weight: '%s'"% weight) + print("opts: '%s'"% opts) + print() + + +pp = PromptParser() +pp.parse(sys.argv[1]) From f41b391e522ec23b1a90dc3770cb3c98003639cd Mon Sep 17 00:00:00 2001 From: Robert Kaye Date: Thu, 25 Jan 2024 18:06:34 +0100 Subject: [PATCH 02/12] Integrate new parser --- tests/test_parser.py | 129 +++++++++------- troi/parse_prompt.py | 318 +++++++++++++++++---------------------- troi/parser.py | 192 ----------------------- troi/patches/lb_radio.py | 5 +- 4 files changed, 208 insertions(+), 436 deletions(-) delete mode 100755 troi/parser.py diff --git a/tests/test_parser.py b/tests/test_parser.py index 005f178c..3c65a857 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,141 +1,154 @@ from uuid import UUID import unittest -from troi.parse_prompt import parse, ParseError +from troi.parse_prompt import PromptParser, ParseError class TestParser(unittest.TestCase): def test_basic_entities(self): - r = parse("a:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") + pp = PromptParser() + r = pp.parse("artist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") assert r[0] == {"entity": "artist", "values": [UUID("57baa3c6-ee43-4db3-9e6a-50bbc9792ee4")], "weight": 1, "opts": []} - r = parse("artist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") + r = pp.parse("artist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") assert r[0] == {"entity": "artist", "values": [UUID("57baa3c6-ee43-4db3-9e6a-50bbc9792ee4")], "weight": 1, "opts": []} - self.assertRaises(ParseError, parse, "wrong:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") + self.assertRaises(ParseError, pp.parse, "wrong:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") - r = parse("artist:the knife") + r = pp.parse("artist:(the knife)") assert r[0] == {"entity": "artist", "values": ["the knife"], "weight": 1, "opts": []} - self.assertRaises(ParseError, parse, "artist:u2:nosim") + self.assertRaises(ParseError, pp.parse, "artist:u2:nosim") + self.assertRaises(ParseError, pp.parse, "artists:u2:nosim") def test_tags(self): - r = parse("t:abstract t:rock t:blues") + pp = PromptParser() + r = pp.parse("tag:abstract tag:rock tag:blues") assert r[0] == {"entity": "tag", "values": ["abstract"], "weight": 1, "opts": []} assert r[1] == {"entity": "tag", "values": ["rock"], "weight": 1, "opts": []} assert r[2] == {"entity": "tag", "values": ["blues"], "weight": 1, "opts": []} - r = parse("t:(abstract,rock,blues)") + r = pp.parse("tag:(abstract,rock,blues)") assert r[0] == {"entity": "tag", "values": ["abstract", "rock", "blues"], "weight": 1, "opts": []} - r = parse("t:(abstract rock blues)") + r = pp.parse("tag:(abstract rock blues)") assert r[0] == {"entity": "tag", "values": ["abstract rock blues"], "weight": 1, "opts": []} - r = parse("tag:(abstract,rock,blues):1:or") + r = pp.parse("tag:(abstract,rock,blues):1:or") assert r[0] == {"entity": "tag", "values": ["abstract", "rock", "blues"], "weight": 1, "opts": ["or"]} - r = parse("t:(abstract,rock,blues)") + r = pp.parse("tag:(abstract,rock,blues)") assert r[0] == {"entity": "tag", "values": ["abstract", "rock", "blues"], "weight": 1, "opts": []} - r = parse('t:(trip hop, hip hop)') + r = pp.parse('tag:(trip hop, hip hop)') assert r[0] == {"entity": "tag", "values": ["trip hop", "hip hop"], "weight": 1, "opts": []} - r = parse("t:(r&b)") + r = pp.parse("tag:(r&b)") assert r[0] == {"entity": "tag", "values": ["r&b"], "weight": 1, "opts": []} - r = parse("t:r&b") - assert r[0] == {"entity": "tag", "values": ["r&b"], "weight": 1, "opts": []} - - r = parse("t:blümchen") - assert r[0] == {"entity": "tag", "values": ["blümchen"], "weight": 1, "opts": []} - - r = parse("t:(blümchen)") + r = pp.parse("tag:(blümchen)") assert r[0] == {"entity": "tag", "values": ["blümchen"], "weight": 1, "opts": []} - r = parse("t:(モーニング娘。)") + r = pp.parse("tag:(モーニング娘。)") assert r[0] == {"entity": "tag", "values": ["モーニング娘。"], "weight": 1, "opts": []} def test_tag_errors(self): - self.assertRaises(ParseError, parse, "t:(abstract rock blues):bork") - self.assertRaises(ParseError, parse, "tag:(foo") - self.assertRaises(ParseError, parse, "tag:foo)") - self.assertRaises(ParseError, parse, 'tag:foo"') - self.assertRaises(ParseError, parse, 'tag:"foo') + pp = PromptParser() + self.assertRaises(ParseError, pp.parse, "t:(abstract rock blues):bork") + self.assertRaises(ParseError, pp.parse, "tag:(foo") + self.assertRaises(ParseError, pp.parse, "tag:foo)") def test_shortcuts(self): - r = parse("#abstract #rock #blues") + pp = PromptParser() + r = pp.parse("#abstract") assert r[0] == {"entity": "tag", "values": ["abstract"], "weight": 1, "opts": []} - assert r[1] == {"entity": "tag", "values": ["rock"], "weight": 1, "opts": []} - assert r[2] == {"entity": "tag", "values": ["blues"], "weight": 1, "opts": []} + + pp = PromptParser() + r = pp.parse("u2") + assert r[0] == {"entity": "artist", "values": ["u2"], "weight": 1, "opts": []} def test_compound(self): - r = parse('artist:05319f96-e409-4199-b94f-3cabe7cc188a:2 #downtempo:1 tag:(trip hop, abstract):2') + pp = PromptParser() + r = pp.parse('artist:05319f96-e409-4199-b94f-3cabe7cc188a:2 tag:(downtempo):1 tag:(trip hop, abstract):2') assert r[0] == {"entity": "artist", "values": [UUID("05319f96-e409-4199-b94f-3cabe7cc188a")], "weight": 2, "opts": []} assert r[1] == {"entity": "tag", "values": ["downtempo"], "weight": 1, "opts": []} assert r[2] == {"entity": "tag", "values": ["trip hop", "abstract"], "weight": 2, "opts": []} def test_weights(self): - r = parse("a:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4:1 a:f54ba4c6-12dd-4358-9136-c64ad89420c5:2") + pp = PromptParser() + r = pp.parse("artist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4:1 artist:f54ba4c6-12dd-4358-9136-c64ad89420c5:2") assert r[0] == {"entity": "artist", "values": [UUID("57baa3c6-ee43-4db3-9e6a-50bbc9792ee4")], "weight": 1, "opts": []} assert r[1] == {"entity": "artist", "values": [UUID("f54ba4c6-12dd-4358-9136-c64ad89420c5")], "weight": 2, "opts": []} - self.assertRaises(ParseError, parse, - "a:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4:1 a:f54ba4c6-12dd-4358-9136-c64ad89420c5:fussy") - self.assertRaises(ParseError, parse, "a:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4:1 a:f54ba4c6-12dd-4358-9136-c64ad89420c5:.5") + self.assertRaises(ParseError, pp.parse, + "artist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4:1 artist:f54ba4c6-12dd-4358-9136-c64ad89420c5:fussy") + self.assertRaises(ParseError, pp.parse, "artist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4:1 artist:f54ba4c6-12dd-4358-9136-c64ad89420c5:.5") - r = parse("a:portishead::easy") + r = pp.parse("artist:portishead::easy") assert r[0] == {"entity": "artist", "values": ["portishead"], "weight": 1, "opts": ["easy"]} - r = parse("a:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4::easy") + r = pp.parse("artist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4::easy") assert r[0] == {"entity": "artist", "values": [UUID("57baa3c6-ee43-4db3-9e6a-50bbc9792ee4")], "weight": 1, "opts": ["easy"]} - def test_collection_playlist(self): + def test_opts(self): + pp = PromptParser() + r = pp.parse("stats:(mr_monkey)::month") + print(r[0]) + assert r[0] == {"entity": "stats", "values": ["mr_monkey"], "weight": 1, "opts": ["month"]} + r = pp.parse("stats:(mr_monkey)::month,easy") + assert r[0] == {"entity": "stats", "values": ["mr_monkey"], "weight": 1, "opts": ["month", "easy"]} + + self.assertRaises(ParseError, pp.parse, 'artist:(meh)::nosim,') - r = parse("collection:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") + def test_parens(self): + pp = PromptParser() + self.assertRaises(ParseError, pp.parse, 'artist:adfadf(meh)') + self.assertRaises(ParseError, pp.parse, 'artist:adfadf(meh') + self.assertRaises(ParseError, pp.parse, 'artist:adfadf)meh') + + def test_collection_playlist(self): + pp = PromptParser() + r = pp.parse("collection:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") assert r[0] == {"entity": "collection", "values": [UUID("57baa3c6-ee43-4db3-9e6a-50bbc9792ee4")], "weight": 1, "opts": []} - r = parse("playlist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") + r = pp.parse("playlist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") assert r[0] == {"entity": "playlist", "values": [UUID("57baa3c6-ee43-4db3-9e6a-50bbc9792ee4")], "weight": 1, "opts": []} - r = parse("p:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") + r = pp.parse("playlist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") assert r[0] == {"entity": "playlist", "values": [UUID("57baa3c6-ee43-4db3-9e6a-50bbc9792ee4")], "weight": 1, "opts": []} def test_stats(self): - - r = parse("stats:") - assert r[0] == {"entity": "stats", "values": [], "weight": 1, "opts": []} - - r = parse("stats:mr_monkey:1:year") + pp = PromptParser() + r = pp.parse("stats:mr_monkey:1:year") assert r[0] == {"entity": "stats", "values": ["mr_monkey"], "weight": 1, "opts": ["year"]} - r = parse("s:rob:1:week") + r = pp.parse("stats:rob:1:week") assert r[0] == {"entity": "stats", "values": ["rob"], "weight": 1, "opts": ["week"]} - r = parse("stats:(mr_monkey)::month") - assert r[0] == {"entity": "stats", "values": ["mr_monkey"], "weight": 1, "opts": ["month"]} + r = pp.parse("stats:(mr_monkey)::month,nosim") + assert r[0] == {"entity": "stats", "values": ["mr_monkey"], "weight": 1, "opts": ["month", "nosim"]} - r = parse("stats:(mr_monkey):2:month") + r = pp.parse("stats:(mr_monkey):2:month") assert r[0] == {"entity": "stats", "values": ["mr_monkey"], "weight": 2, "opts": ["month"]} - r = parse("stats:(rob zombie)") + r = pp.parse("stats:(rob zombie)") assert r[0] == {"entity": "stats", "values": ["rob zombie"], "weight": 1, "opts": []} def test_recs(self): + pp = PromptParser() + self.assertRaises(ParseError, pp.parse, 'recs:') - r = parse("recs:") - assert r[0] == {"entity": "recs", "values": [], "weight": 1, "opts": []} - - r = parse("recs:mr_monkey::listened") + r = pp.parse("recs:mr_monkey::listened") assert r[0] == {"entity": "recs", "values": ["mr_monkey"], "weight": 1, "opts": ["listened"]} - r = parse("r:rob:1:unlistened") + r = pp.parse("recs:rob:1:unlistened") assert r[0] == {"entity": "recs", "values": ["rob"], "weight": 1, "opts": ["unlistened"]} - r = parse("recs:(mr_monkey):1:listened") + r = pp.parse("recs:(mr_monkey):1:listened") assert r[0] == {"entity": "recs", "values": ["mr_monkey"], "weight": 1, "opts": ["listened"]} - r = parse("recs:(mr_monkey):2:unlistened") + r = pp.parse("recs:(mr_monkey):2:unlistened") assert r[0] == {"entity": "recs", "values": ["mr_monkey"], "weight": 2, "opts": ["unlistened"]} - r = parse("recs:(rob zombie)") + r = pp.parse("recs:(rob zombie)") assert r[0] == {"entity": "recs", "values": ["rob zombie"], "weight": 1, "opts": []} diff --git a/troi/parse_prompt.py b/troi/parse_prompt.py index 89dbb969..179a076b 100755 --- a/troi/parse_prompt.py +++ b/troi/parse_prompt.py @@ -1,196 +1,146 @@ -import sys from uuid import UUID - -import pyparsing as pp -import pyparsing.exceptions +import re TIME_RANGES = ["week", "month", "quarter", "half_yearly", "year", "all_time", "this_week", "this_month", "this_year"] - OPTIONS = ["easy", "hard", "medium", "and", "or", "nosim", "listened", "unlistened"] + TIME_RANGES +ELEMENTS = ["artist", "tag", "collection", "playlist", "stats", "recs"] class ParseError(Exception): pass +#TODO: Implement UUID parsing -def build_parser(): - """ Build a parser using pyparsing, which is bloody brilliant! """ - - # Define the entities and their keywords - artist_element = pp.MatchFirst((pp.Keyword("artist"), pp.Keyword("a"))) - tag_element = pp.MatchFirst((pp.Keyword("tag"), pp.Keyword("t"))) - collection_element = pp.MatchFirst((pp.Keyword("collection"))) - playlist_element = pp.MatchFirst((pp.Keyword("playlist"), pp.Keyword("p"))) - stats_element = pp.MatchFirst((pp.Keyword("stats"), pp.Keyword("s"))) - recs_element = pp.MatchFirst((pp.Keyword("recs"), pp.Keyword("r"))) - - # Define the various text fragments/identifiers that we plan to use - text = pp.Word(pp.identbodychars + " ") - uuid = pp.pyparsing_common.uuid() - paren_text = pp.QuotedString("(", end_quote_char=")") - tag_chars = pp.identbodychars + "&!-@$%^*=+;'" - ws_tag = pp.OneOrMore(pp.Word(tag_chars + " ")) - tag = pp.Word(tag_chars) - - # Define supporting fragments that will be used multiple times - paren_tag = pp.Suppress(pp.Literal("(")) \ - + pp.delimitedList(pp.Group(ws_tag, aslist=True), delim=",") \ - + pp.Suppress(pp.Literal(")")) - weight = pp.Suppress(pp.Literal(':')) \ - + pp.Opt(pp.pyparsing_common.integer(), 1) - opt_keywords = pp.MatchFirst([pp.Keyword(k) for k in OPTIONS]) - options = pp.Suppress(pp.Literal(':')) \ - + opt_keywords - paren_options = pp.Suppress(pp.Literal(':')) \ - + pp.Suppress(pp.Literal("(")) \ - + pp.delimitedList(pp.Group(opt_keywords, aslist=True), delim=",") \ - + pp.Suppress(pp.Literal(")")) - optional = pp.Opt(weight + pp.Opt(pp.Group(options | paren_options), ""), 1) - - # Define artist element - element_uuid = artist_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Group(uuid, aslist=True) \ - + optional - element_text = artist_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Group(text, aslist=True) \ - + optional - element_paren_text = artist_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Group(paren_text, aslist=True) \ - + optional - - # Define tag element - element_tag = tag_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Group(tag, aslist=True) \ - + optional - element_paren_tag = tag_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Group(paren_text, aslist=True) \ - + optional - element_tag_shortcut = pp.Literal('#') \ - + pp.Group(tag, aslist=True) \ - + optional - element_tag_paren_shortcut = pp.Literal('#') \ - + pp.Group(paren_tag, aslist=True) \ - + optional - - # Collection, playlist and stats, rec elements - element_collection = collection_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Group(uuid, aslist=True) \ - + optional - element_playlist = playlist_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Group(uuid, aslist=True) \ - + optional - element_stats = stats_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Opt(pp.Group(text, aslist=True), "") \ - + optional - element_paren_stats = stats_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Group(paren_text, aslist=True) \ - + optional - element_recs = recs_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Opt(pp.Group(text, aslist=True), "") \ - + optional - element_paren_recs = recs_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Group(paren_text, aslist=True) \ - + optional - - # Finally combine all elements into one, starting with the shortest/simplest elements and getting more - # complex - elements = element_tag | element_tag_shortcut | element_uuid | element_paren_recs | element_collection | element_playlist | \ - element_text | element_paren_stats | element_paren_recs | element_recs | element_stats | \ - element_paren_text | element_paren_tag | element_tag_paren_shortcut - - # All of the above was to parse one single term, now allow the stats to define more than one if they want - return pp.OneOrMore(pp.Group(elements, aslist=True)) - - -def common_error_check(prompt: str): - """ Pyparsing is amazing, but the error messages leave a lot to be desired. This function attempts - to scan for common problems and give better error messages.""" - - parts = prompt.split(":") - try: - if parts[2] in OPTIONS: - sugg = f"{parts[0]}:{parts[1]}::{parts[2]}" - raise ParseError("Syntax error: options specified in the weight field, since a : is missing. Did you mean '%s'?" % sugg) - except IndexError: - pass - - -def parse(prompt: str): - """ Parse the given prompt. Return an array of dicts that contain the following keys: - entity: str e.g. "artist" - value: list e.g. "57baa3c6-ee43-4db3-9e6a-50bbc9792ee4" - weight: int e.g. 1 (positive integer) - options: list e.g ["and", "easy"] - - raises ParseError if, well, a parse error is encountered. - """ - - common_error_check(prompt) - - parser = build_parser() - try: - elements = parser.parseString(prompt.lower(), parseAll=True) - except pp.exceptions.ParseException as err: - raise ParseError(err) - - results = [] - for element in elements: - if element[0] == "a": - entity = "artist" - elif element[0] == "s": - entity = "stats" - elif element[0] == "r": - entity = "recs" - elif element[0] == "t": - entity = "tag" - elif element[0] == "p": - entity = "playlist" - elif element[0] == "#": - entity = "tag" - else: - entity = element[0] - - try: - if entity == "tag" and element[1][0].find(",") > 0: - element[1] = [s.strip() for s in element[1][0].split(",")] - except IndexError: - pass - - try: - values = [UUID(element[1][0])] - except (ValueError, AttributeError, IndexError): - values = [] - for value in element[1]: - if isinstance(value, list): - values.append(value[0]) - else: - values.append(value) - try: - weight = element[2] - except IndexError: - weight = 1 - - opts = [] - try: - for opt in element[3]: - if isinstance(opt, list): - opts.append(opt[0]) - else: - opts.append(opt) - except IndexError: - pass +class PromptParser: + + def __init__(self): + self.clean_spaces = re.compile(r"\s+") + self.element_check = re.compile(r"([a-zA-Z]+):") + + def identify_block(self, block): + + for element in ELEMENTS: + if block.startswith(element + ":"): + return element - results.append({"entity": entity, "values": values, "weight": weight, "opts": opts}) + if block.startswith("#"): + return "hashtag" - return results + # check for malformed element names + m = self.element_check.match(block) + if m is not None: + raise ParseError("Unrecognized element name '%s'. Must be one of: %s" % (m.group(0), ",".join(ELEMENTS))) + + return "artistname" + + def parse_special_cases(self, prompt): + + block_type = self.identify_block(prompt) + if block_type == "hashtag": + return "tag:(%s)" % prompt[1:] + if block_type == "artistname": + return "artist:(%s)" % prompt + + return prompt + + def set_block_values(self, name, values, weight, opts, text, block): + + if values is None: + try: + values = [UUID(text)] + except ValueError: + if name == "tag": + values = text.split(",") + values = [ v.strip() for v in values ] + else: + values = [text] + elif weight is None: + if not text: + weight = 1 + else: + try: + weight = int(text) + except ValueError: + raise ParseError("Weight must be a positive integer, not '%s'" % text) + elif not opts: + opts = text.split(",") + if opts and opts[-1] == "": + raise ParseError("Trailing comma in options.") + + return values, weight, opts + + def parse(self, prompt): + prompt = self.clean_spaces.sub(" ", prompt).strip() + block = self.parse_special_cases(prompt) + blocks = [] + while True: + name = "" + for element in ELEMENTS: + if block.startswith(element + ":"): + name = element + break + if not name: + raise ParseError("Unknown element '%s'" % block) + + block = block[len(name):] + if block[0] == ':': + block = block[1:] + + opts = [] + weight = None + values = None + text = "" + parens = 0 + escaped = False + for i in range(len(block)): + if not escaped and block[i] == '\\': + escaped = True + continue + + if escaped: + if block[i] in ('(', ')', '\\'): + escaped = False + text += block[i] + continue + escaped = False + + if block[i] == '(': + if i > 0: + raise ParseError("() must start at the beginning of the value field.") + parens += 1 + continue + + if block[i] == ')': + parens -= 1 + if parens < 0: + raise ParseError("closing ) without matching opening ( near: '%s'." % block[i:]) + continue + + if block[i] == ':' and parens == 0: + values, weight, opts = self.set_block_values(name, values, weight, opts, text, block) + text = "" + continue + + if block[i] == ' ' and parens == 0: + break + + text += block[i] + + values, weight, opts = self.set_block_values(name, values, weight, opts, text, block) + try: + block = block[i + 1:] + except UnboundLocalError: + raise ParseError("incomplete prompt") + + if parens > 0: + raise ParseError("Missing closing ).") + + if parens < 0: + raise ParseError("Missing opening (.") + + blocks.append({"entity": name, "values": values, "weight": weight or 1, "opts": opts}) + + if len(block) == 0: + break + + return blocks diff --git a/troi/parser.py b/troi/parser.py deleted file mode 100755 index a132ed9b..00000000 --- a/troi/parser.py +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env python3 - -import re -import sys - - -TIME_RANGES = ["week", "month", "quarter", "half_yearly", "year", "all_time", "this_week", "this_month", "this_year"] -OPTIONS = ["easy", "hard", "medium", "and", "or", "nosim", "listened", "unlistened"] + TIME_RANGES -ELEMENTS = ["artist", "tag", "collection", "playlist", "stats", "recs"] - -# TODO: Handle "rec:rob" better than artist(rec:rob) -# Escape ( ) and , - -class ParseError(Exception): - pass - - -class PromptParser: - - def __init__(self): - self.element_check = re.compile(r"([a-zA-Z]+):") - - - def identify_block(self, block): - - for element in ELEMENTS: - if block.startswith(element + ":"): - return element - - if block.startswith("#"): - return "hashtag" - - # check for malformed element names - m = self.element_check.match(block) - if m is not None: - raise ParseError("Unrecognized element name '%s'. Must be one of: %s" % (m.group(0), ",".join(ELEMENTS))) - - return "artistname" - - def parse_special_cases(self, prompt): - - block_type = self.identify_block(prompt) - if block_type == "hashtag": - return "tag:(%s)" % prompt[1:] - if block_type == "artistname": - return "artist:(%s)" % prompt - - return prompt - - def parse_query_into_blocks(self, prompt): - - blocks = [] - block = "" - parens = 0 - escaped = False - for i in range(len(prompt)): - if not escaped and prompt[i] == '\\': - escaped = True - continue - - if escaped: - if prompt[i] in ('(', ')', '\\'): - print("escaped ok") - escaped = False - block += prompt[i] - continue - escaped = False - - if prompt[i] == '(': - parens += 1 - block += prompt[i] - continue - - if prompt[i] == ')': - parens -= 1 - block += prompt[i] - continue - - if prompt[i] == ' ' and parens == 0: - if block == "": - continue - - blocks.append(block) - block = "" - - if prompt[i] == ' ' and block == '': - continue - - block += prompt[i] - - if block: - blocks.append(block) - - return blocks - - def parse_block(self, block): - - opts = [] - weight = None - - name = self.identify_block(block) - print(name) - block = block[len(name) + 1:] - - if name in ("hashtag", "artistname"): - raise ParseError("Unknown element '%s' at offset 1" % name) - - value = None - text = "" - parens = 0 - for i in range(len(block)): - if block[i] == '(': - if i > 0: - raise ParseError("() must start at the beginning of the value field.") - parens += 1 - continue - - if block[i] == ')': - parens -= 1 - if parens < 0: - raise ParseError("closing ) without matching opening ( near: '%s'. offset %d" % (block[i:], i + len(name) + 1)) - continue - - if block[i] == ':' and parens == 0 and value is None: - value = text - print("value: '%s'" % value) - text = "" - continue - - if block[i] == ':' and value is not None and weight is None: - if not text: - weight = 1 - else: - # TODO: Add error checking - weight = int(text) - - opts = block[i+1:].split(",") - if opts and opts[-1] == "": - raise ParseError("Trailing comma in options.") - - return name, value, weight, opts - - text += block[i] - - if parens > 0: - raise ParseError("Missing closing ).") - - if parens < 0: - raise ParseError("Missing opening (.") - - print(text) - if value is None and text: - value = text - text = "" - - if weight is None: - weight = 1 - - if opts and opts[-1] == "": - raise ParseError("Trailing comma in options.") - - return name, value, weight, opts - - - def parse(self, prompt): - - """ - Portishead - #pop - - artist:(portishead) - tag:(trip hop) - - 1. Break into list of elements - - """ - - prompt = prompt.strip() - prompt = self.parse_special_cases(prompt) - blocks = self.parse_query_into_blocks(prompt) - for block in blocks: - block = self.parse_special_cases(block) - name, value, weight, opts = self.parse_block(block) - print("name: '%s'"% name) - print("value: '%s'"% value) - print("weight: '%s'"% weight) - print("opts: '%s'"% opts) - print() - - -pp = PromptParser() -pp.parse(sys.argv[1]) diff --git a/troi/patches/lb_radio.py b/troi/patches/lb_radio.py index 68aa26cf..2218468d 100755 --- a/troi/patches/lb_radio.py +++ b/troi/patches/lb_radio.py @@ -10,7 +10,7 @@ import troi.listenbrainz.recs import troi.musicbrainz.recording_lookup from troi.playlist import PlaylistMakerElement -from troi.parse_prompt import parse, ParseError, TIME_RANGES +from troi.parse_prompt import PromptParser, ParseError, TIME_RANGES from troi.patches.lb_radio_classes.artist import LBRadioArtistRecordingElement from troi.patches.lb_radio_classes.blend import InterleaveRecordingsElement, WeighAndBlendRecordingsElement from troi.patches.lb_radio_classes.collection import LBRadioCollectionRecordingElement @@ -101,8 +101,9 @@ def create(self, inputs): self.mode = inputs["mode"] # First parse the prompt + pp = PromptParser() try: - prompt_elements = parse(self.prompt) + prompt_elements = pp.parse(self.prompt) except ParseError as err: raise RuntimeError(f"cannot parse prompt: '{err}'") From 331ce8bb00b2f912ebe44aef36e37d63031a0cb8 Mon Sep 17 00:00:00 2001 From: Robert Kaye Date: Thu, 25 Jan 2024 18:12:54 +0100 Subject: [PATCH 03/12] new parser is almost ready --- pyproject.toml | 3 +-- troi/parse_prompt.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bb30576a..55c1b77a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,7 @@ dependencies = [ 'liblistenbrainz>=v0.5.5', 'python-dateutil>=2.8.2', 'spotipy>=2.22.1', - 'more_itertools', - 'pyparsing' + 'more_itertools' ] [project.scripts] diff --git a/troi/parse_prompt.py b/troi/parse_prompt.py index 179a076b..f6d60c1b 100755 --- a/troi/parse_prompt.py +++ b/troi/parse_prompt.py @@ -9,7 +9,7 @@ class ParseError(Exception): pass -#TODO: Implement UUID parsing +#TODO: implement opts checking wrt to the given element class PromptParser: From 9c4bbe01165b95e2803639dda8fcfc4c41f6a2e1 Mon Sep 17 00:00:00 2001 From: Robert Kaye Date: Mon, 29 Jan 2024 17:02:38 +0100 Subject: [PATCH 04/12] Add opt checking to new parser and update docs --- docs/lb_radio.rst | 24 +++++++++++------------- troi/parse_prompt.py | 24 +++++++++++++++++++++--- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/docs/lb_radio.rst b/docs/lb_radio.rst index 92503a8c..e64c298c 100644 --- a/docs/lb_radio.rst +++ b/docs/lb_radio.rst @@ -90,8 +90,8 @@ because it should accept the full name of an artist. For latin character sets, t :: - artist:Blümchen - artist:The Knife + artist:(Blümchen) + artist:(The Knife) But, if you need other unicode characters, the name must be enclosed by (): @@ -105,10 +105,10 @@ Tags have similar restrictions. If a tag you'd like to specify has no spaces or :: - tag:punk + tag:(punk) #punk -But with spaces or non-latin unicode characters, wrap it in (): +But with spaces or non-latin unicode characters, wrap it in () and use the full tag element name: :: @@ -124,14 +124,14 @@ Simple examples :: - artist:Rick Astley + artist:(Rick Astley) Create a single stream, from artist Rick Astley and similar artists. Artist names must be spelled here exactly as they are spelled in MusicBrainz. If for some reason the artist name is not recognized, specify an MBID instead. See below. :: - tag:rock:3 tag:pop:2 + tag:(rock):3 tag:(pop):2 Create two streams, one from tag "rock" contributing 3 parts of the recordings and one from tag "pop" contibuting 2 parts of the recordings. @@ -147,15 +147,13 @@ Specify an exact artist, using an artist MBID. #rock #pop -The # shorthand notation allows user to quickly specify a tag radio. This prompt generates two equal streams from the tags "rock" and "pop". +The # shorthand notation allows user to quickly specify a tag radio, but it onlt works for one tag and the tag cannot contain spaces: :: - #(rock,pop) - tag:(rock,pop) + #rock -These two prompts are equal, the # notation is simply a shortcut for tag. This prompt generates a playlist with recordings that have been tagged -with both the "rock" AND "pop" tags. +This prompt generates a playlist with recordings that have been tagged with both the "rock" AND "pop" tags. :: @@ -205,13 +203,13 @@ More complex examples :: - artist:(pretty lights):3:easy tag:(trip hop):2 artist:morcheeba::nosim + artist:(pretty lights):3:easy tag:(trip hop):2 artist:(morcheeba)::nosim This prompt will play 3 parts from artist "Pretty Lights", 2 parts from the tag "trip hop" and 1 part from the artist "Morcheeba" with no tracks from similar artists. :: - tag:(deep house):2:medium tag:(metal):1:hard artist:blümchen:2:easy + tag:(deep house):2:medium tag:(metal):1:hard artist:(blümchen):2:easy This will play 2 parts from tag "deep house" on medium mode, 1 part from tag "metal" on hard mode and 2 parts from artists "Blümchen" on easy mode. diff --git a/troi/parse_prompt.py b/troi/parse_prompt.py index f6d60c1b..4b352d0f 100755 --- a/troi/parse_prompt.py +++ b/troi/parse_prompt.py @@ -2,14 +2,27 @@ import re TIME_RANGES = ["week", "month", "quarter", "half_yearly", "year", "all_time", "this_week", "this_month", "this_year"] -OPTIONS = ["easy", "hard", "medium", "and", "or", "nosim", "listened", "unlistened"] + TIME_RANGES ELEMENTS = ["artist", "tag", "collection", "playlist", "stats", "recs"] +ELEMENT_OPTIONS = { + "artist": ["nosim", "easy", "medium", "hard"], + "tag": ["nosim", "and", "or", "easy", "medium", "hard"], + "collection": ["easy", "medium", "hard"], + "playlist": ["easy", "medium", "hard"], + "stats": TIME_RANGES, + "recs": ["easy", "medium", "hard", "listened", "unlistened"] +} + +OPTIONS = set() +for eo in ELEMENT_OPTIONS: + OPTIONS.update(ELEMENT_OPTIONS[eo]) + +OPTIONS = list(OPTIONS) + class ParseError(Exception): pass -#TODO: implement opts checking wrt to the given element class PromptParser: @@ -51,7 +64,7 @@ def set_block_values(self, name, values, weight, opts, text, block): except ValueError: if name == "tag": values = text.split(",") - values = [ v.strip() for v in values ] + values = [v.strip() for v in values] else: values = [text] elif weight is None: @@ -138,6 +151,11 @@ def parse(self, prompt): if parens < 0: raise ParseError("Missing opening (.") + for opt in opts: + print(opts) + if opt not in ELEMENT_OPTIONS[name]: + raise ParseError("Option '%s' is not allowed for element %s" % (opt, name)) + blocks.append({"entity": name, "values": values, "weight": weight or 1, "opts": opts}) if len(block) == 0: From e97930bd4e8f63709e57bb27a2eeb5a4e1f3a70b Mon Sep 17 00:00:00 2001 From: Robert Kaye Date: Mon, 29 Jan 2024 17:28:07 +0100 Subject: [PATCH 05/12] Update docs, cleanup --- docs/lb_radio.rst | 32 ++++++++++++++++---------------- troi/parse_prompt.py | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/lb_radio.rst b/docs/lb_radio.rst index e64c298c..b35fcab9 100644 --- a/docs/lb_radio.rst +++ b/docs/lb_radio.rst @@ -124,49 +124,49 @@ Simple examples :: - artist:(Rick Astley) + Rick Astley Create a single stream, from artist Rick Astley and similar artists. Artist names must be spelled here exactly as they are spelled in MusicBrainz. If for some reason the artist name is not recognized, specify an MBID instead. See below. + :: - tag:(rock):3 tag:(pop):2 + #punk -Create two streams, one from tag "rock" contributing 3 parts of the recordings and one from tag "pop" contibuting 2 parts of the recordings. +The # shorthand notation allows user to quickly specify a tag radio, but it only works for one tag and the tag cannot contain spaces. For +more advanced prompts, use the full notation described above. :: - artist:8f6bd1e4-fbe1-4f50-aa9b-94c450ec0f11 - + tag:(rock,pop)::or -Specify an exact artist, using an artist MBID. +This prompt generates a playlist with recordings that have been tagged with either the "rock" OR "pop" tags. The weight can be omitted and will +be assumed to be 1. :: - #rock #pop - + tag:(rock) tag:(pop) -The # shorthand notation allows user to quickly specify a tag radio, but it onlt works for one tag and the tag cannot contain spaces: +Create two streams, one from tag "rock" contributing 3 parts of the recordings and one from tag "pop" contibuting 2 parts of the recordings. :: - #rock + tag:(trip hop) -This prompt generates a playlist with recordings that have been tagged with both the "rock" AND "pop" tags. +Tags that have a space in them must be enclosed in (). Specifying multiple tags requires the tags to be enclosed in () as well as comma separated. :: - tag:(rock,pop)::or + tag:(trip hop, downtempo) -This prompt generates a playlist with recordings that have been tagged with either the "rock" OR "pop" tags. The weight can be omitted and will -be assumed to be 1. +If LB-radio does not find your artist, you can specify an artist using an Artist MBID: :: - tag:(trip hop) + artist:8f6bd1e4-fbe1-4f50-aa9b-94c450ec0f11 -Tags that have a space in them must be enclosed in (). Specifying multiple tags requires the tags to be enclosed in () as well as comma separated. +LB-radio also supports MusicBrainz collections as sources: :: diff --git a/troi/parse_prompt.py b/troi/parse_prompt.py index 4b352d0f..aa3066f6 100755 --- a/troi/parse_prompt.py +++ b/troi/parse_prompt.py @@ -139,6 +139,7 @@ def parse(self, prompt): text += block[i] + # Now that we've parsed a block, do some sanity checking values, weight, opts = self.set_block_values(name, values, weight, opts, text, block) try: block = block[i + 1:] @@ -152,7 +153,6 @@ def parse(self, prompt): raise ParseError("Missing opening (.") for opt in opts: - print(opts) if opt not in ELEMENT_OPTIONS[name]: raise ParseError("Option '%s' is not allowed for element %s" % (opt, name)) From c04b0c811b8034d8876199032c297ebe26469233 Mon Sep 17 00:00:00 2001 From: Robert Kaye Date: Mon, 29 Jan 2024 17:43:27 +0100 Subject: [PATCH 06/12] Add docstrings --- troi/parse_prompt.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/troi/parse_prompt.py b/troi/parse_prompt.py index aa3066f6..a7c54a70 100755 --- a/troi/parse_prompt.py +++ b/troi/parse_prompt.py @@ -25,12 +25,16 @@ class ParseError(Exception): class PromptParser: + """ + Parse the LB radio prompt and return a list of elements and all of their data/options + """ def __init__(self): self.clean_spaces = re.compile(r"\s+") self.element_check = re.compile(r"([a-zA-Z]+):") def identify_block(self, block): + """Given a prompt string, identify the block that is at the beginning of the string""" for element in ELEMENTS: if block.startswith(element + ":"): @@ -47,6 +51,7 @@ def identify_block(self, block): return "artistname" def parse_special_cases(self, prompt): + """Detect the artist and tag special cases and re-write the query in long hand form.""" block_type = self.identify_block(prompt) if block_type == "hashtag": @@ -57,6 +62,7 @@ def parse_special_cases(self, prompt): return prompt def set_block_values(self, name, values, weight, opts, text, block): + """Parse, process and sanity check data for an element""" if values is None: try: @@ -83,6 +89,8 @@ def set_block_values(self, name, values, weight, opts, text, block): return values, weight, opts def parse(self, prompt): + """Parse an actual LB-radio prompt and return a list of [name, [values], weight, [opts]]""" + prompt = self.clean_spaces.sub(" ", prompt).strip() block = self.parse_special_cases(prompt) blocks = [] From 85fbc8580fa587e2589f9847d8f178b3a4a55d2b Mon Sep 17 00:00:00 2001 From: Robert Kaye Date: Tue, 30 Jan 2024 17:05:35 +0100 Subject: [PATCH 07/12] Update docs --- docs/lb_radio.rst | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/docs/lb_radio.rst b/docs/lb_radio.rst index b35fcab9..f828d13a 100644 --- a/docs/lb_radio.rst +++ b/docs/lb_radio.rst @@ -85,21 +85,16 @@ is likely going to create a playlist with familiar music, and a hard playlist ma Syntax Notes ------------ -The syntax attempts to be intuitive and simple, but it does have some limitations. The artist: entity has the most tricky restrictions -because it should accept the full name of an artist. For latin character sets, the short form for an artist name can be used: +Artist and tag names are the tricky bits to specify in a prompt, so they must be enclosed with (): :: artist:(Blümchen) - artist:(The Knife) - -But, if you need other unicode characters, the name must be enclosed by (): - -:: - + tag:(deep house) artist:(Мумий Тролль) -Furthermore, artist names must be spelled exactly as their appear in MusicBrainz. +Furthermore, artist names must be spelled exactly as their appear in MusicBrainz. If you have difficulty specifying the +correct artist, you can use an artist MBID to be very precise. Tags have similar restrictions. If a tag you'd like to specify has no spaces or non-latin unicode characters you may use: From 3389992a6179feeba07aae8e8b89f22995166847 Mon Sep 17 00:00:00 2001 From: Robert Kaye Date: Tue, 30 Jan 2024 17:14:02 +0100 Subject: [PATCH 08/12] Fix exit code --- troi/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/troi/cli.py b/troi/cli.py index ef8301bc..2b2b7b26 100755 --- a/troi/cli.py +++ b/troi/cli.py @@ -119,7 +119,7 @@ def info(patch): def test(args): """Run unit tests""" import pytest - pytest.main(list(args)) + raise SystemExit(pytest.main()) if __name__ == "__main__": From a0887f77eafa239fcec4a7c6c3c3df378ca5b563 Mon Sep 17 00:00:00 2001 From: Robert Kaye Date: Tue, 30 Jan 2024 17:15:01 +0100 Subject: [PATCH 09/12] Fix tests --- tests/test_parser.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_parser.py b/tests/test_parser.py index 3c65a857..acb1f307 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -95,8 +95,8 @@ def test_opts(self): r = pp.parse("stats:(mr_monkey)::month") print(r[0]) assert r[0] == {"entity": "stats", "values": ["mr_monkey"], "weight": 1, "opts": ["month"]} - r = pp.parse("stats:(mr_monkey)::month,easy") - assert r[0] == {"entity": "stats", "values": ["mr_monkey"], "weight": 1, "opts": ["month", "easy"]} + r = pp.parse("artist:(monkey)::nosim,easy") + assert r[0] == {"entity": "artist", "values": ["monkey"], "weight": 1, "opts": ["nosim", "easy"]} self.assertRaises(ParseError, pp.parse, 'artist:(meh)::nosim,') @@ -125,8 +125,8 @@ def test_stats(self): r = pp.parse("stats:rob:1:week") assert r[0] == {"entity": "stats", "values": ["rob"], "weight": 1, "opts": ["week"]} - r = pp.parse("stats:(mr_monkey)::month,nosim") - assert r[0] == {"entity": "stats", "values": ["mr_monkey"], "weight": 1, "opts": ["month", "nosim"]} + r = pp.parse("stats:(mr_monkey)::month") + assert r[0] == {"entity": "stats", "values": ["mr_monkey"], "weight": 1, "opts": ["month"]} r = pp.parse("stats:(mr_monkey):2:month") assert r[0] == {"entity": "stats", "values": ["mr_monkey"], "weight": 2, "opts": ["month"]} From 9d53a0157529385cbb3862178c7ed69ecb843932 Mon Sep 17 00:00:00 2001 From: Robert Kaye Date: Tue, 30 Jan 2024 17:19:23 +0100 Subject: [PATCH 10/12] Fix invocation again --- troi/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/troi/cli.py b/troi/cli.py index 2b2b7b26..66a3a4d7 100755 --- a/troi/cli.py +++ b/troi/cli.py @@ -119,7 +119,7 @@ def info(patch): def test(args): """Run unit tests""" import pytest - raise SystemExit(pytest.main()) + raise SystemExit(pytest.main(list(args))) if __name__ == "__main__": From 3ee78cacba0318ad43406bf09696ccdfc474f25b Mon Sep 17 00:00:00 2001 From: Robert Kaye Date: Tue, 30 Jan 2024 17:22:49 +0100 Subject: [PATCH 11/12] Intentially failing tests --- tests/test_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_parser.py b/tests/test_parser.py index acb1f307..2c742248 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -148,7 +148,7 @@ def test_recs(self): assert r[0] == {"entity": "recs", "values": ["mr_monkey"], "weight": 1, "opts": ["listened"]} r = pp.parse("recs:(mr_monkey):2:unlistened") - assert r[0] == {"entity": "recs", "values": ["mr_monkey"], "weight": 2, "opts": ["unlistened"]} + assert r[0] == {"entity": "recs", "values": ["mr_monkey"], "weight": 1, "opts": ["unlistened"]} r = pp.parse("recs:(rob zombie)") assert r[0] == {"entity": "recs", "values": ["rob zombie"], "weight": 1, "opts": []} From 6964eb607aba4fdc10f842afc5a4eb1de0f433c6 Mon Sep 17 00:00:00 2001 From: Robert Kaye Date: Tue, 30 Jan 2024 17:25:02 +0100 Subject: [PATCH 12/12] Re-fix test --- tests/test_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_parser.py b/tests/test_parser.py index 2c742248..acb1f307 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -148,7 +148,7 @@ def test_recs(self): assert r[0] == {"entity": "recs", "values": ["mr_monkey"], "weight": 1, "opts": ["listened"]} r = pp.parse("recs:(mr_monkey):2:unlistened") - assert r[0] == {"entity": "recs", "values": ["mr_monkey"], "weight": 1, "opts": ["unlistened"]} + assert r[0] == {"entity": "recs", "values": ["mr_monkey"], "weight": 2, "opts": ["unlistened"]} r = pp.parse("recs:(rob zombie)") assert r[0] == {"entity": "recs", "values": ["rob zombie"], "weight": 1, "opts": []}