Browse Source

Abstract setuptools asset compiling (#4091)

* Abstract setuptools asset compiling

* use package name

* Various fixes/tweaks to asset building

* Additional tweaks for asset reusability

* Maintain path checking

* get_paths -> get_dist_paths

* Correct manifest paths

* Missing import
David Cramer 8 years ago
parent
commit
1451a2d063

+ 1 - 0
.gitignore

@@ -21,6 +21,7 @@ sentry-package.json
 /node_modules/
 /docs/_build
 example/db.sqlite
+/src/sentry/assets.json
 /src/sentry/static/version
 /src/sentry/static/sentry/dist/
 /src/sentry/static/sentry/vendor/

+ 1 - 1
MANIFEST.in

@@ -1,4 +1,4 @@
-include setup.py src/sentry/sentry-package.json src/sentry/static/version README.rst MANIFEST.in LICENSE AUTHORS
+include setup.py src/sentry/assets.json README.rst MANIFEST.in LICENSE AUTHORS
 recursive-include src/sentry/templates *
 recursive-include src/sentry/locale *
 recursive-include src/sentry/data *

+ 1 - 1
Makefile

@@ -51,7 +51,7 @@ clean:
 	@echo "--> Cleaning pyc files"
 	find . -name "*.pyc" -delete
 	@echo "--> Cleaning python build artifacts"
-	rm -rf build/ dist/ sentry-package.json
+	rm -rf build/ dist/ src/sentry/assets.json
 	@echo ""
 
 build-js-po:

+ 20 - 303
setup.py

@@ -24,34 +24,31 @@ any application.
 """
 from __future__ import absolute_import
 
-import sys
-
 # if sys.version_info[:2] != (2, 7):
 #     print 'Error: Sentry requires Python 2.7'
 #     sys.exit(1)
 
 import os
-import json
-import shutil
 import os.path
-import datetime
-import traceback
-from distutils import log
-from subprocess import check_output
-from distutils.core import Command
-from distutils.command.build import build as BuildCommand
+import sys
 
+from distutils.command.build import build as BuildCommand
 from setuptools import setup, find_packages
 from setuptools.command.sdist import sdist as SDistCommand
 from setuptools.command.develop import develop as DevelopCommand
 
-# The version of sentry
-VERSION = '8.9.0.dev0'
+ROOT = os.path.realpath(os.path.join(os.path.dirname(
+    sys.modules['__main__'].__file__)))
 
-# Also see sentry.utils.integrationdocs.DOC_FOLDER
-INTEGRATION_DOC_FOLDER = os.path.join(os.path.abspath(
-    os.path.dirname(__file__)), 'src', 'sentry', 'integration-docs')
+# Add Sentry to path so we can import distutils
+sys.path.insert(0, os.path.join(ROOT, 'src'))
 
+from sentry.utils.distutils import (
+    BuildAssetsCommand, BuildIntegrationDocsCommand
+)
+
+# The version of sentry
+VERSION = '8.9.0.dev0'
 
 # Hack to prevent stupid "TypeError: 'NoneType' object is not callable" error
 # in multiprocessing/util.py _exit_function when running `python
@@ -63,7 +60,6 @@ for m in ('multiprocessing', 'billiard'):
     except ImportError:
         pass
 
-ROOT = os.path.realpath(os.path.join(os.path.dirname(__file__)))
 IS_LIGHT_BUILD = os.environ.get('SENTRY_LIGHT_BUILD') == '1'
 
 dev_requires = [
@@ -148,293 +144,12 @@ dsym_requires = [
 ]
 
 
-class BuildJavascriptCommand(Command):
-    description = 'build javascript support files'
-
-    user_options = [
-        ('work-path=', 'w',
-         "The working directory for source files. Defaults to ."),
-        ('build-lib=', 'b',
-         "directory for script runtime modules"),
-        ('inplace', 'i',
-         "ignore build-lib and put compiled javascript files into the source " +
-         "directory alongside your pure Python modules"),
-        ('force', 'f',
-         "Force rebuilding of static content. Defaults to rebuilding on version "
-         "change detection."),
-    ]
-
-    boolean_options = ['force']
-
-    def initialize_options(self):
-        self.build_lib = None
-        self.force = None
-        self.work_path = None
-        self.inplace = None
-
-    def finalize_options(self):
-        # This requires some explanation.  Basically what we want to do
-        # here is to control if we want to build in-place or into the
-        # build-lib folder.  Traditionally this is set by the `inplace`
-        # command line flag for build_ext.  However as we are a subcommand
-        # we need to grab this information from elsewhere.
-        #
-        # An in-place build puts the files generated into the source
-        # folder, a regular build puts the files into the build-lib
-        # folder.
-        #
-        # The following situations we need to cover:
-        #
-        #   command                         default in-place
-        #   setup.py build_js               0
-        #   setup.py build_ext              value of in-place for build_ext
-        #   setup.py build_ext --inplace    1
-        #   pip install --editable .        1
-        #   setup.py install                0
-        #   setup.py sdist                  0
-        #   setup.py bdist_wheel            0
-        #
-        # The way this is achieved is that build_js is invoked by two
-        # subcommands: bdist_ext (which is in our case always executed
-        # due to a custom distribution) or sdist.
-        #
-        # Note: at one point install was an in-place build but it's not
-        # quite sure why.  In case a version of install breaks again:
-        # installations via pip from git URLs definitely require the
-        # in-place flag to be disabled.  So we might need to detect
-        # that separately.
-        #
-        # To find the default value of the inplace flag we inspect the
-        # sdist and build_ext commands.
-        sdist = self.distribution.get_command_obj('sdist')
-        build_ext = self.get_finalized_command('build_ext')
-
-        # If we are not decided on in-place we are inplace if either
-        # build_ext is inplace or we are invoked through the install
-        # command (easiest check is to see if it's finalized).
-        if self.inplace is None:
-            self.inplace = (build_ext.inplace or sdist.finalized) and 1 or 0
-
-        log.info('building JavaScript support.')
-
-        # If we're coming from sdist, clear the hell out of the dist
-        # folder first.
-        if sdist.finalized:
-            log.info('cleaning out dist folder')
-            try:
-                os.unlink('src/sentry/sentry-package.json')
-            except OSError:
-                pass
-            try:
-                shutil.rmtree('src/sentry/static/sentry/dist')
-            except (OSError, IOError):
-                pass
-
-            log.info('cleaning out integration docs folder')
-            try:
-                shutil.rmtree(INTEGRATION_DOC_FOLDER)
-            except (OSError, IOError):
-                pass
-
-        # In place means build_lib is src.  We also log this.
-        if self.inplace:
-            log.info('In-place js building enabled')
-            self.build_lib = 'src'
-        # Otherwise we fetch build_lib from the build command.
-        else:
-            self.set_undefined_options('build',
-                                       ('build_lib', 'build_lib'))
-            log.info('regular js build: build path is %s' %
-                     self.build_lib)
-
-        if self.work_path is None:
-            self.work_path = ROOT
-
-    def _get_package_version(self):
-        """
-        Attempt to get the most correct current version of Sentry.
-        """
-        pkg_path = os.path.join(self.work_path, 'src')
-
-        sys.path.insert(0, pkg_path)
-        try:
-            import sentry
-        except Exception:
-            version = None
-            build = None
-        else:
-            log.info("pulled version information from 'sentry' module".format(
-                     sentry.__file__))
-            version = VERSION
-            build = sentry.__build__
-        finally:
-            sys.path.pop(0)
-
-        if not (version and build):
-            try:
-                with open(self.sentry_package_json_path) as fp:
-                    data = json.loads(fp.read())
-            except Exception:
-                pass
-            else:
-                log.info("pulled version information from 'sentry-package.json'")
-                version, build = data['version'], data['build']
-
-        return {
-            'version': version,
-            'build': build,
-        }
-
-    def _needs_static(self, version_info):
-        json_path = self.sentry_package_json_path
-        if not os.path.exists(json_path):
-            return True
-
-        with open(json_path) as fp:
-            data = json.load(fp)
-        if data.get('version') != version_info.get('version'):
-            return True
-        if data.get('build') != version_info.get('build'):
-            return True
-        return False
-
-    def _needs_integration_docs(self):
-        return not os.path.isdir(INTEGRATION_DOC_FOLDER)
-
-    def run(self):
-        need_integration_docs = not os.path.isdir(INTEGRATION_DOC_FOLDER)
-        version_info = self._get_package_version()
-
-        if not (self.force or self._needs_static(version_info)):
-            log.info("skipped asset build (version already built)")
-        else:
-            log.info("building assets for Sentry v{} (build {})".format(
-                version_info['version'] or 'UNKNOWN',
-                version_info['build'] or 'UNKNOWN',
-            ))
-            if not version_info['version'] or not version_info['build']:
-                log.fatal('Could not determine sentry version or build')
-                sys.exit(1)
-
-            node_version = []
-            for app in 'node', 'npm':
-                try:
-                    node_version.append(check_output([app, '--version']).rstrip())
-                except OSError:
-                    log.fatal('Cannot find `{0}` executable. Please install {0}`'
-                              ' and try again.'.format(app))
-                    sys.exit(1)
-
-            log.info('using node ({}) and npm ({})'.format(*node_version))
-
-            try:
-                self._build_static()
-            except Exception:
-                traceback.print_exc()
-                log.fatal("unable to build Sentry's static assets!\n"
-                          "Hint: You might be running an invalid version of NPM.")
-                sys.exit(1)
-
-            log.info("writing version manifest")
-            manifest = self._write_version_file(version_info)
-            log.info("recorded manifest\n{}".format(
-                json.dumps(manifest, indent=2),
-            ))
-            need_integration_docs = True
-
-        if not need_integration_docs:
-            log.info('skipped integration docs (already downloaded)')
-        else:
-            log.info('downloading integration docs')
-            from sentry.utils.integrationdocs import sync_docs
-            sync_docs()
-
-        self.update_manifests()
-
-    def update_manifests(self):
-        # if we were invoked from sdist, we need to inform sdist about
-        # which files we just generated.  Otherwise they will be missing
-        # in the manifest.  This adds the files for what webpack generates
-        # plus our own sentry-package.json file.
-        sdist = self.distribution.get_command_obj('sdist')
-        if not sdist.finalized:
-            return
-
-        # The path down from here only works for sdist:
-
-        # Use the underlying file list so that we skip the file-exists
-        # check which we do not want here.
-        files = sdist.filelist.files
-        base = os.path.abspath('.')
-
-        # We need to split off the local parts of the files relative to
-        # the current folder.  This will chop off the right path for the
-        # manifest.
-        for root in self.sentry_static_dist_path, INTEGRATION_DOC_FOLDER:
-            for dirname, _, filenames in os.walk(root):
-                for filename in filenames:
-                    filename = os.path.join(dirname, filename)
-                    files.append(filename[len(base):].lstrip(os.path.sep))
-
-        files.append('src/sentry/sentry-package.json')
-        files.append('src/sentry/static/version')
-
-    def _build_static(self):
-        work_path = self.work_path
-
-        if os.path.exists(os.path.join(work_path, '.git')):
-            log.info("initializing git submodules")
-            check_output(['git', 'submodule', 'init'], cwd=work_path)
-            check_output(['git', 'submodule', 'update'], cwd=work_path)
-
-        log.info("running [npm install --production --quiet]")
-        check_output(['npm', 'install', '--production', '--quiet'], cwd=work_path)
-
-        # By setting NODE_ENV=production, a few things happen
-        #   * React optimizes out certain code paths
-        #   * Webpack will add version strings to built/referenced assets
-
-        log.info("running [webpack]")
-        env = dict(os.environ)
-        env['SENTRY_STATIC_DIST_PATH'] = self.sentry_static_dist_path
-        env['NODE_ENV'] = 'production'
-        check_output(['node_modules/.bin/webpack', '-p', '--bail'],
-                     cwd=work_path, env=env)
-
-    def _write_version_file(self, version_info):
-        manifest = {
-            'createdAt': datetime.datetime.utcnow().isoformat() + 'Z',
-            'version': version_info['version'],
-            'build': version_info['build'],
-        }
-        with open(self.sentry_package_json_path, 'w') as fp:
-            json.dump(manifest, fp)
-        with open(self.sentry_static_version_path, 'w') as fp:
-            fp.write(version_info['build'])
-        return manifest
-
-    @property
-    def sentry_static_dist_path(self):
-        return os.path.abspath(os.path.join(
-            self.build_lib, 'sentry/static/sentry/dist'))
-
-    @property
-    def sentry_package_json_path(self):
-        return os.path.abspath(os.path.join(
-            self.build_lib, 'sentry/sentry-package.json'))
-
-    @property
-    def sentry_static_version_path(self):
-        return os.path.abspath(os.path.join(
-            self.build_lib, 'sentry/static/version'))
-
-
 class SentrySDistCommand(SDistCommand):
-    # If we are not a light build we want to also execute build_js as
+    # If we are not a light build we want to also execute build_assets as
     # part of our source build pipeline.
     if not IS_LIGHT_BUILD:
         sub_commands = SDistCommand.sub_commands + \
-            [('build_js', None)]
+            [('build_assets', None), ('build_integration_docs', None)]
 
 
 class SentryBuildCommand(BuildCommand):
@@ -442,7 +157,8 @@ class SentryBuildCommand(BuildCommand):
     def run(self):
         BuildCommand.run(self)
         if not IS_LIGHT_BUILD:
-            self.run_command('build_js')
+            self.run_command('build_assets')
+            self.run_command('build_integration_docs')
 
 
 class SentryDevelopCommand(DevelopCommand):
@@ -450,14 +166,15 @@ class SentryDevelopCommand(DevelopCommand):
     def run(self):
         DevelopCommand.run(self)
         if not IS_LIGHT_BUILD:
-            self.run_command('build_js')
-
+            self.run_command('build_assets')
+            self.run_command('build_integration_docs')
 
 cmdclass = {
     'sdist': SentrySDistCommand,
     'develop': SentryDevelopCommand,
     'build': SentryBuildCommand,
-    'build_js': BuildJavascriptCommand,
+    'build_assets': BuildAssetsCommand,
+    'build_integration_docs': BuildIntegrationDocsCommand,
 }
 
 

+ 10 - 7
src/sentry/__init__.py

@@ -10,6 +10,8 @@ from __future__ import absolute_import
 import os
 import os.path
 
+from subprocess import check_output
+
 try:
     VERSION = __import__('pkg_resources') \
         .get_distribution('sentry').version
@@ -18,14 +20,15 @@ except Exception as e:
 
 
 def _get_git_revision(path):
-    revision_file = os.path.join(path, 'refs', 'heads', 'master')
-    if not os.path.exists(revision_file):
+    if not os.path.exists(os.path.join(path, '.git')):
         return None
-    fh = open(revision_file, 'r')
     try:
-        return fh.read().strip()
-    finally:
-        fh.close()
+        revision = check_output(['git', 'rev-parse', 'HEAD'],
+                                cwd=path, env=os.environ)
+    except Exception:
+        # binary didn't exist, wasn't on path, etc
+        return None
+    return revision.strip()
 
 
 def get_revision():
@@ -37,7 +40,7 @@ def get_revision():
         return os.environ['SENTRY_BUILD']
     package_dir = os.path.dirname(__file__)
     checkout_dir = os.path.normpath(os.path.join(package_dir, os.pardir, os.pardir))
-    path = os.path.join(checkout_dir, '.git')
+    path = os.path.join(checkout_dir)
     if os.path.exists(path):
         return _get_git_revision(path)
     return None

+ 6 - 0
src/sentry/utils/distutils/__init__.py

@@ -0,0 +1,6 @@
+from __future__ import absolute_import
+
+# !!! This module may not reference any external packages !!! #
+
+from .commands.build_integration_docs import BuildIntegrationDocsCommand  # NOQA
+from .commands.build_assets import BuildAssetsCommand  # NOQA

+ 1 - 0
src/sentry/utils/distutils/commands/__init__.py

@@ -0,0 +1 @@
+from __future__ import absolute_import

+ 173 - 0
src/sentry/utils/distutils/commands/base.py

@@ -0,0 +1,173 @@
+from __future__ import absolute_import
+
+import os
+import os.path
+import shutil
+import sys
+
+from distutils import log
+from subprocess import check_output
+from distutils.core import Command
+
+
+class BaseBuildCommand(Command):
+    user_options = [
+        ('work-path=', 'w',
+         "The working directory for source files. Defaults to ."),
+        ('build-lib=', 'b',
+         "directory for script runtime modules"),
+        ('inplace', 'i',
+         "ignore build-lib and put compiled javascript files into the source " +
+         "directory alongside your pure Python modules"),
+        ('force', 'f',
+         "Force rebuilding of static content. Defaults to rebuilding on version "
+         "change detection."),
+    ]
+
+    boolean_options = ['force']
+
+    def initialize_options(self):
+        self.build_lib = None
+        self.force = None
+        self.work_path = None
+        self.inplace = None
+
+    def get_root_path(self):
+        return os.path.abspath(os.path.dirname(sys.modules['__main__'].__file__))
+
+    def get_dist_paths(self):
+        return []
+
+    def get_manifest_additions(self):
+        return []
+
+    def finalize_options(self):
+        # This requires some explanation.  Basically what we want to do
+        # here is to control if we want to build in-place or into the
+        # build-lib folder.  Traditionally this is set by the `inplace`
+        # command line flag for build_ext.  However as we are a subcommand
+        # we need to grab this information from elsewhere.
+        #
+        # An in-place build puts the files generated into the source
+        # folder, a regular build puts the files into the build-lib
+        # folder.
+        #
+        # The following situations we need to cover:
+        #
+        #   command                         default in-place
+        #   setup.py build_js               0
+        #   setup.py build_ext              value of in-place for build_ext
+        #   setup.py build_ext --inplace    1
+        #   pip install --editable .        1
+        #   setup.py install                0
+        #   setup.py sdist                  0
+        #   setup.py bdist_wheel            0
+        #
+        # The way this is achieved is that build_js is invoked by two
+        # subcommands: bdist_ext (which is in our case always executed
+        # due to a custom distribution) or sdist.
+        #
+        # Note: at one point install was an in-place build but it's not
+        # quite sure why.  In case a version of install breaks again:
+        # installations via pip from git URLs definitely require the
+        # in-place flag to be disabled.  So we might need to detect
+        # that separately.
+        #
+        # To find the default value of the inplace flag we inspect the
+        # sdist and build_ext commands.
+        sdist = self.distribution.get_command_obj('sdist')
+        build_ext = self.get_finalized_command('build_ext')
+
+        # If we are not decided on in-place we are inplace if either
+        # build_ext is inplace or we are invoked through the install
+        # command (easiest check is to see if it's finalized).
+        if self.inplace is None:
+            self.inplace = (build_ext.inplace or sdist.finalized) and 1 or 0
+
+        # If we're coming from sdist, clear the hell out of the dist
+        # folder first.
+        if sdist.finalized:
+            for path in self.get_dist_paths():
+                try:
+                    shutil.rmtree(path)
+                except (OSError, IOError):
+                    pass
+
+        # In place means build_lib is src.  We also log this.
+        if self.inplace:
+            log.debug('in-place js building enabled')
+            self.build_lib = 'src'
+        # Otherwise we fetch build_lib from the build command.
+        else:
+            self.set_undefined_options('build',
+                                       ('build_lib', 'build_lib'))
+            log.debug('regular js build: build path is %s' %
+                     self.build_lib)
+
+        if self.work_path is None:
+            self.work_path = self.get_root_path()
+
+    def _needs_built(self):
+        for path in self.get_dist_paths():
+            if not os.path.isdir(path):
+                return True
+        return False
+
+    def _setup_git(self):
+        work_path = self.work_path
+
+        if os.path.exists(os.path.join(work_path, '.git')):
+            log.info('initializing git submodules')
+            check_output(['git', 'submodule', 'init'], cwd=work_path)
+            check_output(['git', 'submodule', 'update'], cwd=work_path)
+
+    def _setup_npm(self):
+        work_path = self.work_path
+        node_version = []
+        for app in 'node', 'npm':
+            try:
+                node_version.append(check_output([app, '--version']).rstrip())
+            except OSError:
+                log.fatal('Cannot find `{0}` executable. Please install {0}`'
+                          ' and try again.'.format(app))
+                sys.exit(1)
+
+        log.info('using node ({}) and npm ({})'.format(*node_version))
+
+        log.info('running [npm install --production --quiet]')
+        check_output(['npm', 'install', '--production', '--quiet'], cwd=work_path)
+
+    def update_manifests(self):
+        # if we were invoked from sdist, we need to inform sdist about
+        # which files we just generated.  Otherwise they will be missing
+        # in the manifest.  This adds the files for what webpack generates
+        # plus our own assets.json file.
+        sdist = self.distribution.get_command_obj('sdist')
+        if not sdist.finalized:
+            return
+
+        # The path down from here only works for sdist:
+
+        # Use the underlying file list so that we skip the file-exists
+        # check which we do not want here.
+        files = sdist.filelist.files
+        base = os.path.abspath('.')
+
+        # We need to split off the local parts of the files relative to
+        # the current folder.  This will chop off the right path for the
+        # manifest.
+        for path in self.get_dist_paths():
+            for dirname, _, filenames in os.walk(os.path.abspath(path)):
+                for filename in filenames:
+                    filename = os.path.join(dirname, filename)
+                    files.append(filename[len(base):].lstrip(os.path.sep))
+
+        for file in self.get_manifest_additions():
+            files.append(file)
+
+    def run(self):
+        if self.force or self._needs_built():
+            self._setup_git()
+            self._setup_npm()
+            self._build()
+            self.update_manifests()

+ 155 - 0
src/sentry/utils/distutils/commands/build_assets.py

@@ -0,0 +1,155 @@
+from __future__ import absolute_import
+
+import json
+import datetime
+import os
+import os.path
+import sys
+import traceback
+
+from distutils import log
+from subprocess import check_output
+
+from .base import BaseBuildCommand
+
+
+class BuildAssetsCommand(BaseBuildCommand):
+    user_options = BaseBuildCommand.user_options + [
+        ('asset-json-path=', None,
+         'Relative path for JSON manifest. Defaults to {dist_name}/assets.json'),
+        ('inplace', 'i',
+         "ignore build-lib and put compiled javascript files into the source " +
+         "directory alongside your pure Python modules"),
+        ('force', 'f',
+         "Force rebuilding of static content. Defaults to rebuilding on version "
+         "change detection."),
+    ]
+
+    description = 'build static media assets'
+
+    def initialize_options(self):
+        self.asset_json_path = '{}/assets.json'.format(
+            self.distribution.get_name(),
+        )
+        BaseBuildCommand.initialize_options(self)
+
+    def get_dist_paths(self):
+        return [
+            'src/sentry/static/sentry/dist',
+        ]
+
+    def get_manifest_additions(self):
+        return (
+            'src/' + self.asset_json_path,
+        )
+
+    def _get_package_version(self):
+        """
+        Attempt to get the most correct current version of Sentry.
+        """
+        pkg_path = os.path.join(self.work_path, 'src')
+
+        sys.path.insert(0, pkg_path)
+        try:
+            import sentry
+        except Exception:
+            version = None
+            build = None
+        else:
+            log.info('pulled version information from \'sentry\' module'.format(
+                     sentry.__file__))
+            version = self.distribution.get_version()
+            build = sentry.__build__
+        finally:
+            sys.path.pop(0)
+
+        if not (version and build):
+            try:
+                with open(self.get_asset_json_path()) as fp:
+                    data = json.loads(fp.read())
+            except Exception:
+                pass
+            else:
+                log.info('pulled version information from \'{}\''.format(
+                    self.package_path,
+                ))
+                version, build = data['version'], data['build']
+
+        return {
+            'version': version,
+            'build': build,
+        }
+
+    def _needs_static(self, version_info):
+        json_path = self.get_asset_json_path()
+        if not os.path.exists(json_path):
+            return True
+
+        with open(json_path) as fp:
+            data = json.load(fp)
+        if data.get('version') != version_info.get('version'):
+            return True
+        if data.get('build') != version_info.get('build'):
+            return True
+        return False
+
+    def _needs_built(self):
+        if BaseBuildCommand._needs_built(self):
+            return True
+        version_info = self._get_package_version()
+        return self._needs_static(version_info)
+
+    def _build(self):
+        version_info = self._get_package_version()
+        log.info('building assets for {} v{} (build {})'.format(
+            self.distribution.get_name(),
+            version_info['version'] or 'UNKNOWN',
+            version_info['build'] or 'UNKNOWN',
+        ))
+        if not version_info['version'] or not version_info['build']:
+            log.fatal('Could not determine sentry version or build')
+            sys.exit(1)
+
+        try:
+            self._build_static()
+        except Exception:
+            traceback.print_exc()
+            log.fatal('unable to build Sentry\'s static assets!\n'
+                      'Hint: You might be running an invalid version of NPM.')
+            sys.exit(1)
+
+        log.info('writing version manifest')
+        manifest = self._write_version_file(version_info)
+        log.info('recorded manifest\n{}'.format(
+            json.dumps(manifest, indent=2),
+        ))
+
+    def _build_static(self):
+        # By setting NODE_ENV=production, a few things happen
+        #   * React optimizes out certain code paths
+        #   * Webpack will add version strings to built/referenced assets
+        log.info('running [webpack]')
+        env = dict(os.environ)
+        env['SENTRY_STATIC_DIST_PATH'] = self.sentry_static_dist_path
+        env['NODE_ENV'] = 'production'
+        check_output(['node_modules/.bin/webpack', '-p', '--bail'],
+                     cwd=self.work_path, env=env)
+
+    def _write_version_file(self, version_info):
+        manifest = {
+            'createdAt': datetime.datetime.utcnow().isoformat() + 'Z',
+            'version': version_info['version'],
+            'build': version_info['build'],
+        }
+        with open(self.get_asset_json_path(), 'w') as fp:
+            json.dump(manifest, fp)
+        return manifest
+
+    @property
+    def sentry_static_dist_path(self):
+        return os.path.abspath(os.path.join(
+            self.build_lib, 'sentry/static/sentry/dist'))
+
+    def get_asset_json_path(self):
+        return os.path.abspath(os.path.join(
+            self.build_lib, self.asset_json_path))

+ 23 - 0
src/sentry/utils/distutils/commands/build_integration_docs.py

@@ -0,0 +1,23 @@
+from __future__ import absolute_import
+
+import os.path
+
+from distutils import log
+
+from .base import BaseBuildCommand
+
+
+class BuildIntegrationDocsCommand(BaseBuildCommand):
+    description = 'build integration docs'
+
+    def get_dist_paths(self):
+        return [
+            # Also see sentry.utils.integrationdocs.DOC_FOLDER
+            os.path.join(self.get_root_path(),
+                         'src', 'sentry', 'integration-docs'),
+        ]
+
+    def _build(self):
+        from sentry.utils.integrationdocs import sync_docs
+        log.info('downloading integration docs')
+        sync_docs()