Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export * as setBlockResource from './set-block-resource/index';
export * as mapListResource from './map-list-resource/index';
export * as ephemeralCachedSecret from './ephemeral-cached-secret/index';
export * as provider from './provider/index';
export * as providerFunctions from './provider-functions/index';
export * as functions from './functions/index';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

small change, big impact.

Too bad my out-of-repo harnesses didn't really validate Python consumption of provider functions - the real issue is correctly being addressed now with raising the version ceiling of what we test of course

but if I build demo harnesses I should include JSII cross compiled library testing
https://github.com/sakul-learning/cdktn-provider-features-demo


"
`;
Expand All @@ -27,7 +27,7 @@ Object.defineProperty(exports, 'setBlockResource', { get: function () { return r
Object.defineProperty(exports, 'mapListResource', { get: function () { return require('./map-list-resource'); } });
Object.defineProperty(exports, 'ephemeralCachedSecret', { get: function () { return require('./ephemeral-cached-secret'); } });
Object.defineProperty(exports, 'provider', { get: function () { return require('./provider'); } });
Object.defineProperty(exports, 'providerFunctions', { get: function () { return require('./provider-functions'); } });
Object.defineProperty(exports, 'functions', { get: function () { return require('./functions'); } });

"
`;
Original file line number Diff line number Diff line change
Expand Up @@ -238,14 +238,14 @@ export class ExampleProviderFunctions {

exports[`generate provider functions covering variadic parameters, primitive/list returns, and a 'default' parameter name matches the snapshot: provider-index 1`] = `
"// generated by cdktn get
export * as providerFunctions from './provider-functions/index';
export * as functions from './functions/index';

"
`;

exports[`generate provider functions covering variadic parameters, primitive/list returns, and a 'default' parameter name matches the snapshot: provider-lazy-index 1`] = `
"// generated by cdktn get
Object.defineProperty(exports, 'providerFunctions', { get: function () { return require('./provider-functions'); } });
Object.defineProperty(exports, 'functions', { get: function () { return require('./functions'); } });

"
`;
Expand All @@ -254,7 +254,7 @@ exports[`generate provider functions for the time provider (real terraform 1.15.
"// generated by cdktn get
export * as staticResource from './static-resource/index';
export * as provider from './provider/index';
export * as providerFunctions from './provider-functions/index';
export * as functions from './functions/index';

"
`;
Expand All @@ -263,7 +263,7 @@ exports[`generate provider functions for the time provider (real terraform 1.15.
"// generated by cdktn get
Object.defineProperty(exports, 'staticResource', { get: function () { return require('./static-resource'); } });
Object.defineProperty(exports, 'provider', { get: function () { return require('./provider'); } });
Object.defineProperty(exports, 'providerFunctions', { get: function () { return require('./provider-functions'); } });
Object.defineProperty(exports, 'functions', { get: function () { return require('./functions'); } });

"
`;
Expand All @@ -286,7 +286,7 @@ export interface TimeProviderConfig {
readonly alias?: string;
}

import { TimeProviderFunctions } from '../provider-functions/index';
import { TimeProviderFunctions } from '../functions/index';
/**
* Represents a {@link https://registry.terraform.io/providers/hashicorp/time/latest/docs time}
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,44 @@ import {
assertNoFunctionsGetterCollision,
buildProviderFunctionsModel,
} from "../../generator/models/provider-function-model";
import { CodeMaker } from "codemaker";
import { CodeMaker, toSnakeCase } from "codemaker";
import { FunctionSignature } from "@cdktn/commons";
import { createTmpHelper } from "../util";

const tmp = createTmpHelper();

/**
* Verbatim copy of jsii-pacmak's Python cross-submodule import path
* calculation (jsii-pacmak@1.128.0
* `lib/targets/python/type-name.ts#relativeImportPath`), including the
* `startsWith` test that is missing a `.`-boundary check. Copied rather than
* approximated so the assertion below fails for exactly the layouts pacmak
* mis-renders.
*/
function pacmakRelativeImportPath(fromPkg: string, toPkg: string): string {
if (toPkg.startsWith(fromPkg)) {
return `.${toPkg.substring(fromPkg.length + 1)}`;
}
const fromPkgParent = fromPkg.substring(0, fromPkg.lastIndexOf("."));
return `.${pacmakRelativeImportPath(fromPkgParent, toPkg)}`;
}

/**
* Resolves a Python relative import specifier (`.x`, `..x`, ...) written
* inside the package `fromPkg` to the absolute module it names. One leading
* dot means "this package", each further dot climbs one level.
*/
function resolveRelativeImport(fromPkg: string, specifier: string): string {
const dots = /^\.*/.exec(specifier)![0].length;
const tail = specifier.slice(dots);
const segments = fromPkg.split(".");
const base = segments.slice(0, segments.length - (dots - 1));
return [...base, ...(tail ? [tail] : [])].join(".");
}

/** jsii's submodule name -> Python module name mapping. */
const pythonModuleName = (submoduleName: string) => toSnakeCase(submoduleName);

test("generate provider functions for the time provider (real terraform 1.15.6 schema fragment)", async () => {
const code = new CodeMaker();
const workdir = tmp("provider-functions.test");
Expand All @@ -26,7 +58,7 @@ test("generate provider functions for the time provider (real terraform 1.15.6 s
await code.save(workdir);

const providerFunctionsOutput = fs.readFileSync(
path.join(workdir, "providers/time/provider-functions/index.ts"),
path.join(workdir, "providers/time/functions/index.ts"),
"utf-8",
);
expect(providerFunctionsOutput).toMatchSnapshot("time-provider-functions");
Expand All @@ -50,6 +82,76 @@ test("generate provider functions for the time provider (real terraform 1.15.6 s
expect(providerLazyIndex).toMatchSnapshot("provider-lazy-index");
});

// Regression test for the Python bindings of a provider that declares
// provider-defined functions.
//
// The provider class lives in the `provider` jsii submodule and imports the
// functions wrapper class from a sibling submodule. jsii-pacmak renders that
// cross-submodule reference in Python as a *relative* import, computed by
// `relativeImportPath` - which decides "is the target a child of me?" with a
// bare `toPkg.startsWith(fromPkg)`, no `.`-boundary check. A sibling
// submodule whose Python name merely string-prefixes the importing one is
// therefore mistaken for a child, and pacmak emits an import of a module
// that was never written to disk. That is what a `provider-functions` folder
// did: submodule `<provider>.provider_functions` string-prefixes
// `<provider>.provider`, so `<provider>/provider/__init__.py` got
// `from .functions import ...` and importing the provider raised
// ModuleNotFoundError (Go/Java/C# are unaffected - they use fully qualified
// names and never compute a relative path).
//
// Rather than assert the folder name, this reproduces pacmak's own
// calculation over the emitted layout and checks the import it would write
// actually resolves to the emitted functions submodule.
test("the emitted layout makes jsii-pacmak's Python relative import from the provider submodule resolve to the functions submodule", async () => {
const code = new CodeMaker();
const workdir = tmp("provider-functions-python-layout.test");
const spec = JSON.parse(
fs.readFileSync(
path.join(__dirname, "fixtures", "provider-functions.test.fixture.json"),
"utf-8",
),
);
new TerraformProviderGenerator(code, spec).generateAll();
await code.save(workdir);

// The folder the provider class imports the functions wrapper from, read
// back out of the generated source instead of hard-coded.
const providerOutput = fs.readFileSync(
path.join(workdir, "providers/time/provider/index.ts"),
"utf-8",
);
const importMatch = /from '\.\.\/([^/]+)\/index'/.exec(providerOutput);
expect(importMatch).not.toBeNull();
const functionsFolder = importMatch![1];

// ...and it has to be a submodule the root index actually exports, or
// there would be no Python package for it at all.
const providerIndex = fs.readFileSync(
path.join(workdir, "providers/time/index.ts"),
"utf-8",
);
const submodules = new Map(
[
...providerIndex.matchAll(
/export \* as (\w+) from '\.\/([^/]+)\/index'/g,
),
].map((m) => [m[2], m[1]]),
);
expect(submodules.has("provider")).toBe(true);
expect(submodules.has(functionsFolder)).toBe(true);

// Python module names of the two submodules, under the provider's own
// Python root package (`imports.<provider>` in a real project).
const root = "time";
const providerPkg = `${root}.${pythonModuleName(submodules.get("provider")!)}`;
const functionsPkg = `${root}.${pythonModuleName(
submodules.get(functionsFolder)!,
)}`;

const specifier = pacmakRelativeImportPath(providerPkg, functionsPkg);
expect(resolveRelativeImport(providerPkg, specifier)).toBe(functionsPkg);
});

describe("generate provider functions covering variadic parameters, primitive/list returns, and a 'default' parameter name", () => {
let providerFunctionsOutput: string;
let providerIndex: string;
Expand All @@ -72,7 +174,7 @@ describe("generate provider functions covering variadic parameters, primitive/li
await code.save(workdir);

providerFunctionsOutput = fs.readFileSync(
path.join(workdir, "providers/example/provider-functions/index.ts"),
path.join(workdir, "providers/example/functions/index.ts"),
"utf-8",
);
providerIndex = fs.readFileSync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import * as fs from "fs";
import * as path from "path";
import { TerraformProviderGenerator } from "../../generator/provider-generator";
import { PROVIDER_FUNCTIONS_FOLDER_NAME } from "../../generator/models";
import { CodeMaker } from "codemaker";
import { createTmpHelper } from "../util";

Expand All @@ -28,7 +29,7 @@ test("generate provider", async () => {
// aws has no provider-defined functions in this fixture - no getter, no
// cross-directory import should be emitted.
expect(output).not.toContain("public get functions()");
expect(output).not.toContain("provider-functions");
expect(output).not.toContain(`../${PROVIDER_FUNCTIONS_FOLDER_NAME}/index`);
});

test("generate provider with only block_types", async () => {
Expand All @@ -55,5 +56,5 @@ test("generate provider with only block_types", async () => {
// elasticstack has no provider-defined functions in this fixture - no
// getter, no cross-directory import should be emitted.
expect(output).not.toContain("public get functions()");
expect(output).not.toContain("provider-functions");
expect(output).not.toContain(`../${PROVIDER_FUNCTIONS_FOLDER_NAME}/index`);
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) HashiCorp, Inc
// SPDX-License-Identifier: MPL-2.0
import { CodeMaker } from "codemaker";
import { ResourceModel } from "../models";
import { PROVIDER_FUNCTIONS_FOLDER_NAME, ResourceModel } from "../models";
import { AttributesEmitter } from "./attributes-emitter";
import { sanitizedComment } from "../sanitized-comments";

Expand All @@ -19,12 +19,12 @@ export class ResourceEmitter {
this.code.line();

if (resource.isProvider && resource.providerFunctionsModel) {
// Sibling-directory import: providers/<provider>/provider/index.ts
// (the emitted file) and providers/<provider>/provider-functions/
// index.ts are siblings under providers/<provider>/, so unlike the
// child-folder struct imports this one has to step up a level.
// Sibling-directory import: provider/index.ts and functions/index.ts
// are siblings under providers/<provider>/, so unlike the child-folder
// struct imports this one steps up a level. The folder name is
// constrained - see PROVIDER_FUNCTIONS_FOLDER_NAME.
this.code.line(
`import { ${resource.providerFunctionsModel.className} } from '../provider-functions/index${this.importExtension}';`,
`import { ${resource.providerFunctionsModel.className} } from '../${PROVIDER_FUNCTIONS_FOLDER_NAME}/index${this.importExtension}';`,
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,21 @@ export interface ProviderFunctionModel {
readonly variadicParameter?: ProviderFunctionParameterModel;
}

/**
* Folder (and jsii submodule) holding a provider's generated functions
* wrapper, emitted as a sibling of `provider/` under `providers/<provider>/`.
*
* Must not start with `provider`: jsii-pacmak <= 1.135.0 computes Python
* cross-submodule imports with a prefix test that has no `.`-boundary check,
* so `provider_functions` reads as a child of `provider` and emits an import
* for a module that is never written. Fixed upstream in pacmak 1.136.0; this
* repo pins 1.128.0.
*/
export const PROVIDER_FUNCTIONS_FOLDER_NAME = "functions";

/**
* All provider-defined functions of a single provider, mapped to a single
* generated `providers/<provider>/provider-functions/index.ts` file.
* generated `providers/<provider>/functions/index.ts` file.
*/
export interface ProviderFunctionsModel {
readonly providerName: string;
Expand Down Expand Up @@ -843,7 +855,7 @@ export function assertNoFunctionsGetterCollision(
}

/**
* Builds the model for a provider's `provider-functions/index.ts` file from
* Builds the model for a provider's `functions/index.ts` file from
* its provider schema `functions` map. Returns `undefined` when the provider
* declares no functions - callers should skip emitting the file entirely.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export class ResourceModel {
* Only set (by provider-generator.ts) when isProvider is true and the
* provider schema declares provider-defined functions - drives whether
* ResourceEmitter emits the memoized `functions` getter and its
* cross-directory import of the sibling provider-functions/index.ts file.
* cross-directory import of the sibling functions/index.ts file.
*/
public providerFunctionsModel?: ProviderFunctionsModel;
public fileName: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "@cdktn/commons";
import { FQPN, parseFQPN, ProviderName } from "@cdktn/provider-schema";
import {
PROVIDER_FUNCTIONS_FOLDER_NAME,
ProviderFunctionsModel,
ResourceModel,
assertNoFunctionsGetterCollision,
Expand Down Expand Up @@ -304,7 +305,8 @@ export class TerraformProviderGenerator {
provider: ProviderName,
model: ProviderFunctionsModel,
): string {
const filePath = `providers/${provider}/provider-functions/index.ts`;
// Folder name is constrained - see PROVIDER_FUNCTIONS_FOLDER_NAME.
const filePath = `providers/${provider}/${PROVIDER_FUNCTIONS_FOLDER_NAME}/index.ts`;
this.code.openFile(filePath);
this.code.line(`// generated from provider function schema`);
this.code.line();
Expand Down
2 changes: 1 addition & 1 deletion packages/cdktn/src/functions/provider-function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { anyValue, terraformFunction } from "./helpers";

/**
* Runtime entry point invoked by generated provider function bindings
* (`providers/<provider>/provider-functions/index.ts`). Generated code only
* (`providers/<provider>/functions/index.ts`). Generated code only
* imports the public `cdktn` package root, so this is the single chokepoint
* through which every provider-defined function call flows.
*/
Expand Down
Loading