From 3f0855d4e79025f5347d7b42f4a0e044ba3bbc16 Mon Sep 17 00:00:00 2001
From: Arnau Casau <47946624+arnaucasau@users.noreply.github.com>
Date: Mon, 25 Mar 2024 15:49:45 +0100
Subject: [PATCH] Add `generateApiComponents.ts` and tests (#1074)
Part of https://github.com/Qiskit/documentation/issues/1008
This PR is a precursor of #1026, and it creates a new script named
`generateApiComponents.ts` with some tests. This script will have all
the logic to generate the MDX components, but, in the meantime, this PR
adds some functions that can be tested independently by just receiving,
as parameters, a list of props or HTML code.
It also copies over the functions `prepareGitHubLink` and `findByText`
from `processHtml.ts`
---
scripts/lib/api/generateApiComponents.test.ts | 202 ++++++++++++++++++
scripts/lib/api/generateApiComponents.ts | 144 +++++++++++++
scripts/lib/api/processHtml.test.ts | 122 ++---------
scripts/lib/api/processHtml.ts | 47 +---
scripts/lib/stringUtils.test.ts | 6 +
scripts/lib/stringUtils.ts | 6 +
scripts/lib/testUtils.ts | 36 ++++
7 files changed, 420 insertions(+), 143 deletions(-)
create mode 100644 scripts/lib/api/generateApiComponents.test.ts
create mode 100644 scripts/lib/api/generateApiComponents.ts
create mode 100644 scripts/lib/testUtils.ts
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(
``,
);
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(`