From e616d60a399765870e38b1b871b62ffbf774b27b Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 22 May 2024 19:16:41 +0100 Subject: [PATCH 01/68] First draft: Can update lessons and add new ones! :tada: --- package.json | 1 + scripts/tutorial-uploader/main.ts | 161 ++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 scripts/tutorial-uploader/main.ts diff --git a/package.json b/package.json index 6e7333fec7..327f94be19 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "zx": "^7.2.3" }, "dependencies": { + "@directus/sdk": "^16.0.1", "esbuild": "^0.19.11", "fast-levenshtein": "^3.0.0", "markdown-link-extractor": "^3.1.0", diff --git a/scripts/tutorial-uploader/main.ts b/scripts/tutorial-uploader/main.ts new file mode 100644 index 0000000000..d5d2a2c6fd --- /dev/null +++ b/scripts/tutorial-uploader/main.ts @@ -0,0 +1,161 @@ +// 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 { readFile } from 'fs/promises'; +import { createDirectus, rest, authentication, readItems, readItem, updateItem, createItem, uploadFiles } from '@directus/sdk'; + + +/* To do: + * + * [ ] Get URL from environment + * [ ] Get auth from environment + * [ ] Zip file automatically + * [ ] Use temp folder for zipping + * [ ] Read from YAML file + * [ ] Fix types + * [ ] Throw correctly on request failures + * [ ] More helpful console logging + */ + +/* Information specified in the YAML file */ +interface LocalTutorialData { + title: string; + short_description: string; + slug: string; + status: string; + notebook_path: string; + category: string; + reading_time?: number; + catalog_featured?: boolean; +} + +const testTutorial: LocalTutorialData = { + title: "Frank's tutorial", + slug: "frank-tutorial", + status: "published", + short_description: "Short description of Frank's tutorial", + notebook_path: "tutorials/chsh-inequality.zip", + category: "test category", +} + +class API { + client: any; // TODO: Work out how to set this correctly + + constructor(url: string) { + this.client = createDirectus(url) + .with(rest()) + .with(authentication()); + } + + async login() { + await this.client.login("admin@example.com", "password") + } + + async getTutorialId(slug: string): Promise { + // TODO: Work out how to filter requests on server side + const response = await this.client.request( + // @ts-ignore + readItems("tutorials", { fields: ['id', 'slug'] }) + ) + const match = response.find((item: { slug: string }) => item.slug === slug) + return match ? match.id : null + } + + async getEnglishTranslationId(tutorialId: string): Promise { + // @ts-ignore + const response = await this.client.request(readItem("tutorials", tutorialId, { fields: ['translations'] })) + return response.translations[0] + } + + async getCategoryId(categoryName: string): Promise { + const response = await this.client.request( + // @ts-ignore + readItems("tutorials_categories", { fields: ['id', 'name'] }) + ) + const match = response.find((item: { name: string }) => item.name === categoryName) + if (!match) { + console.log(`No category with name "${categoryName}"`) + } + return match.id + } + + async uploadZipFromDisk(zippedFilePath: string): Promise { + const file = new Blob( + [await readFile(zippedFilePath)], + { type: "application/zip" } + ) + const formData = new FormData() + formData.append("title", zippedFilePath) + formData.append("file", file, zippedFilePath) + const response = await this.client.request(uploadFiles(formData)) + return response.id; + } + + async updateExistingTutorial(tutorialId: string, tutorial: LocalTutorialData) { + const temporalFileId = await this.uploadZipFromDisk(tutorial.notebook_path!) + const translationId = await this.getEnglishTranslationId(tutorialId) + const newData = { + reading_time: tutorial.reading_time, + catalog_featured: tutorial.catalog_featured, + status: tutorial.status, + translations: [{ + title: tutorial.title, + id: translationId, + temporal_file: temporalFileId, + short_description: tutorial.short_description, + }] + } + + // @ts-ignore + await this.client.request(updateItem("tutorials", tutorialId, newData)) + } + + /* + * Only sets minimum data required for API to accept the creation request + * updateExistingTutorial is called immediately after + */ + async createTutorial(tutorial: LocalTutorialData): Promise { + const translationData = { + title: tutorial.title, + languages_code: "en-US", + short_description: tutorial.short_description, + } + // @ts-ignore + const translation = await this.client.request(createItem("tutorials_translations", translationData)) + const tutorialData = { + category: await this.getCategoryId(tutorial.category), + translations: [ translation.id ], + slug: tutorial.slug + } + // @ts-ignore + const newTutorial = await this.client.request(createItem("tutorials", tutorialData)) + return newTutorial.id + } + + async upsertTutorial(tutorial: LocalTutorialData) { + let id = await this.getTutorialId(tutorial.slug) + if (id === null) { + id = await this.createTutorial(tutorial) + } + await this.updateExistingTutorial(id, tutorial) + } +} + + +async function main() { + const api = new API("http://0.0.0.0:8055") + await api.login() + + await api.upsertTutorial(testTutorial) +} + +main().then(() => process.exit()); From 008d816c7b7d613728d2e6889d0be9176a81105b Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Thu, 23 May 2024 09:48:18 +0100 Subject: [PATCH 02/68] Use token from environment --- scripts/tutorial-uploader/main.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/scripts/tutorial-uploader/main.ts b/scripts/tutorial-uploader/main.ts index d5d2a2c6fd..19e931d04f 100644 --- a/scripts/tutorial-uploader/main.ts +++ b/scripts/tutorial-uploader/main.ts @@ -11,12 +11,12 @@ // that they have been altered from the originals. import { readFile } from 'fs/promises'; -import { createDirectus, rest, authentication, readItems, readItem, updateItem, createItem, uploadFiles } from '@directus/sdk'; +import { createDirectus, rest, staticToken, readItems, readItem, updateItem, createItem, uploadFiles } from '@directus/sdk'; /* To do: * - * [ ] Get URL from environment + * [x] Get URL from environment * [ ] Get auth from environment * [ ] Zip file automatically * [ ] Use temp folder for zipping @@ -53,11 +53,8 @@ class API { constructor(url: string) { this.client = createDirectus(url) .with(rest()) - .with(authentication()); - } - - async login() { - await this.client.login("admin@example.com", "password") + // @ts-ignore // TODO: Throw if undefined + .with(staticToken(process.env.IBM_QUANTUM_LEARNING_TOKEN)); } async getTutorialId(slug: string): Promise { @@ -152,9 +149,8 @@ class API { async function main() { - const api = new API("http://0.0.0.0:8055") - await api.login() - + // @ts-ignore // TODO: Throw if undefined + const api = new API(process.env.IBM_QUANTUM_LEARNING_API_URL) await api.upsertTutorial(testTutorial) } From 5d692bc8b7c166f51e44e46b02a2cc7c23f007fa Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Thu, 23 May 2024 10:25:06 +0100 Subject: [PATCH 03/68] Separate API and read from YAML --- package.json | 4 +- scripts/tutorial-uploader/api.ts | 159 ++++++++++++++++++++++++++++++ scripts/tutorial-uploader/main.ts | 150 ++++------------------------ 3 files changed, 180 insertions(+), 133 deletions(-) create mode 100644 scripts/tutorial-uploader/api.ts diff --git a/package.json b/package.json index 327f94be19..b01a24d764 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "typecheck": "tsc", "regen-apis": "node -r esbuild-register scripts/commands/regenerateApiDocs.ts", "gen-api": "node -r esbuild-register scripts/commands/updateApiDocs.ts", - "make-historical": "node -r esbuild-register scripts/commands/convertApiDocsToHistorical.ts" + "make-historical": "node -r esbuild-register scripts/commands/convertApiDocsToHistorical.ts", + "tutorial:sync": "node -r esbuild-register scripts/tutorial-uploader/main.ts" }, "devDependencies": { "@swc/jest": "^0.2.29", @@ -62,6 +63,7 @@ "@directus/sdk": "^16.0.1", "esbuild": "^0.19.11", "fast-levenshtein": "^3.0.0", + "js-yaml": "^4.1.0", "markdown-link-extractor": "^3.1.0", "transform-markdown-links": "^2.1.0" } diff --git a/scripts/tutorial-uploader/api.ts b/scripts/tutorial-uploader/api.ts new file mode 100644 index 0000000000..97d6d21a86 --- /dev/null +++ b/scripts/tutorial-uploader/api.ts @@ -0,0 +1,159 @@ +// 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 { readFile } from "fs/promises"; +import { + createDirectus, + rest, + staticToken, + readItems, + readItem, + updateItem, + createItem, + uploadFiles, +} from "@directus/sdk"; + +/* To do: + * + * [x] Get URL from environment + * [ ] Get auth from environment + * [ ] Zip file automatically + * [ ] Use temp folder for zipping + * [ ] Fix types + * [ ] Throw correctly on request failures + * [ ] More helpful console logging + */ + +/* Information specified in the YAML file */ +export interface LocalTutorialData { + title: string; + short_description: string; + slug: string; + status: string; + notebook_path: string; + category: string; + reading_time?: number; + catalog_featured?: boolean; +} + +export class API { + client: any; // TODO: Work out how to set this correctly + + constructor(url: string, token: string) { + this.client = createDirectus(url).with(rest()).with(staticToken(token)); + } + + async getTutorialId(slug: string): Promise { + // TODO: Work out how to filter requests on server side + const response = await this.client.request( + // @ts-ignore + readItems("tutorials", { fields: ["id", "slug"] }), + ); + const match = response.find((item: { slug: string }) => item.slug === slug); + return match ? match.id : null; + } + + async getEnglishTranslationId(tutorialId: string): Promise { + const response = await this.client.request( + // @ts-ignore + readItem("tutorials", tutorialId, { fields: ["translations"] }), + ); + return response.translations[0]; + } + + async getCategoryId(categoryName: string): Promise { + const response = await this.client.request( + // @ts-ignore + readItems("tutorials_categories", { fields: ["id", "name"] }), + ); + const match = response.find( + (item: { name: string }) => item.name === categoryName, + ); + if (!match) { + // TODO: Throw correctly + console.log(`No category with name "${categoryName}"`); + } + return match.id; + } + + /* Returns the file's ID */ + async uploadZipFromDisk(zippedFilePath: string): Promise { + const file = new Blob([await readFile(zippedFilePath)], { + type: "application/zip", + }); + const formData = new FormData(); + formData.append("title", zippedFilePath); + formData.append("file", file, zippedFilePath); + const response = await this.client.request(uploadFiles(formData)); + return response.id; + } + + async updateExistingTutorial( + tutorialId: string, + tutorial: LocalTutorialData, + ) { + const temporalFileId = await this.uploadZipFromDisk( + tutorial.notebook_path!, + ); + const translationId = await this.getEnglishTranslationId(tutorialId); + const newData = { + reading_time: tutorial.reading_time, + catalog_featured: tutorial.catalog_featured, + status: tutorial.status, + translations: [ + { + title: tutorial.title, + id: translationId, + temporal_file: temporalFileId, + short_description: tutorial.short_description, + }, + ], + }; + + // @ts-ignore + await this.client.request(updateItem("tutorials", tutorialId, newData)); + } + + /* + * Only sets minimum data required for API to accept the creation request + * updateExistingTutorial is called immediately after + */ + async createTutorial(tutorial: LocalTutorialData): Promise { + const translationData = { + title: tutorial.title, + languages_code: "en-US", + short_description: tutorial.short_description, + }; + const translation = await this.client.request( + // @ts-ignore + createItem("tutorials_translations", translationData), + ); + const tutorialData = { + category: await this.getCategoryId(tutorial.category), + translations: [translation.id], + slug: tutorial.slug, + }; + const newTutorial = await this.client.request( + // @ts-ignore + createItem("tutorials", tutorialData), + ); + return newTutorial.id; + } + + async upsertTutorial(tutorial: LocalTutorialData) { + let id = await this.getTutorialId(tutorial.slug); + if (!id) { + id = await this.createTutorial(tutorial); + } + await this.updateExistingTutorial(id, tutorial); + } +} diff --git a/scripts/tutorial-uploader/main.ts b/scripts/tutorial-uploader/main.ts index 19e931d04f..0bfc32e0dc 100644 --- a/scripts/tutorial-uploader/main.ts +++ b/scripts/tutorial-uploader/main.ts @@ -10,148 +10,34 @@ // copyright notice, and modified files need to carry a notice indicating // that they have been altered from the originals. -import { readFile } from 'fs/promises'; -import { createDirectus, rest, staticToken, readItems, readItem, updateItem, createItem, uploadFiles } from '@directus/sdk'; +import { readFile } from "fs/promises"; +import yaml from "js-yaml"; +import { API, type LocalTutorialData } from "./api"; +const CONFIG_PATH = "tutorials/learning-api.conf.yaml"; /* To do: - * - * [x] Get URL from environment - * [ ] Get auth from environment - * [ ] Zip file automatically - * [ ] Use temp folder for zipping - * [ ] Read from YAML file - * [ ] Fix types - * [ ] Throw correctly on request failures + * + * [x] Read from YAML file + * [ ] Throw correctly * [ ] More helpful console logging */ -/* Information specified in the YAML file */ -interface LocalTutorialData { - title: string; - short_description: string; - slug: string; - status: string; - notebook_path: string; - category: string; - reading_time?: number; - catalog_featured?: boolean; +async function readConfig(path: string): Promise { + const raw = await readFile(path, "utf8"); + return yaml.load(raw) as LocalTutorialData[]; } -const testTutorial: LocalTutorialData = { - title: "Frank's tutorial", - slug: "frank-tutorial", - status: "published", - short_description: "Short description of Frank's tutorial", - notebook_path: "tutorials/chsh-inequality.zip", - category: "test category", -} - -class API { - client: any; // TODO: Work out how to set this correctly - - constructor(url: string) { - this.client = createDirectus(url) - .with(rest()) - // @ts-ignore // TODO: Throw if undefined - .with(staticToken(process.env.IBM_QUANTUM_LEARNING_TOKEN)); - } - - async getTutorialId(slug: string): Promise { - // TODO: Work out how to filter requests on server side - const response = await this.client.request( - // @ts-ignore - readItems("tutorials", { fields: ['id', 'slug'] }) - ) - const match = response.find((item: { slug: string }) => item.slug === slug) - return match ? match.id : null - } - - async getEnglishTranslationId(tutorialId: string): Promise { - // @ts-ignore - const response = await this.client.request(readItem("tutorials", tutorialId, { fields: ['translations'] })) - return response.translations[0] - } - - async getCategoryId(categoryName: string): Promise { - const response = await this.client.request( - // @ts-ignore - readItems("tutorials_categories", { fields: ['id', 'name'] }) - ) - const match = response.find((item: { name: string }) => item.name === categoryName) - if (!match) { - console.log(`No category with name "${categoryName}"`) - } - return match.id - } - - async uploadZipFromDisk(zippedFilePath: string): Promise { - const file = new Blob( - [await readFile(zippedFilePath)], - { type: "application/zip" } - ) - const formData = new FormData() - formData.append("title", zippedFilePath) - formData.append("file", file, zippedFilePath) - const response = await this.client.request(uploadFiles(formData)) - return response.id; - } - - async updateExistingTutorial(tutorialId: string, tutorial: LocalTutorialData) { - const temporalFileId = await this.uploadZipFromDisk(tutorial.notebook_path!) - const translationId = await this.getEnglishTranslationId(tutorialId) - const newData = { - reading_time: tutorial.reading_time, - catalog_featured: tutorial.catalog_featured, - status: tutorial.status, - translations: [{ - title: tutorial.title, - id: translationId, - temporal_file: temporalFileId, - short_description: tutorial.short_description, - }] - } - - // @ts-ignore - await this.client.request(updateItem("tutorials", tutorialId, newData)) - } - - /* - * Only sets minimum data required for API to accept the creation request - * updateExistingTutorial is called immediately after - */ - async createTutorial(tutorial: LocalTutorialData): Promise { - const translationData = { - title: tutorial.title, - languages_code: "en-US", - short_description: tutorial.short_description, - } - // @ts-ignore - const translation = await this.client.request(createItem("tutorials_translations", translationData)) - const tutorialData = { - category: await this.getCategoryId(tutorial.category), - translations: [ translation.id ], - slug: tutorial.slug - } - // @ts-ignore - const newTutorial = await this.client.request(createItem("tutorials", tutorialData)) - return newTutorial.id - } - - async upsertTutorial(tutorial: LocalTutorialData) { - let id = await this.getTutorialId(tutorial.slug) - if (id === null) { - id = await this.createTutorial(tutorial) - } - await this.updateExistingTutorial(id, tutorial) - } -} - - async function main() { // @ts-ignore // TODO: Throw if undefined - const api = new API(process.env.IBM_QUANTUM_LEARNING_API_URL) - await api.upsertTutorial(testTutorial) + const api = new API( + process.env.LEARNING_API_URL!, + process.env.LEARNING_API_TOKEN!, + ); + + for (const tutorial of await readConfig(CONFIG_PATH)) { + await api.upsertTutorial(tutorial); + } } main().then(() => process.exit()); From 123f362e0a3b839fe0e4b756accc7a0e2a9745ea Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Thu, 23 May 2024 11:52:30 +0100 Subject: [PATCH 04/68] Add tutorial data --- tutorials/learning-api.conf.yaml | 150 ++++++++++++++++++++++++------- 1 file changed, 119 insertions(+), 31 deletions(-) diff --git a/tutorials/learning-api.conf.yaml b/tutorials/learning-api.conf.yaml index d0aca59cfb..07be27acef 100644 --- a/tutorials/learning-api.conf.yaml +++ b/tutorials/learning-api.conf.yaml @@ -1,31 +1,119 @@ -# This file relates tutorials in this folder to lesson URLs on the learning -# platform (https://learning.quantum.ibm.com). See /tutorials/README.md for -# instructions on adding a new tutorial. -lessons: - - path: chsh-inequality - urlStaging: tutorials/1e8b063e-38c3-4525-824b-e4fbb1e675f9 - urlProduction: tutorials/da7dbfe7-4ae7-4889-b78d-5dfd5263fb02 - - path: grovers-algorithm - urlStaging: tutorials/b593867d-8aae-4cf7-b083-325028765fbd - urlProduction: tutorials/76db794e-a4a7-466e-bd2b-d5d5950a9279 - - path: quantum-approximate-optimization-algorithm - urlStaging: tutorials/410c2e0d-004f-427d-a145-2014bb31899e - urlProduction: tutorials/8c5868ac-d82f-4ca4-ba9d-70d3d6344e80 - - path: submitting-transpiled-circuits - urlStaging: tutorials/7dd1d5f9-3b6c-4bd5-9fb9-faeb728b44b2 - urlProduction: tutorials/0f49821a-9de4-4932-a77a-ec3faf23cf55 - - path: variational-quantum-eigensolver - urlStaging: tutorials/ef3b922f-0f32-42a3-8e3b-dc0fc18fd34c - urlProduction: tutorials/5e01f54c-0ef4-4003-8793-d6e5717110bb - - path: repeat-until-success - urlStaging: tutorials/caacbe11-a00f-4bbc-bc8b-953cf1d75b46 - urlProduction: tutorials/5d88dda9-d990-4118-9950-8216528e8c8b - - path: build-repetition-codes - urlStaging: tutorials/46fc6326-bc12-4106-b334-7d693be34dd8 - urlProduction: tutorials/7bd832af-5bc3-4382-b837-accb709d5e5c - - path: explore-composer - urlStaging: tutorials/28f53e87-52ba-4fa7-a1f9-9ad7f548a7e0 - urlProduction: tutorials/967371a7-159d-459a-a7a6-210c55b14766 - - path: combine-error-mitigation-options-with-the-estimator-primitive - urlStaging: tutorials/58d982db-9510-4815-a13c-c81abae1507f - urlProduction: tutorials/5f0507b3-3eae-4ccd-9a7a-74edd5a157a3 +# This file contains all the metadata for tutorials controlled by this repo. +# +# WARNING: Do not change any "slugs" until appropriate redirects are set up. +# Speak to Abby Mitchell, Eric Arellano, or Frank Harkins to set up redirects. +# See /tutorials/README.md for instructions on adding a new tutorial. +# +# Description of values: +# +# title: The title of the page (top-level headings in the notebook are ignored) +# short_description: The description users will see in page previews (max 160 characters) +# slug: The last part of the URL; do not change this (see note above) +# notebook_path: The path to the tutorial folder, relative to `tutorials/` +# status: Can be "draft", "published", or "archived". Do not set to +# "archived" until appropriate redirects are set up. +# category: Can be "Workflow example" or "How-to" +# reading_time: Rough number of minutes needed to read the page +# catalog_featured: Whether the page should be in the featured section in the catalog. + +- title: CHSH inequality + short_description: > + In this tutorial, you will run an experiment on a quantum computer to + demonstrate the violation of the CHSH inequality with the Estimator + primitive. + slug: chsh-inequality + notebook_path: chsh-inequality + status: published + category: Workflow example + reading_time: 20 + catalog_featured: true + +- title: Grover's algorithm + short_description: > + Demonstrating how to create Grover's algorithm with Qiskit Runtime + slug: grovers-algorithm + notebook_path: grovers-algorithm + status: published + category: Workflow example + reading_time: 15 + catalog_featured: false + +- title: Quantum approximate optimization algorithm + short_description: > + Quantum approximate optimization algorithm is a hybrid algorithm to solve + combinatorial optimization problems. This tutorial uses Qiskit Runtime to + solve a simple max-cut problem. + slug: quantum-approximate-optimization-algorithm + notebook_path: quantum-approximate-optimization-algorithm + status: published + category: Workflow example + reading_time: 20 + catalog_featured: true + +- title: Submit transpiled circuits + short_description: > + In this tutorial, we'll disable automatic transpilation and take you + through the full process of creating, transpiling, and submitting circuits. + slug: submit-transpiled-circuits + notebook_path: submitting-transpiled-circuits + status: published + category: How-to + reading_time: 15 + catalog_featured: false + +- title: Variational quantum eigensolver + short_description: > + Variational quantum algorithms are hybrid-algorithms for observing the + utility of quantum computation on noisy, near-term IBM Quantum systems. + slug: variational-quantum-eigensolver + notebook_path: variational-quantum-eigensolver + status: published + category: Workflow example + reading_time: 28 + catalog_featured: true + +- title: Repeat until success + short_description: > + This tutorial demonstrates IBM dynamic-circuits to use mid-circuit + measurements to produce a circuit that repeats until a successful syndrome + measurement. + slug: repeat-until-success + notebook_path: repeat-until-success + status: published + category: How-to + reading_time: 25 + catalog_featured: false + +- title: Build repetition codes + short_description: > + This tutorial demonstrates how to build basic repetition codes using IBM + dynamic circuits, an example of basic quantum error correction (QEC). + slug: build-repetition-codes + notebook_path: build-repetition-codes + status: published + category: How-to + reading_time: 15 + catalog_featured: false + +- title: Explore gates and circuits with the Quantum Composer + short_description: > + Learn how to use IBM Quantum Composer to build quantum circuits and run + them on IBM Quantum systems and simulators. + slug: explore-gates-and-circuits-with-the-quantum-composer + notebook_path: explore-composer + status: published + category: How-to + reading_time: 95 + catalog_featured: false + +- title: Combine error mitigation options with the Estimator primitive + short_description: > + Combine error mitigation options for utility-scale experiments using 100Q+ + IBM Quantum systems and the Qiskit Runtime Estimator primitive. + slug: combine-error-mitigation-options-with-the-estimator-primitive + notebook_path: combine-error-mitigation-options-with-the-estimator-primitive + status: published + category: Workflow example + reading_time: 180 + catalog_featured: true + From 1ae0fbd8c51084e15a3164a66cd302a367f6ee15 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Thu, 23 May 2024 11:53:26 +0100 Subject: [PATCH 05/68] `notebook_path` -> `local_path` Is actually path to folder containing notebook --- scripts/tutorial-uploader/api.ts | 7 ++++--- tutorials/learning-api.conf.yaml | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/scripts/tutorial-uploader/api.ts b/scripts/tutorial-uploader/api.ts index 97d6d21a86..8b4a0ae9a8 100644 --- a/scripts/tutorial-uploader/api.ts +++ b/scripts/tutorial-uploader/api.ts @@ -25,7 +25,8 @@ import { /* To do: * * [x] Get URL from environment - * [ ] Get auth from environment + * [x] Get auth from environment + * [ ] Handle "topics" field * [ ] Zip file automatically * [ ] Use temp folder for zipping * [ ] Fix types @@ -39,7 +40,7 @@ export interface LocalTutorialData { short_description: string; slug: string; status: string; - notebook_path: string; + local_path: string; category: string; reading_time?: number; catalog_featured?: boolean; @@ -102,7 +103,7 @@ export class API { tutorial: LocalTutorialData, ) { const temporalFileId = await this.uploadZipFromDisk( - tutorial.notebook_path!, + tutorial.local_path!, ); const translationId = await this.getEnglishTranslationId(tutorialId); const newData = { diff --git a/tutorials/learning-api.conf.yaml b/tutorials/learning-api.conf.yaml index 07be27acef..2a1446d43b 100644 --- a/tutorials/learning-api.conf.yaml +++ b/tutorials/learning-api.conf.yaml @@ -9,7 +9,7 @@ # title: The title of the page (top-level headings in the notebook are ignored) # short_description: The description users will see in page previews (max 160 characters) # slug: The last part of the URL; do not change this (see note above) -# notebook_path: The path to the tutorial folder, relative to `tutorials/` +# local_path: The path to the tutorial folder, relative to `tutorials/` # status: Can be "draft", "published", or "archived". Do not set to # "archived" until appropriate redirects are set up. # category: Can be "Workflow example" or "How-to" @@ -22,7 +22,7 @@ demonstrate the violation of the CHSH inequality with the Estimator primitive. slug: chsh-inequality - notebook_path: chsh-inequality + local_path: chsh-inequality status: published category: Workflow example reading_time: 20 @@ -32,7 +32,7 @@ short_description: > Demonstrating how to create Grover's algorithm with Qiskit Runtime slug: grovers-algorithm - notebook_path: grovers-algorithm + local_path: grovers-algorithm status: published category: Workflow example reading_time: 15 @@ -44,7 +44,7 @@ combinatorial optimization problems. This tutorial uses Qiskit Runtime to solve a simple max-cut problem. slug: quantum-approximate-optimization-algorithm - notebook_path: quantum-approximate-optimization-algorithm + local_path: quantum-approximate-optimization-algorithm status: published category: Workflow example reading_time: 20 @@ -55,7 +55,7 @@ In this tutorial, we'll disable automatic transpilation and take you through the full process of creating, transpiling, and submitting circuits. slug: submit-transpiled-circuits - notebook_path: submitting-transpiled-circuits + local_path: submitting-transpiled-circuits status: published category: How-to reading_time: 15 @@ -66,7 +66,7 @@ Variational quantum algorithms are hybrid-algorithms for observing the utility of quantum computation on noisy, near-term IBM Quantum systems. slug: variational-quantum-eigensolver - notebook_path: variational-quantum-eigensolver + local_path: variational-quantum-eigensolver status: published category: Workflow example reading_time: 28 @@ -78,7 +78,7 @@ measurements to produce a circuit that repeats until a successful syndrome measurement. slug: repeat-until-success - notebook_path: repeat-until-success + local_path: repeat-until-success status: published category: How-to reading_time: 25 @@ -89,7 +89,7 @@ This tutorial demonstrates how to build basic repetition codes using IBM dynamic circuits, an example of basic quantum error correction (QEC). slug: build-repetition-codes - notebook_path: build-repetition-codes + local_path: build-repetition-codes status: published category: How-to reading_time: 15 @@ -100,7 +100,7 @@ Learn how to use IBM Quantum Composer to build quantum circuits and run them on IBM Quantum systems and simulators. slug: explore-gates-and-circuits-with-the-quantum-composer - notebook_path: explore-composer + local_path: explore-composer status: published category: How-to reading_time: 95 @@ -111,7 +111,7 @@ Combine error mitigation options for utility-scale experiments using 100Q+ IBM Quantum systems and the Qiskit Runtime Estimator primitive. slug: combine-error-mitigation-options-with-the-estimator-primitive - notebook_path: combine-error-mitigation-options-with-the-estimator-primitive + local_path: combine-error-mitigation-options-with-the-estimator-primitive status: published category: Workflow example reading_time: 180 From 1d4217b3ce4e058773d8740341013cc5a5ebe76c Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Thu, 23 May 2024 14:13:29 +0100 Subject: [PATCH 06/68] Zip tutorial folders --- scripts/tutorial-uploader/api.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/scripts/tutorial-uploader/api.ts b/scripts/tutorial-uploader/api.ts index 8b4a0ae9a8..d563003a72 100644 --- a/scripts/tutorial-uploader/api.ts +++ b/scripts/tutorial-uploader/api.ts @@ -11,6 +11,8 @@ // that they have been altered from the originals. import { readFile } from "fs/promises"; +import { $ } from 'zx'; +import { tmpdir } from 'os' import { createDirectus, rest, @@ -27,8 +29,8 @@ import { * [x] Get URL from environment * [x] Get auth from environment * [ ] Handle "topics" field - * [ ] Zip file automatically - * [ ] Use temp folder for zipping + * [x] Zip file automatically + * [x] Use temp folder for zipping * [ ] Fix types * [ ] Throw correctly on request failures * [ ] More helpful console logging @@ -87,13 +89,20 @@ export class API { } /* Returns the file's ID */ - async uploadZipFromDisk(zippedFilePath: string): Promise { + async uploadLocalFolder(path: string): Promise { + // Zip folder + const zippedFilePath = `${tmpdir()}/${path}.zip` + await $`(cd tutorials && zip -qr ${zippedFilePath} ${path})` + + // Build form const file = new Blob([await readFile(zippedFilePath)], { type: "application/zip", }); const formData = new FormData(); formData.append("title", zippedFilePath); formData.append("file", file, zippedFilePath); + + // Upload form const response = await this.client.request(uploadFiles(formData)); return response.id; } @@ -102,7 +111,7 @@ export class API { tutorialId: string, tutorial: LocalTutorialData, ) { - const temporalFileId = await this.uploadZipFromDisk( + const temporalFileId = await this.uploadLocalFolder( tutorial.local_path!, ); const translationId = await this.getEnglishTranslationId(tutorialId); From bbee2cae4f38b562db9b7ee70ed27d336bf03195 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Thu, 23 May 2024 15:37:30 +0100 Subject: [PATCH 07/68] Document developement setup --- scripts/tutorial-uploader/README.md | 39 +++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 scripts/tutorial-uploader/README.md diff --git a/scripts/tutorial-uploader/README.md b/scripts/tutorial-uploader/README.md new file mode 100644 index 0000000000..f42a8c5f39 --- /dev/null +++ b/scripts/tutorial-uploader/README.md @@ -0,0 +1,39 @@ +# Tutorial uploader + +This script uploads tutorials to IBM Quantum Learning. + +## Developing + +To work on this script, you'll need to set up the `saiba-api` project locally. + +There are some extra steps you'll need to take to set up `saiba-api` for +developing this script: + +* Add the following line to the end of `docker-compose.yaml`. Do not commit + this change. + ``` + PUBLIC_URL: 'https://learning.www-dev.quantum-computing.ibm.com/' + ``` + +* Login into the local CMS () using + - email: `admin@example.com` + - password: `password` + +* Create a token for local testing. + 1. In the local CMS, go to "User directory" (in the leftmost navbar) + 2. Click "Create item" + 3. Create a new user with the "Content creator admin" role and generate a new + static token. Copy the token to your clipboard. Then click the tick on the + top-right of the page to save the user. + 4. To test the script in this repo, export the following envrionment + variables. + ``` + export LEARNING_API_URL=http://0.0.0.0:8055 + export LEARNING_API_TOKEN= + ``` + Consider using [direnv](https://direnv.net/) to handle this. + +* Create two categories with names `Workflow example` and `How-to`; our script + fails if these categories don't exist. To create a category: + 1. Go to + 2. Add the name and click the tick in the top-right corner to save From 2e3069d6b1f77cfd1425331c7caa4060821b8bdb Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Thu, 23 May 2024 16:23:36 +0100 Subject: [PATCH 08/68] Validate config file at runtime --- scripts/tutorial-uploader/api.ts | 16 ++----- .../tutorial-uploader/local-tutorial-data.ts | 46 +++++++++++++++++++ scripts/tutorial-uploader/main.ts | 7 ++- 3 files changed, 54 insertions(+), 15 deletions(-) create mode 100644 scripts/tutorial-uploader/local-tutorial-data.ts diff --git a/scripts/tutorial-uploader/api.ts b/scripts/tutorial-uploader/api.ts index d563003a72..3b83ac029e 100644 --- a/scripts/tutorial-uploader/api.ts +++ b/scripts/tutorial-uploader/api.ts @@ -24,6 +24,8 @@ import { uploadFiles, } from "@directus/sdk"; +import { type LocalTutorialData } from './local-tutorial-data'; + /* To do: * * [x] Get URL from environment @@ -36,18 +38,6 @@ import { * [ ] More helpful console logging */ -/* Information specified in the YAML file */ -export interface LocalTutorialData { - title: string; - short_description: string; - slug: string; - status: string; - local_path: string; - category: string; - reading_time?: number; - catalog_featured?: boolean; -} - export class API { client: any; // TODO: Work out how to set this correctly @@ -112,7 +102,7 @@ export class API { tutorial: LocalTutorialData, ) { const temporalFileId = await this.uploadLocalFolder( - tutorial.local_path!, + tutorial.local_path, ); const translationId = await this.getEnglishTranslationId(tutorialId); const newData = { diff --git a/scripts/tutorial-uploader/local-tutorial-data.ts b/scripts/tutorial-uploader/local-tutorial-data.ts new file mode 100644 index 0000000000..f4e408c6c1 --- /dev/null +++ b/scripts/tutorial-uploader/local-tutorial-data.ts @@ -0,0 +1,46 @@ +// 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. + +/* Information specified in the YAML file */ +export interface LocalTutorialData { + title: string; + short_description: string; + slug: string; + status: string; + local_path: string; + category: string; + reading_time: number; + catalog_featured: boolean; +} + +/* Runtime type-checking to make sure YAML file is valid */ +export function verifyLocalTutorialData(obj: any): LocalTutorialData { + for (let [attr, type] of [ + ["title", "string"], + ["short_description", "string"], + ["slug", "string"], + ["status", "string"], + ["local_path", "string"], + ["category", "string"], + ["reading_time", "number"], + ["catalog_featured", "boolean"], + ]) { + if (typeof obj[attr] !== type) { + throw new Error( + "The following entry in `learning-api.conf.yaml` is invalid.\n\n" + + JSON.stringify(obj) + + `\n\nAttribute '${attr}' should exist and be of type '${type}'.\n` + ) + } + } + return obj as LocalTutorialData +} diff --git a/scripts/tutorial-uploader/main.ts b/scripts/tutorial-uploader/main.ts index 0bfc32e0dc..ece06d34cc 100644 --- a/scripts/tutorial-uploader/main.ts +++ b/scripts/tutorial-uploader/main.ts @@ -12,7 +12,8 @@ import { readFile } from "fs/promises"; import yaml from "js-yaml"; -import { API, type LocalTutorialData } from "./api"; +import { API } from "./api"; +import { type LocalTutorialData, verifyLocalTutorialData } from './local-tutorial-data'; const CONFIG_PATH = "tutorials/learning-api.conf.yaml"; @@ -35,7 +36,9 @@ async function main() { process.env.LEARNING_API_TOKEN!, ); - for (const tutorial of await readConfig(CONFIG_PATH)) { + const localTutorialData = (await readConfig(CONFIG_PATH)).map(x => verifyLocalTutorialData(x)) + + for (const tutorial of localTutorialData) { await api.upsertTutorial(tutorial); } } From 9f2d123c8d9611bac5047720a3d1469c44e84f10 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Thu, 23 May 2024 16:35:16 +0100 Subject: [PATCH 09/68] Validate `learning-api.conf.yaml` --- package.json | 3 ++- scripts/tutorial-uploader/{ => lib}/api.ts | 0 .../{ => lib}/local-tutorial-data.ts | 13 ++++++++++-- .../tutorial-uploader/{main.ts => sync.ts} | 15 +++---------- scripts/tutorial-uploader/validate.ts | 21 +++++++++++++++++++ 5 files changed, 37 insertions(+), 15 deletions(-) rename scripts/tutorial-uploader/{ => lib}/api.ts (100%) rename scripts/tutorial-uploader/{ => lib}/local-tutorial-data.ts (77%) rename scripts/tutorial-uploader/{main.ts => sync.ts} (64%) create mode 100644 scripts/tutorial-uploader/validate.ts diff --git a/package.json b/package.json index b01a24d764..0060d814ed 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "regen-apis": "node -r esbuild-register scripts/commands/regenerateApiDocs.ts", "gen-api": "node -r esbuild-register scripts/commands/updateApiDocs.ts", "make-historical": "node -r esbuild-register scripts/commands/convertApiDocsToHistorical.ts", - "tutorial:sync": "node -r esbuild-register scripts/tutorial-uploader/main.ts" + "tutorial:sync": "node -r esbuild-register scripts/tutorial-uploader/sync.ts", + "tutorial:validate": "node -r esbuild-register scripts/tutorial-uploader/validate.ts" }, "devDependencies": { "@swc/jest": "^0.2.29", diff --git a/scripts/tutorial-uploader/api.ts b/scripts/tutorial-uploader/lib/api.ts similarity index 100% rename from scripts/tutorial-uploader/api.ts rename to scripts/tutorial-uploader/lib/api.ts diff --git a/scripts/tutorial-uploader/local-tutorial-data.ts b/scripts/tutorial-uploader/lib/local-tutorial-data.ts similarity index 77% rename from scripts/tutorial-uploader/local-tutorial-data.ts rename to scripts/tutorial-uploader/lib/local-tutorial-data.ts index f4e408c6c1..9e618a6067 100644 --- a/scripts/tutorial-uploader/local-tutorial-data.ts +++ b/scripts/tutorial-uploader/lib/local-tutorial-data.ts @@ -10,6 +10,9 @@ // copyright notice, and modified files need to carry a notice indicating // that they have been altered from the originals. +import yaml from "js-yaml"; +import { readFile } from "fs/promises"; + /* Information specified in the YAML file */ export interface LocalTutorialData { title: string; @@ -22,8 +25,14 @@ export interface LocalTutorialData { catalog_featured: boolean; } +export async function readTutorialData(path: string): Promise { + const raw = await readFile(path, "utf8"); + const parsed = yaml.load(raw) as any[]; + return parsed.map(i => verifyLocalTutorialData(i)); +} + /* Runtime type-checking to make sure YAML file is valid */ -export function verifyLocalTutorialData(obj: any): LocalTutorialData { +function verifyLocalTutorialData(obj: any): LocalTutorialData { for (let [attr, type] of [ ["title", "string"], ["short_description", "string"], @@ -37,7 +46,7 @@ export function verifyLocalTutorialData(obj: any): LocalTutorialData { if (typeof obj[attr] !== type) { throw new Error( "The following entry in `learning-api.conf.yaml` is invalid.\n\n" - + JSON.stringify(obj) + + yaml.dump(obj) + `\n\nAttribute '${attr}' should exist and be of type '${type}'.\n` ) } diff --git a/scripts/tutorial-uploader/main.ts b/scripts/tutorial-uploader/sync.ts similarity index 64% rename from scripts/tutorial-uploader/main.ts rename to scripts/tutorial-uploader/sync.ts index ece06d34cc..a7d06c2c72 100644 --- a/scripts/tutorial-uploader/main.ts +++ b/scripts/tutorial-uploader/sync.ts @@ -10,10 +10,8 @@ // copyright notice, and modified files need to carry a notice indicating // that they have been altered from the originals. -import { readFile } from "fs/promises"; -import yaml from "js-yaml"; -import { API } from "./api"; -import { type LocalTutorialData, verifyLocalTutorialData } from './local-tutorial-data'; +import { API } from "./lib/api"; +import { readTutorialData } from './lib/local-tutorial-data'; const CONFIG_PATH = "tutorials/learning-api.conf.yaml"; @@ -24,11 +22,6 @@ const CONFIG_PATH = "tutorials/learning-api.conf.yaml"; * [ ] More helpful console logging */ -async function readConfig(path: string): Promise { - const raw = await readFile(path, "utf8"); - return yaml.load(raw) as LocalTutorialData[]; -} - async function main() { // @ts-ignore // TODO: Throw if undefined const api = new API( @@ -36,9 +29,7 @@ async function main() { process.env.LEARNING_API_TOKEN!, ); - const localTutorialData = (await readConfig(CONFIG_PATH)).map(x => verifyLocalTutorialData(x)) - - for (const tutorial of localTutorialData) { + for (const tutorial of await readTutorialData(CONFIG_PATH)) { await api.upsertTutorial(tutorial); } } diff --git a/scripts/tutorial-uploader/validate.ts b/scripts/tutorial-uploader/validate.ts new file mode 100644 index 0000000000..a91b857201 --- /dev/null +++ b/scripts/tutorial-uploader/validate.ts @@ -0,0 +1,21 @@ +// 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 { readTutorialData } from './lib/local-tutorial-data'; + +const CONFIG_PATH = "tutorials/learning-api.conf.yaml"; + +async function main() { + await readTutorialData(CONFIG_PATH) +} + +main().then(() => process.exit()); From 12bfe65f2b9aad48da705dea19a83ff78a22eea5 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Thu, 23 May 2024 18:38:16 +0100 Subject: [PATCH 10/68] Handle topics --- scripts/tutorial-uploader/README.md | 11 +++ scripts/tutorial-uploader/lib/api.ts | 68 +++++++++++++++++-- .../lib/local-tutorial-data.ts | 30 +++++--- tutorials/learning-api.conf.yaml | 17 +++++ 4 files changed, 111 insertions(+), 15 deletions(-) diff --git a/scripts/tutorial-uploader/README.md b/scripts/tutorial-uploader/README.md index f42a8c5f39..82568ff028 100644 --- a/scripts/tutorial-uploader/README.md +++ b/scripts/tutorial-uploader/README.md @@ -37,3 +37,14 @@ developing this script: fails if these categories don't exist. To create a category: 1. Go to 2. Add the name and click the tick in the top-right corner to save + +* Create the following tutorial topics at . + > TODO: Can we automate this? + * Chemistry + * Dynamic circuits + * Error mitigation + * Optimization + * Qiskit patterns + * Scheduling + * Scheduling + * Transpilation diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 3b83ac029e..918ec6447b 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -21,6 +21,7 @@ import { readItem, updateItem, createItem, + deleteItem, uploadFiles, } from "@directus/sdk"; @@ -30,7 +31,7 @@ import { type LocalTutorialData } from './local-tutorial-data'; * * [x] Get URL from environment * [x] Get auth from environment - * [ ] Handle "topics" field + * [x] Handle "topics" field * [x] Zip file automatically * [x] Use temp folder for zipping * [ ] Fix types @@ -78,6 +79,66 @@ export class API { return match.id; } + async getTopicId(topicName: string): Promise { + // TODO: Maybe DRY with getCategoryId + const response = await this.client.request( + // @ts-ignore + readItems("tutorials_topics", { fields: ["id", "name"] }), + ); + const match = response.find( + (item: { name: string }) => item.name === topicName, + ); + if (!match) { + // TODO: Throw correctly + console.log(`No topic with name "${topicName}"`); + } + return match.id; + } + + async getTopicRelationId(tutorialId: string, topicId: string | null): Promise { + const response = await this.client.request( + // @ts-ignore + readItems("tutorials_tutorials_topics"), + ); + const match = response.find( + (item: { tutorials_id: string, tutorials_topics_id: string }) => { + (item.tutorials_id === tutorialId) && (item.tutorials_topics_id === topicId) + } + ); + if (!match) { + // TODO: Throw correctly + console.log(`No tutorial/tutorial_topic relation with name "${topicId}"`); + } + return match.id; + } + + + async clearTopics(tutorialId: string) { + // "tutorials_tutorials_topics" is mapping of tutorial to topics + const response = await this.client.request( + // @ts-ignore + readItems("tutorials_tutorials_topics"), + ); + const matches = response.filter( + (item: { tutorials_id: string }) => item.tutorials_id === tutorialId + ); + for (const m of matches) { + // @ts-ignore + await this.client.request(deleteItem("tutorials_tutorials_topics", m.id)).catch((err) => console.log(err)) + } + } + + async updateTutorialTopics(tutorialId: string, topicNames: string[]) { + await this.clearTopics(tutorialId) + for (const name of topicNames) { + const id = await this.getTopicId(name); + await this.client.request( + // @ts-ignore + createItem("tutorials_tutorials_topics", { tutorials_id: tutorialId, tutorials_topics_id: id }) + ) + } + } + /* Returns the file's ID */ async uploadLocalFolder(path: string): Promise { // Zip folder @@ -101,9 +162,7 @@ export class API { tutorialId: string, tutorial: LocalTutorialData, ) { - const temporalFileId = await this.uploadLocalFolder( - tutorial.local_path, - ); + const temporalFileId = await this.uploadLocalFolder(tutorial.local_path,); const translationId = await this.getEnglishTranslationId(tutorialId); const newData = { reading_time: tutorial.reading_time, @@ -121,6 +180,7 @@ export class API { // @ts-ignore await this.client.request(updateItem("tutorials", tutorialId, newData)); + await this.updateTutorialTopics(tutorialId, tutorial.topics) } /* diff --git a/scripts/tutorial-uploader/lib/local-tutorial-data.ts b/scripts/tutorial-uploader/lib/local-tutorial-data.ts index 9e618a6067..d38c9b6daf 100644 --- a/scripts/tutorial-uploader/lib/local-tutorial-data.ts +++ b/scripts/tutorial-uploader/lib/local-tutorial-data.ts @@ -21,6 +21,7 @@ export interface LocalTutorialData { status: string; local_path: string; category: string; + topics: string[]; reading_time: number; catalog_featured: boolean; } @@ -31,23 +32,30 @@ export async function readTutorialData(path: string): Promise verifyLocalTutorialData(i)); } +const isString = (x: any) => { return (typeof x === "string") } +const isNumber = (x: any) => { return (typeof x === "number") } +const isBoolean = (x: any) => { return (typeof x === "boolean") } + /* Runtime type-checking to make sure YAML file is valid */ function verifyLocalTutorialData(obj: any): LocalTutorialData { - for (let [attr, type] of [ - ["title", "string"], - ["short_description", "string"], - ["slug", "string"], - ["status", "string"], - ["local_path", "string"], - ["category", "string"], - ["reading_time", "number"], - ["catalog_featured", "boolean"], + for (let [attr, isCorrectType] of [ + ["title", isString], + ["short_description", isString], + ["slug", isString], + ["status", isString], + ["local_path", isString], + ["category", isString], + ["topics", Array.isArray], + ["reading_time", isNumber], + ["catalog_featured", isBoolean], ]) { - if (typeof obj[attr] !== type) { + // @ts-ignore + if (!isCorrectType(obj[attr])) { throw new Error( "The following entry in `learning-api.conf.yaml` is invalid.\n\n" + yaml.dump(obj) - + `\n\nAttribute '${attr}' should exist and be of type '${type}'.\n` + + `\n\nAttribute '${attr}' should exist and be of correct type.\n` + + `Problem with attribute '${attr}'.\n` ) } } diff --git a/tutorials/learning-api.conf.yaml b/tutorials/learning-api.conf.yaml index 2a1446d43b..51ca0e83d3 100644 --- a/tutorials/learning-api.conf.yaml +++ b/tutorials/learning-api.conf.yaml @@ -13,6 +13,14 @@ # status: Can be "draft", "published", or "archived". Do not set to # "archived" until appropriate redirects are set up. # category: Can be "Workflow example" or "How-to" +# topics: List of topic names. Choose from +# * Chemistry +# * Dynamic circuits +# * Error mitigation +# * Optimization +# * Qiskit Patterns +# * Scheduling +# * Transpilation # reading_time: Rough number of minutes needed to read the page # catalog_featured: Whether the page should be in the featured section in the catalog. @@ -24,6 +32,7 @@ slug: chsh-inequality local_path: chsh-inequality status: published + topics: ["Scheduling"] category: Workflow example reading_time: 20 catalog_featured: true @@ -35,6 +44,7 @@ local_path: grovers-algorithm status: published category: Workflow example + topics: ["Scheduling"] reading_time: 15 catalog_featured: false @@ -47,6 +57,7 @@ local_path: quantum-approximate-optimization-algorithm status: published category: Workflow example + topics: ["Scheduling"] reading_time: 20 catalog_featured: true @@ -58,6 +69,7 @@ local_path: submitting-transpiled-circuits status: published category: How-to + topics: ["Transpilation"] reading_time: 15 catalog_featured: false @@ -69,6 +81,7 @@ local_path: variational-quantum-eigensolver status: published category: Workflow example + topics: ["Scheduling"] reading_time: 28 catalog_featured: true @@ -81,6 +94,7 @@ local_path: repeat-until-success status: published category: How-to + topics: ["Dynamic circuits"] reading_time: 25 catalog_featured: false @@ -92,6 +106,7 @@ local_path: build-repetition-codes status: published category: How-to + topics: ["Dynamic circuits"] reading_time: 15 catalog_featured: false @@ -103,6 +118,7 @@ local_path: explore-composer status: published category: How-to + topics: [] reading_time: 95 catalog_featured: false @@ -114,6 +130,7 @@ local_path: combine-error-mitigation-options-with-the-estimator-primitive status: published category: Workflow example + topics: ["Error mitigation", "Qiskit Patterns"] reading_time: 180 catalog_featured: true From f9f9eefababa47394cd292afdbfddb066ccf3404 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Fri, 24 May 2024 10:25:51 +0100 Subject: [PATCH 11/68] Refactor: De-duplicate `getId` functionality --- scripts/tutorial-uploader/lib/api.ts | 62 ++++------------------------ 1 file changed, 7 insertions(+), 55 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 918ec6447b..366f29d809 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -46,17 +46,18 @@ export class API { this.client = createDirectus(url).with(rest()).with(staticToken(token)); } - async getTutorialId(slug: string): Promise { + async getId(collection: string, field: string, value: any ): Promise { // TODO: Work out how to filter requests on server side const response = await this.client.request( // @ts-ignore - readItems("tutorials", { fields: ["id", "slug"] }), + readItems(collection, { fields: ["id", field] }), ); - const match = response.find((item: { slug: string }) => item.slug === slug); + const match = response.find((item: any) => item[field] === value); return match ? match.id : null; } async getEnglishTranslationId(tutorialId: string): Promise { + // TODO: This assumes the only translation is english (currently true) const response = await this.client.request( // @ts-ignore readItem("tutorials", tutorialId, { fields: ["translations"] }), @@ -64,55 +65,6 @@ export class API { return response.translations[0]; } - async getCategoryId(categoryName: string): Promise { - const response = await this.client.request( - // @ts-ignore - readItems("tutorials_categories", { fields: ["id", "name"] }), - ); - const match = response.find( - (item: { name: string }) => item.name === categoryName, - ); - if (!match) { - // TODO: Throw correctly - console.log(`No category with name "${categoryName}"`); - } - return match.id; - } - - async getTopicId(topicName: string): Promise { - // TODO: Maybe DRY with getCategoryId - const response = await this.client.request( - // @ts-ignore - readItems("tutorials_topics", { fields: ["id", "name"] }), - ); - const match = response.find( - (item: { name: string }) => item.name === topicName, - ); - if (!match) { - // TODO: Throw correctly - console.log(`No topic with name "${topicName}"`); - } - return match.id; - } - - async getTopicRelationId(tutorialId: string, topicId: string | null): Promise { - const response = await this.client.request( - // @ts-ignore - readItems("tutorials_tutorials_topics"), - ); - const match = response.find( - (item: { tutorials_id: string, tutorials_topics_id: string }) => { - (item.tutorials_id === tutorialId) && (item.tutorials_topics_id === topicId) - } - ); - if (!match) { - // TODO: Throw correctly - console.log(`No tutorial/tutorial_topic relation with name "${topicId}"`); - } - return match.id; - } - - async clearTopics(tutorialId: string) { // "tutorials_tutorials_topics" is mapping of tutorial to topics const response = await this.client.request( @@ -131,7 +83,7 @@ export class API { async updateTutorialTopics(tutorialId: string, topicNames: string[]) { await this.clearTopics(tutorialId) for (const name of topicNames) { - const id = await this.getTopicId(name); + const id = await this.getId("tutorials_topics", "name", name); await this.client.request( // @ts-ignore createItem("tutorials_tutorials_topics", { tutorials_id: tutorialId, tutorials_topics_id: id }) @@ -198,7 +150,7 @@ export class API { createItem("tutorials_translations", translationData), ); const tutorialData = { - category: await this.getCategoryId(tutorial.category), + category: await this.getId("tutorials_categories", "name", tutorial.category), translations: [translation.id], slug: tutorial.slug, }; @@ -210,7 +162,7 @@ export class API { } async upsertTutorial(tutorial: LocalTutorialData) { - let id = await this.getTutorialId(tutorial.slug); + let id = await this.getId("tutorials", "slug", tutorial.slug); if (!id) { id = await this.createTutorial(tutorial); } From ce6adbb785a67aa2e3e0f82e355a4c3e93bdcce3 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Fri, 24 May 2024 10:52:46 +0100 Subject: [PATCH 12/68] Refactor: Generalize getting many IDs --- scripts/tutorial-uploader/lib/api.ts | 30 +++++++++++++++++----------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 366f29d809..2216cc6a79 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -46,14 +46,26 @@ export class API { this.client = createDirectus(url).with(rest()).with(staticToken(token)); } - async getId(collection: string, field: string, value: any ): Promise { + async getIds(collection: string, field: string, value: any): Promise { // TODO: Work out how to filter requests on server side const response = await this.client.request( // @ts-ignore readItems(collection, { fields: ["id", field] }), ); - const match = response.find((item: any) => item[field] === value); - return match ? match.id : null; + const matchingIds = response + .filter((item: any) => item[field] === value) + .map((item: any) => item.id) + return matchingIds + } + + async getId(collection: string, field: string, value: any): Promise { + const ids = (await this.getIds(collection, field, value)) + if (ids.length === 0) { return null } + if (ids.length === 1) { return ids[0] } + throw new Error( + `Found ${ids.length} items for getId('${collection}', '${field}', '${value}'. ` + + `Expected one or none.` + ) } async getEnglishTranslationId(tutorialId: string): Promise { @@ -67,16 +79,10 @@ export class API { async clearTopics(tutorialId: string) { // "tutorials_tutorials_topics" is mapping of tutorial to topics - const response = await this.client.request( - // @ts-ignore - readItems("tutorials_tutorials_topics"), - ); - const matches = response.filter( - (item: { tutorials_id: string }) => item.tutorials_id === tutorialId - ); - for (const m of matches) { + const ids = await this.getIds("tutorials_tutorials_topics", "tutorials_id", tutorialId) + for (const id of ids) { // @ts-ignore - await this.client.request(deleteItem("tutorials_tutorials_topics", m.id)).catch((err) => console.log(err)) + await this.client.request(deleteItem("tutorials_tutorials_topics", id)).catch((err) => console.log(err)) } } From 4a25d7b63bc8df4fe2c30389fd28f335bd9c2ade Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Fri, 24 May 2024 10:55:27 +0100 Subject: [PATCH 13/68] Cleanup / lint --- scripts/tutorial-uploader/README.md | 30 ++++--- scripts/tutorial-uploader/lib/api.ts | 85 ++++++++++++------- .../lib/local-tutorial-data.ts | 32 ++++--- scripts/tutorial-uploader/sync.ts | 2 +- scripts/tutorial-uploader/validate.ts | 4 +- 5 files changed, 92 insertions(+), 61 deletions(-) diff --git a/scripts/tutorial-uploader/README.md b/scripts/tutorial-uploader/README.md index 82568ff028..0f51e33571 100644 --- a/scripts/tutorial-uploader/README.md +++ b/scripts/tutorial-uploader/README.md @@ -9,17 +9,20 @@ To work on this script, you'll need to set up the `saiba-api` project locally. There are some extra steps you'll need to take to set up `saiba-api` for developing this script: -* Add the following line to the end of `docker-compose.yaml`. Do not commit +- Add the following line to the end of `docker-compose.yaml`. Do not commit this change. + ``` PUBLIC_URL: 'https://learning.www-dev.quantum-computing.ibm.com/' ``` -* Login into the local CMS () using +- Login into the local CMS () using + - email: `admin@example.com` - password: `password` -* Create a token for local testing. +- Create a token for local testing. + 1. In the local CMS, go to "User directory" (in the leftmost navbar) 2. Click "Create item" 3. Create a new user with the "Content creator admin" role and generate a new @@ -33,18 +36,19 @@ developing this script: ``` Consider using [direnv](https://direnv.net/) to handle this. -* Create two categories with names `Workflow example` and `How-to`; our script +- Create two categories with names `Workflow example` and `How-to`; our script fails if these categories don't exist. To create a category: + 1. Go to 2. Add the name and click the tick in the top-right corner to save -* Create the following tutorial topics at . +- Create the following tutorial topics at . > TODO: Can we automate this? - * Chemistry - * Dynamic circuits - * Error mitigation - * Optimization - * Qiskit patterns - * Scheduling - * Scheduling - * Transpilation + - Chemistry + - Dynamic circuits + - Error mitigation + - Optimization + - Qiskit patterns + - Scheduling + - Scheduling + - Transpilation diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 2216cc6a79..5daf771897 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -11,8 +11,8 @@ // that they have been altered from the originals. import { readFile } from "fs/promises"; -import { $ } from 'zx'; -import { tmpdir } from 'os' +import { $ } from "zx"; +import { tmpdir } from "os"; import { createDirectus, rest, @@ -25,15 +25,9 @@ import { uploadFiles, } from "@directus/sdk"; -import { type LocalTutorialData } from './local-tutorial-data'; +import { type LocalTutorialData } from "./local-tutorial-data"; /* To do: - * - * [x] Get URL from environment - * [x] Get auth from environment - * [x] Handle "topics" field - * [x] Zip file automatically - * [x] Use temp folder for zipping * [ ] Fix types * [ ] Throw correctly on request failures * [ ] More helpful console logging @@ -46,7 +40,11 @@ export class API { this.client = createDirectus(url).with(rest()).with(staticToken(token)); } - async getIds(collection: string, field: string, value: any): Promise { + async getIds( + collection: string, + field: string, + value: any, + ): Promise { // TODO: Work out how to filter requests on server side const response = await this.client.request( // @ts-ignore @@ -54,24 +52,32 @@ export class API { ); const matchingIds = response .filter((item: any) => item[field] === value) - .map((item: any) => item.id) - return matchingIds + .map((item: any) => item.id); + return matchingIds; } - async getId(collection: string, field: string, value: any): Promise { - const ids = (await this.getIds(collection, field, value)) - if (ids.length === 0) { return null } - if (ids.length === 1) { return ids[0] } + async getId( + collection: string, + field: string, + value: any, + ): Promise { + const ids = await this.getIds(collection, field, value); + if (ids.length === 0) { + return null; + } + if (ids.length === 1) { + return ids[0]; + } throw new Error( - `Found ${ids.length} items for getId('${collection}', '${field}', '${value}'. ` - + `Expected one or none.` - ) + `Found ${ids.length} items for getId('${collection}', '${field}', '${value}'. ` + + `Expected one or none.`, + ); } async getEnglishTranslationId(tutorialId: string): Promise { // TODO: This assumes the only translation is english (currently true) const response = await this.client.request( - // @ts-ignore + // @ts-ignore readItem("tutorials", tutorialId, { fields: ["translations"] }), ); return response.translations[0]; @@ -79,29 +85,39 @@ export class API { async clearTopics(tutorialId: string) { // "tutorials_tutorials_topics" is mapping of tutorial to topics - const ids = await this.getIds("tutorials_tutorials_topics", "tutorials_id", tutorialId) + const ids = await this.getIds( + "tutorials_tutorials_topics", + "tutorials_id", + tutorialId, + ); for (const id of ids) { // @ts-ignore - await this.client.request(deleteItem("tutorials_tutorials_topics", id)).catch((err) => console.log(err)) + await this.client + // @ts-ignore + .request(deleteItem("tutorials_tutorials_topics", id)) + .catch((err: any) => console.log(err)); } } async updateTutorialTopics(tutorialId: string, topicNames: string[]) { - await this.clearTopics(tutorialId) + await this.clearTopics(tutorialId); for (const name of topicNames) { const id = await this.getId("tutorials_topics", "name", name); await this.client.request( // @ts-ignore - createItem("tutorials_tutorials_topics", { tutorials_id: tutorialId, tutorials_topics_id: id }) - ) + createItem("tutorials_tutorials_topics", { + tutorials_id: tutorialId, + tutorials_topics_id: id, + }), + ); } } /* Returns the file's ID */ async uploadLocalFolder(path: string): Promise { // Zip folder - const zippedFilePath = `${tmpdir()}/${path}.zip` - await $`(cd tutorials && zip -qr ${zippedFilePath} ${path})` + const zippedFilePath = `${tmpdir()}/${path}.zip`; + await $`(cd tutorials && zip -qr ${zippedFilePath} ${path})`; // Build form const file = new Blob([await readFile(zippedFilePath)], { @@ -120,7 +136,7 @@ export class API { tutorialId: string, tutorial: LocalTutorialData, ) { - const temporalFileId = await this.uploadLocalFolder(tutorial.local_path,); + const temporalFileId = await this.uploadLocalFolder(tutorial.local_path); const translationId = await this.getEnglishTranslationId(tutorialId); const newData = { reading_time: tutorial.reading_time, @@ -138,7 +154,7 @@ export class API { // @ts-ignore await this.client.request(updateItem("tutorials", tutorialId, newData)); - await this.updateTutorialTopics(tutorialId, tutorial.topics) + await this.updateTutorialTopics(tutorialId, tutorial.topics); } /* @@ -149,19 +165,22 @@ export class API { const translationData = { title: tutorial.title, languages_code: "en-US", - short_description: tutorial.short_description, }; const translation = await this.client.request( - // @ts-ignore + // @ts-ignore createItem("tutorials_translations", translationData), ); const tutorialData = { - category: await this.getId("tutorials_categories", "name", tutorial.category), + category: await this.getId( + "tutorials_categories", + "name", + tutorial.category, + ), translations: [translation.id], slug: tutorial.slug, }; const newTutorial = await this.client.request( - // @ts-ignore + // @ts-ignore createItem("tutorials", tutorialData), ); return newTutorial.id; diff --git a/scripts/tutorial-uploader/lib/local-tutorial-data.ts b/scripts/tutorial-uploader/lib/local-tutorial-data.ts index d38c9b6daf..fa916c7d13 100644 --- a/scripts/tutorial-uploader/lib/local-tutorial-data.ts +++ b/scripts/tutorial-uploader/lib/local-tutorial-data.ts @@ -26,15 +26,23 @@ export interface LocalTutorialData { catalog_featured: boolean; } -export async function readTutorialData(path: string): Promise { +export async function readTutorialData( + path: string, +): Promise { const raw = await readFile(path, "utf8"); const parsed = yaml.load(raw) as any[]; - return parsed.map(i => verifyLocalTutorialData(i)); + return parsed.map((i) => verifyLocalTutorialData(i)); } -const isString = (x: any) => { return (typeof x === "string") } -const isNumber = (x: any) => { return (typeof x === "number") } -const isBoolean = (x: any) => { return (typeof x === "boolean") } +const isString = (x: any) => { + return typeof x === "string"; +}; +const isNumber = (x: any) => { + return typeof x === "number"; +}; +const isBoolean = (x: any) => { + return typeof x === "boolean"; +}; /* Runtime type-checking to make sure YAML file is valid */ function verifyLocalTutorialData(obj: any): LocalTutorialData { @@ -52,12 +60,12 @@ function verifyLocalTutorialData(obj: any): LocalTutorialData { // @ts-ignore if (!isCorrectType(obj[attr])) { throw new Error( - "The following entry in `learning-api.conf.yaml` is invalid.\n\n" - + yaml.dump(obj) - + `\n\nAttribute '${attr}' should exist and be of correct type.\n` - + `Problem with attribute '${attr}'.\n` - ) + "The following entry in `learning-api.conf.yaml` is invalid.\n\n" + + yaml.dump(obj) + + `\n\nAttribute '${attr}' should exist and be of correct type.\n` + + `Problem with attribute '${attr}'.\n`, + ); } - } - return obj as LocalTutorialData + } + return obj as LocalTutorialData; } diff --git a/scripts/tutorial-uploader/sync.ts b/scripts/tutorial-uploader/sync.ts index a7d06c2c72..dfb3bb684a 100644 --- a/scripts/tutorial-uploader/sync.ts +++ b/scripts/tutorial-uploader/sync.ts @@ -11,7 +11,7 @@ // that they have been altered from the originals. import { API } from "./lib/api"; -import { readTutorialData } from './lib/local-tutorial-data'; +import { readTutorialData } from "./lib/local-tutorial-data"; const CONFIG_PATH = "tutorials/learning-api.conf.yaml"; diff --git a/scripts/tutorial-uploader/validate.ts b/scripts/tutorial-uploader/validate.ts index a91b857201..a62c1832f4 100644 --- a/scripts/tutorial-uploader/validate.ts +++ b/scripts/tutorial-uploader/validate.ts @@ -10,12 +10,12 @@ // copyright notice, and modified files need to carry a notice indicating // that they have been altered from the originals. -import { readTutorialData } from './lib/local-tutorial-data'; +import { readTutorialData } from "./lib/local-tutorial-data"; const CONFIG_PATH = "tutorials/learning-api.conf.yaml"; async function main() { - await readTutorialData(CONFIG_PATH) + await readTutorialData(CONFIG_PATH); } main().then(() => process.exit()); From 8de3579d76ad8128eca180af2d8765432487e2fe Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Fri, 24 May 2024 14:37:58 +0100 Subject: [PATCH 14/68] Add API tests --- package.json | 1 + scripts/tutorial-uploader/lib/api.test.ts | 144 ++++++++++++++++++ scripts/tutorial-uploader/lib/api.ts | 17 ++- .../lib/local-tutorial-data.ts | 10 +- .../test-data/simple-tutorial/notebook.ipynb | 36 +++++ 5 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 scripts/tutorial-uploader/lib/api.test.ts create mode 100644 scripts/tutorial-uploader/lib/test-data/simple-tutorial/notebook.ipynb diff --git a/package.json b/package.json index 0060d814ed..41f80d9d15 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "check:stale-images": "node -r esbuild-register scripts/commands/checkStaleImages.ts", "fmt": "prettier --write .", "test": "jest", + "test:tutorial-uploader": "JEST_TEST_TUTORIAL_UPLOADER=true jest scripts/tutorial-uploader", "typecheck": "tsc", "regen-apis": "node -r esbuild-register scripts/commands/regenerateApiDocs.ts", "gen-api": "node -r esbuild-register scripts/commands/updateApiDocs.ts", diff --git a/scripts/tutorial-uploader/lib/api.test.ts b/scripts/tutorial-uploader/lib/api.test.ts new file mode 100644 index 0000000000..5b053eb69c --- /dev/null +++ b/scripts/tutorial-uploader/lib/api.test.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 { randomBytes } from "crypto"; + +import { describe, expect, test } from "@jest/globals"; +import { readItem } from "@directus/sdk"; + +import { API } from "./api"; +import { type LocalTutorialData } from "./local-tutorial-data"; + +/* Skip tests if environment is not set */ +const maybeDescribe = process.env.JEST_TEST_TUTORIAL_UPLOADER ? describe : describe.skip; + +/* Create test data */ +const createdSlugs: string[] = [] // To teardown afterwards +function generateTutorialData(): LocalTutorialData { + const testId = "test-" + randomBytes(4).toString("hex") + const slug =`${testId}-my-tutorial-slug` + createdSlugs.push(slug) + return { + title: `My tutorial (${testId})`, + short_description: `My short tutorial description (${testId})`, + slug, + status: "published", + local_path: "scripts/tutorial-uploader/lib/test-data/simple-tutorial", + category: "Workflow example", + topics: [], + reading_time: 50, + catalog_featured: false, + } +} + +/* Just to be sure */ +if (/learning-api\.quantum\.ibm\.com/.test(process.env.LEARNING_API_URL!)) { + throw new Error("Tried to run tests against production!") +} + +maybeDescribe("Tutorial uploader API", () => { + const api = new API( + process.env.LEARNING_API_URL!, + process.env.LEARNING_API_TOKEN!, + ); + + test("upload new tutorial", async () => { + const simpleTutorial = generateTutorialData() + + expect(await api.getId("tutorials", "slug", simpleTutorial.slug)).toBeNull() + + await api.upsertTutorial(simpleTutorial) + const tutorialId = await api.getId("tutorials", "slug", simpleTutorial.slug) + expect(tutorialId).toBeTruthy() + + const retrievedTutorial = await api.client.request( + // @ts-ignore + readItem("tutorials", tutorialId, { fields: ['*', 'translations.*'] }) + ) + expect(retrievedTutorial).toMatchObject({ + "slug": simpleTutorial.slug, + "status": simpleTutorial.status, + "reading_time": simpleTutorial.reading_time, + "catalog_featured": simpleTutorial.catalog_featured, + "category": await api.getId("tutorials_categories", "name", simpleTutorial.category), + "topics": [], + "editors": [], + "allowed_email_domains": null, + "required_instance_access": null, + "sort": null, + "translations": [{ + "title": simpleTutorial.title, + "short_description": simpleTutorial.short_description, + "content": "Here's some basic content.\n", + "languages_code": "en-US", + }] + }) + }); + + + test("update existing tutorial", async () => { + const simpleTutorial = generateTutorialData() + + // Upload tutorial + await api.upsertTutorial(simpleTutorial) + const tutorialId = await api.getId("tutorials", "slug", simpleTutorial.slug) + + // Mutate tutorial data and re-upload + simpleTutorial.title = "A new tutorial title" + simpleTutorial.short_description = "A modified short description" + simpleTutorial.status = "draft" + simpleTutorial.category = "How-to" + simpleTutorial.reading_time = 33 + simpleTutorial.topics = ["Scheduling", "Transpilation",] + simpleTutorial.catalog_featured = false + await api.upsertTutorial(simpleTutorial) + + // Retrieve and check + const retrievedTutorial = await api.client.request( + // @ts-ignore + readItem("tutorials", tutorialId, { fields: ['*', 'topics.*'] }) + ) + const topicIds = await Promise.all( + simpleTutorial.topics + .map((name) => api.getId("tutorials_topics", "name", name)) + ) + expect(retrievedTutorial).toMatchObject({ + "slug": simpleTutorial.slug, + "status": simpleTutorial.status, + "reading_time": simpleTutorial.reading_time, + "catalog_featured": simpleTutorial.catalog_featured, + "category": await api.getId("tutorials_categories", "name", simpleTutorial.category), + "topics": topicIds.map((name: any) => { return { "tutorials_topics_id": name } }), + "editors": [], + "allowed_email_domains": null, + "required_instance_access": null, + "sort": null, + }) + + const retrievedTranslation = (await api.client.request( + // @ts-ignore + readItem("tutorials", tutorialId, { fields: ["translations.*"] }) + )).translations + expect(retrievedTranslation).toMatchObject([{ + "title": simpleTutorial.title, + "short_description": simpleTutorial.short_description, + "content": "Here's some basic content.\n", + "languages_code": "en-US", + }]) + }); + + afterAll(async () => { + for (const slug of createdSlugs) { + await api.deleteTutorial(slug) + } + }); +}); diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 5daf771897..e97f50dfd2 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -11,8 +11,11 @@ // that they have been altered from the originals. import { readFile } from "fs/promises"; -import { $ } from "zx"; import { tmpdir } from "os"; +import { dirname, basename } from "path"; +import { randomBytes } from "crypto"; + +import { $ } from "zx"; import { createDirectus, rest, @@ -116,8 +119,9 @@ export class API { /* Returns the file's ID */ async uploadLocalFolder(path: string): Promise { // Zip folder - const zippedFilePath = `${tmpdir()}/${path}.zip`; - await $`(cd tutorials && zip -qr ${zippedFilePath} ${path})`; + const zippedFilePath = `${tmpdir()}/${randomBytes(8).toString("hex")}/tutorial.zip`; + await $`mkdir -p ${dirname(zippedFilePath)}` + await $`(cd ${dirname(path)} && zip -qr ${zippedFilePath} ${basename(path)})`; // Build form const file = new Blob([await readFile(zippedFilePath)], { @@ -193,4 +197,11 @@ export class API { } await this.updateExistingTutorial(id, tutorial); } + + async deleteTutorial(tutorialSlug: string) { + const id = await this.getId("tutorials", "slug", tutorialSlug) + if (!id) return + // @ts-ignore + await this.client.request(deleteItem("tutorials", id)) + } } diff --git a/scripts/tutorial-uploader/lib/local-tutorial-data.ts b/scripts/tutorial-uploader/lib/local-tutorial-data.ts index fa916c7d13..83e76cd4e9 100644 --- a/scripts/tutorial-uploader/lib/local-tutorial-data.ts +++ b/scripts/tutorial-uploader/lib/local-tutorial-data.ts @@ -12,6 +12,7 @@ import yaml from "js-yaml"; import { readFile } from "fs/promises"; +import { dirname, join } from "path"; /* Information specified in the YAML file */ export interface LocalTutorialData { @@ -26,12 +27,19 @@ export interface LocalTutorialData { catalog_featured: boolean; } +function relativiseLocalPath(tutorial: LocalTutorialData, path: string): LocalTutorialData { + tutorial.local_path = join(dirname(path), tutorial.local_path) + return tutorial; +} + export async function readTutorialData( path: string, ): Promise { const raw = await readFile(path, "utf8"); const parsed = yaml.load(raw) as any[]; - return parsed.map((i) => verifyLocalTutorialData(i)); + return parsed + .map((i) => verifyLocalTutorialData(i)) + .map((i) => relativiseLocalPath(i, path)) } const isString = (x: any) => { diff --git a/scripts/tutorial-uploader/lib/test-data/simple-tutorial/notebook.ipynb b/scripts/tutorial-uploader/lib/test-data/simple-tutorial/notebook.ipynb new file mode 100644 index 0000000000..41363f5499 --- /dev/null +++ b/scripts/tutorial-uploader/lib/test-data/simple-tutorial/notebook.ipynb @@ -0,0 +1,36 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "id": "52ad9e82-d351-4c6c-b8f6-f3ae7922554f", + "metadata": {}, + "source": [ + "# My tutorial title\n", + "\n", + "Here's some basic content." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From f6ea3b139f677b7dc234bdbe43ee38703d531837 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Fri, 24 May 2024 14:39:59 +0100 Subject: [PATCH 15/68] Fix bug: Categories not updated (thanks tests!) --- scripts/tutorial-uploader/lib/api.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index e97f50dfd2..0944465a8c 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -143,6 +143,11 @@ export class API { const temporalFileId = await this.uploadLocalFolder(tutorial.local_path); const translationId = await this.getEnglishTranslationId(tutorialId); const newData = { + category: await this.getId( + "tutorials_categories", + "name", + tutorial.category, + ), reading_time: tutorial.reading_time, catalog_featured: tutorial.catalog_featured, status: tutorial.status, From cab6ded0e1a40c84b55981bdfe002d6237a7fc2c Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Fri, 24 May 2024 14:40:33 +0100 Subject: [PATCH 16/68] Clean up temp file afterwards --- scripts/tutorial-uploader/lib/api.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 0944465a8c..7ff69a2671 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -133,6 +133,10 @@ export class API { // Upload form const response = await this.client.request(uploadFiles(formData)); + + // Clean up + await $`rm -r ${dirname(zippedFilePath)}` + return response.id; } From 1e4d6129de6250c8570b50d2e880a11b14ad82ea Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Fri, 24 May 2024 14:50:31 +0100 Subject: [PATCH 17/68] Improve typing a little --- scripts/tutorial-uploader/lib/api.test.ts | 4 ++-- scripts/tutorial-uploader/lib/local-tutorial-data.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.test.ts b/scripts/tutorial-uploader/lib/api.test.ts index 5b053eb69c..a059acf835 100644 --- a/scripts/tutorial-uploader/lib/api.test.ts +++ b/scripts/tutorial-uploader/lib/api.test.ts @@ -110,14 +110,14 @@ maybeDescribe("Tutorial uploader API", () => { const topicIds = await Promise.all( simpleTutorial.topics .map((name) => api.getId("tutorials_topics", "name", name)) - ) + ) as string[] expect(retrievedTutorial).toMatchObject({ "slug": simpleTutorial.slug, "status": simpleTutorial.status, "reading_time": simpleTutorial.reading_time, "catalog_featured": simpleTutorial.catalog_featured, "category": await api.getId("tutorials_categories", "name", simpleTutorial.category), - "topics": topicIds.map((name: any) => { return { "tutorials_topics_id": name } }), + "topics": topicIds.map((name) => { return { "tutorials_topics_id": name } }), "editors": [], "allowed_email_domains": null, "required_instance_access": null, diff --git a/scripts/tutorial-uploader/lib/local-tutorial-data.ts b/scripts/tutorial-uploader/lib/local-tutorial-data.ts index 83e76cd4e9..2963a30736 100644 --- a/scripts/tutorial-uploader/lib/local-tutorial-data.ts +++ b/scripts/tutorial-uploader/lib/local-tutorial-data.ts @@ -36,7 +36,7 @@ export async function readTutorialData( path: string, ): Promise { const raw = await readFile(path, "utf8"); - const parsed = yaml.load(raw) as any[]; + const parsed = yaml.load(raw) as unknown[]; return parsed .map((i) => verifyLocalTutorialData(i)) .map((i) => relativiseLocalPath(i, path)) From 0739ad907c8f5d96831e608ebbf13164bc3a14c8 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Fri, 24 May 2024 14:51:05 +0100 Subject: [PATCH 18/68] Prettier :sparkles: --- scripts/tutorial-uploader/lib/api.test.ts | 172 ++++++++++-------- scripts/tutorial-uploader/lib/api.ts | 18 +- .../lib/local-tutorial-data.ts | 9 +- 3 files changed, 117 insertions(+), 82 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.test.ts b/scripts/tutorial-uploader/lib/api.test.ts index a059acf835..99c0671c22 100644 --- a/scripts/tutorial-uploader/lib/api.test.ts +++ b/scripts/tutorial-uploader/lib/api.test.ts @@ -19,14 +19,16 @@ import { API } from "./api"; import { type LocalTutorialData } from "./local-tutorial-data"; /* Skip tests if environment is not set */ -const maybeDescribe = process.env.JEST_TEST_TUTORIAL_UPLOADER ? describe : describe.skip; +const maybeDescribe = process.env.JEST_TEST_TUTORIAL_UPLOADER + ? describe + : describe.skip; /* Create test data */ -const createdSlugs: string[] = [] // To teardown afterwards +const createdSlugs: string[] = []; // To teardown afterwards function generateTutorialData(): LocalTutorialData { - const testId = "test-" + randomBytes(4).toString("hex") - const slug =`${testId}-my-tutorial-slug` - createdSlugs.push(slug) + const testId = "test-" + randomBytes(4).toString("hex"); + const slug = `${testId}-my-tutorial-slug`; + createdSlugs.push(slug); return { title: `My tutorial (${testId})`, short_description: `My short tutorial description (${testId})`, @@ -37,12 +39,12 @@ function generateTutorialData(): LocalTutorialData { topics: [], reading_time: 50, catalog_featured: false, - } + }; } /* Just to be sure */ if (/learning-api\.quantum\.ibm\.com/.test(process.env.LEARNING_API_URL!)) { - throw new Error("Tried to run tests against production!") + throw new Error("Tried to run tests against production!"); } maybeDescribe("Tutorial uploader API", () => { @@ -52,93 +54,119 @@ maybeDescribe("Tutorial uploader API", () => { ); test("upload new tutorial", async () => { - const simpleTutorial = generateTutorialData() + const simpleTutorial = generateTutorialData(); - expect(await api.getId("tutorials", "slug", simpleTutorial.slug)).toBeNull() + expect( + await api.getId("tutorials", "slug", simpleTutorial.slug), + ).toBeNull(); - await api.upsertTutorial(simpleTutorial) - const tutorialId = await api.getId("tutorials", "slug", simpleTutorial.slug) - expect(tutorialId).toBeTruthy() + await api.upsertTutorial(simpleTutorial); + const tutorialId = await api.getId( + "tutorials", + "slug", + simpleTutorial.slug, + ); + expect(tutorialId).toBeTruthy(); const retrievedTutorial = await api.client.request( // @ts-ignore - readItem("tutorials", tutorialId, { fields: ['*', 'translations.*'] }) - ) + readItem("tutorials", tutorialId, { fields: ["*", "translations.*"] }), + ); expect(retrievedTutorial).toMatchObject({ - "slug": simpleTutorial.slug, - "status": simpleTutorial.status, - "reading_time": simpleTutorial.reading_time, - "catalog_featured": simpleTutorial.catalog_featured, - "category": await api.getId("tutorials_categories", "name", simpleTutorial.category), - "topics": [], - "editors": [], - "allowed_email_domains": null, - "required_instance_access": null, - "sort": null, - "translations": [{ - "title": simpleTutorial.title, - "short_description": simpleTutorial.short_description, - "content": "Here's some basic content.\n", - "languages_code": "en-US", - }] - }) + slug: simpleTutorial.slug, + status: simpleTutorial.status, + reading_time: simpleTutorial.reading_time, + catalog_featured: simpleTutorial.catalog_featured, + category: await api.getId( + "tutorials_categories", + "name", + simpleTutorial.category, + ), + topics: [], + editors: [], + allowed_email_domains: null, + required_instance_access: null, + sort: null, + translations: [ + { + title: simpleTutorial.title, + short_description: simpleTutorial.short_description, + content: "Here's some basic content.\n", + languages_code: "en-US", + }, + ], + }); }); - test("update existing tutorial", async () => { - const simpleTutorial = generateTutorialData() + const simpleTutorial = generateTutorialData(); // Upload tutorial - await api.upsertTutorial(simpleTutorial) - const tutorialId = await api.getId("tutorials", "slug", simpleTutorial.slug) + await api.upsertTutorial(simpleTutorial); + const tutorialId = await api.getId( + "tutorials", + "slug", + simpleTutorial.slug, + ); // Mutate tutorial data and re-upload - simpleTutorial.title = "A new tutorial title" - simpleTutorial.short_description = "A modified short description" - simpleTutorial.status = "draft" - simpleTutorial.category = "How-to" - simpleTutorial.reading_time = 33 - simpleTutorial.topics = ["Scheduling", "Transpilation",] - simpleTutorial.catalog_featured = false - await api.upsertTutorial(simpleTutorial) + simpleTutorial.title = "A new tutorial title"; + simpleTutorial.short_description = "A modified short description"; + simpleTutorial.status = "draft"; + simpleTutorial.category = "How-to"; + simpleTutorial.reading_time = 33; + simpleTutorial.topics = ["Scheduling", "Transpilation"]; + simpleTutorial.catalog_featured = false; + await api.upsertTutorial(simpleTutorial); // Retrieve and check const retrievedTutorial = await api.client.request( // @ts-ignore - readItem("tutorials", tutorialId, { fields: ['*', 'topics.*'] }) - ) - const topicIds = await Promise.all( - simpleTutorial.topics - .map((name) => api.getId("tutorials_topics", "name", name)) - ) as string[] + readItem("tutorials", tutorialId, { fields: ["*", "topics.*"] }), + ); + const topicIds = (await Promise.all( + simpleTutorial.topics.map((name) => + api.getId("tutorials_topics", "name", name), + ), + )) as string[]; expect(retrievedTutorial).toMatchObject({ - "slug": simpleTutorial.slug, - "status": simpleTutorial.status, - "reading_time": simpleTutorial.reading_time, - "catalog_featured": simpleTutorial.catalog_featured, - "category": await api.getId("tutorials_categories", "name", simpleTutorial.category), - "topics": topicIds.map((name) => { return { "tutorials_topics_id": name } }), - "editors": [], - "allowed_email_domains": null, - "required_instance_access": null, - "sort": null, - }) - - const retrievedTranslation = (await api.client.request( - // @ts-ignore - readItem("tutorials", tutorialId, { fields: ["translations.*"] }) - )).translations - expect(retrievedTranslation).toMatchObject([{ - "title": simpleTutorial.title, - "short_description": simpleTutorial.short_description, - "content": "Here's some basic content.\n", - "languages_code": "en-US", - }]) + slug: simpleTutorial.slug, + status: simpleTutorial.status, + reading_time: simpleTutorial.reading_time, + catalog_featured: simpleTutorial.catalog_featured, + category: await api.getId( + "tutorials_categories", + "name", + simpleTutorial.category, + ), + topics: topicIds.map((name) => { + return { tutorials_topics_id: name }; + }), + editors: [], + allowed_email_domains: null, + required_instance_access: null, + sort: null, + }); + + const retrievedTranslation = ( + await api.client.request( + // @ts-ignore + readItem("tutorials", tutorialId, { fields: ["translations.*"] }), + ) + ).translations; + expect(retrievedTranslation).toMatchObject([ + { + title: simpleTutorial.title, + short_description: simpleTutorial.short_description, + content: "Here's some basic content.\n", + languages_code: "en-US", + }, + ]); }); afterAll(async () => { for (const slug of createdSlugs) { - await api.deleteTutorial(slug) + await api.deleteTutorial(slug); } }); }); diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 7ff69a2671..697440d7f4 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -119,9 +119,13 @@ export class API { /* Returns the file's ID */ async uploadLocalFolder(path: string): Promise { // Zip folder - const zippedFilePath = `${tmpdir()}/${randomBytes(8).toString("hex")}/tutorial.zip`; - await $`mkdir -p ${dirname(zippedFilePath)}` - await $`(cd ${dirname(path)} && zip -qr ${zippedFilePath} ${basename(path)})`; + const zippedFilePath = `${tmpdir()}/${randomBytes(8).toString( + "hex", + )}/tutorial.zip`; + await $`mkdir -p ${dirname(zippedFilePath)}`; + await $`(cd ${dirname(path)} && zip -qr ${zippedFilePath} ${basename( + path, + )})`; // Build form const file = new Blob([await readFile(zippedFilePath)], { @@ -135,7 +139,7 @@ export class API { const response = await this.client.request(uploadFiles(formData)); // Clean up - await $`rm -r ${dirname(zippedFilePath)}` + await $`rm -r ${dirname(zippedFilePath)}`; return response.id; } @@ -208,9 +212,9 @@ export class API { } async deleteTutorial(tutorialSlug: string) { - const id = await this.getId("tutorials", "slug", tutorialSlug) - if (!id) return + const id = await this.getId("tutorials", "slug", tutorialSlug); + if (!id) return; // @ts-ignore - await this.client.request(deleteItem("tutorials", id)) + await this.client.request(deleteItem("tutorials", id)); } } diff --git a/scripts/tutorial-uploader/lib/local-tutorial-data.ts b/scripts/tutorial-uploader/lib/local-tutorial-data.ts index 2963a30736..971f979649 100644 --- a/scripts/tutorial-uploader/lib/local-tutorial-data.ts +++ b/scripts/tutorial-uploader/lib/local-tutorial-data.ts @@ -27,8 +27,11 @@ export interface LocalTutorialData { catalog_featured: boolean; } -function relativiseLocalPath(tutorial: LocalTutorialData, path: string): LocalTutorialData { - tutorial.local_path = join(dirname(path), tutorial.local_path) +function relativiseLocalPath( + tutorial: LocalTutorialData, + path: string, +): LocalTutorialData { + tutorial.local_path = join(dirname(path), tutorial.local_path); return tutorial; } @@ -39,7 +42,7 @@ export async function readTutorialData( const parsed = yaml.load(raw) as unknown[]; return parsed .map((i) => verifyLocalTutorialData(i)) - .map((i) => relativiseLocalPath(i, path)) + .map((i) => relativiseLocalPath(i, path)); } const isString = (x: any) => { From 14443d9601afee28e3e703171d1edd5b68d5001c Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Fri, 24 May 2024 17:18:43 +0100 Subject: [PATCH 19/68] Fix types :tada: Except for tests... --- scripts/tutorial-uploader/lib/api.ts | 46 ++++++++++----------- scripts/tutorial-uploader/lib/schema.ts | 54 +++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 24 deletions(-) create mode 100644 scripts/tutorial-uploader/lib/schema.ts diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 697440d7f4..24e594f07d 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -17,6 +17,7 @@ import { randomBytes } from "crypto"; import { $ } from "zx"; import { + type RestClient, createDirectus, rest, staticToken, @@ -28,29 +29,31 @@ import { uploadFiles, } from "@directus/sdk"; +import { type LearningApiSchema } from "./schema"; import { type LocalTutorialData } from "./local-tutorial-data"; /* To do: - * [ ] Fix types + * [x] Fix types * [ ] Throw correctly on request failures * [ ] More helpful console logging */ export class API { - client: any; // TODO: Work out how to set this correctly + client: RestClient; constructor(url: string, token: string) { - this.client = createDirectus(url).with(rest()).with(staticToken(token)); + this.client = createDirectus(url) + .with(rest()) + .with(staticToken(token)); } async getIds( - collection: string, - field: string, + collection: keyof LearningApiSchema, + field: any, value: any, ): Promise { // TODO: Work out how to filter requests on server side const response = await this.client.request( - // @ts-ignore readItems(collection, { fields: ["id", field] }), ); const matchingIds = response @@ -60,7 +63,7 @@ export class API { } async getId( - collection: string, + collection: keyof LearningApiSchema, field: string, value: any, ): Promise { @@ -77,13 +80,15 @@ export class API { ); } - async getEnglishTranslationId(tutorialId: string): Promise { + async getEnglishTranslationId(tutorialId: string): Promise { // TODO: This assumes the only translation is english (currently true) const response = await this.client.request( - // @ts-ignore readItem("tutorials", tutorialId, { fields: ["translations"] }), ); - return response.translations[0]; + if (!response.translations) { + throw new Error(`No translations for tutorial ${tutorialId}`) + } + return response.translations[0] as number; } async clearTopics(tutorialId: string) { @@ -94,9 +99,7 @@ export class API { tutorialId, ); for (const id of ids) { - // @ts-ignore await this.client - // @ts-ignore .request(deleteItem("tutorials_tutorials_topics", id)) .catch((err: any) => console.log(err)); } @@ -106,8 +109,8 @@ export class API { await this.clearTopics(tutorialId); for (const name of topicNames) { const id = await this.getId("tutorials_topics", "name", name); + if (id === null) throw new Error(`No topic with name '${name}'`) await this.client.request( - // @ts-ignore createItem("tutorials_tutorials_topics", { tutorials_id: tutorialId, tutorials_topics_id: id, @@ -169,7 +172,6 @@ export class API { ], }; - // @ts-ignore await this.client.request(updateItem("tutorials", tutorialId, newData)); await this.updateTutorialTopics(tutorialId, tutorial.topics); } @@ -184,20 +186,17 @@ export class API { languages_code: "en-US", }; const translation = await this.client.request( - // @ts-ignore createItem("tutorials_translations", translationData), ); + const category = await this.getId("tutorials_categories", "name", tutorial.category) + if (category === null) throw new Error(`No category with name '${tutorial.category}'`) + const tutorialData = { - category: await this.getId( - "tutorials_categories", - "name", - tutorial.category, - ), + category, translations: [translation.id], slug: tutorial.slug, }; const newTutorial = await this.client.request( - // @ts-ignore createItem("tutorials", tutorialData), ); return newTutorial.id; @@ -205,7 +204,7 @@ export class API { async upsertTutorial(tutorial: LocalTutorialData) { let id = await this.getId("tutorials", "slug", tutorial.slug); - if (!id) { + if (id === null) { id = await this.createTutorial(tutorial); } await this.updateExistingTutorial(id, tutorial); @@ -213,8 +212,7 @@ export class API { async deleteTutorial(tutorialSlug: string) { const id = await this.getId("tutorials", "slug", tutorialSlug); - if (!id) return; - // @ts-ignore + if (id === null) return; await this.client.request(deleteItem("tutorials", id)); } } diff --git a/scripts/tutorial-uploader/lib/schema.ts b/scripts/tutorial-uploader/lib/schema.ts new file mode 100644 index 0000000000..7dbf2f8c67 --- /dev/null +++ b/scripts/tutorial-uploader/lib/schema.ts @@ -0,0 +1,54 @@ +// 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. + +export interface LearningApiSchema { + tutorials: Tutorial[]; + tutorials_translations: Translation[]; + tutorials_topics: Topic[]; + tutorials_categories: Category[]; + tutorials_tutorials_topics: TutorialsTopicsRelation[]; +} + +interface Tutorial { + id: string; + slug: string; + status: "draft" | "published" | "archived"; + reading_time: number; + category: string; // ID + catalog_featured: boolean; + translations: number[] | Translation[]; +} + +interface Translation { + id: number; + title: string; + short_description: string; + content: string; + languages_code: string; + temporal_file: string | null; +} + +interface Topic { + id: string; + name: string; +} + +interface Category { + id: string; + name: string; +} + +interface TutorialsTopicsRelation { + id: number, + tutorials_id: string | Tutorial; + tutorials_topics_id: string | Topic; +} From 4d44d7f4532ce8a1b585e06435fd304a8f3d57c2 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Fri, 24 May 2024 17:21:23 +0100 Subject: [PATCH 20/68] prettier --- scripts/tutorial-uploader/lib/api.ts | 13 +++++++++---- scripts/tutorial-uploader/lib/schema.ts | 6 +++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 24e594f07d..94aca8c66d 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -86,7 +86,7 @@ export class API { readItem("tutorials", tutorialId, { fields: ["translations"] }), ); if (!response.translations) { - throw new Error(`No translations for tutorial ${tutorialId}`) + throw new Error(`No translations for tutorial ${tutorialId}`); } return response.translations[0] as number; } @@ -109,7 +109,7 @@ export class API { await this.clearTopics(tutorialId); for (const name of topicNames) { const id = await this.getId("tutorials_topics", "name", name); - if (id === null) throw new Error(`No topic with name '${name}'`) + if (id === null) throw new Error(`No topic with name '${name}'`); await this.client.request( createItem("tutorials_tutorials_topics", { tutorials_id: tutorialId, @@ -188,8 +188,13 @@ export class API { const translation = await this.client.request( createItem("tutorials_translations", translationData), ); - const category = await this.getId("tutorials_categories", "name", tutorial.category) - if (category === null) throw new Error(`No category with name '${tutorial.category}'`) + const category = await this.getId( + "tutorials_categories", + "name", + tutorial.category, + ); + if (category === null) + throw new Error(`No category with name '${tutorial.category}'`); const tutorialData = { category, diff --git a/scripts/tutorial-uploader/lib/schema.ts b/scripts/tutorial-uploader/lib/schema.ts index 7dbf2f8c67..5e27608ccf 100644 --- a/scripts/tutorial-uploader/lib/schema.ts +++ b/scripts/tutorial-uploader/lib/schema.ts @@ -11,7 +11,7 @@ // that they have been altered from the originals. export interface LearningApiSchema { - tutorials: Tutorial[]; + tutorials: Tutorial[]; tutorials_translations: Translation[]; tutorials_topics: Topic[]; tutorials_categories: Category[]; @@ -23,7 +23,7 @@ interface Tutorial { slug: string; status: "draft" | "published" | "archived"; reading_time: number; - category: string; // ID + category: string; // ID catalog_featured: boolean; translations: number[] | Translation[]; } @@ -48,7 +48,7 @@ interface Category { } interface TutorialsTopicsRelation { - id: number, + id: number; tutorials_id: string | Tutorial; tutorials_topics_id: string | Topic; } From 86a25ee279b852591c54931a380e44ee2d61a03e Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Fri, 24 May 2024 17:36:42 +0100 Subject: [PATCH 21/68] Commit `package-lock.json` --- package-lock.json | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index beb336d500..7127f51074 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { + "@directus/sdk": "^16.0.1", "esbuild": "^0.19.11", "fast-levenshtein": "^3.0.0", + "js-yaml": "^4.1.0", "markdown-link-extractor": "^3.1.0", "transform-markdown-links": "^2.1.0" }, @@ -1125,6 +1127,28 @@ "node": ">=16" } }, + "node_modules/@directus/sdk": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@directus/sdk/-/sdk-16.0.1.tgz", + "integrity": "sha512-tG9J5uBxJlzB8yoXv6Gin9PhxOoYKXsGEfr4LBNJ+jlFoqPmwlNOManJnUTqGbpNlGxH/7qv3CJyvZUXvo5hoA==", + "dependencies": { + "@directus/system-data": "1.0.3" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/directus/directus?sponsor=1" + } + }, + "node_modules/@directus/system-data": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@directus/system-data/-/system-data-1.0.3.tgz", + "integrity": "sha512-Dr7w2RZiX3/+kBOLqz8KkMHXmDhzVVraaK+CLQNgsB1FOxotuegyWfQxTi+YvDhH9gEs8/yZZBKeyQetcTQfSg==", + "funding": { + "url": "https://github.com/directus/directus?sponsor=1" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz", @@ -2769,8 +2793,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/array-timsort": { "version": "1.0.3", @@ -6017,7 +6040,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, From a5688fa6b09f4c3d920c18014d1c49b787d0bb27 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Fri, 24 May 2024 17:38:25 +0100 Subject: [PATCH 22/68] prettier --- tutorials/learning-api.conf.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/tutorials/learning-api.conf.yaml b/tutorials/learning-api.conf.yaml index 51ca0e83d3..0a82840b59 100644 --- a/tutorials/learning-api.conf.yaml +++ b/tutorials/learning-api.conf.yaml @@ -133,4 +133,3 @@ topics: ["Error mitigation", "Qiskit Patterns"] reading_time: 180 catalog_featured: true - From db4b884dd969d6df0978f17339a20a2785c5aeaa Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Fri, 24 May 2024 17:57:09 +0100 Subject: [PATCH 23/68] Fix CI (?) --- scripts/tutorial-uploader/lib/api.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tutorial-uploader/lib/api.test.ts b/scripts/tutorial-uploader/lib/api.test.ts index 99c0671c22..4ae7c37b62 100644 --- a/scripts/tutorial-uploader/lib/api.test.ts +++ b/scripts/tutorial-uploader/lib/api.test.ts @@ -19,7 +19,7 @@ import { API } from "./api"; import { type LocalTutorialData } from "./local-tutorial-data"; /* Skip tests if environment is not set */ -const maybeDescribe = process.env.JEST_TEST_TUTORIAL_UPLOADER +const maybeDescribe = (process.env.JEST_TEST_TUTORIAL_UPLOADER === "true") ? describe : describe.skip; From cec28031648ff4cc61ad44d5efa09886761457b3 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Fri, 24 May 2024 18:09:29 +0100 Subject: [PATCH 24/68] prettier --- scripts/tutorial-uploader/lib/api.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.test.ts b/scripts/tutorial-uploader/lib/api.test.ts index 4ae7c37b62..7533fc158f 100644 --- a/scripts/tutorial-uploader/lib/api.test.ts +++ b/scripts/tutorial-uploader/lib/api.test.ts @@ -19,9 +19,8 @@ import { API } from "./api"; import { type LocalTutorialData } from "./local-tutorial-data"; /* Skip tests if environment is not set */ -const maybeDescribe = (process.env.JEST_TEST_TUTORIAL_UPLOADER === "true") - ? describe - : describe.skip; +const maybeDescribe = + process.env.JEST_TEST_TUTORIAL_UPLOADER === "true" ? describe : describe.skip; /* Create test data */ const createdSlugs: string[] = []; // To teardown afterwards From ee717575ef8e4b5304bcaea96ad9336efc33b6cb Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Tue, 28 May 2024 13:34:08 +0100 Subject: [PATCH 25/68] Refactor: Add types, docstrings, and clarify variable names --- scripts/tutorial-uploader/lib/api.ts | 113 ++++++++++++++++-------- scripts/tutorial-uploader/lib/schema.ts | 6 ++ 2 files changed, 81 insertions(+), 38 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 94aca8c66d..512917156f 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -29,7 +29,7 @@ import { uploadFiles, } from "@directus/sdk"; -import { type LearningApiSchema } from "./schema"; +import { type LearningApiSchema, StringKeyOf, ElementType } from "./schema"; import { type LocalTutorialData } from "./local-tutorial-data"; /* To do: @@ -47,13 +47,25 @@ export class API { .with(staticToken(token)); } - async getIds( - collection: keyof LearningApiSchema, - field: any, - value: any, + /** + * Get IDs of all items in `collection` that match a field value. + * Roughly: "SELECT * FROM collection WHERE field=value". + */ + async getIds< + CollectionName extends StringKeyOf, + FieldName extends StringKeyOf< + ElementType + >, + FieldValue extends ElementType< + LearningApiSchema[CollectionName] + >[FieldName], + >( + collection: CollectionName, + field: FieldName, + value: FieldValue, ): Promise { - // TODO: Work out how to filter requests on server side const response = await this.client.request( + // @ts-ignore readItems(collection, { fields: ["id", field] }), ); const matchingIds = response @@ -62,10 +74,22 @@ export class API { return matchingIds; } - async getId( - collection: keyof LearningApiSchema, - field: string, - value: any, + /** + * Like getIds (plural) but expects at most one match. + * Throws if more than one match is found. + */ + async getId< + CollectionName extends StringKeyOf, + FieldName extends StringKeyOf< + ElementType + >, + FieldValue extends ElementType< + LearningApiSchema[CollectionName] + >[FieldName], + >( + collection: CollectionName, + field: FieldName, + value: FieldValue, ): Promise { const ids = await this.getIds(collection, field, value); if (ids.length === 0) { @@ -80,6 +104,9 @@ export class API { ); } + /** + * Tutorials can have many translations, but we only use English at the moment. + */ async getEnglishTranslationId(tutorialId: string): Promise { // TODO: This assumes the only translation is english (currently true) const response = await this.client.request( @@ -91,6 +118,12 @@ export class API { return response.translations[0] as number; } + /** + * Remove all topics from a tutorial. + * This works by deleting appropriate entries of + * "tutorials_tutorials_topics": a collection of one-to-many mappings of + * "tutorials" to "tutorials_topics". + */ async clearTopics(tutorialId: string) { // "tutorials_tutorials_topics" is mapping of tutorial to topics const ids = await this.getIds( @@ -149,40 +182,38 @@ export class API { async updateExistingTutorial( tutorialId: string, - tutorial: LocalTutorialData, + localData: LocalTutorialData, ) { - const temporalFileId = await this.uploadLocalFolder(tutorial.local_path); - const translationId = await this.getEnglishTranslationId(tutorialId); - const newData = { + const newTutorial = { category: await this.getId( "tutorials_categories", "name", - tutorial.category, + localData.category, ), - reading_time: tutorial.reading_time, - catalog_featured: tutorial.catalog_featured, - status: tutorial.status, + reading_time: localData.reading_time, + catalog_featured: localData.catalog_featured, + status: localData.status, translations: [ { - title: tutorial.title, - id: translationId, - temporal_file: temporalFileId, - short_description: tutorial.short_description, + title: localData.title, + id: await this.getEnglishTranslationId(tutorialId), + temporal_file: await this.uploadLocalFolder(localData.local_path), + short_description: localData.short_description, }, ], }; - await this.client.request(updateItem("tutorials", tutorialId, newData)); - await this.updateTutorialTopics(tutorialId, tutorial.topics); + await this.client.request(updateItem("tutorials", tutorialId, newTutorial)); + await this.updateTutorialTopics(tutorialId, localData.topics); } /* * Only sets minimum data required for API to accept the creation request - * updateExistingTutorial is called immediately after + * updateExistingTutorial should be called immediately after */ - async createTutorial(tutorial: LocalTutorialData): Promise { + async createTutorial(localData: LocalTutorialData): Promise { const translationData = { - title: tutorial.title, + title: localData.title, languages_code: "en-US", }; const translation = await this.client.request( @@ -191,30 +222,36 @@ export class API { const category = await this.getId( "tutorials_categories", "name", - tutorial.category, + localData.category, ); if (category === null) - throw new Error(`No category with name '${tutorial.category}'`); + throw new Error(`No category with name '${localData.category}'`); - const tutorialData = { + const tutorial = { category, translations: [translation.id], - slug: tutorial.slug, + slug: localData.slug, }; - const newTutorial = await this.client.request( - createItem("tutorials", tutorialData), + const response = await this.client.request( + createItem("tutorials", tutorial), ); - return newTutorial.id; + return response.id; } - async upsertTutorial(tutorial: LocalTutorialData) { - let id = await this.getId("tutorials", "slug", tutorial.slug); + /** + * Update tutorial if it exists, otherwise create new + */ + async upsertTutorial(localData: LocalTutorialData) { + let id = await this.getId("tutorials", "slug", localData.slug); if (id === null) { - id = await this.createTutorial(tutorial); + id = await this.createTutorial(localData); } - await this.updateExistingTutorial(id, tutorial); + await this.updateExistingTutorial(id, localData); } + /** + * For testing + */ async deleteTutorial(tutorialSlug: string) { const id = await this.getId("tutorials", "slug", tutorialSlug); if (id === null) return; diff --git a/scripts/tutorial-uploader/lib/schema.ts b/scripts/tutorial-uploader/lib/schema.ts index 5e27608ccf..b76dc14e17 100644 --- a/scripts/tutorial-uploader/lib/schema.ts +++ b/scripts/tutorial-uploader/lib/schema.ts @@ -10,6 +10,12 @@ // copyright notice, and modified files need to carry a notice indicating // that they have been altered from the originals. +// Like keyof but can only be string (not number or Symbol) +export type StringKeyOf = Extract; + +// To extract type of array +export type ElementType = T extends (infer U)[] ? U : never; + export interface LearningApiSchema { tutorials: Tutorial[]; tutorials_translations: Translation[]; From 8c203ff44baa40361fdcb8c24d170a48ff3b8276 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 13:34:25 +0100 Subject: [PATCH 26/68] Use `--testPathIgnorePatterns` rather than env var Co-authored-by: Eric Arellano <14852634+Eric-Arellano@users.noreply.github.com> --- package.json | 4 ++-- scripts/tutorial-uploader/lib/api.test.ts | 6 +----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 41f80d9d15..dd3bb16bca 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,8 @@ "check:qiskit-bot": "node -r esbuild-register scripts/commands/checkQiskitBotFiles.ts", "check:stale-images": "node -r esbuild-register scripts/commands/checkStaleImages.ts", "fmt": "prettier --write .", - "test": "jest", - "test:tutorial-uploader": "JEST_TEST_TUTORIAL_UPLOADER=true jest scripts/tutorial-uploader", + "test": "jest --testPathIgnorePatterns='tutorial-uploader'", + "test:tutorial-uploader": "jest scripts/tutorial-uploader", "typecheck": "tsc", "regen-apis": "node -r esbuild-register scripts/commands/regenerateApiDocs.ts", "gen-api": "node -r esbuild-register scripts/commands/updateApiDocs.ts", diff --git a/scripts/tutorial-uploader/lib/api.test.ts b/scripts/tutorial-uploader/lib/api.test.ts index 7533fc158f..69688aa272 100644 --- a/scripts/tutorial-uploader/lib/api.test.ts +++ b/scripts/tutorial-uploader/lib/api.test.ts @@ -18,10 +18,6 @@ import { readItem } from "@directus/sdk"; import { API } from "./api"; import { type LocalTutorialData } from "./local-tutorial-data"; -/* Skip tests if environment is not set */ -const maybeDescribe = - process.env.JEST_TEST_TUTORIAL_UPLOADER === "true" ? describe : describe.skip; - /* Create test data */ const createdSlugs: string[] = []; // To teardown afterwards function generateTutorialData(): LocalTutorialData { @@ -46,7 +42,7 @@ if (/learning-api\.quantum\.ibm\.com/.test(process.env.LEARNING_API_URL!)) { throw new Error("Tried to run tests against production!"); } -maybeDescribe("Tutorial uploader API", () => { +describe("Tutorial uploader API", () => { const api = new API( process.env.LEARNING_API_URL!, process.env.LEARNING_API_TOKEN!, From b339c185fbaa63c68508b9f2b9041e296b405850 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 13:36:24 +0100 Subject: [PATCH 27/68] Rename `validate.ts` -> `validate-config.ts` Co-authored-by: Eric Arellano <14852634+Eric-Arellano@users.noreply.github.com> --- package.json | 2 +- scripts/tutorial-uploader/{validate.ts => validate-config.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename scripts/tutorial-uploader/{validate.ts => validate-config.ts} (100%) diff --git a/package.json b/package.json index dd3bb16bca..334193f1d3 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "gen-api": "node -r esbuild-register scripts/commands/updateApiDocs.ts", "make-historical": "node -r esbuild-register scripts/commands/convertApiDocsToHistorical.ts", "tutorial:sync": "node -r esbuild-register scripts/tutorial-uploader/sync.ts", - "tutorial:validate": "node -r esbuild-register scripts/tutorial-uploader/validate.ts" + "tutorial:validate": "node -r esbuild-register scripts/tutorial-uploader/validate-config.ts" }, "devDependencies": { "@swc/jest": "^0.2.29", diff --git a/scripts/tutorial-uploader/validate.ts b/scripts/tutorial-uploader/validate-config.ts similarity index 100% rename from scripts/tutorial-uploader/validate.ts rename to scripts/tutorial-uploader/validate-config.ts From b14fa811edc8ad519fe0a895b6d09d46796ee9fe Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 13:43:41 +0100 Subject: [PATCH 28/68] Update scripts/tutorial-uploader/lib/schema.ts Co-authored-by: Eric Arellano <14852634+Eric-Arellano@users.noreply.github.com> --- scripts/tutorial-uploader/lib/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tutorial-uploader/lib/schema.ts b/scripts/tutorial-uploader/lib/schema.ts index b76dc14e17..2f52f5bf27 100644 --- a/scripts/tutorial-uploader/lib/schema.ts +++ b/scripts/tutorial-uploader/lib/schema.ts @@ -29,7 +29,7 @@ interface Tutorial { slug: string; status: "draft" | "published" | "archived"; reading_time: number; - category: string; // ID + category: string; // This is the uuid of the category. catalog_featured: boolean; translations: number[] | Translation[]; } From a6325a808f48439034bb267911d9e7bf437737a1 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 13:49:17 +0100 Subject: [PATCH 29/68] Update scripts/tutorial-uploader/lib/api.test.ts Co-authored-by: Eric Arellano <14852634+Eric-Arellano@users.noreply.github.com> --- scripts/tutorial-uploader/lib/api.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tutorial-uploader/lib/api.test.ts b/scripts/tutorial-uploader/lib/api.test.ts index 7533fc158f..5796f7a1f6 100644 --- a/scripts/tutorial-uploader/lib/api.test.ts +++ b/scripts/tutorial-uploader/lib/api.test.ts @@ -43,7 +43,7 @@ function generateTutorialData(): LocalTutorialData { /* Just to be sure */ if (/learning-api\.quantum\.ibm\.com/.test(process.env.LEARNING_API_URL!)) { - throw new Error("Tried to run tests against production!"); + throw new Error("Tried to run tests against production! Set the env var LEARNING_API_URL to either staging or local (see tutorial-uploader/README.md)"); } maybeDescribe("Tutorial uploader API", () => { From 5b22525c840fadd9b5366b4e82e42c5063c0d5c9 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 13:49:27 +0100 Subject: [PATCH 30/68] Update scripts/tutorial-uploader/lib/api.ts Co-authored-by: Eric Arellano <14852634+Eric-Arellano@users.noreply.github.com> --- scripts/tutorial-uploader/lib/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 512917156f..6eddf543bd 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -39,7 +39,7 @@ import { type LocalTutorialData } from "./local-tutorial-data"; */ export class API { - client: RestClient; + readonly client: RestClient; constructor(url: string, token: string) { this.client = createDirectus(url) From cbf85075b08d3a8a8fa30f7313f3919daaa4dd9f Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 14:01:16 +0100 Subject: [PATCH 31/68] Add SO attribution --- scripts/tutorial-uploader/lib/schema.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/tutorial-uploader/lib/schema.ts b/scripts/tutorial-uploader/lib/schema.ts index 2f52f5bf27..f7b744b362 100644 --- a/scripts/tutorial-uploader/lib/schema.ts +++ b/scripts/tutorial-uploader/lib/schema.ts @@ -11,9 +11,11 @@ // that they have been altered from the originals. // Like keyof but can only be string (not number or Symbol) +// From https://stackoverflow.com/a/65420892 export type StringKeyOf = Extract; // To extract type of array +// From https://stackoverflow.com/a/52331580 export type ElementType = T extends (infer U)[] ? U : never; export interface LearningApiSchema { From 5b11d6161873bad9f949fc7d0de8df3799a8a992 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 14:01:49 +0100 Subject: [PATCH 32/68] Comment API quirks --- scripts/tutorial-uploader/lib/schema.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/tutorial-uploader/lib/schema.ts b/scripts/tutorial-uploader/lib/schema.ts index f7b744b362..7c6fe42a74 100644 --- a/scripts/tutorial-uploader/lib/schema.ts +++ b/scripts/tutorial-uploader/lib/schema.ts @@ -33,6 +33,8 @@ interface Tutorial { reading_time: number; category: string; // This is the uuid of the category. catalog_featured: boolean; + // API can return either the translation IDs or the translation objects + // depending on the request. translations: number[] | Translation[]; } @@ -55,8 +57,14 @@ interface Category { name: string; } +/** + * This object links a tutorial to a topics. + * A tutorial can be linked to many topics. + * https://docs.directus.io/guides/sdk/types.html#adding-relational-fields + */ interface TutorialsTopicsRelation { id: number; + // API can return either the UUIDs or the objects depending on the request. tutorials_id: string | Tutorial; tutorials_topics_id: string | Topic; } From 6512ac5a080bd514a2bc6d7f568cdb4fa68d0b9a Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 14:07:42 +0100 Subject: [PATCH 33/68] Use lodash `isType` functions Co-authored-by: Eric Arellano <14852634+Eric-Arellano@users.noreply.github.com> --- scripts/tutorial-uploader/lib/local-tutorial-data.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/scripts/tutorial-uploader/lib/local-tutorial-data.ts b/scripts/tutorial-uploader/lib/local-tutorial-data.ts index 971f979649..bd988f1111 100644 --- a/scripts/tutorial-uploader/lib/local-tutorial-data.ts +++ b/scripts/tutorial-uploader/lib/local-tutorial-data.ts @@ -13,6 +13,7 @@ import yaml from "js-yaml"; import { readFile } from "fs/promises"; import { dirname, join } from "path"; +import { isString, isNumber, isBoolean } from "lodash"; /* Information specified in the YAML file */ export interface LocalTutorialData { @@ -45,16 +46,6 @@ export async function readTutorialData( .map((i) => relativiseLocalPath(i, path)); } -const isString = (x: any) => { - return typeof x === "string"; -}; -const isNumber = (x: any) => { - return typeof x === "number"; -}; -const isBoolean = (x: any) => { - return typeof x === "boolean"; -}; - /* Runtime type-checking to make sure YAML file is valid */ function verifyLocalTutorialData(obj: any): LocalTutorialData { for (let [attr, isCorrectType] of [ From 1696a8be75493290fafb945706a43689f6bfbc20 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 14:52:56 +0100 Subject: [PATCH 34/68] Drop assumption that only translation is en-US --- scripts/tutorial-uploader/lib/api.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 6eddf543bd..8fcfd39ea6 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -29,8 +29,13 @@ import { uploadFiles, } from "@directus/sdk"; -import { type LearningApiSchema, StringKeyOf, ElementType } from "./schema"; -import { type LocalTutorialData } from "./local-tutorial-data"; +import { + LearningApiSchema, + Translation, + StringKeyOf, + ElementType, +} from "./schema"; +import type { LocalTutorialData } from "./local-tutorial-data"; /* To do: * [x] Fix types @@ -108,14 +113,19 @@ export class API { * Tutorials can have many translations, but we only use English at the moment. */ async getEnglishTranslationId(tutorialId: string): Promise { - // TODO: This assumes the only translation is english (currently true) const response = await this.client.request( - readItem("tutorials", tutorialId, { fields: ["translations"] }), + // @ts-ignore + readItem("tutorials", tutorialId, { + fields: ["translations.id", "translations.languages_code"], + }), ); - if (!response.translations) { - throw new Error(`No translations for tutorial ${tutorialId}`); + const englishTranslations = response.translations?.filter( + (x: Translation) => x.languages_code === "en-US", + ) as Translation[] | undefined; + if (!englishTranslations?.length) { + throw new Error(`No english translations for tutorial ${tutorialId}`); } - return response.translations[0] as number; + return englishTranslations[0].id; } /** From ad843ecec555193d760701bf8cf569dc0db89bc8 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 15:12:28 +0100 Subject: [PATCH 35/68] Use single mock tutorial object --- scripts/tutorial-uploader/lib/api.test.ts | 111 ++++++++++------------ 1 file changed, 49 insertions(+), 62 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.test.ts b/scripts/tutorial-uploader/lib/api.test.ts index c41180ed78..899079ae7c 100644 --- a/scripts/tutorial-uploader/lib/api.test.ts +++ b/scripts/tutorial-uploader/lib/api.test.ts @@ -19,23 +19,17 @@ import { API } from "./api"; import { type LocalTutorialData } from "./local-tutorial-data"; /* Create test data */ -const createdSlugs: string[] = []; // To teardown afterwards -function generateTutorialData(): LocalTutorialData { - const testId = "test-" + randomBytes(4).toString("hex"); - const slug = `${testId}-my-tutorial-slug`; - createdSlugs.push(slug); - return { - title: `My tutorial (${testId})`, - short_description: `My short tutorial description (${testId})`, - slug, - status: "published", - local_path: "scripts/tutorial-uploader/lib/test-data/simple-tutorial", - category: "Workflow example", - topics: [], - reading_time: 50, - catalog_featured: false, - }; -} +const MOCK_TUTORIAL: LocalTutorialData = { + title: "Mock tutorial for testing", + short_description: "A mock tutorial for testing with Jest", + slug: "mock-tutorial", + status: "published", + local_path: "scripts/tutorial-uploader/lib/test-data/simple-tutorial", + category: "Workflow example", + topics: [], + reading_time: 50, + catalog_featured: false, +}; /* Just to be sure */ if (/learning-api\.quantum\.ibm\.com/.test(process.env.LEARNING_API_URL!)) { @@ -49,18 +43,10 @@ describe("Tutorial uploader API", () => { ); test("upload new tutorial", async () => { - const simpleTutorial = generateTutorialData(); - - expect( - await api.getId("tutorials", "slug", simpleTutorial.slug), - ).toBeNull(); + expect(await api.getId("tutorials", "slug", MOCK_TUTORIAL.slug)).toBeNull(); - await api.upsertTutorial(simpleTutorial); - const tutorialId = await api.getId( - "tutorials", - "slug", - simpleTutorial.slug, - ); + await api.upsertTutorial(MOCK_TUTORIAL); + const tutorialId = await api.getId("tutorials", "slug", MOCK_TUTORIAL.slug); expect(tutorialId).toBeTruthy(); const retrievedTutorial = await api.client.request( @@ -68,14 +54,14 @@ describe("Tutorial uploader API", () => { readItem("tutorials", tutorialId, { fields: ["*", "translations.*"] }), ); expect(retrievedTutorial).toMatchObject({ - slug: simpleTutorial.slug, - status: simpleTutorial.status, - reading_time: simpleTutorial.reading_time, - catalog_featured: simpleTutorial.catalog_featured, + slug: MOCK_TUTORIAL.slug, + status: MOCK_TUTORIAL.status, + reading_time: MOCK_TUTORIAL.reading_time, + catalog_featured: MOCK_TUTORIAL.catalog_featured, category: await api.getId( "tutorials_categories", "name", - simpleTutorial.category, + MOCK_TUTORIAL.category, ), topics: [], editors: [], @@ -84,8 +70,8 @@ describe("Tutorial uploader API", () => { sort: null, translations: [ { - title: simpleTutorial.title, - short_description: simpleTutorial.short_description, + title: MOCK_TUTORIAL.title, + short_description: MOCK_TUTORIAL.short_description, content: "Here's some basic content.\n", languages_code: "en-US", }, @@ -94,25 +80,22 @@ describe("Tutorial uploader API", () => { }); test("update existing tutorial", async () => { - const simpleTutorial = generateTutorialData(); - // Upload tutorial - await api.upsertTutorial(simpleTutorial); - const tutorialId = await api.getId( - "tutorials", - "slug", - simpleTutorial.slug, - ); + await api.upsertTutorial(MOCK_TUTORIAL); + const tutorialId = await api.getId("tutorials", "slug", MOCK_TUTORIAL.slug); // Mutate tutorial data and re-upload - simpleTutorial.title = "A new tutorial title"; - simpleTutorial.short_description = "A modified short description"; - simpleTutorial.status = "draft"; - simpleTutorial.category = "How-to"; - simpleTutorial.reading_time = 33; - simpleTutorial.topics = ["Scheduling", "Transpilation"]; - simpleTutorial.catalog_featured = false; - await api.upsertTutorial(simpleTutorial); + const modifiedTutorial = { + ...MOCK_TUTORIAL, + title: "A new tutorial title", + short_description: "A modified short description", + status: "draft", + category: "How-to", + reading_time: 33, + topics: ["Scheduling", "Transpilation"], + catalog_featured: false, + }; + await api.upsertTutorial(modifiedTutorial); // Retrieve and check const retrievedTutorial = await api.client.request( @@ -120,19 +103,19 @@ describe("Tutorial uploader API", () => { readItem("tutorials", tutorialId, { fields: ["*", "topics.*"] }), ); const topicIds = (await Promise.all( - simpleTutorial.topics.map((name) => + modifiedTutorial.topics.map((name) => api.getId("tutorials_topics", "name", name), ), )) as string[]; expect(retrievedTutorial).toMatchObject({ - slug: simpleTutorial.slug, - status: simpleTutorial.status, - reading_time: simpleTutorial.reading_time, - catalog_featured: simpleTutorial.catalog_featured, + slug: modifiedTutorial.slug, + status: modifiedTutorial.status, + reading_time: modifiedTutorial.reading_time, + catalog_featured: modifiedTutorial.catalog_featured, category: await api.getId( "tutorials_categories", "name", - simpleTutorial.category, + modifiedTutorial.category, ), topics: topicIds.map((name) => { return { tutorials_topics_id: name }; @@ -151,17 +134,21 @@ describe("Tutorial uploader API", () => { ).translations; expect(retrievedTranslation).toMatchObject([ { - title: simpleTutorial.title, - short_description: simpleTutorial.short_description, + title: modifiedTutorial.title, + short_description: modifiedTutorial.short_description, content: "Here's some basic content.\n", languages_code: "en-US", }, ]); }); - afterAll(async () => { - for (const slug of createdSlugs) { - await api.deleteTutorial(slug); + beforeAll(async () => { + if (await api.getId("tutorials", "slug", MOCK_TUTORIAL.slug)) { + await api.deleteTutorial(MOCK_TUTORIAL.slug); } }); + + afterEach(async () => { + await api.deleteTutorial(MOCK_TUTORIAL.slug); + }); }); From a3badb3df736a8fa074b7ddf281d43f5ebebbdda Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 15:15:26 +0100 Subject: [PATCH 36/68] Explain why `cd` --- scripts/tutorial-uploader/lib/api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 8fcfd39ea6..7b715e1746 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -169,6 +169,8 @@ export class API { "hex", )}/tutorial.zip`; await $`mkdir -p ${dirname(zippedFilePath)}`; + // We cd into the parent dir to avoid including the full directory + // structure in the zip await $`(cd ${dirname(path)} && zip -qr ${zippedFilePath} ${basename( path, )})`; From 1a1b7e9b9bd995e9993c4e0793f796c463ce2487 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 15:16:14 +0100 Subject: [PATCH 37/68] lint --- scripts/tutorial-uploader/lib/api.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/tutorial-uploader/lib/api.test.ts b/scripts/tutorial-uploader/lib/api.test.ts index 899079ae7c..8b34fdebfb 100644 --- a/scripts/tutorial-uploader/lib/api.test.ts +++ b/scripts/tutorial-uploader/lib/api.test.ts @@ -33,7 +33,9 @@ const MOCK_TUTORIAL: LocalTutorialData = { /* Just to be sure */ if (/learning-api\.quantum\.ibm\.com/.test(process.env.LEARNING_API_URL!)) { - throw new Error("Tried to run tests against production! Set the env var LEARNING_API_URL to either staging or local (see tutorial-uploader/README.md)"); + throw new Error( + "Tried to run tests against production! Set the env var LEARNING_API_URL to either staging or local (see tutorial-uploader/README.md)", + ); } describe("Tutorial uploader API", () => { From 99524a1e6c80864417f001a28e13a6ebbe362b1f Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 16:12:25 +0100 Subject: [PATCH 38/68] Make helper function `getTutorialIdBySlug` --- scripts/tutorial-uploader/lib/api.test.ts | 8 ++++---- scripts/tutorial-uploader/lib/api.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.test.ts b/scripts/tutorial-uploader/lib/api.test.ts index 8b34fdebfb..23f86098c3 100644 --- a/scripts/tutorial-uploader/lib/api.test.ts +++ b/scripts/tutorial-uploader/lib/api.test.ts @@ -45,10 +45,10 @@ describe("Tutorial uploader API", () => { ); test("upload new tutorial", async () => { - expect(await api.getId("tutorials", "slug", MOCK_TUTORIAL.slug)).toBeNull(); + expect(await api.getTutorialIdBySlug(MOCK_TUTORIAL.slug)).toBeNull(); await api.upsertTutorial(MOCK_TUTORIAL); - const tutorialId = await api.getId("tutorials", "slug", MOCK_TUTORIAL.slug); + const tutorialId = await api.getTutorialIdBySlug(MOCK_TUTORIAL.slug); expect(tutorialId).toBeTruthy(); const retrievedTutorial = await api.client.request( @@ -84,7 +84,7 @@ describe("Tutorial uploader API", () => { test("update existing tutorial", async () => { // Upload tutorial await api.upsertTutorial(MOCK_TUTORIAL); - const tutorialId = await api.getId("tutorials", "slug", MOCK_TUTORIAL.slug); + const tutorialId = await api.getTutorialIdBySlug(MOCK_TUTORIAL.slug); // Mutate tutorial data and re-upload const modifiedTutorial = { @@ -145,7 +145,7 @@ describe("Tutorial uploader API", () => { }); beforeAll(async () => { - if (await api.getId("tutorials", "slug", MOCK_TUTORIAL.slug)) { + if (await api.getTutorialIdBySlug(MOCK_TUTORIAL.slug)) { await api.deleteTutorial(MOCK_TUTORIAL.slug); } }); diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 7b715e1746..194c845014 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -109,6 +109,10 @@ export class API { ); } + async getTutorialIdBySlug(slug: string): Promise { + return await this.getId("tutorials", "slug", slug); + } + /** * Tutorials can have many translations, but we only use English at the moment. */ @@ -254,7 +258,7 @@ export class API { * Update tutorial if it exists, otherwise create new */ async upsertTutorial(localData: LocalTutorialData) { - let id = await this.getId("tutorials", "slug", localData.slug); + let id = await this.getTutorialIdBySlug(localData.slug); if (id === null) { id = await this.createTutorial(localData); } @@ -265,7 +269,7 @@ export class API { * For testing */ async deleteTutorial(tutorialSlug: string) { - const id = await this.getId("tutorials", "slug", tutorialSlug); + const id = await this.getTutorialIdBySlug(tutorialSlug); if (id === null) return; await this.client.request(deleteItem("tutorials", id)); } From 02e3ee9ef8bddd8b1664e23cd9bfe976a7e3f916 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 16:34:02 +0100 Subject: [PATCH 39/68] Improve `getEnglishTranslationId` --- scripts/tutorial-uploader/lib/api.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 194c845014..cb698a8187 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -123,13 +123,16 @@ export class API { fields: ["translations.id", "translations.languages_code"], }), ); - const englishTranslations = response.translations?.filter( + if (!response.translations) { + throw new Error(`Something went wrong getting translations for tutorial ${tutorialId}`); + } + const englishTranslation = (response.translations as Translation[]).find( (x: Translation) => x.languages_code === "en-US", - ) as Translation[] | undefined; - if (!englishTranslations?.length) { - throw new Error(`No english translations for tutorial ${tutorialId}`); + ); + if (!englishTranslation) { + throw new Error(`No english translation for tutorial ${tutorialId}`); } - return englishTranslations[0].id; + return englishTranslation.id; } /** From 0e3e5dcfbca7b2b5fc3434a37cf723eecce68d6b Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 16:34:27 +0100 Subject: [PATCH 40/68] Fix/ignore TS warnings --- scripts/tutorial-uploader/lib/api.ts | 2 +- scripts/tutorial-uploader/lib/schema.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index cb698a8187..d1cd355ce0 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -118,8 +118,8 @@ export class API { */ async getEnglishTranslationId(tutorialId: string): Promise { const response = await this.client.request( - // @ts-ignore readItem("tutorials", tutorialId, { + // @ts-ignore fields: ["translations.id", "translations.languages_code"], }), ); diff --git a/scripts/tutorial-uploader/lib/schema.ts b/scripts/tutorial-uploader/lib/schema.ts index 7c6fe42a74..dd15573464 100644 --- a/scripts/tutorial-uploader/lib/schema.ts +++ b/scripts/tutorial-uploader/lib/schema.ts @@ -26,7 +26,7 @@ export interface LearningApiSchema { tutorials_tutorials_topics: TutorialsTopicsRelation[]; } -interface Tutorial { +export interface Tutorial { id: string; slug: string; status: "draft" | "published" | "archived"; @@ -38,7 +38,7 @@ interface Tutorial { translations: number[] | Translation[]; } -interface Translation { +export interface Translation { id: number; title: string; short_description: string; @@ -47,12 +47,12 @@ interface Translation { temporal_file: string | null; } -interface Topic { +export interface Topic { id: string; name: string; } -interface Category { +export interface Category { id: string; name: string; } @@ -62,7 +62,7 @@ interface Category { * A tutorial can be linked to many topics. * https://docs.directus.io/guides/sdk/types.html#adding-relational-fields */ -interface TutorialsTopicsRelation { +export interface TutorialsTopicsRelation { id: number; // API can return either the UUIDs or the objects depending on the request. tutorials_id: string | Tutorial; From d5e32c2a3ec2f01117e428bab20d2aaba04f1812 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 16:55:26 +0100 Subject: [PATCH 41/68] Prettier --- scripts/tutorial-uploader/lib/api.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index d1cd355ce0..e4fdb68cf1 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -124,7 +124,9 @@ export class API { }), ); if (!response.translations) { - throw new Error(`Something went wrong getting translations for tutorial ${tutorialId}`); + throw new Error( + `Something went wrong getting translations for tutorial ${tutorialId}`, + ); } const englishTranslation = (response.translations as Translation[]).find( (x: Translation) => x.languages_code === "en-US", From 9135b9010b4f651cd2ca8094e4e95122ab94da4b Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 17:01:40 +0100 Subject: [PATCH 42/68] Ignore test notebook --- scripts/nb-tester/notebooks.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/nb-tester/notebooks.toml b/scripts/nb-tester/notebooks.toml index faee00dd19..48ab391e57 100644 --- a/scripts/nb-tester/notebooks.toml +++ b/scripts/nb-tester/notebooks.toml @@ -33,6 +33,7 @@ notebooks_normal_test = [ # Don't test the following notebooks (this section can include glob patterns) notebooks_exclude = [ "scripts/ibm-quantum-learning-uploader/test/template.ipynb", + "scripts/tutorial-uploader/lib/test-data/simple-tutorial/notebook.ipynb", "**/.ipynb_checkpoints/**", # The following notebooks are broken and need fixing "tutorials/submitting-transpiled-circuits/submitting-transpiled-circuits.ipynb", From 2e03fdebaac7603f2bcdbb7bd0c1188678a78f18 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 17:21:49 +0100 Subject: [PATCH 43/68] Improve logging --- scripts/tutorial-uploader/lib/api.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index e4fdb68cf1..c40581b310 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -37,11 +37,7 @@ import { } from "./schema"; import type { LocalTutorialData } from "./local-tutorial-data"; -/* To do: - * [x] Fix types - * [ ] Throw correctly on request failures - * [ ] More helpful console logging - */ +$.verbose = false; export class API { readonly client: RestClient; @@ -205,6 +201,7 @@ export class API { tutorialId: string, localData: LocalTutorialData, ) { + console.log(`Updating tutorial '${localData.slug}'...`); const newTutorial = { category: await this.getId( "tutorials_categories", @@ -233,6 +230,7 @@ export class API { * updateExistingTutorial should be called immediately after */ async createTutorial(localData: LocalTutorialData): Promise { + console.log(`Creating new tutorial '${localData.slug}'...`); const translationData = { title: localData.title, languages_code: "en-US", @@ -275,7 +273,10 @@ export class API { */ async deleteTutorial(tutorialSlug: string) { const id = await this.getTutorialIdBySlug(tutorialSlug); - if (id === null) return; + if (id === null) { + throw new Error(`Can't delete tutorial '${tutorialSlug}' as no tutorial exists with that slug.`); + }; + console.log(`Deleting tutorial '${tutorialSlug}'`); await this.client.request(deleteItem("tutorials", id)); } } From a138d425269cd31cf97c7fecd4ce39d6e012620f Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 18:08:06 +0100 Subject: [PATCH 44/68] Update tutorial documentation --- tutorials/README.md | 79 +++++++++++++++++++-------------------------- 1 file changed, 33 insertions(+), 46 deletions(-) diff --git a/tutorials/README.md b/tutorials/README.md index 450b508836..fb17de7d03 100644 --- a/tutorials/README.md +++ b/tutorials/README.md @@ -3,60 +3,47 @@ This folder contains the content for our tutorials, which appear on [IBM Quantum Learning](https://learning.quantum.ibm.com/catalog/tutorials). +## Editing tutorials + +Each tutorial has its own folder in `tutorials/`. Within that folder is the +content notebook (ending in `.ipynb`) and an optional `images` folder. These +are linked to the learning platform through the `learning-api.conf.yaml` file, +which also contains the tutorials' metadata. + +> [!Warning] +> +> ### Gotcha: Top-level headings +> +> One potential gotcha is that the learning platform ignores the top-level +> heading of the notebook. These headings are only included in the notebook for +> writers' convenience. If you want to change the title of a notebook, edit the +> title in `tutorials/learning-api.conf.yaml`. + +## Adding new tutorials + +To create a new tutorial, add a new notebook to the `tutorials/` folder and +make new entry to `learning-api.conf.yaml`. The next time the deploy action is +run, the tutorial will be created on the platform, so make sure to only merge +the change to `main` when you're ready for the tutorial to go live. + ## Deploying tutorials To deploy changes to tutorials, run the [Deploy tutorials](https://github.com/Qiskit/documentation/actions/workflows/deploy-tutorials.yml) -workflow. This will push the notebooks on the main branch to the environment -you select. +workflow. This will push the notebooks and their metadata from the main branch +to the environment you select. -You should always start with deploying to "Learning platform (staging)". This -will deploy to https://learning.www-dev.quantum.ibm.com/catalog/tutorials. -Check that your tutorial renders properly. +You should always start with deploying to "Learning platform (staging)". And +check that your tutorial renders properly. Once you are happy with staging, rerun the [Deploy tutorials](https://github.com/Qiskit/documentation/actions/workflows/deploy-tutorials.yml) -workflow, but this time choose "Learning platform (production)". Warning: this will -update every non-network tutorial to use the version from the `main` branch. That means -that if another author had a tutorial that was merged to `main` but not yet ready to go live -to production, you might accidentally deploy their tutorial. So, before deploying to -production, check with the team that it is okay to deploy. +workflow, but this time choose "Learning platform (production)". Warning: this +will update every non-network tutorial to use the version from the `main` +branch. That means that if another author had a tutorial that was merged to +`main` but not yet ready to go live to production, you might accidentally +deploy their tutorial. So, before deploying to production, check with the team +that it is okay to deploy. After deploying to production, check https://learning.quantum.ibm.com/catalog/tutorials to ensure your tutorial is working correctly. - -## Gotcha: tutorial headings ignored - -One potential gotcha is that the learning platform ignores the top-level -heading of the notebook. These headings are only included in the notebook for -writers' convenience. If you want to change the title of a notebook, find the -page on https://learning-api.quantum.ibm.com/admin and change its title -there. Make sure to update the title in the notebook too. - -## Adding new tutorials - -Each tutorial has its own folder in `tutorials/`. Within that folder is the content notebook -(ending in `.ipynb`) and an optional `images` folder. - -To add a new tutorial to the learning platform, go to -https://learning-api.quantum.ibm.com/admin/ and choose "Create item" (the blue -`+` in the top-right corner). Enter all the information, but leave the content -field blank. This will create the tutorial in "draft" mode. - -Once you've added the tutorial, copy the tutorial's URL relative to `content` -(that is, the URL after `https://learning-api.quantum.ibm.com/admin/content/`). -The copied URL should be of the form `tutorials/`. Next, make a new entry -in the `learning-api.conf.yaml` file in this folder. Set the `path` attribute -to the name of the tutorial folder in this repo, and `urlProduction` to the -tutorial ID you copied earlier. Repeat the same process for the staging -environment (https://learning-api-dev.quantum.ibm.com/admin/). - -Next, you will need to add the tutorial to our API token permissions. To do -that, contact Abdón Rodríguez and ask to add the tutorial IDs to the -Qiskit/documentation API keys. - -To push content from this repo to the learning platform, run the [Deploy -tutorials](https://github.com/Qiskit/documentation/actions/workflows/deploy-tutorials.yml) -workflow. This will push the content of **all** tutorials from the main branch. -Once your content is uploaded, you can go back to the admin panel for that -tutorial and publish the lesson to take it out of draft mode. From e85dade9bed4777a786ec2394d751e00151e2ee5 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 18:08:15 +0100 Subject: [PATCH 45/68] Update workflow --- .github/workflows/deploy-tutorials.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/deploy-tutorials.yml b/.github/workflows/deploy-tutorials.yml index 2077cdf8f7..565e1566ed 100644 --- a/.github/workflows/deploy-tutorials.yml +++ b/.github/workflows/deploy-tutorials.yml @@ -25,15 +25,14 @@ jobs: environment: ${{ inputs.environment }} steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - name: Set up Node.js + uses: actions/setup-node@v3 with: - python-version: "3.11" - - name: Install tool - run: pip install ./scripts/ibm-quantum-learning-uploader + node-version: 18 + - name: Install Node.js dependencies + run: npm ci - name: Upload tutorials - run: | - cd tutorials - sync-lessons + run: npm run tutorials:sync env: LEARNING_API_TOKEN: ${{ secrets.LEARNING_API_TOKEN }} - LEARNING_API_ENVIRONMENT: ${{ vars.LEARNING_API_ENVIRONMENT }} + LEARNING_API_URL: ${{ vars.LEARNING_API_URL }} From a1980b76029a0d5e3bfdf72577a01781d169a1c2 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 18:11:10 +0100 Subject: [PATCH 46/68] Remove staging URL --- scripts/tutorial-uploader/README.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/scripts/tutorial-uploader/README.md b/scripts/tutorial-uploader/README.md index 0f51e33571..eb7bc83994 100644 --- a/scripts/tutorial-uploader/README.md +++ b/scripts/tutorial-uploader/README.md @@ -9,12 +9,8 @@ To work on this script, you'll need to set up the `saiba-api` project locally. There are some extra steps you'll need to take to set up `saiba-api` for developing this script: -- Add the following line to the end of `docker-compose.yaml`. Do not commit - this change. - - ``` - PUBLIC_URL: 'https://learning.www-dev.quantum-computing.ibm.com/' - ``` +- Follow instruction in the README in the saiba-api repo. Don't forget the + instructions to add the `PUBLIC_URL` entry to your `docker-compose.yaml`. - Login into the local CMS () using From 7088cf66ee0b1bdde1ee8cc30011e5e60cba57ae Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 18:44:15 +0100 Subject: [PATCH 47/68] Verify environment is set correctly --- scripts/tutorial-uploader/lib/api.test.ts | 5 +---- scripts/tutorial-uploader/lib/api.ts | 17 ++++++++++++++++- scripts/tutorial-uploader/sync.ts | 6 +----- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.test.ts b/scripts/tutorial-uploader/lib/api.test.ts index 23f86098c3..c2b4792b0d 100644 --- a/scripts/tutorial-uploader/lib/api.test.ts +++ b/scripts/tutorial-uploader/lib/api.test.ts @@ -39,10 +39,7 @@ if (/learning-api\.quantum\.ibm\.com/.test(process.env.LEARNING_API_URL!)) { } describe("Tutorial uploader API", () => { - const api = new API( - process.env.LEARNING_API_URL!, - process.env.LEARNING_API_TOKEN!, - ); + const api = new API(); test("upload new tutorial", async () => { expect(await api.getTutorialIdBySlug(MOCK_TUTORIAL.slug)).toBeNull(); diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index c40581b310..4ba4eeac5a 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -39,10 +39,25 @@ import type { LocalTutorialData } from "./local-tutorial-data"; $.verbose = false; +function getLearningEnvironment(): [string, string] { + if (!process.env.LEARNING_API_URL) { + throw new Error( + "environment variable 'LEARNING_API_URL' is not set (see tutorial-uploader/README.md)", + ); + } + if (!process.env.LEARNING_API_TOKEN) { + throw new Error( + "environment variable 'LEARNING_API_TOKEN' is not set (see tutorial-uploader/README.md)", + ); + } + return [process.env.LEARNING_API_URL, process.env.LEARNING_API_TOKEN]; +} + export class API { readonly client: RestClient; - constructor(url: string, token: string) { + constructor() { + const [url, token] = getLearningEnvironment(); this.client = createDirectus(url) .with(rest()) .with(staticToken(token)); diff --git a/scripts/tutorial-uploader/sync.ts b/scripts/tutorial-uploader/sync.ts index dfb3bb684a..be7a9502c4 100644 --- a/scripts/tutorial-uploader/sync.ts +++ b/scripts/tutorial-uploader/sync.ts @@ -23,11 +23,7 @@ const CONFIG_PATH = "tutorials/learning-api.conf.yaml"; */ async function main() { - // @ts-ignore // TODO: Throw if undefined - const api = new API( - process.env.LEARNING_API_URL!, - process.env.LEARNING_API_TOKEN!, - ); + const api = new API(); for (const tutorial of await readTutorialData(CONFIG_PATH)) { await api.upsertTutorial(tutorial); From a1095a15e0563fff4758bace546be59f77b868c4 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 18:53:31 +0100 Subject: [PATCH 48/68] Set up topics and categories automatically --- package.json | 3 +- scripts/tutorial-uploader/README.md | 22 ++----- .../tutorial-uploader/setup-for-testing.ts | 57 +++++++++++++++++++ 3 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 scripts/tutorial-uploader/setup-for-testing.ts diff --git a/package.json b/package.json index 334193f1d3..2a83ad7e32 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "gen-api": "node -r esbuild-register scripts/commands/updateApiDocs.ts", "make-historical": "node -r esbuild-register scripts/commands/convertApiDocsToHistorical.ts", "tutorial:sync": "node -r esbuild-register scripts/tutorial-uploader/sync.ts", - "tutorial:validate": "node -r esbuild-register scripts/tutorial-uploader/validate-config.ts" + "tutorial:validate": "node -r esbuild-register scripts/tutorial-uploader/validate-config.ts", + "tutorial:setup-testing": "node -r esbuild-register scripts/tutorial-uploader/setup-for-testing.ts" }, "devDependencies": { "@swc/jest": "^0.2.29", diff --git a/scripts/tutorial-uploader/README.md b/scripts/tutorial-uploader/README.md index eb7bc83994..b9e361d307 100644 --- a/scripts/tutorial-uploader/README.md +++ b/scripts/tutorial-uploader/README.md @@ -32,19 +32,9 @@ developing this script: ``` Consider using [direnv](https://direnv.net/) to handle this. -- Create two categories with names `Workflow example` and `How-to`; our script - fails if these categories don't exist. To create a category: - - 1. Go to - 2. Add the name and click the tick in the top-right corner to save - -- Create the following tutorial topics at . - > TODO: Can we automate this? - - Chemistry - - Dynamic circuits - - Error mitigation - - Optimization - - Qiskit patterns - - Scheduling - - Scheduling - - Transpilation +- With the local database running, run the following command to add the topics + and categories that we expect to exist. + + ``` + npm run tutorial:setup-testing + ``` diff --git a/scripts/tutorial-uploader/setup-for-testing.ts b/scripts/tutorial-uploader/setup-for-testing.ts new file mode 100644 index 0000000000..dad6793d0a --- /dev/null +++ b/scripts/tutorial-uploader/setup-for-testing.ts @@ -0,0 +1,57 @@ +// 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 { API } from "./lib/api"; +import { createItem } from "@directus/sdk"; + +if (/learning-api\.quantum\.ibm\.com/.test(process.env.LEARNING_API_URL!)) { + throw new Error( + "Tried to run setup against production! Set the env var LEARNING_API_URL to either staging or local (see tutorial-uploader/README.md)", + ); +} + +const TOPICS = [ + "Chemistry", + "Dynamic circuits", + "Error mitigation", + "Optimization", + "Qiskit patterns", + "Scheduling", + "Transpilation", +]; + +const CATEGORIES = ["Workflow example", "How-to"]; + +async function main() { + const api = new API(); + + const promises = []; + for (const topicName of TOPICS) { + if (!(await api.getId("tutorials_topics", "name", topicName))) { + console.log(`Creating tutorial topic '${topicName}'...`); + await api.client.request( + createItem("tutorials_topics", { name: topicName }), + ); + } + } + + for (const categoryName of CATEGORIES) { + if (!(await api.getId("tutorials_categories", "name", categoryName))) { + console.log(`Creating tutorial category '${categoryName}'...`); + await api.client.request( + createItem("tutorials_categories", { name: categoryName }), + ); + } + } +} + +main().then(() => process.exit()); From e1e17ca6908d390ea0f44e3c2608bc2b18c630a6 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 18:54:10 +0100 Subject: [PATCH 49/68] Cleanup --- scripts/tutorial-uploader/lib/api.test.ts | 2 -- scripts/tutorial-uploader/lib/api.ts | 6 ++++-- scripts/tutorial-uploader/sync.ts | 7 ------- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.test.ts b/scripts/tutorial-uploader/lib/api.test.ts index c2b4792b0d..e9dd96fa49 100644 --- a/scripts/tutorial-uploader/lib/api.test.ts +++ b/scripts/tutorial-uploader/lib/api.test.ts @@ -10,8 +10,6 @@ // copyright notice, and modified files need to carry a notice indicating // that they have been altered from the originals. -import { randomBytes } from "crypto"; - import { describe, expect, test } from "@jest/globals"; import { readItem } from "@directus/sdk"; diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 4ba4eeac5a..46de3a9c7c 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -289,8 +289,10 @@ export class API { async deleteTutorial(tutorialSlug: string) { const id = await this.getTutorialIdBySlug(tutorialSlug); if (id === null) { - throw new Error(`Can't delete tutorial '${tutorialSlug}' as no tutorial exists with that slug.`); - }; + throw new Error( + `Can't delete tutorial '${tutorialSlug}' as no tutorial exists with that slug.`, + ); + } console.log(`Deleting tutorial '${tutorialSlug}'`); await this.client.request(deleteItem("tutorials", id)); } diff --git a/scripts/tutorial-uploader/sync.ts b/scripts/tutorial-uploader/sync.ts index be7a9502c4..fea47872a4 100644 --- a/scripts/tutorial-uploader/sync.ts +++ b/scripts/tutorial-uploader/sync.ts @@ -15,13 +15,6 @@ import { readTutorialData } from "./lib/local-tutorial-data"; const CONFIG_PATH = "tutorials/learning-api.conf.yaml"; -/* To do: - * - * [x] Read from YAML file - * [ ] Throw correctly - * [ ] More helpful console logging - */ - async function main() { const api = new API(); From f99965ec97f799dfa1f5d4eaba8c354ea8ebd71a Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Wed, 29 May 2024 21:05:36 +0100 Subject: [PATCH 50/68] oops --- .github/workflows/deploy-tutorials.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-tutorials.yml b/.github/workflows/deploy-tutorials.yml index 565e1566ed..61cb94aaca 100644 --- a/.github/workflows/deploy-tutorials.yml +++ b/.github/workflows/deploy-tutorials.yml @@ -32,7 +32,7 @@ jobs: - name: Install Node.js dependencies run: npm ci - name: Upload tutorials - run: npm run tutorials:sync + run: npm run tutorial:sync env: LEARNING_API_TOKEN: ${{ secrets.LEARNING_API_TOKEN }} LEARNING_API_URL: ${{ vars.LEARNING_API_URL }} From 72089f2792b367cdf8d9ad74e7dd7c4edefa3a87 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Thu, 30 May 2024 10:27:45 +0100 Subject: [PATCH 51/68] Apply suggestions from code review Co-authored-by: Arnau Casau <47946624+arnaucasau@users.noreply.github.com> --- scripts/tutorial-uploader/README.md | 4 ++-- tutorials/README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/tutorial-uploader/README.md b/scripts/tutorial-uploader/README.md index b9e361d307..10ff4db234 100644 --- a/scripts/tutorial-uploader/README.md +++ b/scripts/tutorial-uploader/README.md @@ -9,7 +9,7 @@ To work on this script, you'll need to set up the `saiba-api` project locally. There are some extra steps you'll need to take to set up `saiba-api` for developing this script: -- Follow instruction in the README in the saiba-api repo. Don't forget the +- Follow the instructions in the README in the saiba-api repo. Don't forget the instructions to add the `PUBLIC_URL` entry to your `docker-compose.yaml`. - Login into the local CMS () using @@ -24,7 +24,7 @@ developing this script: 3. Create a new user with the "Content creator admin" role and generate a new static token. Copy the token to your clipboard. Then click the tick on the top-right of the page to save the user. - 4. To test the script in this repo, export the following envrionment + 4. To test the script in this repo, export the following environment variables. ``` export LEARNING_API_URL=http://0.0.0.0:8055 diff --git a/tutorials/README.md b/tutorials/README.md index fb17de7d03..d36652fc4c 100644 --- a/tutorials/README.md +++ b/tutorials/README.md @@ -22,7 +22,7 @@ which also contains the tutorials' metadata. ## Adding new tutorials To create a new tutorial, add a new notebook to the `tutorials/` folder and -make new entry to `learning-api.conf.yaml`. The next time the deploy action is +make a new entry to `learning-api.conf.yaml`. The next time the deploy action is run, the tutorial will be created on the platform, so make sure to only merge the change to `main` when you're ready for the tutorial to go live. From ae35611a332d77b925b2d0d65ff43615a168afd5 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Thu, 30 May 2024 11:09:01 +0100 Subject: [PATCH 52/68] Assert type rather than cast --- scripts/tutorial-uploader/lib/local-tutorial-data.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/tutorial-uploader/lib/local-tutorial-data.ts b/scripts/tutorial-uploader/lib/local-tutorial-data.ts index bd988f1111..77c0f00ffb 100644 --- a/scripts/tutorial-uploader/lib/local-tutorial-data.ts +++ b/scripts/tutorial-uploader/lib/local-tutorial-data.ts @@ -41,13 +41,14 @@ export async function readTutorialData( ): Promise { const raw = await readFile(path, "utf8"); const parsed = yaml.load(raw) as unknown[]; - return parsed - .map((i) => verifyLocalTutorialData(i)) - .map((i) => relativiseLocalPath(i, path)); + parsed.forEach((i) => assertIsLocalTutorialData(i)); + return parsed.map((i) => relativiseLocalPath(i as LocalTutorialData, path)); } /* Runtime type-checking to make sure YAML file is valid */ -function verifyLocalTutorialData(obj: any): LocalTutorialData { +function assertIsLocalTutorialData( + obj: unknown, +): asserts obj is LocalTutorialData { for (let [attr, isCorrectType] of [ ["title", isString], ["short_description", isString], @@ -69,5 +70,4 @@ function verifyLocalTutorialData(obj: any): LocalTutorialData { ); } } - return obj as LocalTutorialData; } From 0741e9c4860abb512d6057cba92383bd4e402aa5 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Thu, 30 May 2024 12:54:12 +0100 Subject: [PATCH 53/68] Change capitalization to match learning platform --- scripts/tutorial-uploader/setup-for-testing.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/tutorial-uploader/setup-for-testing.ts b/scripts/tutorial-uploader/setup-for-testing.ts index dad6793d0a..7a87c4c3c5 100644 --- a/scripts/tutorial-uploader/setup-for-testing.ts +++ b/scripts/tutorial-uploader/setup-for-testing.ts @@ -24,7 +24,7 @@ const TOPICS = [ "Dynamic circuits", "Error mitigation", "Optimization", - "Qiskit patterns", + "Qiskit Patterns", "Scheduling", "Transpilation", ]; @@ -34,7 +34,6 @@ const CATEGORIES = ["Workflow example", "How-to"]; async function main() { const api = new API(); - const promises = []; for (const topicName of TOPICS) { if (!(await api.getId("tutorials_topics", "name", topicName))) { console.log(`Creating tutorial topic '${topicName}'...`); From bc5aa11270484338120e1b0303482f4154d16bd9 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Thu, 30 May 2024 16:47:15 +0100 Subject: [PATCH 54/68] Update scripts/tutorial-uploader/lib/api.ts Co-authored-by: Eric Arellano <14852634+Eric-Arellano@users.noreply.github.com> --- scripts/tutorial-uploader/lib/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 46de3a9c7c..edabf3de94 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -215,7 +215,7 @@ export class API { async updateExistingTutorial( tutorialId: string, localData: LocalTutorialData, - ) { + ): Promise { console.log(`Updating tutorial '${localData.slug}'...`); const newTutorial = { category: await this.getId( From 58d326b0c02f2af7a13a0b2744056c02cb2117b2 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Thu, 30 May 2024 16:52:34 +0100 Subject: [PATCH 55/68] Apply suggestions from code review Co-authored-by: Eric Arellano <14852634+Eric-Arellano@users.noreply.github.com> --- scripts/tutorial-uploader/lib/api.ts | 4 ++-- tutorials/README.md | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index edabf3de94..b115ff7fc7 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -275,7 +275,7 @@ export class API { /** * Update tutorial if it exists, otherwise create new */ - async upsertTutorial(localData: LocalTutorialData) { + async upsertTutorial(localData: LocalTutorialData): Promise { let id = await this.getTutorialIdBySlug(localData.slug); if (id === null) { id = await this.createTutorial(localData); @@ -286,7 +286,7 @@ export class API { /** * For testing */ - async deleteTutorial(tutorialSlug: string) { + async deleteTutorial(tutorialSlug: string): Promise { const id = await this.getTutorialIdBySlug(tutorialSlug); if (id === null) { throw new Error( diff --git a/tutorials/README.md b/tutorials/README.md index d36652fc4c..4c653ff9a5 100644 --- a/tutorials/README.md +++ b/tutorials/README.md @@ -33,8 +33,9 @@ tutorials](https://github.com/Qiskit/documentation/actions/workflows/deploy-tuto workflow. This will push the notebooks and their metadata from the main branch to the environment you select. -You should always start with deploying to "Learning platform (staging)". And -check that your tutorial renders properly. +You should always start with deploying to "Learning platform (staging)", and +check that your tutorial renders properly. Ask a teammate for the staging link +if you need it. Once you are happy with staging, rerun the [Deploy tutorials](https://github.com/Qiskit/documentation/actions/workflows/deploy-tutorials.yml) From 10da9dd129fe7fb83dc34fa3a29899f52e7a7274 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Mon, 10 Jun 2024 11:12:08 +0100 Subject: [PATCH 56/68] Privatise methods and add comment --- scripts/tutorial-uploader/lib/api.ts | 32 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 46de3a9c7c..744e1032fc 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -67,7 +67,7 @@ export class API { * Get IDs of all items in `collection` that match a field value. * Roughly: "SELECT * FROM collection WHERE field=value". */ - async getIds< + async #getIds< CollectionName extends StringKeyOf, FieldName extends StringKeyOf< ElementType @@ -107,7 +107,7 @@ export class API { field: FieldName, value: FieldValue, ): Promise { - const ids = await this.getIds(collection, field, value); + const ids = await this.#getIds(collection, field, value); if (ids.length === 0) { return null; } @@ -127,7 +127,7 @@ export class API { /** * Tutorials can have many translations, but we only use English at the moment. */ - async getEnglishTranslationId(tutorialId: string): Promise { + async #getEnglishTranslationId(tutorialId: string): Promise { const response = await this.client.request( readItem("tutorials", tutorialId, { // @ts-ignore @@ -154,9 +154,9 @@ export class API { * "tutorials_tutorials_topics": a collection of one-to-many mappings of * "tutorials" to "tutorials_topics". */ - async clearTopics(tutorialId: string) { + async #clearTopics(tutorialId: string) { // "tutorials_tutorials_topics" is mapping of tutorial to topics - const ids = await this.getIds( + const ids = await this.#getIds( "tutorials_tutorials_topics", "tutorials_id", tutorialId, @@ -168,8 +168,8 @@ export class API { } } - async updateTutorialTopics(tutorialId: string, topicNames: string[]) { - await this.clearTopics(tutorialId); + async #updateTutorialTopics(tutorialId: string, topicNames: string[]) { + await this.#clearTopics(tutorialId); for (const name of topicNames) { const id = await this.getId("tutorials_topics", "name", name); if (id === null) throw new Error(`No topic with name '${name}'`); @@ -183,7 +183,7 @@ export class API { } /* Returns the file's ID */ - async uploadLocalFolder(path: string): Promise { + async #uploadLocalFolder(path: string): Promise { // Zip folder const zippedFilePath = `${tmpdir()}/${randomBytes(8).toString( "hex", @@ -200,7 +200,7 @@ export class API { type: "application/zip", }); const formData = new FormData(); - formData.append("title", zippedFilePath); + formData.append("title", zippedFilePath); // Name is not important as file is temporary formData.append("file", file, zippedFilePath); // Upload form @@ -212,7 +212,7 @@ export class API { return response.id; } - async updateExistingTutorial( + async #updateExistingTutorial( tutorialId: string, localData: LocalTutorialData, ) { @@ -229,22 +229,22 @@ export class API { translations: [ { title: localData.title, - id: await this.getEnglishTranslationId(tutorialId), - temporal_file: await this.uploadLocalFolder(localData.local_path), + id: await this.#getEnglishTranslationId(tutorialId), + temporal_file: await this.#uploadLocalFolder(localData.local_path), short_description: localData.short_description, }, ], }; await this.client.request(updateItem("tutorials", tutorialId, newTutorial)); - await this.updateTutorialTopics(tutorialId, localData.topics); + await this.#updateTutorialTopics(tutorialId, localData.topics); } /* * Only sets minimum data required for API to accept the creation request * updateExistingTutorial should be called immediately after */ - async createTutorial(localData: LocalTutorialData): Promise { + async #createTutorial(localData: LocalTutorialData): Promise { console.log(`Creating new tutorial '${localData.slug}'...`); const translationData = { title: localData.title, @@ -278,9 +278,9 @@ export class API { async upsertTutorial(localData: LocalTutorialData) { let id = await this.getTutorialIdBySlug(localData.slug); if (id === null) { - id = await this.createTutorial(localData); + id = await this.#createTutorial(localData); } - await this.updateExistingTutorial(id, localData); + await this.#updateExistingTutorial(id, localData); } /** From e2289654a84ea1cbc627720710f48c37c975601f Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Mon, 10 Jun 2024 11:22:57 +0100 Subject: [PATCH 57/68] Add comments reminding to update --- scripts/tutorial-uploader/setup-for-testing.ts | 1 + tutorials/learning-api.conf.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/scripts/tutorial-uploader/setup-for-testing.ts b/scripts/tutorial-uploader/setup-for-testing.ts index 7a87c4c3c5..6301b4f40f 100644 --- a/scripts/tutorial-uploader/setup-for-testing.ts +++ b/scripts/tutorial-uploader/setup-for-testing.ts @@ -19,6 +19,7 @@ if (/learning-api\.quantum\.ibm\.com/.test(process.env.LEARNING_API_URL!)) { ); } +/* If these change, make sure to update tutorials/learning-api.conf.yaml */ const TOPICS = [ "Chemistry", "Dynamic circuits", diff --git a/tutorials/learning-api.conf.yaml b/tutorials/learning-api.conf.yaml index 0a82840b59..73dd146118 100644 --- a/tutorials/learning-api.conf.yaml +++ b/tutorials/learning-api.conf.yaml @@ -21,6 +21,7 @@ # * Qiskit Patterns # * Scheduling # * Transpilation +# If these change, make sure to update scripts/tutorial-uploader/setup-for-testing.ts # reading_time: Rough number of minutes needed to read the page # catalog_featured: Whether the page should be in the featured section in the catalog. From 3bf7bc4877933a068822a847799b84879003b54e Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Mon, 10 Jun 2024 11:42:33 +0100 Subject: [PATCH 58/68] Update README --- tutorials/README.md | 78 ++++++++++++--------------------------------- 1 file changed, 21 insertions(+), 57 deletions(-) diff --git a/tutorials/README.md b/tutorials/README.md index 14feccd340..5a7478569b 100644 --- a/tutorials/README.md +++ b/tutorials/README.md @@ -10,77 +10,41 @@ content notebook (ending in `.ipynb`) and an optional `images` folder. These are linked to the learning platform through the `learning-api.conf.yaml` file, which also contains the tutorials' metadata. +Once your changes are in `main`, follow the steps in "Deploy tutorials" to +publish them. + > [!Warning] > > ### Gotcha: Top-level headings > -> One potential gotcha is that the learning platform ignores the top-level -> heading of the notebook. These headings are only included in the notebook for -> writers' convenience. If you want to change the title of a notebook, edit the -> title in `tutorials/learning-api.conf.yaml`. +> The learning platform ignores the top-level heading of the notebook. These +> headings are only included in the notebook for writers' convenience. If you +> want to change the title of a notebook, edit the title in +> `tutorials/learning-api.conf.yaml`. -## Adding new tutorials +## Add a new tutorial To create a new tutorial, add a new notebook to the `tutorials/` folder and -make a new entry to `learning-api.conf.yaml`. The next time the deploy action is -run, the tutorial will be created on the platform, so make sure to only merge -the change to `main` when you're ready for the tutorial to go live. +make a new entry in `learning-api.conf.yaml`. When you're ready to publish, +merge to main and follow the steps in "Deploy tutorials". + +The next time the deploy action is run, the tutorial will be created on the +platform, so make sure to only merge the change to `main` when you're ready for +the tutorial to go live. -## Edit existing tutorials and deploy +## Deploy tutorials -To deploy changes to tutorials, run the [Deploy +Once your changes are in `main`, you can deploy them by running the [Deploy tutorials](https://github.com/Qiskit/documentation/actions/workflows/deploy-tutorials.yml) -workflow. This will push the notebooks and their metadata from the main branch -to the environment you select. +workflow. This will push **all** notebooks and their metadata from the main +branch to the environment you select. -You should always start with deploying to "Learning platform (staging)", and -check that your tutorial renders properly. Ask a teammate for the staging link -if you need it. +Start by deploying to "Learning platform (staging)" and check that your +tutorial renders properly. Ask a teammate for the staging link if you need it. Once you are happy with staging, rerun the [Deploy tutorials](https://github.com/Qiskit/documentation/actions/workflows/deploy-tutorials.yml) -workflow, but this time choose "Learning platform (production)". Warning: this -will update every non-network tutorial to use the version from the `main` -branch. That means that if another author had a tutorial that was merged to -`main` but not yet ready to go live to production, you might accidentally -deploy their tutorial. So, before deploying to production, check with the team -that it is okay to deploy. +workflow, but this time choose "Learning platform (production)". After deploying to production, check https://learning.quantum.ibm.com/catalog/tutorials to ensure your tutorial is working correctly. - -## Gotcha: tutorial headings ignored - -One potential gotcha is that the learning platform ignores the top-level -heading of the notebook. These headings are only included in the notebook for -writers' convenience. If you want to change the title of a notebook, find the -page on https://learning-api.quantum.ibm.com/admin and change its title -there. Make sure to update the title in the notebook too. - -## Add new tutorials and deploy - -Each tutorial has its own folder in `tutorials/`. Within that folder is the content notebook -(ending in `.ipynb`) and an optional `images` folder. - -To add a new tutorial to the learning platform, go to -https://learning-api.quantum.ibm.com/admin/ and choose "Create item" (the blue -`+` in the top-right corner). Enter all the information, but leave the content -field blank. This will create the tutorial in "draft" mode. - -Once you've added the tutorial, copy the tutorial's URL relative to `content` -(that is, the URL after `https://learning-api.quantum.ibm.com/admin/content/`). -The copied URL should be of the form `tutorials/`. Next, make a new entry -in the `learning-api.conf.yaml` file in this folder. Set the `path` attribute -to the name of the tutorial folder in this repo, and `urlProduction` to the -tutorial ID you copied earlier. Repeat the same process for the staging -environment (https://learning-api-dev.quantum.ibm.com/admin/). - -Next, you will need to add the tutorial to our API token permissions. To do -that, contact Abdón Rodríguez and ask to add the tutorial IDs to the -Qiskit/documentation API keys. - -To push content from this repo to the learning platform, run the [Deploy -tutorials](https://github.com/Qiskit/documentation/actions/workflows/deploy-tutorials.yml) -workflow. This will push the content of **all** tutorials from the main branch. -Once your content is uploaded, you can go back to the admin panel for that -tutorial and publish the lesson to take it out of draft mode. From 208c08615cc939650b2b91813b45412f93144b08 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Fri, 14 Jun 2024 10:48:38 +0100 Subject: [PATCH 59/68] Add warning to avoid changing slugs Mostly paranoia, but changing a slug will probably not have the intended effect. I imagine a contributor would think this would change the slug of the page, but it will actually just upload a new page with the new slug so there would be two pages online. Changing a slug also requires a redirect, for which there's no way to automate. I think a comment here is appropriate as contributors might not (and shouldn't have to) read the entire contributing document before editing this file. --- tutorials/learning-api.conf.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tutorials/learning-api.conf.yaml b/tutorials/learning-api.conf.yaml index 73dd146118..859ea6f3f5 100644 --- a/tutorials/learning-api.conf.yaml +++ b/tutorials/learning-api.conf.yaml @@ -30,7 +30,7 @@ In this tutorial, you will run an experiment on a quantum computer to demonstrate the violation of the CHSH inequality with the Estimator primitive. - slug: chsh-inequality + slug: chsh-inequality # Do not change this local_path: chsh-inequality status: published topics: ["Scheduling"] @@ -41,7 +41,7 @@ - title: Grover's algorithm short_description: > Demonstrating how to create Grover's algorithm with Qiskit Runtime - slug: grovers-algorithm + slug: grovers-algorithm # Do not change this local_path: grovers-algorithm status: published category: Workflow example @@ -54,7 +54,7 @@ Quantum approximate optimization algorithm is a hybrid algorithm to solve combinatorial optimization problems. This tutorial uses Qiskit Runtime to solve a simple max-cut problem. - slug: quantum-approximate-optimization-algorithm + slug: quantum-approximate-optimization-algorithm # Do not change this local_path: quantum-approximate-optimization-algorithm status: published category: Workflow example @@ -66,7 +66,7 @@ short_description: > In this tutorial, we'll disable automatic transpilation and take you through the full process of creating, transpiling, and submitting circuits. - slug: submit-transpiled-circuits + slug: submit-transpiled-circuits # Do not change this local_path: submitting-transpiled-circuits status: published category: How-to @@ -78,7 +78,7 @@ short_description: > Variational quantum algorithms are hybrid-algorithms for observing the utility of quantum computation on noisy, near-term IBM Quantum systems. - slug: variational-quantum-eigensolver + slug: variational-quantum-eigensolver # Do not change this local_path: variational-quantum-eigensolver status: published category: Workflow example @@ -91,7 +91,7 @@ This tutorial demonstrates IBM dynamic-circuits to use mid-circuit measurements to produce a circuit that repeats until a successful syndrome measurement. - slug: repeat-until-success + slug: repeat-until-success # Do not change this local_path: repeat-until-success status: published category: How-to @@ -103,7 +103,7 @@ short_description: > This tutorial demonstrates how to build basic repetition codes using IBM dynamic circuits, an example of basic quantum error correction (QEC). - slug: build-repetition-codes + slug: build-repetition-codes # Do not change this local_path: build-repetition-codes status: published category: How-to @@ -115,7 +115,7 @@ short_description: > Learn how to use IBM Quantum Composer to build quantum circuits and run them on IBM Quantum systems and simulators. - slug: explore-gates-and-circuits-with-the-quantum-composer + slug: explore-gates-and-circuits-with-the-quantum-composer # Do not change this local_path: explore-composer status: published category: How-to @@ -127,7 +127,7 @@ short_description: > Combine error mitigation options for utility-scale experiments using 100Q+ IBM Quantum systems and the Qiskit Runtime Estimator primitive. - slug: combine-error-mitigation-options-with-the-estimator-primitive + slug: combine-error-mitigation-options-with-the-estimator-primitive # Do not change this local_path: combine-error-mitigation-options-with-the-estimator-primitive status: published category: Workflow example From cc1cf942de83adf50280311c0e16cd0ff9259725 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Fri, 14 Jun 2024 13:11:47 +0100 Subject: [PATCH 60/68] Fix types Co-authored-by: Arnau Casau <47946624+arnaucasau@users.noreply.github.com> --- scripts/tutorial-uploader/lib/api.test.ts | 19 +++++++++++-------- scripts/tutorial-uploader/lib/api.ts | 6 ++++-- .../lib/local-tutorial-data.ts | 10 ++++------ scripts/tutorial-uploader/lib/schema.ts | 1 + 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.test.ts b/scripts/tutorial-uploader/lib/api.test.ts index e9dd96fa49..d8782ebc60 100644 --- a/scripts/tutorial-uploader/lib/api.test.ts +++ b/scripts/tutorial-uploader/lib/api.test.ts @@ -47,8 +47,9 @@ describe("Tutorial uploader API", () => { expect(tutorialId).toBeTruthy(); const retrievedTutorial = await api.client.request( - // @ts-ignore - readItem("tutorials", tutorialId, { fields: ["*", "translations.*"] }), + readItem("tutorials", tutorialId as string, { + fields: ["*", { translations: ["*"] }], + }), ); expect(retrievedTutorial).toMatchObject({ slug: MOCK_TUTORIAL.slug, @@ -96,8 +97,9 @@ describe("Tutorial uploader API", () => { // Retrieve and check const retrievedTutorial = await api.client.request( - // @ts-ignore - readItem("tutorials", tutorialId, { fields: ["*", "topics.*"] }), + readItem("tutorials", tutorialId as string, { + fields: ["*", { topics: ["*"] }], + }), ); const topicIds = (await Promise.all( modifiedTutorial.topics.map((name) => @@ -114,8 +116,8 @@ describe("Tutorial uploader API", () => { "name", modifiedTutorial.category, ), - topics: topicIds.map((name) => { - return { tutorials_topics_id: name }; + topics: topicIds.map((id) => { + return { tutorials_topics_id: id }; }), editors: [], allowed_email_domains: null, @@ -125,8 +127,9 @@ describe("Tutorial uploader API", () => { const retrievedTranslation = ( await api.client.request( - // @ts-ignore - readItem("tutorials", tutorialId, { fields: ["translations.*"] }), + readItem("tutorials", tutorialId as string, { + fields: [{ translations: ["*"] }], + }), ) ).translations; expect(retrievedTranslation).toMatchObject([ diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 8833ca299a..0147fe90cd 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -130,8 +130,10 @@ export class API { async #getEnglishTranslationId(tutorialId: string): Promise { const response = await this.client.request( readItem("tutorials", tutorialId, { - // @ts-ignore - fields: ["translations.id", "translations.languages_code"], + fields: [ + { translations: ["id"] }, + { translations: ["languages_code"] }, + ], }), ); if (!response.translations) { diff --git a/scripts/tutorial-uploader/lib/local-tutorial-data.ts b/scripts/tutorial-uploader/lib/local-tutorial-data.ts index 77c0f00ffb..b9bedb9049 100644 --- a/scripts/tutorial-uploader/lib/local-tutorial-data.ts +++ b/scripts/tutorial-uploader/lib/local-tutorial-data.ts @@ -46,10 +46,8 @@ export async function readTutorialData( } /* Runtime type-checking to make sure YAML file is valid */ -function assertIsLocalTutorialData( - obj: unknown, -): asserts obj is LocalTutorialData { - for (let [attr, isCorrectType] of [ +function assertIsLocalTutorialData(obj: any): asserts obj is LocalTutorialData { + let propertyValidators: [string, (value?: any) => value is any][] = [ ["title", isString], ["short_description", isString], ["slug", isString], @@ -59,8 +57,8 @@ function assertIsLocalTutorialData( ["topics", Array.isArray], ["reading_time", isNumber], ["catalog_featured", isBoolean], - ]) { - // @ts-ignore + ]; + for (let [attr, isCorrectType] of propertyValidators) { if (!isCorrectType(obj[attr])) { throw new Error( "The following entry in `learning-api.conf.yaml` is invalid.\n\n" + diff --git a/scripts/tutorial-uploader/lib/schema.ts b/scripts/tutorial-uploader/lib/schema.ts index dd15573464..6fc8a417b4 100644 --- a/scripts/tutorial-uploader/lib/schema.ts +++ b/scripts/tutorial-uploader/lib/schema.ts @@ -33,6 +33,7 @@ export interface Tutorial { reading_time: number; category: string; // This is the uuid of the category. catalog_featured: boolean; + topics: number | TutorialsTopicsRelation; // API can return either the translation IDs or the translation objects // depending on the request. translations: number[] | Translation[]; From 2dee7ff5ee574bb83729a6b2f5c039ba1d963a39 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Fri, 14 Jun 2024 13:12:51 +0100 Subject: [PATCH 61/68] Fix tests Dunno why this was expected at some point --- scripts/tutorial-uploader/lib/api.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.test.ts b/scripts/tutorial-uploader/lib/api.test.ts index d8782ebc60..6a458e857d 100644 --- a/scripts/tutorial-uploader/lib/api.test.ts +++ b/scripts/tutorial-uploader/lib/api.test.ts @@ -62,7 +62,6 @@ describe("Tutorial uploader API", () => { MOCK_TUTORIAL.category, ), topics: [], - editors: [], allowed_email_domains: null, required_instance_access: null, sort: null, @@ -119,7 +118,6 @@ describe("Tutorial uploader API", () => { topics: topicIds.map((id) => { return { tutorials_topics_id: id }; }), - editors: [], allowed_email_domains: null, required_instance_access: null, sort: null, From 92c3c6db16a6a9e44cdc95cde891a7894a0f3857 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Fri, 5 Jul 2024 18:35:34 +0100 Subject: [PATCH 62/68] Add ability to set access controls --- scripts/tutorial-uploader/lib/api.test.ts | 10 ++++++---- scripts/tutorial-uploader/lib/api.ts | 2 ++ scripts/tutorial-uploader/lib/local-tutorial-data.ts | 2 ++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.test.ts b/scripts/tutorial-uploader/lib/api.test.ts index 6a458e857d..8561009696 100644 --- a/scripts/tutorial-uploader/lib/api.test.ts +++ b/scripts/tutorial-uploader/lib/api.test.ts @@ -27,6 +27,8 @@ const MOCK_TUTORIAL: LocalTutorialData = { topics: [], reading_time: 50, catalog_featured: false, + required_instance_access: ["ibm-quantum/group/project"], + allowed_email_domains: ["ibm.com", "hotmail.co.uk"], }; /* Just to be sure */ @@ -62,8 +64,8 @@ describe("Tutorial uploader API", () => { MOCK_TUTORIAL.category, ), topics: [], - allowed_email_domains: null, - required_instance_access: null, + allowed_email_domains: ["ibm.com", "hotmail.co.uk"], + required_instance_access: ["ibm-quantum/group/project"], sort: null, translations: [ { @@ -118,8 +120,8 @@ describe("Tutorial uploader API", () => { topics: topicIds.map((id) => { return { tutorials_topics_id: id }; }), - allowed_email_domains: null, - required_instance_access: null, + allowed_email_domains: ["ibm.com", "hotmail.co.uk"], + required_instance_access: ["ibm-quantum/group/project"], sort: null, }); diff --git a/scripts/tutorial-uploader/lib/api.ts b/scripts/tutorial-uploader/lib/api.ts index 0147fe90cd..73eca3f5d0 100644 --- a/scripts/tutorial-uploader/lib/api.ts +++ b/scripts/tutorial-uploader/lib/api.ts @@ -228,6 +228,8 @@ export class API { reading_time: localData.reading_time, catalog_featured: localData.catalog_featured, status: localData.status, + required_instance_access: localData.required_instance_access, + allowed_email_domains: localData.allowed_email_domains, translations: [ { title: localData.title, diff --git a/scripts/tutorial-uploader/lib/local-tutorial-data.ts b/scripts/tutorial-uploader/lib/local-tutorial-data.ts index b9bedb9049..3e1a36872c 100644 --- a/scripts/tutorial-uploader/lib/local-tutorial-data.ts +++ b/scripts/tutorial-uploader/lib/local-tutorial-data.ts @@ -26,6 +26,8 @@ export interface LocalTutorialData { topics: string[]; reading_time: number; catalog_featured: boolean; + required_instance_access?: string[]; + allowed_email_domains?: string[]; } function relativiseLocalPath( From 22edaf82acba8fe69acb8d170b942ebfa798ad6c Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Fri, 5 Jul 2024 19:33:33 +0100 Subject: [PATCH 63/68] Add back reverted change in merge --- tutorials/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tutorials/README.md b/tutorials/README.md index e3ff35b82d..5fe962b8da 100644 --- a/tutorials/README.md +++ b/tutorials/README.md @@ -3,7 +3,7 @@ This folder contains the content for our tutorials, which appear on [IBM Quantum Learning](https://learning.quantum.ibm.com/catalog/tutorials). -## Editing tutorials +## Editing and deploying existing tutorials Each tutorial has its own folder in `tutorials/`. Within that folder is the content notebook (ending in `.ipynb`) and an optional `images` folder. These From d57e7259e2ef25dae7d0775f390c76adb2052498 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Mon, 8 Jul 2024 19:32:28 +0100 Subject: [PATCH 64/68] Remove config file These tutorials no longer exist in this repo, this PR is now just to finalise the tool --- tutorials/learning-api.conf.yaml | 136 ------------------------------- 1 file changed, 136 deletions(-) delete mode 100644 tutorials/learning-api.conf.yaml diff --git a/tutorials/learning-api.conf.yaml b/tutorials/learning-api.conf.yaml deleted file mode 100644 index 859ea6f3f5..0000000000 --- a/tutorials/learning-api.conf.yaml +++ /dev/null @@ -1,136 +0,0 @@ -# This file contains all the metadata for tutorials controlled by this repo. -# -# WARNING: Do not change any "slugs" until appropriate redirects are set up. -# Speak to Abby Mitchell, Eric Arellano, or Frank Harkins to set up redirects. -# See /tutorials/README.md for instructions on adding a new tutorial. -# -# Description of values: -# -# title: The title of the page (top-level headings in the notebook are ignored) -# short_description: The description users will see in page previews (max 160 characters) -# slug: The last part of the URL; do not change this (see note above) -# local_path: The path to the tutorial folder, relative to `tutorials/` -# status: Can be "draft", "published", or "archived". Do not set to -# "archived" until appropriate redirects are set up. -# category: Can be "Workflow example" or "How-to" -# topics: List of topic names. Choose from -# * Chemistry -# * Dynamic circuits -# * Error mitigation -# * Optimization -# * Qiskit Patterns -# * Scheduling -# * Transpilation -# If these change, make sure to update scripts/tutorial-uploader/setup-for-testing.ts -# reading_time: Rough number of minutes needed to read the page -# catalog_featured: Whether the page should be in the featured section in the catalog. - -- title: CHSH inequality - short_description: > - In this tutorial, you will run an experiment on a quantum computer to - demonstrate the violation of the CHSH inequality with the Estimator - primitive. - slug: chsh-inequality # Do not change this - local_path: chsh-inequality - status: published - topics: ["Scheduling"] - category: Workflow example - reading_time: 20 - catalog_featured: true - -- title: Grover's algorithm - short_description: > - Demonstrating how to create Grover's algorithm with Qiskit Runtime - slug: grovers-algorithm # Do not change this - local_path: grovers-algorithm - status: published - category: Workflow example - topics: ["Scheduling"] - reading_time: 15 - catalog_featured: false - -- title: Quantum approximate optimization algorithm - short_description: > - Quantum approximate optimization algorithm is a hybrid algorithm to solve - combinatorial optimization problems. This tutorial uses Qiskit Runtime to - solve a simple max-cut problem. - slug: quantum-approximate-optimization-algorithm # Do not change this - local_path: quantum-approximate-optimization-algorithm - status: published - category: Workflow example - topics: ["Scheduling"] - reading_time: 20 - catalog_featured: true - -- title: Submit transpiled circuits - short_description: > - In this tutorial, we'll disable automatic transpilation and take you - through the full process of creating, transpiling, and submitting circuits. - slug: submit-transpiled-circuits # Do not change this - local_path: submitting-transpiled-circuits - status: published - category: How-to - topics: ["Transpilation"] - reading_time: 15 - catalog_featured: false - -- title: Variational quantum eigensolver - short_description: > - Variational quantum algorithms are hybrid-algorithms for observing the - utility of quantum computation on noisy, near-term IBM Quantum systems. - slug: variational-quantum-eigensolver # Do not change this - local_path: variational-quantum-eigensolver - status: published - category: Workflow example - topics: ["Scheduling"] - reading_time: 28 - catalog_featured: true - -- title: Repeat until success - short_description: > - This tutorial demonstrates IBM dynamic-circuits to use mid-circuit - measurements to produce a circuit that repeats until a successful syndrome - measurement. - slug: repeat-until-success # Do not change this - local_path: repeat-until-success - status: published - category: How-to - topics: ["Dynamic circuits"] - reading_time: 25 - catalog_featured: false - -- title: Build repetition codes - short_description: > - This tutorial demonstrates how to build basic repetition codes using IBM - dynamic circuits, an example of basic quantum error correction (QEC). - slug: build-repetition-codes # Do not change this - local_path: build-repetition-codes - status: published - category: How-to - topics: ["Dynamic circuits"] - reading_time: 15 - catalog_featured: false - -- title: Explore gates and circuits with the Quantum Composer - short_description: > - Learn how to use IBM Quantum Composer to build quantum circuits and run - them on IBM Quantum systems and simulators. - slug: explore-gates-and-circuits-with-the-quantum-composer # Do not change this - local_path: explore-composer - status: published - category: How-to - topics: [] - reading_time: 95 - catalog_featured: false - -- title: Combine error mitigation options with the Estimator primitive - short_description: > - Combine error mitigation options for utility-scale experiments using 100Q+ - IBM Quantum systems and the Qiskit Runtime Estimator primitive. - slug: combine-error-mitigation-options-with-the-estimator-primitive # Do not change this - local_path: combine-error-mitigation-options-with-the-estimator-primitive - status: published - category: Workflow example - topics: ["Error mitigation", "Qiskit Patterns"] - reading_time: 180 - catalog_featured: true From e0d0ea7f4510e79d9f29e9bdc5f0395e65bf8e72 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Mon, 8 Jul 2024 19:45:49 +0100 Subject: [PATCH 65/68] Add learning-api.conf.yaml for final notebook --- tutorials/learning-api.conf.yaml | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tutorials/learning-api.conf.yaml diff --git a/tutorials/learning-api.conf.yaml b/tutorials/learning-api.conf.yaml new file mode 100644 index 0000000000..313b0fdcf0 --- /dev/null +++ b/tutorials/learning-api.conf.yaml @@ -0,0 +1,38 @@ +# This file contains all the metadata for tutorials controlled by this repo. +# +# WARNING: Do not change any "slugs" until appropriate redirects are set up. +# Speak to Abby Mitchell, Eric Arellano, or Frank Harkins to set up redirects. +# See /tutorials/README.md for instructions on adding a new tutorial. +# +# Description of values: +# +# title: The title of the page (top-level headings in the notebook are ignored) +# short_description: The description users will see in page previews (max 160 characters) +# slug: The last part of the URL; do not change this (see note above) +# local_path: The path to the tutorial folder, relative to `tutorials/` +# status: Can be "draft", "published", or "archived". Do not set to +# "archived" until appropriate redirects are set up. +# category: Can be "Workflow example" or "How-to" +# topics: List of topic names. Choose from +# * Chemistry +# * Dynamic circuits +# * Error mitigation +# * Optimization +# * Qiskit Patterns +# * Scheduling +# * Transpilation +# If these change, make sure to update scripts/tutorial-uploader/setup-for-testing.ts +# reading_time: Rough number of minutes needed to read the page +# catalog_featured: Whether the page should be in the featured section in the catalog. + +- title: Solve utility-scale quantum optimization problems + short_description: > + Implement the Quantum Approximate Optimization Algorithm (QAOA) on a + simple max-cut problem, then scale the problem to over 100 qubits. + slug: qaoa-utility-scale # Do not change this + local_path: qaoa-utility-scale + status: published + topics: ["Optimization", "Qiskit Patterns"] + category: Workflow example + reading_time: 40 + catalog_featured: false From b74619fee9974e53c39c6d3b77b6e17318232e05 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Mon, 8 Jul 2024 21:33:47 +0100 Subject: [PATCH 66/68] Apply suggestions from code review Co-authored-by: Eric Arellano <14852634+Eric-Arellano@users.noreply.github.com> --- scripts/tutorial-uploader/lib/api.test.ts | 20 ++++++++++---------- tutorials/learning-api.conf.yaml | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/scripts/tutorial-uploader/lib/api.test.ts b/scripts/tutorial-uploader/lib/api.test.ts index 8561009696..73a8b33c13 100644 --- a/scripts/tutorial-uploader/lib/api.test.ts +++ b/scripts/tutorial-uploader/lib/api.test.ts @@ -41,6 +41,16 @@ if (/learning-api\.quantum\.ibm\.com/.test(process.env.LEARNING_API_URL!)) { describe("Tutorial uploader API", () => { const api = new API(); + beforeAll(async () => { + if (await api.getTutorialIdBySlug(MOCK_TUTORIAL.slug)) { + await api.deleteTutorial(MOCK_TUTORIAL.slug); + } + }); + + afterEach(async () => { + await api.deleteTutorial(MOCK_TUTORIAL.slug); + }); + test("upload new tutorial", async () => { expect(await api.getTutorialIdBySlug(MOCK_TUTORIAL.slug)).toBeNull(); @@ -141,14 +151,4 @@ describe("Tutorial uploader API", () => { }, ]); }); - - beforeAll(async () => { - if (await api.getTutorialIdBySlug(MOCK_TUTORIAL.slug)) { - await api.deleteTutorial(MOCK_TUTORIAL.slug); - } - }); - - afterEach(async () => { - await api.deleteTutorial(MOCK_TUTORIAL.slug); - }); }); diff --git a/tutorials/learning-api.conf.yaml b/tutorials/learning-api.conf.yaml index 313b0fdcf0..050463d424 100644 --- a/tutorials/learning-api.conf.yaml +++ b/tutorials/learning-api.conf.yaml @@ -1,7 +1,7 @@ # This file contains all the metadata for tutorials controlled by this repo. # # WARNING: Do not change any "slugs" until appropriate redirects are set up. -# Speak to Abby Mitchell, Eric Arellano, or Frank Harkins to set up redirects. +# Speak to the learning platform team to set up redirects. # See /tutorials/README.md for instructions on adding a new tutorial. # # Description of values: From 07a02b250733c3cd704b67da6c4d00a656159cd5 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Mon, 8 Jul 2024 21:44:17 +0100 Subject: [PATCH 67/68] Update info (and add note about reading time) Co-authored-by: Abby Mitchell <23662430+javabster@users.noreply.github.com> --- tutorials/learning-api.conf.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tutorials/learning-api.conf.yaml b/tutorials/learning-api.conf.yaml index 050463d424..f3a6b5ee13 100644 --- a/tutorials/learning-api.conf.yaml +++ b/tutorials/learning-api.conf.yaml @@ -22,7 +22,7 @@ # * Scheduling # * Transpilation # If these change, make sure to update scripts/tutorial-uploader/setup-for-testing.ts -# reading_time: Rough number of minutes needed to read the page +# reading_time: Rough number of minutes needed to read the page, set to 0 for no reading time # catalog_featured: Whether the page should be in the featured section in the catalog. - title: Solve utility-scale quantum optimization problems @@ -34,5 +34,5 @@ status: published topics: ["Optimization", "Qiskit Patterns"] category: Workflow example - reading_time: 40 + reading_time: 0 catalog_featured: false From bcf10e0fa8b20f4c2e6e7363a59738af734e0942 Mon Sep 17 00:00:00 2001 From: Frank Harkins Date: Tue, 9 Jul 2024 10:33:46 +0100 Subject: [PATCH 68/68] Change slug As per Abby's request, we will be overwriting the existing QAOA tutorial with this one. Co-authored-by: Abby Mitchell <23662430+javabster@users.noreply.github.com> --- tutorials/learning-api.conf.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tutorials/learning-api.conf.yaml b/tutorials/learning-api.conf.yaml index f3a6b5ee13..fc79a69632 100644 --- a/tutorials/learning-api.conf.yaml +++ b/tutorials/learning-api.conf.yaml @@ -29,7 +29,7 @@ short_description: > Implement the Quantum Approximate Optimization Algorithm (QAOA) on a simple max-cut problem, then scale the problem to over 100 qubits. - slug: qaoa-utility-scale # Do not change this + slug: quantum-approximate-optimization-algorithm # Do not change this local_path: qaoa-utility-scale status: published topics: ["Optimization", "Qiskit Patterns"]