diff --git a/.gitignore b/.gitignore index 1db64ac..d00b2a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,22 @@ node_modules .log +*.pyc + +# Python Distribution / packaging +.Python +env/ +.env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..07fdb04 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include LICENSE +include README.md +include python/README.md +include data.json diff --git a/README.md b/README.md index 57819c4..afe9910 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,6 @@ If you are a node user, running `npm install && npm start` will rebuild the SQLi Make this repository a dependency of your project and automate the process of copying `fantasy.db` into your testing harness. Because this repository is meant to be used by multiple programming languages, there are no affordances for auto-migrating your database (PRs welcome!). Use [schema.sql](https://github.com/endpoints/fantasy-database/blob/master/schema.sql) as a reference for building migrations, and [data.json](https://github.com/endpoints/fantasy-database/blob/master/data.json) for seeding if you'd like to test in something other than SQLite. + +#### Usage notes +- [Python](python) diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..7073960 --- /dev/null +++ b/python/README.md @@ -0,0 +1,38 @@ + +# fantasy-database + +> A database with a few fantasy books in it for testing query builders, orms, rest frameworks, etc. + + +## Django + +This test app for Django provides models, migrations, and fixtures that implement the fantasy-database. It's +recommended that app installation be limited to testing only. + +Example usage: + +In your `settings.py` module: + +```python + +# Testing +TESTING = len(sys.argv) > 1 and sys.argv[1] in ('test', 'testserver') + +if TESTING: + INSTALLED_APPS += ( + 'django_fantasy', + ) + +... +``` + +In a test module: +```python + +from django.test import TestCase + +class FantasyTests(TestCase): + fixtures = ['fantasy-database.json'] + ... + +``` diff --git a/python/django_fantasy/__init__.py b/python/django_fantasy/__init__.py new file mode 100644 index 0000000..15682dc --- /dev/null +++ b/python/django_fantasy/__init__.py @@ -0,0 +1,2 @@ + +default_app_config = 'django_fantasy.apps.FantasyConfig' diff --git a/python/django_fantasy/apps.py b/python/django_fantasy/apps.py new file mode 100644 index 0000000..de3481a --- /dev/null +++ b/python/django_fantasy/apps.py @@ -0,0 +1,8 @@ + +from django.apps import AppConfig + + +class FantasyConfig(AppConfig): + name = 'django_fantasy' + label = 'fantasy' + verbose_name = "Fantasy Database" diff --git a/python/django_fantasy/fixtures/fantasy-database.json b/python/django_fantasy/fixtures/fantasy-database.json new file mode 100644 index 0000000..bef5c65 --- /dev/null +++ b/python/django_fantasy/fixtures/fantasy-database.json @@ -0,0 +1 @@ +// This will be replaced on setup \ No newline at end of file diff --git a/python/django_fantasy/migrations/0001_initial.py b/python/django_fantasy/migrations/0001_initial.py new file mode 100644 index 0000000..e9b6381 --- /dev/null +++ b/python/django_fantasy/migrations/0001_initial.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='Author', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=80)), + ('date_of_birth', models.DateField()), + ('date_of_death', models.DateField(null=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Book', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=80)), + ('date_published', models.DateField()), + ('author', models.ForeignKey(to='fantasy.Author')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Chapter', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=80)), + ('ordering', models.PositiveIntegerField()), + ('book', models.ForeignKey(to='fantasy.Book')), + ], + ), + migrations.CreateModel( + name='Photo', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=80)), + ('uri', models.URLField()), + ('imageable_id', models.PositiveIntegerField()), + ('imageable_type', models.ForeignKey(to='contenttypes.ContentType')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Series', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=80)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Store', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=80)), + ('books', models.ManyToManyField(to='fantasy.Book')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='book', + name='series', + field=models.ForeignKey(to='fantasy.Series', null=True), + ), + migrations.AlterUniqueTogether( + name='chapter', + unique_together=set([('book', 'ordering')]), + ), + ] diff --git a/python/django_fantasy/migrations/__init__.py b/python/django_fantasy/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/django_fantasy/models.py b/python/django_fantasy/models.py new file mode 100644 index 0000000..7f5dedb --- /dev/null +++ b/python/django_fantasy/models.py @@ -0,0 +1,59 @@ + +from django.db import models +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType + + +class TimestampedModel(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class Author(TimestampedModel): + name = models.CharField(max_length=80) + date_of_birth = models.DateField() + date_of_death = models.DateField(null=True) + + +class Series(TimestampedModel): + title = models.CharField(max_length=80) + photo = GenericRelation( + 'fantasy.Photo', + content_type_field='imageable_type', + object_id_field='imageable_id', + ) + + +class Book(TimestampedModel): + series = models.ForeignKey('fantasy.Series', null=True) + author = models.ForeignKey('fantasy.Author') + title = models.CharField(max_length=80) + date_published = models.DateField() + + +class Chapter(TimestampedModel): + title = models.CharField(max_length=80) + book = models.ForeignKey('fantasy.Book') + ordering = models.PositiveIntegerField() + + class Meta: + unique_together = ( + ('book', 'ordering'), + ) + + +class Store(TimestampedModel): + name = models.CharField(max_length=80) + books = models.ManyToManyField('fantasy.Book') + + +class Photo(TimestampedModel): + title = models.CharField(max_length=80) + uri = models.URLField() + + imageable_type = models.ForeignKey(ContentType) + imageable_id = models.PositiveIntegerField() + imageable_object = GenericForeignKey('imageable_type', 'imageable_id') diff --git a/python/setup_commands.py b/python/setup_commands.py new file mode 100644 index 0000000..6ec85e6 --- /dev/null +++ b/python/setup_commands.py @@ -0,0 +1,103 @@ + +import os +import sys +import json +from setuptools.command.build_py import build_py +from setuptools.command.test import test + +BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) + + +def build_fixture(input_path, output_path): + from utils import translate_django_fixture + + contents = None + with open(input_path) as infile: + contents = json.loads(infile.read()) + + contents = translate_django_fixture(contents) + + with open(output_path, 'w') as outfile: + outfile.write(json.dumps(contents)) + + +class BuildPy(build_py): + + # adapted from: + # http://www.digip.org/blog/2011/01/generating-data-files-in-setup.py.html + def run(self): + # honor the --dry-run flag + if not self.dry_run: + target_dir = os.path.join(self.build_lib, 'django_fantasy/fixtures') + + # mkpath is a distutils helper to create directories + self.mkpath(target_dir) + + input_path = os.path.join(BASE_DIR, 'data.json') + output_path = os.path.join(target_dir, 'fantasy-database.json') + + build_fixture(input_path, output_path) + + # distutils uses old-style classes, so no super() + build_py.run(self) + + +class Test(test): + user_options = [ + ('test-labels=', 'l', "Test labels to pass to runner.py test"), + ('djtest-args=', 'a', "Arguments to pass to runner.py test"), + ] + + def initialize_options(self): + test.initialize_options(self) + self.test_labels = 'tests' + self.djtest_args = '' + + def finalize_options(self): + test.finalize_options(self) + self.test_args = [] + self.test_suite = True + + # This is almost a direct copy of the original method. The difference is + # that this method only performs a non-'inplace' build. + def with_project_on_sys_path(self, func): + from pkg_resources import ( + normalize_path, working_set, add_activation_listener, require + ) + + # Ensure metadata is up-to-date + self.reinitialize_command('build_py', inplace=0) + self.run_command('build_py') + bpy_cmd = self.get_finalized_command("build_py") + build_path = normalize_path(bpy_cmd.build_lib) + + # Build extensions + self.reinitialize_command('egg_info', egg_base=build_path) + self.run_command('egg_info') + + self.reinitialize_command('build_ext', inplace=0) + self.run_command('build_ext') + + ei_cmd = self.get_finalized_command("egg_info") + + old_path = sys.path[:] + old_modules = sys.modules.copy() + + try: + sys.path.insert(0, normalize_path(ei_cmd.egg_base)) + working_set.__init__() + add_activation_listener(lambda dist: dist.activate()) + require('%s==%s' % (ei_cmd.egg_name, ei_cmd.egg_version)) + func() + finally: + sys.path[:] = old_path + sys.modules.clear() + sys.modules.update(old_modules) + working_set.__init__() + + def run_tests(self): + from tests.runner import main + + test_labels = self.test_labels.split() + djtest_args = self.djtest_args.split() + main(['runner.py', 'test'] + test_labels + djtest_args) diff --git a/python/tests/__init__.py b/python/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/tests/runner.py b/python/tests/runner.py new file mode 100644 index 0000000..3b183a3 --- /dev/null +++ b/python/tests/runner.py @@ -0,0 +1,28 @@ +import os +import sys +import glob + + +base = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) + +# add test eggs to path +eggs = os.path.join(base, "*.egg") +sys.path += glob.glob(eggs) + +# also add parent directory to path (to find tests) +pkg_base = os.path.join(base, 'python') +sys.path.append(pkg_base) + +os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' + + +def main(argv): + from django.core.management import execute_from_command_line + execute_from_command_line(argv) + + +if __name__ == '__main__': + args = sys.argv + args.insert(1, 'test') + + main(args) diff --git a/python/tests/settings.py b/python/tests/settings.py new file mode 100644 index 0000000..a698e19 --- /dev/null +++ b/python/tests/settings.py @@ -0,0 +1,26 @@ + +SECRET_KEY = 'not-so-secret' + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:' + } +} + +INSTALLED_APPS = ( + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django_fantasy', + 'tests', +) + + +ROOT_URLCONF = 'tests.urls' + + +USE_TZ = True diff --git a/python/tests/test_django.py b/python/tests/test_django.py new file mode 100644 index 0000000..ce39862 --- /dev/null +++ b/python/tests/test_django.py @@ -0,0 +1,14 @@ + +from django.test import TestCase +from django_fantasy import models + + +class FixtureTest(TestCase): + fixtures = ['fantasy-database.json'] + + def test_data(self): + authors = models.Author.objects.values_list('name', flat=True) + + self.assertEqual(len(authors), 2) + self.assertEqual(authors[0], 'J. R. R. Tolkien') + self.assertEqual(authors[1], 'J. K. Rowling') diff --git a/python/tests/urls.py b/python/tests/urls.py new file mode 100644 index 0000000..f5a2617 --- /dev/null +++ b/python/tests/urls.py @@ -0,0 +1,4 @@ +""" +Blank URLConf just to keep the test suite happy +""" +urlpatterns = [] diff --git a/python/utils.py b/python/utils.py new file mode 100644 index 0000000..153c441 --- /dev/null +++ b/python/utils.py @@ -0,0 +1,113 @@ + +from datetime import datetime, timedelta, tzinfo + + +# copied from django.utils.timezone +ZERO = timedelta(0) + + +class UTC(tzinfo): + """ + UTC implementation taken from Python's docs. + Used only when pytz isn't available. + """ + + def __repr__(self): + return "" + + def utcoffset(self, dt): + return ZERO + + def tzname(self, dt): + return "UTC" + + def dst(self, dt): + return ZERO + + +def translate_django_fixture(data): + """ + Translate the contents of the `data.json` file into a django compatible fixture. + """ + + results = [] + now = datetime.now().replace(tzinfo=UTC()).isoformat() + + for author in data['authors']: + results.append({ + 'pk': author['id'], 'model': 'fantasy.author', + 'fields': { + 'created_at': now, + 'updated_at': now, + 'name': author['name'], + 'date_of_birth': author['date_of_birth'], + 'date_of_death': author['date_of_death'], + } + }) + + for series in data['series']: + results.append({ + 'pk': series['id'], 'model': 'fantasy.series', + 'fields': { + 'created_at': now, + 'updated_at': now, + 'title': series['title'], + } + }) + + for book in data['books']: + results.append({ + 'pk': book['id'], 'model': 'fantasy.book', + 'fields': { + 'created_at': now, + 'updated_at': now, + 'series': book['series_id'], + 'author': book['author_id'], + 'title': book['title'], + 'date_published': book['date_published'], + } + }) + + for chapter in data['chapters']: + results.append({ + 'pk': chapter['id'], 'model': 'fantasy.chapter', + 'fields': { + 'created_at': now, + 'updated_at': now, + 'title': chapter['title'], + 'book': chapter['book_id'], + 'ordering': chapter['ordering'], + } + }) + + for store in data['stores']: + results.append({ + 'pk': store['id'], 'model': 'fantasy.store', + 'fields': { + 'created_at': now, + 'updated_at': now, + 'name': store['name'], + 'books': [sb['book_id'] for sb in data['books_stores'] if sb['store_id'] == store['id']], + } + }) + + for photo in data['photos']: + types_map = { + 'authors': ('fantasy', 'author'), + 'books': ('fantasy', 'book'), + 'series': ('fantasy', 'series'), + } + + results.append({ + 'pk': photo['id'], 'model': 'fantasy.photo', + 'fields': { + 'created_at': now, + 'updated_at': now, + 'imageable_id': photo['imageable_id'], + 'imageable_type': types_map[photo['imageable_type']], + 'title': photo['title'], + 'uri': photo['uri'], + } + }) + + return results diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2a9acf1 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7a52ec8 --- /dev/null +++ b/setup.py @@ -0,0 +1,56 @@ +import os +import sys +from setuptools import setup, find_packages + + +README = open(os.path.join(os.path.dirname(__file__), 'python/README.md')).read() + +# allow setup.py to be run from any path +os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) + +# append the python directory to our path so we can import utils +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(os.path.join(BASE_DIR, 'python')) + + +def get_commands(): + from setup_commands import Test, BuildPy + return Test, BuildPy + +Test, BuildPy = get_commands() + + +setup( + name='fantasy-database', + version='2.0.3-post.2', + license='MIT', + + description='A database with a few fantasy books in it for testing query builders, orms, rest frameworks, etc.', + long_description=README, + url='https://github.com/tkellen/fantasy-database', + author='Tyler Kellen', + author_email='tyler@sleekcode.net', + package_dir={'': 'python'}, + py_modules=['setup_commands', 'utils'], + packages=find_packages('python', exclude=['tests']), + + tests_require=['django>=1.7'], + + cmdclass={ + 'build_py': BuildPy, + 'test': Test, + }, + + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Environment :: Web Environment', + 'Framework :: Django', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2.7', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Software Development :: Libraries :: Python Modules', + ], +)