diff --git a/scripts/lib/api/generateApiComponents.test.ts b/scripts/lib/api/generateApiComponents.test.ts new file mode 100644 index 00000000000..d4e3d8e2290 --- /dev/null +++ b/scripts/lib/api/generateApiComponents.test.ts @@ -0,0 +1,202 @@ +// This code is a Qiskit project. +// +// (C) Copyright IBM 2024. +// +// This code is licensed under the Apache License, Version 2.0. You may +// obtain a copy of this license in the LICENSE file in the root directory +// of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +// +// Any modifications or derivative works of this code must retain this +// copyright notice, and modified files need to carry a notice indicating +// that they have been altered from the originals. + +import { expect, test } from "@jest/globals"; + +import { + ComponentProps, + prepareGitHubLink, + htmlSignatureToMd, + addExtraSignatures, + createOpeningTag, +} from "./generateApiComponents"; +import { APOSTROPHE_HEX_CODE } from "../stringUtils"; +import { CheerioDoc } from "../testUtils"; + +const RAW_SIGNATURE_EXAMPLE = `Estimator.run(circuits, observables, parameter_values=None, **kwargs)`; + +test("htmlSignatureToMd", async () => { + const result = await htmlSignatureToMd(RAW_SIGNATURE_EXAMPLE); + expect(result).toEqual( + `Estimator.run(circuits, observables, parameter_values=None, **kwargs)`, + ); +}); + +describe("addExtraSignatures()", () => { + test("Function with overload signatures", () => { + const componentProps = { + id: "qiskit_ibm_runtime.Estimator.run", + rawSignature: "first signature", + }; + const extraRawSignatures = [ + { + id: "qiskit_ibm_runtime.Estimator.run", + rawSignature: "second signature", + }, + ]; + + const resultExpected = { + id: "qiskit_ibm_runtime.Estimator.run", + rawSignature: "first signature", + extraRawSignatures: ["second signature"], + }; + addExtraSignatures(componentProps, extraRawSignatures); + expect(componentProps).toEqual(resultExpected); + }); + + test("Function without overload signatures", () => { + const componentProps = { + id: "qiskit_ibm_runtime.Estimator.run", + rawSignature: "first signature", + }; + const extraRawSignatures: ComponentProps[] = []; + + addExtraSignatures(componentProps, extraRawSignatures); + expect(componentProps).toEqual(componentProps); + }); +}); + +describe("createOpeningTag()", () => { + test("Create Function tag with some props", async () => { + const componentProps = { + id: "qiskit_ibm_runtime.Estimator.run", + name: "run", + rawSignature: RAW_SIGNATURE_EXAMPLE, + }; + + const tag = await createOpeningTag("Function", componentProps); + expect(tag).toEqual(` + `); + }); + + test("Create Function tag with overloaded signatures", async () => { + const componentProps = { + id: "qiskit_ibm_runtime.Estimator.run", + name: "run", + rawSignature: RAW_SIGNATURE_EXAMPLE, + extraRawSignatures: [RAW_SIGNATURE_EXAMPLE, RAW_SIGNATURE_EXAMPLE], + }; + + const tag = await createOpeningTag("Function", componentProps); + expect(tag).toEqual(` + `); + }); + + test("Create Attribute tag with default value and type hint", async () => { + const componentProps = { + id: "qiskit.circuit.QuantumCircuit.instance", + name: "instance", + attributeTypeHint: "str | None", + attributeValue: "None", + }; + + const tag = await createOpeningTag("Attribute", componentProps); + expect(tag).toEqual(` + `); + }); + + test("Create Class tag without props", async () => { + const componentProps = { + id: "qiskit.circuit.Sampler", + }; + + const tag = await createOpeningTag("Class", componentProps); + expect(tag).toEqual(` + `); + }); +}); + +describe("prepareGitHubLink()", () => { + test("no link", () => { + const html = `None)#`; + const doc = CheerioDoc.load(html); + const result = prepareGitHubLink(doc.$main, false); + expect(result).toEqual(undefined); + doc.expectHtml(html); + }); + + test("link from sphinx.ext.viewcode", () => { + const doc = CheerioDoc.load( + `None)[source]#`, + ); + const result = prepareGitHubLink(doc.$main, false); + expect(result).toEqual(`https://ibm.com/my_link`); + doc.expectHtml( + `None)#`, + ); + }); + + test("link from sphinx.ext.linkcode", () => { + const doc = CheerioDoc.load( + `None)[source]#`, + ); + const result = prepareGitHubLink(doc.$main, false); + expect(result).toEqual( + `https://github.com/Qiskit/qiskit/blob/stable/1.0/qiskit/utils/deprecation.py#L24-L101`, + ); + doc.expectHtml( + `None)#`, + ); + }); + + test("method link only used when it has line numbers", () => { + const withLinesDoc = CheerioDoc.load( + `)[source]`, + ); + const withoutLinesDoc = CheerioDoc.load( + `)[source]`, + ); + const withLinesResult = prepareGitHubLink(withLinesDoc.$main, true); + const withoutLinesResult = prepareGitHubLink(withoutLinesDoc.$main, true); + + expect(withLinesResult).toEqual( + `https://github.com/Qiskit/qiskit-ibm-provider/tree/stable/0.10/qiskit_ibm_provider/transpiler/passes/scheduling/block_base_padder.py#L91-L117`, + ); + expect(withoutLinesResult).toEqual(undefined); + + const strippedHtml = `)`; + withLinesDoc.expectHtml(strippedHtml); + withoutLinesDoc.expectHtml(strippedHtml); + }); +}); diff --git a/scripts/lib/api/generateApiComponents.ts b/scripts/lib/api/generateApiComponents.ts new file mode 100644 index 00000000000..8e43bba2772 --- /dev/null +++ b/scripts/lib/api/generateApiComponents.ts @@ -0,0 +1,144 @@ +// This code is a Qiskit project. +// +// (C) Copyright IBM 2024. +// +// This code is licensed under the Apache License, Version 2.0. You may +// obtain a copy of this license in the LICENSE file in the root directory +// of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +// +// Any modifications or derivative works of this code must retain this +// copyright notice, and modified files need to carry a notice indicating +// that they have been altered from the originals. + +import { CheerioAPI, Cheerio } from "cheerio"; +import { unified } from "unified"; +import rehypeParse from "rehype-parse"; +import rehypeRemark from "rehype-remark"; +import remarkStringify from "remark-stringify"; + +import { APOSTROPHE_HEX_CODE } from "../stringUtils"; + +export type ComponentProps = { + id: string; + name?: string; + attributeTypeHint?: string; + attributeValue?: string; + githubSourceLink?: string; + rawSignature?: string; + extraRawSignatures?: string[]; +}; + +// ------------------------------------------------------------------ +// Generate MDX components +// ------------------------------------------------------------------ + +/** + * Creates the opening tag of the API components. The function sets all possible + * props values even if they are empty or undefined. All the props without value + * will be removed when generating the markdown file in `htmlToMd.ts`. + */ +export async function createOpeningTag( + tagName: string, + props: ComponentProps, +): Promise { + const attributeTypeHint = props.attributeTypeHint?.replaceAll( + "'", + APOSTROPHE_HEX_CODE, + ); + const attributeValue = props.attributeValue?.replaceAll( + "'", + APOSTROPHE_HEX_CODE, + ); + const signature = await htmlSignatureToMd(props.rawSignature!); + const extraSignatures: string[] = []; + for (const sig of props.extraRawSignatures ?? []) { + extraSignatures.push( + `${APOSTROPHE_HEX_CODE}${await htmlSignatureToMd( + sig!, + )}${APOSTROPHE_HEX_CODE}`, + ); + } + + return `<${tagName} + id='${props.id}' + name='${props.name}' + attributeTypeHint='${attributeTypeHint}' + attributeValue='${attributeValue}' + github='${props.githubSourceLink}' + signature='${signature}' + extraSignatures='[${extraSignatures.join(", ")}]' + > + `; +} + +/** + * Removes the original link from sphinx.ext.viewcode and returns the HTML string for our own link. + * + * This returns the HTML string, rather than directly inserting into the HTML, because the insertion + * logic is most easily handled by the calling code. + * + * This function works the same regardless of whether the Sphinx build used `sphinx.ext.viewcode` + * or `sphinx.ext.linkcode` because they have the same HTML structure. + * + * If the link corresponds to a method, we only return a link if it has line numbers included, + * which implies that the link came from `sphinx.ext.linkcode` rather than `sphinx.ext.viewcode`. + * That's because the owning class will already have a link to the relevant file; it's + * noisy and not helpful to repeat the same link without line numbers for the individual methods. + */ +export function prepareGitHubLink( + $child: Cheerio, + isMethod: boolean, +): string | undefined { + const originalLink = $child.find(".viewcode-link").closest("a"); + if (originalLink.length === 0) { + return undefined; + } + const href = originalLink.attr("href")!; + originalLink.first().remove(); + return !isMethod || href.includes(".py#") ? href : undefined; +} + +/** + * Find the element that both matches the `selector` and whose content is the same as `text` + */ +export function findByText( + $: CheerioAPI, + $main: Cheerio, + selector: string, + text: string, +): Cheerio { + return $main.find(selector).filter((i, el) => $(el).text().trim() === text); +} + +export function addExtraSignatures( + componentProps: ComponentProps, + extraRawSignatures: ComponentProps[], +): void { + componentProps.extraRawSignatures = [ + ...extraRawSignatures.flatMap((sigProps) => sigProps.rawSignature ?? []), + ]; +} + +/** + * Converts a given HTML into markdown + */ +export async function htmlSignatureToMd( + signatureHtml: string, +): Promise { + if (!signatureHtml) { + return ""; + } + + const html = `${signatureHtml}`; + const file = await unified() + .use(rehypeParse) + .use(rehypeRemark) + .use(remarkStringify) + .process(html); + + return String(file) + .replaceAll("\n", "") + .replaceAll("'", APOSTROPHE_HEX_CODE) + .replace(/^`/, "") + .replace(/`$/, ""); +} diff --git a/scripts/lib/api/processHtml.test.ts b/scripts/lib/api/processHtml.test.ts index 8a6de44bab0..d73db331d9d 100644 --- a/scripts/lib/api/processHtml.test.ts +++ b/scripts/lib/api/processHtml.test.ts @@ -10,7 +10,6 @@ // copyright notice, and modified files need to carry a notice indicating // that they have been altered from the originals. -import { CheerioAPI, Cheerio, load as cheerioLoad } from "cheerio"; import { describe, expect, test } from "@jest/globals"; import { @@ -26,37 +25,14 @@ import { removeMatplotlibFigCaptions, replaceViewcodeLinksWithGitHub, convertRubricsToHeaders, - prepareGitHubLink, processMembersAndSetMeta, } from "./processHtml"; import { Metadata } from "./Metadata"; - -class Doc { - readonly $: CheerioAPI; - readonly $main: Cheerio; - - constructor($: CheerioAPI, $main: Cheerio) { - this.$ = $; - this.$main = $main; - } - - static load(html: string): Doc { - const $ = cheerioLoad(`
${html}
`); - return new Doc($, $("[role='main']")); - } - - html(): string { - return this.$main.html()!.trim(); - } - - expectHtml(expected: string): void { - expect(this.html()).toEqual(expected.trim()); - } -} +import { CheerioDoc } from "../testUtils"; describe("loadImages()", () => { test("normal file", () => { - const doc = Doc.load( + const doc = CheerioDoc.load( `Logo`, ); const images = loadImages(doc.$, doc.$main, "/my-images", false); @@ -76,7 +52,7 @@ describe("loadImages()", () => { }); test("release note", () => { - const doc = Doc.load( + const doc = CheerioDoc.load( ``, ); const images = loadImages(doc.$, doc.$main, "/my-images/0.45", true); @@ -91,7 +67,7 @@ describe("loadImages()", () => { }); test("handleSphinxDesignCards()", () => { - const doc = Doc.load(` + const doc = CheerioDoc.load(`
Account initialization @@ -143,13 +119,13 @@ test("handleSphinxDesignCards()", () => { }); test("renameAllH1s()", () => { - const doc = Doc.load(`

Release Notes!!!

0.45.0

`); + const doc = CheerioDoc.load(`

Release Notes!!!

0.45.0

`); renameAllH1s(doc.$, "New Title"); doc.expectHtml(`

New Title

0.45.0

`); }); test("removeHtmlExtensionsInRelativeLinks()", () => { - const doc = Doc.load( + const doc = CheerioDoc.load( ``, ); removeHtmlExtensionsInRelativeLinks(doc.$, doc.$main); @@ -159,7 +135,7 @@ test("removeHtmlExtensionsInRelativeLinks()", () => { }); test("removeDownloadSourceCode()", () => { - const doc = Doc.load( + const doc = CheerioDoc.load( `

(Source code)

../_images/converters-1.png @@ -174,7 +150,7 @@ test("removeDownloadSourceCode()", () => { }); test("removePermalinks()", () => { - const doc = Doc.load(`Link + const doc = CheerioDoc.load(`Link Link Link Link @@ -185,7 +161,7 @@ test("removePermalinks()", () => { }); test("removeColonSpans()", () => { - const doc = Doc.load( + const doc = CheerioDoc.load( `
Parameters:
`, ); removeColonSpans(doc.$main); @@ -194,7 +170,7 @@ test("removeColonSpans()", () => { describe("removeMatplotlibFigCaptions()", () => { test("removes
", () => { - const doc = Doc.load(` + const doc = CheerioDoc.load(`
../_images/fake_provider-1_00.png
@@ -230,7 +206,7 @@ describe("removeMatplotlibFigCaptions()", () => { }); test("removes
", () => { - const doc = Doc.load(` + const doc = CheerioDoc.load(`
../_images/qiskit-transpiler-passes-DynamicalDecoupling-1_00.png

@@ -276,14 +252,14 @@ describe("removeMatplotlibFigCaptions()", () => {

My caption

`; - const doc = Doc.load(html); + const doc = CheerioDoc.load(html); removeMatplotlibFigCaptions(doc.$main); doc.expectHtml(html); }); }); test("addLanguageClassToCodeBlocks()", () => { - const doc1 = Doc.load(`

Circuit symbol:

+ const doc1 = CheerioDoc.load(`

Circuit symbol:

     ┌──────────┐
     q_0: ┤ U(ϴ,φ,λ) ├
         └──────────┘
@@ -301,7 +277,7 @@ test("addLanguageClassToCodeBlocks()", () => {
     
`); - const doc2 = Doc.load(`
+ const doc2 = CheerioDoc.load(`
from qiskit_ibm_runtime.options import Options
 
@@ -324,7 +300,7 @@ test("addLanguageClassToCodeBlocks()", () => {
 
 test("replaceSourceLinksWithGitHub()", () => {
   // Assumes that removeHtmlExtensionsInRelativeLinks() has already removed .html from the URL.
-  const doc = Doc.load(
+  const doc = CheerioDoc.load(
     `
     
     `,
@@ -343,7 +319,7 @@ test("replaceSourceLinksWithGitHub()", () => {
 });
 
 test("convertRubricsToHeaders()", () => {
-  const doc = Doc.load(`

Example

+ const doc = CheerioDoc.load(`

Example

Examples

References

Reference

@@ -366,7 +342,7 @@ describe("maybeSetModuleMetadata()", () => { test("not a module", () => { const html = `

Hello

`; const meta: Metadata = {}; - const doc = Doc.load(html); + const doc = CheerioDoc.load(html); maybeSetModuleMetadata(doc.$, doc.$main, meta); doc.expectHtml(html); expect(meta).toEqual({}); @@ -374,7 +350,7 @@ describe("maybeSetModuleMetadata()", () => { const checkModuleFound = (html: string, name: string): void => { const meta: Metadata = {}; - const doc = Doc.load(html); + const doc = CheerioDoc.load(html); maybeSetModuleMetadata(doc.$, doc.$main, meta); doc.expectHtml(html); expect(meta).toEqual({ @@ -406,62 +382,6 @@ describe("maybeSetModuleMetadata()", () => { }); }); -describe("prepareGitHubLink()", () => { - test("no link", () => { - const html = `None)#`; - const doc = Doc.load(html); - const result = prepareGitHubLink(doc.$main, false); - expect(result).toEqual(""); - doc.expectHtml(html); - }); - - test("link from sphinx.ext.viewcode", () => { - const doc = Doc.load( - `None)[source]#`, - ); - const result = prepareGitHubLink(doc.$main, false); - expect(result).toEqual( - ` GitHub`, - ); - doc.expectHtml( - `None)#`, - ); - }); - - test("link from sphinx.ext.linkcode", () => { - const doc = Doc.load( - `None)[source]#`, - ); - const result = prepareGitHubLink(doc.$main, false); - expect(result).toEqual( - ` GitHub`, - ); - doc.expectHtml( - `None)#`, - ); - }); - - test("method link only used when it has line numbers", () => { - const withLinesDoc = Doc.load( - `)[source]`, - ); - const withoutLinesDoc = Doc.load( - `)[source]`, - ); - const withLinesResult = prepareGitHubLink(withLinesDoc.$main, true); - const withoutLinesResult = prepareGitHubLink(withoutLinesDoc.$main, true); - - expect(withLinesResult).toEqual( - ` GitHub`, - ); - expect(withoutLinesResult).toEqual(""); - - const strippedHtml = `)`; - withLinesDoc.expectHtml(strippedHtml); - withoutLinesDoc.expectHtml(strippedHtml); - }); -}); - describe("processMembersAndSetMeta()", () => { test("function with added heading", () => { const html = `

Circuit Converters

@@ -476,7 +396,7 @@ describe("processMembersAndSetMeta()", () => {
  • copy_operations – Deep copy the operation objects in the QuantumCircuit for the output DAGCircuit.

  • `; - const doc = Doc.load(html); + const doc = CheerioDoc.load(html); const meta: Metadata = {}; processMembersAndSetMeta(doc.$, doc.$main, meta); doc.expectHtml(`

    Circuit Converters

    @@ -523,7 +443,7 @@ backends may not have this attribute.

    `; - const doc = Doc.load(html); + const doc = CheerioDoc.load(html); const meta: Metadata = {}; processMembersAndSetMeta(doc.$, doc.$main, meta); doc.expectHtml(`

    least_busy

    @@ -583,7 +503,7 @@ minimal install. You can read more about those, and ways to check for their pre particular error, which subclasses both QiskitError and the Python built-in ImportError.

    `; - const doc = Doc.load(html); + const doc = CheerioDoc.load(html); const meta: Metadata = {}; processMembersAndSetMeta(doc.$, doc.$main, meta); doc.expectHtml(`
    diff --git a/scripts/lib/api/processHtml.ts b/scripts/lib/api/processHtml.ts index 543b2f61f16..c5e41c7e36f 100644 --- a/scripts/lib/api/processHtml.ts +++ b/scripts/lib/api/processHtml.ts @@ -15,6 +15,7 @@ import { CheerioAPI, Cheerio, load } from "cheerio"; import { Image } from "./HtmlToMdResult"; import { Metadata, ApiType } from "./Metadata"; import { getLastPartFromFullIdentifier } from "../stringUtils"; +import { findByText, prepareGitHubLink } from "./generateApiComponents"; export type ProcessedHtml = { html: string; @@ -314,7 +315,10 @@ function processMember( apiType: string, id: string, ) { - const githubSourceLink = prepareGitHubLink($child, apiType === "method"); + const githubUrl = prepareGitHubLink($child, apiType === "method"); + const githubSourceLink = githubUrl + ? ` GitHub` + : ""; findByText($, $main, "em.property", apiType).remove(); @@ -448,35 +452,6 @@ function processFunctionOrException( return `

    ${apiName}

    ${descriptionHtml}`; } -/** - * Removes the original link from sphinx.ext.viewcode and returns the HTML string for our own link. - * - * This returns the HTML string, rather than directly inserting into the HTML, because the insertion - * logic is most easily handled by the calling code. - * - * This function works the same regardless of whether the Sphinx build used `sphinx.ext.viewcode` - * or `sphinx.ext.linkcode` because they have the same HTML structure. - * - * If the link corresponds to a method, we only return a link if it has line numbers included, - * which implies that the link came from `sphinx.ext.linkcode` rather than `sphinx.ext.viewcode`. - * That's because the owning class will already have a link to the relevant file; it's - * noisy and not helpful to repeat the same link without line numbers for the individual methods. - */ -export function prepareGitHubLink( - $child: Cheerio, - isMethod: boolean, -): string { - const originalLink = $child.find(".viewcode-link").closest("a"); - if (originalLink.length === 0) { - return ""; - } - const href = originalLink.attr("href")!; - originalLink.first().remove(); - return !isMethod || href.includes(".py#") - ? ` GitHub` - : ""; -} - export function maybeSetModuleMetadata( $: CheerioAPI, $main: Cheerio, @@ -531,18 +506,6 @@ export function updateModuleHeadings( }); } -/** - * Find the element that both matches the `selector` and whose content is the same as `text` - */ -function findByText( - $: CheerioAPI, - $main: Cheerio, - selector: string, - text: string, -): Cheerio { - return $main.find(selector).filter((i, el) => $(el).text().trim() === text); -} - function getApiType($dl: Cheerio): ApiType | undefined { for (const className of [ "function", diff --git a/scripts/lib/stringUtils.test.ts b/scripts/lib/stringUtils.test.ts index 3b42d11cef3..15a3cd71c87 100644 --- a/scripts/lib/stringUtils.test.ts +++ b/scripts/lib/stringUtils.test.ts @@ -17,6 +17,7 @@ import { removePrefix, removeSuffix, getLastPartFromFullIdentifier, + capitalize, } from "./stringUtils"; test("removePart()", () => { @@ -49,3 +50,8 @@ test("getLastPartFromFullIdentifier", () => { expect(getLastPartFromFullIdentifier("my_software")).toEqual("my_software"); expect(getLastPartFromFullIdentifier("")).toEqual(""); }); + +test("capitalize()", () => { + const input = "hello world!"; + expect(capitalize(input)).toEqual("Hello world!"); +}); diff --git a/scripts/lib/stringUtils.ts b/scripts/lib/stringUtils.ts index 531c1eb196d..d30e9562ed9 100644 --- a/scripts/lib/stringUtils.ts +++ b/scripts/lib/stringUtils.ts @@ -12,6 +12,8 @@ import { last, split } from "lodash"; +export const APOSTROPHE_HEX_CODE = "'"; + export function removePart(text: string, separator: string, matcher: string[]) { return text .split(separator) @@ -36,3 +38,7 @@ export function removeSuffix(text: string, suffix: string) { export function getLastPartFromFullIdentifier(fullIdentifierName: string) { return last(split(fullIdentifierName, "."))!; } + +export function capitalize(text: string) { + return text.charAt(0).toUpperCase() + text.slice(1); +} diff --git a/scripts/lib/testUtils.ts b/scripts/lib/testUtils.ts new file mode 100644 index 00000000000..d3a8469fcb0 --- /dev/null +++ b/scripts/lib/testUtils.ts @@ -0,0 +1,36 @@ +// This code is a Qiskit project. +// +// (C) Copyright IBM 2024. +// +// This code is licensed under the Apache License, Version 2.0. You may +// obtain a copy of this license in the LICENSE file in the root directory +// of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +// +// Any modifications or derivative works of this code must retain this +// copyright notice, and modified files need to carry a notice indicating +// that they have been altered from the originals. + +import { CheerioAPI, Cheerio, load as cheerioLoad } from "cheerio"; + +export class CheerioDoc { + readonly $: CheerioAPI; + readonly $main: Cheerio; + + constructor($: CheerioAPI, $main: Cheerio) { + this.$ = $; + this.$main = $main; + } + + static load(html: string): CheerioDoc { + const $ = cheerioLoad(`
    ${html}
    `); + return new CheerioDoc($, $("[role='main']")); + } + + html(): string { + return this.$main.html()!.trim(); + } + + expectHtml(expected: string): void { + expect(this.html()).toEqual(expected.trim()); + } +}