Skip to content

Commit 559911c

Browse files
Bundle watcher (#892)
## Changes * Autogenerate BundleSchema.d.ts file (from `databricks bundle schema` command) to provide typing for the parsed bundle object. * Add a watcher for all DABs configuration files * Add BundleFileSet object, to handle operations on the full bundle file tree. * Used these to add autocompletion for all files included in the root bundle.yaml. **NOTE:** This PR currently parses yamls in code. We want to move that to databricks CLI. The implementation can is modular and can be swapped with the CLI calls once we have those features in the CLI. ## Tests * Unit tests * Manual
1 parent 5b0f6e8 commit 559911c

File tree

13 files changed

+6397
-28
lines changed

13 files changed

+6397
-28
lines changed

packages/databricks-vscode/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,7 @@
671671
"package:cli:link": "rm -f ./bin/databricks && mkdir -p bin && ln -s ../../../../cli/cli bin/databricks",
672672
"package:wrappers:write": "ts-node ./scripts/writeIpynbWrapper.ts -s ./resources/python/notebook.workflow-wrapper.py -o ./resources/python/generated/notebook.workflow-wrapper.json",
673673
"package:jupyter-init-script:write": "ts-node ./scripts/writeJupyterInitFileWithVersion.ts",
674+
"package:bundle-schema:write": "yarn package:cli:fetch && ts-node ./scripts/writeBundleSchema.ts ./bin/databricks ./src/bundle/BundleSchema.d.ts",
674675
"package:compile": "yarn run esbuild:base",
675676
"package:copy-webview-toolkit": "cp ./node_modules/@vscode/webview-ui-toolkit/dist/toolkit.js ./out/toolkit.js",
676677
"esbuild:base": "esbuild ./src/extension.ts --bundle --outfile=out/extension.js --external:vscode --format=cjs --platform=node --sourcemap --target=es2019",
@@ -692,6 +693,7 @@
692693
"dependencies": {
693694
"@databricks/databricks-sdk": "file:../../vendor/databricks-sdk.tgz",
694695
"@databricks/databricks-vscode-types": "workspace:^",
696+
"@types/lodash": "^4.14.199",
695697
"@vscode/debugadapter": "^1.61.0",
696698
"@vscode/extension-telemetry": "^0.8.1",
697699
"@vscode/webview-ui-toolkit": "^1.2.2",
@@ -708,7 +710,7 @@
708710
"@types/bcryptjs": "^2.4.2",
709711
"@types/chai": "^4.3.5",
710712
"@types/fs-extra": "^11.0.1",
711-
"@types/mocha": "^10.0.1",
713+
"@types/mocha": "^10.0.2",
712714
"@types/mock-require": "^2.0.1",
713715
"@types/node": "^20.4.2",
714716
"@types/sinonjs__fake-timers": "^8.1.2",
@@ -728,7 +730,8 @@
728730
"esbuild": "^0.19.4",
729731
"eslint": "^8.51.0",
730732
"fs-extra": "^11.1.1",
731-
"glob": "^10.3.3",
733+
"glob": "^10.3.10",
734+
"json-schema-to-typescript": "^13.1.1",
732735
"mocha": "^10.2.0",
733736
"mock-require": "^3.0.3",
734737
"nyc": "^15.1.0",
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* This script generates the BundleSchema.d.ts file from the bundle schema.
3+
* It MUST be run after a yarn package:cli:fetch
4+
*/
5+
6+
import * as cp from "child_process";
7+
import * as fs from "fs";
8+
import {compileFromFile} from "json-schema-to-typescript";
9+
import {tmpdir} from "os";
10+
import path from "path";
11+
import {argv} from "process";
12+
13+
const output = cp.execFileSync(argv[2], ["bundle", "schema"]);
14+
15+
const tmpFile = path.join(tmpdir(), "BundleSchema.json");
16+
fs.writeFileSync(tmpFile, output);
17+
18+
// eslint-disable-next-line no-console
19+
console.log("Bundle schema written to", tmpFile);
20+
21+
// compile from file
22+
compileFromFile(tmpFile).then((ts) => fs.writeFileSync(argv[3], ts));
23+
24+
// eslint-disable-next-line no-console
25+
console.log("BundleSchema.d.ts written to", argv[3]);
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import {Uri} from "vscode";
2+
import {BundleFileSet} from "./BundleFileSet";
3+
import {expect} from "chai";
4+
import path from "path";
5+
import * as tmp from "tmp-promise";
6+
import * as fs from "fs/promises";
7+
import {BundleSchema} from "./BundleSchema";
8+
import * as yaml from "yaml";
9+
10+
describe(__filename, async function () {
11+
let tmpdir: tmp.DirectoryResult;
12+
13+
beforeEach(async () => {
14+
tmpdir = await tmp.dir({unsafeCleanup: true});
15+
});
16+
17+
afterEach(async () => {
18+
await tmpdir.cleanup();
19+
});
20+
21+
it("should return the correct absolute path", () => {
22+
const tmpdirUri = Uri.file(tmpdir.path);
23+
24+
const bundleFileSet = new BundleFileSet(tmpdirUri);
25+
26+
expect(bundleFileSet.getAbsolutePath("test.txt").fsPath).to.equal(
27+
path.join(tmpdirUri.fsPath, "test.txt")
28+
);
29+
30+
expect(
31+
bundleFileSet.getAbsolutePath(Uri.file("test.txt")).fsPath
32+
).to.equal(path.join(tmpdirUri.fsPath, "test.txt"));
33+
});
34+
35+
it("should find the correct root bundle yaml", async () => {
36+
const tmpdirUri = Uri.file(tmpdir.path);
37+
const bundleFileSet = new BundleFileSet(tmpdirUri);
38+
39+
expect(await bundleFileSet.getRootFile()).to.be.undefined;
40+
41+
await fs.writeFile(path.join(tmpdirUri.fsPath, "bundle.yaml"), "");
42+
43+
expect((await bundleFileSet.getRootFile())?.fsPath).to.equal(
44+
path.join(tmpdirUri.fsPath, "bundle.yaml")
45+
);
46+
});
47+
48+
it("should return undefined if more than one root bundle yaml is found", async () => {
49+
const tmpdirUri = Uri.file(tmpdir.path);
50+
const bundleFileSet = new BundleFileSet(tmpdirUri);
51+
52+
await fs.writeFile(path.join(tmpdirUri.fsPath, "bundle.yaml"), "");
53+
await fs.writeFile(path.join(tmpdirUri.fsPath, "databricks.yaml"), "");
54+
55+
expect(await bundleFileSet.getRootFile()).to.be.undefined;
56+
});
57+
58+
describe("file listing", async () => {
59+
beforeEach(async () => {
60+
const rootBundleData: BundleSchema = {
61+
include: [
62+
"included.yaml",
63+
path.join("includes", "**", "*.yaml"),
64+
],
65+
};
66+
67+
await fs.writeFile(
68+
path.join(tmpdir.path, "bundle.yaml"),
69+
yaml.stringify(rootBundleData)
70+
);
71+
72+
await fs.writeFile(path.join(tmpdir.path, "included.yaml"), "");
73+
await fs.writeFile(path.join(tmpdir.path, "notIncluded.yaml"), "");
74+
await fs.mkdir(path.join(tmpdir.path, "includes"));
75+
await fs.writeFile(
76+
path.join(tmpdir.path, "includes", "included.yaml"),
77+
""
78+
);
79+
});
80+
81+
it("should return correct included files", async () => {
82+
const tmpdirUri = Uri.file(tmpdir.path);
83+
const bundleFileSet = new BundleFileSet(tmpdirUri);
84+
85+
expect(await bundleFileSet.getIncludedFilesGlob()).to.equal(
86+
`{included.yaml,${path.join("includes", "**", "*.yaml")}}`
87+
);
88+
89+
const actual = (await bundleFileSet.getIncludedFiles())?.map(
90+
(v) => v.fsPath
91+
);
92+
const expected = [
93+
Uri.file(path.join(tmpdirUri.fsPath, "included.yaml")),
94+
Uri.file(
95+
path.join(tmpdirUri.fsPath, "includes", "included.yaml")
96+
),
97+
].map((v) => v.fsPath);
98+
expect(actual).to.deep.equal(expected);
99+
});
100+
101+
it("should return all bundle files", async () => {
102+
const tmpdirUri = Uri.file(tmpdir.path);
103+
const bundleFileSet = new BundleFileSet(tmpdirUri);
104+
105+
const actual = (await bundleFileSet.allFiles()).map(
106+
(v) => v.fsPath
107+
);
108+
const expected = [
109+
Uri.joinPath(tmpdirUri, "bundle.yaml"),
110+
Uri.joinPath(tmpdirUri, "included.yaml"),
111+
Uri.joinPath(tmpdirUri, "includes", "included.yaml"),
112+
].map((v) => v.fsPath);
113+
expect(actual).to.deep.equal(expected);
114+
});
115+
116+
it("isRootBundleFile should return true only for root bundle file", async () => {
117+
const tmpdirUri = Uri.file(tmpdir.path);
118+
const bundleFileSet = new BundleFileSet(tmpdirUri);
119+
120+
const possibleRoots = [
121+
"bundle.yaml",
122+
"bundle.yml",
123+
"databricks.yaml",
124+
"databricks.yml",
125+
];
126+
127+
for (const root of possibleRoots) {
128+
expect(
129+
bundleFileSet.isRootBundleFile(
130+
Uri.file(path.join(tmpdirUri.fsPath, root))
131+
)
132+
).to.be.true;
133+
}
134+
135+
expect(
136+
bundleFileSet.isRootBundleFile(
137+
Uri.file(path.join(tmpdirUri.fsPath, "bundle-wrong.yaml"))
138+
)
139+
).to.be.false;
140+
});
141+
142+
it("isIncludedBundleFile should return true only for included files", async () => {
143+
const tmpdirUri = Uri.file(tmpdir.path);
144+
const bundleFileSet = new BundleFileSet(tmpdirUri);
145+
146+
expect(
147+
await bundleFileSet.isIncludedBundleFile(
148+
Uri.file(path.join(tmpdirUri.fsPath, "included.yaml"))
149+
)
150+
).to.be.true;
151+
152+
expect(
153+
await bundleFileSet.isIncludedBundleFile(
154+
Uri.file(
155+
path.join(tmpdirUri.fsPath, "includes", "included.yaml")
156+
)
157+
)
158+
).to.be.true;
159+
160+
expect(
161+
await bundleFileSet.isIncludedBundleFile(
162+
Uri.file(path.join(tmpdirUri.fsPath, "notIncluded.yaml"))
163+
)
164+
).to.be.false;
165+
});
166+
167+
it("isBundleFile should return true only for bundle files", async () => {
168+
const tmpdirUri = Uri.file(tmpdir.path);
169+
const bundleFileSet = new BundleFileSet(tmpdirUri);
170+
171+
const possibleBundleFiles = [
172+
"bundle.yaml",
173+
"bundle.yml",
174+
"databricks.yaml",
175+
"databricks.yml",
176+
"included.yaml",
177+
path.join("includes", "included.yaml"),
178+
];
179+
180+
for (const bundleFile of possibleBundleFiles) {
181+
expect(
182+
await bundleFileSet.isBundleFile(
183+
Uri.file(path.join(tmpdirUri.fsPath, bundleFile))
184+
)
185+
).to.be.true;
186+
}
187+
188+
expect(
189+
await bundleFileSet.isBundleFile(
190+
Uri.file(path.join(tmpdirUri.fsPath, "notIncluded.yaml"))
191+
)
192+
).to.be.false;
193+
});
194+
});
195+
});
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import {Uri} from "vscode";
2+
import * as glob from "glob";
3+
import {merge} from "lodash";
4+
import * as yaml from "yaml";
5+
import path from "path";
6+
import {BundleSchema} from "./BundleSchema";
7+
import {readFile} from "fs/promises";
8+
import {CachedValue} from "../locking/CachedValue";
9+
import minimatch from "minimatch";
10+
11+
export async function parseBundleYaml(file: Uri) {
12+
const data = yaml.parse(await readFile(file.fsPath, "utf-8"));
13+
return data as BundleSchema;
14+
}
15+
16+
function toGlobPath(path: string) {
17+
if (process.platform === "win32") {
18+
return path.replace(/\\/g, "/");
19+
}
20+
return path;
21+
}
22+
export class BundleFileSet {
23+
private rootFilePattern: string = "{bundle,databricks}.{yaml,yml}";
24+
private _mergedBundle: CachedValue<BundleSchema> =
25+
new CachedValue<BundleSchema>(async () => {
26+
let bundle = {};
27+
await this.forEach(async (data) => {
28+
bundle = merge(bundle, data);
29+
});
30+
return bundle as BundleSchema;
31+
});
32+
33+
constructor(private readonly workspaceRoot: Uri) {}
34+
35+
getAbsolutePath(path: string | Uri) {
36+
if (typeof path === "string") {
37+
return Uri.joinPath(this.workspaceRoot, path);
38+
}
39+
return Uri.joinPath(this.workspaceRoot, path.fsPath);
40+
}
41+
42+
async getRootFile() {
43+
const rootFile = await glob.glob(
44+
toGlobPath(this.getAbsolutePath(this.rootFilePattern).fsPath),
45+
{nocase: process.platform === "win32"}
46+
);
47+
if (rootFile.length !== 1) {
48+
return undefined;
49+
}
50+
return Uri.file(rootFile[0]);
51+
}
52+
53+
async getIncludedFilesGlob() {
54+
const rootFile = await this.getRootFile();
55+
if (rootFile === undefined) {
56+
return undefined;
57+
}
58+
const bundle = await parseBundleYaml(Uri.file(rootFile.fsPath));
59+
const includedFilesGlob =
60+
bundle?.include === undefined || bundle?.include.length === 0
61+
? undefined
62+
: `{${bundle.include?.join(",")}}`;
63+
64+
return includedFilesGlob;
65+
}
66+
67+
async getIncludedFiles() {
68+
const includedFilesGlob = await this.getIncludedFilesGlob();
69+
if (includedFilesGlob !== undefined) {
70+
return (
71+
await glob.glob(
72+
toGlobPath(
73+
path.join(this.workspaceRoot.fsPath, includedFilesGlob)
74+
),
75+
{nocase: process.platform === "win32"}
76+
)
77+
).map((i) => Uri.file(i));
78+
}
79+
}
80+
81+
async allFiles() {
82+
const rootFile = await this.getRootFile();
83+
if (rootFile === undefined) {
84+
return [];
85+
}
86+
87+
return [rootFile, ...((await this.getIncludedFiles()) ?? [])];
88+
}
89+
90+
async findFileWithPredicate(predicate: (file: Uri) => Promise<boolean>) {
91+
const matchedFiles: Uri[] = [];
92+
for (const file of await this.allFiles()) {
93+
if (await predicate(file)) {
94+
matchedFiles.push(file);
95+
}
96+
}
97+
return matchedFiles;
98+
}
99+
100+
async forEach(f: (data: BundleSchema, file: Uri) => Promise<void>) {
101+
for (const file of await this.allFiles()) {
102+
await f(await parseBundleYaml(file), file);
103+
}
104+
}
105+
106+
isRootBundleFile(e: Uri) {
107+
return minimatch(
108+
e.fsPath,
109+
toGlobPath(this.getAbsolutePath(this.rootFilePattern).fsPath)
110+
);
111+
}
112+
113+
async isIncludedBundleFile(e: Uri) {
114+
let includedFilesGlob = await this.getIncludedFilesGlob();
115+
if (includedFilesGlob === undefined) {
116+
return false;
117+
}
118+
includedFilesGlob = this.getAbsolutePath(includedFilesGlob).fsPath;
119+
return minimatch(e.fsPath, toGlobPath(includedFilesGlob));
120+
}
121+
122+
async isBundleFile(e: Uri) {
123+
return this.isRootBundleFile(e) || (await this.isIncludedBundleFile(e));
124+
}
125+
126+
async invalidateMergedBundleCache() {
127+
await this._mergedBundle.invalidate();
128+
}
129+
130+
get mergedBundle() {
131+
return this._mergedBundle.value;
132+
}
133+
}

0 commit comments

Comments
 (0)