From 302c8606ff4c975b6310fe2cdfe58f92cede290b Mon Sep 17 00:00:00 2001 From: Paul Melnikow Date: Mon, 27 Aug 2018 07:46:06 -0400 Subject: [PATCH] Rewrite [pypi]; affects [npm] (#1922) --- lib/all-badge-examples.js | 51 +----- lib/deprecation-helpers.js | 8 + lib/licenses.js | 22 ++- lib/licenses.spec.js | 26 ++- lib/version.js | 11 ++ lib/version.spec.js | 15 +- server.js | 181 ------------------- services/base.js | 4 +- services/deprecated-service.js | 31 ++++ services/errors.js | 15 ++ services/npm/npm-license.service.js | 17 +- services/npm/npm-version.service.js | 14 +- services/pypi/pypi-base.js | 44 +++++ services/pypi/pypi-djversions.service.js | 53 ++++++ services/pypi/pypi-downloads.service.js | 12 ++ services/pypi/pypi-format.service.js | 53 ++++++ {lib => services/pypi}/pypi-helpers.js | 17 ++ {lib => services/pypi}/pypi-helpers.spec.js | 35 ++++ services/pypi/pypi-implementation.service.js | 50 +++++ services/pypi/pypi-license.service.js | 35 ++++ services/pypi/pypi-pyversions.service.js | 63 +++++++ services/pypi/pypi-status.service.js | 71 ++++++++ services/pypi/pypi-version.service.js | 39 ++++ services/pypi/pypi-wheel.service.js | 48 +++++ services/pypi/pypi.tester.js | 49 ++--- 25 files changed, 691 insertions(+), 273 deletions(-) create mode 100644 services/deprecated-service.js create mode 100644 services/pypi/pypi-base.js create mode 100644 services/pypi/pypi-djversions.service.js create mode 100644 services/pypi/pypi-downloads.service.js create mode 100644 services/pypi/pypi-format.service.js rename {lib => services/pypi}/pypi-helpers.js (77%) rename {lib => services/pypi}/pypi-helpers.spec.js (71%) create mode 100644 services/pypi/pypi-implementation.service.js create mode 100644 services/pypi/pypi-license.service.js create mode 100644 services/pypi/pypi-pyversions.service.js create mode 100644 services/pypi/pypi-status.service.js create mode 100644 services/pypi/pypi-version.service.js create mode 100644 services/pypi/pypi-wheel.service.js diff --git a/lib/all-badge-examples.js b/lib/all-badge-examples.js index ec8193ebc56b4..f06e559c959d9 100644 --- a/lib/all-badge-examples.js +++ b/lib/all-badge-examples.js @@ -614,21 +614,6 @@ const allBadgeExamples = [ title: 'PowerShell Gallery', previewUri: '/powershellgallery/dt/ACMESharp.svg', }, - { - title: 'PyPI', - previewUri: '/pypi/dm/Django.svg', - keywords: ['python'], - }, - { - title: 'PyPI', - previewUri: '/pypi/dw/Django.svg', - keywords: ['python'], - }, - { - title: 'PyPI', - previewUri: '/pypi/dd/Django.svg', - keywords: ['python'], - }, { title: 'Conda', previewUri: '/conda/dn/conda-forge/python.svg', @@ -1048,11 +1033,6 @@ const allBadgeExamples = [ title: 'Bower', previewUri: '/bower/l/bootstrap.svg', }, - { - title: 'PyPI - License', - previewUri: '/pypi/l/Django.svg', - keywords: ['python', 'pypi'], - }, { title: 'Hex.pm', previewUri: '/hexpm/l/plug.svg', @@ -1222,9 +1202,14 @@ const allBadgeExamples = [ }, examples: [ { - title: 'PyPI', - previewUri: '/pypi/v/nine.svg', - keywords: ['python'], + title: 'npm bundle size (minified)', + previewUri: '/bundlephobia/min/react.svg', + keywords: ['node'], + }, + { + title: 'npm bundle size (minified + gzip)', + previewUri: '/bundlephobia/minzip/react.svg', + keywords: ['node'], }, { title: 'Conda', @@ -1552,24 +1537,8 @@ const allBadgeExamples = [ }, examples: [ { - title: 'PyPI - Wheel', - previewUri: '/pypi/wheel/Django.svg', - keywords: ['python', 'pypi'], - }, - { - title: 'PyPI - Format', - previewUri: '/pypi/format/Django.svg', - keywords: ['python', 'pypi'], - }, - { - title: 'PyPI - Implementation', - previewUri: '/pypi/implementation/Django.svg', - keywords: ['python', 'pypi'], - }, - { - title: 'PyPI - Status', - previewUri: '/pypi/status/Django.svg', - keywords: ['python', 'pypi'], + title: 'VersionEye', + previewUri: '/versioneye/d/ruby/rails.svg', }, { title: 'Wheelmap', diff --git a/lib/deprecation-helpers.js b/lib/deprecation-helpers.js index c9f26194c4f17..bf187516fed3f 100644 --- a/lib/deprecation-helpers.js +++ b/lib/deprecation-helpers.js @@ -2,6 +2,7 @@ const { makeBadgeData, setBadgeColor } = require('./badge-data') const { deprecatedServices } = require('./deprecated-services') +const { Deprecated } = require('../services/errors') const isDeprecated = function( service, @@ -21,7 +22,14 @@ const getDeprecatedBadge = function(label, data) { return badgeData } +function enforceDeprecation(effectiveDate) { + if (Date.now() >= effectiveDate.getTime()) { + throw new Deprecated() + } +} + module.exports = { isDeprecated, getDeprecatedBadge, + enforceDeprecation, } diff --git a/lib/licenses.js b/lib/licenses.js index 544345b63c189..afb4eb3deeade 100644 --- a/lib/licenses.js +++ b/lib/licenses.js @@ -1,4 +1,7 @@ 'use strict' + +const { toArray } = require('./badge-data') + const licenseTypes = { // permissive licenses - not public domain and not copyleft permissive: { @@ -59,4 +62,21 @@ const defaultLicenseColor = 'lightgrey' const licenseToColor = spdxId => licenseToColorMap[spdxId] || defaultLicenseColor -module.exports = { licenseToColor } +function renderLicenseBadge({ license, licenses }) { + if (licenses === undefined) { + licenses = toArray(license) + } + + if (licenses.length === 0) { + return { message: 'missing', color: 'red' } + } + + return { + message: licenses.join(', '), + // TODO This does not provide a color when more than one license is + // present. Probably that should be fixed. + color: licenseToColor(licenses), + } +} + +module.exports = { licenseToColor, renderLicenseBadge } diff --git a/lib/licenses.spec.js b/lib/licenses.spec.js index c176d76cb9c15..45048a1afd04f 100644 --- a/lib/licenses.spec.js +++ b/lib/licenses.spec.js @@ -1,7 +1,7 @@ 'use strict' -const { test, given } = require('sazerac') -const { licenseToColor } = require('./licenses') +const { test, given, forCases } = require('sazerac') +const { licenseToColor, renderLicenseBadge } = require('./licenses') describe('license helpers', function() { test(licenseToColor, () => { @@ -11,4 +11,26 @@ describe('license helpers', function() { given('unknown-license').expect('lightgrey') given(null).expect('lightgrey') }) + + test(renderLicenseBadge, () => { + forCases([ + given({ license: undefined }), + given({ licenses: [] }), + given({}), + ]).expect({ + message: 'missing', + color: 'red', + }) + forCases([ + given({ license: 'WTFPL' }), + given({ licenses: ['WTFPL'] }), + ]).expect({ + message: 'WTFPL', + color: '7cd958', + }) + given({ licenses: ['MPL-2.0', 'MIT'] }).expect({ + message: 'MPL-2.0, MIT', + color: 'lightgrey', + }) + }) }) diff --git a/lib/version.js b/lib/version.js index 3f993c311afad..b217658008c04 100644 --- a/lib/version.js +++ b/lib/version.js @@ -8,6 +8,8 @@ 'use strict' const semver = require('semver') +const { addv } = require('./text-formatters') +const { version: versionColor } = require('./color-formatters') // Given a list of versions (as strings), return the latest version. // Return undefined if no version could be found. @@ -139,10 +141,19 @@ function rangeStart(v) { return range.set[0][0].semver.version } +function renderVersionBadge({ version, tag, defaultLabel }) { + return { + label: tag ? `${defaultLabel}@${tag}` : undefined, + message: addv(version), + color: versionColor(version), + } +} + module.exports = { latest, listCompare, slice, minor, rangeStart, + renderVersionBadge, } diff --git a/lib/version.spec.js b/lib/version.spec.js index 970714b89ed8d..29903adbe10ed 100644 --- a/lib/version.spec.js +++ b/lib/version.spec.js @@ -1,7 +1,7 @@ 'use strict' const { test, given } = require('sazerac') -const { latest, slice, rangeStart } = require('./version') +const { latest, slice, rangeStart, renderVersionBadge } = require('./version') const includePre = true describe('Version helpers', function() { @@ -119,4 +119,17 @@ describe('Version helpers', function() { test(rangeStart, () => { given('^2.4.7').expect('2.4.7') }) + + test(renderVersionBadge, () => { + given({ version: '1.2.3' }).expect({ + label: undefined, + message: 'v1.2.3', + color: 'blue', + }) + given({ version: '1.2.3', tag: 'next', defaultLabel: 'npm' }).expect({ + label: 'npm@next', + message: 'v1.2.3', + color: 'blue', + }) + }) }) diff --git a/server.js b/server.js index d846136d40a0e..1660e051f0717 100644 --- a/server.js +++ b/server.js @@ -105,10 +105,6 @@ const { mapGithubCommitsSince, mapGithubReleaseDate, } = require('./lib/github-provider'); -const { - sortDjangoVersions, - parseClassifiers, -} = require('./lib/pypi-helpers.js'); const serverStartTime = new Date((new Date()).toGMTString()); @@ -1589,183 +1585,6 @@ cache(function(data, match, sendBadge, request) { }); })); -// PyPI integration. -camp.route(/^\/pypi\/([^/]+)\/(.*)\.(svg|png|gif|jpg|json)$/, -cache(function(data, match, sendBadge, request) { - var info = match[1]; - var egg = match[2]; // eg, `gevent`, `Django`. - var format = match[3]; - var apiUrl = 'https://pypi.org/pypi/' + egg + '/json'; - var badgeData = getBadgeData('pypi', data); - request(apiUrl, function(err, res, buffer) { - if (err != null) { - badgeData.text[1] = 'inaccessible'; - sendBadge(format, badgeData); - return; - } - try { - var parsedData = JSON.parse(buffer); - if (info === 'dm' || info === 'dw' || info ==='dd') { - // See #716 for the details of the loss of service. - badgeData.text[0] = getLabel('downloads', data); - badgeData.text[1] = 'no longer available'; - //var downloads; - //switch (info.charAt(1)) { - // case 'm': - // downloads = data.info.downloads.last_month; - // badgeData.text[1] = metric(downloads) + '/month'; - // break; - // case 'w': - // downloads = parsedData.info.downloads.last_week; - // badgeData.text[1] = metric(downloads) + '/week'; - // break; - // case 'd': - // downloads = parsedData.info.downloads.last_day; - // badgeData.text[1] = metric(downloads) + '/day'; - // break; - //} - //badgeData.colorscheme = downloadCountColor(downloads); - sendBadge(format, badgeData); - } else if (info === 'v') { - var version = parsedData.info.version; - badgeData.text[1] = versionText(version); - badgeData.colorscheme = versionColor(version); - sendBadge(format, badgeData); - } else if (info === 'l') { - var license = parsedData.info.license; - badgeData.text[0] = getLabel('license', data); - if (license === null || license === 'UNKNOWN') { - badgeData.text[1] = 'Unknown'; - } else { - badgeData.text[1] = license; - badgeData.colorscheme = 'blue'; - } - sendBadge(format, badgeData); - } else if (info === 'wheel') { - let releases = parsedData.releases[parsedData.info.version]; - let hasWheel = false; - for (let i = 0; i < releases.length; i++) { - if (releases[i].packagetype === 'wheel' || - releases[i].packagetype === 'bdist_wheel') { - hasWheel = true; - break; - } - } - badgeData.text[0] = getLabel('wheel', data); - badgeData.text[1] = hasWheel ? 'yes' : 'no'; - badgeData.colorscheme = hasWheel ? 'brightgreen' : 'red'; - sendBadge(format, badgeData); - } else if (info === 'format') { - let releases = parsedData.releases[parsedData.info.version]; - let hasWheel = false; - var hasEgg = false; - for (var i = 0; i < releases.length; i++) { - if (releases[i].packagetype === 'wheel' || - releases[i].packagetype === 'bdist_wheel') { - hasWheel = true; - break; - } - if (releases[i].packagetype === 'egg' || - releases[i].packagetype === 'bdist_egg') { - hasEgg = true; - } - } - badgeData.text[0] = getLabel('format', data); - if (hasWheel) { - badgeData.text[1] = 'wheel'; - badgeData.colorscheme = 'brightgreen'; - } else if (hasEgg) { - badgeData.text[1] = 'egg'; - badgeData.colorscheme = 'red'; - } else { - badgeData.text[1] = 'source'; - badgeData.colorscheme = 'yellow'; - } - sendBadge(format, badgeData); - } else if (info === 'pyversions') { - let versions = parseClassifiers( - parsedData, - /^Programming Language :: Python :: ([\d.]+)$/ - ); - - // We only show v2 if eg. v2.4 does not appear. - // See https://github.com/badges/shields/pull/489 for more. - ['2', '3'].forEach(function(version) { - var hasSubVersion = function(v) { return v.indexOf(version + '.') === 0; }; - if (versions.some(hasSubVersion)) { - versions = versions.filter(function(v) { return v !== version; }); - } - }); - if (!versions.length) { - versions.push('not found'); - } - badgeData.text[0] = getLabel('python', data); - badgeData.text[1] = versions.sort().join(', '); - badgeData.colorscheme = 'blue'; - sendBadge(format, badgeData); - } else if (info === 'djversions') { - let versions = parseClassifiers( - parsedData, - /^Framework :: Django :: ([\d.]+)$/ - ); - - if (!versions.length) { - versions.push('not found'); - } - - // sort low to high - versions = sortDjangoVersions(versions); - - badgeData.text[0] = getLabel('django versions', data); - badgeData.text[1] = versions.join(', '); - badgeData.colorscheme = 'blue'; - sendBadge(format, badgeData); - } else if (info === 'implementation') { - let implementations = parseClassifiers( - parsedData, - /^Programming Language :: Python :: Implementation :: (\S+)$/ - ); - - if (!implementations.length) { - implementations.push('cpython'); // assume CPython - } - badgeData.text[0] = getLabel('implementation', data); - badgeData.text[1] = implementations.sort().join(', '); - badgeData.colorscheme = 'blue'; - sendBadge(format, badgeData); - } else if (info === 'status') { - let pattern = /^Development Status :: ([1-7]) - (\S+)$/; - var statusColors = { - '1': 'red', '2': 'red', '3': 'red', '4': 'yellow', - '5': 'brightgreen', '6': 'brightgreen', '7': 'red' }; - var statusCode = '1', statusText = 'unknown'; - for (let i = 0; i < parsedData.info.classifiers.length; i++) { - let matched = pattern.exec(parsedData.info.classifiers[i]); - if (matched && matched[1] && matched[2]) { - statusCode = matched[1]; - statusText = matched[2].toLowerCase().replace('-', '--'); - if (statusText === 'production/stable') { - statusText = 'stable'; - } - break; - } - } - badgeData.text[0] = getLabel('status', data); - badgeData.text[1] = statusText; - badgeData.colorscheme = statusColors[statusCode]; - sendBadge(format, badgeData); - } else { - // That request is incorrect. - badgeData.text[1] = 'request unknown'; - sendBadge(format, badgeData); - } - } catch(e) { - badgeData.text[1] = 'invalid'; - sendBadge(format, badgeData); - } - }); -})); - // LuaRocks version integration. camp.route(/^\/luarocks\/v\/([^/]+)\/([^/]+)(?:\/(.+))?\.(svg|png|gif|jpg|json)$/, cache(function(data, match, sendBadge, request) { diff --git a/services/base.js b/services/base.js index 9b8f4b0d1226f..07d79be2b3f2c 100644 --- a/services/base.js +++ b/services/base.js @@ -7,6 +7,7 @@ const { InvalidResponse, Inaccessible, InvalidParameter, + Deprecated, } = require('./errors') const queryString = require('query-string') const { @@ -187,7 +188,8 @@ class BaseService { } } else if ( error instanceof InvalidResponse || - error instanceof Inaccessible + error instanceof Inaccessible || + error instanceof Deprecated ) { trace.logTrace('outbound', emojic.noGoodWoman, 'Handled error', error) return { diff --git a/services/deprecated-service.js b/services/deprecated-service.js new file mode 100644 index 0000000000000..c0adb08abf5d2 --- /dev/null +++ b/services/deprecated-service.js @@ -0,0 +1,31 @@ +'use strict' + +const BaseService = require('./base') +const { Deprecated } = require('./errors') + +// Only `url` is required. +function deprecatedService({ url, label, category, examples = [] }) { + return class DeprecatedService extends BaseService { + static get category() { + return category + } + + static get url() { + return url + } + + static get defaultBadgeData() { + return { label } + } + + static get examples() { + return examples + } + + async handle() { + throw new Deprecated() + } + } +} + +module.exports = deprecatedService diff --git a/services/errors.js b/services/errors.js index ad8c0707740f4..3aa5fd008aa56 100644 --- a/services/errors.js +++ b/services/errors.js @@ -83,9 +83,24 @@ class InvalidParameter extends ShieldsRuntimeError { } } +class Deprecated extends ShieldsRuntimeError { + get name() { + return 'Deprecated' + } + get defaultPrettyMessage() { + return 'no longer available' + } + + constructor(props) { + const message = 'Deprecated' + super(props, message) + } +} + module.exports = { NotFound, InvalidResponse, Inaccessible, InvalidParameter, + Deprecated, } diff --git a/services/npm/npm-license.service.js b/services/npm/npm-license.service.js index 32e79b1001b18..e1d4ece2fb18e 100644 --- a/services/npm/npm-license.service.js +++ b/services/npm/npm-license.service.js @@ -1,6 +1,6 @@ 'use strict' -const { licenseToColor } = require('../../lib/licenses') +const { renderLicenseBadge } = require('../../lib/licenses') const { toArray } = require('../../lib/badge-data') const NpmBase = require('./npm-base') @@ -9,10 +9,6 @@ module.exports = class NpmLicense extends NpmBase { return 'license' } - static get defaultBadgeData() { - return { label: 'license' } - } - static get url() { return this.buildUrl('npm/l', { withTag: false }) } @@ -36,16 +32,7 @@ module.exports = class NpmLicense extends NpmBase { } static render({ licenses }) { - if (licenses.length === 0) { - return { message: 'missing', color: 'red' } - } - - return { - message: licenses.join(', '), - // TODO This does not provide a color when more than one license is - // present. Probably that should be fixed. - color: licenseToColor(licenses), - } + return renderLicenseBadge({ licenses }) } async handle(namedParams, queryParams) { diff --git a/services/npm/npm-version.service.js b/services/npm/npm-version.service.js index ca179b99131c1..447640f854ce7 100644 --- a/services/npm/npm-version.service.js +++ b/services/npm/npm-version.service.js @@ -1,8 +1,7 @@ 'use strict' const Joi = require('joi') -const { addv } = require('../../lib/text-formatters') -const { version: versionColor } = require('../../lib/color-formatters') +const { renderVersionBadge } = require('../../lib/version') const { NotFound } = require('../errors') const NpmBase = require('./npm-base') @@ -66,11 +65,12 @@ module.exports = class NpmVersion extends NpmBase { } static render({ tag, version }) { - return { - label: tag ? `npm@${tag}` : undefined, - message: addv(version), - color: versionColor(version), - } + const { label: defaultLabel } = this.defaultBadgeData + return renderVersionBadge({ + tag, + version, + defaultLabel, + }) } async handle(namedParams, queryParams) { diff --git a/services/pypi/pypi-base.js b/services/pypi/pypi-base.js new file mode 100644 index 0000000000000..7d4e7489d4ebc --- /dev/null +++ b/services/pypi/pypi-base.js @@ -0,0 +1,44 @@ +'use strict' + +const Joi = require('joi') +const BaseJsonService = require('../base-json') + +const schema = Joi.object({ + info: Joi.object({ + version: Joi.string().required(), + license: Joi.string().required(), + classifiers: Joi.array() + .items(Joi.string().required()) + .required(), + }).required(), + releases: Joi.object() + .pattern( + Joi.string(), + Joi.array() + .items( + Joi.object({ + packagetype: Joi.string().required(), + }) + ) + .required() + ) + .required(), +}).required() + +module.exports = class PypiBase extends BaseJsonService { + static buildUrl(base) { + return { + base, + format: '(.*)', + capture: ['egg'], + } + } + + async fetch({ egg }) { + return this._requestJson({ + schema, + url: `https://pypi.org/pypi/${egg}/json`, + errorMessages: { 404: 'package or version not found' }, + }) + } +} diff --git a/services/pypi/pypi-djversions.service.js b/services/pypi/pypi-djversions.service.js new file mode 100644 index 0000000000000..461c4a6f5b389 --- /dev/null +++ b/services/pypi/pypi-djversions.service.js @@ -0,0 +1,53 @@ +'use strict' + +const PypiBase = require('./pypi-base') +const { sortDjangoVersions, parseClassifiers } = require('./pypi-helpers') + +module.exports = class PypiDjangoVersions extends PypiBase { + static get category() { + return 'platform-support' + } + + static get url() { + return this.buildUrl('pypi/djversions') + } + + static get defaultBadgeData() { + return { label: 'django versions' } + } + + static get examples() { + return [ + { + title: 'PyPI - Django Version', + previewUrl: 'djangorestframework', + keywords: ['python', 'django'], + }, + ] + } + + static render({ versions }) { + if (versions.length > 0) { + return { + message: sortDjangoVersions(versions).join(' | '), + color: 'blue', + } + } else { + return { + message: 'missing', + color: 'red', + } + } + } + + async handle({ egg }) { + const packageData = await this.fetch({ egg }) + + const versions = parseClassifiers( + packageData, + /^Framework :: Django :: ([\d.]+)$/ + ) + + return this.constructor.render({ versions }) + } +} diff --git a/services/pypi/pypi-downloads.service.js b/services/pypi/pypi-downloads.service.js new file mode 100644 index 0000000000000..b38a0794a6c78 --- /dev/null +++ b/services/pypi/pypi-downloads.service.js @@ -0,0 +1,12 @@ +'use strict' + +const deprecatedService = require('../deprecated-service') +const PypiBase = require('./pypi-base') + +// https://github.com/badges/shields/issues/716 +module.exports = ['pypi/dm', 'pypi/dw', 'pypi/dd'].map(base => + deprecatedService({ + category: 'downloads', + url: PypiBase.buildUrl(base), + }) +) diff --git a/services/pypi/pypi-format.service.js b/services/pypi/pypi-format.service.js new file mode 100644 index 0000000000000..39afa4474556a --- /dev/null +++ b/services/pypi/pypi-format.service.js @@ -0,0 +1,53 @@ +'use strict' + +const PypiBase = require('./pypi-base') +const { getPackageFormats } = require('./pypi-helpers') + +module.exports = class PypiFormat extends PypiBase { + static get category() { + return 'other' + } + + static get url() { + return this.buildUrl('pypi/format') + } + + static get defaultBadgeData() { + return { label: 'format' } + } + + static get examples() { + return [ + { + title: 'PyPI - Format', + previewUrl: 'Django', + keywords: ['python'], + }, + ] + } + + static render({ hasWheel, hasEgg }) { + if (hasWheel) { + return { + message: 'wheel', + color: 'brightgreen', + } + } else if (hasEgg) { + return { + message: 'egg', + color: 'red', + } + } else { + return { + message: 'source', + color: 'yellow', + } + } + } + + async handle({ egg }) { + const packageData = await this.fetch({ egg }) + const { hasWheel, hasEgg } = getPackageFormats(packageData) + return this.constructor.render({ hasWheel, hasEgg }) + } +} diff --git a/lib/pypi-helpers.js b/services/pypi/pypi-helpers.js similarity index 77% rename from lib/pypi-helpers.js rename to services/pypi/pypi-helpers.js index c89f003142db5..8893478069c39 100644 --- a/lib/pypi-helpers.js +++ b/services/pypi/pypi-helpers.js @@ -50,8 +50,25 @@ const parseClassifiers = function(parsedData, pattern) { return results } +function getPackageFormats(packageData) { + const { + info: { version }, + releases, + } = packageData + const releasesForVersion = releases[version] + return { + hasWheel: releasesForVersion.some(({ packagetype }) => + ['wheel', 'bdist_wheel'].includes(packagetype) + ), + hasEgg: releasesForVersion.some(({ packagetype }) => + ['egg', 'bdist_egg'].includes(packagetype) + ), + } +} + module.exports = { parseClassifiers, parseDjangoVersionString, sortDjangoVersions, + getPackageFormats, } diff --git a/lib/pypi-helpers.spec.js b/services/pypi/pypi-helpers.spec.js similarity index 71% rename from lib/pypi-helpers.spec.js rename to services/pypi/pypi-helpers.spec.js index acc19bd56d6e4..30eea371051d3 100644 --- a/lib/pypi-helpers.spec.js +++ b/services/pypi/pypi-helpers.spec.js @@ -5,6 +5,7 @@ const { parseClassifiers, parseDjangoVersionString, sortDjangoVersions, + getPackageFormats, } = require('./pypi-helpers.js') const classifiersFixture = { @@ -70,6 +71,7 @@ describe('PyPI helpers', function() { }) test(sortDjangoVersions, function() { + // Each of these includes a different variant: 2.0, 2, and 2.0rc1. given(['2.0', '1.9', '10', '1.11', '2.1', '2.11']).expect([ '1.9', '1.11', @@ -97,4 +99,37 @@ describe('PyPI helpers', function() { '10', ]) }) + + test(getPackageFormats, () => { + given({ + info: { version: '2.19.1' }, + releases: { + '1.0.4': [{ packagetype: 'sdist' }], + '2.19.1': [{ packagetype: 'bdist_wheel' }, { packagetype: 'sdist' }], + }, + }).expect({ hasWheel: true, hasEgg: false }) + given({ + info: { version: '1.0.4' }, + releases: { + '1.0.4': [{ packagetype: 'sdist' }], + '2.19.1': [{ packagetype: 'bdist_wheel' }, { packagetype: 'sdist' }], + }, + }).expect({ hasWheel: false, hasEgg: false }) + given({ + info: { version: '0.8.2' }, + releases: { + '0.8': [{ packagetype: 'sdist' }], + '0.8.1': [ + { packagetype: 'bdist_egg' }, + { packagetype: 'bdist_egg' }, + { packagetype: 'sdist' }, + ], + '0.8.2': [ + { packagetype: 'bdist_egg' }, + { packagetype: 'bdist_egg' }, + { packagetype: 'sdist' }, + ], + }, + }).expect({ hasWheel: false, hasEgg: true }) + }) }) diff --git a/services/pypi/pypi-implementation.service.js b/services/pypi/pypi-implementation.service.js new file mode 100644 index 0000000000000..5242dd54425bc --- /dev/null +++ b/services/pypi/pypi-implementation.service.js @@ -0,0 +1,50 @@ +'use strict' + +const PypiBase = require('./pypi-base') +const { parseClassifiers } = require('./pypi-helpers') + +module.exports = class PypiImplementation extends PypiBase { + static get category() { + return 'other' + } + + static get url() { + return this.buildUrl('pypi/implementation') + } + + static get defaultBadgeData() { + return { label: 'implementation' } + } + + static get examples() { + return [ + { + title: 'PyPI - Implementation', + previewUrl: 'Django', + keywords: ['python'], + }, + ] + } + + static render({ implementations }) { + return { + message: implementations.sort().join(' | '), + color: 'blue', + } + } + + async handle({ egg }) { + const packageData = await this.fetch({ egg }) + + let implementations = parseClassifiers( + packageData, + /^Programming Language :: Python :: Implementation :: (\S+)$/ + ) + if (implementations.length === 0) { + // Assume CPython. + implementations = ['cpython'] + } + + return this.constructor.render({ implementations }) + } +} diff --git a/services/pypi/pypi-license.service.js b/services/pypi/pypi-license.service.js new file mode 100644 index 0000000000000..cc9af0e4c8a09 --- /dev/null +++ b/services/pypi/pypi-license.service.js @@ -0,0 +1,35 @@ +'use strict' + +const { renderLicenseBadge } = require('../../lib/licenses') +const PypiBase = require('./pypi-base') + +module.exports = class PypiLicense extends PypiBase { + static get category() { + return 'license' + } + + static get url() { + return this.buildUrl('pypi/l') + } + + static get examples() { + return [ + { + title: 'PyPI - License', + previewUrl: 'Django', + keywords: ['python'], + }, + ] + } + + static render({ license }) { + return renderLicenseBadge({ license }) + } + + async handle({ egg }) { + const { + info: { license }, + } = await this.fetch({ egg }) + return this.constructor.render({ license }) + } +} diff --git a/services/pypi/pypi-pyversions.service.js b/services/pypi/pypi-pyversions.service.js new file mode 100644 index 0000000000000..c68e1b107f265 --- /dev/null +++ b/services/pypi/pypi-pyversions.service.js @@ -0,0 +1,63 @@ +'use strict' + +const PypiBase = require('./pypi-base') +const { parseClassifiers } = require('./pypi-helpers') + +module.exports = class PypiPythonVersions extends PypiBase { + static get category() { + return 'platform-support' + } + + static get url() { + return this.buildUrl('pypi/pyversions') + } + + static get defaultBadgeData() { + return { label: 'python' } + } + + static get examples() { + return [ + { + title: 'PyPI - Python Version', + previewUrl: 'Django', + keywords: ['python'], + }, + ] + } + + static render({ versions }) { + const versionSet = new Set(versions) + // We only show v2 if eg. v2.4 does not appear. + // See https://github.com/badges/shields/pull/489 for more. + ;['2', '3'].forEach(majorVersion => { + if (Array.from(versions).some(v => v.startsWith(`${majorVersion}.`))) { + versionSet.delete(majorVersion) + } + }) + if (versionSet.size) { + return { + message: Array.from(versionSet) + .sort() + .join(' | '), + color: 'blue', + } + } else { + return { + message: 'missing', + color: 'red', + } + } + } + + async handle({ egg }) { + const packageData = await this.fetch({ egg }) + + const versions = parseClassifiers( + packageData, + /^Programming Language :: Python :: ([\d.]+)$/ + ) + + return this.constructor.render({ versions }) + } +} diff --git a/services/pypi/pypi-status.service.js b/services/pypi/pypi-status.service.js new file mode 100644 index 0000000000000..cd9890e8f176b --- /dev/null +++ b/services/pypi/pypi-status.service.js @@ -0,0 +1,71 @@ +'use strict' + +const PypiBase = require('./pypi-base') +const { parseClassifiers } = require('./pypi-helpers') + +module.exports = class PypiStatus extends PypiBase { + static get category() { + return 'other' + } + + static get url() { + return this.buildUrl('pypi/status') + } + + static get defaultBadgeData() { + return { label: 'status' } + } + + static get examples() { + return [ + { + title: 'PyPI - Status', + previewUrl: 'Django', + keywords: ['python'], + }, + ] + } + + static render({ status = '' }) { + status = status.toLowerCase() + + const color = { + planning: 'red', + 'pre-alpha': 'red', + alpha: 'red', + beta: 'yellow', + stable: 'brightgreen', + mature: 'brightgreen', + inactive: 'red', + }[status] + + return { + message: status, + color, + } + } + + async handle({ egg }) { + const packageData = await this.fetch({ egg }) + + // Possible statuses: + // - Development Status :: 1 - Planning + // - Development Status :: 2 - Pre-Alpha + // - Development Status :: 3 - Alpha + // - Development Status :: 4 - Beta + // - Development Status :: 5 - Production/Stable + // - Development Status :: 6 - Mature + // - Development Status :: 7 - Inactive + // https://pypi.org/pypi?%3Aaction=list_classifiers + const status = parseClassifiers( + packageData, + /^Development Status :: (\d - \S+)$/ + ) + .sort() + .map(classifier => classifier.split(' - ').pop()) + .map(classifier => classifier.replace(/production\/stable/i, 'stable')) + .pop() + + return this.constructor.render({ status }) + } +} diff --git a/services/pypi/pypi-version.service.js b/services/pypi/pypi-version.service.js new file mode 100644 index 0000000000000..239b3db883173 --- /dev/null +++ b/services/pypi/pypi-version.service.js @@ -0,0 +1,39 @@ +'use strict' + +const { renderVersionBadge } = require('../../lib/version') +const PypiBase = require('./pypi-base') + +module.exports = class PypiVersion extends PypiBase { + static get category() { + return 'version' + } + + static get url() { + return this.buildUrl('pypi/v') + } + + static get defaultBadgeData() { + return { label: 'pypi' } + } + + static get examples() { + return [ + { + title: 'PyPI', + previewUrl: 'nine', + keywords: ['python'], + }, + ] + } + + static render({ version }) { + return renderVersionBadge({ version }) + } + + async handle({ egg }) { + const { + info: { version }, + } = await this.fetch({ egg }) + return this.constructor.render({ version }) + } +} diff --git a/services/pypi/pypi-wheel.service.js b/services/pypi/pypi-wheel.service.js new file mode 100644 index 0000000000000..212ee611d0b77 --- /dev/null +++ b/services/pypi/pypi-wheel.service.js @@ -0,0 +1,48 @@ +'use strict' + +const PypiBase = require('./pypi-base') +const { getPackageFormats } = require('./pypi-helpers') + +module.exports = class PypiWheel extends PypiBase { + static get category() { + return 'other' + } + + static get url() { + return this.buildUrl('pypi/wheel') + } + + static get defaultBadgeData() { + return { label: 'wheel' } + } + + static get examples() { + return [ + { + title: 'PyPI - Wheel', + previewUrl: 'Django', + keywords: ['python'], + }, + ] + } + + static render({ hasWheel }) { + if (hasWheel) { + return { + message: 'yes', + color: 'brightgreen', + } + } else { + return { + message: 'no', + color: 'red', + } + } + } + + async handle({ egg }) { + const packageData = await this.fetch({ egg }) + const { hasWheel } = getPackageFormats(packageData) + return this.constructor.render({ hasWheel }) + } +} diff --git a/services/pypi/pypi.tester.js b/services/pypi/pypi.tester.js index fd0a8a2bea3eb..dd84233ed484c 100644 --- a/services/pypi/pypi.tester.js +++ b/services/pypi/pypi.tester.js @@ -6,13 +6,11 @@ const { isSemver } = require('../test-validators') const isPsycopg2Version = Joi.string().regex(/^v([0-9][.]?)+$/) -// These regexes are the same, but defined separately for clarity. -const isCommaSeperatedPythonVersions = Joi.string().regex( - /^([0-9]+.[0-9]+[,]?[ ]?)+$/ -) -const isCommaSeperatedDjangoVersions = Joi.string().regex( - /^([0-9]+.[0-9]+[,]?[ ]?)+$/ +// These regexes are the same, but declared separately for clarity. +const isPipeSeparatedPythonVersions = Joi.string().regex( + /^([0-9]+.[0-9]+(?: \| )?)+$/ ) +const isPipeSeparatedDjangoVersions = isPipeSeparatedPythonVersions const t = new ServiceTester({ id: 'pypi', title: 'PyPi badges' }) module.exports = t @@ -39,15 +37,15 @@ t.create('monthly downloads (expected failure)') t.create('daily downloads (invalid)') .get('/dd/not-a-package.json') - .expectJSON({ name: 'pypi', value: 'invalid' }) + .expectJSON({ name: 'downloads', value: 'no longer available' }) t.create('weekly downloads (invalid)') .get('/dw/not-a-package.json') - .expectJSON({ name: 'pypi', value: 'invalid' }) + .expectJSON({ name: 'downloads', value: 'no longer available' }) t.create('monthly downloads (invalid)') .get('/dm/not-a-package.json') - .expectJSON({ name: 'pypi', value: 'invalid' }) + .expectJSON({ name: 'downloads', value: 'no longer available' }) /* tests for version endpoint @@ -84,7 +82,7 @@ t.create('version (not semver)') t.create('version (invalid)') .get('/v/not-a-package.json') - .expectJSON({ name: 'pypi', value: 'invalid' }) + .expectJSON({ name: 'pypi', value: 'package or version not found' }) // tests for license endpoint @@ -98,7 +96,7 @@ t.create('license (valid, no package version specified)') t.create('license (invalid)') .get('/l/not-a-package.json') - .expectJSON({ name: 'pypi', value: 'invalid' }) + .expectJSON({ name: 'license', value: 'package or version not found' }) // tests for wheel endpoint @@ -116,7 +114,7 @@ t.create('wheel (no wheel)') t.create('wheel (invalid)') .get('/wheel/not-a-package.json') - .expectJSON({ name: 'pypi', value: 'invalid' }) + .expectJSON({ name: 'wheel', value: 'package or version not found' }) // tests for format endpoint @@ -138,7 +136,7 @@ t.create('format (egg)') t.create('format (invalid)') .get('/format/not-a-package.json') - .expectJSON({ name: 'pypi', value: 'invalid' }) + .expectJSON({ name: 'format', value: 'package or version not found' }) // tests for pyversions endpoint @@ -147,7 +145,7 @@ t.create('python versions (valid, package version in request)') .expectJSONTypes( Joi.object().keys({ name: 'python', - value: isCommaSeperatedPythonVersions, + value: isPipeSeparatedPythonVersions, }) ) @@ -156,17 +154,17 @@ t.create('python versions (valid, no package version specified)') .expectJSONTypes( Joi.object().keys({ name: 'python', - value: isCommaSeperatedPythonVersions, + value: isPipeSeparatedPythonVersions, }) ) t.create('python versions (no versions specified)') .get('/pyversions/pyshp/1.2.12.json') - .expectJSON({ name: 'python', value: 'not found' }) + .expectJSON({ name: 'python', value: 'missing' }) t.create('python versions (invalid)') .get('/pyversions/not-a-package.json') - .expectJSON({ name: 'pypi', value: 'invalid' }) + .expectJSON({ name: 'python', value: 'package or version not found' }) // tests for django versions endpoint @@ -175,7 +173,7 @@ t.create('supported django versions (valid, package version in request)') .expectJSONTypes( Joi.object().keys({ name: 'django versions', - value: isCommaSeperatedDjangoVersions, + value: isPipeSeparatedDjangoVersions, }) ) @@ -184,23 +182,26 @@ t.create('supported django versions (valid, no package version specified)') .expectJSONTypes( Joi.object().keys({ name: 'django versions', - value: isCommaSeperatedDjangoVersions, + value: isPipeSeparatedDjangoVersions, }) ) t.create('supported django versions (no versions specified)') .get('/djversions/django/1.11.json') - .expectJSON({ name: 'django versions', value: 'not found' }) + .expectJSON({ name: 'django versions', value: 'missing' }) t.create('supported django versions (invalid)') .get('/djversions/not-a-package.json') - .expectJSON({ name: 'pypi', value: 'invalid' }) + .expectJSON({ + name: 'django versions', + value: 'package or version not found', + }) // tests for implementation endpoint t.create('implementation (valid, package version in request)') .get('/implementation/beehive/1.0.json') - .expectJSON({ name: 'implementation', value: 'cpython, jython, pypy' }) + .expectJSON({ name: 'implementation', value: 'cpython | jython | pypy' }) t.create('implementation (valid, no package version specified)') .get('/implementation/numpy.json') @@ -212,7 +213,7 @@ t.create('implementation (not specified)') t.create('implementation (invalid)') .get('/implementation/not-a-package.json') - .expectJSON({ name: 'pypi', value: 'invalid' }) + .expectJSON({ name: 'implementation', value: 'package or version not found' }) // tests for status endpoint @@ -230,4 +231,4 @@ t.create('status (valid, beta)') t.create('status (invalid)') .get('/status/not-a-package.json') - .expectJSON({ name: 'pypi', value: 'invalid' }) + .expectJSON({ name: 'status', value: 'package or version not found' })