diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7facc9b --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# Created by .ignore support plugin (hsz.mobi) +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8771c83 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2016 Aaron Toth + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..f9a481c --- /dev/null +++ b/README.rst @@ -0,0 +1,6 @@ +puckdb +====== + +An async-first hockey data extractor and API. + +Still under active development and not ready for consumption. diff --git a/puckdb/__init__.py b/puckdb/__init__.py new file mode 100644 index 0000000..81b4be4 --- /dev/null +++ b/puckdb/__init__.py @@ -0,0 +1,3 @@ +__title__ = 'puckdb' +__author__ = 'Aaron Toth' +__version__ = '0.0.1' diff --git a/puckdb/async/__init__.py b/puckdb/async/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/puckdb/async/db.py b/puckdb/async/db.py new file mode 100644 index 0000000..dc576e7 --- /dev/null +++ b/puckdb/async/db.py @@ -0,0 +1,14 @@ +from contextlib import contextmanager + +from aiopg.sa import create_engine + +from .. import conf + + +@contextmanager +async def get_engine(): + async with create_engine(dsn=conf.get_db()) as engine: + await engine + + +__all__ = ['get_engine'] diff --git a/puckdb/conf.py b/puckdb/conf.py new file mode 100644 index 0000000..e15bc31 --- /dev/null +++ b/puckdb/conf.py @@ -0,0 +1,53 @@ +import configparser +import os + +import click + +from . import __title__ + + +def _get_config_file_path(): + return os.path.join(click.get_app_dir(__title__), 'config.ini') + + +def _read(): + conf_file = _get_config_file_path() + conf = configparser.RawConfigParser() + try: + conf.read(conf_file) + return conf, conf_file + except IOError: + raise IOError('Could not find settings file.\n' + 'Make sure it exists at "{path}"'.format(path=conf_file)) + + +def _write(dsn=''): + conf_file = _get_config_file_path() + conf = configparser.RawConfigParser() + if 'db' not in conf.sections(): + conf.add_section('db') + conf.set('db', 'dsn', dsn) + with open(conf_file, 'w') as f: + conf.write(f) + + +def init(): + config_file = _get_config_file_path() + if not os.path.exists(os.path.dirname(config_file)): + os.makedirs(os.path.dirname(config_file)) + _write() + + +def get_db() -> str: + dsn = os.getenv('PUCKDB_DATABASE', None) + if dsn: + return dsn + config, config_file = _read() + try: + return config.get('db', 'dsn') + except configparser.NoSectionError: + raise IOError('Could not read database settings from the config file.\n' + 'Make sure the [db] section exists in "{path}"'.format(path=config_file)) + except configparser.NoOptionError: + raise IOError('Could not read database settings from the config file.\n' + 'Make sure the [db] has the proper headings in "{path}"'.format(path=config_file)) diff --git a/puckdb/console.py b/puckdb/console.py new file mode 100644 index 0000000..542c500 --- /dev/null +++ b/puckdb/console.py @@ -0,0 +1,17 @@ +import click + + +@click.command() +def init(): + pass + + +@click.group +@click.version_option() +def main(): + pass + +main.add_command(init) + +if __name__ == '__main__': + main() diff --git a/puckdb/constants.py b/puckdb/constants.py new file mode 100644 index 0000000..3f534cc --- /dev/null +++ b/puckdb/constants.py @@ -0,0 +1,11 @@ +import enum + + +class GameState(enum.Enum): + not_started = -1 + in_progress = 0 + finished = 1 + + +class GameEvent(enum.Enum): + pass diff --git a/puckdb/db.py b/puckdb/db.py new file mode 100644 index 0000000..48e5fc0 --- /dev/null +++ b/puckdb/db.py @@ -0,0 +1,33 @@ +import sqlalchemy as sa +from sqlalchemy.schema import CreateTable + +from .constants import GameState +from .async.db import * + +metadata = sa.MetaData() + +league_tbl = sa.Table('league', metadata, + sa.Column('id', sa.SmallInteger, primary_key=True), + sa.Column('name', sa.String(255)) +) + +team_tbl = sa.Table('team', metadata, + sa.Column('id', sa.SmallInteger, primary_key=True), + sa.Column('league', sa.SmallInteger, sa.ForeignKey('league.id'), nullable=False), + sa.Column('name', sa.String), + sa.Column('full_name', sa.String), + sa.Column('city', sa.String) +) + +game_tbl = sa.Table('game', metadata, + sa.Column('id', sa.BigInteger, primary_key=True), + sa.Column('season', sa.SmallInteger), + sa.Column('status', sa.Enum(GameState)), + sa.Column('away', sa.SmallInteger, sa.ForeignKey('team.id'), nullable=False), + sa.Column('home', sa.SmallInteger, sa.ForeignKey('team.id'), nullable=False), + sa.Column('away_score', sa.SmallInteger), + sa.Column('home_score', sa.SmallInteger), + sa.Column('start', sa.DateTime, index=True), + sa.Column('duration', sa.Time), + sa.Column('periods', sa.SmallInteger) +) diff --git a/puckdb/exceptions.py b/puckdb/exceptions.py new file mode 100644 index 0000000..2e0f141 --- /dev/null +++ b/puckdb/exceptions.py @@ -0,0 +1,6 @@ +class FilterException(Exception): + def __init__(self, message=None): + self.message = message + + def __str__(self): + return 'Invalid filter{message}'.format(': ' + self.message if self.message else '') diff --git a/puckdb/filters.py b/puckdb/filters.py new file mode 100644 index 0000000..805c730 --- /dev/null +++ b/puckdb/filters.py @@ -0,0 +1,40 @@ +from datetime import datetime + +from . import exceptions + + +class BaseFilter(object): + pass + + +class TeamFilter(BaseFilter): + def __init__(self, name=None): + self.name = name + + +class GameFilter(BaseFilter): + def __init__(self, from_date=None, to_date=None, team=None): + """ + + :type from_date: datetime + :type to_date: datetime + :type team: TeamFilter + """ + if from_date is None: + raise exceptions.FilterException('from_date must be provided') + to_date = to_date or datetime.utcnow() + if to_date < from_date: + raise exceptions.FilterException('to_date must be after from_date') + self.from_date = from_date + self.to_date = to_date + self.team = team + + @property + def season_range(self): + seasons = [] + from_season = self.from_date.year if self.from_date.month >= 9 else self.from_date.year - 1 + to_season = self.to_date.year - 1 if self.to_date.month < 9 else self.to_date.year + for i in range(to_season - from_season + 1): + season_start = from_season + i + seasons.append('{}{}'.format(season_start, season_start+1)) + return seasons diff --git a/puckdb/zamboni.py b/puckdb/zamboni.py new file mode 100644 index 0000000..dd72c15 --- /dev/null +++ b/puckdb/zamboni.py @@ -0,0 +1,20 @@ +import abc +from datetime import datetime + +import aiohttp + +from . import exceptions, filters + +class BaseScraper(object): + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + async def get_schedule(self, game_filter: filters.GameFilter): + pass + + +class NHLScraper(BaseScraper): + schedule_url = 'http://live.nhl.com/GameData/SeasonSchedule-{season}.json' + + async def get_schedule(self, game_filter: filters.GameFilter): + pass diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..6366dd9 --- /dev/null +++ b/setup.py @@ -0,0 +1,44 @@ +import re +import ast + +from setuptools import setup, find_packages + +_version_re = re.compile(r'__version__\s+=\s+(.*)') +with open('puckdb/__init__.py', 'rb') as f: + version = str(ast.literal_eval(_version_re.search( + f.read().decode('utf-8')).group(1))) + +setup( + name='puckdb', + author='Aaron Toth', + version=version, + url='https://github.com/aaront/puckdb', + description='An async-first hockey data extractor and API', + long_description=open('README.rst').read(), + install_requires=[ + 'click', + 'cchardet', + 'aiohttp', + 'aiopg', + 'sqlalchemy' + ], + test_suite="tests", + include_package_data=True, + packages=find_packages(), + package_data={'': ['LICENSE']}, + package_dir={'puckdb': 'puckdb'}, + license='Apache 2.0', + entry_points=''' + [console_scripts] + puckdb=puckdb.console:main + ''', + classifiers=( + 'Development Status :: 2 - Pre-Alpha', + 'Intended Audience :: Developers', + 'Natural Language :: English', + 'License :: OSI Approved :: Apache Software License', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3.5', + 'Topic :: Software Development :: Libraries' + ) +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_filters.py b/tests/test_filters.py new file mode 100644 index 0000000..558a350 --- /dev/null +++ b/tests/test_filters.py @@ -0,0 +1,32 @@ +import unittest +from datetime import datetime + +from puckdb import filters + + +class TestGameFilter(unittest.TestCase): + def test_one_season_range(self): + from_date = datetime(2014, 10, 22) + to_date = datetime(2015, 4, 1) + game_filter = filters.GameFilter(from_date=from_date, to_date=to_date) + seasons = game_filter.season_range + self.assertEqual(1, len(seasons)) + self.assertEqual('20142015', seasons[0]) + + def test_season_before_range(self): + from_date = datetime(2014, 4, 22) + to_date = datetime(2015, 4, 1) + game_filter = filters.GameFilter(from_date=from_date, to_date=to_date) + seasons = game_filter.season_range + self.assertEqual(2, len(seasons)) + self.assertEqual('20132014', seasons[0]) + self.assertEqual('20142015', seasons[1]) + + def test_season_after_range(self): + from_date = datetime(2014, 10, 22) + to_date = datetime(2015, 9, 1) + game_filter = filters.GameFilter(from_date=from_date, to_date=to_date) + seasons = game_filter.season_range + self.assertEqual(2, len(seasons)) + self.assertEqual('20142015', seasons[0]) + self.assertEqual('20152016', seasons[1])