├── src
├── vite-env.d.ts
├── config
│ └── index.ts
├── setup.py
├── types.ts
├── utils.ts
├── worker.ts
└── main.ts
├── .husky
└── pre-commit
├── .eslintrc.json
├── .gitignore
├── .prettierrc.json
├── playground
├── index.html
└── logo.svg
├── tsconfig.json
├── LICENSE
├── vite.config.ts
├── CHANGELOG.md
├── package.json
├── CODE_OF_CONDUCT.md
└── README.md
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx lint-staged
5 |
--------------------------------------------------------------------------------
/src/config/index.ts:
--------------------------------------------------------------------------------
1 | const config = {
2 | loadPyodideOptions: {
3 | indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.23.2/full/',
4 | },
5 | };
6 |
7 | export default config;
8 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true
5 | },
6 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
7 | "parser": "@typescript-eslint/parser",
8 | "plugins": ["@typescript-eslint"],
9 | "parserOptions": {
10 | "ecmaVersion": "latest",
11 | "sourceType": "module"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 2,
3 | "useTabs": false,
4 | "semi": true,
5 | "singleQuote": true,
6 | "printWidth": 100,
7 | "trailingComma": "all",
8 | "importOrder": ["", "^@/(.*)$", "^[./]"],
9 | "importOrderGroupNamespaceSpecifiers": true,
10 | "importOrderSeparation": true,
11 | "importOrderSortSpecifiers": true
12 | }
13 |
--------------------------------------------------------------------------------
/playground/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | @x-python/core
8 |
9 |
10 |
11 |
12 |
13 |
check the console 👇
14 |
15 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
5 | "allowJs": false,
6 | "skipLibCheck": true,
7 | "esModuleInterop": false,
8 | "types": ["node"],
9 | "moduleResolution": "Node",
10 | "allowSyntheticDefaultImports": true,
11 | "strict": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "module": "ESNext",
14 | "isolatedModules": true,
15 | "noUnusedLocals": true,
16 | "noUnusedParameters": true,
17 | "noImplicitReturns": true,
18 | "noEmit": true,
19 | "jsx": "preserve",
20 | "baseUrl": "./",
21 | "paths": {
22 | "~/*": ["src/*"]
23 | },
24 | "typeRoots": ["node_modules/@types"]
25 | },
26 | "include": ["./src"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Suren Atoyan
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/setup.py:
--------------------------------------------------------------------------------
1 | # pyright: reportMissingImports=false
2 | # type: ignore
3 |
4 | import sys
5 | import micropip
6 | import asyncio
7 | sys.modules["_multiprocessing"] = object
8 |
9 | # micropip tries to install the latest version of a package
10 | # we pin the version to avoid breaking changes
11 | # here are the tested versions
12 | await micropip.install("jedi==0.19.1")
13 | await micropip.install("black==23.11.0")
14 |
15 | import jedi
16 | from black import format_str, FileMode
17 |
18 |
19 | def get_autocompletion(code, line, column):
20 | result = jedi.Interpreter(code, [globals(), locals()])
21 |
22 | completions = result.complete(line, column)
23 |
24 | matches = []
25 | for comp in completions:
26 | matches.append(dict(
27 | name=comp.name,
28 | type=comp.type,
29 | description=comp.description,
30 | full_name=comp.full_name
31 | ))
32 |
33 | return {
34 | "matches": matches
35 | }
36 |
37 |
38 | async def install_pacakge(package):
39 | try:
40 | await micropip.install(package, keep_going=True)
41 | return {
42 | "success": True
43 | }
44 | except Exception as e:
45 | return {
46 | "success": False,
47 | "error": str(e)
48 | }
49 |
50 |
51 | def format_code(code, options):
52 | if options:
53 | mode = FileMode(**options)
54 | return format_str(code, mode=mode)
55 | return format_str(code, mode=FileMode())
56 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from 'path';
2 | import { defineConfig } from 'vite';
3 | import dts from 'vite-plugin-dts';
4 | import type { ModuleFormat } from 'rollup';
5 |
6 | export default defineConfig({
7 | plugins: [
8 | dts({
9 | insertTypesEntry: true,
10 | rollupTypes: true,
11 | }),
12 | {
13 | // enable cross-origin-isolation for SharedArrayBuffer
14 | // https://web.dev/cross-origin-isolation-guide/#enable-cross-origin-isolation
15 | name: 'configure-response-headers',
16 | configureServer(server) {
17 | server.middlewares.use((_req, res, next) => {
18 | res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
19 | res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
20 | next();
21 | });
22 | },
23 | },
24 | ],
25 | build: {
26 | lib: {
27 | entry: resolve(__dirname, 'src/main.ts'),
28 | name: 'xPython',
29 | fileName: (format: ModuleFormat) => {
30 | switch (format) {
31 | case 'es':
32 | return 'x-python.js';
33 | case 'umd':
34 | return 'x-python.umd.js';
35 | }
36 | },
37 | },
38 | rollupOptions: {
39 | external: ['pyodide'],
40 | output: {
41 | globals: {
42 | pyodide: 'pyodide',
43 | },
44 | },
45 | },
46 | },
47 | server: {
48 | open: './playground/index.html',
49 | },
50 | worker: {
51 | format: 'es',
52 | },
53 | });
54 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## 0.0.10
2 |
3 | ###### _Dec 13, 2023_
4 |
5 | - pin `jedi` to `black` versions
6 |
7 | ## 0.0.9
8 |
9 | ###### _May 22, 2023_
10 |
11 | - add `options` to `format` method
12 | - replace deprecated `pyodide.isProxy` with `instanceof pyodide.ffi.PyProxy`
13 | - import `PyProxy` from `pyodide/ffi` instead of `pyodide`
14 | - update `pyodide` to `0.23.2`
15 |
16 | ## 0.0.8
17 |
18 | ###### _Apr 9, 2023_
19 |
20 | - update `pyodide` to `0.23.0`
21 |
22 | ## 0.0.7
23 |
24 | ###### _Apr 9, 2023_
25 |
26 | - export all types
27 |
28 | ## 0.0.6
29 |
30 | ###### _Jan 4, 2023_
31 |
32 | - fix `husky` issue by making `pre-commit` script executable
33 | - make context-aware js functions available in python environment
34 | - implement `interrupt` method with `interruptBuffer` (`SharedArrayBuffer`)
35 |
36 | ## 0.0.5
37 |
38 | ###### _Dec 28, 2022_
39 |
40 | - remove `immer` from dependencies
41 | - add separate types for all callbacks
42 | - add `removeCallback` and `addCallback` utility functions
43 | - replace `immer` with `removeCallback` and `addCallback` utility functions
44 |
45 | ## 0.0.4
46 |
47 | ###### _Dec 28, 2022_
48 |
49 | - create a separate field in config for `loadPyodide` options
50 | - fully pass `config.loadPyodideOptions` to `loadPyodide`
51 |
52 | ## 0.0.3
53 |
54 | ###### _Dec 28, 2022_
55 |
56 | - add cjs version of the bundle
57 |
58 | ## 0.0.2
59 |
60 | ###### _Dec 28, 2022_
61 |
62 | - add file references for unpkg/jsdelivr/module/main
63 | - rename bundle prefix (to x-python)
64 |
65 | ## 0.0.1
66 |
67 | ###### _Dec 28, 2022_
68 |
69 | 🎉 First release
70 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@x-python/core",
3 | "version": "0.0.10",
4 | "type": "module",
5 | "license": "MIT",
6 | "homepage": "https://github.com/suren-atoyan/x-python",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/suren-atoyan/x-python.git"
10 | },
11 | "bugs": {
12 | "url": "https://github.com/suren-atoyan/x-python/issues"
13 | },
14 | "keywords": [
15 | "python",
16 | "python in browser",
17 | "javascript",
18 | "webassembly"
19 | ],
20 | "main": "./dist/x-python.umd.js",
21 | "module": "./dist/x-python.js",
22 | "unpkg": "./dist/x-python.umd.js",
23 | "jsdelivr": "./dist/x-python.umd.js",
24 | "types": "./dist/main.d.ts",
25 | "scripts": {
26 | "dev": "vite",
27 | "lint": "npx eslint src",
28 | "build": "tsc && vite build",
29 | "build:watch": "tsc && vite build --watch",
30 | "prepublishOnly": "npm run lint && npm run build && rm -rf ./dist/assets",
31 | "prepare": "husky install"
32 | },
33 | "devDependencies": {
34 | "@types/node": "^18.11.17",
35 | "@typescript-eslint/eslint-plugin": "^5.47.1",
36 | "eslint": "^8.30.0",
37 | "husky": "^7.0.0",
38 | "lint-staged": "^13.1.0",
39 | "prettier": "^2.8.1",
40 | "typescript": "^4.9.3",
41 | "vite": "^4.0.0",
42 | "vite-plugin-dts": "^1.7.1"
43 | },
44 | "files": [
45 | "dist"
46 | ],
47 | "exports": {
48 | ".": {
49 | "import": "./dist/x-python.js",
50 | "require": "./dist/x-python.umd.js"
51 | }
52 | },
53 | "dependencies": {
54 | "pyodide": "^0.23.2",
55 | "state-local": "^1.0.7"
56 | },
57 | "lint-staged": {
58 | "src/**/*.{js,ts,json,md}": [
59 | "prettier --write"
60 | ],
61 | "src/**/*.{js,ts,json}": [
62 | "eslint --max-warnings=0"
63 | ]
64 | },
65 | "author": {
66 | "name": "Suren Atoyan",
67 | "email": "contact@surenatoyan.com",
68 | "url": "http://surenatoyan.com/"
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | - Using welcoming and inclusive language
18 | - Being respectful of differing viewpoints and experiences
19 | - Gracefully accepting constructive criticism
20 | - Focusing on what is best for the community
21 | - Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | - The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | - Trolling, insulting/derogatory comments, and personal or political attacks
28 | - Public or private harassment
29 | - Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | - Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at contact@surenatoyan.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | // payloads
2 | type ExecPayload = {
3 | code: string;
4 | context?: Context;
5 | };
6 |
7 | type Context = Record;
8 |
9 | type CompletePayload = {
10 | code: string;
11 | line?: number;
12 | column?: number;
13 | };
14 |
15 | type InstallPayload = {
16 | packages: string[];
17 | };
18 |
19 | type FormatPayload = {
20 | code: string;
21 | options?: Record;
22 | };
23 |
24 | type JSFnCallPayload = {
25 | args: unknown[];
26 | name: string;
27 | };
28 |
29 | type Payload = ExecPayload | CompletePayload | InstallPayload | FormatPayload | JSFnCallPayload;
30 | // ====== ** ======
31 |
32 | // callbacks
33 | type Callback = {
34 | resolve: (value: T) => void;
35 | reject: (value: string) => void;
36 | };
37 |
38 | type Callbacks = Record>;
39 |
40 | type ActionCallbacks = Callbacks;
41 | type JSCallbacks = Callbacks;
42 |
43 | type JSFunctions = {
44 | // eslint-disable-next-line @typescript-eslint/ban-types
45 | [name: string]: Function;
46 | };
47 | // ====== ** ======
48 |
49 | // params
50 | type ExecResponse = {
51 | id: CommandUniqueId;
52 | error: string | null;
53 | result: string | null;
54 | stdout: string | null;
55 | stderr: string | null;
56 | };
57 |
58 | type CompleteResponse = {
59 | result: CompletionResults;
60 | id: CommandUniqueId;
61 | error: string | null;
62 | };
63 |
64 | type InstallResponse = {
65 | id: CommandUniqueId;
66 | success: boolean;
67 | error: string | null;
68 | };
69 |
70 | type FormatResponse = {
71 | id: CommandUniqueId;
72 | result: string | null;
73 | error: string | null;
74 | };
75 |
76 | type JSFnCallResponse = {
77 | id: CommandUniqueId;
78 | result: unknown;
79 | error?: string | null;
80 | };
81 |
82 | type Response = ExecResponse | CompleteResponse | InstallResponse | FormatResponse;
83 | // ====== ** ======
84 |
85 | // return values
86 | type ExecReturnValue = Omit;
87 | type CompleteReturnValue = Omit;
88 | type InstallReturnValue = Omit;
89 | type FormatReturnValue = Omit;
90 | type JSFnCallReturnValue = Omit;
91 | type ActionReturnValue =
92 | | ExecReturnValue
93 | | CompleteReturnValue
94 | | InstallReturnValue
95 | | FormatReturnValue
96 | | JSFnCallReturnValue;
97 | // ====== ** ======
98 |
99 | type ChannelTransmitData = {
100 | id: CommandUniqueId;
101 | action: ActionType;
102 | data: Payload | Response;
103 | };
104 |
105 | enum ChannelSetupStatus {
106 | READY,
107 | }
108 |
109 | enum ActionType {
110 | EXEC,
111 | COMPLETE,
112 | INSTALL,
113 | FORMAT,
114 | JS_FN_CALL,
115 | }
116 |
117 | enum PayloadType {
118 | FN = '__function__',
119 | }
120 |
121 | type CompletionResult = {
122 | name: string;
123 | type: string;
124 | description: string;
125 | full_name: string;
126 | };
127 |
128 | type CompletionMatch = CompletionResult;
129 |
130 | type CompletionResults = {
131 | matches: CompletionMatch[];
132 | };
133 |
134 | type CommandUniqueId = number;
135 |
136 | type MainModuleState = {
137 | config: object;
138 | pyodideWorker: Worker | null;
139 | callbacks: ActionCallbacks;
140 | commandUniqueId: CommandUniqueId;
141 | jsFunctions: JSFunctions;
142 | interruptBuffer: Uint8Array | null;
143 | };
144 |
145 | type WorkerModuleState = {
146 | callbacks: JSCallbacks;
147 | commandUniqueId: CommandUniqueId;
148 | };
149 |
150 | type ComplexPayload = {
151 | type: PayloadType;
152 | name: string;
153 | };
154 |
155 | export { ChannelSetupStatus, ActionType, PayloadType };
156 | export type {
157 | // payloads
158 | Payload,
159 | ExecPayload,
160 | CompletePayload,
161 | InstallPayload,
162 | FormatPayload,
163 | ComplexPayload,
164 | JSFnCallPayload,
165 | Context,
166 | // ====== ** ======
167 | // callbacks
168 | Callback,
169 | ActionCallbacks,
170 | JSCallbacks,
171 | JSFunctions,
172 | // ====== ** ======
173 | // params
174 | Response,
175 | ExecResponse,
176 | CompleteResponse,
177 | InstallResponse,
178 | FormatResponse,
179 | JSFnCallResponse,
180 | // return values
181 | ActionReturnValue,
182 | ExecReturnValue,
183 | CompleteReturnValue,
184 | InstallReturnValue,
185 | FormatReturnValue,
186 | JSFnCallReturnValue,
187 | // ====== ** ======
188 | ChannelTransmitData,
189 | CompletionResult,
190 | CompletionResults,
191 | MainModuleState,
192 | WorkerModuleState,
193 | CommandUniqueId,
194 | };
195 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import type { PyodideInterface } from 'pyodide';
2 | import type { PyProxy } from 'pyodide/ffi';
3 |
4 | import { CommandUniqueId, Callback, ActionCallbacks, JSFunctions, JSCallbacks } from './types';
5 |
6 | const WRAPPER_FUNCTION_NAME = 'wrapper';
7 |
8 | function converteToJs(result: PyProxy, pyodide: PyodideInterface) {
9 | const convertedToJs =
10 | result?.toJs?.({
11 | dict_converter: Object.fromEntries,
12 | create_pyproxies: false,
13 | }) || result;
14 |
15 | const converted =
16 | convertedToJs instanceof pyodide.ffi.PyProxy ? convertedToJs.toString() : convertedToJs;
17 |
18 | if (ArrayBuffer.isView(converted)) {
19 | const text = new TextDecoder().decode(converted);
20 | result.getBuffer().release();
21 | return text;
22 | }
23 |
24 | return converted;
25 | }
26 |
27 | const extractModuleExecptionLineRegExp = /File "", line (\d+)($|, in )$/m;
28 | const extractWrapperExecptionLineRegExp = new RegExp(
29 | `File "", line (\\d+), in ${WRAPPER_FUNCTION_NAME}`,
30 | 'm',
31 | );
32 | const lineNumberRegExp = /line (\d+)/g;
33 |
34 | function replaceLineNumber(messageLine: string): string {
35 | return messageLine.replace(lineNumberRegExp, (_, lineNumber) => {
36 | return `line ${+lineNumber - 1}`;
37 | });
38 | }
39 |
40 | function extractMainErrorMessage(message: string) {
41 | const doesContainModuleExecptionLine = extractModuleExecptionLineRegExp.test(message);
42 | const doesContainWrapperExecptionLine = extractWrapperExecptionLineRegExp.test(message);
43 |
44 | if (!(doesContainModuleExecptionLine || doesContainWrapperExecptionLine)) return message;
45 |
46 | const errorMessageLines = message.split('\n');
47 |
48 | const firstLineIndex = errorMessageLines.findIndex((line) =>
49 | doesContainModuleExecptionLine
50 | ? extractModuleExecptionLineRegExp.test(line)
51 | : extractWrapperExecptionLineRegExp.test(line),
52 | );
53 |
54 | // TODO (Suren): this should be removed once we import this file as module
55 | let skipOtherLines = false;
56 |
57 | return errorMessageLines
58 | .slice(firstLineIndex)
59 | .reduce((acc: string[], messageLine) => {
60 | if (skipOtherLines) {
61 | acc.push(replaceLineNumber(messageLine));
62 | } else {
63 | if (doesContainModuleExecptionLine && !doesContainWrapperExecptionLine) {
64 | if (extractModuleExecptionLineRegExp.test(messageLine)) {
65 | skipOtherLines = true;
66 | acc.push(replaceLineNumber(messageLine).replace(', in ', ''));
67 | return acc;
68 | }
69 | }
70 |
71 | if (doesContainWrapperExecptionLine) {
72 | if (extractWrapperExecptionLineRegExp.test(messageLine)) {
73 | skipOtherLines = true;
74 | acc.push(replaceLineNumber(messageLine).replace(`, in ${WRAPPER_FUNCTION_NAME}`, ''));
75 | return acc;
76 | }
77 | }
78 | }
79 |
80 | return acc;
81 | }, [])
82 | .join('\n');
83 | }
84 |
85 | function once(fn: () => T) {
86 | let res: T;
87 |
88 | return () => {
89 | if (!res) {
90 | res = fn();
91 | }
92 |
93 | return res;
94 | };
95 | }
96 |
97 | function ensureCallbackIdExists(id: CommandUniqueId, doesIdExist: boolean) {
98 | if (!doesIdExist) {
99 | throw new Error(`a wrong id is provided from worker - callback with ${id} id doesn't exist`);
100 | }
101 | }
102 |
103 | // NOTE: the below utilities will be replaced with immer in the next version
104 | function removeCallback(callbacks: ActionCallbacks, removingId: CommandUniqueId) {
105 | // eslint-disable-next-line
106 | const { [removingId]: removingCallback, ...rest } = callbacks;
107 |
108 | return rest;
109 | }
110 |
111 | function addCallback(
112 | callbacks: ActionCallbacks | JSCallbacks,
113 | id: CommandUniqueId | string,
114 | callback: Callback,
115 | ) {
116 | return { ...callbacks, [id]: callback };
117 | }
118 |
119 | function addJsFunction(
120 | jsFunctions: JSFunctions,
121 | id: CommandUniqueId | string,
122 | // eslint-disable-next-line @typescript-eslint/ban-types
123 | jsFunction: Function,
124 | ) {
125 | return { ...jsFunctions, [id]: jsFunction };
126 | }
127 |
128 | function removeJsFunction(jsFunctions: JSFunctions, fnName: string) {
129 | // eslint-disable-next-line
130 | const { [fnName]: removingFunctionName, ...rest } = jsFunctions;
131 |
132 | return rest;
133 | }
134 |
135 | export {
136 | extractMainErrorMessage,
137 | converteToJs,
138 | once,
139 | ensureCallbackIdExists,
140 | removeCallback,
141 | addCallback,
142 | addJsFunction,
143 | removeJsFunction,
144 | };
145 |
--------------------------------------------------------------------------------
/playground/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
110 |
--------------------------------------------------------------------------------
/src/worker.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-case-declarations */
2 | import type { PyodideInterface } from 'pyodide';
3 | import { loadPyodide } from 'pyodide';
4 |
5 | import config from './config';
6 | import pythonSetupCode from './setup.py?raw';
7 | import state from 'state-local';
8 | import {
9 | ChannelTransmitData,
10 | CompletePayload,
11 | CompleteReturnValue,
12 | ComplexPayload,
13 | ExecPayload,
14 | ExecReturnValue,
15 | FormatPayload,
16 | FormatReturnValue,
17 | InstallPayload,
18 | InstallReturnValue,
19 | PayloadType,
20 | WorkerModuleState,
21 | JSFnCallReturnValue,
22 | CommandUniqueId,
23 | } from './types';
24 | import { ActionType, ChannelSetupStatus } from './types';
25 | import { addCallback, converteToJs, extractMainErrorMessage } from './utils';
26 |
27 | /** the local state of the module */
28 | const [getState, setState] = state.create({
29 | callbacks: {},
30 | commandUniqueId: 0,
31 | } as WorkerModuleState);
32 |
33 | async function main() {
34 | // eslint-disable-next-line no-restricted-globals
35 | const pyodide: PyodideInterface = await loadPyodide(config.loadPyodideOptions);
36 |
37 | await pyodide.loadPackage('micropip');
38 | await pyodide.runPythonAsync(pythonSetupCode);
39 |
40 | let interruptBuffer;
41 |
42 | if (self.SharedArrayBuffer) {
43 | interruptBuffer = new Uint8Array(new self.SharedArrayBuffer(1));
44 | pyodide.setInterruptBuffer(interruptBuffer);
45 | }
46 |
47 | postMessage({
48 | status: ChannelSetupStatus.READY,
49 | interruptBuffer,
50 | });
51 |
52 | const actions = {
53 | [ActionType.EXEC]: async ({ code, context = {} }: ExecPayload): Promise => {
54 | const callbackIdsToCleanUp: CommandUniqueId[] = [];
55 | // Set context values to global python namespace
56 | Object.entries(context).forEach(([variableName, value]) => {
57 | if ((value as ComplexPayload)?.type === PayloadType.FN) {
58 | pyodide.globals.set(
59 | variableName,
60 | generateComplexPayloadHandler(variableName, callbackIdsToCleanUp),
61 | );
62 | } else {
63 | pyodide.globals.set(variableName, pyodide.toPy(value));
64 | }
65 | });
66 |
67 | try {
68 | await pyodide.loadPackagesFromImports(code);
69 | // clear stdout & stderr before each run
70 | pyodide.runPython('import sys, io; sys.stdout = io.StringIO(); sys.stderr = io.StringIO()');
71 | const result = await pyodide.runPythonAsync(code);
72 | const stdout = pyodide.runPython('import sys; sys.stdout.getvalue()').trim();
73 | const stderr = pyodide.runPython('import sys; sys.stderr.getvalue()').trim();
74 |
75 | return {
76 | result: converteToJs(result, pyodide),
77 | stdout: converteToJs(stdout, pyodide),
78 | stderr,
79 | error: null,
80 | };
81 | } catch (error) {
82 | return {
83 | result: null,
84 | stdout: null,
85 | stderr: null,
86 | error: extractMainErrorMessage((error as Error).message),
87 | };
88 | } finally {
89 | const { callbacks } = getState() as WorkerModuleState;
90 | // Remove context values from global python namespace.
91 | // NOTE: there is an open issue related to this
92 | // https://github.com/pyodide/pyodide/issues/703
93 | // People were looking for an option like
94 | // pyodide.globals.clean() or similar.
95 | // The issue with that is the fact that pyodide.globals contains builtins,
96 | // like '__name__', '__doc__', '__package__', '__loader__', '__spec__', etc.
97 | // So, currently, we do the "cleanup" process manually.
98 | Object.entries(context).forEach(([variableName]) => {
99 | pyodide.globals.set(variableName, pyodide.toPy(null)?.toString());
100 | });
101 |
102 | callbackIdsToCleanUp.forEach((id) => {
103 | delete callbacks[id];
104 | });
105 | }
106 | },
107 | [ActionType.COMPLETE]: async ({
108 | code,
109 | line,
110 | column,
111 | }: CompletePayload): Promise => {
112 | await pyodide.loadPackagesFromImports(code);
113 | const completions = pyodide.globals.get('get_autocompletion')(code, line, column);
114 |
115 | return { result: converteToJs(completions, pyodide), error: null };
116 | },
117 | [ActionType.INSTALL]: async ({ packages }: InstallPayload): Promise => {
118 | const installData = await pyodide.globals.get('install_pacakge')(packages[0]);
119 |
120 | return converteToJs(installData, pyodide);
121 | },
122 | [ActionType.FORMAT]: async ({
123 | code,
124 | options = {},
125 | }: FormatPayload): Promise => {
126 | const formatted = pyodide.globals.get('format_code')(code, pyodide.toPy(options));
127 |
128 | return { result: converteToJs(formatted, pyodide), error: null };
129 | },
130 | };
131 |
132 | onmessage = async function onmessage(event: MessageEvent) {
133 | const { id, action = ActionType.EXEC, data } = event.data;
134 |
135 | if (action === ActionType.JS_FN_CALL) {
136 | handleJSFnResponse(data as JSFnCallReturnValue, id);
137 | } else {
138 | // TODO (Suren): simplify this
139 | let result;
140 | switch (action) {
141 | case ActionType.EXEC:
142 | result = await actions[ActionType.EXEC](data as ExecPayload);
143 | break;
144 | case ActionType.COMPLETE:
145 | result = await actions[ActionType.COMPLETE](data as CompletePayload);
146 | break;
147 | case ActionType.INSTALL:
148 | result = await actions[ActionType.INSTALL](data as InstallPayload);
149 | break;
150 | case ActionType.FORMAT:
151 | result = await actions[ActionType.FORMAT](data as FormatPayload);
152 | break;
153 | }
154 |
155 | postMessage({
156 | data: result,
157 | id,
158 | action,
159 | });
160 | }
161 | };
162 |
163 | // * ================= * //
164 | function handleJSFnResponse(data: JSFnCallReturnValue, id: CommandUniqueId) {
165 | const { result, error } = data;
166 | const { callbacks } = getState() as WorkerModuleState;
167 |
168 | if (error) {
169 | callbacks[id].reject(error);
170 | }
171 |
172 | callbacks[id].resolve(result);
173 | }
174 |
175 | function generateComplexPayloadHandler(name: string, callbackIdsToCleanUp: CommandUniqueId[]) {
176 | return async (...args: unknown[]) => {
177 | return new Promise((resolve, reject) => {
178 | const { callbacks, commandUniqueId } = getState() as WorkerModuleState;
179 |
180 | postMessage({
181 | action: ActionType.JS_FN_CALL,
182 | data: {
183 | args,
184 | name,
185 | },
186 | id: commandUniqueId,
187 | });
188 |
189 | callbackIdsToCleanUp.push(commandUniqueId);
190 |
191 | setState({
192 | callbacks: addCallback(callbacks, commandUniqueId, {
193 | resolve,
194 | reject,
195 | }),
196 | commandUniqueId: commandUniqueId + 1,
197 | });
198 | });
199 | };
200 | }
201 | }
202 |
203 | main();
204 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * xPython module
3 | * @module xPython
4 | */
5 | import state from 'state-local';
6 |
7 | import defaultConfig from './config';
8 | import {
9 | ActionType,
10 | ChannelSetupStatus,
11 | CompletePayload,
12 | CompletionResults,
13 | ExecPayload,
14 | ExecReturnValue,
15 | Response,
16 | FormatPayload,
17 | FormatReturnValue,
18 | InstallReturnValue,
19 | MainModuleState,
20 | Payload,
21 | Context,
22 | PayloadType,
23 | JSFnCallPayload,
24 | CommandUniqueId,
25 | ChannelTransmitData,
26 | ActionReturnValue,
27 | } from './types';
28 | import { ensureCallbackIdExists, once, removeCallback, addCallback, addJsFunction } from './utils';
29 |
30 | import Worker from './worker?worker&inline';
31 |
32 | const hasWorkerSupport = Boolean(globalThis.Worker);
33 |
34 | /** the local state of the module */
35 | const [getState, setState] = state.create({
36 | config: defaultConfig,
37 | pyodideWorker: null,
38 | // To avoid chronological mismatches and to support a robust
39 | // system for non-sequential executions
40 | // we define an identifier (an incrementing number) for
41 | // each execution call and we do keep a mapping between ids and callbacks
42 |
43 | // this particular method for handling above described issue
44 | // was taken from the official pyodide documentation
45 | // https://pyodide.org/en/stable/usage/webworker.html#the-worker-api
46 | callbacks: {},
47 | commandUniqueId: 0,
48 | jsFunctions: {},
49 | interruptBuffer: null,
50 | } as MainModuleState);
51 |
52 | const channel = {
53 | async ensureWorkerIsSetup() {
54 | const { pyodideWorker } = getState() as MainModuleState;
55 |
56 | if (!pyodideWorker) {
57 | await init();
58 | }
59 | },
60 | async command(data: Payload | ActionReturnValue, action: ActionType, id?: CommandUniqueId) {
61 | await channel.ensureWorkerIsSetup();
62 | const { commandUniqueId, pyodideWorker, interruptBuffer } = getState() as MainModuleState;
63 |
64 | // clear interruptBuffer in case it was accidentally left set after previous code completed.
65 | if (interruptBuffer) interruptBuffer[0] = 0;
66 |
67 | if (!id) {
68 | setState({ commandUniqueId: commandUniqueId + 1 });
69 | }
70 |
71 | pyodideWorker?.postMessage({ data, id: id ?? commandUniqueId, action });
72 | },
73 | };
74 |
75 | const init = once>(function init(): Promise {
76 | return new Promise((resolve, reject) => {
77 | if (!hasWorkerSupport) {
78 | reject(new Error('your browser does\nt support web workers!'));
79 | }
80 |
81 | const pyodideWorker: Worker = new Worker();
82 |
83 | pyodideWorker.onmessage = function onmessage(event) {
84 | if (event.data?.status === ChannelSetupStatus.READY) {
85 | setState({ pyodideWorker, interruptBuffer: event.data.interruptBuffer });
86 |
87 | pyodideWorker.onmessage = function onmessage(event: MessageEvent) {
88 | const { action, id, data } = event.data;
89 |
90 | // All messages received from the python worker will land here.
91 | // Here we branch out to two main message types.
92 | // One is the response to different actions that we sent before from the main thread.
93 | // We call it `handleActionResponse` - image one called `xPython.exec({ code: '1 + 1' })`;
94 | // we send `exec` action to the python worker, we receive the result and `handleActionResponse` is for handling that response.
95 | // Another one is for handling JS function calls that were being called from python environment.
96 | // This time the initiator is the python worker and in the main thread we just handle that command from
97 | // python environemnt.
98 | switch (action) {
99 | case ActionType.JS_FN_CALL:
100 | handleJSFnCaLL(id, data as JSFnCallPayload);
101 | break;
102 | default:
103 | handleActionResponse(id, data as Response);
104 | break;
105 | }
106 | };
107 |
108 | resolve(pyodideWorker);
109 | } else {
110 | reject(new Error('unexpected error in setup process'));
111 | }
112 | };
113 |
114 | pyodideWorker.onerror = function onerror(error: ErrorEvent) {
115 | reject(error.message);
116 | };
117 | });
118 | });
119 |
120 | async function exec(payload: ExecPayload): Promise {
121 | return new Promise((resolve, reject) => {
122 | const { callbacks, commandUniqueId } = getState() as MainModuleState;
123 |
124 | channel.command(sanitizePayload(payload), ActionType.EXEC);
125 |
126 | // TODO (Suren): remove js functions related to this command
127 |
128 | setState({
129 | callbacks: addCallback(callbacks, commandUniqueId, { resolve, reject }),
130 | });
131 | });
132 | }
133 |
134 | const complete = {
135 | async repl(payload: CompletePayload): Promise {
136 | return new Promise((resolve, reject) => {
137 | const { commandUniqueId, callbacks } = getState() as MainModuleState;
138 |
139 | const { code, line, column } = payload;
140 |
141 | let normalizeLine = line;
142 | let normalizeColumn = column;
143 |
144 | if (!line) {
145 | // if line is not provided
146 | // we will make it so
147 | // like the cursor on the last line
148 | normalizeLine = code.split('\n').length;
149 | }
150 |
151 | if (!normalizeColumn) {
152 | // if column is not provided
153 | // we will make it so
154 | // like the cursor on the last column
155 | normalizeColumn = code.split('\n')[(normalizeLine as number) - 1].length;
156 | }
157 |
158 | channel.command({ code, line: normalizeLine, column: normalizeColumn }, ActionType.COMPLETE);
159 |
160 | setState({
161 | callbacks: addCallback(callbacks, commandUniqueId, {
162 | resolve,
163 | reject,
164 | }),
165 | });
166 | });
167 | },
168 | };
169 |
170 | async function install(packages: string[]) {
171 | return new Promise((resolve, reject) => {
172 | const { callbacks, commandUniqueId } = getState() as MainModuleState;
173 |
174 | channel.command({ packages }, ActionType.INSTALL);
175 |
176 | setState({
177 | callbacks: addCallback(callbacks, commandUniqueId, {
178 | resolve,
179 | reject,
180 | }),
181 | });
182 | });
183 | }
184 |
185 | async function format(payload: FormatPayload): Promise {
186 | return new Promise((resolve, reject) => {
187 | const { callbacks, commandUniqueId } = getState() as MainModuleState;
188 |
189 | channel.command(payload, ActionType.FORMAT);
190 |
191 | setState({
192 | callbacks: addCallback(callbacks, commandUniqueId, {
193 | resolve,
194 | reject,
195 | }),
196 | });
197 | });
198 | }
199 |
200 | function interrupt() {
201 | const { interruptBuffer } = getState() as MainModuleState;
202 |
203 | if (!globalThis.SharedArrayBuffer) {
204 | throw new Error(`
205 | \`.interrupt\` method uses SharedArrayBuffer which requires "cross-origin-isolation" to be enabled.
206 | To enable "cross-origin-isolation" check this article - https://web.dev/cross-origin-isolation-guide/#enable-cross-origin-isolation
207 | `);
208 | }
209 |
210 | if (interruptBuffer) interruptBuffer[0] = 2;
211 | }
212 |
213 | // * ============== * //
214 |
215 | function handleActionResponse(id: CommandUniqueId, data: ActionReturnValue) {
216 | const { callbacks } = getState() as MainModuleState;
217 |
218 | ensureCallbackIdExists(id, Boolean(callbacks[id]));
219 |
220 | const { resolve, reject } = callbacks[id];
221 |
222 | setState({
223 | callbacks: removeCallback(callbacks, id),
224 | });
225 |
226 | if (data.error) {
227 | reject?.(data.error);
228 | return;
229 | }
230 |
231 | resolve(data);
232 | }
233 |
234 | async function handleJSFnCaLL(id: CommandUniqueId, { args, name }: JSFnCallPayload) {
235 | const { jsFunctions } = getState() as MainModuleState;
236 |
237 | let result, error;
238 |
239 | try {
240 | result = await jsFunctions[name]?.(...args);
241 | } catch (err) {
242 | error = err as string;
243 | }
244 |
245 | channel.command({ result, error }, ActionType.JS_FN_CALL, id);
246 | }
247 |
248 | // `context` object will be passed to python through a separate thread/worker.
249 | // When you postMessage a datum from one thread to another
250 | // that datum is being cloned via "structured clone algorithm".
251 | // functions cannot be duplicated by the structured clone algorithm, as well as
252 | // classes, DOM nodes, etc.
253 | // Here we do replace all functions in the context with `ComplexPayload`s.
254 | // `ComplexPayload`s have a "cloneable" structure and can be passed to another thread.
255 | // They will be treated differently before passing to the python environment.
256 | function replaceFunctions(context: Context): Context {
257 | const { jsFunctions } = getState() as MainModuleState;
258 |
259 | return Object.entries(context).reduce((acc, [key, value]) => {
260 | if (typeof value === 'function') {
261 | acc[key] = {
262 | type: PayloadType.FN,
263 | name: value.name,
264 | };
265 |
266 | setState({
267 | jsFunctions: addJsFunction(jsFunctions, value.name, value),
268 | });
269 | } else {
270 | acc[key] = value;
271 | }
272 |
273 | return acc;
274 | }, {} as Context);
275 | }
276 |
277 | function sanitizePayload(payload: ExecPayload): ExecPayload {
278 | if (payload.context) {
279 | return {
280 | ...payload,
281 | context: replaceFunctions(payload.context),
282 | };
283 | }
284 |
285 | return payload;
286 | }
287 |
288 | export * from './types';
289 | export { init, exec, complete, install, format, interrupt };
290 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # @x-python/core · [](https://www.npmjs.com/package/@x-python/core) [](https://github.com/suren-atoyan/x-python/blob/master/LICENSE) [](https://www.npmjs.com/package/@x-python/core) [](https://github.com/suren-atoyan/x-python/pulls)
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | A complete solution for python-in-browser. (check the [Usage](#usage) section :point_down:)
10 |
11 |
12 |
13 | 🔥 A REPL powered by `xPython` as `React` component is coming soon
14 |
15 | ⌛️ It's still in beta testing
16 |
17 |
18 |
19 | ## Synopsis
20 |
21 | Clean API to execute Python code, get code completions, format the code, install packages, and many more.
22 |
23 | ## Motivation
24 |
25 | In the past few years we used python in production browser-based applications in quite different scenarios. From just executing a python code to implementing autoformat and autocomplete. And while all of this is possible there are many questionable situations in the implementations. For example, it's been a while since we've had a full `Python` distribution for the browser based on webassembly - [pyodide](https://pyodide.org/en/stable/). `Pyodide` is great! You can just install and use it. But to use it correctly, instead of installing it in the main (UI) thread, it would be desirable to install/run it in a separate thread. Once you've created a separate thread you need to create a channel between main/UI and pyodide worker and come up with some protocol for communication. You also need to do something with error handling, handling standard output streams, non-sequential executions and other all possible corner cases. This was one of the things that `xPython` will handle for you :slightly_smiling_face: It also provides a clean API for code completion, installing packages, and formatting the code... and many more are coming soon. Long story short I tried to provide a clean and complete interface to interact with Python in browser-based applications.
26 |
27 | ## Documentation
28 |
29 | #### Contents
30 |
31 | - [Installation](#installation)
32 | - [Usage](#usage)
33 | - [API](#api)
34 | - [.init](#init)
35 | - [.exec](#exec)
36 | - [.complete](#complete)
37 | - [.repl](#complete)
38 | - [.format](#format)
39 | - [.install](#install)
40 | - [Development](#development)
41 |
42 | ### Installation
43 |
44 | ```bash
45 | npm install @x-python/core
46 | ```
47 |
48 | or
49 |
50 | ```bash
51 | yarn add @x-python/core
52 | ```
53 |
54 | ### Usage
55 |
56 | ```javascript
57 | import * as xPython from '@x-python/core';
58 |
59 | // initialize xPython
60 | await xPython.init();
61 |
62 | // execute python code
63 | await xPython.exec({ code: '1 + 1' });
64 | await xPython.exec({ code: 'print("test")' });
65 |
66 | // multiline example
67 | await xPython.exec({
68 | code: `
69 | import sys
70 | sys.version
71 | `,
72 | });
73 |
74 | // you can use built-in packages without additionally installing them
75 | await xPython.exec({
76 | code: `
77 | import numpy as np
78 | np.random.rand()
79 | `,
80 | });
81 |
82 | // code completion
83 | await xPython.complete.repl({ code: `import sys; sys.ver` });
84 |
85 | // specify the cursor position
86 | await xPython.complete.repl({
87 | code: `
88 | from math import factorial
89 |
90 | test = 8
91 | print(tes)
92 | factorial(x)
93 | `,
94 | line: 5,
95 | column: 9,
96 | });
97 |
98 | // format the code
99 | const { result } = await xPython.format({
100 | code: `
101 | def add(a, b):
102 | return a + b
103 |
104 | print(add(12,
105 |
106 | 54))
107 | `,
108 | });
109 |
110 | console.log(result);
111 |
112 | // install packages
113 | await xPython.install(['nicelog']);
114 |
115 | // and use the newly installed package :)
116 | const { stderr } = await xPython.exec({
117 | code: `
118 | import logging
119 | import sys
120 |
121 | from nicelog.formatters import Colorful
122 |
123 | # Setup a logger
124 | logger = logging.getLogger('foo')
125 | logger.setLevel(logging.DEBUG)
126 |
127 | # Setup a handler, writing colorful output
128 | # to the console
129 | handler = logging.StreamHandler(sys.stderr)
130 | handler.setFormatter(Colorful())
131 | handler.setLevel(logging.DEBUG)
132 | logger.addHandler(handler)
133 |
134 | # Now log some messages..
135 | logger.debug('Debug message')
136 | logger.info('Info message')
137 | logger.warning('Warning message')
138 | logger.error('Error message')
139 | logger.critical('Critical message')
140 | try:
141 | raise ValueError('This is an exception')
142 | except:
143 | logger.exception("An error occurred")
144 | `,
145 | });
146 |
147 | console.log(stderr);
148 | ```
149 |
150 | ## API
151 |
152 | #### .init
153 |
154 | It will initialize `xPython`. Most importantly it will create a separate thread (dedicated web worker), install `pyodide` inside that thread, create a channel, and setup all necessary packages and functions for further usage.
155 |
156 | ```javascript
157 | import * as xPython from '@x-python/core';
158 |
159 | await xPython.init();
160 | ```
161 |
162 | Usually, we do initialize `xPython` before using other methods (like `exec`, `complete`, etc), but it's not mandatory :slightly_smiling_face: So, you can go ahead and do `xPython.exec({ code: '...' })` without doing `xPython.init()` - it will do `xPython.init()` on first `xPython.exec` (or `.complete`, `.format` and any other supported method) call if it's not initialized. The aim of the existence of a separate initialize method is to provide full flexibility to developers. The initialization process takes time and it should be possible to handle that time in the way you want. So, you can do `await xPython.init();` at the beginning or do it after a certain user action or, if you are okay with your users waiting a little bit more after the first execution then you can skip the initialization process and it will be handled automatically :slightly_smiling_face:
163 |
164 | #### .exec
165 |
166 | `exec` is one of the most frequently used. Basically, it's for executing python code. A simple usage looks like this:
167 |
168 | ```javascript
169 | import * as xPython from '@x-python/core';
170 |
171 | await xPython.exec({ code: '1 + 1' });
172 | ```
173 |
174 | You can also provide a `context` with global variables, like:
175 |
176 | ```javascript
177 | import * as xPython from '@x-python/core';
178 |
179 | await xPython.exec({ code: 'x + 1', context: { x: 1 } });
180 | ```
181 |
182 | But let's take a closer look at what it returns. In both cases we will get something like this:
183 |
184 | ```js
185 | { result: 2, error: null, stdout: '', stderr: '' }
186 | ```
187 |
188 | `result` is what is returned from the executed script. But we also have `stdout` (and `stderr`) for standard output streams. If we execute `print("test")` nothing will be returned, but you will have a `stdout`.
189 |
190 | ```javascript
191 | import * as xPython from '@x-python/core';
192 |
193 | await xPython.exec({ code: 'print("test")' });
194 |
195 | // { result: undefined, error: null, stdout: 'test', stderr: '' }
196 | ```
197 |
198 | Of course you can exec multiline code snippets:
199 |
200 | ```javascript
201 | import * as xPython from '@x-python/core';
202 |
203 | await xPython.exec({
204 | code: `
205 | import sys
206 |
207 | sys.version
208 | `,
209 | });
210 | ```
211 |
212 | You can directly use `pyodide` built-in package list without installing them. The full list is [here](https://pyodide.org/en/stable/usage/packages-in-pyodide.html)
213 |
214 | ```javascript
215 | import * as xPython from '@x-python/core';
216 |
217 | await xPython.exec({
218 | code: `
219 | import numpy as np
220 |
221 | np.random.rand()
222 | `,
223 | });
224 | ```
225 |
226 | It will autodetect `numpy` and it will install it if you're using it for the first time.
227 |
228 | #### .complete
229 |
230 | To get code completions you can use `xPython.complete.repl`. Simple usage looks like this:
231 |
232 | ```javascript
233 | import * as xPython from '@x-python/core';
234 |
235 | await xPython.complete.repl({ code: 'import sys; sys.ver' });
236 | ```
237 |
238 | This example will return an array with two possible options: `version` and `version_info` :slightly_smiling_face:
239 |
240 | The full signature of this method also includes `line` and `column` options to specify the cursor position. If `line` isn't provided `xPython` will assume it's the last line and, correspondingly, if `column` isn't provided it will assume that it's the last column. So, in the previous example, it assumed that cursor is at the end of `sys.var` and returned code completions based on that assumption. An example with cursor position specified:
241 |
242 | ```javascript
243 | await xPython.complete.repl({
244 | code: `
245 | from math import factorial
246 |
247 | test = 8
248 | print(tes)
249 | factorial(x)
250 | `,
251 | line: 5,
252 | column: 9,
253 | });
254 | ```
255 |
256 | This will return the only available option here: `test`.
257 |
258 | The curious eye may notice that instead of `.complete` we called `.complete.repl`. When it comes to code completion at least two environments can be your target: `REPL` and `Script/File/Editor`. And based on the environment code completion can vary. In the current version, we do support only `REPL`, but very soon other options will also be available ⏳
259 |
260 | #### .format
261 |
262 | Code formatting is an essential part of interacting with your code. A simple usage looks like this:
263 |
264 | ```javascript
265 | import * as xPython from '@x-python/core';
266 |
267 | const { result } = await xPython.format({
268 | code: `
269 | def add(a, b):
270 | return a + b
271 |
272 | print(add(12,
273 |
274 | 54))
275 | `,
276 | });
277 |
278 | console.log(result);
279 | ```
280 |
281 | **NOTE:** in upcoming versions a full configuration option will be provided.
282 |
283 | #### .install
284 |
285 | The entire standard library is available out of the box, so you can import `sys`, `math`, or `os` without doing anything special. In addition to this `pyodide` also provides a list of built-in packages, like `numpy`, `pandas`, `scipy`, `matplotlib`, `scikit-learn`, etc. Check the full list [here](https://pyodide.org/en/stable/usage/packages-in-pyodide.html). You can use any package from the above-mentioned list and it will be installed automatically and on demand. And if that's not enough you can still install any pure `Python` packages with wheels available on `PyPI` :slightly_smiling_face: Let's install the package called `nicelog`.
286 |
287 | ```javascript
288 | import * as xPython from '@x-python/core';
289 |
290 | await xPython.install(['nicelog']);
291 | ```
292 |
293 | That's it :slightly_smiling_face: Now you have `nicelog` installed and it's ready to be used. Not familiar with `nicelog`? Let's check what's inside:
294 |
295 | ```javascript
296 | import * as xPython from '@x-python/core';
297 |
298 | await xPython.complete.repl({ code: 'from nicelog import ' });
299 | ```
300 |
301 | As it's already installed it should be available for code completion as well :white_check_mark:
302 |
303 | Example from the `nicelog` `PyPI` [page](https://pypi.org/project/nicelog/).
304 |
305 | ```javascript
306 | const { stderr } = await xPython.exec({
307 | code: `
308 | import logging
309 | import sys
310 |
311 | from nicelog.formatters import Colorful
312 |
313 | # Setup a logger
314 | logger = logging.getLogger('foo')
315 | logger.setLevel(logging.DEBUG)
316 |
317 | # Setup a handler, writing colorful output
318 | # to the console
319 | handler = logging.StreamHandler(sys.stderr)
320 | handler.setFormatter(Colorful())
321 | handler.setLevel(logging.DEBUG)
322 | logger.addHandler(handler)
323 |
324 | # Now log some messages..
325 | logger.debug('Debug message')
326 | logger.info('Info message')
327 | logger.warning('Warning message')
328 | logger.error('Error message')
329 | logger.critical('Critical message')
330 | try:
331 | raise ValueError('This is an exception')
332 | except:
333 | logger.exception("An error occurred")
334 | `,
335 | });
336 |
337 | console.log(stderr);
338 | ```
339 |
340 | ### Development
341 |
342 | To play with the library locally do the following steps:
343 |
344 | 1. clone this repo
345 |
346 | ```bash
347 | git clone git@github.com:suren-atoyan/x-python.git
348 | ```
349 |
350 | 2. install dependencies
351 |
352 | ```bash
353 | npm install # or yarn
354 | ```
355 |
356 | 3. run the dev server
357 |
358 | ```bash
359 | npm run dev
360 | ```
361 |
362 | That's it :slightly_smiling_face: Under `/playground` folder you can find the `index.html` file which contains a script with the demo code and under `/src` folder you can find the library source code. Enjoy it :tada:
363 |
364 | ## License
365 |
366 | [MIT](./LICENSE)
367 |
--------------------------------------------------------------------------------