diff --git a/CHANGELOG.md b/CHANGELOG.md index e04cd4c..c1d6e98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# 1.4.0 +## Features +* Package requirements into service root directory in order to avoid munging + sys.path to load requirements (#30). +* Package requirements when deploying individual non-WSGI functions (#30). +* Added `pythonBin` option to set python executable, defaulting to current runtime version (#29). + + # 1.3.1 ## Features * Add configuration for handling base path mappings (API_GATEWAY_BASE_PATH) diff --git a/README.md b/README.md index 16e3afa..b676fff 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A Serverless v1.x plugin to build your deploy Python WSGI applications using Serverless. Compatible WSGI application frameworks include Flask, Django and Pyramid - for a complete list, see: -[http://wsgi.readthedocs.io/en/latest/frameworks.html](http://wsgi.readthedocs.io/en/latest/frameworks.html). +http://wsgi.readthedocs.io/en/latest/frameworks.html. ### Features @@ -130,7 +130,7 @@ Serverless: Stack update finished... Set `custom.wsgi.app` in `serverless.yml` according to your WSGI callable: * For Pyramid, use [make_wsgi_app](http://docs.pylonsproject.org/projects/pyramid/en/latest/api/config.html#pyramid.config.Configurator.make_wsgi_app) to intialize the callable -* Django is configured for WSGI by default, set the callable to `.wsgi.application`. See [https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/](https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/) for more information. +* Django is configured for WSGI by default, set the callable to `.wsgi.application`. See https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ for more information. ## Usage @@ -147,30 +147,36 @@ Flask==0.12.2 requests==2.18.3 ``` -For more information, see [https://pip.readthedocs.io/en/1.1/requirements.html](https://pip.readthedocs.io/en/1.1/requirements.html). +For more information, see https://pip.readthedocs.io/en/1.1/requirements.html. You can use the requirement packaging functionality of *serverless-wsgi* without the WSGI handler itself by including the plugin in your `serverless.yml` configuration, without specifying the `custom.wsgi.app` setting. This will omit the WSGI handler from the package, but include any requirements specified in `requirements.txt`. -If you do not include the WSGI handler, you'll need to add `.requirements` to the Python search path -manually in your handler, before importing any packages: +If you don't want to use automatic requirement packaging you can set `custom.wsgi.packRequirements` to false: -``` -import os -import sys -root = os.path.abspath(os.path.join(os.path.dirname(__file__))) -sys.path.insert(0, os.path.join(root, '.requirements')) +```yaml +custom: + wsgi: + app: api.app + packRequirements: false ``` -If you don't want to use automatic requirement packaging you can set `custom.wsgi.packRequirements` to false: +For a more advanced approach to packaging requirements, consider using https://github.com/UnitedIncome/serverless-python-requirements. + +### Python version + +Python is used for packaging requirements and serving the app when invoking `sls wsgi serve`. By +default, the current runtime setting is expected to be the name of the Python binary in `PATH`, +for instance `python3.6`. If this is not the name of your Python binary, override it using the +`pythonBin` option: ```yaml custom: wsgi: app: api.app - packRequirements: false + pythonBin: python3 ``` ### Local server diff --git a/index.js b/index.js index 139a414..c2ce843 100644 --- a/index.js +++ b/index.js @@ -11,8 +11,11 @@ BbPromise.promisifyAll(fse); class ServerlessWSGI { validate() { this.enableRequirements = true; + this.pythonBin = this.serverless.service.provider.runtime; if (this.serverless.service.custom && this.serverless.service.custom.wsgi) { + this.pythonBin = this.serverless.service.custom.wsgi.pythonBin || this.pythonBin; + if (this.serverless.service.custom.wsgi.app) { this.wsgiApp = this.serverless.service.custom.wsgi.app; this.appPath = path.dirname(path.join(this.serverless.config.servicePath, this.wsgiApp)); @@ -25,14 +28,18 @@ class ServerlessWSGI { this.serverless.service.package = this.serverless.service.package || {}; this.serverless.service.package.include = this.serverless.service.package.include || []; + this.serverless.service.package.exclude = this.serverless.service.package.exclude || []; - var includes = ['wsgi.py', '.wsgi_app']; + this.serverless.service.package.include = _.union( + this.serverless.service.package.include, + ['wsgi.py', '.wsgi_app']); if (this.enableRequirements) { - includes.push('.requirements/**'); + this.requirementsInstallPath = path.join( + this.appPath ? this.appPath : this.serverless.config.servicePath, + '.requirements'); + this.serverless.service.package.exclude.push('.requirements/**'); } - - this.serverless.service.package.include = _.union(this.serverless.service.package.include, includes); } packWsgiHandler() { @@ -56,7 +63,6 @@ class ServerlessWSGI { packRequirements() { const requirementsPath = this.appPath || this.serverless.config.servicePath; const requirementsFile = path.join(requirementsPath, 'requirements.txt'); - const requirementsInstallPath = this.appPath ? this.appPath : this.serverless.config.servicePath; let args = [path.resolve(__dirname, 'requirements.py')]; if (!this.enableRequirements) { @@ -75,12 +81,12 @@ class ServerlessWSGI { } } - args.push(path.join(requirementsInstallPath, '.requirements')); + args.push(this.requirementsInstallPath); this.serverless.cli.log('Packaging required Python packages...'); return new BbPromise((resolve, reject) => { - const res = child_process.spawnSync('python', args); + const res = child_process.spawnSync(this.pythonBin, args); if (res.error) { return reject(res.error); } @@ -91,12 +97,63 @@ class ServerlessWSGI { }); } - cleanup() { - const artifacts = ['wsgi.py', '.wsgi_app']; + linkRequirements() { + if (!this.enableRequirements) { + return BbPromise.resolve(); + } - if (this.enableRequirements) { - artifacts.push('.requirements'); + if (fse.existsSync(this.requirementsInstallPath)) { + this.serverless.cli.log('Linking required Python packages...'); + + fse.readdirSync(this.requirementsInstallPath).map((file) => { + this.serverless.service.package.include.push(file); + this.serverless.service.package.include.push(`${file}/**`); + + try { + fse.symlinkSync(`${this.requirementsInstallPath}/${file}`, file); + } catch (exception) { + let linkConflict = false; + try { + linkConflict = (fse.readlinkSync(file) !== `${this.requirementsInstallPath}/${file}`); + } catch (e) { + linkConflict = true; + } + if (linkConflict) { + throw new this.serverless.classes.Error( + `Unable to link dependency '${file}' ` + + 'because a file by the same name exists in this service'); + } + } + }); + } + } + + unlinkRequirements() { + if (!this.enableRequirements) { + return BbPromise.resolve(); + } + + if (fse.existsSync(this.requirementsInstallPath)) { + this.serverless.cli.log('Unlinking required Python packages...'); + + fse.readdirSync(this.requirementsInstallPath).map((file) => { + if (fse.existsSync(file)) { + fse.unlinkSync(file); + } + }); } + } + + cleanRequirements() { + if (!this.enableRequirements) { + return BbPromise.resolve(); + } + + return fse.removeAsync(this.requirementsInstallPath); + } + + cleanup() { + const artifacts = ['wsgi.py', '.wsgi_app']; return BbPromise.all(_.map(artifacts, (artifact) => fse.removeAsync(path.join(this.serverless.config.servicePath, artifact)))); @@ -106,7 +163,7 @@ class ServerlessWSGI { const providerEnvVars = this.serverless.service.provider.environment || {}; _.merge(process.env, providerEnvVars); - _.each(this.serverless.service.functions, function (func) { + _.each(this.serverless.service.functions, (func) => { if (func.handler == 'wsgi.handler') { const functionEnvVars = func.environment || {}; _.merge(process.env, functionEnvVars); @@ -125,7 +182,7 @@ class ServerlessWSGI { const port = this.options.port || 5000; return new BbPromise((resolve, reject) => { - var status = child_process.spawnSync('python', [ + var status = child_process.spawnSync(this.pythonBin, [ path.resolve(__dirname, 'serve.py'), this.serverless.config.servicePath, this.wsgiApp, @@ -158,6 +215,12 @@ class ServerlessWSGI { }, }, }, + clean: { + usage: 'Remove cached requirements.', + lifecycleEvents: [ + 'clean', + ], + }, }, }, }; @@ -166,10 +229,12 @@ class ServerlessWSGI { 'before:deploy:createDeploymentArtifacts': () => BbPromise.bind(this) .then(this.validate) .then(this.packWsgiHandler) - .then(this.packRequirements), + .then(this.packRequirements) + .then(this.linkRequirements), 'after:deploy:createDeploymentArtifacts': () => BbPromise.bind(this) .then(this.validate) + .then(this.unlinkRequirements) .then(this.cleanup), 'wsgi:serve:serve': () => BbPromise.bind(this) @@ -177,18 +242,31 @@ class ServerlessWSGI { .then(this.loadEnvVars) .then(this.serve), + 'wsgi:clean:clean': () => BbPromise.bind(this) + .then(this.validate) + .then(this.unlinkRequirements) + .then(this.cleanRequirements) + .then(this.cleanup), + 'before:deploy:function:packageFunction': () => BbPromise.bind(this) .then(() => { if (this.options.functionObj.handler == 'wsgi.handler') { return BbPromise.bind(this) .then(this.validate) .then(this.packWsgiHandler) - .then(this.packRequirements); + .then(this.packRequirements) + .then(this.linkRequirements); + } else { + return BbPromise.bind(this) + .then(this.validate) + .then(this.packRequirements) + .then(this.linkRequirements); } }), 'after:deploy:function:packageFunction': () => BbPromise.bind(this) .then(this.validate) + .then(this.unlinkRequirements) .then(this.cleanup) }; } diff --git a/index.test.js b/index.test.js index 8bfedfa..9f312a4 100644 --- a/index.test.js +++ b/index.test.js @@ -12,15 +12,15 @@ const fse = require('fs-extra'); const chaiAsPromised = require('chai-as-promised'); chai.use(chaiAsPromised); -describe('serverless-wsgi', function() { - describe('init', function() { - it('registers commands', function() { +describe('serverless-wsgi', () => { + describe('init', () => { + it('registers commands', () => { var plugin = new Plugin(); expect(plugin.commands.wsgi.commands.serve.lifecycleEvents).to.include('serve'); }); - it('registers hooks', function() { + it('registers hooks', () => { var plugin = new Plugin(); expect(plugin.hooks['before:deploy:createDeploymentArtifacts']).to.be.a('function'); @@ -29,411 +29,500 @@ describe('serverless-wsgi', function() { }); }); - describe('wsgi', function() { - it('skips packaging for non-wsgi app', function() { + describe('wsgi', () => { + it('skips packaging for non-wsgi app', () => { var plugin = new Plugin({ config: { servicePath: '/tmp' }, - service: {}, + service: { provider: { runtime: 'python2.7' } }, classes: { Error: Error }, - cli: { log: function () {} } + cli: { log: () => {} } }, {}); var sandbox = sinon.sandbox.create(); - var copy_stub = sandbox.stub(fse, 'copyAsync'); - var write_stub = sandbox.stub(fse, 'writeFileAsync'); - var proc_stub = sandbox.stub(child_process, 'spawnSync'); - plugin.hooks['before:deploy:createDeploymentArtifacts']().then(function () { - expect(copy_stub.called).to.be.false; - expect(write_stub.called).to.be.false; - expect(proc_stub.called).to.be.false; + var copyStub = sandbox.stub(fse, 'copyAsync'); + var writeStub = sandbox.stub(fse, 'writeFileAsync'); + var procStub = sandbox.stub(child_process, 'spawnSync'); + plugin.hooks['before:deploy:createDeploymentArtifacts']().then(() => { + expect(copyStub.called).to.be.false; + expect(writeStub.called).to.be.false; + expect(procStub.called).to.be.false; sandbox.restore(); }); }); - it('packages wsgi handler', function() { + it('packages wsgi handler', () => { var plugin = new Plugin({ config: { servicePath: '/tmp' }, service: { + provider: { runtime: 'python2.7' }, custom: { wsgi: { app: 'api.app' } } }, classes: { Error: Error }, - cli: { log: function () {} } + cli: { log: () => {} } }, {}); var sandbox = sinon.sandbox.create(); - var copy_stub = sandbox.stub(fse, 'copyAsync'); - var write_stub = sandbox.stub(fse, 'writeFileAsync'); - var proc_stub = sandbox.stub(child_process, 'spawnSync').callsFake(function () { - return { status: 0 }; - }); - plugin.hooks['before:deploy:createDeploymentArtifacts']().then(function () { - expect(copy_stub.calledWith( + var copyStub = sandbox.stub(fse, 'copyAsync'); + var writeStub = sandbox.stub(fse, 'writeFileAsync'); + var procStub = sandbox.stub(child_process, 'spawnSync').returns({ status: 0 }); + plugin.hooks['before:deploy:createDeploymentArtifacts']().then(() => { + expect(copyStub.calledWith( path.resolve(__dirname, 'wsgi.py'), '/tmp/wsgi.py' - )).to.be.ok; - expect(write_stub.calledWith( + )).to.be.true; + expect(writeStub.calledWith( '/tmp/.wsgi_app', 'api.app' - )).to.be.ok; - expect(proc_stub.calledWith( - 'python', + )).to.be.true; + expect(procStub.calledWith( + 'python2.7', [ path.resolve(__dirname, 'requirements.py'), path.resolve(__dirname, 'requirements.txt'), '/tmp/.requirements' ] - )).to.be.ok; + )).to.be.true; sandbox.restore(); - expect(plugin.serverless.service.package.include).to.have.members(['wsgi.py', '.wsgi_app', '.requirements/**']); + expect(plugin.serverless.service.package.include).to.have.members(['wsgi.py', '.wsgi_app']); + expect(plugin.serverless.service.package.exclude).to.have.members(['.requirements/**']); }); }); - it('cleans up after deployment', function() { + it('cleans up after deployment', () => { var plugin = new Plugin({ config: { servicePath: '/tmp' }, service: { + provider: { runtime: 'python2.7' }, custom: { wsgi: { app: 'api.app' } } }, classes: { Error: Error }, - cli: { log: function () {} } + cli: { log: () => {} } }, {}); var sandbox = sinon.sandbox.create(); - var remove_stub = sandbox.stub(fse, 'removeAsync'); - plugin.hooks['after:deploy:createDeploymentArtifacts']().then(function () { - expect(remove_stub.calledWith('/tmp/wsgi.py')).to.be.ok; - expect(remove_stub.calledWith('/tmp/.wsgi_app')).to.be.ok; - expect(remove_stub.calledWith('/tmp/.requirements')).to.be.ok; + var removeStub = sandbox.stub(fse, 'removeAsync'); + plugin.hooks['after:deploy:createDeploymentArtifacts']().then(() => { + expect(removeStub.calledWith('/tmp/wsgi.py')).to.be.true; + expect(removeStub.calledWith('/tmp/.wsgi_app')).to.be.true; + expect(removeStub.calledWith('/tmp/.requirements')).to.be.false; sandbox.restore(); }); }); }); - describe('requirements', function() { - it('packages user requirements for wsgi app', function() { + describe('requirements', () => { + it('packages user requirements for wsgi app', () => { var plugin = new Plugin({ config: { servicePath: '/tmp' }, service: { + provider: { runtime: 'python2.7' }, custom: { wsgi: { app: 'api.app' } }, package: { include: ['sample.txt'] } }, classes: { Error: Error }, - cli: { log: function () {} } + cli: { log: () => {} } }, {}); var sandbox = sinon.sandbox.create(); - var copy_stub = sandbox.stub(fse, 'copyAsync'); - var write_stub = sandbox.stub(fse, 'writeFileAsync'); - sandbox.stub(fse, 'existsSync').callsFake(function () { return true; }); - var proc_stub = sandbox.stub(child_process, 'spawnSync').callsFake(function () { - return { status: 0 }; + var copyStub = sandbox.stub(fse, 'copyAsync'); + var writeStub = sandbox.stub(fse, 'writeFileAsync'); + var symlinkStub = sandbox.stub(fse, 'symlinkSync'); + sandbox.stub(fse, 'readdirSync').returns(['flask']); + sandbox.stub(fse, 'existsSync').returns(true); + var procStub = sandbox.stub(child_process, 'spawnSync').returns({ status: 0 }); + plugin.hooks['before:deploy:createDeploymentArtifacts']().then(() => { + expect(copyStub.called).to.be.true; + expect(writeStub.called).to.be.true; + expect(symlinkStub.called).to.be.true; + expect(procStub.calledWith( + 'python2.7', + [ + path.resolve(__dirname, 'requirements.py'), + path.resolve(__dirname, 'requirements.txt'), + '/tmp/requirements.txt', + '/tmp/.requirements' + ] + )).to.be.true; + expect(plugin.serverless.service.package.include).to.have.members(['sample.txt', 'wsgi.py', '.wsgi_app', 'flask', 'flask/**']); + expect(plugin.serverless.service.package.exclude).to.have.members(['.requirements/**']); + sandbox.restore(); }); - plugin.hooks['before:deploy:createDeploymentArtifacts']().then(function () { - expect(copy_stub.called).to.be.true; - expect(write_stub.called).to.be.true; - expect(proc_stub.calledWith( - 'python', + }); + + it('allows setting the python binary', () => { + var plugin = new Plugin({ + config: { servicePath: '/tmp' }, + service: { + provider: { runtime: 'python2.7' }, + custom: { wsgi: { app: 'api.app', pythonBin: 'my-python' } }, + package: { include: ['sample.txt'] } + }, + classes: { Error: Error }, + cli: { log: () => {} } + }, {}); + + var sandbox = sinon.sandbox.create(); + var copyStub = sandbox.stub(fse, 'copyAsync'); + var writeStub = sandbox.stub(fse, 'writeFileAsync'); + var symlinkStub = sandbox.stub(fse, 'symlinkSync'); + sandbox.stub(fse, 'readdirSync').returns(['flask']); + sandbox.stub(fse, 'existsSync').returns(true); + var procStub = sandbox.stub(child_process, 'spawnSync').returns({ status: 0 }); + plugin.hooks['before:deploy:createDeploymentArtifacts']().then(() => { + expect(copyStub.called).to.be.true; + expect(writeStub.called).to.be.true; + expect(symlinkStub.called).to.be.true; + expect(procStub.calledWith( + 'my-python', [ path.resolve(__dirname, 'requirements.py'), path.resolve(__dirname, 'requirements.txt'), '/tmp/requirements.txt', '/tmp/.requirements' ] - )).to.be.ok; - expect(plugin.serverless.service.package.include).to.have.members(['sample.txt', 'wsgi.py', '.wsgi_app', '.requirements/**']); + )).to.be.true; + expect(plugin.serverless.service.package.include).to.have.members(['sample.txt', 'wsgi.py', '.wsgi_app', 'flask', 'flask/**']); + expect(plugin.serverless.service.package.exclude).to.have.members(['.requirements/**']); sandbox.restore(); }); }); - it('packages user requirements for wsgi app inside directory', function() { + it('packages user requirements for wsgi app inside directory', () => { var plugin = new Plugin({ config: { servicePath: '/tmp' }, service: { + provider: { runtime: 'python2.7' }, custom: { wsgi: { app: 'api/api.app' } } }, classes: { Error: Error }, - cli: { log: function () {} } + cli: { log: () => {} } }, {}); var sandbox = sinon.sandbox.create(); - var copy_stub = sandbox.stub(fse, 'copyAsync'); - var write_stub = sandbox.stub(fse, 'writeFileAsync'); - sandbox.stub(fse, 'existsSync').callsFake(function () { return true; }); - var proc_stub = sandbox.stub(child_process, 'spawnSync').callsFake(function () { - return { status: 0 }; - }); - plugin.hooks['before:deploy:createDeploymentArtifacts']().then(function () { - expect(copy_stub.called).to.be.true; - expect(write_stub.called).to.be.true; - expect(proc_stub.calledWith( - 'python', + var copyStub = sandbox.stub(fse, 'copyAsync'); + var writeStub = sandbox.stub(fse, 'writeFileAsync'); + sandbox.stub(fse, 'readdirSync').returns([]); + sandbox.stub(fse, 'existsSync').returns(true); + var procStub = sandbox.stub(child_process, 'spawnSync').returns({ status: 0 }); + plugin.hooks['before:deploy:createDeploymentArtifacts']().then(() => { + expect(copyStub.called).to.be.true; + expect(writeStub.called).to.be.true; + expect(procStub.calledWith( + 'python2.7', [ path.resolve(__dirname, 'requirements.py'), path.resolve(__dirname, 'requirements.txt'), '/tmp/api/requirements.txt', '/tmp/api/.requirements' ] - )).to.be.ok; + )).to.be.true; sandbox.restore(); }); }); - it('packages user requirements for non-wsgi app', function() { + it('throws an error when a file already exists in the service root', () => { var plugin = new Plugin({ config: { servicePath: '/tmp' }, - service: {}, + service: { provider: { runtime: 'python2.7' } }, classes: { Error: Error }, - cli: { log: function () {} } + cli: { log: () => {} } }, {}); var sandbox = sinon.sandbox.create(); - var copy_stub = sandbox.stub(fse, 'copyAsync'); - var write_stub = sandbox.stub(fse, 'writeFileAsync'); - sandbox.stub(fse, 'existsSync').callsFake(function () { return true; }); - var proc_stub = sandbox.stub(child_process, 'spawnSync').callsFake(function () { - return { status: 0 }; + sandbox.stub(fse, 'copyAsync'); + sandbox.stub(fse, 'writeFileAsync'); + sandbox.stub(fse, 'symlinkSync').throws(); + sandbox.stub(fse, 'readlinkSync').throws(); + sandbox.stub(fse, 'readdirSync').returns(['flask']); + sandbox.stub(fse, 'existsSync').returns(true); + sandbox.stub(child_process, 'spawnSync').returns({ status: 0 }); + expect(plugin.hooks['before:deploy:createDeploymentArtifacts']()).to.be.rejected.and.notify(() => { + sandbox.restore(); }); - plugin.hooks['before:deploy:createDeploymentArtifacts']().then(function () { - expect(copy_stub.called).to.be.false; - expect(write_stub.called).to.be.false; - expect(proc_stub.calledWith( - 'python', + }); + + it('throws an error when a conflicting symlink already exists in the service root', () => { + var plugin = new Plugin({ + config: { servicePath: '/tmp' }, + service: { provider: { runtime: 'python2.7' } }, + classes: { Error: Error }, + cli: { log: () => {} } + }, {}); + + var sandbox = sinon.sandbox.create(); + sandbox.stub(fse, 'copyAsync'); + sandbox.stub(fse, 'writeFileAsync'); + sandbox.stub(fse, 'symlinkSync').throws(); + sandbox.stub(fse, 'readlinkSync').returns('not-flask'); + sandbox.stub(fse, 'readdirSync').returns(['flask']); + sandbox.stub(fse, 'existsSync').returns(true); + sandbox.stub(child_process, 'spawnSync').returns({ status: 0 }); + expect(plugin.hooks['before:deploy:createDeploymentArtifacts']()).to.be.rejected.and.notify(() => { + sandbox.restore(); + }); + }); + + it('packages user requirements for non-wsgi app', () => { + var plugin = new Plugin({ + config: { servicePath: '/tmp' }, + service: { provider: { runtime: 'python2.7' } }, + classes: { Error: Error }, + cli: { log: () => {} } + }, {}); + + var sandbox = sinon.sandbox.create(); + var copyStub = sandbox.stub(fse, 'copyAsync'); + var writeStub = sandbox.stub(fse, 'writeFileAsync'); + sandbox.stub(fse, 'symlinkSync').throws(); + sandbox.stub(fse, 'readlinkSync').returns('/tmp/.requirements/flask'); + sandbox.stub(fse, 'readdirSync').returns(['flask']); + sandbox.stub(fse, 'existsSync').returns(true); + var procStub = sandbox.stub(child_process, 'spawnSync').returns({ status: 0 }); + plugin.hooks['before:deploy:createDeploymentArtifacts']().then(() => { + expect(copyStub.called).to.be.false; + expect(writeStub.called).to.be.false; + expect(procStub.calledWith( + 'python2.7', [ path.resolve(__dirname, 'requirements.py'), '/tmp/requirements.txt', '/tmp/.requirements' ] - )).to.be.ok; + )).to.be.true; sandbox.restore(); }); }); - it('skips packaging for non-wsgi app without user requirements', function() { + it('skips packaging for non-wsgi app without user requirements', () => { var plugin = new Plugin({ config: { servicePath: '/tmp' }, - service: {}, + service: { provider: { runtime: 'python2.7' } }, classes: { Error: Error }, - cli: { log: function () {} } + cli: { log: () => {} } }, {}); var sandbox = sinon.sandbox.create(); - var copy_stub = sandbox.stub(fse, 'copyAsync'); - var write_stub = sandbox.stub(fse, 'writeFileAsync'); - sandbox.stub(fse, 'existsSync').callsFake(function () { return false; }); - var proc_stub = sandbox.stub(child_process, 'spawnSync').callsFake(function () { - return { status: 0 }; - }); - plugin.hooks['before:deploy:createDeploymentArtifacts']().then(function () { - expect(copy_stub.called).to.be.false; - expect(write_stub.called).to.be.false; - expect(proc_stub.called).to.be.false; - expect(plugin.serverless.service.package.include).not.to.have.members(['.requirements/**']); + var copyStub = sandbox.stub(fse, 'copyAsync'); + var writeStub = sandbox.stub(fse, 'writeFileAsync'); + sandbox.stub(fse, 'existsSync').returns(false); + var procStub = sandbox.stub(child_process, 'spawnSync').returns({ status: 0 }); + plugin.hooks['before:deploy:createDeploymentArtifacts']().then(() => { + expect(copyStub.called).to.be.false; + expect(writeStub.called).to.be.false; + expect(procStub.called).to.be.false; + expect(plugin.serverless.service.package.exclude).to.have.members(['.requirements/**']); sandbox.restore(); }); }); - it('rejects with non successful exit code', function() { + it('rejects with non successful exit code', () => { var plugin = new Plugin({ config: { servicePath: '/tmp' }, - service: {}, + service: { provider: { runtime: 'python2.7' } }, classes: { Error: Error }, - cli: { log: function () {} } + cli: { log: () => {} } }, {}); var sandbox = sinon.sandbox.create(); - sandbox.stub(fse, 'existsSync').callsFake(function () { return true; }); - sandbox.stub(child_process, 'spawnSync').callsFake(function () { - return { status: 1 }; - }); + sandbox.stub(fse, 'existsSync').returns(true); + sandbox.stub(child_process, 'spawnSync').returns({ status: 1 }); - expect(plugin.hooks['before:deploy:createDeploymentArtifacts']()).to.eventually.be.rejected.and.notify(function () { + expect(plugin.hooks['before:deploy:createDeploymentArtifacts']()).to.eventually.be.rejected.and.notify(() => { sandbox.restore(); }); }); - it('rejects with stderr output', function() { + it('rejects with stderr output', () => { var plugin = new Plugin({ config: { servicePath: '/tmp' }, - service: {}, + service: { provider: { runtime: 'python2.7' } }, classes: { Error: Error }, - cli: { log: function () {} } + cli: { log: () => {} } }, {}); var sandbox = sinon.sandbox.create(); - sandbox.stub(fse, 'existsSync').callsFake(function () { return true; }); - sandbox.stub(child_process, 'spawnSync').callsFake(function () { - return { status: 0, error: 'fail' }; - }); + sandbox.stub(fse, 'existsSync').returns(true); + sandbox.stub(child_process, 'spawnSync').returns({ status: 0, error: 'fail' }); - expect(plugin.hooks['before:deploy:createDeploymentArtifacts']()).to.eventually.be.rejected.and.notify(function () { + expect(plugin.hooks['before:deploy:createDeploymentArtifacts']()).to.eventually.be.rejected.and.notify(() => { sandbox.restore(); }); }); - it('skips packaging if disabled', function() { + it('skips packaging if disabled', () => { var plugin = new Plugin({ config: { servicePath: '/tmp' }, service: { + provider: { runtime: 'python2.7' }, custom: { wsgi: { app: 'api.app', packRequirements: false } } }, classes: { Error: Error }, - cli: { log: function () {} } + cli: { log: () => {} } }, {}); var sandbox = sinon.sandbox.create(); - var copy_stub = sandbox.stub(fse, 'copyAsync'); - var write_stub = sandbox.stub(fse, 'writeFileAsync'); - var exists_stub = sandbox.stub(fse, 'existsSync').callsFake(function () { return true; }); - var proc_stub = sandbox.stub(child_process, 'spawnSync').callsFake(function () { - return { status: 0 }; - }); - plugin.hooks['before:deploy:createDeploymentArtifacts']().then(function () { - expect(copy_stub.called).to.be.true; - expect(write_stub.called).to.be.true; - expect(exists_stub.called).to.be.false; - expect(proc_stub.called).to.be.false; + var copyStub = sandbox.stub(fse, 'copyAsync'); + var writeStub = sandbox.stub(fse, 'writeFileAsync'); + var existsStub = sandbox.stub(fse, 'existsSync').returns(true); + var procStub = sandbox.stub(child_process, 'spawnSync').returns({ status: 0 }); + plugin.hooks['before:deploy:createDeploymentArtifacts']().then(() => { + expect(copyStub.called).to.be.true; + expect(writeStub.called).to.be.true; + expect(existsStub.called).to.be.false; + expect(procStub.called).to.be.false; expect(plugin.serverless.service.package.include).not.to.have.members(['.requirements/**']); sandbox.restore(); }); }); - it('skips requirements cleanup if disabled', function() { + it('skips requirements cleanup if disabled', () => { var plugin = new Plugin({ config: { servicePath: '/tmp' }, service: { + provider: { runtime: 'python2.7' }, custom: { wsgi: { app: 'api.app', packRequirements: false } } }, classes: { Error: Error }, - cli: { log: function () {} } + cli: { log: () => {} } }, {}); var sandbox = sinon.sandbox.create(); - var remove_stub = sandbox.stub(fse, 'removeAsync'); - plugin.hooks['after:deploy:createDeploymentArtifacts']().then(function () { - expect(remove_stub.calledWith('/tmp/wsgi.py')).to.be.ok; - expect(remove_stub.calledWith('/tmp/.wsgi_app')).to.be.ok; - expect(remove_stub.calledWith('/tmp/.requirements')).to.be.false; + var removeStub = sandbox.stub(fse, 'removeAsync'); + plugin.hooks['after:deploy:createDeploymentArtifacts']().then(() => { + expect(removeStub.calledWith('/tmp/wsgi.py')).to.be.true; + expect(removeStub.calledWith('/tmp/.wsgi_app')).to.be.true; + expect(removeStub.calledWith('/tmp/.requirements')).to.be.false; sandbox.restore(); }); }); }); - describe('function deployment', function() { - it('skips packaging for non-wsgi function', function() { + describe('function deployment', () => { + it('skips packaging for non-wsgi function', () => { var functions = { app: {} }; var plugin = new Plugin({ config: { servicePath: '/tmp' }, service: { + provider: { runtime: 'python2.7' }, custom: { wsgi: { app: 'api.app' } }, functions: functions }, classes: { Error: Error }, - cli: { log: function () {} } + cli: { log: () => {} } }, { functionObj: functions.app }); var sandbox = sinon.sandbox.create(); - var copy_stub = sandbox.stub(fse, 'copyAsync'); - var write_stub = sandbox.stub(fse, 'writeFileAsync'); - var proc_stub = sandbox.stub(child_process, 'spawnSync'); - plugin.hooks['before:deploy:function:packageFunction']().then(function () { - expect(copy_stub.called).to.be.false; - expect(write_stub.called).to.be.false; - expect(proc_stub.called).to.be.false; + var copyStub = sandbox.stub(fse, 'copyAsync'); + var writeStub = sandbox.stub(fse, 'writeFileAsync'); + var procStub = sandbox.stub(child_process, 'spawnSync').returns({ status: 0 }); + plugin.hooks['before:deploy:function:packageFunction']().then(() => { + expect(copyStub.called).to.be.false; + expect(writeStub.called).to.be.false; + expect(procStub.called).to.be.true; sandbox.restore(); }); }); - it('packages wsgi handler', function() { + it('packages wsgi handler', () => { var functions = { app: { handler: 'wsgi.handler' } }; var plugin = new Plugin({ config: { servicePath: '/tmp' }, service: { + provider: { runtime: 'python2.7' }, custom: { wsgi: { app: 'api.app' } }, functions: functions }, classes: { Error: Error }, - cli: { log: function () {} } + cli: { log: () => {} } }, { functionObj: functions.app }); var sandbox = sinon.sandbox.create(); - var copy_stub = sandbox.stub(fse, 'copyAsync'); - var write_stub = sandbox.stub(fse, 'writeFileAsync'); - var proc_stub = sandbox.stub(child_process, 'spawnSync').callsFake(function () { - return { status: 0 }; - }); - plugin.hooks['before:deploy:function:packageFunction']().then(function () { - expect(copy_stub.calledWith( + var copyStub = sandbox.stub(fse, 'copyAsync'); + var writeStub = sandbox.stub(fse, 'writeFileAsync'); + sandbox.stub(fse, 'readdirSync').returns([]); + sandbox.stub(fse, 'existsSync').returns(true); + var procStub = sandbox.stub(child_process, 'spawnSync').returns({ status: 0 }); + plugin.hooks['before:deploy:function:packageFunction']().then(() => { + expect(copyStub.calledWith( path.resolve(__dirname, 'wsgi.py'), '/tmp/wsgi.py' - )).to.be.ok; - expect(write_stub.calledWith( + )).to.be.true; + expect(writeStub.calledWith( '/tmp/.wsgi_app', 'api.app' - )).to.be.ok; - expect(proc_stub.calledWith( - 'python', + )).to.be.true; + expect(procStub.calledWith( + 'python2.7', [ path.resolve(__dirname, 'requirements.py'), path.resolve(__dirname, 'requirements.txt'), + '/tmp/requirements.txt', '/tmp/.requirements' ] - )).to.be.ok; + )).to.be.true; sandbox.restore(); }); }); - it('cleans up after deployment', function() { + it('cleans up after deployment', () => { var functions = { app: { handler: 'wsgi.handler' } }; var plugin = new Plugin({ config: { servicePath: '/tmp' }, service: { + provider: { runtime: 'python2.7' }, custom: { wsgi: { app: 'api.app' } }, functions: functions }, classes: { Error: Error }, - cli: { log: function () {} } + cli: { log: () => {} } }, { functionObj: functions.app }); var sandbox = sinon.sandbox.create(); - var remove_stub = sandbox.stub(fse, 'removeAsync'); - plugin.hooks['after:deploy:function:packageFunction']().then(function () { - expect(remove_stub.calledWith('/tmp/wsgi.py')).to.be.ok; - expect(remove_stub.calledWith('/tmp/.wsgi_app')).to.be.ok; - expect(remove_stub.calledWith('/tmp/.requirements')).to.be.ok; + var removeStub = sandbox.stub(fse, 'removeAsync'); + var existsStub = sandbox.stub(fse, 'existsSync').returns(true); + sandbox.stub(fse, 'readdirSync').returns(['flask']); + var unlinkStub = sandbox.stub(fse, 'unlinkSync'); + plugin.hooks['after:deploy:function:packageFunction']().then(() => { + expect(existsStub.calledWith('/tmp/.requirements')).to.be.true; + expect(unlinkStub.calledWith('flask')).to.be.true; + expect(removeStub.calledWith('/tmp/wsgi.py')).to.be.true; + expect(removeStub.calledWith('/tmp/.wsgi_app')).to.be.true; + expect(removeStub.calledWith('/tmp/.requirements')).to.be.false; sandbox.restore(); }); }); }); - describe('serve', function() { - it('fails for non-wsgi app', function() { + describe('serve', () => { + it('fails for non-wsgi app', () => { var plugin = new Plugin({ - service: { provider: {} }, + config: { servicePath: '/tmp' }, + service: { provider: { runtime: 'python2.7' } }, classes: { Error: Error } }); return expect(plugin.hooks['wsgi:serve:serve']()).to.be.rejected; }); - it('executes python wrapper', function() { + it('executes python wrapper', () => { var plugin = new Plugin({ config: { servicePath: '/tmp' }, service: { - provider: {}, + provider: { runtime: 'python2.7' }, custom: { wsgi: { app: 'api.app' } } }, classes: { Error: Error } }, {}); - var stub = sinon.stub(child_process, 'spawnSync').callsFake(function() { return {}; }); - plugin.hooks['wsgi:serve:serve']().then(function () { + var stub = sinon.stub(child_process, 'spawnSync').returns({}); + plugin.hooks['wsgi:serve:serve']().then(() => { expect(stub.calledWith( - 'python', + 'python2.7', [ path.resolve(__dirname, 'serve.py'), '/tmp', @@ -441,25 +530,25 @@ describe('serverless-wsgi', function() { 5000 ], { stdio: 'inherit' } - )).to.be.ok; + )).to.be.true; stub.restore(); }); }); - it('handles errors', function() { + it('handles errors', () => { var plugin = new Plugin({ config: { servicePath: '/tmp' }, service: { - provider: {}, + provider: { runtime: 'python2.7' }, custom: { wsgi: { app: 'api.app' } } }, classes: { Error: Error } }, {}); - var stub = sinon.stub(child_process, 'spawnSync').callsFake(function() { return { error: 'Something failed' }; }); - expect(plugin.hooks['wsgi:serve:serve']()).to.eventually.be.rejected.and.notify(function () { + var stub = sinon.stub(child_process, 'spawnSync').returns({ error: 'Something failed' }); + expect(plugin.hooks['wsgi:serve:serve']()).to.eventually.be.rejected.and.notify(() => { expect(stub.calledWith( - 'python', + 'python2.7', [ path.resolve(__dirname, 'serve.py'), '/tmp', @@ -467,25 +556,25 @@ describe('serverless-wsgi', function() { 5000 ], { stdio: 'inherit' } - )).to.be.ok; + )).to.be.true; stub.restore(); }); }); - it('allows changing port', function() { + it('allows changing port', () => { var plugin = new Plugin({ config: { servicePath: '/tmp' }, service: { - provider: {}, + provider: { runtime: 'python2.7' }, custom: { wsgi: { app: 'api.app' } } }, classes: { Error: Error } }, { port: 8000 }); - var stub = sinon.stub(child_process, 'spawnSync').callsFake(function() { return {}; }); - plugin.hooks['wsgi:serve:serve']().then(function () { + var stub = sinon.stub(child_process, 'spawnSync').returns({}); + plugin.hooks['wsgi:serve:serve']().then(() => { expect(stub.calledWith( - 'python', + 'python2.7', [ path.resolve(__dirname, 'serve.py'), '/tmp', @@ -493,16 +582,17 @@ describe('serverless-wsgi', function() { 8000 ], { stdio: 'inherit' } - )).to.be.ok; + )).to.be.true; stub.restore(); }); }); - it('loads environment variables', function() { + it('loads environment variables', () => { var plugin = new Plugin({ config: { servicePath: '/tmp' }, service: { provider: { + runtime: 'python2.7', environment: { SOME_ENV_VAR: 42 } }, functions: { @@ -516,9 +606,9 @@ describe('serverless-wsgi', function() { }, { port: 8000 }); var sandbox = sinon.sandbox.create(); - sandbox.stub(child_process, 'spawnSync').callsFake(function() { return {}; }); + sandbox.stub(child_process, 'spawnSync').returns({}); sandbox.stub(process, 'env').value({}); - plugin.hooks['wsgi:serve:serve']().then(function () { + plugin.hooks['wsgi:serve:serve']().then(() => { expect(process.env.SOME_ENV_VAR).to.equal(42); expect(process.env.SECOND_VAR).to.equal(33); expect(process.env.THIRD_VAR).to.be.undefined; @@ -526,4 +616,63 @@ describe('serverless-wsgi', function() { }); }); }); + + describe('clean', () => { + it('cleans up everything', () => { + var functions = { + app: { handler: 'wsgi.handler' } + }; + var plugin = new Plugin({ + config: { servicePath: '/tmp' }, + service: { + provider: { runtime: 'python2.7' }, + custom: { wsgi: { app: 'api.app' } }, + functions: functions + }, + classes: { Error: Error }, + cli: { log: () => {} } + }, { functionObj: functions.app }); + + var sandbox = sinon.sandbox.create(); + var removeStub = sandbox.stub(fse, 'removeAsync'); + var existsStub = sandbox.stub(fse, 'existsSync').returns(true); + sandbox.stub(fse, 'readdirSync').returns(['flask']); + var unlinkStub = sandbox.stub(fse, 'unlinkSync'); + plugin.hooks['wsgi:clean:clean']().then(() => { + expect(existsStub.calledWith('/tmp/.requirements')).to.be.true; + expect(unlinkStub.calledWith('flask')).to.be.true; + expect(removeStub.calledWith('/tmp/wsgi.py')).to.be.true; + expect(removeStub.calledWith('/tmp/.wsgi_app')).to.be.true; + expect(removeStub.calledWith('/tmp/.requirements')).to.be.true; + sandbox.restore(); + }); + }); + + it('skips requirements cache if not enabled', () => { + var functions = { + app: { handler: 'wsgi.handler' } + }; + var plugin = new Plugin({ + config: { servicePath: '/tmp' }, + service: { + provider: { runtime: 'python2.7' }, + custom: { wsgi: { app: 'api.app', packRequirements: false } }, + functions: functions + }, + classes: { Error: Error }, + cli: { log: () => {} } + }, { functionObj: functions.app }); + + var sandbox = sinon.sandbox.create(); + var removeStub = sandbox.stub(fse, 'removeAsync'); + var existsStub = sandbox.stub(fse, 'existsSync').returns(true); + plugin.hooks['wsgi:clean:clean']().then(() => { + expect(existsStub.calledWith('/tmp/.requirements')).to.be.false; + expect(removeStub.calledWith('/tmp/wsgi.py')).to.be.true; + expect(removeStub.calledWith('/tmp/.wsgi_app')).to.be.true; + expect(removeStub.calledWith('/tmp/.requirements')).to.be.false; + sandbox.restore(); + }); + }); + }); }); diff --git a/package-lock.json b/package-lock.json index af8cadf..afea440 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "serverless-wsgi", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 71265ef..3261469 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "serverless-wsgi", - "version": "1.3.1", + "version": "1.4.0", "engines": { "node": ">=4.0" }, diff --git a/wsgi.py b/wsgi.py index d861ebd..aebfc5b 100644 --- a/wsgi.py +++ b/wsgi.py @@ -16,13 +16,6 @@ PY2 = sys.version_info[0] == 2 TEXT_MIME_TYPES = ['application/json', 'application/xml'] -root = os.path.abspath(os.path.dirname(__file__)) -with open(os.path.join(root, '.wsgi_app'), 'r') as f: - app_path = f.read() -app_dir = os.path.dirname(app_path) -requirements_path = os.path.join(root, app_dir, '.requirements') -sys.path.insert(0, requirements_path) - import importlib # noqa: E402 if PY2: from StringIO import StringIO # noqa: E402 @@ -34,6 +27,10 @@ from werkzeug.urls import url_encode # noqa: E402 from werkzeug._compat import wsgi_encoding_dance # noqa: E402 +root = os.path.abspath(os.path.dirname(__file__)) +with open(os.path.join(root, '.wsgi_app'), 'r') as f: + app_path = f.read() + wsgi_fqn = app_path.rsplit('.', 1) wsgi_fqn_parts = wsgi_fqn[0].rsplit('/', 1) if len(wsgi_fqn_parts) == 2: