From fbb2ff3619764d7343d8dcad8acd719ce67caa1c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89rico=20Andrei?= <ericof@gmail.com>
Date: Mon, 1 Aug 2022 16:17:12 -0300
Subject: [PATCH] [pypi] Add Framework Version Badges support (#8261)

* [pypi] Add Framework Version Badges support
* Fix redirect from Django versions
* Fix staticPreview
* Refactor service to remove duplication
* Rename to Versions from Framework Classifiers
* Refactor render and handle to thrown an exception

Co-authored-by: repo-ranger[bot] <39074581+repo-ranger[bot]@users.noreply.github.com>
---
 services/pypi/pypi-django-versions.service.js |  57 ++----
 services/pypi/pypi-django-versions.tester.js  |  34 ++--
 .../pypi/pypi-framework-versions.service.js   | 103 +++++++++++
 .../pypi/pypi-framework-versions.tester.js    | 164 ++++++++++++++++++
 services/pypi/pypi-helpers.js                 |  20 +--
 services/pypi/pypi-helpers.spec.js            |   8 +-
 6 files changed, 303 insertions(+), 83 deletions(-)
 create mode 100644 services/pypi/pypi-framework-versions.service.js
 create mode 100644 services/pypi/pypi-framework-versions.tester.js

diff --git a/services/pypi/pypi-django-versions.service.js b/services/pypi/pypi-django-versions.service.js
index ffe0fbf1bf..da07cf8f0c 100644
--- a/services/pypi/pypi-django-versions.service.js
+++ b/services/pypi/pypi-django-versions.service.js
@@ -1,45 +1,12 @@
-import PypiBase from './pypi-base.js'
-import { sortDjangoVersions, parseClassifiers } from './pypi-helpers.js'
-
-export default class PypiDjangoVersions extends PypiBase {
-  static category = 'platform-support'
-
-  static route = this.buildRoute('pypi/djversions')
-
-  static examples = [
-    {
-      title: 'PyPI - Django Version',
-      pattern: ':packageName',
-      namedParams: { packageName: 'djangorestframework' },
-      staticPreview: this.render({ versions: ['1.11', '2.0', '2.1'] }),
-      keywords: ['python'],
-    },
-  ]
-
-  static defaultBadgeData = { label: 'django versions' }
-
-  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 })
-  }
-}
+import { redirector } from '../index.js'
+
+export default redirector({
+  category: 'platform-support',
+  route: {
+    base: 'pypi/djversions',
+    pattern: ':packageName*',
+  },
+  transformPath: ({ packageName }) =>
+    `/pypi/frameworkversions/django/${packageName}`,
+  dateAdded: new Date('2022-07-28'),
+})
diff --git a/services/pypi/pypi-django-versions.tester.js b/services/pypi/pypi-django-versions.tester.js
index 83fdca15ec..adba0a5e2b 100644
--- a/services/pypi/pypi-django-versions.tester.js
+++ b/services/pypi/pypi-django-versions.tester.js
@@ -1,32 +1,24 @@
-import Joi from 'joi'
 import { createServiceTester } from '../tester.js'
 export const t = await createServiceTester()
 
-const isPipeSeparatedDjangoVersions = Joi.string().regex(
-  /^([1-9]\.[0-9]+(?: \| )?)+$/
+t.create(
+  'redirect supported django versions (valid, package version in request)'
 )
-
-t.create('supported django versions (valid, package version in request)')
   .get('/djangorestframework/3.7.3.json')
-  .expectBadge({
-    label: 'django versions',
-    message: isPipeSeparatedDjangoVersions,
-  })
+  .expectRedirect(
+    '/pypi/frameworkversions/django/djangorestframework/3.7.3.json'
+  )
 
-t.create('supported django versions (valid, no package version specified)')
+t.create(
+  'redirect supported django versions (valid, no package version specified)'
+)
   .get('/djangorestframework.json')
-  .expectBadge({
-    label: 'django versions',
-    message: isPipeSeparatedDjangoVersions,
-  })
+  .expectRedirect('/pypi/frameworkversions/django/djangorestframework.json')
 
-t.create('supported django versions (no versions specified)')
+t.create('redirect supported django versions (no versions specified)')
   .get('/django/1.11.json')
-  .expectBadge({ label: 'django versions', message: 'missing' })
+  .expectRedirect('/pypi/frameworkversions/django/django/1.11.json')
 
-t.create('supported django versions (invalid)')
+t.create('redirect supported django versions (invalid)')
   .get('/not-a-package.json')
-  .expectBadge({
-    label: 'django versions',
-    message: 'package or version not found',
-  })
+  .expectRedirect('/pypi/frameworkversions/django/not-a-package.json')
diff --git a/services/pypi/pypi-framework-versions.service.js b/services/pypi/pypi-framework-versions.service.js
new file mode 100644
index 0000000000..1da6edb1e8
--- /dev/null
+++ b/services/pypi/pypi-framework-versions.service.js
@@ -0,0 +1,103 @@
+import { InvalidResponse } from '../index.js'
+import PypiBase from './pypi-base.js'
+import { sortPypiVersions, parseClassifiers } from './pypi-helpers.js'
+
+const frameworkNameMap = {
+  'aws-cdk': {
+    name: 'AWS CDK',
+    classifier: 'AWS CDK',
+  },
+  django: {
+    name: 'Django',
+    classifier: 'Django',
+  },
+  'django-cms': {
+    name: 'Django CMS',
+    classifier: 'Django CMS',
+  },
+  jupyterlab: {
+    name: 'JupyterLab',
+    classifier: 'Jupyter :: JupyterLab',
+  },
+  odoo: {
+    name: 'Odoo',
+    classifier: 'Odoo',
+  },
+  plone: {
+    name: 'Plone',
+    classifier: 'Plone',
+  },
+  wagtail: {
+    name: 'Wagtail',
+    classifier: 'Wagtail',
+  },
+  zope: {
+    name: 'Zope',
+    classifier: 'Zope',
+  },
+}
+
+const documentation = `
+<p>
+  This service currently support the following Frameworks: <br/>
+  ${Object.values(frameworkNameMap).map(obj => `<strong>${obj.name}</strong>`)}
+</p>
+`
+export default class PypiFrameworkVersion extends PypiBase {
+  static category = 'platform-support'
+
+  static route = {
+    base: 'pypi/frameworkversions',
+    pattern: `:frameworkName(${Object.keys(frameworkNameMap).join(
+      '|'
+    )})/:packageName*`,
+  }
+
+  static examples = [
+    {
+      title: 'PyPI - Versions from Framework Classifiers',
+      namedParams: {
+        frameworkName: 'Plone',
+        packageName: 'plone.volto',
+      },
+      staticPreview: this.render({
+        name: 'Plone',
+        versions: ['5.2', '6.0'],
+      }),
+      keywords: ['python'],
+      documentation,
+    },
+  ]
+
+  static defaultBadgeData = { label: 'versions' }
+
+  static render({ name, versions }) {
+    name = name ? name.toLowerCase() : ''
+    const label = `${name} versions`
+    return {
+      label,
+      message: sortPypiVersions(versions).join(' | '),
+      color: 'blue',
+    }
+  }
+
+  async handle({ frameworkName, packageName }) {
+    const classifier = frameworkNameMap[frameworkName]
+      ? frameworkNameMap[frameworkName].classifier
+      : frameworkName
+    const name = frameworkNameMap[frameworkName]
+      ? frameworkNameMap[frameworkName].name
+      : frameworkName
+    const regex = new RegExp(`^Framework :: ${classifier} :: ([\\d.]+)$`)
+    const packageData = await this.fetch({ egg: packageName })
+    const versions = parseClassifiers(packageData, regex)
+
+    if (versions.length === 0) {
+      throw new InvalidResponse({
+        prettyMessage: `${name} versions are missing for ${packageName}`,
+      })
+    }
+
+    return this.constructor.render({ name, versions })
+  }
+}
diff --git a/services/pypi/pypi-framework-versions.tester.js b/services/pypi/pypi-framework-versions.tester.js
new file mode 100644
index 0000000000..89d2f9821b
--- /dev/null
+++ b/services/pypi/pypi-framework-versions.tester.js
@@ -0,0 +1,164 @@
+import Joi from 'joi'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+const isPipeSeparatedFrameworkVersions = Joi.string().regex(
+  /^([1-9]+(\.[0-9]+)?(?: \| )?)+$/
+)
+
+t.create('supported django versions (valid, package version in request)')
+  .get('/django/djangorestframework/3.7.3.json')
+  .expectBadge({
+    label: 'django versions',
+    message: isPipeSeparatedFrameworkVersions,
+  })
+
+t.create('supported django versions (valid, no package version specified)')
+  .get('/django/djangorestframework.json')
+  .expectBadge({
+    label: 'django versions',
+    message: isPipeSeparatedFrameworkVersions,
+  })
+
+t.create('supported django versions (no versions specified)')
+  .get('/django/django/1.11.json')
+  .expectBadge({
+    label: 'versions',
+    message: 'Django versions are missing for django/1.11',
+  })
+
+t.create('supported django versions (invalid)')
+  .get('/django/not-a-package.json')
+  .expectBadge({
+    label: 'versions',
+    message: 'package or version not found',
+  })
+
+t.create('supported plone versions (valid, package version in request)')
+  .get('/plone/plone.rest/1.6.2.json')
+  .expectBadge({ label: 'plone versions', message: '4.3 | 5.0 | 5.1 | 5.2' })
+
+t.create('supported plone versions (valid, no package version specified)')
+  .get('/plone/plone.rest.json')
+  .expectBadge({
+    label: 'plone versions',
+    message: isPipeSeparatedFrameworkVersions,
+  })
+
+t.create('supported plone versions (invalid)')
+  .get('/plone/not-a-package.json')
+  .expectBadge({
+    label: 'versions',
+    message: 'package or version not found',
+  })
+
+t.create('supported zope versions (valid, package version in request)')
+  .get('/zope/plone/5.2.9.json')
+  .expectBadge({ label: 'zope versions', message: '4' })
+
+t.create('supported zope versions (valid, no package version specified)')
+  .get('/zope/Plone.json')
+  .expectBadge({
+    label: 'zope versions',
+    message: isPipeSeparatedFrameworkVersions,
+  })
+
+t.create('supported zope versions (invalid)')
+  .get('/zope/not-a-package.json')
+  .expectBadge({
+    label: 'versions',
+    message: 'package or version not found',
+  })
+
+t.create('supported wagtail versions (valid, package version in request)')
+  .get('/wagtail/wagtail-headless-preview/0.3.0.json')
+  .expectBadge({ label: 'wagtail versions', message: '2 | 3' })
+
+t.create('supported wagtail versions (valid, no package version specified)')
+  .get('/wagtail/wagtail-headless-preview.json')
+  .expectBadge({
+    label: 'wagtail versions',
+    message: isPipeSeparatedFrameworkVersions,
+  })
+
+t.create('supported wagtail versions (invalid)')
+  .get('/wagtail/not-a-package.json')
+  .expectBadge({
+    label: 'versions',
+    message: 'package or version not found',
+  })
+
+t.create('supported django cms versions (valid, package version in request)')
+  .get('/django-cms/djangocms-ads/1.1.0.json')
+  .expectBadge({
+    label: 'django cms versions',
+    message: '3.7 | 3.8 | 3.9 | 3.10',
+  })
+
+t.create('supported django cms versions (valid, no package version specified)')
+  .get('/django-cms/djangocms-ads.json')
+  .expectBadge({
+    label: 'django cms versions',
+    message: isPipeSeparatedFrameworkVersions,
+  })
+
+t.create('supported django cms versions (invalid)')
+  .get('/django-cms/not-a-package.json')
+  .expectBadge({
+    label: 'versions',
+    message: 'package or version not found',
+  })
+
+t.create('supported odoo versions (valid, package version in request)')
+  .get('/odoo/odoo-addon-sale-tier-validation/15.0.1.0.0.6.json')
+  .expectBadge({ label: 'odoo versions', message: '15.0' })
+
+t.create('supported odoo versions (valid, no package version specified)')
+  .get('/odoo/odoo-addon-sale-tier-validation.json')
+  .expectBadge({
+    label: 'odoo versions',
+    message: isPipeSeparatedFrameworkVersions,
+  })
+
+t.create('supported odoo versions (invalid)')
+  .get('/odoo/not-a-package.json')
+  .expectBadge({
+    label: 'versions',
+    message: 'package or version not found',
+  })
+
+t.create('supported aws cdk versions (valid, package version in request)')
+  .get('/aws-cdk/aws-cdk.aws-glue-alpha/2.34.0a0.json')
+  .expectBadge({ label: 'aws cdk versions', message: '2' })
+
+t.create('supported aws cdk versions (valid, no package version specified)')
+  .get('/aws-cdk/aws-cdk.aws-glue-alpha.json')
+  .expectBadge({
+    label: 'aws cdk versions',
+    message: isPipeSeparatedFrameworkVersions,
+  })
+
+t.create('supported aws cdk versions (invalid)')
+  .get('/aws-cdk/not-a-package.json')
+  .expectBadge({
+    label: 'versions',
+    message: 'package or version not found',
+  })
+
+t.create('supported jupyterlab versions (valid, package version in request)')
+  .get('/jupyterlab/structured-text/0.0.2.json')
+  .expectBadge({ label: 'jupyterlab versions', message: '3' })
+
+t.create('supported jupyterlab versions (valid, no package version specified)')
+  .get('/jupyterlab/structured-text.json')
+  .expectBadge({
+    label: 'jupyterlab versions',
+    message: isPipeSeparatedFrameworkVersions,
+  })
+
+t.create('supported jupyterlab versions (invalid)')
+  .get('/jupyterlab/not-a-package.json')
+  .expectBadge({
+    label: 'versions',
+    message: 'package or version not found',
+  })
diff --git a/services/pypi/pypi-helpers.js b/services/pypi/pypi-helpers.js
index 0e16534d8b..706955b4f9 100644
--- a/services/pypi/pypi-helpers.js
+++ b/services/pypi/pypi-helpers.js
@@ -6,7 +6,7 @@
   our own functions to parse and sort django versions
 */
 
-function parseDjangoVersionString(str) {
+function parsePypiVersionString(str) {
   if (typeof str !== 'string') {
     return false
   }
@@ -20,18 +20,12 @@ function parseDjangoVersionString(str) {
 }
 
 // Sort an array of django versions low to high.
-function sortDjangoVersions(versions) {
+function sortPypiVersions(versions) {
   return versions.sort((a, b) => {
-    if (
-      parseDjangoVersionString(a).major === parseDjangoVersionString(b).major
-    ) {
-      return (
-        parseDjangoVersionString(a).minor - parseDjangoVersionString(b).minor
-      )
+    if (parsePypiVersionString(a).major === parsePypiVersionString(b).major) {
+      return parsePypiVersionString(a).minor - parsePypiVersionString(b).minor
     } else {
-      return (
-        parseDjangoVersionString(a).major - parseDjangoVersionString(b).major
-      )
+      return parsePypiVersionString(a).major - parsePypiVersionString(b).major
     }
   })
 }
@@ -101,8 +95,8 @@ function getPackageFormats(packageData) {
 
 export {
   parseClassifiers,
-  parseDjangoVersionString,
-  sortDjangoVersions,
+  parsePypiVersionString,
+  sortPypiVersions,
   getLicenses,
   getPackageFormats,
 }
diff --git a/services/pypi/pypi-helpers.spec.js b/services/pypi/pypi-helpers.spec.js
index d5a54dd5f5..17e42b3b4f 100644
--- a/services/pypi/pypi-helpers.spec.js
+++ b/services/pypi/pypi-helpers.spec.js
@@ -1,8 +1,8 @@
 import { test, given, forCases } from 'sazerac'
 import {
   parseClassifiers,
-  parseDjangoVersionString,
-  sortDjangoVersions,
+  parsePypiVersionString,
+  sortPypiVersions,
   getLicenses,
   getPackageFormats,
 } from './pypi-helpers.js'
@@ -60,7 +60,7 @@ describe('PyPI helpers', function () {
     given(classifiersFixture, /^(?!.*)*$/).expect([])
   })
 
-  test(parseDjangoVersionString, function () {
+  test(parsePypiVersionString, function () {
     given('1').expect({ major: 1, minor: 0 })
     given('1.0').expect({ major: 1, minor: 0 })
     given('7.2').expect({ major: 7, minor: 2 })
@@ -69,7 +69,7 @@ describe('PyPI helpers', function () {
     given('foo').expect({ major: 0, minor: 0 })
   })
 
-  test(sortDjangoVersions, function () {
+  test(sortPypiVersions, 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',
-- 
GitLab