mirror of
https://github.com/astral-sh/setup-uv.git
synced 2026-09-04 14:39:28 +00:00
Use uv Python keys for runtime IDs
This commit is contained in:
+12
-20
@@ -386,29 +386,21 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
- name: Verify Python version outputs and cache key
|
- name: Verify Python version outputs and cache key
|
||||||
run: |
|
run: |
|
||||||
expected=$(python -I - <<'PY'
|
expected_prefix=$(python -I - <<'PY'
|
||||||
import platform
|
import platform
|
||||||
import sys
|
import sys
|
||||||
import sysconfig
|
import sysconfig
|
||||||
|
|
||||||
if sys.implementation.name == "cpython":
|
runtime = f"{sys.implementation.name}-{platform.python_version()}"
|
||||||
runtime = f"cpython-{platform.python_version()}"
|
|
||||||
else:
|
|
||||||
version = sys.implementation.version
|
|
||||||
implementation_version = f"{version.major}.{version.minor}.{version.micro}"
|
|
||||||
suffix = {"alpha": "a", "beta": "b", "candidate": "rc", "final": ""}[version.releaselevel]
|
|
||||||
if suffix:
|
|
||||||
implementation_version += f"{suffix}{version.serial}"
|
|
||||||
runtime = f"{sys.implementation.name}-{implementation_version}-python-{platform.python_version()}"
|
|
||||||
if sysconfig.get_config_var("Py_GIL_DISABLED") == 1:
|
if sysconfig.get_config_var("Py_GIL_DISABLED") == 1:
|
||||||
runtime += "-freethreaded"
|
runtime += "+freethreaded"
|
||||||
print(runtime)
|
print(f"{runtime}-")
|
||||||
PY
|
PY
|
||||||
)
|
)
|
||||||
if [ "$PYTHON_RUNTIME_ID" != "$expected" ]; then
|
case "$PYTHON_RUNTIME_ID" in
|
||||||
echo "Wrong Python runtime ID: $PYTHON_RUNTIME_ID (expected $expected)"
|
"$expected_prefix"*) ;;
|
||||||
exit 1
|
*) echo "Wrong Python runtime ID: $PYTHON_RUNTIME_ID (expected prefix $expected_prefix)"; exit 1 ;;
|
||||||
fi
|
esac
|
||||||
if [ "$PYTHON_VERSION" != "$PYTHON_REQUEST" ]; then
|
if [ "$PYTHON_VERSION" != "$PYTHON_REQUEST" ]; then
|
||||||
echo "Wrong requested Python version: $PYTHON_VERSION"
|
echo "Wrong requested Python version: $PYTHON_VERSION"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -489,10 +481,10 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
- name: Verify Python runtime ID from custom venv
|
- name: Verify Python runtime ID from custom venv
|
||||||
run: |
|
run: |
|
||||||
if [ "$PYTHON_RUNTIME_ID" != "cpython-3.13.1-freethreaded" ]; then
|
case "$PYTHON_RUNTIME_ID" in
|
||||||
echo "Wrong Python runtime ID: $PYTHON_RUNTIME_ID"
|
cpython-3.13.1+freethreaded-*) ;;
|
||||||
exit 1
|
*) echo "Wrong Python runtime ID: $PYTHON_RUNTIME_ID"; exit 1 ;;
|
||||||
fi
|
esac
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
PYTHON_RUNTIME_ID: ${{ steps.setup-uv.outputs.python-runtime-id }}
|
PYTHON_RUNTIME_ID: ${{ steps.setup-uv.outputs.python-runtime-id }}
|
||||||
|
|||||||
@@ -138,8 +138,8 @@ Have a look under [Advanced Configuration](#advanced-configuration) for detailed
|
|||||||
- `cache-hit`: A boolean value to indicate a cache entry was found.
|
- `cache-hit`: A boolean value to indicate a cache entry was found.
|
||||||
- `venv`: Path to the activated venv if activate-environment is true.
|
- `venv`: Path to the activated venv if activate-environment is true.
|
||||||
- `python-version`: The Python version that was set.
|
- `python-version`: The Python version that was set.
|
||||||
- `python-runtime-id`: An opaque identifier for the activated venv's Python runtime, including
|
- `python-runtime-id`: An opaque identifier reported by uv for the activated venv's Python runtime, including
|
||||||
implementation, full version, and free-threaded build information. Useful as a cache-key component.
|
implementation, full Python version, and free-threaded build information. Useful as a cache-key component.
|
||||||
Empty when `activate-environment` is false. The action fails if the activated runtime cannot be determined.
|
Empty when `activate-environment` is false. The action fails if the activated runtime cannot be determined.
|
||||||
- `python-cache-hit`: A boolean value to indicate the Python cache entry was found.
|
- `python-cache-hit`: A boolean value to indicate the Python cache entry was found.
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { join } from "node:path";
|
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { afterEach, beforeEach, expect, it, jest } from "@jest/globals";
|
import { beforeEach, expect, it, jest } from "@jest/globals";
|
||||||
import { createSetupInputs } from "../helpers/setup-inputs";
|
import { createSetupInputs } from "../helpers/setup-inputs";
|
||||||
|
|
||||||
const mockExecFile =
|
const mockExecFile =
|
||||||
jest.fn<
|
jest.fn<
|
||||||
(...args: unknown[]) => Promise<{ stdout: string; stderr: string }>
|
(...args: unknown[]) => Promise<{ stdout: string; stderr: string }>
|
||||||
>();
|
>();
|
||||||
const originalPlatform = process.platform;
|
|
||||||
const inputs = createSetupInputs({
|
const inputs = createSetupInputs({
|
||||||
activateEnvironment: true,
|
activateEnvironment: true,
|
||||||
pythonVersion: "3.15t",
|
pythonVersion: "3.15t",
|
||||||
@@ -20,28 +18,15 @@ jest.unstable_mockModule("node:child_process", () => ({
|
|||||||
|
|
||||||
const { getPythonRuntimeId } = await import("../../src/utils/python-runtime");
|
const { getPythonRuntimeId } = await import("../../src/utils/python-runtime");
|
||||||
|
|
||||||
function mockRuntime(overrides: Record<string, unknown> = {}) {
|
|
||||||
mockExecFile.mockResolvedValue({
|
|
||||||
stderr: "",
|
|
||||||
stdout: `${JSON.stringify({
|
|
||||||
freethreaded: false,
|
|
||||||
implementation: "cpython",
|
|
||||||
implementationVersion: [3, 13, 1, "final", 0],
|
|
||||||
pythonVersion: "3.13.1",
|
|
||||||
...overrides,
|
|
||||||
})}\r\n`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockExecFile.mockReset();
|
mockExecFile.mockReset();
|
||||||
mockRuntime();
|
mockExecFile.mockResolvedValue({
|
||||||
});
|
stderr: "",
|
||||||
afterEach(() => {
|
stdout: '[{"key":"cpython-3.13.1-linux-x86_64-gnu"}]\r\n',
|
||||||
Object.defineProperty(process, "platform", { value: originalPlatform });
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not query Python without environment activation", async () => {
|
it("does not query uv without environment activation", async () => {
|
||||||
expect(
|
expect(
|
||||||
await getPythonRuntimeId({ ...inputs, activateEnvironment: false }),
|
await getPythonRuntimeId({ ...inputs, activateEnvironment: false }),
|
||||||
).toBe("");
|
).toBe("");
|
||||||
@@ -49,62 +34,47 @@ it("does not query Python without environment activation", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["3.13.1", false, "cpython-3.13.1"],
|
"cpython-3.13.1-linux-x86_64-gnu",
|
||||||
["3.15.0a1", false, "cpython-3.15.0a1"],
|
"cpython-3.15.0rc1+freethreaded-macos-aarch64-none",
|
||||||
["3.15.0b2", false, "cpython-3.15.0b2"],
|
"cpython-3.15.0rc2+freethreaded-windows-x86_64-none",
|
||||||
["3.15.0rc1", false, "cpython-3.15.0rc1"],
|
"pypy-3.11.15-linux-x86_64-gnu",
|
||||||
["3.15.0rc1", true, "cpython-3.15.0rc1-freethreaded"],
|
])("returns uv's opaque runtime key unchanged: %s", async (key) => {
|
||||||
["3.15.0rc2", true, "cpython-3.15.0rc2-freethreaded"],
|
mockExecFile.mockResolvedValue({
|
||||||
["3.15.0", true, "cpython-3.15.0-freethreaded"],
|
stderr: "",
|
||||||
])("formats CPython %s, free-threading=%s", async (version, free, expected) => {
|
stdout: `${JSON.stringify([{ key }])}\r\n`,
|
||||||
mockRuntime({ freethreaded: free, pythonVersion: version });
|
|
||||||
expect(await getPythonRuntimeId(inputs)).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
["pypy", [7, 3, 23, "final", 0], "pypy-7.3.23"],
|
|
||||||
["pypy", [7, 3, 24, "final", 0], "pypy-7.3.24"],
|
|
||||||
["pypy", [7, 3, 24, "alpha", 1], "pypy-7.3.24a1"],
|
|
||||||
["pypy", [7, 3, 24, "beta", 2], "pypy-7.3.24b2"],
|
|
||||||
["pypy", [7, 3, 24, "candidate", 3], "pypy-7.3.24rc3"],
|
|
||||||
["graalpy", [25, 0, 0, "final", 0], "graalpy-25.0.0"],
|
|
||||||
])("formats %s implementation version %j", async (name, version, expected) => {
|
|
||||||
mockRuntime({
|
|
||||||
implementation: name,
|
|
||||||
implementationVersion: version,
|
|
||||||
pythonVersion: "3.11.15",
|
|
||||||
});
|
});
|
||||||
expect(await getPythonRuntimeId(inputs)).toBe(`${expected}-python-3.11.15`);
|
expect(await getPythonRuntimeId(inputs)).toBe(key);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each(['/runner temp/a "quoted" venv', "C:\\runner temp\\custom venv"])(
|
||||||
["linux", 'a "quoted" venv', "bin/python"],
|
"queries the exact venv directory: %s",
|
||||||
["darwin", "custom venv", "bin/python"],
|
async (venvPath) => {
|
||||||
["win32", "custom venv", "Scripts/python.exe"],
|
|
||||||
])("passes the venv executable directly on %s", async (platform, name, exe) => {
|
|
||||||
Object.defineProperty(process, "platform", { value: platform });
|
|
||||||
const venvPath = join("/runner temp", name);
|
|
||||||
await getPythonRuntimeId({ ...inputs, venvPath });
|
await getPythonRuntimeId({ ...inputs, venvPath });
|
||||||
expect(mockExecFile).toHaveBeenCalledWith(
|
expect(mockExecFile).toHaveBeenCalledWith(
|
||||||
join(venvPath, exe),
|
"uv",
|
||||||
["-I", "-c", expect.any(String)],
|
[
|
||||||
|
"python",
|
||||||
|
"list",
|
||||||
|
venvPath,
|
||||||
|
"--only-installed",
|
||||||
|
"--output-format",
|
||||||
|
"json",
|
||||||
|
],
|
||||||
{ encoding: "utf8" },
|
{ encoding: "utf8" },
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
new Error("interpreter missing"),
|
new Error("uv failed"),
|
||||||
"",
|
|
||||||
"not JSON",
|
"not JSON",
|
||||||
"null",
|
"null",
|
||||||
"{}",
|
"{}",
|
||||||
JSON.stringify({
|
"[]",
|
||||||
freethreaded: false,
|
'[{"key":""}]',
|
||||||
implementation: "pypy",
|
'[{"key":123}]',
|
||||||
implementationVersion: [7, 3, 24, "unknown", 0],
|
'[{"key":"first"},{"key":"second"}]',
|
||||||
pythonVersion: "3.11.15",
|
])("rejects uv failure or invalid results: %s", async (result) => {
|
||||||
}),
|
|
||||||
])("rejects interpreter failure or invalid metadata: %s", async (result) => {
|
|
||||||
if (result instanceof Error) {
|
if (result instanceof Error) {
|
||||||
mockExecFile.mockRejectedValue(result);
|
mockExecFile.mockRejectedValue(result);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@ outputs:
|
|||||||
python-version:
|
python-version:
|
||||||
description: "The Python version that was set."
|
description: "The Python version that was set."
|
||||||
python-runtime-id:
|
python-runtime-id:
|
||||||
description: "An opaque identifier for the activated venv's Python runtime, including implementation, full version, and free-threaded build information. Empty when activate-environment is false. The action fails if the activated runtime cannot be determined."
|
description: "An opaque identifier reported by uv for the activated venv's Python runtime, including implementation, full Python version, and free-threaded build information. Empty when activate-environment is false. The action fails if the activated runtime cannot be determined."
|
||||||
python-cache-hit:
|
python-cache-hit:
|
||||||
description: "A boolean value to indicate the Python cache entry was found"
|
description: "A boolean value to indicate the Python cache entry was found"
|
||||||
runs:
|
runs:
|
||||||
|
|||||||
+14
-37
@@ -102068,53 +102068,30 @@ function getResolutionStrategy() {
|
|||||||
|
|
||||||
// src/utils/python-runtime.ts
|
// src/utils/python-runtime.ts
|
||||||
var import_node_child_process = require("node:child_process");
|
var import_node_child_process = require("node:child_process");
|
||||||
var import_node_path2 = require("node:path");
|
|
||||||
var import_node_util4 = require("node:util");
|
var import_node_util4 = require("node:util");
|
||||||
var execFileAsync = (0, import_node_util4.promisify)(import_node_child_process.execFile);
|
var execFileAsync = (0, import_node_util4.promisify)(import_node_child_process.execFile);
|
||||||
var PYTHON_RUNTIME_QUERY = `
|
|
||||||
import json
|
|
||||||
import platform
|
|
||||||
import sys
|
|
||||||
import sysconfig
|
|
||||||
|
|
||||||
print(json.dumps({
|
|
||||||
"implementation": sys.implementation.name,
|
|
||||||
"implementationVersion": list(sys.implementation.version),
|
|
||||||
"pythonVersion": platform.python_version(),
|
|
||||||
"freethreaded": sysconfig.get_config_var("Py_GIL_DISABLED") == 1,
|
|
||||||
}))
|
|
||||||
`;
|
|
||||||
function formatRuntimeId(runtime) {
|
|
||||||
if (typeof runtime.implementation !== "string" || runtime.implementation === "" || typeof runtime.pythonVersion !== "string" || runtime.pythonVersion === "" || typeof runtime.freethreaded !== "boolean") {
|
|
||||||
throw new Error("Invalid Python runtime metadata");
|
|
||||||
}
|
|
||||||
let id = `cpython-${runtime.pythonVersion}`;
|
|
||||||
if (runtime.implementation !== "cpython") {
|
|
||||||
const [major2, minor2, micro, releaseLevel, serial] = runtime.implementationVersion;
|
|
||||||
const suffixes = { alpha: "a", beta: "b", candidate: "rc", final: "" };
|
|
||||||
const suffix = suffixes[releaseLevel];
|
|
||||||
if (suffix === void 0 || ![major2, minor2, micro, serial].every(
|
|
||||||
(part) => Number.isInteger(part) && part >= 0
|
|
||||||
)) {
|
|
||||||
throw new Error("Invalid Python implementation version");
|
|
||||||
}
|
|
||||||
const implementationVersion = `${major2}.${minor2}.${micro}${suffix}${suffix ? serial : ""}`;
|
|
||||||
id = `${runtime.implementation}-${implementationVersion}-python-${runtime.pythonVersion}`;
|
|
||||||
}
|
|
||||||
return runtime.freethreaded ? `${id}-freethreaded` : id;
|
|
||||||
}
|
|
||||||
async function getPythonRuntimeId(inputs) {
|
async function getPythonRuntimeId(inputs) {
|
||||||
if (!inputs.activateEnvironment) {
|
if (!inputs.activateEnvironment) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const pythonPath = process.platform === "win32" ? (0, import_node_path2.join)(inputs.venvPath, "Scripts", "python.exe") : (0, import_node_path2.join)(inputs.venvPath, "bin", "python");
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync(
|
const { stdout } = await execFileAsync(
|
||||||
pythonPath,
|
"uv",
|
||||||
["-I", "-c", PYTHON_RUNTIME_QUERY],
|
[
|
||||||
|
"python",
|
||||||
|
"list",
|
||||||
|
inputs.venvPath,
|
||||||
|
"--only-installed",
|
||||||
|
"--output-format",
|
||||||
|
"json"
|
||||||
|
],
|
||||||
{ encoding: "utf8" }
|
{ encoding: "utf8" }
|
||||||
);
|
);
|
||||||
return formatRuntimeId(JSON.parse(stdout));
|
const pythons = JSON.parse(stdout);
|
||||||
|
if (!Array.isArray(pythons) || pythons.length !== 1 || typeof pythons[0]?.key !== "string" || pythons[0].key === "") {
|
||||||
|
throw new Error("Expected one installed Python with a runtime key");
|
||||||
|
}
|
||||||
|
return pythons[0].key;
|
||||||
} catch (error2) {
|
} catch (error2) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to identify the activated environment's Python runtime: ${error2 instanceof Error ? error2.message : String(error2)}`,
|
`Failed to identify the activated environment's Python runtime: ${error2 instanceof Error ? error2.message : String(error2)}`,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ This allows directly using it in later steps:
|
|||||||
By default, the venv is created at `.venv` inside the `working-directory`.
|
By default, the venv is created at `.venv` inside the `working-directory`.
|
||||||
|
|
||||||
With `activate-environment: true`, the `python-runtime-id` output identifies the
|
With `activate-environment: true`, the `python-runtime-id` output identifies the
|
||||||
venv's Python runtime. This is an opaque identifier that users of the action
|
venv's Python runtime as reported by uv. This is an opaque identifier that users of the action
|
||||||
can use as a cache key if necessary; users should not assume anything about
|
can use as a cache key if necessary; users should not assume anything about
|
||||||
the stability or structure of the identifier itself.
|
the stability or structure of the identifier itself.
|
||||||
|
|
||||||
|
|||||||
+19
-64
@@ -1,82 +1,37 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { join } from "node:path";
|
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import type { SetupInputs } from "./inputs";
|
import type { SetupInputs } from "./inputs";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
const PYTHON_RUNTIME_QUERY = `
|
|
||||||
import json
|
|
||||||
import platform
|
|
||||||
import sys
|
|
||||||
import sysconfig
|
|
||||||
|
|
||||||
print(json.dumps({
|
|
||||||
"implementation": sys.implementation.name,
|
|
||||||
"implementationVersion": list(sys.implementation.version),
|
|
||||||
"pythonVersion": platform.python_version(),
|
|
||||||
"freethreaded": sysconfig.get_config_var("Py_GIL_DISABLED") == 1,
|
|
||||||
}))
|
|
||||||
`;
|
|
||||||
|
|
||||||
type ReleaseLevel = "alpha" | "beta" | "candidate" | "final";
|
|
||||||
|
|
||||||
interface PythonRuntime {
|
|
||||||
implementation: string;
|
|
||||||
implementationVersion: [number, number, number, ReleaseLevel, number];
|
|
||||||
pythonVersion: string;
|
|
||||||
freethreaded: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRuntimeId(runtime: PythonRuntime): string {
|
|
||||||
if (
|
|
||||||
typeof runtime.implementation !== "string" ||
|
|
||||||
runtime.implementation === "" ||
|
|
||||||
typeof runtime.pythonVersion !== "string" ||
|
|
||||||
runtime.pythonVersion === "" ||
|
|
||||||
typeof runtime.freethreaded !== "boolean"
|
|
||||||
) {
|
|
||||||
throw new Error("Invalid Python runtime metadata");
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = `cpython-${runtime.pythonVersion}`;
|
|
||||||
if (runtime.implementation !== "cpython") {
|
|
||||||
const [major, minor, micro, releaseLevel, serial] =
|
|
||||||
runtime.implementationVersion;
|
|
||||||
const suffixes = { alpha: "a", beta: "b", candidate: "rc", final: "" };
|
|
||||||
const suffix = suffixes[releaseLevel];
|
|
||||||
if (
|
|
||||||
suffix === undefined ||
|
|
||||||
![major, minor, micro, serial].every(
|
|
||||||
(part) => Number.isInteger(part) && part >= 0,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error("Invalid Python implementation version");
|
|
||||||
}
|
|
||||||
const implementationVersion = `${major}.${minor}.${micro}${suffix}${suffix ? serial : ""}`;
|
|
||||||
id = `${runtime.implementation}-${implementationVersion}-python-${runtime.pythonVersion}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return runtime.freethreaded ? `${id}-freethreaded` : id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPythonRuntimeId(inputs: SetupInputs): Promise<string> {
|
export async function getPythonRuntimeId(inputs: SetupInputs): Promise<string> {
|
||||||
if (!inputs.activateEnvironment) {
|
if (!inputs.activateEnvironment) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const pythonPath =
|
|
||||||
process.platform === "win32"
|
|
||||||
? join(inputs.venvPath, "Scripts", "python.exe")
|
|
||||||
: join(inputs.venvPath, "bin", "python");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync(
|
const { stdout } = await execFileAsync(
|
||||||
pythonPath,
|
"uv",
|
||||||
["-I", "-c", PYTHON_RUNTIME_QUERY],
|
[
|
||||||
|
"python",
|
||||||
|
"list",
|
||||||
|
inputs.venvPath,
|
||||||
|
"--only-installed",
|
||||||
|
"--output-format",
|
||||||
|
"json",
|
||||||
|
],
|
||||||
{ encoding: "utf8" },
|
{ encoding: "utf8" },
|
||||||
);
|
);
|
||||||
return formatRuntimeId(JSON.parse(stdout));
|
const pythons = JSON.parse(stdout);
|
||||||
|
if (
|
||||||
|
!Array.isArray(pythons) ||
|
||||||
|
pythons.length !== 1 ||
|
||||||
|
typeof pythons[0]?.key !== "string" ||
|
||||||
|
pythons[0].key === ""
|
||||||
|
) {
|
||||||
|
throw new Error("Expected one installed Python with a runtime key");
|
||||||
|
}
|
||||||
|
return pythons[0].key;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to identify the activated environment's Python runtime: ${error instanceof Error ? error.message : String(error)}`,
|
`Failed to identify the activated environment's Python runtime: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user