4
\$\begingroup\$

This script is meant to do the tedious work involved in a monthly event I run on a subreddit. It does a search for all posts relevant to the event since the last posting, and creates the bulk of the next month's post.

I would most like criticism at an organizational level. My functions ramble together, and it's hard to keep track of what I have, so I'd like suggestions for a better to do that.

In the problem domain, the name Piece is not as horribly vague as it seems. Of course, if you're aware of this and still think it's an awful name, I welcome your thoughts.

import configparser
import datetime
import logging
import re

import pickle
from typing import Optional

import praw
import praw.models

DELIMITER = '---'  # type: str
REDDIT = None
JAM_MAINTAINER = 'G01denW01f11'


def init_reddit(config_pathname: str) -> praw.Reddit:
    """Create global Reddit object from config file"""
    config = configparser.ConfigParser()
    config.read(config_pathname)
    return praw.Reddit(client_id=config['RedditParams']['client_id'],
                       client_secret=config['RedditParams']['client_secret'],
                       user_agent=config['RedditParams']['user_agent'])


def get_reddit() -> praw.Reddit:
    """Get the global Reddit object. Create it if it hasn't been created"""
    global REDDIT
    if not REDDIT:
        REDDIT = init_reddit('config.ini')
    return REDDIT


class Piece(object):
    """A piece to be listed in the piano jam"""

    def __init__(self, composer: str = None, title: str = None, video_url: str = None, score_url: str = None,
                 category: str = None):
        self.composer = composer  # type: str
        self.title = title  # type: str
        self.video_url = video_url  # type: str
        self.score_url = score_url  # type: str
        self.category = category  # type: str

    def __eq__(self, other: 'Piece') -> bool:
        return self.composer == other.composer and self.title == other.title

    def __ne__(self, other: 'Piece') -> bool:
        return not self == other

    def __str__(self) -> str:
        return '{}: [{}]({}) | [Sheet Music]({})'.format(self.composer, self.title, self.video_url.replace(')', '\)'),
                                                         self.score_url.replace(')', '\)'))


class Submission(object):
    """A submission to the month's Jam"""

    def __init__(self, username: str = None, url: str = None, title: str = None, piece: Piece = None):
        self.username = username  # type: str
        self.url = url  # type: str
        self.title = title  # type: str
        self.piece = piece  # type: Piece

    def __eq__(self, other: 'Submission') -> bool:
        return self.username == other.username and self.piece == other.piece

    def __ne__(self, other: 'Submission') -> bool:
        return not self == other

    def __str__(self) -> str:
        return '{}\'s {} by [/u/{}]({})'.format(self.piece.composer, self.piece.title, self.username, self.url)

    def set_piece(self, pieces: [Piece]) -> None:
        """
        From a list of valid pieces, set the one that matches
        :param pieces: A list of pieces to choose from
        """
        self.piece = find_piece_matching_title(pieces, self.title)
        if not self.piece:
            logging.warning('Could not find piece for {} | {}'.format(self.title, self.url))


def find_piece_matching_title(pieces: [Piece], title: str) -> Optional[Piece]:
    """
    Use a simple heuristic to tell which piece a submission is from the title
    :param pieces: Pieces to choose from
    :param title: Submission title
    :return: Appropriate piece, if any
    """
    for piece in pieces:
        if biggest_word_in_line(piece.title).lower() in title.lower():
            return piece
    return None


def format_title(section_title: str) -> str:
    """
    Apply proper formatting to the title of a section
    :param section_title: The title of a section to be formatted
    :return: Formatted title
    """
    return '**{}**'.format(section_title)


class Jam(object):
    """A Piano Jam posting"""

    CATEGORIES = ['Jazz', 'Classical', 'Ragtime', 'Video Game / Anime / Film']  # type: [str]

    def __init__(self, outline_pathname: str = 'jam_outline.txt'):
        """
        Create a Piano Jam instance from a given outline file
        :param outline_pathname: pathname to file with default jam contents
        """
        self.filename = ''  # type: str
        self.submissions = []  # type: [Submission]
        self.pieces = []  # type: [Piece]
        with open(outline_pathname, 'r') as f:
            self.text = f.read()

    def __str__(self):
        submissions_str = ''
        for submission in self.submissions:
            submissions_str += str(submission) + '\n\n'
        pieces_str = ''
        for piece in self.pieces:
            pieces_str += str(piece) + '\n\n'
        return self.text.format(submissions_str, pieces_str)

    def add_submission(self, submission: Submission):
        """
        Add a submission to the Jam. Multiple submissions do not get added
        :param submission: Submission to the Piano Jam
        :return: None
        """
        for prior_submission in self.submissions:
            if submission.username == prior_submission.username and submission.piece == submission.piece:
                if submission.url != prior_submission.url:
                    logging.warning('User {0} attempted to submit a piece multiple times'.format(submission.username))
                return
        self.submissions.append(submission)

    def add_piece(self, piece: Piece):
        if piece not in self.pieces:
            self.pieces.append(piece)

    def save(self, filename: str='') -> None:
        if filename:
            self.filename = filename
        if not self.filename:
            raise ValueError('No filename to save to!')
        with open(self.filename, 'wb') as f:
            pickle.dump(self, f)

    @classmethod
    def load(cls, filename: str) -> 'Jam':
        with open(filename, 'rb') as f:
            jam = pickle.load(f)  # type: Jam
        if type(jam) != Jam:
            raise TypeError('Tried to load a Jam. Got {}'.format(type(jam)))
        assert jam.filename == filename
        return jam


def parse_piece(piece_text: str) -> Piece:
    """
    Construct a Piece from its string representation.
    Expected format: Composer: [Title](url) | [Sheet Music](sheetUrl)
    :param piece_text: Line from Piano Jam specifying a Piece to learn
    """
    piece = Piece()
    piece.composer = piece_text[:piece_text.index(':')]
    piece.title = re.findall(re.compile('\[(.*?)\]'), piece_text)[0]  # type: str
    urls = re.findall(re.compile('\((.*?)\)'), piece_text)
    piece.video_url = urls[0]  # type: str
    piece.score_url = urls[1]  # type: str
    return piece


def parse_pieces(section_text: str) -> [Piece]:
    """Parse all the pieces in a given section"""
    pieces = section_text.split('\n')[1:]  # First line is the category; discard
    return (parse_piece(piece_text) for piece_text in pieces if piece_text.strip() != '')


def get_pieces_from_jam(jam_text: str) -> [Piece]:
    """
    Parse all the pieces from a Jam, given the contents of a post
    :param jam_text: The contents of a Piano Jam posting
    :return: List of pieces to be used for the Jam
    """
    sections = jam_text.split(DELIMITER)
    sections = (section.strip() for section in sections)
    filtered_sections = []
    for section in sections:
        section = section.strip()
        for category in Jam.CATEGORIES:
            category = format_title(category)
            if section.startswith(category):
                filtered_sections.append(section)
                break
    pieces = []
    for section in filtered_sections:
        pieces.extend(parse_pieces(section))
    return pieces


def get_selections_from_url(url: str) -> [Piece]:
    """
    Parse all the pieces from a jam, given its url
    :param url: URL to a Piano Jam post
    :return: List of pieces to be used for the Jam
    """
    try:
        post = praw.models.Submission(get_reddit(), url=url)
    except KeyError:
        raise KeyError('Could not recognize url {0}'.format(url))
    return get_pieces_from_jam(post.selftext)


def search_for_submissions():
    """
    Search Reddit for posts with [Piano Jam] in title within past month
    :return: List of urls to posts
    """
    subreddit = get_reddit().subreddit('piano')
    results = subreddit.search('[Piano Jam]', sort='new', time_filter='month')
    return (result for result in results)


def filter_submissions(submissions: [praw.models.Submission], jam: praw.models.Submission):
    return [submission for submission in submissions
            if '[piano jam]' in submission.title.lower() and
            datetime.datetime.fromtimestamp(submission.created) >
            datetime.datetime.fromtimestamp(jam.created)]


def find_last_jam() -> praw.models.Submission:
    candidates = search_for_submissions()
    for candidate in candidates:
        if candidate.author == JAM_MAINTAINER and '[' not in candidate.title:
            return candidate
    raise ValueError('Could not find last Piano Jam')


def biggest_word_in_line(line: str) -> str:
    words = line.split()
    length = 0
    biggest_word = None
    for word in words:
        if len(word) > length:
            length = len(word)
            biggest_word = word
    assert biggest_word
    return biggest_word


def create_jam() -> [Submission]:
    """
    Find all Piano Jam submissions since the last posting
    Log a warning if there are submissions not in the previous Jam.
    Create Jam from submissions and pickle it for later use.
    """
    previous_jam = find_last_jam()
    entries = filter_submissions(search_for_submissions(), previous_jam)
    submissions = [Submission(entry.author, entry.shortlink, entry.title) for entry in entries]
    pieces = get_pieces_from_jam(previous_jam.selftext)
    new_jam = Jam()
    for submission in submissions:
        submission.set_piece(pieces)
        if submission.piece:
            new_jam.add_submission(submission)
    new_jam.save('current_jam.txt')
\$\endgroup\$
1
\$\begingroup\$
  1. It doesn't make a whole lot of sense to have a function with no arguments that modifies a global object. Your init_reddit function is better than your get_reddit function because of this.

  2. IMHO you should rethink why you have a function that has more comments than code in it. There may be a more idiomatic way to express that. (see find_piece_matching_title, format_title)

  3. Classes are good; consider making a Reddit class that either inherits from praw.Reddit or has your reddit instance as a member variable. You could put search_for_submissions and filter_submissions in there.

  4. Your parse_piece, parse_pieces, get_pieces_from_jam, etc. functions should be a part of your Piece or Jam objects. If you're using objects to contain your data, it makes sense to have functions manipulating that data as methods.

Overall, I see in your code a whole bunch of top-level functions and objects without a clear indication of how they're supposed to work together. The difficult part in coding is not necessarily writing the individual pieces, but in figuring out the simplest (least complected) way for them to interact.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.