├── 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 | 3 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 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 · [![monthly downloads](https://img.shields.io/npm/dm/@x-python/core)](https://www.npmjs.com/package/@x-python/core) [![gitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/suren-atoyan/x-python/blob/master/LICENSE) [![npm version](https://img.shields.io/npm/v/@x-python/core.svg?style=flat)](https://www.npmjs.com/package/@x-python/core) [![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](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 | --------------------------------------------------------------------------------