Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(python): streaming bin script output #3794

Merged
merged 11 commits into from
Nov 23, 2022
12 changes: 12 additions & 0 deletions packages/@jsii/kernel/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,18 @@ export interface LoadResponse {
readonly types: number;
}

export interface GetScriptCommandRequest {
readonly assembly: string;
readonly script: string;
readonly args?: string[];
}

export interface GetScriptCommandResponse {
command: string;
args: string[];
env: Record<string, string>;
}

export interface InvokeScriptRequest {
readonly assembly: string;
readonly script: string;
Expand Down
45 changes: 45 additions & 0 deletions packages/@jsii/kernel/src/kernel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ defineTest.skip = function (
return defineTest(name, method, test.skip);
};

defineTest.skipIf = (expr: boolean) => (expr ? defineTest.skip : defineTest);

// Note: this test asserts file permissions, which work differently on Windows, so we skip it there
(process.platform === 'win32' ? test.skip : test)(
'load preserves file permissions',
Expand Down Expand Up @@ -2138,6 +2140,49 @@ defineTest('Override transitive property', (sandbox) => {
expect(propValue).toBe('N3W');
});

defineTest.skipIf(!!recordingOutput)(
'getBinScriptCommand() returns output',
(sandbox) => {
const result = sandbox.getBinScriptCommand({
assembly: 'jsii-calc',
script: 'calc',
});

expect(result).toMatchObject<api.GetScriptCommandResponse>({
command: expect.stringMatching(
/node_modules[/\\]jsii-calc[/\\]bin[/\\]run$/,
),
args: [],
env: {
NODE_OPTIONS: expect.any(String),
PATH: expect.any(String),
},
});
},
);

defineTest.skipIf(!!recordingOutput)(
'getBinScriptCommand() accepts arguments',
(sandbox) => {
const result = sandbox.getBinScriptCommand({
assembly: 'jsii-calc',
script: 'calc',
args: ['arg1', 'arg2'],
});

expect(result).toMatchObject<api.GetScriptCommandResponse>({
command: expect.stringMatching(
/node_modules[/\\]jsii-calc[/\\]bin[/\\]run$/,
),
args: ['arg1', 'arg2'],
env: {
NODE_OPTIONS: expect.any(String),
PATH: expect.any(String),
},
});
},
);

defineTest('invokeBinScript() return output', (sandbox) => {
const result = sandbox.invokeBinScript({
assembly: 'jsii-calc',
Expand Down
89 changes: 50 additions & 39 deletions packages/@jsii/kernel/src/kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,50 +169,29 @@ export class Kernel {
};
}

public getBinScriptCommand(
req: api.GetScriptCommandRequest,
): api.GetScriptCommandResponse {
return this._getBinScriptCommand(req);
}

public invokeBinScript(
req: api.InvokeScriptRequest,
): api.InvokeScriptResponse {
const packageDir = this._getPackageDir(req.assembly);
if (fs.pathExistsSync(packageDir)) {
// module exists, verify version
const epkg = fs.readJsonSync(path.join(packageDir, 'package.json'));

if (!epkg.bin) {
throw new JsiiFault(
'There is no bin scripts defined for this package.',
);
}

const scriptPath = epkg.bin[req.script];
const { command, args, env } = this._getBinScriptCommand(req);

if (!epkg.bin) {
throw new JsiiFault(`Script with name ${req.script} was not defined.`);
}

const result = cp.spawnSync(
path.join(packageDir, scriptPath),
req.args ?? [],
{
encoding: 'utf-8',
env: {
...process.env,
// Make sure the current NODE_OPTIONS are honored if we shell out to node
NODE_OPTIONS: process.execArgv.join(' '),
// Make sure "this" node is ahead of $PATH just in case
PATH: `${path.dirname(process.execPath)}:${process.env.PATH}`,
},
shell: true,
},
);
const result = cp.spawnSync(command, args, {
encoding: 'utf-8',
env,
shell: true,
});

return {
stdout: result.stdout,
stderr: result.stderr,
status: result.status,
signal: result.signal,
};
}
throw new JsiiFault(`Package with name ${req.assembly} was not loaded.`);
return {
stdout: result.stdout,
stderr: result.stderr,
status: result.status,
signal: result.signal,
};
}

public create(req: api.CreateRequest): api.CreateResponse {
Expand Down Expand Up @@ -1325,6 +1304,38 @@ export class Kernel {
return property;
}

/**
* Shared (non-public implementation) to as not to break API recording.
*/
private _getBinScriptCommand(
req: api.GetScriptCommandRequest,
): api.GetScriptCommandResponse {
const packageDir = this._getPackageDir(req.assembly);
if (fs.pathExistsSync(packageDir)) {
// module exists, verify version
const epkg = fs.readJsonSync(path.join(packageDir, 'package.json'));

const scriptPath = epkg.bin?.[req.script];

if (!epkg.bin) {
throw new JsiiFault(`Script with name ${req.script} was not defined.`);
}

return {
command: path.join(packageDir, scriptPath),
Copy link
Contributor

Choose a reason for hiding this comment

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

On Windows, this may only work if the caller spawns that commend via a shell, which is a relatively easy way to inadvertently make your code not work on Windows...

There would be a .cmd file that might be preferable to use as an entry point when on Windows if present...

Copy link
Contributor Author

@jmalins jmalins Nov 15, 2022

Choose a reason for hiding this comment

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

I don't disagree. I was taking a "do no harm" approach with this PR by just carrying forward the existing logic, which would also not work on Windows.

I don't have easy access to a Windows host to test and understand the auto-generated .cmd file mentioned here: https://docs.npmjs.com/cli/v9/configuring-npm/package-json#bin

Is it essential that Windows support be included in the PR, since this isn't a new regression?

Copy link
Contributor

Choose a reason for hiding this comment

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

Is it essential that Windows support be included in the PR, since this isn't a new regression?

Well... no. I have this principle that I never demand contributors leave the place cleaner than they found it 😅

args: req.args ?? [],
env: {
...process.env,
// Make sure the current NODE_OPTIONS are honored if we shell out to node
NODE_OPTIONS: process.execArgv.join(' '),
Comment on lines +1329 to +1330
Copy link
Contributor

Choose a reason for hiding this comment

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

Not everything in process.execArgv is guaranteed to be valid to use in NODE_OPTIONS... That'll be randomly problematic in certain environments.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same comment as above, this is a direct port of the existing invokeBinScript logic.

I think, in this case, the contents of process.execArgv is predictable because the JSII runtime spawns the node process. Python is currently the only runtime with bin script support and it appears to only pass --max-old-space-size=4069, which shouldn't present a problem:
https://github.com/aws/jsii/blob/main/packages/%40jsii/python-runtime/src/jsii/_kernel/providers/process.py#L252

// Make sure "this" node is ahead of $PATH just in case
PATH: `${path.dirname(process.execPath)}:${process.env.PATH}`,
},
};
}
throw new JsiiFault(`Package with name ${req.assembly} was not loaded.`);
}

//
// type information
//
Expand Down
17 changes: 16 additions & 1 deletion packages/@jsii/python-runtime/src/jsii/_kernel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
from .. import _reference_map
from .._utils import Singleton
from .providers import BaseProvider, ProcessProvider
from .types import Callback
from .types import (
EnumRef,
LoadRequest,
Expand All @@ -34,6 +33,8 @@
GetResponse,
InvokeRequest,
InvokeResponse,
GetScriptCommandRequest,
GetScriptCommandResponse,
InvokeScriptRequest,
InvokeScriptResponse,
KernelResponse,
Expand Down Expand Up @@ -299,6 +300,20 @@ def __init__(self, provider_class: Type[BaseProvider] = ProcessProvider) -> None
def load(self, name: str, version: str, tarball: str) -> None:
self.provider.load(LoadRequest(name=name, version=version, tarball=tarball))

def getBinScriptCommand(
self, pkgname: str, script: str, args: Optional[Sequence[str]] = None
) -> GetScriptCommandResponse:
if args is None:
args = []

return self.provider.getScriptCommand(
GetScriptCommandRequest(
assembly=pkgname,
script=script,
args=_make_reference_for_native(self, args),
)
)

def invokeBinScript(
self, pkgname: str, script: str, args: Optional[Sequence[str]] = None
) -> InvokeScriptResponse:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
GetResponse,
InvokeRequest,
InvokeResponse,
GetScriptCommandRequest,
GetScriptCommandResponse,
InvokeScriptRequest,
InvokeScriptResponse,
DeleteRequest,
Expand Down Expand Up @@ -47,6 +49,12 @@ class BaseProvider(metaclass=abc.ABCMeta):
def load(self, request: LoadRequest) -> LoadResponse:
...

@abc.abstractmethod
def getScriptCommand(
self, request: GetScriptCommandRequest
) -> GetScriptCommandResponse:
...

@abc.abstractmethod
def invokeBinScript(self, request: InvokeScriptRequest) -> InvokeScriptResponse:
...
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
GetResponse,
InvokeRequest,
InvokeResponse,
GetScriptCommandRequest,
GetScriptCommandResponse,
InvokeScriptRequest,
InvokeScriptResponse,
SetRequest,
Expand Down Expand Up @@ -160,6 +162,12 @@ def __init__(self):
LoadRequest,
_with_api_key("load", self._serializer.unstructure_attrs_asdict),
)
self._serializer.register_unstructure_hook(
GetScriptCommandRequest,
_with_api_key(
"getBinScriptCommand", self._serializer.unstructure_attrs_asdict
),
)
self._serializer.register_unstructure_hook(
InvokeScriptRequest,
_with_api_key("invokeBinScript", self._serializer.unstructure_attrs_asdict),
Expand Down Expand Up @@ -343,6 +351,11 @@ def _process(self) -> _NodeProcess:
def load(self, request: LoadRequest) -> LoadResponse:
return self._process.send(request, LoadResponse)

def getScriptCommand(
self, request: GetScriptCommandRequest
) -> GetScriptCommandResponse:
return self._process.send(request, GetScriptCommandResponse)

def invokeBinScript(self, request: InvokeScriptRequest) -> InvokeScriptResponse:
return self._process.send(request, InvokeScriptResponse)

Expand Down
18 changes: 17 additions & 1 deletion packages/@jsii/python-runtime/src/jsii/_kernel/types.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Generic, List, Optional, Mapping, TypeVar, Union
from typing import Any, Dict, Generic, List, Optional, Mapping, TypeVar, Union
from typing_extensions import Protocol

import attr
Expand Down Expand Up @@ -41,6 +41,22 @@ class LoadResponse:
types: int


@attr.s(auto_attribs=True, frozen=True, slots=True)
class GetScriptCommandRequest:

assembly: str
script: str
args: List[Any] = attr.Factory(list)


@attr.s(auto_attribs=True, frozen=True, slots=True)
class GetScriptCommandResponse:

command: str
args: List[str] = attr.Factory(list)
env: Dict[str, str] = attr.Factory(dict)


@attr.s(auto_attribs=True, frozen=True, slots=True)
class InvokeScriptRequest:

Expand Down
16 changes: 9 additions & 7 deletions packages/@jsii/python-runtime/src/jsii/_runtime.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import abc
import os
import sys
import subprocess

import attr

Expand Down Expand Up @@ -66,14 +67,15 @@ def invokeBinScript(
if args is None:
args = []

response = _kernel.invokeBinScript(pkgname, script, args)
if response.stdout:
print(response.stdout)
response = _kernel.getBinScriptCommand(pkgname, script, args)

if response.stderr:
print(response.stderr, file=sys.stderr)

return response.status
result = subprocess.run(
" ".join([response.command, *response.args]),
encoding="utf-8",
shell=True,
env=response.env,
)
return result.returncode


class JSIIMeta(_ClassPropertyMeta, type):
Expand Down
Loading