Add dependency badge for Pipenv applications [GithubPipenv] (#4096)
I recently published https://github.com/metabolize/rq-dashboard-on-heroku and want to add badges to show the locked version of Python and rq-dashboard, the main dependency it’s wrapping. This is along the lines of #2259, which was for package.json-based applications, and also included some discussion of a Python application that used `requirements.txt`. It’s useful for showing the pinned version of any dependency in a Python application that uses a lockfile. In the future, as an alternative to reading Pipfile.lock, I could see expanding this to read Pipfile. However for my purposes I prefer to show the locked dependency, since that’s the version that a user of my package would actually get if they ran it on Heroku.
This commit is contained in:
@@ -24,9 +24,9 @@ const contentSchema = Joi.object({
|
||||
encoding: Joi.equal('base64').required(),
|
||||
}).required()
|
||||
|
||||
async function fetchJsonFromRepo(
|
||||
async function fetchRepoContent(
|
||||
serviceInstance,
|
||||
{ schema, user, repo, branch = 'master', filename }
|
||||
{ user, repo, branch = 'master', filename }
|
||||
) {
|
||||
const errorMessages = errorMessagesFor(
|
||||
`repo not found, branch not found, or ${filename} missing`
|
||||
@@ -41,13 +41,32 @@ async function fetchJsonFromRepo(
|
||||
errorMessages,
|
||||
})
|
||||
|
||||
let decoded
|
||||
try {
|
||||
decoded = Buffer.from(content, 'base64').toString('utf-8')
|
||||
return Buffer.from(content, 'base64').toString('utf-8')
|
||||
} catch (e) {
|
||||
throw new InvalidResponse({ prettyMessage: 'undecodable content' })
|
||||
}
|
||||
const json = serviceInstance._parseJson(decoded)
|
||||
} else {
|
||||
const url = `https://raw.githubusercontent.com/${user}/${repo}/${branch}/${filename}`
|
||||
return serviceInstance._request({
|
||||
url,
|
||||
errorMessages,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJsonFromRepo(
|
||||
serviceInstance,
|
||||
{ schema, user, repo, branch = 'master', filename }
|
||||
) {
|
||||
if (serviceInstance.staticAuthConfigured) {
|
||||
const buffer = await fetchRepoContent(serviceInstance, {
|
||||
user,
|
||||
repo,
|
||||
branch,
|
||||
filename,
|
||||
})
|
||||
const json = serviceInstance._parseJson(buffer)
|
||||
return serviceInstance.constructor._validate(json, schema)
|
||||
} else {
|
||||
const url = `https://raw.githubusercontent.com/${user}/${repo}/${branch}/${filename}`
|
||||
|
||||
@@ -7,10 +7,9 @@ const { InvalidResponse, NotFound } = require('..')
|
||||
const documentation = `
|
||||
<p>
|
||||
If your GitHub badge errors, it might be because you hit GitHub's rate limits.
|
||||
<br>
|
||||
You can increase Shields.io's rate limit by
|
||||
<a href="https://img.shields.io/github-auth">going to this page</a> to add
|
||||
Shields as a GitHub application on your GitHub account.
|
||||
<a href="https://img.shields.io/github-auth">adding the Shields GitHub
|
||||
application</a> using your GitHub account.
|
||||
</p>
|
||||
`
|
||||
|
||||
|
||||
202
services/github/github-pipenv.service.js
Normal file
202
services/github/github-pipenv.service.js
Normal file
@@ -0,0 +1,202 @@
|
||||
'use strict'
|
||||
|
||||
const { renderVersionBadge } = require('../version')
|
||||
const { isLockfile, getDependencyVersion } = require('../pipenv-helpers')
|
||||
const { addv } = require('../text-formatters')
|
||||
const { ConditionalGithubAuthV3Service } = require('./github-auth-service')
|
||||
const { fetchJsonFromRepo } = require('./github-common-fetch')
|
||||
const { documentation: githubDocumentation } = require('./github-helpers')
|
||||
const { NotFound } = require('..')
|
||||
|
||||
const keywords = ['pipfile']
|
||||
|
||||
const documentation = `
|
||||
<p>
|
||||
<a href="https://github.com/pypa/pipenv">Pipenv</a> is a dependency
|
||||
manager for Python which manages a
|
||||
<a href="https://virtualenv.pypa.io/en/latest/">virtualenv</a> for
|
||||
projects. It adds/removes packages from your <code>Pipfile</code> as
|
||||
you install/uninstall packages and generates the ever-important
|
||||
<code>Pipfile.lock</code>, which can be checked in to source control
|
||||
in order to produce deterministic builds.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The GitHub Pipenv badges are intended for applications using Pipenv
|
||||
which are hosted on GitHub.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
When <code>Pipfile.lock</code> is checked in, the <strong>GitHub Pipenv
|
||||
locked dependency version</strong> badge displays the locked version of
|
||||
a dependency listed in <code>[packages]</code> or
|
||||
<code>[dev-packages]</code> (or any of their transitive dependencies).
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Usually a Python version is specified in the <code>Pipfile</code>, which
|
||||
<code>pipenv lock</code> then places in <code>Pipfile.lock</code>. The
|
||||
<strong>GitHub Pipenv Python version</strong> badge displays that version.
|
||||
</p>
|
||||
|
||||
${githubDocumentation}
|
||||
`
|
||||
|
||||
class GithubPipenvLockedPythonVersion extends ConditionalGithubAuthV3Service {
|
||||
static get category() {
|
||||
return 'platform-support'
|
||||
}
|
||||
|
||||
static get route() {
|
||||
return {
|
||||
base: 'github/pipenv/locked/python-version',
|
||||
pattern: ':user/:repo/:branch*',
|
||||
}
|
||||
}
|
||||
|
||||
static get examples() {
|
||||
return [
|
||||
{
|
||||
title: 'GitHub Pipenv locked Python version',
|
||||
pattern: ':user/:repo',
|
||||
namedParams: {
|
||||
user: 'metabolize',
|
||||
repo: 'rq-dashboard-on-heroku',
|
||||
},
|
||||
staticPreview: this.render({ version: '3.7' }),
|
||||
documentation,
|
||||
keywords,
|
||||
},
|
||||
{
|
||||
title: 'GitHub Pipenv locked Python version (branch)',
|
||||
pattern: ':user/:repo/:branch',
|
||||
namedParams: {
|
||||
user: 'metabolize',
|
||||
repo: 'rq-dashboard-on-heroku',
|
||||
branch: 'master',
|
||||
},
|
||||
staticPreview: this.render({ version: '3.7', branch: 'master' }),
|
||||
documentation,
|
||||
keywords,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
static get defaultBadgeData() {
|
||||
return {
|
||||
label: 'python',
|
||||
}
|
||||
}
|
||||
|
||||
static render({ version, branch }) {
|
||||
return renderVersionBadge({
|
||||
version,
|
||||
tag: branch,
|
||||
defaultLabel: 'python',
|
||||
})
|
||||
}
|
||||
|
||||
async handle({ user, repo, branch }) {
|
||||
const {
|
||||
_meta: {
|
||||
requires: { python_version: version },
|
||||
},
|
||||
} = await fetchJsonFromRepo(this, {
|
||||
schema: isLockfile,
|
||||
user,
|
||||
repo,
|
||||
branch,
|
||||
filename: 'Pipfile.lock',
|
||||
})
|
||||
if (version === undefined) {
|
||||
throw new NotFound({ prettyMessage: 'version not specified' })
|
||||
}
|
||||
return this.constructor.render({ version, branch })
|
||||
}
|
||||
}
|
||||
|
||||
class GithubPipenvLockedDependencyVersion extends ConditionalGithubAuthV3Service {
|
||||
static get category() {
|
||||
return 'dependencies'
|
||||
}
|
||||
|
||||
static get route() {
|
||||
return {
|
||||
base: 'github/pipenv/locked/dependency-version',
|
||||
pattern: ':user/:repo/:kind(dev)?/:packageName/:branch*',
|
||||
}
|
||||
}
|
||||
|
||||
static get examples() {
|
||||
return [
|
||||
{
|
||||
title: 'GitHub Pipenv locked dependency version',
|
||||
pattern: ':user/:repo/:kind(dev)?/:packageName',
|
||||
namedParams: {
|
||||
user: 'metabolize',
|
||||
repo: 'rq-dashboard-on-heroku',
|
||||
packageName: 'flask',
|
||||
},
|
||||
staticPreview: this.render({
|
||||
dependency: 'flask',
|
||||
version: '1.1.1',
|
||||
}),
|
||||
documentation,
|
||||
keywords: ['python', ...keywords],
|
||||
},
|
||||
{
|
||||
title: 'GitHub Pipenv locked dependency version (branch)',
|
||||
pattern: ':user/:repo/:kind(dev)?/:packageName/:branch',
|
||||
namedParams: {
|
||||
user: 'metabolize',
|
||||
repo: 'rq-dashboard-on-heroku',
|
||||
kind: 'dev',
|
||||
packageName: 'black',
|
||||
branch: 'master',
|
||||
},
|
||||
staticPreview: this.render({ dependency: 'black', version: '19.3b0' }),
|
||||
documentation,
|
||||
keywords: ['python', ...keywords],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
static get defaultBadgeData() {
|
||||
return {
|
||||
label: 'dependency',
|
||||
}
|
||||
}
|
||||
|
||||
static render({ dependency, version, ref }) {
|
||||
return {
|
||||
label: dependency,
|
||||
message: version ? addv(version) : ref,
|
||||
color: 'blue',
|
||||
}
|
||||
}
|
||||
|
||||
async handle({ user, repo, kind, branch, packageName }) {
|
||||
const lockfileData = await fetchJsonFromRepo(this, {
|
||||
schema: isLockfile,
|
||||
user,
|
||||
repo,
|
||||
branch,
|
||||
filename: 'Pipfile.lock',
|
||||
})
|
||||
const { version, ref } = getDependencyVersion({
|
||||
kind,
|
||||
wantedDependency: packageName,
|
||||
lockfileData,
|
||||
})
|
||||
return this.constructor.render({
|
||||
dependency: packageName,
|
||||
version,
|
||||
ref,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = [
|
||||
GithubPipenvLockedPythonVersion,
|
||||
GithubPipenvLockedDependencyVersion,
|
||||
]
|
||||
93
services/github/github-pipenv.tester.js
Normal file
93
services/github/github-pipenv.tester.js
Normal file
@@ -0,0 +1,93 @@
|
||||
'use strict'
|
||||
|
||||
const Joi = require('@hapi/joi')
|
||||
const { ServiceTester } = require('../tester')
|
||||
const {
|
||||
isVPlusDottedVersionAtLeastOne,
|
||||
isVPlusDottedVersionNClausesWithOptionalSuffix,
|
||||
} = require('../test-validators')
|
||||
|
||||
// e.g. v19.3b0
|
||||
const isBlackVersion = Joi.string().regex(/^v\d+(\.\d+)*(.*)?$/)
|
||||
const isShortSha = Joi.string().regex(/[0-9a-f]{7}/)
|
||||
|
||||
const t = (module.exports = new ServiceTester({
|
||||
id: 'GithubPipenv',
|
||||
title: 'GithubPipenv',
|
||||
pathPrefix: '/github/pipenv',
|
||||
}))
|
||||
|
||||
t.create('Locked Python version')
|
||||
.get('/locked/python-version/metabolize/rq-dashboard-on-heroku.json')
|
||||
.expectBadge({
|
||||
label: 'python',
|
||||
message: isVPlusDottedVersionAtLeastOne,
|
||||
})
|
||||
|
||||
t.create('Locked Python version (no pipfile.lock)')
|
||||
.get('/locked/python-version/metabolize/react-flexbox-svg.json')
|
||||
.expectBadge({
|
||||
label: 'python',
|
||||
message: 'repo not found, branch not found, or Pipfile.lock missing',
|
||||
})
|
||||
|
||||
t.create('Locked Python version (pipfile.lock has no python version)')
|
||||
.get('/locked/python-version/fikovnik/ShiftIt.json')
|
||||
.expectBadge({
|
||||
label: 'python',
|
||||
message: 'version not specified',
|
||||
})
|
||||
|
||||
t.create('Locked version of default dependency')
|
||||
.get(
|
||||
'/locked/dependency-version/metabolize/rq-dashboard-on-heroku/rq-dashboard.json'
|
||||
)
|
||||
.expectBadge({
|
||||
label: 'rq-dashboard',
|
||||
message: isVPlusDottedVersionNClausesWithOptionalSuffix,
|
||||
})
|
||||
|
||||
t.create('Locked version of default dependency (branch)')
|
||||
.get(
|
||||
'/locked/dependency-version/metabolize/rq-dashboard-on-heroku/rq-dashboard/master.json'
|
||||
)
|
||||
.expectBadge({
|
||||
label: 'rq-dashboard',
|
||||
message: isVPlusDottedVersionNClausesWithOptionalSuffix,
|
||||
})
|
||||
|
||||
t.create('Locked version of dev dependency')
|
||||
.get(
|
||||
'/locked/dependency-version/metabolize/rq-dashboard-on-heroku/dev/black.json'
|
||||
)
|
||||
.expectBadge({
|
||||
label: 'black',
|
||||
message: isBlackVersion,
|
||||
})
|
||||
|
||||
t.create('Locked version of dev dependency (branch)')
|
||||
.get(
|
||||
'/locked/dependency-version/metabolize/rq-dashboard-on-heroku/dev/black/master.json'
|
||||
)
|
||||
.expectBadge({
|
||||
label: 'black',
|
||||
message: isBlackVersion,
|
||||
})
|
||||
|
||||
t.create('Locked version of unknown dependency')
|
||||
.get(
|
||||
'/locked/dependency-version/metabolize/rq-dashboard-on-heroku/dev/i-made-this-up.json'
|
||||
)
|
||||
.expectBadge({
|
||||
label: 'dependency',
|
||||
message: 'dev dependency not found',
|
||||
})
|
||||
|
||||
t.create('Locked version of VCS dependency')
|
||||
.get(
|
||||
'/locked/dependency-version/DemocracyClub/aggregator-api/dc-base-theme.json'
|
||||
)
|
||||
.expectBadge({
|
||||
label: 'dc-base-theme',
|
||||
message: isShortSha,
|
||||
})
|
||||
58
services/pipenv-helpers.js
Normal file
58
services/pipenv-helpers.js
Normal file
@@ -0,0 +1,58 @@
|
||||
'use strict'
|
||||
|
||||
const Joi = require('@hapi/joi')
|
||||
const { InvalidParameter } = require('.')
|
||||
|
||||
const isDependency = Joi.alternatives(
|
||||
Joi.object({
|
||||
version: Joi.string().required(),
|
||||
}).required(),
|
||||
Joi.object({
|
||||
ref: Joi.string().required(),
|
||||
}).required()
|
||||
)
|
||||
|
||||
const isLockfile = Joi.object({
|
||||
_meta: Joi.object({
|
||||
requires: Joi.object({
|
||||
python_version: Joi.string(),
|
||||
}).required(),
|
||||
}).required(),
|
||||
default: Joi.object().pattern(Joi.string().required(), isDependency),
|
||||
develop: Joi.object().pattern(Joi.string().required(), isDependency),
|
||||
}).required()
|
||||
|
||||
function getDependencyVersion({
|
||||
kind = 'default',
|
||||
wantedDependency,
|
||||
lockfileData,
|
||||
}) {
|
||||
let dependenciesOfKind
|
||||
if (kind === 'dev') {
|
||||
dependenciesOfKind = lockfileData.develop
|
||||
} else if (kind === 'default') {
|
||||
dependenciesOfKind = lockfileData.default
|
||||
} else {
|
||||
throw Error(`Not very kind: ${kind}`)
|
||||
}
|
||||
|
||||
if (!(wantedDependency in dependenciesOfKind)) {
|
||||
throw new InvalidParameter({
|
||||
prettyMessage: `${kind} dependency not found`,
|
||||
})
|
||||
}
|
||||
|
||||
const { version, ref } = dependenciesOfKind[wantedDependency]
|
||||
|
||||
if (version) {
|
||||
// Strip the `==` which is always present.
|
||||
return { version: version.replace('==', '') }
|
||||
} else {
|
||||
return { ref: ref.substring(1, 8) }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isLockfile,
|
||||
getDependencyVersion,
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
const Joi = require('@hapi/joi')
|
||||
const t = (module.exports = require('../tester').createServiceTester())
|
||||
|
||||
// These regexes are the same, but declared separately for clarity.
|
||||
const isPipeSeparatedPythonVersions = Joi.string().regex(
|
||||
/^([0-9]+\.[0-9]+(?: \| )?)+$/
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user