Skip to content
Merged
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
6 changes: 4 additions & 2 deletions components/deducers/python-pyright/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,18 @@
"scripts": {
"build": "bash scripts/prepare-pyright-pkg.sh && tsc",
"watch": "npm run build -- --watch",
"test": "jest",
"test:watch": "jest --watch",
"test": "jest --verbose --coverage",
"test:watch": "jest --verbose --coverage --watch",
"lint": "eslint .",
"clean": "tsc --build --clean"
},
"dependencies": {
"@plutolang/base": "workspace:^",
"fs-extra": "^11.1.1",
"pyright-internal": "file:./libs/pyright-internal"
},
"devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/node": "^17.0.45",
"jest": "^29.7.0",
Expand Down
39 changes: 38 additions & 1 deletion components/deducers/python-pyright/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import { LogLevel } from "pyright-internal/dist/common/console";

import * as TextUtils from "./text-utils";
import * as ProgramUtils from "./program-utils";
import * as TypeConsts from "./type-consts";
import { TypeSearcher } from "./type-searcher";
import { ArgumentCategory, CallNode } from "pyright-internal/dist/parser/parseNodes";
import { TypeEvaluator } from "pyright-internal/dist/analyzer/typeEvaluatorTypes";
import { TypeCategory } from "pyright-internal/dist/analyzer/types";
import { Value, ValueEvaluator } from "./value-evaluator";

export default class PyrightDeducer extends Deducer {
//eslint-disable-next-line @typescript-eslint/no-var-requires
Expand Down Expand Up @@ -52,7 +57,39 @@ function doTypeSearch(program: Program, sourceFile: SourceFile) {
walker.specialNodeMap.forEach((nodes, key) => {
console.log("Special Node:", key);
nodes.forEach((node) => {
console.log(" ", TextUtils.getTextOfNode(node, sourceFile));
console.log("/--------------------\\");
console.log("|", TextUtils.getTextOfNode(node, sourceFile));
if (
key === TypeConsts.IRESOURCE_FULL_NAME ||
key === TypeConsts.IRESOURCE_INFRA_API_FULL_NAME
) {
getArgumentValue(node, sourceFile, program.evaluator!);
}
console.log("\\--------------------/\n\n");
});
});
}

function getArgumentValue(
callNode: CallNode,
sourceFile: SourceFile,
typeEvaluator: TypeEvaluator
) {
callNode.arguments.forEach((arg) => {
console.log("| Argument:");
console.log("| Text: ", TextUtils.getTextOfNode(arg, sourceFile));

const valueNodeType = typeEvaluator.getType(arg.valueExpression);
if (valueNodeType?.category === TypeCategory.Function) {
console.log("| Value is a function, we need to encapsulate it into closures afterward.");
return;
}

if (arg.argumentCategory === ArgumentCategory.Simple) {
const valueEvaluator = new ValueEvaluator(typeEvaluator);
const value = valueEvaluator.getValue(arg.valueExpression);
console.log("| Value: ", value);
console.log("| Stringified: ", Value.toString(value));
}
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from dataclasses import dataclass
from typing import Literal


@dataclass
class Base:
name: str
age: int


@dataclass
class Model:
base: Base
gender: Literal["male", "female"]
nullable: int | None = None
tup: tuple[int, int, int] = (1, 2, 3)


# Direct literal value evaluation
num_1 = 1
str_1 = "str1"
bool_1 = True
null_1 = None

num_2 = num_1 + 1
str_2 = str_1 + "str2" "str2_plus"


def fn_1(a: int, b: str, c: bool):
pass


fn_1(num_2, b=str_1, c=bool_1)


# tuple value evaluation
num_tuple_1 = (1, 2, 3)
str_tuple_1 = ("str2", "str3", "str4")
bool_tuple_1 = (True, False, True)

mix_tuple_1 = (1, "str5", True)
nested_tuple_1 = ((1, 2), ("str6", "str7"), (True, False))
nested_mix_tuple_1 = ((1, "str8", True), (2, "str9", False))

"""
The reason the following expression isn't assigned to a variable is that if it were, the test case
would attempt to evaluate the variable's value. However, since the variable contains a data class
instance, and the value evaluator can't deduce the data class instance, the test case would fail.
"""
(Base(name="name", age=19), Base(name="name2", age=20))
(
(1, "str10", True),
(2, "str11", False),
(3, "str12", Base(name="name3", age=21)),
)

# data class value evaluation
Model(base=Base("name", age=19), gender="male", nullable=null_1)
31 changes: 31 additions & 0 deletions components/deducers/python-pyright/src/test/test-utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as os from "os";
import * as fs from "fs-extra";
import * as path from "path";
import { Uri } from "pyright-internal/dist/common/uri/uri";
import { Program } from "pyright-internal/dist/analyzer/program";
Expand Down Expand Up @@ -29,3 +31,32 @@ export function parseFiles(filePaths: string[]) {
const sourceFiles = uris.map((uri) => program.getSourceFile(uri)!);
return { program, sourceFiles };
}

export function parseCode(code: string, filename: string = "tmp.py") {
const program = ProgramUtils.createProgram({
logLevel: LogLevel.Warn,
extraPaths: [
path.resolve(__dirname, "../../../../../packages/base-py"),
path.resolve(__dirname, "../../../../../packages/pluto-py"),
],
});

const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "pyright-deducer-"));
const tmpfile = path.join(tmpdir, filename);
fs.writeFileSync(tmpfile, code);

const uri = Uri.file(tmpfile);
program.addTrackedFile(uri);

// eslint-disable-next-line no-empty
while (program.analyze()) {}

const sourceFile = program.getSourceFile(uri)!;
return {
program,
sourceFile,
clean: () => {
fs.rmSync(path.dirname(sourceFile.getUri().key), { recursive: true });
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const SAMPLES_ROOT = path.join(__dirname, "samples");

test("TypeSearcher should correctly identify special types in the parse tree", () => {
// Set up
const samplePath = path.join(SAMPLES_ROOT, "special-type.valid.py");
const samplePath = path.join(SAMPLES_ROOT, "special_type_valid.py");
const { program, sourceFiles } = TestUtils.parseFiles([samplePath]);

// Ensure there is only one source file
Expand Down
197 changes: 197 additions & 0 deletions components/deducers/python-pyright/src/test/value-evaluator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import * as path from "path";
import { ParseTreeWalker } from "pyright-internal/dist/analyzer/parseTreeWalker";
import {
ExpressionNode,
ParseNode,
ParseNodeType,
isExpressionNode,
} from "pyright-internal/dist/parser/parseNodes";
import { SourceFile } from "pyright-internal/dist/analyzer/sourceFile";
import { Value, ValueEvaluator } from "../value-evaluator";
import * as TextUtils from "../text-utils";
import * as TestUtils from "./test-utils";

const SAMPLES_ROOT = path.join(__dirname, "samples");

const DATACLASS_DEF = `
from dataclasses import dataclass
from typing import Literal


@dataclass
class Base:
name: str
age: int


@dataclass
class Model:
base: Base
gender: Literal["male", "female"]
nullable: int | None = None
tup: tuple[int, int, int] = (1, 2, 3)
`;

type Validator = (node: ExpressionNode) => void;

class ExpressionWalker extends ParseTreeWalker {
constructor(
private readonly sourceFile: SourceFile,
private readonly validator: Validator
) {
super();
}

override visit(node: ParseNode): boolean {
switch (node.nodeType) {
case ParseNodeType.Function:
case ParseNodeType.Import:
case ParseNodeType.ImportAs:
case ParseNodeType.ImportFrom:
case ParseNodeType.ImportFromAs:
case ParseNodeType.Class:
case ParseNodeType.Lambda:
return false;
}

if (isExpressionNode(node)) {
this.evaluateValue(node);
return false;
}
return true;
}

private evaluateValue(node: ExpressionNode) {
const nodeText = TextUtils.getTextOfNode(node, this.sourceFile);
if (nodeText?.startsWith("fn_")) {
// Skip the function calls.
return;
}

this.validator(node);
}
}

test("ValueEvaluator should correctly evaluate the values", () => {
// Set up
const samplePath = path.join(SAMPLES_ROOT, "value_evaluator_valid.py");
const { program, sourceFiles } = TestUtils.parseFiles([samplePath]);

// Ensure there is only one source file
expect(sourceFiles.length).toEqual(1);

// Get the parse tree of the source file
const parseTree = sourceFiles[0].getParseResults()?.parseTree;
expect(parseTree).toBeDefined();

const valueEvaluator = new ValueEvaluator(program.evaluator!);

const walker = new ExpressionWalker(sourceFiles[0]!, (node) => {
const value = valueEvaluator.getValue(node);
expect(value).toBeDefined();
});
walker.walk(parseTree!);
});

describe("evaluateValueForBuiltin", () => {
test("should throw an error when try to deducer a non-literal type", () => {
const code = `
from typing import Any
import random

var_0: Any = 1
var_1 = random.randint(1, 10)
var_2 = 1.0
var_3 = bytearray(b"bytearr")
var_4 = b"bytes"
var_5 = {"a": 1}
var_7 = frozenset([1])
var_8 = [1]
var_9 = {1}
`;

testInlineCode(code, (valueEvaluator, sourceFile) => {
return (node) => {
const text = TextUtils.getTextOfNode(node, sourceFile);
if (text?.startsWith("var_")) {
expect(() => valueEvaluator.getValue(node)).toThrow();
}
};
});
});
});

describe("evaluateValueForTuple", () => {
test("should throw an error when a type argument is not of category Class", () => {
const code = `
${DATACLASS_DEF}

from typing import Any

any_var: Any = 1
tuple_1 = (any_var, 2, 3)

model = Base(name="John", age=25)
tuple_2 = (model,)
`;

testInlineCode(code, (valueEvaluator, sourceFile) => {
return (node) => {
const text = TextUtils.getTextOfNode(node, sourceFile);
if (text?.startsWith("tuple_") || /^\(.*\)$/g.test(text!)) {
expect(() => valueEvaluator.getValue(node)).toThrow();
}
};
});
});
});

describe("evaluateValueForDataClass", () => {
test("should throw an error when try to deducer a data class instance", () => {
const code = `
${DATACLASS_DEF}

model = Model(Base("name", 25), gender="male")
`;

testInlineCode(code, (valueEvaluator, sourceFile) => {
return (node) => {
const text = TextUtils.getTextOfNode(node, sourceFile);
if (text === "model") {
expect(() => valueEvaluator.getValue(node)).toThrow();
}

if (text === 'Model(Base("name", 25), gender="male")') {
const value = valueEvaluator.getValue(node);
expect(value).toBeDefined();

const stringified = Value.toString(value);
expect(stringified.startsWith("tmp.Model")).toBeTruthy();

const match = stringified.match(/\(.*\)/);
expect(match).not.toBeNull();
expect(match![0]).toContain('base=tmp.Base(name="name", age=25)');
expect(match![0]).toContain('gender="male"');
expect(match![0]).toContain("nullable=None");
expect(match![0]).toContain("tup=(1, 2, 3)");
}
};
});
});
});

function testInlineCode(
code: string,
validatorBuilder: (evaluator: ValueEvaluator, sourceFile: SourceFile) => Validator
) {
const { program, sourceFile, clean } = TestUtils.parseCode(code);

const parseTree = sourceFile.getParseResults()?.parseTree;
expect(parseTree).toBeDefined();

const valueEvaluator = new ValueEvaluator(program.evaluator!);
const walker = new ExpressionWalker(sourceFile, validatorBuilder(valueEvaluator, sourceFile));
walker.walk(parseTree!);

clean();
}
Loading