Skip to content

Commit

Permalink
feat: add optional outListingFile config property that copies model…
Browse files Browse the repository at this point in the history
… listing JSON file as post-generate step (#493)

Fixes #492
  • Loading branch information
chrispcampbell authored Jun 4, 2024
1 parent dc60d0a commit af4abbe
Show file tree
Hide file tree
Showing 11 changed files with 135 additions and 20 deletions.
16 changes: 4 additions & 12 deletions examples/house-game/sde.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,11 @@ export async function config() {
}
},

plugins: [
// Copy the generated model listing to the app so that it can be loaded
// at runtime
{
postGenerate: async context => {
const srcPath = joinPath(context.config.prepDir, 'build', 'processed.json')
const dstName = 'listing.json'
const stagedFilePath = context.prepareStagedFile('model', dstName, generatedFilePath(), dstName)
await copyFile(srcPath, stagedFilePath)
return true
}
},
// Copy the generated model listing to the app so that it can be loaded
// at runtime
outListingFile: generatedFilePath('listing.json'),

plugins: [
// Generate a `worker.js` file that runs the generated model in a worker
workerPlugin({
outputPaths: [generatedFilePath('worker.js')]
Expand Down
1 change: 1 addition & 0 deletions packages/build/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
dist
docs/entry.md
sde-prep
tests/build-prod/outputs
9 changes: 9 additions & 0 deletions packages/build/docs/interfaces/ResolvedConfig.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,12 @@ ___
The code format to generate. If 'js', the model will be compiled to a JavaScript
file. If 'c', the model will be compiled to a C file (in which case an additional
plugin will be needed to convert the C code to a WebAssembly module).

___

### outListingFile

`Optional` **outListingFile**: `string`

The absolute path to the JSON file that will be written by the build process that
lists all dimensions and variables in the model.
10 changes: 10 additions & 0 deletions packages/build/docs/interfaces/UserConfig.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ defaults to 'js'.

___

### outListingFile

`Optional` **outListingFile**: `string`

If defined, the build process will write a JSON file to the provided path that lists
all dimensions and variables in the model. This can be an absolute path, or if it
is a relative path it will be resolved relative to the `rootDir` for the project.

___

### plugins

`Optional` **plugins**: [`Plugin`](Plugin.md)[]
Expand Down
6 changes: 6 additions & 0 deletions packages/build/src/_shared/resolved-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ export interface ResolvedConfig {
*/
genFormat: 'js' | 'c'

/**
* The absolute path to the JSON file that will be written by the build process that
* lists all dimensions and variables in the model.
*/
outListingFile?: string

/**
* The path to the `@sdeverywhere/cli` package. This is currently only used to get
* access to the files in the `src/c` directory.
Expand Down
13 changes: 12 additions & 1 deletion packages/build/src/build/impl/gen-model.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2022 Climate Interactive / New Venture Fund

import { copyFile, readdir, readFile, writeFile } from 'fs/promises'
import { join as joinPath } from 'path'
import { basename, dirname, join as joinPath } from 'path'

import { log } from '../../_shared/log'

Expand Down Expand Up @@ -87,6 +87,17 @@ export async function generateModel(context: BuildContext, plugins: Plugin[]): P
await copyFile(generatedCodePath, stagedOutputJsPath)
}

if (config.outListingFile) {
// Copy the model listing file
const srcListingJsonPath = joinPath(prepDir, 'build', 'processed.json')
const stagedDir = 'model'
const stagedFile = 'listing.json'
const dstDir = dirname(config.outListingFile)
const dstFile = basename(config.outListingFile)
const stagedListingJsonPath = context.prepareStagedFile(stagedDir, stagedFile, dstDir, dstFile)
await copyFile(srcListingJsonPath, stagedListingJsonPath)
}

const t1 = performance.now()
const elapsed = ((t1 - t0) / 1000).toFixed(1)
log('info', `Done generating model (${elapsed}s)`)
Expand Down
14 changes: 13 additions & 1 deletion packages/build/src/config/config-loader.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2022 Climate Interactive / New Venture Fund

import { existsSync, lstatSync, mkdirSync } from 'fs'
import { dirname, join as joinPath, relative, resolve as resolvePath } from 'path'
import { dirname, isAbsolute, join as joinPath, relative, resolve as resolvePath } from 'path'
import { fileURLToPath } from 'url'

import type { Result } from 'neverthrow'
Expand Down Expand Up @@ -171,6 +171,17 @@ function resolveUserConfig(
throw new Error(`The configured genFormat value is invalid; must be either 'js' or 'c'`)
}

// Validate the out listing file, if defined
let outListingFile: string
if (userConfig.outListingFile) {
// Get the absolute path of the output file
if (isAbsolute(userConfig.outListingFile)) {
outListingFile = userConfig.outListingFile
} else {
outListingFile = resolvePath(rootDir, userConfig.outListingFile)
}
}

return {
mode,
rootDir,
Expand All @@ -179,6 +190,7 @@ function resolveUserConfig(
modelInputPaths,
watchPaths,
genFormat,
outListingFile,
sdeDir,
sdeCmdPath
}
Expand Down
7 changes: 7 additions & 0 deletions packages/build/src/config/user-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ export interface UserConfig {
*/
genFormat?: 'js' | 'c'

/**
* If defined, the build process will write a JSON file to the provided path that lists
* all dimensions and variables in the model. This can be an absolute path, or if it
* is a relative path it will be resolved relative to the `rootDir` for the project.
*/
outListingFile?: string

/**
* The array of plugins that are used to customize the build process. These will be
* executed in the order defined here.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
// Copyright (c) 2022 Climate Interactive / New Venture Fund

import { resolve as resolvePath } from 'path'
import { rmSync } from 'node:fs'
import { resolve as resolvePath } from 'node:path'

import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'

import type { ModelSpec, UserConfig } from '../../src'
import { build } from '../../src'

import { buildOptions } from '../_shared/build-options'
import {} from 'vitest'

const modelSpec: ModelSpec = {
inputs: [{ varName: 'Y', defaultValue: 0, minValue: -10, maxValue: 10 }],
Expand All @@ -16,6 +18,11 @@ const modelSpec: ModelSpec = {
}

describe('build in development mode', () => {
beforeEach(() => {
const prepDir = resolvePath(__dirname, 'sde-prep')
rmSync(prepDir, { recursive: true, force: true })
})

it('should return undefined exit code', async () => {
const userConfig: UserConfig = {
rootDir: resolvePath(__dirname, '..'),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Copyright (c) 2022 Climate Interactive / New Venture Fund

import { resolve as resolvePath } from 'path'
import { existsSync, rmSync } from 'node:fs'
import { join as joinPath, resolve as resolvePath } from 'node:path'

import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'

import type { ModelSpec, Plugin, UserConfig } from '../../src'
import { build } from '../../src'
Expand Down Expand Up @@ -56,6 +57,59 @@ const plugin = (num: number, calls: string[]) => {
}

describe('build in production mode', () => {
beforeEach(() => {
const prepDir = resolvePath(__dirname, 'sde-prep')
rmSync(prepDir, { recursive: true, force: true })

const outputsDir = resolvePath(__dirname, 'outputs')
rmSync(outputsDir, { recursive: true, force: true })
})

it('should write listing.json file (when absolute path is provided)', async () => {
const userConfig: UserConfig = {
genFormat: 'c',
rootDir: resolvePath(__dirname, '..'),
prepDir: resolvePath(__dirname, 'sde-prep'),
modelFiles: [resolvePath(__dirname, '..', '_shared', 'sample.mdl')],
// Note that `outListingFile` is specified with an absolute path here
outListingFile: resolvePath(__dirname, 'outputs', 'listing.json'),
modelSpec: async () => {
return modelSpec
}
}

const result = await build('production', buildOptions(userConfig))
if (result.isErr()) {
throw new Error('Expected ok result but got: ' + result.error.message)
}

expect(result.value.exitCode).toBe(0)
expect(existsSync(resolvePath(__dirname, 'outputs', 'listing.json'))).toBe(true)
})

it('should write listing.json file (when relative path is provided)', async () => {
const userConfig: UserConfig = {
genFormat: 'c',
rootDir: resolvePath(__dirname, '..'),
prepDir: resolvePath(__dirname, 'sde-prep'),
modelFiles: [resolvePath(__dirname, '..', '_shared', 'sample.mdl')],
// Note that `outListingFile` is specified with a relative path here, which
// will be resolved relative to `rootDir`
outListingFile: joinPath('build-prod', 'outputs', 'listing.json'),
modelSpec: async () => {
return modelSpec
}
}

const result = await build('production', buildOptions(userConfig))
if (result.isErr()) {
throw new Error('Expected ok result but got: ' + result.error.message)
}

expect(result.value.exitCode).toBe(0)
expect(existsSync(resolvePath(__dirname, 'outputs', 'listing.json'))).toBe(true)
})

it('should skip certain callbacks if model files array is empty', async () => {
const calls: string[] = []

Expand Down
10 changes: 8 additions & 2 deletions packages/build/tests/config/config.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
// Copyright (c) 2022 Climate Interactive / New Venture Fund

import { join as joinPath } from 'path'
import { rmSync } from 'node:fs'
import { join as joinPath } from 'node:path'

import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'

import { build } from '../../src'

import { buildOptions } from '../_shared/build-options'

describe('build config file loading', () => {
beforeEach(() => {
const prepDir = joinPath(__dirname, 'sde-prep')
rmSync(prepDir, { recursive: true, force: true })
})

it('should fail if config file cannot be found', async () => {
const configPath = joinPath(__dirname, 'sde.unknown.js')
const result = await build('production', buildOptions(configPath))
Expand Down

0 comments on commit af4abbe

Please sign in to comment.