diff --git a/news/changelog-1.7.md b/news/changelog-1.7.md index c4941e75be2..790e6e98977 100644 --- a/news/changelog-1.7.md +++ b/news/changelog-1.7.md @@ -41,6 +41,10 @@ All changes included in 1.7: - ([#12042](https://github.com/quarto-dev/quarto-cli/issues/12042)): Preserve Markdown content that follows YAML metadata in a `raw` .ipynb cell. +## `quarto inspect` + +- ([#12336](https://github.com/quarto-dev/quarto-cli/issues/12336)): Clean up transient files created by `quarto inspect`. + ## `html` format - ([#12277](https://github.com/quarto-dev/quarto-cli/pull/12277)): Provide light and dark plot and table renderings with `renderings: [light,dark]` diff --git a/src/project/project-context.ts b/src/project/project-context.ts index f8ec9345e49..8307f2af43a 100644 --- a/src/project/project-context.ts +++ b/src/project/project-context.ts @@ -69,6 +69,7 @@ import { ExecutionEngine, kMarkdownEngine } from "../execute/types.ts"; import { projectResourceFiles } from "./project-resources.ts"; import { + cleanupFileInformationCache, ignoreFieldsForProjectType, normalizeFormatYaml, projectConfigFile, @@ -267,6 +268,7 @@ export async function projectContext( const temp = createTempContext({ dir: join(dir, ".quarto", "temp"), }); + const fileInformationCache = new Map(); const result: ProjectContext = { resolveBrand: async (fileName?: string) => projectResolveBrand(result, fileName), @@ -286,7 +288,7 @@ export async function projectContext( }, dir, engines: [], - fileInformationCache: new Map(), + fileInformationCache, files: { input: [], }, @@ -312,6 +314,7 @@ export async function projectContext( diskCache: await createProjectCache(join(dir, ".quarto")), temp, cleanup: () => { + cleanupFileInformationCache(result); result.diskCache.close(); }, }; @@ -359,6 +362,7 @@ export async function projectContext( const temp = createTempContext({ dir: join(dir, ".quarto", "temp"), }); + const fileInformationCache = new Map(); const result: ProjectContext = { resolveBrand: async (fileName?: string) => projectResolveBrand(result, fileName), @@ -379,7 +383,7 @@ export async function projectContext( dir, config: projectConfig, engines: [], - fileInformationCache: new Map(), + fileInformationCache, files: { input: [], }, @@ -402,6 +406,7 @@ export async function projectContext( diskCache: await createProjectCache(join(dir, ".quarto")), temp, cleanup: () => { + cleanupFileInformationCache(result); result.diskCache.close(); }, }; @@ -427,6 +432,7 @@ export async function projectContext( configResolvers.shift(); } else if (force) { const temp = globalTempContext(); + const fileInformationCache = new Map(); const context: ProjectContext = { resolveBrand: async (fileName?: string) => projectResolveBrand(context, fileName), @@ -451,7 +457,7 @@ export async function projectContext( [kProjectOutputDir]: flags?.outputDir, }, }, - fileInformationCache: new Map(), + fileInformationCache, files: { input: [], }, @@ -474,6 +480,7 @@ export async function projectContext( diskCache: await createProjectCache(join(temp.baseDir, ".quarto")), temp, cleanup: () => { + cleanupFileInformationCache(context); context.diskCache.close(); }, }; diff --git a/src/project/project-shared.ts b/src/project/project-shared.ts index ec9bcfb619f..caf3be2147a 100644 --- a/src/project/project-shared.ts +++ b/src/project/project-shared.ts @@ -4,7 +4,7 @@ * Copyright (C) 2020-2022 Posit Software, PBC */ -import { existsSync } from "../deno_ral/fs.ts"; +import { existsSync, safeRemoveSync } from "../deno_ral/fs.ts"; import { dirname, isAbsolute, @@ -582,3 +582,27 @@ export async function projectResolveBrand( } } } + +export function cleanupFileInformationCache(project: ProjectContext) { + project.fileInformationCache.forEach((entry) => { + if (entry?.target?.data) { + const data = entry.target.data as { + transient?: boolean; + }; + if (data.transient && entry.target?.input) { + safeRemoveSync(entry.target?.input); + } + } + }); +} + +export async function withProjectCleanup( + project: ProjectContext, + fn: (project: ProjectContext) => Promise, +): Promise { + try { + return await fn(project); + } finally { + project.cleanup(); + } +} diff --git a/src/quarto-core/inspect.ts b/src/quarto-core/inspect.ts index 12f122422a0..1f914b6be5e 100644 --- a/src/quarto-core/inspect.ts +++ b/src/quarto-core/inspect.ts @@ -27,6 +27,7 @@ import { projectExcludeDirs, projectFileMetadata, projectResolveCodeCellsForFile, + withProjectCleanup, } from "../project/project-shared.ts"; import { normalizePath, safeExistsSync } from "../core/path.ts"; import { kExtensionDir } from "../extension/constants.ts"; @@ -42,9 +43,9 @@ import { InspectedFile, InspectedProjectConfig, } from "./inspect-types.ts"; -import { readAndValidateYamlFromFile } from "../core/schema/validated-yaml.ts"; import { validateDocumentFromSource } from "../core/schema/validate-document.ts"; import { error } from "../deno_ral/log.ts"; +import { ProjectContext } from "../project/types.ts"; export function isProjectConfig( config: InspectedConfig, @@ -60,171 +61,162 @@ export function isDocumentConfig( export async function inspectConfig(path?: string): Promise { path = path || Deno.cwd(); - if (!existsSync(path)) { throw new Error(`${path} not found`); } - // get quarto version - let version = quartoConfig.version(); - if (version === kLocalDevelopment) { - version = "99.9.9"; - } - - const nbContext = notebookContext(); - // get project context (if any) - const context = await projectContext(path, nbContext); - - const inspectedProjectConfig = async () => { - if (context?.config) { - const fileInformation: Record = {}; - for (const file of context.files.input) { - const engine = await fileExecutionEngine(file, undefined, context); - const src = await context.resolveFullMarkdownForFile(engine, file); - if (engine) { - const errors = await validateDocumentFromSource( - src, - engine.name, - error, - ); - if (errors.length) { - throw new Error(`${path} is not a valid Quarto input document`); - } - } - - await projectResolveCodeCellsForFile(context, engine, file); - await projectFileMetadata(context, file); - fileInformation[file] = { - includeMap: context.fileInformationCache.get(file)?.includeMap ?? [], - codeCells: context.fileInformationCache.get(file)?.codeCells ?? [], - metadata: context.fileInformationCache.get(file)?.metadata ?? {}, - }; - } - const config: InspectedProjectConfig = { - quarto: { - version, - }, - dir: context.dir, - engines: context.engines, - config: context.config, - files: context.files, - fileInformation, - }; - return config; - } else { - return undefined; - } - }; - const stat = Deno.statSync(path); if (stat.isDirectory) { - const config = await inspectedProjectConfig(); - if (config) { - return config; - } else { + const nbContext = notebookContext(); + const ctx = await projectContext(path, nbContext); + if (!ctx) { throw new Error(`${path} is not a Quarto project.`); } + const config = await withProjectCleanup(ctx, inspectProjectConfig); + if (!config) { + throw new Error(`${path} is not a Quarto project.`); + } + return config; } else { - const project = await projectContext(path, nbContext) || - (await singleFileProjectContext(path, nbContext)); - const engine = await fileExecutionEngine(path, undefined, project); + return await inspectDocumentConfig(path); + } +} + +const inspectProjectConfig = async (context: ProjectContext) => { + if (!context.config) { + return undefined; + } + const fileInformation: Record = {}; + for (const file of context.files.input) { + const engine = await fileExecutionEngine(file, undefined, context); + const src = await context.resolveFullMarkdownForFile(engine, file); if (engine) { - // partition markdown - const partitioned = await engine.partitionedMarkdown(path); - - // FIXME Why are we doing this twice? See "const project..." above - // get formats - const context = (await projectContext(path, nbContext)) || - (await singleFileProjectContext(path, nbContext)); - const src = await context.resolveFullMarkdownForFile(engine, path); - if (engine) { - const errors = await validateDocumentFromSource( - src, - engine.name, - error, - ); - if (errors.length) { - throw new Error(`${path} is not a valid Quarto input document`); - } - } - const formats = await withRenderServices( - nbContext, - (services: RenderServices) => - renderFormats(path!, services, "all", context), + const errors = await validateDocumentFromSource( + src, + engine.name, + error, ); - - // accumulate resources from formats then resolve them - const resourceConfig: string[] = Object.values(formats).reduce( - (resources: string[], format: Format) => { - resources = ld.uniq(resources.concat( - resourcesFromMetadata(format.metadata[kResources]), - )); - // include css specified in metadata - if (format.pandoc[kCss]) { - return ld.uniq(resources.concat( - resourcesFromMetadata(format.pandoc[kCss]), - )); - } else { - return resources; - } - }, + if (errors.length) { + throw new Error(`${file} is not a valid Quarto input document`); + } + } + await projectResolveCodeCellsForFile(context, engine, file); + await projectFileMetadata(context, file); + fileInformation[file] = { + includeMap: context.fileInformationCache.get(file)?.includeMap ?? [], - ); - - const fileDir = normalizePath(dirname(path)); - - const excludeDirs = context ? projectExcludeDirs(context) : []; + codeCells: context.fileInformationCache.get(file)?.codeCells ?? [], + metadata: context.fileInformationCache.get(file)?.metadata ?? {}, + }; + } + const config: InspectedProjectConfig = { + quarto: { + version: quartoConfig.version(), + }, + dir: context.dir, + engines: context.engines, + config: context.config, + files: context.files, + fileInformation, + }; + return config; +}; - const resources = await resolveResources( - context ? context.dir : fileDir, - fileDir, - excludeDirs, - partitioned.markdown, - resourceConfig, +const inspectDocumentConfig = async (path: string) => { + const nbContext = notebookContext(); + const project = await projectContext(path, nbContext) || + (await singleFileProjectContext(path, nbContext)); + return withProjectCleanup(project, async (project) => { + const engine = await fileExecutionEngine(path, undefined, project); + if (!engine) { + throw new Error(`${path} is not a valid Quarto input document`); + } + // partition markdown + const partitioned = await engine.partitionedMarkdown(path); + const context = project; + const src = await context.resolveFullMarkdownForFile(engine, path); + if (engine) { + const errors = await validateDocumentFromSource( + src, + engine.name, + error, ); - - // if there is an _extensions dir then add it - const extensions = join(fileDir, kExtensionDir); - if (safeExistsSync(extensions)) { - resources.push( - ...extensionFilesFromDirs([extensions]).map((file) => - relative(fileDir, file) - ), - ); + if (errors.length) { + throw new Error(`${path} is not a valid Quarto input document`); } + } + const formats = await withRenderServices( + nbContext, + (services: RenderServices) => + renderFormats(path!, services, "all", context), + ); + // accumulate resources from formats then resolve them + const resourceConfig: string[] = Object.values(formats).reduce( + (resources: string[], format: Format) => { + resources = ld.uniq(resources.concat( + resourcesFromMetadata(format.metadata[kResources]), + )); + // include css specified in metadata + if (format.pandoc[kCss]) { + return ld.uniq(resources.concat( + resourcesFromMetadata(format.pandoc[kCss]), + )); + } else { + return resources; + } + }, + [], + ); + + const fileDir = normalizePath(dirname(path)); + const excludeDirs = context ? projectExcludeDirs(context) : []; + const resources = await resolveResources( + context ? context.dir : fileDir, + fileDir, + excludeDirs, + partitioned.markdown, + resourceConfig, + ); + + // if there is an _extensions dir then add it + const extensions = join(fileDir, kExtensionDir); + if (safeExistsSync(extensions)) { + resources.push( + ...extensionFilesFromDirs([extensions]).map((file) => + relative(fileDir, file) + ), + ); + } - await context.resolveFullMarkdownForFile(engine, path); - await projectResolveCodeCellsForFile(context, engine, path); - await projectFileMetadata(context, path); - const fileInformation = context.fileInformationCache.get(path); - - // data to write - const config: InspectedDocumentConfig = { - quarto: { - version, + await context.resolveFullMarkdownForFile(engine, path); + await projectResolveCodeCellsForFile(context, engine, path); + await projectFileMetadata(context, path); + const fileInformation = context.fileInformationCache.get(path); + + // data to write + const config: InspectedDocumentConfig = { + quarto: { + version: quartoConfig.version(), + }, + engines: [engine.name], + formats, + resources, + fileInformation: { + [path]: { + includeMap: fileInformation?.includeMap ?? [], + codeCells: fileInformation?.codeCells ?? [], + metadata: fileInformation?.metadata ?? {}, }, - engines: [engine.name], - formats, - resources, - fileInformation: { - [path]: { - includeMap: fileInformation?.includeMap ?? [], - codeCells: fileInformation?.codeCells ?? [], - metadata: fileInformation?.metadata ?? {}, - }, - }, - }; + }, + }; - // if there is a project then add it - if (context?.config) { - config.project = await inspectedProjectConfig(); - } - return config; - } else { - throw new Error(`${path} is not a valid Quarto input document`); + // if there is a project then add it + if (context?.config) { + config.project = await inspectProjectConfig(context); } - } -} + return config; + }); +}; async function resolveResources( rootDir: string, diff --git a/tests/docs/inspect/cleanup-issue-12336/_quarto.yml b/tests/docs/inspect/cleanup-issue-12336/_quarto.yml new file mode 100644 index 00000000000..5c796d9c1a7 --- /dev/null +++ b/tests/docs/inspect/cleanup-issue-12336/_quarto.yml @@ -0,0 +1,5 @@ +project: + title: "cleanup-bug" + + + diff --git a/tests/docs/inspect/cleanup-issue-12336/cleanup-bug.qmd b/tests/docs/inspect/cleanup-issue-12336/cleanup-bug.qmd new file mode 100644 index 00000000000..33753084025 --- /dev/null +++ b/tests/docs/inspect/cleanup-issue-12336/cleanup-bug.qmd @@ -0,0 +1,8 @@ +--- +title: "cleanup-bug" +engine: jupyter +--- + +## Quarto + +Quarto enables you to weave together content and executable code into a finished document. To learn more about Quarto see . diff --git a/tests/smoke/inspect/inspect-cleanup.test.ts b/tests/smoke/inspect/inspect-cleanup.test.ts new file mode 100644 index 00000000000..c6891ec225d --- /dev/null +++ b/tests/smoke/inspect/inspect-cleanup.test.ts @@ -0,0 +1,40 @@ +/* +* inspect-cleanup.test.ts +* +* Copyright (C) 2020-2025 Posit Software, PBC +* +*/ + +import { existsSync } from "../../../src/deno_ral/fs.ts"; +import { } from "../../../src/project/types.ts"; +import { + ExecuteOutput, + testQuartoCmd, +} from "../../test.ts"; +import { assert } from "testing/asserts"; + +(() => { + const input = "docs/inspect/cleanup-issue-12336/cleanup-bug.qmd"; + const output = "docs/inspect/cleanup-issue-12336/cleanup-bug.json"; + testQuartoCmd( + "inspect", + [input, output], + [ + { + name: "inspect-code-cells", + verify: async (outputs: ExecuteOutput[]) => { + assert(existsSync(output)); + const json = JSON.parse(Deno.readTextFileSync(output)); + assert(json.fileInformation["docs/inspect/cleanup-issue-12336/cleanup-bug.qmd"].metadata.engine === "jupyter"); + assert(!existsSync("docs/inspect/cleanup-issue-12336/cleanup-bug.quarto_ipynb")); + } + } + ], + { + teardown: async () => { + if (existsSync(output)) { + Deno.removeSync(output); + } + } + }, +)})(); \ No newline at end of file