From 8ceb98baf4f589e092e30df929cde3fa12f7b6db Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 24 Mar 2012 12:15:11 -0500 Subject: [PATCH] save point (see note) Added initial database schema and ability to install it, added init-db command to pyramid scaffold. --- edbob/__init__.py | 2 +- edbob/commands.py | 2 +- edbob/db/__init__.py | 87 ++-- edbob/db/auth.py | 43 +- edbob/db/enum.py | 36 ++ edbob/db/exceptions.py | 65 +++ edbob/db/extensions.py | 426 ++++++++++++++++++ edbob/db/mappers.py | 13 +- edbob/db/model.py | 14 +- edbob/db/perms.py | 84 ---- edbob/db/schema/README | 4 + edbob/db/schema/__init__.py | 0 edbob/db/schema/manage.py | 5 + edbob/db/schema/migrate.cfg | 25 + edbob/db/schema/versions/__init__.py | 0 edbob/db/util.py | 163 +++++++ edbob/initialization.py | 35 +- .../edbob/+package+/__init__.py_tmpl | 9 +- .../edbob/+package+/commands.py_tmpl | 82 ++++ .../scaffolds/edbob/+package+/db.py_tmpl | 11 + edbob/pyramid/scaffolds/edbob/README.txt_tmpl | 12 +- .../scaffolds/edbob/development.ini_tmpl | 3 + .../scaffolds/edbob/production.ini_tmpl | 8 +- edbob/pyramid/scaffolds/edbob/setup.py_tmpl | 13 + edbob/pyramid/subscribers.py | 43 +- edbob/pyramid/views/__init__.py | 26 ++ edbob/sqlalchemy.py | 35 ++ edbob/{times.py => time.py} | 42 +- setup.py | 19 +- 29 files changed, 1116 insertions(+), 191 deletions(-) create mode 100644 edbob/db/enum.py create mode 100644 edbob/db/exceptions.py create mode 100644 edbob/db/extensions.py delete mode 100644 edbob/db/perms.py create mode 100644 edbob/db/schema/README create mode 100644 edbob/db/schema/__init__.py create mode 100644 edbob/db/schema/manage.py create mode 100644 edbob/db/schema/migrate.cfg create mode 100644 edbob/db/schema/versions/__init__.py create mode 100644 edbob/db/util.py create mode 100644 edbob/pyramid/scaffolds/edbob/+package+/commands.py_tmpl create mode 100644 edbob/pyramid/scaffolds/edbob/+package+/db.py_tmpl rename edbob/{times.py => time.py} (75%) diff --git a/edbob/__init__.py b/edbob/__init__.py index 39a1e43..aa2b830 100644 --- a/edbob/__init__.py +++ b/edbob/__init__.py @@ -29,7 +29,7 @@ from edbob._version import __version__ from edbob.core import * -from edbob.times import * +from edbob.time import * from edbob.files import * from edbob.modules import * from edbob.configuration import * diff --git a/edbob/commands.py b/edbob/commands.py index 900adb1..8b3cfd2 100644 --- a/edbob/commands.py +++ b/edbob/commands.py @@ -157,7 +157,7 @@ Try '%(name)s help ' for more help.""" % self # Initialize everything... if not args.no_init: - edbob.init(*(args.config_paths or [])) + edbob.init(self.name, *(args.config_paths or [])) # Command line logging flags should override config. if args.verbose: diff --git a/edbob/db/__init__.py b/edbob/db/__init__.py index b498266..b766b72 100644 --- a/edbob/db/__init__.py +++ b/edbob/db/__init__.py @@ -32,52 +32,77 @@ from sqlalchemy.orm import sessionmaker import edbob +__all__ = ['engines', 'engine', 'Session', 'metadata', + 'get_setting', 'save_setting'] + +inited = False +engines = None +engine = None Session = sessionmaker() +metadata = None -def init(): +def init(config): """ - Called whenever ``'edbob.db'`` is configured to be auto-initialized. + Initializes the database connection(s); called by :func:`edbob.init()` if + config includes something like:: - This function is responsible for establishing the primary database engine - (a ``sqlalchemy.Engine`` instance, read from config), and extending the - root ``edbob`` namespace with the ORM classes (``Person``, ``User``, etc.), - as well as a few other things, e.g. ``engine``, ``Session`` and - ``metadata``. + .. highlight:: ini - In addition to this, if a connection to the primary database can be - obtained, it will be consulted to see which extensions are active within - it. If any are found, edbob's ORM will be extended in-place accordingly. + [edbob] + init = ['edbob.db'] + + [edbob.db] + sqlalchemy.urls = { + 'default': 'postgresql://user:pass@localhost/edbob, + } + + This function reads connection info from ``config`` and builds a dictionary + or :class:`sqlalchemy.Engine` instances accordingly. It also extends the + root ``edbob`` namespace with the ORM classes (:class:`edbob.Person`, + :class:`edbob.User`, etc.), as well as a few other things + (e.g. :attr:`edbob.engine`, :attr:`edbob.Session`, :attr:`edbob.metadata`). """ - config = edbob.config.get_dict('edbob.db') - engine = engine_from_config(config) - edbob.graft(edbob, locals(), 'engine') - - Session.configure(bind=engine) - edbob.graft(edbob, globals(), 'Session') - + import edbob.db + from edbob.db import classes + from edbob.db import enum from edbob.db.model import get_metadata - metadata = get_metadata(bind=engine) - edbob.graft(edbob, locals(), 'metadata') - from edbob.db.mappers import make_mappers - make_mappers(metadata) + from edbob.db.extensions import extend_framework - from edbob.db.ext import extend_framework + global inited, engines, engine, metadata + + keys = config.get('edbob.db', 'sqlalchemy.keys') + if keys: + keys = keys.split() + else: + keys = ['default'] + + engines = {} + cfg = config.get_dict('edbob.db') + for key in keys: + try: + engines[key] = engine_from_config(cfg, 'sqlalchemy.%s.' % key) + except KeyError: + if key == 'default': + try: + engines[key] = engine_from_config(cfg) + except KeyError: + pass + + engine = engines.get('default') + if engine: + Session.configure(bind=engine) + + metadata = get_metadata(bind=engine) + make_mappers(metadata) extend_framework() - # Note that we extend the framework before we graft the 'classes' module - # contents, since extensions may graft things to that module. - import edbob.db.classes as classes + edbob.graft(edbob, edbob.db) edbob.graft(edbob, classes) - - # Same goes for the enum module. - import edbob.db.enum as enum edbob.graft(edbob, enum) - - # Add settings functions. - edbob.graft(edbob, globals(), ('get_setting', 'save_setting')) + inited = True def get_setting(name, session=None): diff --git a/edbob/db/auth.py b/edbob/db/auth.py index 6669ed9..f988c4e 100644 --- a/edbob/db/auth.py +++ b/edbob/db/auth.py @@ -26,15 +26,48 @@ ``edbob.db.auth`` -- Authentication & Authorization """ +import bcrypt + from sqlalchemy.orm import object_session -from edbob.db.classes import Role, User +import edbob +from edbob.db import needs_session +from edbob.db.classes import Permission, Role, User -def get_administrator(session): +class BcryptAuthenticator(edbob.Object): """ - Returns a :class:`edbob.Role` instance representing the "Administrator" - role, attached to the given ``session``. + Authentication with py-bcrypt (Blowfish). + """ + + def populate_user(self, user, password): + user.salt = bcrypt.gensalt() + user.password = bcrypt.hashpw(password, user.salt) + + def authenticate_user(self, user, password): + return bcrypt.hashpw(password, user.salt) == user.password + + +@needs_session +def authenticate_user(session, username, password): + """ + Attempts to authenticate with ``username`` and ``password``. If successful, + returns the :class:`edbob.User` instance; otherwise returns ``None``. + """ + + user = session.query(User).filter_by(username=username).first() + if not user: + return None + auth = BcryptAuthenticator() + if not auth.authenticate_user(user, password): + return None + return user + + +def administrator_role(session): + """ + Returns the "Administrator" :class:`edbob.Role` instance, attached to the + given ``session``. """ uuid = 'd937fa8a965611dfa0dd001143047286' @@ -59,7 +92,7 @@ def has_permission(obj, perm): elif isinstance(obj, Role): roles = [obj] else: - raise TypeError, "You must pass either a User or Role for 'obj'; got: %s" % repr(obj) + raise TypeError("You must pass either a User or Role for 'obj'; got: %s" % repr(obj)) session = object_session(obj) assert session admin = get_administrator(session) diff --git a/edbob/db/enum.py b/edbob/db/enum.py new file mode 100644 index 0000000..d2d11b8 --- /dev/null +++ b/edbob/db/enum.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# edbob -- Pythonic Software Framework +# Copyright © 2010-2012 Lance Edgar +# +# This file is part of edbob. +# +# edbob is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# edbob is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with edbob. If not, see . +# +################################################################################ + +""" +``edbob.db.enum`` -- Enumerations +""" + + +USER_ACTIVE = 1 +USER_INACTIVE = 2 + +USER_STATUS = { + USER_ACTIVE : "active", + USER_INACTIVE : "inactive", + } diff --git a/edbob/db/exceptions.py b/edbob/db/exceptions.py new file mode 100644 index 0000000..c745d3c --- /dev/null +++ b/edbob/db/exceptions.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# edbob -- Pythonic Software Framework +# Copyright © 2010-2012 Lance Edgar +# +# This file is part of edbob. +# +# edbob is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# edbob is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with edbob. If not, see . +# +################################################################################ + +""" +``edbob.db.exceptions`` -- Database Exceptions +""" + + +class CoreSchemaAlreadyInstalled(Exception): + """ + Raised when a request is made to install the core schema to a database, but + it is already installed there. + """ + + def __init__(self, installed_version): + self.installed_version = installed_version + + def __str__(self): + return "Core schema already installed (version %s)" % self.installed_version + + +class CoreSchemaNotInstalled(Exception): + """ + Raised when a request is made which requires the core schema to be present + in a database, yet such is not the case. + """ + + def __init__(self, engine): + self.engine = engine + + def __str__(self): + return "Core schema not installed: %s" % str(self.engine) + + +class ExtensionNotFound(Exception): + """ + Raised when an extension is requested which cannot be located. + """ + + def __init__(self, name): + self.name = name + + def __str__(self): + return "Extension not found: %s" % self.name diff --git a/edbob/db/extensions.py b/edbob/db/extensions.py new file mode 100644 index 0000000..cc27b63 --- /dev/null +++ b/edbob/db/extensions.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# edbob -- Pythonic Software Framework +# Copyright © 2010-2012 Lance Edgar +# +# This file is part of edbob. +# +# edbob is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# edbob is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with edbob. If not, see . +# +################################################################################ + +""" +``edbob.db.extensions`` -- Database Extensions +""" + +import logging +# from pkg_resources import iter_entry_points + +import sqlalchemy.exc +# from sqlalchemy.orm import clear_mappers + +# import migrate.versioning.api +# from migrate.versioning.schema import ControlledSchema + +# import rattail +# from rattail.db import exc as exceptions +# from rattail.db import Session +# from rattail.db.classes import ActiveExtension +# from rattail.db.mappers import make_mappers +# from rattail.db.model import get_metadata +# from rattail.db.util import get_repository_path, get_repository_version + +import edbob +import edbob.db +from edbob.db import exceptions +from edbob.db import Session +from edbob.db.classes import ActiveExtension +from edbob.db.util import get_database_version +from edbob.util import requires_impl + + +log = logging.getLogger(__name__) + +available_extensions = edbob.entry_point_map('edbob.db.extensions') +active_extensions = {} + + +class Extension(edbob.Object): + """ + Base class for schema/ORM extensions. + """ + + # Set this to a list of strings (extension names) as needed within your + # derived class. + required_extensions = [] + + @property + @requires_impl(is_property=True) + def name(self): + """ + The name of the extension. + """ + pass + + @property + @requires_impl(is_property=True) + def schema(self): + """ + Should return a reference to the extension's ``schema`` module, which + is assumed to be a SQLAlchemy-Migrate repository. + """ + pass + + def add_class(self, cls): + """ + Convenience method for use in :meth:`extend_classes()`. + """ + + from edbob.db import classes + + name = cls.__name__ + edbob.graft(classes, {name:cls}, name) + + def extend_classes(self): + """ + Any extra classes provided by the extension should be added to the ORM + whenever this method is called. + + Note that the :meth:`add_class()` convenience method is designed to be + used when adding classes. + """ + pass + + def extend_mappers(self, metadata): + """ + All SQLAlchemy mapping to be done by the extension should be done + within this method. + + Any extra classes the extension provides will typically be mapped here. + Any manipulation the extension needs to perform on the ``edbob`` core + ORM should be done here as well. + """ + pass + + def get_metadata(self): + """ + Should return a :class:`sqlalchemy.MetaData` instance containing the + schema definition for the extension, or ``None``. + """ + + return None + + def remove_class(self, name): + """ + Convenience method for use in :meth:`restore_classes()`. + """ + + from edbob.db import classes + + if name in classes.__all__: + classes.__all__.remove(name) + if hasattr(classes, name): + del classes.__dict__[name] + + def restore_classes(self): + """ + This method should remove any extra classes which were added within + :meth:`extend_classes()`. Note that there is a :meth:`remove_class()` + method for convenience in doing so. + """ + pass + + +# def activate_extension(extension, engine=None): +# """ +# Activates the :class:`RattailExtension` instance represented by +# ``extension`` (which can be the actual instance, or the extension's name) +# by installing its schema and registering it within the database, and +# immediately applies it to the current ORM API. + +# If ``engine`` is not provided, then ``rattail.engine`` is assumed. +# """ + +# if engine is None: +# engine = rattail.engine + +# if not isinstance(extension, RattailExtension): +# extension = get_extension(extension) + +# log.info("Activating extension: %s" % extension.name) +# install_extension_schema(extension, engine) + +# session = Session(bind=engine) +# if not session.query(ActiveExtension).get(extension.name): +# session.add(ActiveExtension(name=extension.name)) +# session.commit() +# session.close() + +# merge_extension_metadata(extension) +# extension.extend_classes() +# extension.extend_mappers(rattail.metadata) +# _active_extensions[extension.name] = extension + + +# def deactivate_extension(extension, engine=None): +# """ +# Uninstalls an extension's schema from the primary database, and immediately +# requests it to restore the ORM API. + +# If ``engine`` is not provided, then ``rattail.engine`` is assumed. +# """ + +# if engine is None: +# engine = rattail.engine + +# if not isinstance(extension, RattailExtension): +# extension = get_extension(extension) + +# log.info("Deactivating extension: %s" % extension.name) +# if extension.name in _active_extensions: +# del _active_extensions[extension.name] + +# session = Session() +# ext = session.query(ActiveExtension).get(extension.name) +# if ext: +# session.delete(ext) +# session.commit() +# session.close() + +# uninstall_extension_schema(extension, engine) +# unmerge_extension_metadata(extension) +# extension.restore_classes() + +# clear_mappers() +# make_mappers(rattail.metadata) +# for name in sorted(_active_extensions, extension_sorter(_active_extensions)): +# _active_extensions[name].extend_mappers(rattail.metadata) + + +def extend_framework(): + """ + Attempts to connect to the primary database and, if successful, inspects it + to determine which extensions are active within it. Any such extensions + found will be used to extend the ORM/API in-place. + """ + + engine = edbob.db.engine + + # Check primary database connection. + try: + engine.connect() + except sqlalchemy.exc.OperationalError: + return + + # Check database version to see if core schema is installed. + try: + db_version = get_database_version(engine) + except exceptions.CoreSchemaNotInstalled: + return + + # Since extensions may depend on one another, we must first retrieve the + # list of active extensions' names from the database and *then* sort them + # according to their stated dependencies. (This information is only known + # after instantiating the extensions.) + + session = Session() + try: + active_extensions = session.query(ActiveExtension).all() + except sqlalchemy.exc.ProgrammingError: + session.close() + return + + extensions = {} + for ext in active_extensions: + extensions[ext.name] = get_extension(ext.name) + session.close() + + for name in sorted(extensions, extension_sorter(extensions)): + ext = extensions[name] + log.info("Applying active extension: %s" % name) + merge_extension_metadata(ext) + ext.extend_classes() + ext.extend_mappers(rattail.metadata) + active_extensions[name] = ext + + +# def extension_active(extension): +# """ +# Returns boolean indicating whether or not the given ``extension`` is active +# within the current database. +# """ + +# if not isinstance(extension, RattailExtension): +# extension = get_extension(extension) +# return extension.name in _active_extensions + + +def extension_sorter(extensions): + """ + Returns a function to be used for sorting extensions according to their + inter-dependencies. ``extensions`` should be a dictionary containing the + extensions which are to be sorted. + """ + + def sorter(name_x, name_y): + ext_x = extensions[name_x] + ext_y = extensions[name_y] + + if name_y in ext_x.required_extensions: + return 1 + if name_x in ext_y.required_extensions: + return -1 + + if ext_x.required_extensions and not ext_y.required_extensions: + return 1 + if ext_y.required_extensions and not ext_x.required_extensions: + return -1 + return 0 + + return sorter + + +def get_extension(name): + """ + Returns a :class:`Extension` instance, according to ``name``. An error is + raised if the extension cannot be found. + """ + + if name in available_extensions: + return available_extensions[name]() + raise exceptions.ExtensionNotFound(name) + + +# def install_extension_schema(extension, engine=None): +# """ +# Installs an extension's schema to the database and adds version control for +# it. +# """ + +# if engine is None: +# engine = rattail.engine + +# # Extensionls aren't required to provide metadata... +# ext_meta = extension.get_metadata() +# if not ext_meta: +# return + +# # ...but if they do they must also provide a SQLAlchemy-Migrate repository! +# assert extension.schema, "Extension does not implement 'schema': %s" % extension.name + +# meta = rattail.metadata +# for table in meta.sorted_tables: +# table.tometadata(ext_meta) +# for table in ext_meta.sorted_tables: +# if table.name not in meta.tables: +# table.create(bind=engine, checkfirst=True) + +# migrate.versioning.api.version_control( +# str(engine.url), get_repository_path(extension), get_repository_version(extension)) + + +def merge_extension_metadata(ext): + """ + Merges an extension's metadata with the global ``edbob.db.metadata`` + instance. + + .. note:: + ``edbob`` uses this internally; you should not need to. + """ + + ext_meta = ext.get_metadata() + if not ext_meta: + return + meta = edbob.db.metadata + for table in meta.sorted_tables: + table.tometadata(ext_meta) + for table in ext_meta.sorted_tables: + if table.name not in meta.tables: + table.tometadata(meta) + + +# def uninstall_extension_schema(extension, engine=None): +# """ +# Uninstalls an extension's tables from the database represented by +# ``engine`` (or ``rattail.engine`` if none is provided), and removes +# SQLAlchemy-Migrate version control for the extension. +# """ + +# if engine is None: +# engine = rattail.engine + +# ext_meta = extension.get_metadata() +# if not ext_meta: +# return + +# schema = ControlledSchema(engine, get_repository_path(extension)) +# engine.execute(schema.table.delete().where( +# schema.table.c.repository_id == schema.repository.id)) + +# meta = get_metadata() +# for table in meta.sorted_tables: +# table.tometadata(ext_meta) +# for table in reversed(ext_meta.sorted_tables): +# if table.name not in meta.tables: +# table.drop(bind=engine) + + +# def unmerge_extension_metadata(extension): +# """ +# Removes an extension's metadata from the global ``rattail.metadata`` +# instance. +# """ + +# ext_meta = extension.get_metadata() +# if not ext_meta: +# return + +# meta = rattail.metadata +# ext_tables = ext_meta.tables.keys() +# for table in reversed(meta.sorted_tables): +# if table.name in ext_tables: +# meta.remove(table) + + +# # def merge_extension_permissions(extension): +# # ''' +# # Helper function to merge an extension's permission definitions with those of +# # the framework. (This should only be called by the framework itself.) +# # ''' +# # from rattail.v1.perms import permissions +# # log.debug('Merging permissions from extension: %s' % extension.name) +# # for group_name in extension.permissions: +# # if group_name not in permissions: +# # permissions[group_name] = extension.permissions[group_name] +# # elif extension.permissions[group_name][0] != permissions[group_name][0]: +# # log.warning("Extension '%s' tries to override UUID of permission group '%s' (but is denied)" % ( +# # extension.name, group_name)) +# # else: +# # # Extensions may override permission group display names. +# # if extension.permissions[group_name][1]: +# # permissions[group_name][1] = extension.permissions[group_name][1] +# # perms = permissions[group_name][2] +# # ext_perms = extension.permissions[group_name][2] +# # for perm_name in ext_perms: +# # if perm_name not in perms: +# # perms[perm_name] = ext_perms[perm_name] +# # elif ext_perms[perm_name][0] != perms[perm_name][0]: +# # log.warning("Extension '%s' tries to override UUID of permission '%s' (but is denied)" % ( +# # extension.name, '.'.join((group_name, perm_name)))) +# # else: +# # # Extensions may override permission display names. +# # if ext_perms[perm_name][1]: +# # perms[perm_name][1] = ext_perms[perm_name][1] diff --git a/edbob/db/mappers.py b/edbob/db/mappers.py index 2691d61..d66b269 100644 --- a/edbob/db/mappers.py +++ b/edbob/db/mappers.py @@ -28,7 +28,7 @@ from sqlalchemy.orm import mapper, relationship -import edbob.db.classes as c +from edbob.db import classes as c def make_mappers(metadata): @@ -60,17 +60,6 @@ def make_mappers(metadata): c.Person, t['people'], properties=dict( - customers=relationship( - c.Customer, - backref='person', - ), - - employee=relationship( - c.Employee, - back_populates='person', - uselist=False, - ), - user=relationship( c.User, back_populates='person', diff --git a/edbob/db/model.py b/edbob/db/model.py index 92c4942..cf3e574 100644 --- a/edbob/db/model.py +++ b/edbob/db/model.py @@ -28,7 +28,7 @@ from sqlalchemy import * -from edbob import get_uuid +from edbob.sqlalchemy import table_with_uuid def get_metadata(*args, **kwargs): @@ -66,9 +66,8 @@ def get_metadata(*args, **kwargs): return None return '%(first_name)s %(last_name)s' % locals() - people = Table( + people = table_with_uuid( 'people', metadata, - Column('uuid', String(32), primary_key=True, default=get_uuid), Column('first_name', String(50)), Column('last_name', String(50)), Column('display_name', String(100), default=get_person_display_name), @@ -80,9 +79,8 @@ def get_metadata(*args, **kwargs): Column('permission', String(50), primary_key=True), ) - roles = Table( + roles = table_with_uuid( 'roles', metadata, - Column('uuid', String(32), primary_key=True, default=get_uuid), Column('name', String(25), nullable=False, unique=True), ) @@ -92,16 +90,14 @@ def get_metadata(*args, **kwargs): Column('value', Text), ) - users = Table( + users = table_with_uuid( 'users', metadata, - Column('uuid', String(32), primary_key=True, default=get_uuid), Column('username', String(25), nullable=False, unique=True), Column('person_uuid', String(32), ForeignKey('people.uuid')), ) - users_roles = Table( + users_roles = table_with_uuid( 'users_roles', metadata, - Column('uuid', String(32), primary_key=True, default=get_uuid), Column('user_uuid', String(32), ForeignKey('users.uuid')), Column('role_uuid', String(32), ForeignKey('roles.uuid')), ) diff --git a/edbob/db/perms.py b/edbob/db/perms.py deleted file mode 100644 index 379af7b..0000000 --- a/edbob/db/perms.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -################################################################################ -# -# edbob -- Pythonic Software Framework -# Copyright © 2010-2012 Lance Edgar -# -# This file is part of edbob. -# -# edbob is free software: you can redistribute it and/or modify it under the -# terms of the GNU Affero General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# edbob is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for -# more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with edbob. If not, see . -# -################################################################################ - -""" -``edbob.db.perms`` -- Roles & Permissions -""" - -from sqlalchemy.orm import object_session - -from edbob.db.classes import Role, User, Permission - - -def get_administrator(session): - """ - Returns the "Administrator" :class:`rattail.db.classes.Role` instance, - attached to the given ``session``. - """ - - uuid = 'd937fa8a965611dfa0dd001143047286' - admin = session.query(Role).get(uuid) - if admin: - return admin - admin = Role(uuid=uuid, name='Administrator') - session.add(admin) - return admin - - -# def has_permission(object_, permission, session=None): -# ''' -# Checks the given ``object_`` (which may be either a :class:`rattail.v1.User` or -# a :class:`rattail.v1.Role`) and returns a boolean indicating whether or not the -# object is allowed the given permission. ``permission`` may be either a -# :class:`rattail.v1.Permission` instance, or the fully-qualified name of one. - -# If ``object_`` is ``None``, the permission check is made against the special -# "(Anybody)" role. -# ''' - -def has_permission(obj, perm): - """ - Checks the given ``obj`` (which may be either a - :class:`rattail.db.classes.User`` or :class:`rattail.db.classes.Role` - instance), and returns a boolean indicating whether or not the object is - allowed the given permission. ``perm`` should be a fully-qualified - permission name, e.g. ``'employees.admin'``. - """ - - if isinstance(obj, User): - roles = obj.roles - elif isinstance(obj, Role): - roles = [obj] - else: - raise TypeError, "You must pass either a User or Role for 'obj'; got: %s" % repr(obj) - session = object_session(obj) - assert session - admin = get_administrator(session) - for role in roles: - if role is admin: - return True - for permission in role.permissions: - if permission == perm: - return True - return False diff --git a/edbob/db/schema/README b/edbob/db/schema/README new file mode 100644 index 0000000..6218f8c --- /dev/null +++ b/edbob/db/schema/README @@ -0,0 +1,4 @@ +This is a database migration repository. + +More information at +http://code.google.com/p/sqlalchemy-migrate/ diff --git a/edbob/db/schema/__init__.py b/edbob/db/schema/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/edbob/db/schema/manage.py b/edbob/db/schema/manage.py new file mode 100644 index 0000000..c3209f2 --- /dev/null +++ b/edbob/db/schema/manage.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +from migrate.versioning.shell import main + +if __name__ == '__main__': + main(debug='False') diff --git a/edbob/db/schema/migrate.cfg b/edbob/db/schema/migrate.cfg new file mode 100644 index 0000000..46e9c87 --- /dev/null +++ b/edbob/db/schema/migrate.cfg @@ -0,0 +1,25 @@ +[db_settings] +# Used to identify which repository this database is versioned under. +# You can use the name of your project. +repository_id=edbob + +# The name of the database table used to track the schema version. +# This name shouldn't already be used by your project. +# If this is changed once a database is under version control, you'll need to +# change the table name in each database too. +version_table=migrate_version + +# When committing a change script, Migrate will attempt to generate the +# sql for all supported databases; normally, if one of them fails - probably +# because you don't have that database installed - it is ignored and the +# commit continues, perhaps ending successfully. +# Databases in this list MUST compile successfully during a commit, or the +# entire commit will fail. List the databases your application will actually +# be using to ensure your updates to that database work properly. +# This must be a list; example: ['postgres','sqlite'] +required_dbs=[] + +# When creating new change scripts, Migrate will stamp the new script with +# a version number. By default this is latest_version + 1. You can set this +# to 'true' to tell Migrate to use the UTC timestamp instead. +use_timestamp_numbering=False diff --git a/edbob/db/schema/versions/__init__.py b/edbob/db/schema/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/edbob/db/util.py b/edbob/db/util.py new file mode 100644 index 0000000..daf87a8 --- /dev/null +++ b/edbob/db/util.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# edbob -- Pythonic Software Framework +# Copyright © 2010-2012 Lance Edgar +# +# This file is part of edbob. +# +# edbob is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# edbob is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with edbob. If not, see . +# +################################################################################ + +""" +``edbob.db.util`` -- Database Utilities +""" + +import os.path + +import sqlalchemy.exc +import migrate.versioning.api +import migrate.exceptions + +# import rattail +# from rattail.db import exc as exceptions +# from rattail.db import Session +# from rattail.db.classes import Role +# from rattail.db.model import get_metadata +# from rattail.db.perms import get_administrator + +import edbob.db +from edbob.db import exceptions +from edbob.db.model import get_metadata + + +# def core_schema_installed(engine=None): +# """ +# Returns boolean indicating whether or not the core schema has been +# installed to the database represented by ``engine``. If ``engine`` is not +# provided, then ``rattail.engine`` will be assumed. +# """ + +# if engine is None: +# engine = rattail.engine + +# try: +# get_database_version(engine) +# except exceptions.CoreSchemaNotInstalled: +# return False +# return True + + +def get_database_version(engine=None, extension=None): + """ + Returns a SQLAlchemy-Migrate version number found in the database + represented by ``engine``. + + If no engine is provided, :attr:`edbob.db.engine` is assumed. + + If ``extension`` is provided, the version for its schema is returned; + otherwise the core schema is assumed. + """ + + if engine is None: + engine = edbob.db.engine + + try: + version = migrate.versioning.api.db_version( + str(engine.url), get_repository_path(extension)) + + except (sqlalchemy.exc.NoSuchTableError, + migrate.exceptions.DatabaseNotControlledError): + raise exceptions.CoreSchemaNotInstalled(engine) + + return version + + +def get_repository_path(extension=None): + """ + Returns the absolute filesystem path to the SQLAlchemy-Migrate repository + for ``extension``. + + If no extension is provided, ``edbob``'s core repository is assumed. + """ + + if not extension: + from edbob.db import schema + return os.path.dirname(schema.__file__) + + return os.path.dirname(extension.schema.__file__) + + +def get_repository_version(extension=None): + """ + Returns the version of the SQLAlchemy-Migrate repository for ``extension``. + + If no extension is provided, ``edbob``'s core repository is assumed. + """ + + return migrate.versioning.api.version(get_repository_path(extension)) + + +def install_core_schema(engine=None): + """ + Installs the core schema to the database represented by ``engine``. + + If no engine is provided, :attr:`edbob.db.engine` is assumed. + """ + + if not engine: + engine = edbob.db.engine + + # Try to connect in order to force an error, if applicable. + conn = engine.connect() + conn.close() + + # Check DB version to see if core schema is already installed. + try: + db_version = get_database_version(engine) + except exceptions.CoreSchemaNotInstalled: + pass + else: + raise exceptions.CoreSchemaAlreadyInstalled(db_version) + + # Create tables for core schema. + metadata = get_metadata() + metadata.create_all(bind=engine) + + # Add versioning for core schema. + migrate.versioning.api.version_control( + str(engine.url), get_repository_path(), get_repository_version()) + + # WTF + # session = Session(bind=engine) + # get_administrator(session) + # session.commit() + # session.close() + + +# def upgrade_schema(extension=None, engine=None): +# """ +# Upgrades a schema within the database represented by ``engine`` (or +# ``rattail.engine`` if none is provided). If ``extension`` is provided, +# then its schema will be upgraded; otherwise the core is assumed. +# """ + +# if engine is None: +# engine = rattail.engine +# repo_version = get_repository_version(extension) +# db_version = get_database_version(engine, extension) +# if db_version < repo_version: +# migrate.versioning.api.upgrade(str(engine.url), get_repository_path(extension), repo_version) diff --git a/edbob/initialization.py b/edbob/initialization.py index b513733..e27c7fb 100644 --- a/edbob/initialization.py +++ b/edbob/initialization.py @@ -27,13 +27,14 @@ """ import os -# import locale import logging -from edbob.configuration import AppConfigParser -from edbob.configuration import default_system_paths, default_user_paths -from edbob.core import graft -from edbob.times import set_timezone +import edbob +from edbob.configuration import ( + AppConfigParser, + default_system_paths, + default_user_paths, + ) __all__ = ['init'] @@ -83,19 +84,15 @@ def init(appname='edbob', *args, **kwargs): config.read(paths, recurse=not shell) config.configure_logging() - # loc = config.get('edbob', 'locale') - # if loc: - # locale.setlocale(locale.LC_ALL, loc) - # log.info("Set locale to '%s'" % loc) + default_modules = 'edbob.time' + modules = config.get('edbob', 'init', default=default_modules) + if modules: + for name in modules.split(','): + name = name.strip() + module = __import__(name, globals(), locals(), fromlist=['init']) + getattr(module, 'init')(config) + # config.inited.append(name) - tz = config.get('edbob', 'timezone') - if tz: - set_timezone(tz) - log.info("Set timezone to '%s'" % tz) - else: - log.warning("No timezone configured; falling back to US/Central") - set_timezone('US/Central') - - import edbob - graft(edbob, locals(), 'config') + # config.inited.append('edbob') + edbob.graft(edbob, locals(), 'config') edbob.inited = True diff --git a/edbob/pyramid/scaffolds/edbob/+package+/__init__.py_tmpl b/edbob/pyramid/scaffolds/edbob/+package+/__init__.py_tmpl index fb5d03d..d2f0770 100644 --- a/edbob/pyramid/scaffolds/edbob/+package+/__init__.py_tmpl +++ b/edbob/pyramid/scaffolds/edbob/+package+/__init__.py_tmpl @@ -5,10 +5,15 @@ """ import os.path -import edbob + import pyramid_beaker from pyramid.config import Configurator +import edbob + +from {{package}}._version import __version__ +from {{package}}.db import DBSession + def main(global_config, **settings): """ @@ -39,7 +44,7 @@ def main(global_config, **settings): config.set_session_factory(session_factory) pyramid_beaker.set_cache_regions_from_settings(settings) - # Initialize edbob + # Configure edbob edbob.basic_logging() edbob.init('{{package}}', os.path.abspath(settings['edbob.config'])) diff --git a/edbob/pyramid/scaffolds/edbob/+package+/commands.py_tmpl b/edbob/pyramid/scaffolds/edbob/+package+/commands.py_tmpl new file mode 100644 index 0000000..af0c11f --- /dev/null +++ b/edbob/pyramid/scaffolds/edbob/+package+/commands.py_tmpl @@ -0,0 +1,82 @@ +#!/usr/bin/env python + +""" +``{{package}}.commands`` -- Console Commands +""" + +import sys + +import edbob +from edbob import commands + +from {{package}} import __version__ + + +class Command(commands.Command): + """ + The primary command for {{project}}. + """ + + name = '{{package}}' + version = __version__ + description = "{{project}}" + long_description = '' + + +class InitDatabaseCommand(commands.Subcommand): + """ + Initializes the database. This is meant to be leveraged as part of setting + up the application. The database used by this command will be determined + by config, for example:: + + .. highlight:: ini + + [edbob.db] + sqlalchemy.url = postgresql://user:pass@localhost/{{package}} + """ + + name = 'init-db' + description = "Initialize the database" + + def run(self, args): + from edbob.db import engine, Session + from edbob.db.util import install_core_schema + from edbob.db.exceptions import CoreSchemaAlreadyInstalled + + # Install core schema to database. + try: + install_core_schema(engine) + except CoreSchemaAlreadyInstalled, err: + print err + return + + from edbob.db.classes import Role, User + from edbob.db.auth import administrator_role + + session = Session() + + # Create 'admin' user with full rights. + admin = User(username='admin', password='admin') + admin.roles.append(administrator_role(session)) + session.add(admin) + + # Do any other bootstrapping you like here... + + session.commit() + session.close() + + print "Initialized database %s" % engine.url + + +def main(*args): + """ + The primary entry point for the command system. + """ + + if args: + args = list(args) + else: + args = sys.argv[1:] + + cmd = Command() + cmd.run(*args) diff --git a/edbob/pyramid/scaffolds/edbob/+package+/db.py_tmpl b/edbob/pyramid/scaffolds/edbob/+package+/db.py_tmpl new file mode 100644 index 0000000..8ccec8f --- /dev/null +++ b/edbob/pyramid/scaffolds/edbob/+package+/db.py_tmpl @@ -0,0 +1,11 @@ +#!/usr/bin/env python + +""" +``{{package}}.db`` -- Database Stuff +""" + +from sqlalchemy.orm import scoped_session, sessionmaker +from zope.sqlalchemy import ZopeTransactionExtension + + +DBSession = scoped_session(sessionmaker(extension=ZopeTransactionExtension())) diff --git a/edbob/pyramid/scaffolds/edbob/README.txt_tmpl b/edbob/pyramid/scaffolds/edbob/README.txt_tmpl index f777d91..7cfee7d 100644 --- a/edbob/pyramid/scaffolds/edbob/README.txt_tmpl +++ b/edbob/pyramid/scaffolds/edbob/README.txt_tmpl @@ -5,9 +5,13 @@ Welcome to the {{project}} project. -Installation ------------- +Getting Started +--------------- -Install the project with:: +- cd - $ pip install {{package}} +- $venv/bin/python setup.py develop + +- $venv/bin/populate_{{package}} development.ini + +- $venv/bin/pserve --reload development.ini diff --git a/edbob/pyramid/scaffolds/edbob/development.ini_tmpl b/edbob/pyramid/scaffolds/edbob/development.ini_tmpl index 79f1858..ffec311 100644 --- a/edbob/pyramid/scaffolds/edbob/development.ini_tmpl +++ b/edbob/pyramid/scaffolds/edbob/development.ini_tmpl @@ -51,6 +51,9 @@ port = 6543 [edbob] include_config = ['%(here)s/production.ini'] +[edbob.db] +sqlalchemy.url = sqlite:///{{package}}.sqlite + #################### # logging diff --git a/edbob/pyramid/scaffolds/edbob/production.ini_tmpl b/edbob/pyramid/scaffolds/edbob/production.ini_tmpl index 82f6c91..35a86b0 100644 --- a/edbob/pyramid/scaffolds/edbob/production.ini_tmpl +++ b/edbob/pyramid/scaffolds/edbob/production.ini_tmpl @@ -41,9 +41,12 @@ port = 6543 #################### [edbob] -timezone = US/Central +init = edbob.time, edbob.db # shell.python = ipython +[edbob.db] +sqlalchemy.url = postgresql://user:pass@localhost/{{package}} + [edbob.mail] smtp.server = localhost # smtp.username = user @@ -56,6 +59,9 @@ recipients.default = [ ] subject.default = Message from {{project}} +[edbob.time] +timezone = US/Central + #################### # logging diff --git a/edbob/pyramid/scaffolds/edbob/setup.py_tmpl b/edbob/pyramid/scaffolds/edbob/setup.py_tmpl index f981327..b95ff7f 100644 --- a/edbob/pyramid/scaffolds/edbob/setup.py_tmpl +++ b/edbob/pyramid/scaffolds/edbob/setup.py_tmpl @@ -39,12 +39,19 @@ requires = [ # # package # low high + 'decorator', # 3.3.2 'edbob', # 0.1a1 'Mako', # 0.6.2 'pyramid', # 1.3b2 + 'pyramid_beaker', # 0.6.1 'pyramid_debugtoolbar', # 1.0 + 'pyramid_tm', # 0.3 + 'SQLAlchemy', # 0.7.6 + 'Tempita', # 0.5.1 + 'transaction', # 1.2.0 'waitress', # 0.8.1 'WebHelpers', # 1.3 + 'zope.sqlalchemy', # 0.7 ] @@ -77,6 +84,12 @@ setup( zip_safe = False, entry_points = """ +[console_scripts] +{{package}} = {{package}}.commands:main + +[{{package}}.commands] +init-db = {{package}}.commands:InitDatabaseCommand + [paste.app_factory] main = {{package}}:main diff --git a/edbob/pyramid/subscribers.py b/edbob/pyramid/subscribers.py index a401385..60ee3eb 100644 --- a/edbob/pyramid/subscribers.py +++ b/edbob/pyramid/subscribers.py @@ -26,21 +26,56 @@ ``edbob.pyramid.subscribers`` -- Subscribers """ +from pyramid.security import authenticated_userid +# from sqlahelper import get_session + import edbob +from edbob.db.auth import has_permission from edbob.pyramid import helpers -def add_renderer_globals(event): +def before_render(event): """ - Adds goodies to the global template renderer context. + Adds goodies to the global template renderer context: + + * ``h`` + * ``url`` + * ``edbob`` """ renderer_globals = event renderer_globals['h'] = helpers - renderer_globals['edbob'] = edbob renderer_globals['url'] = event['request'].route_url + renderer_globals['edbob'] = edbob + + +def context_found(event): + """ + This hook attaches the :class:`edbob.User` instance for the currently + logged-in user to the request (if there is one) as ``request.user``. + + Also adds a ``has_perm()`` function to the request, which is a shortcut for + :func:`edbob.db.auth.has_permission()`. + """ + + def has_perm_func(request): + def has_perm(perm): + if not request.current_user: + return False + return has_permission(request.current_user, perm) + return has_perm + + request = event.request + request.user = None + request.has_perm = has_perm_func(request) + + uuid = authenticated_userid(request) + if uuid: + request.user = get_session().query(rattail.User).get(uuid) def includeme(config): - config.add_subscriber('edbob.pyramid.subscribers:add_renderer_globals', + config.add_subscriber('edbob.pyramid.subscribers:before_render', 'pyramid.events.BeforeRender') + config.add_subscriber('edbob.pyramid.subscribers.context_found', + 'pyramid.events.ContextFound') diff --git a/edbob/pyramid/views/__init__.py b/edbob/pyramid/views/__init__.py index 3cf3447..cfcc6b4 100644 --- a/edbob/pyramid/views/__init__.py +++ b/edbob/pyramid/views/__init__.py @@ -31,6 +31,9 @@ import os.path from pyramid.response import Response from pyramid.view import view_config +from pyramid.httpexceptions import HTTPFound + +from edbob.db.auth import authenticate_user _here = os.path.join(os.path.dirname(__file__), os.pardir) @@ -51,6 +54,29 @@ def home(context, request): @view_config(route_name='login', renderer='login.mako') def login(context, request): + """ + The login view, responsible for displaying and handling the login form. + """ + + if request.params.get('referer'): + referer = request.params['referer'] + elif request.session.get('referer'): + referer = request.session.pop('referer') + else: + referer = request.referer or request.route_url('home') + # if request.current_user: + # return HTTPFound(location=referer) + # form = Form(self.request, schema=UserLogin) + # if form.validate(): + # user = authenticate_user(self.Session(), form.data['username'], form.data['password']) + # if user: + # self.request.session.flash("%s logged in at %s" % ( + # user.display_name, + # datetime.datetime.now().strftime("%I:%M %p"))) + # headers = remember(self.request, user.uuid) + # return HTTPFound(location=referer, headers=headers) + # self.request.session.flash("Invalid username or password.") + # return {'form':FormRenderer(form), 'referer':referer} return {} diff --git a/edbob/sqlalchemy.py b/edbob/sqlalchemy.py index 061997b..4d88ae9 100644 --- a/edbob/sqlalchemy.py +++ b/edbob/sqlalchemy.py @@ -26,6 +26,15 @@ ``edbob.sqlalchemy`` -- SQLAlchemy Stuff """ +from __future__ import absolute_import + +from sqlalchemy import Table, Column, String + +from edbob.core import get_uuid + + +__all__ = ['getset_factory', 'table_with_uuid'] + def getset_factory(collection_class, proxy): """ @@ -37,3 +46,29 @@ def getset_factory(collection_class, proxy): return getattr(obj, proxy.value_attr) setter = lambda obj, val: setattr(obj, proxy.value_attr, val) return getter, setter + + +def table_with_uuid(name, metadata, *args, **kwargs): + """ + Convenience function to abstract the addition of the ``uuid`` column to a + new table. Can be used to replace this:: + + .. highlight:: python + + Table( + 'things', metadata, + Column('uuid', String(32), primary_key=True, default=get_uuid), + Column('name', String(50)), + ) + + ...with this:: + + table_with_uuid( + 'things', metadata, + Column('name', String(50)), + ) + """ + + return Table(name, metadata, + Column('uuid', String(32), primary_key=True, default=get_uuid), + *args, **kwargs) diff --git a/edbob/times.py b/edbob/time.py similarity index 75% rename from edbob/times.py rename to edbob/time.py index 226a489..550ce2c 100644 --- a/edbob/times.py +++ b/edbob/time.py @@ -23,17 +23,35 @@ ################################################################################ """ -``edbob.times`` -- Date & Time Utilities +``edbob.time`` -- Date & Time Utilities """ import datetime import pytz +import logging __all__ = ['local_time', 'set_timezone', 'utc_time'] -_timezone = None +log = logging.getLogger(__name__) +timezone = None + + +def init(config): + """ + Initializes the time framework. Currently this only sets the local + timezone according to config. + """ + + tz = config.get('edbob.time', 'timezone') + if tz: + set_timezone(tz) + log.info("Set timezone to '%s'" % tz) + else: + log.warning("No timezone configured; falling back to US/Central") + set_timezone('US/Central') + def local_time(timestamp=None): """ @@ -51,11 +69,11 @@ def local_time(timestamp=None): should none be specified. ``timestamp`` will be returned unchanged. """ - if _timezone: + if timezone: if timestamp is None: timestamp = datetime.datetime.utcnow() timestamp = pytz.utc.localize(timestamp) - return timestamp.astimezone(_timezone) + return timestamp.astimezone(timezone) if timestamp is None: timestamp = datetime.datetime.now() @@ -67,21 +85,25 @@ def set_timezone(tz): Sets edbob's notion of the "local" timezone. ``tz`` should be an Olson name. - .. highlight:: ini - You usually don't need to call this yourself, since it's called by - :func:`edbob.init()` whenever ``edbob.conf`` includes a timezone:: + :func:`edbob.init()` whenever the config file includes a timezone (but + only as long as ``edbob.time`` is configured to be initialized):: + + .. highlight:: ini [edbob] + init = ['edbob.time'] + + [edbob.time] timezone = US/Central """ - global _timezone + global timezone if tz is None: - _timezone = None + timezone = None else: - _timezone = pytz.timezone(tz) + timezone = pytz.timezone(tz) def utc_time(timestamp=None): diff --git a/setup.py b/setup.py index 636b07d..19d6b03 100644 --- a/setup.py +++ b/setup.py @@ -99,15 +99,18 @@ setup( extras_require = { # - # Same guidelines apply to the extra dependencies: + # Same guidelines apply to the extra dependency versions. - # 'db': [ - # # - # # package # low high - # # - # 'SQLAlchemy', # 0.6.7 - # 'sqlalchemy-migrate', # 0.6.1 - # ], + 'db': [ + # + # package # low high + # + 'decorator', # 3.3.2 + 'py-bcrypt', # 0.2 + 'SQLAlchemy', # 0.7.6 + 'sqlalchemy-migrate', # 0.7.2 + 'Tempita', # 0.5.1 + ], 'docs': [ #