Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add generateApiComponents.ts and tests #1074

Merged
merged 8 commits into from
Mar 25, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions scripts/lib/api/generateApiComponents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
// 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 { CheerioAPI, Cheerio, load as cheerioLoad } from "cheerio";

import {
ComponentProps,
prepareGitHubLink,
htmlSignatureToMd,
addExtraSignatures,
createOpeningTag,
} from "./generateApiComponents";

class Doc {
arnaucasau marked this conversation as resolved.
Show resolved Hide resolved
readonly $: CheerioAPI;
readonly $main: Cheerio<any>;

constructor($: CheerioAPI, $main: Cheerio<any>) {
this.$ = $;
this.$main = $main;
}

static load(html: string): Doc {
const $ = cheerioLoad(`<div role="main">${html}</div>`);
return new Doc($, $("[role='main']"));
}

html(): string {
return this.$main.html()!.trim();
}

expectHtml(expected: string): void {
expect(this.html()).toEqual(expected.trim());
}
}

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='[&#x27;Estimator.run(circuits, observables, parameter_values=None, **kwargs)&#x27;, &#x27;Estimator.run(circuits, observables, parameter_values=None, **kwargs)&#x27;]'
arnaucasau marked this conversation as resolved.
Show resolved Hide resolved
>
`);
});

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 = Doc.load(html);
const result = prepareGitHubLink(doc.$main, false);
expect(result).toEqual(undefined);
doc.expectHtml(html);
});

test("link from sphinx.ext.viewcode", () => {
const doc = Doc.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 = Doc.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 = Doc.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 = Doc.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);
});
});
138 changes: 138 additions & 0 deletions scripts/lib/api/generateApiComponents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// 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 type = props.attributeTypeHint?.replaceAll("'", APOSTROPHE_HEX_CODE);
arnaucasau marked this conversation as resolved.
Show resolved Hide resolved
const value = 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='${type}'
attributeValue='${value}'
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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a big deal if this is going to be annoying to implement, but you can switch processHtml.md to use this now, along with findByText. That way we don't duplicate. And you could move the tests too if it's too hard.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, it probably would be good to do this if isn't too much of a pain because it may take a few days to land the follow up PR since it's blocked by the Docker image being updated for staging.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

$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`
*/
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 ?? []),
];
Comment on lines +117 to +119
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using flatMap surprises me because rawSignature is a string. Should this be

componentProps.extraRawSignatures = Array.from(
    extraRawSignatures.map((x) => x.signature).filter((sig) => sig !== undefined)
)

Also can extraRawSignatures have a more precise type like string[] or Array<string | undefined>? Seems weird to have all the ComponentProps if we're going to discard them.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In reality, rawSignature is a string but could be undefined given that we have API types without it. The problem with using the filter is that we get the type (string | undefined)[] instead of string[] | undefined as in the flatMap.

Also can extraRawSignatures have a more precise type

This is a good idea. I think we could remove the flatMap or the map + filter entirely, but I would like to do it in #1026 as a follow-up because we can simplify the main function by only storing the overloaded signature and not all props. The function addExtraSignatures will make all the work as a black box

}

/**
* Converts a given HTML into markdown
*/
export async function htmlSignatureToMd(
signatureHtml: string,
): Promise<string> {
if (!signatureHtml) {
return "";
}

const html = `<span class="target" id=''/><p><code>${signatureHtml}</code></p>`;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is id an empty string?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's empty because it's not needed, but I was able to remove the span and the paragraph too 👍 . Apparently, we only need to add the code tag. Without the code tag, the signature has an extra apostrophe at the beginning and at the end

const file = await unified()
.use(rehypeParse)
.use(rehypeRemark)
.use(remarkStringify)
.process(html);

return String(file)
.replaceAll("\n", "")
.replaceAll("'", APOSTROPHE_HEX_CODE)
.replace(/^`/, "")
.replace(/`$/, "");
}
2 changes: 2 additions & 0 deletions scripts/lib/stringUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

import { last, split } from "lodash";

arnaucasau marked this conversation as resolved.
Show resolved Hide resolved
export const APOSTROPHE_HEX_CODE = "&#x27;";

export function removePart(text: string, separator: string, matcher: string[]) {
return text
.split(separator)
Expand Down
Loading