diff --git a/docs/.static/placeholder.txt b/docs/.static/placeholder.txt new file mode 100644 index 0000000..e69de29 diff --git a/docs/.templates/placeholder.txt b/docs/.templates/placeholder.txt new file mode 100644 index 0000000..e69de29 diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..8677d0b --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys +sys.path.insert(0, os.path.abspath('../..')) + + +# -- Project information ----------------------------------------------------- + +project = 'PyTest-Flask-SQLAlchemy' +copyright = '2019, Jean Cochrane and DataMade. Released under the MIT License.' +author = 'Jean Cochrane' + +# The short X.Y version +version = '1.0' +# The full version, including alpha/beta/rc tags +release = '1.0.2' + + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + # Cross Referencing + 'sphinx.ext.intersphinx', + 'sphinx.ext.autosectionlabel', # Explicit Referencing e.g. DOCUMENT:SECTION + # Code Documentation + 'sphinx.ext.viewcode', + 'sphinx.ext.autodoc', + 'sphinx.ext.doctest', + # Project Management + 'sphinx.ext.todo', + 'sphinx.ext.coverage', + 'sphinx.ext.githubpages', + # Typography + 'sphinx.ext.mathjax', + # Document Control + 'sphinx.ext.ifconfig', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['.templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path . +exclude_patterns = [] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# -- Auto-Section Label Configuration ---------------------------------------- +autosectionlabel_prefix_document = True + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +html_theme_options = { + # 'logo': 'logo.png', + 'github_user': 'jeancochrane', + 'github_repo': 'pytest-flask-sqlalchemy', + 'github_banner': 'true', + 'travis_button': 'true', +} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['.static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = 'PyTest-Flask-SQLAlchemydoc' + + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'PyTest-Flask-SQLAlchemy.tex', 'PyTest-Flask-SQLAlchemy Documentation', + 'Jean Cochrane', 'manual'), +] + + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'pytest-flask-sqlalchemy', 'PyTest-Flask-SQLAlchemy Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'PyTest-Flask-SQLAlchemy', 'PyTest-Flask-SQLAlchemy Documentation', + author, 'PyTest-Flask-SQLAlchemy', 'One line description of project.', + 'Miscellaneous'), +] + + +# -- Options for Epub output ------------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project +epub_author = author +epub_publisher = author +epub_copyright = copyright + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +# +# epub_identifier = '' + +# A unique identification for the text. +# +# epub_uid = '' + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ['search.html'] + + +# -- Extension configuration ------------------------------------------------- + +# -- Options for intersphinx extension --------------------------------------- + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'https://docs.python.org/': None} + +# -- Options for todo extension ---------------------------------------------- + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = True \ No newline at end of file diff --git a/docs/databases.rst b/docs/databases.rst new file mode 100644 index 0000000..f78de9e --- /dev/null +++ b/docs/databases.rst @@ -0,0 +1,11 @@ +--------- +Databases +--------- +.. Originally : Supported Back Ends + +So far, pytest-flask-sqlalchemy has been most extensively tested against PostgreSQL 9.6. +It should theoretically work with any backend that is supported by SQLAlchemy, but Postgres is the only backend that is currently tested by the test suite. + +Official support for SQLite and MySQL is `planned for a future release `_. +In the meantime, if you're using one of those backends and you run in to problems, we would greatly appreciate your help! `Open an issue `_ if something isn't working as you expect. + diff --git a/docs/design.rst b/docs/design.rst new file mode 100644 index 0000000..8cc56f8 --- /dev/null +++ b/docs/design.rst @@ -0,0 +1,28 @@ +------ +Design +------ + +Development +=========== + +Running the tests +----------------- + +To run the tests, start by installing a development version of the plugin that includes test dependencies: + +.. code-block:: console + + pip install -e .[tests] + + +Next, export a `database connection string `_ that the tests can use (the database referenced by the string will be created during test setup, so it does not need to exist): + +.. code-block:: console + + export TEST_DATABASE_URL= + +Finally, run the tests using pytest: + +.. code-block:: console + + pytest diff --git a/docs/fixtures.rst b/docs/fixtures.rst new file mode 100644 index 0000000..df5600b --- /dev/null +++ b/docs/fixtures.rst @@ -0,0 +1,82 @@ +-------- +Fixtures +-------- + +This plugin provides two fixtures for performing database updates inside nested transactions that get rolled back at the end of a test: :ref:`db_session ` and :ref:`db_engine `. +The fixtures provide similar functionality, but with different APIs. + +.. _db_session: + +``db_session`` +============== + +The :ref:`db_session ` fixture allows you to perform direct updates that will be rolled back when the test exits. +It exposes the same API as `SQLAlchemy's scoped_session object `_. + +Including this fixture as a function argument of a test will activate any mocks that are defined by the configuration properties :ref:`mocked-engines `, :ref:`mocked-sessions `, or :ref:`mocked-sessionmakers ` in the test configuration file for the duration of that test. + +.. rubric:: Example: + +.. code-block:: Python + + def test_a_transaction(db_session): + row = db_session.query(Table).get(1) + row.name = 'testing' + + db_session.add(row) + db_session.commit() + + def test_transaction_doesnt_persist(db_session): + row = db_session.query(Table).get(1) + assert row.name != 'testing' + + +.. _db_engine: + +``db_engine`` +============= + +Like :ref:`db_session `, the `db_engine` fixture allows you to perform direct updates against the test database that will be rolled back when the test exits. +It is an instance of Python's built-in `MagicMock `_ class, with a spec set to match the API of `SQLAlchemy's Engine `_ object. + +Only a few `Engine` methods are exposed on this fixture: + +- `db_engine.begin`: begin a new nested transaction (`API docs `_) +- `db_engine.execute`: execute a raw SQL query (`API docs `_) +- `db_engine.raw_connection`: return a raw DBAPI connection (`API docs `_) + +Since `db_engine` is an instance of `MagicMock` with an `Engine` spec, other methods of the `Engine` API can be called, but they will not perform any useful work. + +Including this fixture as a function argument of a test will activate any mocks that are defined by the configuration properties :ref:`mocked-engines `, :ref:`mocked-sessions `, or :ref:`mocked-sessionmakers ` in the test configuration file for the duration of that test. + +.. rubric:: Example: + +.. code-block:: Python + + def test_a_transaction_using_engine(db_engine): + with db_engine.begin() as conn: + row = conn.execute('''UPDATE table SET name = 'testing' WHERE id = 1''') + + def test_transaction_doesnt_persist(db_engine): + row_name = db_engine.execute('''SELECT name FROM table WHERE id = 1''').fetchone()[0] + assert row_name != 'testing' + +.. _enabling-transactions-without-fixtures: + +Enabling transactions without fixtures +-------------------------------------- + +If you know you want to make all of your tests transactional, it can be annoying to have to specify one of the :ref:`fixtures ` in every test signature. + +The best way to automatically enable transactions without having to include an extra fixture in every test is to wire up an `autouse fixture `_ for your test suite. +This can be as simple as :: + + # Automatically enable transactions for all tests, without importing any extra fixtures. + @pytest.fixture(autouse=True) + def enable_transactional_tests(db_session): + pass + + +In this configuration, the `enable_transactional_tests` fixture will be automatically used in all tests, meaning that `db_session` will also be used. +This way, all tests will be wrapped in transactions without having to explicitly require either `db_session` or `enable_transactional_tests`. + diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..2765d12 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,56 @@ +.. PyTest-Flask-SQLAlchemy documentation master file, created by + sphinx-quickstart on Fri Sep 20 10:22:51 2019. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +======================= +PyTest-Flask-SQLAlchemy +======================= + +.. image:: https://badge.fury.io/py/pytest-flask-sqlalchemy.svg + :target: https://badge.fury.io/py/pytest-flask-sqlalchemy + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + Quick Start + Install + Usage + Fixtures + Databases + Design + +A `pytest `_ plugin providing fixtures for running tests in transactions using `Flask-SQLAlchemy `_. + +Motivation +---------- + +Inspired by `Django's built-in support for transactional tests `_, this plugin seeks to provide comprehensive, easy-to-use Pytest fixtures for wrapping tests in database transactions for `Flask-SQLAlchemy `_ apps. +The goal is to make testing stateful Flask-SQLAlchemy applications easier by providing fixtures that permit the developer to **make arbitrary database updates with the confidence that any changes made during a test will roll back** once the test exits. + +Acknowledgements +---------------- + +This plugin was initially developed for testing `Dedupe.io `_, a web app for record linkage and entity resolution using machine learning. +Dedupe.io is built and maintained by `DataMade `_. + +The code is greatly indebted to `Alex Michael `_, whose blog post `"Delightful testing with pytest and Flask-SQLAlchemy" `_ helped establish the basic approach on which this plugin builds. + +Many thanks to `Igor Ghisi `_, who donated the PyPi package name. +Igor had been working on a similar plugin and proposed combining efforts. +Thanks to Igor, the plugin name is much stronger. + +Copyright +---------------- + +Copyright (c) 2019 Jean Cochrane and DataMade. Released under the MIT License. + +Third-party copyright in this distribution is noted where applicable. + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..43ea043 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,42 @@ +------------ +Installation +------------ + +Python package Index +==================== + +Install using pip: + +.. code-block:: console + + pip install pytest-flask-sqlalchemy + + +Once installed, pytest will detect the plugin automatically during test collection. +For basic background on using third-party plugins with pytest, see the `pytest +documentation `_. + +Development +=========== + +Clone the repo from GitHub and switch into the new directory: + +.. code-block:: console + + git clone git@github.com:jeancochrane/pytest-flask-sqlalchemy.git + cd pytest-flask-sqlalchemy + +You can install using pip: + +.. code-block:: console + + pip install . + +Removal +======= + +You can uninstall the package using pip; irrespective of how it was first installed : + +.. code-block:: console + + pip uninstall pytest-flask-sqlalchemy diff --git a/docs/quickstart.rst b/docs/quickstart.rst new file mode 100644 index 0000000..cf7b031 --- /dev/null +++ b/docs/quickstart.rst @@ -0,0 +1,74 @@ +-------------- +Quick examples +-------------- + +Use the :ref:`db_session fixture ` to make **database updates that won't persist beyond the body of the test**:: + + def test_a_transaction(db_session): + row = db_session.query(Table).get(1) + row.name = 'testing' + + db_session.add(row) + db_session.commit() + + def test_transaction_doesnt_persist(db_session): + row = db_session.query(Table).get(1) + assert row.name != 'testing' + +The :ref:`db_engine fixture ` works the same way, but **copies the API of SQLAlchemy's** `Engine object `_:: + + def test_a_transaction_using_engine(db_engine): + with db_engine.begin() as conn: + row = conn.execute('''UPDATE table SET name = 'testing' WHERE id = 1''') + + def test_transaction_doesnt_persist(db_engine): + row_name = db_engine.execute('''SELECT name FROM table WHERE id = 1''').fetchone()[0] + assert row_name != 'testing' + +Use :ref:`configuration properties ` to **mock database connections in an app and enforce nested transactions**, allowing any method from the codebase to run inside a test with the assurance that any database changes made will be rolled back at the end of the test: + +.. code-block:: ini + :caption: :file:`setup.cfg` + :name: configuration + + [tool:pytest] + mocked-sessions=database.db.session + mocked-engines=database.engine + + +.. code-block:: python + :caption: :file:`database.py` + :name: database + + db = flask_sqlalchemy.SQLAlchemy() + engine = sqlalchemy.create_engine('DATABASE_URI') + + +.. code-block:: python + :caption: :file:`models.py` + :name: models + + class Table(db.Model): + __tablename__ = 'table' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80)) + + def set_name(new_name) + self.name = new_name + db.session.add(self) + db.session.commit() + + +.. code-block:: python + :caption: :file:`tests/test_set_name.py` + :name: test:set name + + def test_set_name(db_session): + row = db_session.query(Table).get(1) + row.set_name('testing') + assert row.name == 'testing' + + def test_transaction_doesnt_persist(db_session): + row = db_session.query(Table).get(1) + assert row.name != 'testing' + diff --git a/docs/utilization.rst b/docs/utilization.rst new file mode 100644 index 0000000..5bdd06e --- /dev/null +++ b/docs/utilization.rst @@ -0,0 +1,224 @@ +----- +Usage +----- + +Configuration +============= + +.. _conftest-setup: + +Conftest Setup +-------------- + +This plugin assumes that a fixture called `_db` has been defined in the root conftest file for your tests. +The `_db` fixture should expose access to a valid SQLAlchemy `Session object `_ that can interact with your database, +for example via the `SQLAlchemy initialization class `_ that configures Flask-SQLAlchemy. + +The fixtures in this plugin depend on this ``_db`` fixture to access your database and create nested transactions to run tests in. +**You must define this fixture in your `conftest.py` file for the plugin to work.** + +An example setup that will produce a valid ``_db`` fixture could look like this (this example comes from the :ref:`test setup ` for this repo): + +.. literalinclude:: ../tests/_conftest.py + :name: test setup:database + :lines: 25-39 + +.. @pytest.fixture(scope='session') +.. def database(request): +.. ''' +.. Create a Postgres database for the tests, and drop it when the tests are done. +.. ''' +.. pg_host = DB_OPTS.get("host") +.. pg_port = DB_OPTS.get("port") +.. pg_user = DB_OPTS.get("username") +.. pg_db = DB_OPTS["database"] +.. +.. init_postgresql_database(pg_user, pg_host, pg_port, pg_db) +.. +.. @request.addfinalizer +.. def drop_database(): +.. drop_postgresql_database(pg_user, pg_host, pg_port, pg_db, 9.6) + +.. literalinclude:: ../tests/_conftest.py + :name: test setup:app + :lines: 42-51 + +.. @pytest.fixture(scope='session') +.. def app(database): +.. ''' +.. Create a Flask app context for the tests. +.. ''' +.. app = Flask(__name__) +.. app.config['SQLALCHEMY_DATABASE_URI'] = DB_CONN +.. return app + + +.. literalinclude:: ../tests/_conftest.py + :name: test setup:_db + :lines: 53-61 + +.. @pytest.fixture(scope='session') +.. def _db(app): +.. ''' +.. Provide the transactional fixtures with access to the database via a Flask-SQLAlchemy +.. database connection. +.. ''' +.. db = SQLAlchemy(app=app) +.. +.. return db + + +Alternatively, if you already have a fixture that sets up database access for +your tests, you can define `_db` to return that fixture directly: + +.. code-block:: + + @pytest.fixture(scope='session') + def database(): + # Set up all your database stuff here + # ... + return db + + @pytest.fixture(scope='session') + def _db(database): + return database + +.. _test-configuration: + +Test configuration +------------------ + +This plugin allows you to configure a few different properties in a :file:`setup.cfg` test configuration file in order to handle the specific database connection needs of your app. +For basic background on setting up pytest configuration files, see the `pytest docs `_. + +All three configuration properties (:ref:`mocked-engines `), :ref:`mocked-sessions `, and :ref:`mocked-sessionmakers `) +work by `patching `_ **one or more specified objects during a test**, +replacing them with equivalent objects whose database interactions will run inside of a transaction and ultimately be rolled back when the test exits. +Using these patches, you can call methods from your codebase that alter database state with the knowledge that no changes will persist beyond the body of the test. + +The configured patches are only applied in tests where a transactional fixture (either :ref:`db_session ` or :ref:`db_engine `) is included in the test function arguments. + +.. _mocked-engines: + +``mocked-engines`` +~~~~~~~~~~~~~~~~~~ + +The `mocked-engines` property directs the plugin to `patch `_ objects in your codebase, typically SQLAlchemy `Engine `_ instances, +replacing them with the :ref:`db_engine fixture ` such that any database updates performed by the objects get rolled back at the end of the test. + +The value for this property should be formatted as a whitespace-separated list of standard Python import paths, like `database.engine`. This property is **optional**. + +.. rubric:: Example: + +.. code-block:: Python + :caption: :file:`database.py` + + engine = sqlalchemy.create_engine(DATABASE_URI) + +.. code-block:: ini + :caption: :file:`setup.cfg` + + [tool:pytest] + mocked-engines=database.engine + +To patch multiple objects at once, separate the paths with a whitespace: + +.. code-block:: ini + :caption: :file:`setup.cfg` + + [tool:pytest] + mocked-engines=database.engine database.second_engine + +.. _mocked-sessions: + +``mocked-sessions`` +~~~~~~~~~~~~~~~~~~~ + +The `mocked-sessions` property directs the plugin to `patch `_ objects in your codebase, +typically SQLAlchemy `Session `_ instances, +replacing them with the :ref:`db_session ` fixture such that any database updates performed by the objects get rolled back at the end of the test. + +The value for this property should be formatted as a whitespace-separated list of standard Python import paths, like `database.db.session`. This property is **optional**. + +Example: + +.. code-block:: Python + :caption: :file:`database.py` + + db = SQLAlchemy() + +.. code-block:: ini + :caption: :file:`setup.cfg` + + [tool:pytest] + mocked-sessions=database.db.session + +To patch multiple objects at once, separate the paths with a whitespace: + +.. code-block:: ini + :caption: :file:`setup.cfg` + + [tool:pytest] + mocked-sessions=database.db.session database.second_db.session + +.. _mocked-sessionmakers: + +``mocked-sessionmakers`` +~~~~~~~~~~~~~~~~~~~~~~~~ + +The `mocked-sessionmakers` property directs the plugin to `patch `_ objects in your codebase, +typically instances of `SQLAlchemy's sessionmaker factory `_, +replacing them with a mocked class that will return the transactional :ref:`db_session ` fixture. +This can be useful if you have pre-configured instances of sessionmaker objects that you import in the code to spin up sessions on the fly. + +The value for this property should be formatted as a whitespace-separated list of standard Python import paths, like `database.WorkerSessionmaker`. +This property is **optional**. + +.. rubric:: Example: + +.. code-block:: Python + :caption: :file:`database.py` + + WorkerSessionmaker = sessionmaker() + + +.. code-block:: ini + :caption: :file:`setup.cfg` + + [tool:pytest] + mocked-sessionmakers=database.WorkerSessionmaker + +To patch multiple objects at once, separate the paths with a whitespace. + +.. code-block:: ini + :caption: :file:`setup.cfg` + + [tool:pytest] + mocked-sessionmakers=database.WorkerSessionmaker database.SecondWorkerSessionmaker + +Writing transactional tests +--------------------------- + +Once you have your :ref:`conftest file set up ` and you've :ref:`overrided the necessary connectables in your test configuration `, you're ready to write some transactional tests. +Simply import one of the module's :ref:`transactional fixtures ` in your test signature, and the test will be wrapped in a transaction. + +Note that by default, **tests are only wrapped in transactions if they import one of the** :ref:`transactional fixtures ` **provided by this module.** +Tests that do not import the fixture will interact with your database without opening a transaction: + +.. code-block:: Python + + # This test will be wrapped in a transaction. + def transactional_test(db_session): + ... + + # This test **will not** be wrapped in a transaction, since it does not import a + # transactional fixture. + def non_transactional_test(): + ... + +The fixtures provide a way for you to control which tests require transactions and which don't. +This is often useful, since avoiding transaction setup can speed up tests that don't interact with your database. + +For more information about the transactional fixtures provided by this module, read on to the :ref:`fixtures section `. +For guidance on how to automatically enable transactions without having to specify fixtures, +see the section on :ref:`enabling transactions without fixtures `. diff --git a/setup.py b/setup.py index dac565e..f41f503 100644 --- a/setup.py +++ b/setup.py @@ -1,12 +1,15 @@ from setuptools import setup +project = 'pytest-flask-sqlalchemy' +release = '1.0.2' +version = '.'.join(release.split('.')[:-1]) def readme(): with open('README.md') as f: return f.read() setup( - name='pytest-flask-sqlalchemy', + name=project, author='Jean Cochrane', author_email='jean@jeancochrane.com', url='https://github.com/jeancochrane/pytest-flask-sqlalchemy', @@ -14,7 +17,7 @@ def readme(): long_description=readme(), long_description_content_type='text/markdown', license='MIT', - version='1.0.2', + version= release, packages=['pytest_flask_sqlalchemy'], install_requires=['pytest>=3.2.1', 'pytest-mock>=1.6.2', @@ -40,4 +43,11 @@ def readme(): 'pytest-flask-sqlalchemy = pytest_flask_sqlalchemy.plugin', ] }, + + # Sphinx + command_options={'build_sphinx': { # Pull this info from a PROJECT.__meta__ module + 'project': ('setup.py', project), + 'version': ('setup.py', version), + 'release': ('setup.py', release), + 'source_dir': ('setup.py', 'docs')}}, )