Skip to content

Commit

Permalink
Merge pull request #125 from metabrainz/replace-parser
Browse files Browse the repository at this point in the history
Replace parser
  • Loading branch information
mayhem authored Jan 30, 2024
2 parents ce0bca6 + 6964eb6 commit 2af0ef8
Show file tree
Hide file tree
Showing 6 changed files with 267 additions and 253 deletions.
54 changes: 30 additions & 24 deletions docs/lb_radio.rst
Original file line number Diff line number Diff line change
Expand Up @@ -85,77 +85,83 @@ 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, so it must be wrapped in ():
Artist and tag names are the tricky bits to specify in a prompt, so they must be enclosed with ():

::

artist:(Blümchen)
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 and comma seperated lists of tags have similar restrictions and must be enclosed by ():
Tags have similar restrictions. If a tag you'd like to specify has no spaces or non-latin unicode characters you may use:

::

tag:(punk)
#punk

But with spaces or non-latin unicode characters, wrap it in () and use the full tag element name:

A shorthand with #<tag> is allowed, as long as the tag does not contain spaces:
::

#punk
tag:(hip hop)

::

tag:(あなたを決して裏切りません)


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. This prompt generates two equal streams from the tags "rock" and "pop".
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,pop)
tag:(rock,pop)
tag:(trip hop)

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.
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:

::

Expand Down
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
124 changes: 74 additions & 50 deletions tests/test_parser.py
Original file line number Diff line number Diff line change
@@ -1,130 +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,rock,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 = 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:(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("artist:(monkey)::nosim,easy")
assert r[0] == {"entity": "artist", "values": ["monkey"], "weight": 1, "opts": ["nosim", "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")
r = pp.parse("stats:(mr_monkey)::month")
assert r[0] == {"entity": "stats", "values": ["mr_monkey"], "weight": 1, "opts": ["month"]}

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": []}
2 changes: 1 addition & 1 deletion troi/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def info(patch):
def test(args):
"""Run unit tests"""
import pytest
pytest.main(list(args))
raise SystemExit(pytest.main(list(args)))


if __name__ == "__main__":
Expand Down
Loading

0 comments on commit 2af0ef8

Please sign in to comment.