Skip to content

Commit

Permalink
Add generateApiComponents.ts and tests (#1074)
Browse files Browse the repository at this point in the history
Part of #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`
  • Loading branch information
arnaucasau authored Mar 25, 2024
1 parent f3a730c commit 3f0855d
Show file tree
Hide file tree
Showing 7 changed files with 420 additions and 143 deletions.
202 changes: 202 additions & 0 deletions scripts/lib/api/generateApiComponents.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `<span class='sig-prename descclassname'><span class='pre'>Estimator.</span></span><span class='sig-name descname'><span class='pre'>run</span></span><span class='sig-paren'>(</span><em class='sig-param'><span class='n'><span class='pre'>circuits</span></span></em>, <em class='sig-param'><span class='n'><span class='pre'>observables</span></span></em>, <em class='sig-param'><span class='n'><span class='pre'>parameter_values</span></span><span class='o'><span class='pre'>=</span></span><span class='default_value'><span class='pre'>None</span></span></em>, <em class='sig-param'><span class='o'><span class='pre'>**</span></span><span class='n'><span class='pre'>kwargs</span></span></em><span class='sig-paren'>)</span></dt>`;

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(`<Function
id='qiskit_ibm_runtime.Estimator.run'
name='run'
attributeTypeHint='undefined'
attributeValue='undefined'
github='undefined'
signature='Estimator.run(circuits, observables, parameter_values=None, **kwargs)'
extraSignatures='[]'
>
`);
});

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(`<Function
id='qiskit_ibm_runtime.Estimator.run'
name='run'
attributeTypeHint='undefined'
attributeValue='undefined'
github='undefined'
signature='Estimator.run(circuits, observables, parameter_values=None, **kwargs)'
extraSignatures='[${APOSTROPHE_HEX_CODE}Estimator.run(circuits, observables, parameter_values=None, **kwargs)${APOSTROPHE_HEX_CODE}, ${APOSTROPHE_HEX_CODE}Estimator.run(circuits, observables, parameter_values=None, **kwargs)${APOSTROPHE_HEX_CODE}]'
>
`);
});

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(`<Attribute
id='qiskit.circuit.QuantumCircuit.instance'
name='instance'
attributeTypeHint='str | None'
attributeValue='None'
github='undefined'
signature=''
extraSignatures='[]'
>
`);
});

test("Create Class tag without props", async () => {
const componentProps = {
id: "qiskit.circuit.Sampler",
};

const tag = await createOpeningTag("Class", componentProps);
expect(tag).toEqual(`<Class
id='qiskit.circuit.Sampler'
name='undefined'
attributeTypeHint='undefined'
attributeValue='undefined'
github='undefined'
signature=''
extraSignatures='[]'
>
`);
});
});

describe("prepareGitHubLink()", () => {
test("no link", () => {
const html = `<span class="pre">None</span><span class="sig-paren">)</span><a class="headerlink" href="#qiskit_ibm_runtime.IBMBackend" title="Link to this definition">#</a>`;
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(
`<span class="pre">None</span><span class="sig-paren">)</span><a class="reference internal" href="https://ibm.com/my_link"><span class="viewcode-link"><span class="pre">[source]</span></span></a><a class="headerlink" href="#qiskit_ibm_runtime.IBMBackend" title="Link to this definition">#</a>`,
);
const result = prepareGitHubLink(doc.$main, false);
expect(result).toEqual(`https://ibm.com/my_link`);
doc.expectHtml(
`<span class="pre">None</span><span class="sig-paren">)</span><a class="headerlink" href="#qiskit_ibm_runtime.IBMBackend" title="Link to this definition">#</a>`,
);
});

test("link from sphinx.ext.linkcode", () => {
const doc = CheerioDoc.load(
`<span class="pre">None</span><span class="sig-paren">)</span><a class="reference external" href="https://github.com/Qiskit/qiskit/blob/stable/1.0/qiskit/utils/deprecation.py#L24-L101"><span class="viewcode-link"><span class="pre">[source]</span></span></a><a class="headerlink" href="#qiskit_ibm_runtime.IBMBackend" title="Link to this definition">#</a>`,
);
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(
`<span class="pre">None</span><span class="sig-paren">)</span><a class="headerlink" href="#qiskit_ibm_runtime.IBMBackend" title="Link to this definition">#</a>`,
);
});

test("method link only used when it has line numbers", () => {
const withLinesDoc = CheerioDoc.load(
`<span class="sig-paren">)</span><a class="reference external" href="https://github.com/Qiskit/qiskit-ibm-provider/tree/stable/0.10/qiskit_ibm_provider/transpiler/passes/scheduling/block_base_padder.py#L91-L117"><span class="viewcode-link"><span class="pre">[source]</span></span></a><a class="headerlink" href="#qiskit_ibm_provider.transpiler.passes.scheduling.PadDelay.run" title="Link to this definition">¶</a>`,
);
const withoutLinesDoc = CheerioDoc.load(
`<span class="sig-paren">)</span><a class="reference external" href="https://github.com/Qiskit/qiskit-ibm-provider/tree/stable/0.10/qiskit_ibm_provider/transpiler/passes/scheduling/block_base_padder.py"><span class="viewcode-link"><span class="pre">[source]</span></span></a><a class="headerlink" href="#qiskit_ibm_provider.transpiler.passes.scheduling.PadDelay.run" title="Link to this definition">¶</a>`,
);
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 = `<span class="sig-paren">)</span><a class="headerlink" href="#qiskit_ibm_provider.transpiler.passes.scheduling.PadDelay.run" title="Link to this definition">¶</a>`;
withLinesDoc.expectHtml(strippedHtml);
withoutLinesDoc.expectHtml(strippedHtml);
});
});
144 changes: 144 additions & 0 deletions scripts/lib/api/generateApiComponents.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<any>,
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<any>,
selector: string,
text: string,
): Cheerio<any> {
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<string> {
if (!signatureHtml) {
return "";
}

const html = `<code>${signatureHtml}</code>`;
const file = await unified()
.use(rehypeParse)
.use(rehypeRemark)
.use(remarkStringify)
.process(html);

return String(file)
.replaceAll("\n", "")
.replaceAll("'", APOSTROPHE_HEX_CODE)
.replace(/^`/, "")
.replace(/`$/, "");
}
Loading

0 comments on commit 3f0855d

Please sign in to comment.