Rewrite [pypi]; affects [npm] (#1922)

This commit is contained in:
Paul Melnikow
2018-08-27 07:46:06 -04:00
committed by GitHub
parent 78bb890b52
commit 302c8606ff
25 changed files with 691 additions and 273 deletions
+3 -1
View File
@@ -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 {
+31
View File
@@ -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
+15
View File
@@ -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,
}
+2 -15
View File
@@ -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) {
+7 -7
View File
@@ -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) {
+44
View File
@@ -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' },
})
}
}
+53
View File
@@ -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 })
}
}
+12
View File
@@ -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),
})
)
+53
View File
@@ -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 })
}
}
+74
View File
@@ -0,0 +1,74 @@
'use strict'
/*
Django versions will be specified in the form major.minor
trying to sort with `semver.compare` will throw e.g:
TypeError: Invalid Version: 1.11
because no patch release is specified, so we will define
our own functions to parse and sort django versions
*/
const parseDjangoVersionString = function(str) {
if (typeof str !== 'string') {
return false
}
const x = str.split('.')
const maj = parseInt(x[0]) || 0
const min = parseInt(x[1]) || 0
return {
major: maj,
minor: min,
}
}
// sort an array of django versions low to high
const sortDjangoVersions = function(versions) {
return versions.sort((a, b) => {
if (
parseDjangoVersionString(a).major === parseDjangoVersionString(b).major
) {
return (
parseDjangoVersionString(a).minor - parseDjangoVersionString(b).minor
)
} else {
return (
parseDjangoVersionString(a).major - parseDjangoVersionString(b).major
)
}
})
}
// extract classifiers from a pypi json response based on a regex
const parseClassifiers = function(parsedData, pattern) {
const results = []
for (let i = 0; i < parsedData.info.classifiers.length; i++) {
const matched = pattern.exec(parsedData.info.classifiers[i])
if (matched && matched[1]) {
results.push(matched[1].toLowerCase())
}
}
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,
}
+135
View File
@@ -0,0 +1,135 @@
'use strict'
const { test, given } = require('sazerac')
const {
parseClassifiers,
parseDjangoVersionString,
sortDjangoVersions,
getPackageFormats,
} = require('./pypi-helpers.js')
const classifiersFixture = {
info: {
classifiers: [
'Development Status :: 5 - Production/Stable',
'Environment :: Web Environment',
'Framework :: Django',
'Framework :: Django :: 1.10',
'Framework :: Django :: 1.11',
'Intended Audience :: Developers',
'Intended Audience :: Developers',
'License :: OSI Approved :: BSD License',
'Operating System :: OS Independent',
'Natural Language :: English',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Topic :: Internet :: WWW/HTTP',
'Programming Language :: Python :: Implementation :: CPython',
'Programming Language :: Python :: Implementation :: PyPy',
],
},
}
describe('PyPI helpers', function() {
test(parseClassifiers, function() {
given(
classifiersFixture,
/^Programming Language :: Python :: ([\d.]+)$/
).expect(['2', '2.7', '3', '3.4', '3.5', '3.6'])
given(classifiersFixture, /^Framework :: Django :: ([\d.]+)$/).expect([
'1.10',
'1.11',
])
given(
classifiersFixture,
/^Programming Language :: Python :: Implementation :: (\S+)$/
).expect(['cpython', 'pypy'])
// regex that matches everything
given(classifiersFixture, /^([\S\s+]+)$/).expect(
classifiersFixture.info.classifiers.map(e => e.toLowerCase())
)
// regex that matches nothing
given(classifiersFixture, /^(?!.*)*$/).expect([])
})
test(parseDjangoVersionString, function() {
given('1').expect({ major: 1, minor: 0 })
given('1.0').expect({ major: 1, minor: 0 })
given('7.2').expect({ major: 7, minor: 2 })
given('7.2derpderp').expect({ major: 7, minor: 2 })
given('7.2.9.5.8.3').expect({ major: 7, minor: 2 })
given('foo').expect({ major: 0, minor: 0 })
})
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',
'2.0',
'2.1',
'2.11',
'10',
])
given(['2', '1.9', '10', '1.11', '2.1', '2.11']).expect([
'1.9',
'1.11',
'2',
'2.1',
'2.11',
'10',
])
given(['2.0rc1', '10', '1.9', '1.11', '2.1', '2.11']).expect([
'1.9',
'1.11',
'2.0rc1',
'2.1',
'2.11',
'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 })
})
})
@@ -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 })
}
}
+35
View File
@@ -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 })
}
}
+63
View File
@@ -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 })
}
}
+71
View File
@@ -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 })
}
}
+39
View File
@@ -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 })
}
}
+48
View File
@@ -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 })
}
}
+25 -24
View File
@@ -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' })