Source code for nkdsu.apps.vote.utils

from __future__ import annotations

import logging
import re
import string
from dataclasses import dataclass
from os import environ
from typing import (
    Any,
    Callable,
    Iterable,
    NoReturn,
    Optional,
    TYPE_CHECKING,
    TypeVar,
    cast,
)
from urllib.parse import urlencode

from django.conf import settings
from django.contrib.auth.models import User
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist
from django.urls import reverse
import musicbrainzngs
from mypy_extensions import KwArg, VarArg
import requests

if TYPE_CHECKING:
    from .models import Profile, Show, Track


BUILDING_DOCS = bool(environ.get('BUILDING_DOCS'))

logger = logging.getLogger(__name__)


indefinitely: int = (
    (60 * 60 * 24 * 7) + (60 * 60) + 60
)  # one week, one hour and one minute


[docs] @dataclass class BrowsableItem: url: Optional[str] name: str visible: bool = True
[docs] def group(self) -> tuple[int, str]: """ Return a sort order and a user-facing name for the group to put this item in. By default, an initial letter. """ if not self.name: return (3, '') first_character = self.name[0] if first_character in string.ascii_letters: return (0, first_character.lower()) elif first_character in string.digits: return (1, '0-9') else: return (2, '#')
[docs] @dataclass class BrowsableYear(BrowsableItem):
[docs] def group(self) -> tuple[int, str]: decade = (int(self.name) // 10) * 10 return (decade, f"{decade}s")
SHORT_URL_LENGTH: int = 20 READING_USERNAME: str = 'nkdsu'
[docs] def length_str(msec: float) -> str: """ Convert a number of milliseconds into a human-readable representation of the length of a track. >>> length_str(999) '0:00' >>> length_str(1000) '0:01' >>> length_str(1000 * (60 + 15)) '1:15' >>> length_str(1000 * (60 + 15)) '1:15' >>> length_str((60 * 60 * 1000) + (1000 * (60 + 15))) '1:01:15' """ seconds = (msec or 0) / 1000 remainder_seconds = seconds % 60 minutes = (seconds - remainder_seconds) / 60 if minutes >= 60: remainder_minutes = minutes % 60 hours = (minutes - remainder_minutes) / 60 return '%i:%02d:%02d' % (hours, remainder_minutes, remainder_seconds) else: return '%i:%02d' % (minutes, remainder_seconds)
[docs] def camel_to_snake(camel: str) -> str: """ >>> camel_to_snake('camelCaseABCD') 'camel_case_a_b_c_d' """ return re.sub(r'[A-Z]', lambda m: f'_{m.group(0).lower()}', camel)
[docs] def track_with_get_param_url(tracks: Iterable[Track], url: str) -> str: """ Generate a URL for a view that uses :class:`.TracksFromGetParamMixin`. """ base = reverse(url) query = {'t': ','.join(t.id for t in tracks)} return f'{base}?{urlencode(query)}'
[docs] def add_to_list_url(tracks: Iterable[Track]) -> str: return track_with_get_param_url(tracks, 'vote:add-to-list')
[docs] def vote_url(tracks: Iterable[Track]) -> str: return track_with_get_param_url(tracks, 'vote:vote')
[docs] def split_id3_title(id3_title: str) -> tuple[str, Optional[str]]: """ Take a 'Title (role)'-style ID3 title and return ``(title, role)``. >>> split_id3_title('title') ('title', None) >>> split_id3_title('title (role)') ('title', 'role') The role will be populated if we're able to find a set of matching brackets starting with the final character: >>> split_id3_title('title ((role)') ('title (', 'role') >>> split_id3_title('title ((r(o)(l)e)') ('title (', 'r(o)(l)e') But no role will be returned if the brackets close more than they open, or if the final character is not a ``)``: >>> split_id3_title('title (role) ') ('title (role) ', None) >>> split_id3_title('title (role))') ('title (role))', None) """ role = None bracket_depth = 0 for i in range(1, len(id3_title) + 1): char = id3_title[-i] if char == ')': bracket_depth += 1 elif char == '(': bracket_depth -= 1 if bracket_depth == 0: if i != 1: role = id3_title[len(id3_title) - i :] break if role: title = id3_title.replace(role, '').strip() role = role[1:-1] # strip brackets else: title = id3_title return title, role
# http://zeth.net/post/327/
[docs] def split_query_into_keywords(query: str) -> list[str]: """ Split the query into keywords. Where keywords are double quoted together, use as one keyword. >>> split_query_into_keywords('hello there, how are you doing') ['hello', 'there,', 'how', 'are', 'you', 'doing'] >>> split_query_into_keywords('hello there, "how are you doing"') ['how are you doing', 'hello', 'there,'] """ keywords = [] # Deal with quoted keywords while '"' in query: first_quote = query.find('"') second_quote = query.find('"', first_quote + 1) if second_quote == -1: break quoted_keywords = query[first_quote : second_quote + 1] keywords.append(quoted_keywords.strip('"')) query = query.replace(quoted_keywords, ' ') # Split the rest by spaces keywords.extend(query.split()) return keywords
T = TypeVar('T') C = TypeVar('C', bound=Callable[[VarArg(Any), KwArg(Any)], Any])
[docs] def memoize(func: C) -> C: """ Do nothing, for now; :func:`.lru_cache` does not get wiped for new instances of `self`, which is a problem when we need to catch updates. """ return func
[docs] def cached(seconds: int, cache_key: str) -> Callable[[C], C]: def wrapper(func: C) -> C: def wrapped(*a, **k) -> Any: def do_thing(func, *a, **k) -> Any: hit = cache.get(cache_key) if hit is not None: return hit rv = func(*a, **k) cache.set(cache_key, rv, seconds) return rv return do_thing(func, *a, **k) return cast(C, wrapped) return wrapper
[docs] def pk_cached(seconds: int) -> Callable[[T], T]: # does nothing (currently), but expresses a desire to cache stuff in future def wrapper(func: T) -> T: def wrapped(obj, *a, **k): def do_thing(func, pk, *a, **k): return func(obj, *a, **k) return do_thing(func, obj.pk, *a, **k) return cast(T, wrapped) return wrapper
[docs] def lastfm(**kwargs): params = { 'api_key': settings.LASTFM_API_KEY, 'api_secret': settings.LASTFM_API_SECRET, 'format': 'json', } params.update(kwargs) resp = requests.get('http://ws.audioscrobbler.com/2.0/', params=params) return resp.json()
[docs] def assert_never(value: NoReturn) -> NoReturn: assert False, f'this code should not have been reached; got {value!r}'
[docs] def get_profile_for(user: User) -> Profile: try: return user.profile except ObjectDoesNotExist: from .models import Profile return Profile.objects.create(user=user)
[docs] def vote_edit_cutoff() -> Show: """ Return the last show for which vote comments should be editable. """ from .models import Show current = Show.current() prev = current.prev() return current if prev is None else prev
musicbrainzngs.set_useragent('nkd.su', '0', 'http://nkd.su/')