├── src └── py_positron │ ├── buildcmd.py │ ├── example │ ├── checkmark.png │ ├── main_app.py │ └── index.html │ ├── __pycache__ │ ├── __init__.cpython-313.pyc │ └── startproj.cpython-313.pyc │ ├── __main__.py │ ├── python_executor.ps1 │ ├── winvenv_setup.ps1 │ ├── winpackage_installer.ps1 │ ├── activate.py │ ├── createvenv.py │ ├── install.py │ ├── updatecmd.py │ ├── cli.py │ ├── startproj.py │ ├── create.py │ └── __init__.py ├── MANIFEST.in ├── icon_wide.png ├── dist ├── py_positron-0.0.2.0.tar.gz └── py_positron-0.0.2.0-py3-none-any.whl ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── docs └── index.html ├── PULL_REQUEST_TEMPLATE.md ├── LICENSE ├── pyproject.toml ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md ├── README.md └── .gitignore /src/py_positron/buildcmd.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include src/py_positron/example/* 2 | include src/py_positron/* -------------------------------------------------------------------------------- /icon_wide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itzmetanjim/py-positron/HEAD/icon_wide.png -------------------------------------------------------------------------------- /dist/py_positron-0.0.2.0.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itzmetanjim/py-positron/HEAD/dist/py_positron-0.0.2.0.tar.gz -------------------------------------------------------------------------------- /src/py_positron/example/checkmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itzmetanjim/py-positron/HEAD/src/py_positron/example/checkmark.png -------------------------------------------------------------------------------- /dist/py_positron-0.0.2.0-py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itzmetanjim/py-positron/HEAD/dist/py_positron-0.0.2.0-py3-none-any.whl -------------------------------------------------------------------------------- /src/py_positron/__pycache__/__init__.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itzmetanjim/py-positron/HEAD/src/py_positron/__pycache__/__init__.cpython-313.pyc -------------------------------------------------------------------------------- /src/py_positron/__pycache__/startproj.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itzmetanjim/py-positron/HEAD/src/py_positron/__pycache__/startproj.cpython-313.pyc -------------------------------------------------------------------------------- /src/py_positron/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | if __name__ == "__main__": 3 | from py_positron import cli 4 | #import cli 5 | cli.main() 6 | 7 | -------------------------------------------------------------------------------- /src/py_positron/python_executor.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory=$true)] 3 | [string]$Command, 4 | [Parameter(Mandatory=$true)] 5 | [string]$Path, 6 | [Parameter(Mandatory=$true)] 7 | [string]$pythonExe 8 | ) 9 | Set-Location $Path 10 | & $pythonExe $Command 11 | -------------------------------------------------------------------------------- /src/py_positron/winvenv_setup.ps1: -------------------------------------------------------------------------------- 1 | # 1. Create venv 2 | python -m venv .\winvenv 3 | 4 | # 2. Activate venv (PowerShell) 5 | .\winvenv\Scripts\Activate.ps1 6 | 7 | # 3. Upgrade pip 8 | python -m pip install --upgrade pip 9 | 10 | # 4. Install PyPositron 11 | python -m pip install py_positron -------------------------------------------------------------------------------- /src/py_positron/example/main_app.py: -------------------------------------------------------------------------------- 1 | import py_positron as positron 2 | import time 3 | 4 | def main(ui: positron.PositronWindowWrapper): 5 | button = ui.document.getElementById("button") 6 | def on_click(): 7 | current_time = time.strftime("%H:%M:%S") 8 | ui.document.alert(f"The current time is {current_time}") 9 | 10 | button.addEventListener("click", on_click) 11 | 12 | def after_close(ui: positron.PositronWindowWrapper): 13 | print("Closing...") 14 | 15 | positron.openUI("frontend/index.html", main, after_close, title="Example App") 16 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/py_positron/winpackage_installer.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory=$true)] 3 | [string]$PackageName, 4 | [Parameter(Mandatory=$true)] 5 | [string]$Path, 6 | [Parameter(Mandatory=$false)] 7 | [bool]$Update = $false 8 | ) 9 | Set-Location $Path 10 | $venvDir = Join-Path $Path 'winvenv' 11 | $pythonExe = Join-Path $venvDir 'Scripts\python.exe' 12 | 13 | if (-not (Test-Path $pythonExe)) { 14 | Write-Error "python.exe not found under '$venvDir'." 15 | exit 1 16 | } 17 | 18 | if ($Update) { 19 | & $pythonExe -m pip install --upgrade $PackageName 20 | } else { 21 | & $pythonExe -m pip install $PackageName 22 | } 23 | # Check if the installation was successful 24 | if ($LASTEXITCODE -ne 0) { 25 | Write-Error "Failed to install/update '$PackageName'." 26 | exit $LASTEXITCODE 27 | } -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Redirecting… 6 | 7 | 10 | 20 | 21 | 22 |

Redirecting!

23 |

The new website for PyPositron is at pypositron.github.io

24 |

If you are not redirected, click here.

25 | 26 | 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Please include a summary of the change and which issue is fixed. Also include relevant motivation and context. 4 | 5 | ## Type of change 6 | 7 | - [ ] Bug fix 8 | - [ ] New feature 9 | - [ ] Breaking change 10 | - [ ] Documentation update 11 | - [ ] Other (describe): 12 | 13 | ## Checklist 14 | 17 | - [ ] My code follows the style guidelines of this project. 18 | - [ ] I tested the code. 19 | - [ ] I have performed a self-review of my own code. 20 | - [ ] I have commented my code, particularly in hard-to-understand areas. 21 | - [ ] I have updated documentation as needed. 22 | - [ ] My changes generate no new warnings/errors. 23 | - [ ] I have added tests that prove my fix is effective or that my feature works. 24 | - [ ] New and existing unit tests pass locally. 25 | 26 | ## Additional context 27 | 28 | Add any other context or screenshots about the pull request here. 29 | 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 All PyPositron contributors 4 | Copyright (c) 2025 Tanjim Kamal 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "py_positron" 3 | version = "0.0.2.3" 4 | authors = [ 5 | { name="itzmetanjim", email="tanjimkamal1@gmail.com" }, 6 | ] 7 | dependencies = [ 8 | "pywebview", 9 | "pathlib2", 10 | "packaging" 11 | ] 12 | description = "PyPositron is a python framework for building apps with HTML, CSS and Python. itzmetanjim.github.io/py-positron/" 13 | readme = "README.md" 14 | requires-python = ">=3.9" 15 | classifiers = [ 16 | "Development Status :: 3 - Alpha", 17 | "Intended Audience :: Developers", 18 | "Programming Language :: Python :: 3", 19 | "Operating System :: Microsoft :: Windows", 20 | "Operating System :: MacOS", 21 | "Operating System :: POSIX", 22 | "Operating System :: Unix", 23 | "Programming Language :: Python :: 3.10", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | "Programming Language :: Python :: 3.13" 27 | ] 28 | license = "MIT" 29 | license-files = ["LICEN[CS]E*"] 30 | keywords = ["python","framework","webview","gui","desktop","cross-platform","electron","pywebview","positron","py-positron","html","css","js","javascript","web","app"] 31 | [project.urls] 32 | Homepage = "https://github.com/itzmetanjim/py-positron" 33 | Issues = "https://github.com/itzmetanjim/py-positron/issues" 34 | Documentation= "https://github.com/itzmetanjim/py-positron/wiki" 35 | Repository= "https://github.com/itzmetanjim/py-positron" 36 | 37 | [build-system] 38 | requires = ["setuptools >= 77.0.3"] 39 | build-backend = "setuptools.build_meta" 40 | [project.scripts] 41 | positron = "py_positron.cli:main" 42 | [tool.setuptools] 43 | [tool.setuptools.packages.find] 44 | where = ["src"] 45 | 46 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to py-positron 2 | 3 | Thank you for considering contributing to **py-positron**\! Your help is greatly appreciated. 4 | Please follow these guidelines to ensure a smooth contribution process. 5 | > Note: If you want to contribute to the website [pypositron.github.io](https://pypositron.github.io), go to [the repo for the website](https://github.com/PyPositron/pypositron.github.io) instead. You can follow the same guidelines, however. 6 | ## Getting Started 7 | 8 | 1. **Fork** the repository. 9 | 2. **Clone** your fork: 10 | ``` 11 | git clone https://github.com/your-username/py-positron.git 12 | ``` 13 | 3. **Create** a new branch for your changes: 14 | ``` 15 | git checkout -b my-feature-branch 16 | ``` 17 | ## Things to contribute 18 | * [ ] Make documentation, README and CONTRIBUTING.md that is not AI-generated 19 | * [ ] Add more examples and tutorials 20 | * [ ] Make the code neater 21 | * [ ] Make the installer/executable creation system 22 | * [ ] Test on Linux 23 | * [ ] Add support for MacOS 24 | * [ ] Add building and packaging features (like converting to executables/installers that can run on any system without Python installed) 25 | * [ ] Optimize performance. 26 | 27 | ## Development Guidelines 28 | 29 | - Write clear, concise commit messages. 30 | - Follow [PEP8](https://pep8.org/) for Python code style. 31 | - Keep pull requests focused and small. 32 | - If your change affects the public API, please update the documentation. 33 | 34 | ## Reporting Bugs 35 | 36 | If you find a bug: 37 | 38 | - Open an [issue](https://github.com/itzmetanjim/py-positron/issues) with a clear title and description. 39 | - Include steps to reproduce, expected behavior, and actual behavior. 40 | - Add relevant logs or screenshots if possible. 41 | 42 | ## Submitting a Pull Request 43 | 44 | 1. Ensure your branch is up-to-date with `main`: 45 | ``` 46 | git fetch origin 47 | git rebase origin/main 48 | ``` 49 | 2. Run tests if applicable. 50 | 3. Open a pull request with a clear description of your changes. 51 | 52 | ## Code of Conduct 53 | 54 | Please be respectful. By participating, you agree to abide by the [Code of Conduct](CODE_OF_CONDUCT.md). 55 | 56 | ## Thank you 57 | 58 | Your contributions make **py-positron** better\! 59 | -------------------------------------------------------------------------------- /src/py_positron/activate.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import subprocess 4 | import sys 5 | from pathlib import Path 6 | 7 | 8 | def activate() -> None: 9 | """Activate PyPositron virtual environment.""" 10 | os.chdir(os.path.dirname(os.path.abspath("config.json"))) 11 | config: dict[str, str] = load_config() 12 | 13 | # switch CWD to project root so all relative paths (entry_file, venvs) resolve correctly 14 | if Path(config["winvenv_executable"]).exists(): 15 | windows_activation(config["winvenv_executable"]) 16 | elif Path(config["linuxvenv"]).exists(): 17 | linux_activation() 18 | else: 19 | exit_activation() 20 | 21 | 22 | def windows_activation(env_path: str) -> None: 23 | """Activate the Windows virtual environment.""" 24 | ps1_path: str = str(Path.cwd() / Path(env_path).parent / "activate.ps1") 25 | cmd: list[str] = [ 26 | "powershell", 27 | "-NoProfile", 28 | "-ExecutionPolicy", 29 | "Bypass", 30 | "-Command", 31 | f"& '{ps1_path}'", 32 | ] 33 | result = subprocess.run(cmd, check=True) 34 | if result.returncode != 0: 35 | print("Error:", result.stderr, file=sys.stderr) 36 | sys.exit(result.returncode) 37 | sys.exit(0) 38 | 39 | 40 | def linux_activation() -> None: 41 | """Activate the Linux virtual environment.""" 42 | cmd: str = 'bash -c "source linuxvenv/bin/activate"' 43 | os.system(cmd) 44 | 45 | 46 | def exit_activation() -> None: 47 | """Exit the virtual environment setup.""" 48 | print( 49 | "This project does not contain a PyPositron venv. " 50 | "Please create a new venv with PyPositron venv.", 51 | ) 52 | sys.exit(1) 53 | 54 | 55 | def load_config() -> dict[str, str]: 56 | """Load the configuration from config.json.""" 57 | config_file: str = "config.json" 58 | if not Path(config_file).exists(): 59 | print( 60 | "This folder does not contain a PyPositron project. " 61 | "Please navigate to the project root, where config.json is located." 62 | "\nYou can create a new project with PyPositron create.", 63 | ) 64 | sys.exit(1) 65 | 66 | with Path.open(Path(config_file).resolve()) as f: 67 | return json.load(f) 68 | -------------------------------------------------------------------------------- /src/py_positron/createvenv.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import json 4 | import platform 5 | import subprocess 6 | from pathlib2 import Path 7 | def venv(): 8 | if not os.path.exists("config.json"): 9 | print("No config.json found. Please create a project first, or navigate to the project root directory.") 10 | sys.exit(1) 11 | with open(os.path.abspath("config.json"), "r") as f: 12 | config = json.load(f) 13 | if platform.system().lower().strip() == "windows": 14 | config["has_venv"] = True 15 | config["winvenv_executable"] = "winvenv/Scripts/python.exe" 16 | print("Creating venv and installing Positron (this may take a while)...") 17 | ps1_path = Path(__file__).parent / "winvenv_setup.ps1" 18 | absolute = ps1_path.resolve() 19 | cmd = ["powershell","-NoProfile","-ExecutionPolicy", "Bypass","-Command", f"& '{absolute}'"] 20 | result = subprocess.run(cmd, capture_output=True, text=True) 21 | if result.returncode != 0: 22 | print("Error:", result.stderr, file=sys.stderr) 23 | print("Failed to create venv. Please create a venv manually and install Positron in it.") 24 | sys.exit(result.returncode) 25 | print("Positron successfully installed in venv.") 26 | 27 | elif platform.system().lower().strip() == "linux": 28 | config["has_venv"] = True 29 | config["linuxvenv"] = os.path.join(os.getcwd(), "linuxvenv") 30 | print("Creating venv (this may take a while)...") 31 | os.system("python3 -m venv ./linuxvenv/") 32 | print("Activating venv...") 33 | os.system("bash -c \"source linuxvenv/bin/activate\"") 34 | print("Installing Positron in venv- if this fails, use pip to install it...") 35 | os.system('bash -c "source linuxvenv/bin/activate && pip3 install -U pip"') 36 | os.system('bash -c "source linuxvenv/bin/activate && pip3 install -U py_positron"') 37 | print("Positron installed in venv.") 38 | elif platform.system().lower().strip() == "darwin": 39 | print("Venv creation is not supported on MacOS through the project creation wizard.\nSee https://github.com/itzmetanjim/py-positron/wiki/Manually-creating-a-venv.") 40 | else: 41 | print("""Could not create venv- Unsupported OS. Please create a venv manually and install Positron in it. See https://github.com/itzmetanjim/py-positron/wiki/Manually-creating-a-venv.""") 42 | sys.exit(1) 43 | print("Updating config.json...") 44 | with open(os.path.abspath("config.json"), "w") as f: 45 | json.dump(config, f, indent=4) 46 | print("Venv created successfully.") 47 | print("You can now install libraries in the venv using positron install .") -------------------------------------------------------------------------------- /src/py_positron/install.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import argparse 4 | import json 5 | import platform 6 | import subprocess 7 | from pathlib2 import Path 8 | def install(args): 9 | #pip installs a library 10 | parser=argparse.ArgumentParser() 11 | parser.add_argument("library", help="Library to install", type=str) 12 | parser.add_argument("--root_path","-r", help="Root path of the project", type=str,default="--",required=False,action="store") 13 | args=parser.parse_args(args) 14 | directory=args.root_path 15 | if args.root_path=="--": 16 | directory=os.getcwd() 17 | os.chdir(directory) 18 | if not os.path.exists(os.path.join(directory, "config.json")): 19 | print("It seems like the directory you are running this from is not a Positron project.") 20 | print("Please run this command from the root of your Positron project (where config.json is located).") 21 | print("To make a new positron project, run positron create.") 22 | exit(0) 23 | if not os.path.exists(os.path.join(directory,"winvenv", "Scripts")) and not os.path.exists(os.path.join(directory,"linuxvenv", "bin")): 24 | print("It seems like the project you are running this from does not have a venv..") 25 | print("To install a venv in Windows or Linux, run positron venv") 26 | exit(0) 27 | if platform.system().lower().strip() == "windows": 28 | if os.path.exists(os.path.join(directory,'winvenv', "Scripts")): 29 | ps1_path = Path(__file__).parent / "winpackage_installer.ps1" 30 | absolute = ps1_path.resolve() 31 | cmd = ["powershell","-NoProfile","-ExecutionPolicy", "Bypass","-Command", f"& '{absolute}' {args.library} \'{directory}\'"] 32 | result = subprocess.run(cmd,check=True) 33 | if result.returncode != 0: 34 | print("Error:", result.stderr, file=sys.stderr) 35 | sys.exit(result.returncode) 36 | else: 37 | print("This project does not have a Windows venv, create one with positron venv") 38 | print("Note that you will have to install all extra libraries again in the Windows venv.") 39 | exit(0) 40 | elif platform.system().lower().strip() == "linux": 41 | if os.path.exists(os.path.join(directory,'linuxvenv', "bin")): 42 | os.system(f"bash -c \"source \'{os.path.join(directory,'linuxvenv', 'bin', 'activate')}\' && pip3 install {args.library}\"") 43 | elif os.path.exists(os.path.join(directory,'winvenv', "Scripts")): 44 | print("This project does not have a Linux venv, create one with positron venv") 45 | print("Note that you will have to install all extra libraries again in the Linux venv.") 46 | exit(0) 47 | elif platform.system().lower().strip() == "darwin": 48 | print("MacOS is not supported yet.") 49 | exit() 50 | -------------------------------------------------------------------------------- /src/py_positron/updatecmd.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import argparse 4 | import json 5 | import platform 6 | import subprocess 7 | from pathlib2 import Path 8 | def update(args): 9 | 10 | parser=argparse.ArgumentParser(args) 11 | parser.add_argument("library", help="Name of the library to update. This is the same as the name you would use with pip.", type=str,default="",nargs="?") 12 | parser.add_argument("--root_path","-r", help="Root path of the project", type=str,default="--",required=False,action="store") 13 | args=parser.parse_args(args) 14 | library=args.library 15 | if library=="": 16 | library="py-positron" 17 | directory=args.root_path 18 | if args.root_path=="--": 19 | directory=os.getcwd() 20 | os.chdir(directory) 21 | if not os.path.exists(os.path.join(directory, "config.json")): 22 | print("It seems like the directory you are running this from is not a Positron project.") 23 | print("Please run this command from the root of your Positron project (where config.json is located).") 24 | print("To make a new positron project, run positron create.") 25 | exit(0) 26 | if not os.path.exists(os.path.join(directory,"winvenv", "Scripts")) and not os.path.exists(os.path.join(directory,"linuxvenv", "bin")): 27 | print("It seems like the project you are running this from does not have a venv.") 28 | print("This command is not needed if you don't use a venv.") 29 | print("To install a venv in Windows or Linux, run positron venv") 30 | exit(0) 31 | if platform.system().lower().strip() == "windows": 32 | if os.path.exists(os.path.join(directory,'winvenv', "Scripts")): 33 | ps1_path = Path(__file__).parent / "winpackage_installer.ps1" 34 | absolute = ps1_path.resolve() 35 | cmd = ["powershell","-NoProfile","-ExecutionPolicy", "Bypass","-Command", f"& '{absolute}' {library} \'{directory}\' 1"] 36 | result = subprocess.run(cmd,check=True) 37 | if result.returncode != 0: 38 | print("Error:", result.stderr, file=sys.stderr) 39 | sys.exit(result.returncode) 40 | else: 41 | print("This project does not have a Windows venv, create one with positron venv") 42 | print("Note that you will have to install all extra libraries again in the Windows venv.") 43 | exit(0) 44 | elif platform.system().lower().strip() == "linux": 45 | if os.path.exists(os.path.join(directory,'linuxvenv', "bin")): 46 | os.system(f"bash -c \"source \'{os.path.join(directory,'linuxvenv', 'bin', 'activate')}\' && pip3 install {library}\"") 47 | elif os.path.exists(os.path.join(directory,'winvenv', "Scripts")): 48 | print("This project does not have a Linux venv, create one with positron venv") 49 | print("Note that you will have to install all extra libraries again in the Linux venv.") 50 | exit(0) 51 | elif platform.system().lower().strip() == "darwin": 52 | print("MacOS is not supported yet.") 53 | exit() 54 | if library=="py-positron": 55 | with open(os.path.join(directory, "config.json"), "r") as f: 56 | config = json.load(f) 57 | config["positron_version"] = "latest" 58 | with open(os.path.join(directory, "config.json"), "w") as f: 59 | json.dump(config, f, indent=4) 60 | -------------------------------------------------------------------------------- /src/py_positron/example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Python-Powered App 5 | 82 | 83 | 84 |

Congratulations!

85 |

You have successfully created your first app using PyPositron!


86 | A green checkmark icon. If you are seeing this instead of the icon, there might be something wrong with your browser.
87 |
93 | 94 | 95 | #You can write small amounts of Python code here for convenience. 96 | 97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | this repo. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /src/py_positron/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import sys 4 | import os 5 | def main(): 6 | parser=argparse.ArgumentParser() 7 | parser.add_argument("command", help="Command to run. Available commands: help, create, start, install, pip, activate, venv, update", type=str,choices=["help","create","start","install","activate","venv","pip","update"],default="help",nargs="?") 8 | parser.add_argument("-v","--version", help="Show the version of PyPositron.", action="store_true") 9 | parser.add_argument("nargs",nargs=argparse.REMAINDER,help="Arguments for the command. Type 'positron -h' for more information on the command.") 10 | args=parser.parse_args() 11 | argv=args.nargs 12 | 13 | # Check if help is requested for a specific command 14 | if args.command and ("-h" in argv or "--help" in argv): 15 | # Create sub-parsers with proper program names for help display 16 | if args.command == "install" or args.command == "pip": 17 | from py_positron import install 18 | install.install(argv) 19 | exit(0) 20 | elif args.command == "start": 21 | from py_positron import startproj as start 22 | start.start(argv) 23 | exit(0) 24 | elif args.command == "update": 25 | from py_positron import updatecmd as update 26 | update.update(argv) 27 | exit(0) 28 | elif args.command == "create": 29 | # Create a parser for create command help 30 | create_parser = argparse.ArgumentParser(prog=f"positron {args.command}") 31 | create_parser.add_argument("--directory", "-d", help="Directory to create the project in (default: current directory)", type=str, default=".") 32 | create_parser.add_argument("--name", "-n", help="Name of the project (default: demo_project)", type=str, default="demo_project") 33 | create_parser.add_argument("--author", "-a", help="Author name (optional)", type=str, default="") 34 | create_parser.add_argument("--description", help="Project description (optional)", type=str, default="") 35 | create_parser.add_argument("--no-venv", help="Do not create a virtual environment", action="store_true") 36 | create_parser.print_help() 37 | exit(0) 38 | elif args.command == "venv": 39 | # Create a parser for venv command help 40 | venv_parser = argparse.ArgumentParser(prog=f"positron {args.command}") 41 | venv_parser.add_argument("--root_path", "-r", help="Root path of the project (default: current directory)", type=str, default=".") 42 | venv_parser.print_help() 43 | exit(0) 44 | elif args.command == "build": 45 | # Create a parser for build command help 46 | build_parser = argparse.ArgumentParser(prog=f"positron {args.command}") 47 | build_parser.add_argument("--root_path", "-r", help="Root path of the project (default: current directory)", type=str, default=".") 48 | build_parser.add_argument("--output", "-o", help="Output filename for the executable", type=str) 49 | build_parser.add_argument("--build-type", help="Type of build to create", type=str, choices=["EXE", "EXE-ONEDIR", "DEB", "DMG", "APK"], default="EXE-ONEDIR") 50 | build_parser.add_argument("--onefile", help="Create a single executable file instead of a directory", action="store_true") 51 | build_parser.add_argument("--windows-console", help="Keep console window on Windows", action="store_true") 52 | build_parser.add_argument("--include-data", help="Include additional data files (format: source=destination)", action="append") 53 | build_parser.add_argument("--python-path", help="Specific Python interpreter to use for building", type=str) 54 | build_parser.print_help() 55 | exit(0) 56 | else: 57 | # For other commands, show basic help 58 | help_parser = argparse.ArgumentParser(prog=f"positron {args.command}") 59 | help_parser.add_argument("--help", "-h", help="Show this help message and exit", action="help") 60 | help_parser.print_help() 61 | exit(0) 62 | if args.version: 63 | import py_positron 64 | print("PyPositron version:", py_positron.__version__) 65 | exit(0) 66 | if args.command=="help": 67 | parser.print_help() 68 | exit(0) 69 | elif args.command=="create": 70 | from py_positron import create 71 | #import create 72 | create.create() 73 | exit(0) 74 | elif args.command=="install" or args.command=="pip": 75 | from py_positron import install 76 | # import install 77 | install.install(argv) 78 | exit(0) 79 | elif args.command=="start": 80 | from py_positron import startproj as start 81 | #import start 82 | start.start(argv) 83 | # elif args.command=="activate": 84 | # #from py_positron import activate 85 | # import activate 86 | # activate.activate() 87 | # exit(0) 88 | elif args.command=="venv": 89 | from py_positron import createvenv 90 | #import createvenv 91 | createvenv.venv() 92 | exit(0) 93 | elif args.command=="update": 94 | from py_positron import updatecmd as update 95 | #import update 96 | update.update(argv) 97 | exit(0) 98 | elif args.command=="build": 99 | from py_positron import buildcmd as build 100 | #import build 101 | build.build(argv) 102 | exit(0) 103 | else: 104 | print("NotImplemented") 105 | exit(0) 106 | 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | PyPositron wordmark 3 |

4 | 5 | # PyPositron 6 | ### **PyPositron is currently in alpha stage.** It is not yet ready for production use, but you can try it out and contribute to its development. See more in [Contributing](#contributing). 7 | **PyPositron** is a Python-powered desktop app framework that lets you build cross-platform apps using HTML, CSS, and Python—just like Electron, but with Python as the backend. Write your UI in HTML/CSS, add interactivity with Python, and run your app natively! 8 | 9 | PyPositron - Effortlessly build desktop apps, hassle-free. | Product Hunt 10 | 11 | 12 | ## Features 13 | 14 | - Build desktop apps using HTML and CSS. 15 | - Use Python for backend and frontend logic. (with support for both Python and JS) 16 | - Use any web framework (like Bootstrap, Tailwind, React, etc.) for your UI. 17 | - Use any HTML builder UI for your app (like Bootstrap Studio, Pinegrow, etc) 18 | - Use JS for compatibility with existing HTML/CSS frameworks. 19 | - Use AI tools for generating your UI without needing proprietary system prompts- simply tell it to generate HTML/CSS/JS UI for your app. 20 | - Virtual environment support. 21 | - Efficient installer creation for easy distribution (that does not exist yet) 22 | (The installer automatically finds an existing browser instead of installing a new one for every app like Electron.JS). 23 | 24 | ## Why PyPositron? 25 | Compared to Electron and other Python frameworks (like PyQt)- 26 | | Feature | PyPositron | Electron | PyQt | 27 | |---------|------------|----------|------| 28 | | Language | Python | **JavaScript, C, C++, etc** | Python | 29 | | UI Frameworks | **Any web technologies** | **Any Web technologies** | Qt Widgets | 30 | | Packaging | **Efficient installer or standalone executable (not yet implemented)** | Electron Builder | PyInstaller etc | 31 | | Performance | **Lightweight** | Heavyweight | **Lightweight** | 32 | | AI Compatibility | **Yes** | **Yes\*** | No\* | 33 | | Compatibility | **All frontend and backend HTML/CSS/JS frameworks and web technologies** | **All frontend and backend HTML/CSS/JS frameworks and web technologies** | Limited to Qt | 34 | 35 | \* maybe 36 | 37 | ## Quick Start 38 | 39 | ### 1. Create a New Project 40 | Install PyPositron if not already installed: 41 | ```bash 42 | pip install py-positron 43 | ``` 44 | Them create a new project using the CLI: 45 | ```bash 46 | positron create 47 | # Follow the prompts to set up your project 48 | ``` 49 | There should be directories in this structure- 50 | 51 | ``` 52 | your_app/ 53 | ├── backend 54 | │ └── main.py 55 | ├── frontend/ 56 | │ └── index.html 57 | ├── [win/linux]venv/ # If created 58 | │ └──... 59 | ├── LICENSE #MIT by default 60 | ├── config.json 61 | └── ... 62 | ``` 63 | 64 | - **backend/main.py**: Entry point for your app. 65 | - **frontend/index.html**: Your app's UI (HTML/CSS/inline Python/JS). 66 | - **winvenv/** or **linuxvenv/**: (Optional) Virtual environment for dependencies. 67 | 68 | ### 2. Run Your App 69 | 70 | ```bash 71 | positron start 72 | ``` 73 | 74 | This should open up a window with a checkmark and a button. 75 | 76 | ## CLI Commands 77 | 78 | | Command | Description | 79 | |------------------------------------------|---------------------------------------------------------------| 80 | | `positron create` | Create a new PyPositron project (interactive setup). | 81 | | `positron start [--executable ]` | Run your PyPositron app (optionally specify Python interpreter).| 82 | | `positron install ` | Install a Python package into the project venv. | 83 | | `positron venv` | Create a virtual environment inside your project folder. | 84 | 85 | ## Example Output 86 | 87 |
88 |
89 | 90 |
91 | The default example project created with positron start. 92 |
93 |


94 |
95 | 96 |
97 | A simple example code editor (dark on the right) with the backend code shown (left). 98 |
99 |
100 |
101 | 102 | 103 | ## Documentation & Resources 104 | 105 | - [Official Tutorial & Docs](https://github.com/itzmetanjim/py-positron/wiki) 106 | 107 | ## License and code of conduct. 108 | 109 | This project uses the MIT License. See [LICENSE](LICENSE) for details. If you have any problems with this, feel free to open an issue/dicussion. 110 | 111 | This project follows a [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). 112 | 113 | 114 | ## Contributing 115 | This project is in alpha stage. Contributions are welcome. See [Contributing](CONTRIBUTING.md) for details. 116 | Things to do- 117 | * [ ] Make documentation and README that is not AI-generated 118 | * [ ] Add more examples and tutorials 119 | * [ ] Make the installer/executable creation system 120 | * [ ] Test on Linux 121 | * [ ] Add support for MacOS 122 | * [ ] Add building and packaging features (like converting to executables/installers that can run on any system without Python installed) 123 | * [ ] Optimize performance. 124 | 125 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[codz] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | #poetry.toml 110 | 111 | # pdm 112 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 113 | # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. 114 | # https://pdm-project.org/en/latest/usage/project/#working-with-version-control 115 | #pdm.lock 116 | #pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # pixi 121 | # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. 122 | #pixi.lock 123 | # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one 124 | # in the .venv directory. It is recommended not to include this directory in version control. 125 | .pixi 126 | 127 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 128 | __pypackages__/ 129 | 130 | # Celery stuff 131 | celerybeat-schedule 132 | celerybeat.pid 133 | 134 | # SageMath parsed files 135 | *.sage.py 136 | 137 | # Environments 138 | .env 139 | .envrc 140 | .venv 141 | env/ 142 | venv/ 143 | ENV/ 144 | env.bak/ 145 | venv.bak/ 146 | 147 | # Spyder project settings 148 | .spyderproject 149 | .spyproject 150 | 151 | # Rope project settings 152 | .ropeproject 153 | 154 | # mkdocs documentation 155 | /site 156 | 157 | # mypy 158 | .mypy_cache/ 159 | .dmypy.json 160 | dmypy.json 161 | 162 | # Pyre type checker 163 | .pyre/ 164 | 165 | # pytype static type analyzer 166 | .pytype/ 167 | 168 | # Cython debug symbols 169 | cython_debug/ 170 | 171 | # Covers JetBrains IDEs: IntelliJ, GoLand, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 172 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 173 | 174 | # User-specific stuff 175 | .idea/**/workspace.xml 176 | .idea/**/tasks.xml 177 | .idea/**/usage.statistics.xml 178 | .idea/**/dictionaries 179 | .idea/**/shelf 180 | 181 | # AWS User-specific 182 | .idea/**/aws.xml 183 | 184 | # Generated files 185 | .idea/**/contentModel.xml 186 | 187 | # Sensitive or high-churn files 188 | .idea/**/dataSources/ 189 | .idea/**/dataSources.ids 190 | .idea/**/dataSources.local.xml 191 | .idea/**/sqlDataSources.xml 192 | .idea/**/dynamic.xml 193 | .idea/**/uiDesigner.xml 194 | .idea/**/dbnavigator.xml 195 | 196 | # Gradle 197 | .idea/**/gradle.xml 198 | .idea/**/libraries 199 | 200 | # Gradle and Maven with auto-import 201 | # When using Gradle or Maven with auto-import, you should exclude module files, 202 | # since they will be recreated, and may cause churn. Uncomment if using 203 | # auto-import. 204 | # .idea/artifacts 205 | # .idea/compiler.xml 206 | # .idea/jarRepositories.xml 207 | # .idea/modules.xml 208 | # .idea/*.iml 209 | # .idea/modules 210 | # *.iml 211 | # *.ipr 212 | 213 | # CMake 214 | cmake-build-*/ 215 | 216 | # Mongo Explorer plugin 217 | .idea/**/mongoSettings.xml 218 | 219 | # File-based project format 220 | *.iws 221 | 222 | # IntelliJ 223 | out/ 224 | 225 | # mpeltonen/sbt-idea plugin 226 | .idea_modules/ 227 | 228 | # JIRA plugin 229 | atlassian-ide-plugin.xml 230 | 231 | # Cursive Clojure plugin 232 | .idea/replstate.xml 233 | 234 | # SonarLint plugin 235 | .idea/sonarlint/ 236 | .idea/sonarlint.xml # see https://community.sonarsource.com/t/is-the-file-idea-idea-idea-sonarlint-xml-intended-to-be-under-source-control/121119 237 | 238 | # Crashlytics plugin (for Android Studio and IntelliJ) 239 | com_crashlytics_export_strings.xml 240 | crashlytics.properties 241 | crashlytics-build.properties 242 | fabric.properties 243 | 244 | # Editor-based HTTP Client 245 | .idea/httpRequests 246 | http-client.private.env.json 247 | 248 | # Android studio 3.1+ serialized cache file 249 | .idea/caches/build_file_checksums.ser 250 | 251 | # Apifox Helper cache 252 | .idea/.cache/.Apifox_Helper 253 | .idea/ApifoxUploaderProjectSetting.xml 254 | 255 | # Abstra 256 | # Abstra is an AI-powered process automation framework. 257 | # Ignore directories containing user credentials, local state, and settings. 258 | # Learn more at https://abstra.io/docs 259 | .abstra/ 260 | 261 | # Visual Studio Code 262 | .vscode/* 263 | !.vscode/settings.json 264 | !.vscode/tasks.json 265 | !.vscode/launch.json 266 | !.vscode/extensions.json 267 | !.vscode/*.code-snippets 268 | !*.code-workspace 269 | 270 | # Built Visual Studio Code Extensions 271 | *.vsix 272 | 273 | # Ruff stuff: 274 | .ruff_cache/ 275 | 276 | # PyPI configuration file 277 | .pypirc 278 | 279 | # Cursor 280 | # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to 281 | # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data 282 | # refer to https://docs.cursor.com/context/ignore-files 283 | .cursorignore 284 | .cursorindexingignore 285 | 286 | # Marimo 287 | marimo/_static/ 288 | marimo/_lsp/ 289 | __marimo__/ 290 | -------------------------------------------------------------------------------- /src/py_positron/startproj.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import json 4 | import platform 5 | import subprocess 6 | from pathlib2 import Path 7 | import argparse 8 | import re 9 | from packaging import version 10 | 11 | def get_current_positron_version(): 12 | """Get the version of the currently running PyPositron instance.""" 13 | try: 14 | # Import the current PyPositron package to get its version 15 | import py_positron 16 | return py_positron.__version__ 17 | except (ImportError, AttributeError): 18 | return None 19 | 20 | def get_venv_positron_version(python_executable): 21 | """Get the PyPositron version installed in the specified Python environment.""" 22 | try: 23 | # Run a command to get the version from the target environment 24 | cmd = [python_executable, "-c", "import py_positron; print(py_positron.__version__)"] 25 | result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) 26 | 27 | if result.returncode == 0: 28 | return result.stdout.strip() 29 | else: 30 | return None 31 | except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError): 32 | return None 33 | 34 | def compare_versions(current_version, venv_version): 35 | """Compare two version strings. Returns True if current > venv, False otherwise.""" 36 | try: 37 | return version.parse(current_version) > version.parse(venv_version) 38 | except Exception: 39 | return False 40 | 41 | def warn_version_mismatch(current_version, venv_version): 42 | """Display a warning about version mismatch.""" 43 | print(f"\x1b[0;93;49m[WARN] Version mismatch detected!\x1b[0m") 44 | print(f" Current PyPositron: v{current_version}") 45 | print(f" Virtual environment PyPositron: v{venv_version}") 46 | print(f" Your virtual environment has an older version of PyPositron.") 47 | print(f" Run 'positron update' to update the virtual environment to the latest version.\x1b[0m") 48 | print() 49 | 50 | def check_and_warn_version_mismatch(python_executable): 51 | """Check for version mismatch and warn the user if necessary.""" 52 | current_version = get_current_positron_version() 53 | if not current_version: 54 | return # Can't check if we don't know current version 55 | 56 | venv_version = get_venv_positron_version(python_executable) 57 | if not venv_version: 58 | return # Can't check if we can't get venv version 59 | 60 | if compare_versions(current_version, venv_version): 61 | warn_version_mismatch(current_version, venv_version) 62 | 63 | def check_and_warn_version_mismatch_linux(venv_path): 64 | """Check for version mismatch in Linux venv and warn the user if necessary.""" 65 | current_version = get_current_positron_version() 66 | if not current_version: 67 | return # Can't check if we don't know current version 68 | 69 | python_executable = os.path.join(venv_path, "bin", "python3") 70 | if os.path.exists(python_executable): 71 | venv_version = get_venv_positron_version(python_executable) 72 | if venv_version and compare_versions(current_version, venv_version): 73 | warn_version_mismatch(current_version, venv_version) 74 | 75 | def start(argv): 76 | parser = argparse.ArgumentParser() 77 | parser.add_argument("--no-venv", action="store_true", help="Run without venv, even if a compatible venv is present.") 78 | # Default to 'python' on Windows (to use PATH python.exe) and 'python3' elsewhere 79 | default_exe = "python" if platform.system().lower().startswith("win") else "python3" 80 | parser.add_argument( 81 | "--executable", 82 | default=default_exe, 83 | help=f"Python executable to use (default: {default_exe})" 84 | ) 85 | args=parser.parse_args(argv) 86 | if not os.path.exists("config.json"): 87 | print("This folder does not contain a PyPositron project. Please navigate to the project root, where config.json is located.") 88 | print("You can create a new project with PyPositron create.") 89 | sys.exit(1) 90 | with open(os.path.abspath("config.json"), "r") as f: 91 | config = json.load(f) # switch CWD to project root so all relative paths (entry_file, venvs) resolve correctly 92 | os.chdir(os.path.dirname(os.path.abspath("config.json"))) 93 | if not os.path.exists(config["entry_file"]): 94 | print(f"The entry file {config['entry_file']} does not exist. Please create it or change the entry file path in config.json.") 95 | sys.exit(1) 96 | if not args.no_venv: 97 | if platform.system().lower().strip() == "windows": 98 | ps1_path = Path(__file__).parent / "python_executor.ps1" 99 | absolute = ps1_path.resolve() 100 | if config["has_venv"]: 101 | if os.path.exists(config.get("winvenv_executable","")) and config.get("winvenv_executable","") != "": 102 | # Check for version mismatch before running 103 | check_and_warn_version_mismatch(config["winvenv_executable"]) 104 | cmd = ["powershell","-NoProfile","-ExecutionPolicy", "Bypass","-Command", f"& '{absolute}' '{config['entry_file']}' '{os.getcwd()}' '{config['winvenv_executable']}'"] 105 | result = subprocess.run(cmd,check=True) 106 | if result.returncode != 0: 107 | print("Error:", result.stderr, file=sys.stderr) 108 | sys.exit(result.returncode) 109 | sys.exit(0) 110 | else: 111 | print("\x1b[0;93;49m[WARN]Running without venv, as this project does not contain a windows venv, but has a linux venv.\x1b[0m") 112 | # Use the executable from command line argument instead of config 113 | cmd = ["powershell","-NoProfile","-ExecutionPolicy", "Bypass","-Command", f"& '{absolute}' '{config['entry_file']}' '{os.getcwd()}' '{args.executable}'"] 114 | result = subprocess.run(cmd,check=True) 115 | if result.returncode != 0: 116 | print("Error:", result.stderr, file=sys.stderr) 117 | sys.exit(result.returncode) 118 | sys.exit(0) 119 | else: 120 | if config["has_venv"]: 121 | if os.path.exists(config.get("linuxvenv","")) and config.get("linuxvenv","") != "": 122 | # Check for version mismatch before running 123 | check_and_warn_version_mismatch_linux(config["linuxvenv"]) 124 | os.system("bash -c \'source \""+config["linuxvenv"]+"/bin/activate\" && "+args.executable+" \""+os.path.abspath(config["entry_file"])+"\"\'") 125 | exit(0) 126 | else: 127 | print("\x1b[0;93;49m[WARN]Running without venv, as this project does not contain a linux venv, but has a Windows venv.\x1b[0m") 128 | else: 129 | os.system(args.executable+" \""+os.path.abspath(config["entry_file"])+"\"") 130 | exit(0) 131 | else: 132 | if platform.system().lower().strip() == "windows": 133 | ps1_path = Path(__file__).parent / "python_executor.ps1" 134 | absolute = ps1_path.resolve() 135 | # Use the executable from command line argument instead of config 136 | cmd = ["powershell","-NoProfile","-ExecutionPolicy", "Bypass","-Command", f"& '{absolute}' '{config['entry_file']}' '{os.getcwd()}' '{args.executable}'"] 137 | result = subprocess.run(cmd,check=True) 138 | if result.returncode != 0: 139 | print("Error:", result.stderr, file=sys.stderr) 140 | sys.exit(result.returncode) 141 | else: 142 | os.system(args.executable+" \""+os.path.abspath(config["entry_file"])+"\"") 143 | exit(0) 144 | -------------------------------------------------------------------------------- /src/py_positron/create.py: -------------------------------------------------------------------------------- 1 | #!usr/bin/env python3 2 | import os 3 | import sys 4 | import json 5 | import platform 6 | import datetime 7 | import subprocess 8 | from pathlib2 import Path 9 | import argparse 10 | import py_positron 11 | def create(): 12 | license_template=""" 13 | Copyright {text} 14 | 15 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | checkmarkpng_path = os.path.join(os.path.dirname(__file__), "example/checkmark.png") 22 | mainpy="" 23 | indexhtml="" 24 | with open(os.path.join(os.path.dirname(__file__), "example/main_app.py"), "r") as f: 25 | mainpy=f.read() 26 | with open(os.path.join(os.path.dirname(__file__), "example/index.html"), "r") as f: 27 | indexhtml=f.read() 28 | print("Welcome to the PyPositron project creation wizard!\nPress ^C to exit.") 29 | try: 30 | directory = input("\033[1;92;49mProject directory (leave empty to use current directory):\033[0m ") 31 | if directory =="": 32 | print("Using current directory.") 33 | directory = os.getcwd() 34 | try: 35 | os.makedirs(directory, exist_ok=True) 36 | except OSError as e: 37 | print(f"\033[1;91;49m[ERROR] Invalid directory syntax: {e}\033[0m") 38 | exit(1) 39 | print("You can change any of the following options later in \033[4;39;49mconfig.json\033[0m.") 40 | projname=input("\033[1;92;49mProject name (default: demo_project):\033[0m ") 41 | if projname == "": 42 | projname = "demo_project" 43 | authorname=input("\033[1;92;49mAuthor name (optional):\033[0m ") 44 | description=input("\033[1;92;49mProject description (optional):\033[0m ") 45 | project_version=input("\033[1;92;49mProject version (default: 0.1.0):\033[0m ") 46 | if project_version == "": 47 | project_version = "0.1.0" 48 | print("A virtual environment (venv) can be used to isolate the project enviroment from the main python installation.") 49 | print("This can be useful to avoid conflicts between different projects and packages.") 50 | print("The venv will be automatically activated and deactivated by PyPositron.") 51 | print("It is recommended to use a venv for most projects, but you will need to install all your packages seperately on the venv.") 52 | print("If you are using Mac, you have to create a venv manually https://github.com/itzmetanjim/py-PyPositron/wiki/Manually-creating-a-venv") 53 | print("You can create a venv in your project later by using \033[4;39;49mpositron venv\033[0m.") 54 | venv_choice=input("\033[1;92;49mCreate a venv inside the project directory? y/N: \033[0m ") 55 | while venv_choice.lower() not in ["y", "n", "yes", "no",""]: 56 | print("\033[1;91;49m[ERROR] Invalid input. Please enter y or n.\033[0m") 57 | venv_choice=input("\033[1;92;49mCreate a venv inside the project directory? y/N: \033[0m ") 58 | venv=False 59 | if venv_choice.lower() in ["y", "yes"]: 60 | venv = True 61 | elif venv_choice.lower() in ["n", "no", ""]: 62 | venv = False 63 | if venv: 64 | print("You can install libraries in a PyPositron project venv by using positron pip install .") 65 | print("The PyPositron package will be automatically installed in the venv when the project is created.") 66 | print("If it fails, create a venv using positron venv.") 67 | print("\033[1;92;49mSelected options (can be changed):\033[0m") 68 | print("\033[1;92;49mDirectory:\033[0m ", directory) 69 | print("\033[1;92;49mProject name:\033[0m ", projname) 70 | print("\033[1;92;49mAuthor name:\033[0m ", authorname) 71 | print("\033[1;92;49mDescription:\033[0m ", description) 72 | print("\033[1;92;49mA venv will be created.\033[0m " if venv else "\033[1;92;49mNo venv will be created, but you can create one later.\033[0m ") 73 | print("You can change these options and more through \033[4;39;49mconfig.json\033[0m.") 74 | input("\033[1;92;49mPress Enter to continue or ^C to exit and change the options\033[0m") 75 | print("Creating project...") 76 | config={ 77 | "name": projname, 78 | "author": authorname, 79 | "description": description, 80 | "has_venv": venv, 81 | "requirements":"requirements.txt", 82 | "entry_file":"backend/main.py", 83 | "positron_version":py_positron.__version__, #Placeholder, replace with positron.__version__ 84 | "python_version":f"{sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]}", 85 | "project_version": project_version, 86 | "winvenv_executable": "winvenv/Scripts/python.exe" if venv and platform.system().lower().strip()=="windows" else "", 87 | "linuxvenv":"", 88 | "license_type": "MIT", 89 | "license_file": "LICENSE", 90 | } 91 | except KeyboardInterrupt: 92 | exit(0) 93 | except EOFError: 94 | exit(0) 95 | if platform.system().lower().strip() == "linux": 96 | config.update({"linuxvenv": "linuxvenv" if venv else ""}) 97 | print("Creating config.json...") 98 | with open(os.path.join(os.path.abspath(directory), "config.json"), "w") as f: 99 | json.dump(config, f, indent=4) 100 | print("Creating license file...") 101 | with open(os.path.join(os.path.abspath(directory), "LICENSE"), "w") as f: 102 | f.write(license_template.format(text=str(datetime.datetime.now().year)+" "+authorname)) 103 | print("Creating directories and example files...") 104 | print("Creating backend...") 105 | try: 106 | os.mkdir(os.path.join(os.path.abspath(directory), "backend")) 107 | except FileExistsError: 108 | pass 109 | print("Creating frontend...") 110 | try: 111 | os.mkdir(os.path.join(os.path.abspath(directory), "frontend")) 112 | except FileExistsError: 113 | pass 114 | print("Creating example files...") 115 | with open(os.path.join(os.path.abspath(directory), "backend/main.py"), "w") as f: 116 | f.write(mainpy) 117 | with open(os.path.join(os.path.abspath(directory), "frontend/index.html"), "w") as f: 118 | f.write(indexhtml) 119 | with open(os.path.join(os.path.abspath(directory), "frontend/checkmark.png"), "wb") as f: 120 | with open(checkmarkpng_path, "rb") as f2: 121 | f.write(f2.read()) 122 | print("Changing to the project directory...") 123 | os.chdir(os.path.abspath(directory)) 124 | if venv: 125 | if platform.system().lower().strip() == "windows": 126 | print("Creating venv and installing PyPositron (this may take a while)...") 127 | ps1_path = Path(__file__).parent / "winvenv_setup.ps1" 128 | absolute = ps1_path.resolve() 129 | cmd = ["powershell","-NoProfile","-ExecutionPolicy", "Bypass","-Command", f"& '{absolute}'"] 130 | result = subprocess.run(cmd, capture_output=True, text=True) 131 | if result.returncode != 0: 132 | print("Error:", result.stderr, file=sys.stderr) 133 | print("Failed to create venv. Please create a venv manually and install PyPositron in it.") 134 | sys.exit(result.returncode) 135 | print("PyPositron successfully installed in venv.") 136 | 137 | elif platform.system().lower().strip() == "linux": 138 | print("Creating venv (this may take a while)...") 139 | os.system("python3 -m venv ./linuxvenv/") 140 | print("Activating venv...") 141 | os.system("bash -c \"source linuxvenv/bin/activate\"") 142 | print("Installing PyPositron in venv- if this fails, use pip to install it...") 143 | os.system('bash -c "source linuxvenv/bin/activate && pip3 install -U pip"') 144 | os.system('bash -c "source linuxvenv/bin/activate && pip3 install -U py_positron"') 145 | print("PyPositron installed in venv.") 146 | elif platform.system().lower().strip() == "darwin": 147 | print("Venv creation is not supported on MacOS through the project creation wizard.\nSee https://github.com/itzmetanjim/py-positron/wiki/Manually-creating-a-venv.") 148 | else: 149 | print("""Could not create venv- Unsupported OS. Please create a venv manually and install PyPositron in it. See https://github.com/itzmetanjim/py-positron/wiki/Manually-creating-a-venv.""") 150 | print("\033[1;92;49mProject created successfully!\033[0m") 151 | print("\033[1;92;49mYou can now run your project with:\033[0m") 152 | print("\033[4;39;49mpositron start\033[0m") 153 | -------------------------------------------------------------------------------- /src/py_positron/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.2.3" 2 | import webview 3 | import os 4 | import threading 5 | import re 6 | import importlib 7 | import json 8 | import traceback 9 | import sys 10 | import html 11 | from typing import Callable, Optional 12 | class PositronWindowWrapper: 13 | """Wrapper for a PyPositron window with event loop thread and context.""" 14 | def __init__(self, window, context, main_thread): 15 | self.window: webview.Window = window 16 | self.context: PositronContext = context 17 | self.document = Document(window) 18 | self.exposed = ExposedFunctions(context.exposed_functions) 19 | self.event_thread: threading.Thread = main_thread 20 | self.htmlwindow = HTMLWindow(window) 21 | 22 | def escape_js_string(string: str) -> str: 23 | """Escape string for JavaScript""" 24 | return string.replace("\\", "\\\\").replace("\"", "\\\"").replace("\'", "\\'").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t").replace("\f", "\\f").replace("\v", "\\v") 25 | 26 | class PositronContext: 27 | def __init__(self, window): 28 | self.window = window 29 | self.globals = {} 30 | self.locals = {} 31 | self.exposed_functions = {} 32 | self.event_handlers = {} 33 | 34 | def __setattr__(self, name, value): 35 | """Allow dynamic attribute assignment""" 36 | object.__setattr__(self, name, value) 37 | def execute(self, code): 38 | try: 39 | self.locals.update({ 40 | "window": self.window, 41 | "document":Document(self.window), 42 | "exposed": ExposedFunctions(self.exposed_functions), 43 | "import_module": importlib.import_module, 44 | }) 45 | exec(code, self.globals, self.locals) 46 | self.globals.update(self.locals) #... 47 | return True, None 48 | except Exception as e: 49 | error_info = traceback.format_exc() 50 | return False, error_info 51 | def register_event_handler(self, element_id, event_type, callback): 52 | 53 | key = f"{element_id}_{event_type}" 54 | callback_globals = dict(self.globals) 55 | callback_locals = dict(self.locals) 56 | callback_func = callback 57 | exposed_functions = self.exposed_functions 58 | 59 | 60 | def wrapped_callback(): 61 | try: 62 | exec_globals = dict(callback_globals) 63 | exec_locals = dict(callback_locals) 64 | important_stuff={ 65 | "window": self.window, 66 | "document": Document(self.window), 67 | "exposed": ExposedFunctions(exposed_functions), 68 | "callback_func": callback_func 69 | } 70 | exec_globals.update(important_stuff) 71 | exec_locals.update(important_stuff) 72 | exec_code = "def _executor():\n return callback_func()" 73 | exec(exec_code, exec_globals, exec_locals) 74 | result = exec_locals["_executor"]() 75 | return result 76 | except Exception as e: 77 | print(f"Error in event handler: {e}") 78 | traceback.print_exc() 79 | return str(e) 80 | self.event_handlers[key] = wrapped_callback 81 | return key 82 | class Element: 83 | def __init__(self, window, js_path): 84 | self.window = window 85 | self.js_path = js_path 86 | 87 | # Properties 88 | @property 89 | def accessKey(self) -> str: 90 | """Sets or returns the accesskey attribute of an element""" 91 | return self.window.evaluate_js(f'{self.js_path}.accessKey') 92 | 93 | @accessKey.setter 94 | def accessKey(self, value: str): 95 | """Sets the accesskey attribute of an element""" 96 | self.window.evaluate_js(f'{self.js_path}.accessKey = {json.dumps(value)}') 97 | 98 | @property 99 | def attributes(self): 100 | """Returns a NamedNodeMap of an element's attributes""" 101 | return self.window.evaluate_js(f'{self.js_path}.attributes') 102 | 103 | @property 104 | def childElementCount(self) -> int: 105 | """Returns an element's number of child elements""" 106 | return self.window.evaluate_js(f'{self.js_path}.childElementCount') or 0 107 | 108 | @property 109 | def childNodes(self): 110 | """Returns a NodeList of an element's child nodes""" 111 | return ElementList(self.window, f'{self.js_path}.childNodes') 112 | 113 | @property 114 | def children(self): 115 | """Returns an HTMLCollection of an element's child elements""" 116 | return ElementList(self.window, f'{self.js_path}.children') 117 | 118 | @property 119 | def classList(self): 120 | """Returns the class name(s) of an element""" 121 | return self.window.evaluate_js(f'{self.js_path}.classList') 122 | 123 | @property 124 | def className(self) -> str: 125 | """Sets or returns the value of the class attribute of an element""" 126 | return self.window.evaluate_js(f'{self.js_path}.className') 127 | 128 | @className.setter 129 | def className(self, value: str): 130 | """Sets the value of the class attribute of an element""" 131 | self.window.evaluate_js(f'{self.js_path}.className = {json.dumps(value)}') 132 | 133 | @property 134 | def clientHeight(self) -> int: 135 | """Returns the height of an element, including padding""" 136 | return self.window.evaluate_js(f'{self.js_path}.clientHeight') or 0 137 | 138 | @property 139 | def clientLeft(self) -> int: 140 | """Returns the width of the left border of an element""" 141 | return self.window.evaluate_js(f'{self.js_path}.clientLeft') or 0 142 | 143 | @property 144 | def clientTop(self) -> int: 145 | """Returns the width of the top border of an element""" 146 | return self.window.evaluate_js(f'{self.js_path}.clientTop') or 0 147 | 148 | @property 149 | def clientWidth(self) -> int: 150 | """Returns the width of an element, including padding""" 151 | return self.window.evaluate_js(f'{self.js_path}.clientWidth') or 0 152 | 153 | @property 154 | def contentEditable(self) -> str: 155 | """Sets or returns whether the content of an element is editable or not""" 156 | return self.window.evaluate_js(f'{self.js_path}.contentEditable') 157 | 158 | @contentEditable.setter 159 | def contentEditable(self, value: str): 160 | """Sets whether the content of an element is editable or not""" 161 | self.window.evaluate_js(f'{self.js_path}.contentEditable = {json.dumps(value)}') 162 | 163 | @property 164 | def dir(self) -> str: 165 | """Sets or returns the value of the dir attribute of an element""" 166 | return self.window.evaluate_js(f'{self.js_path}.dir') 167 | 168 | @dir.setter 169 | def dir(self, value: str): 170 | """Sets the value of the dir attribute of an element""" 171 | self.window.evaluate_js(f'{self.js_path}.dir = {json.dumps(value)}') 172 | 173 | @property 174 | def firstChild(self): 175 | """Returns the first child node of an element""" 176 | return Element(self.window, f'{self.js_path}.firstChild') 177 | 178 | @property 179 | def firstElementChild(self): 180 | """Returns the first child element of an element""" 181 | return Element(self.window, f'{self.js_path}.firstElementChild') 182 | 183 | @property 184 | def id(self) -> str: 185 | """Sets or returns the value of the id attribute of an element""" 186 | return self.window.evaluate_js(f'{self.js_path}.id') 187 | 188 | @id.setter 189 | def id(self, value: str): 190 | """Sets the value of the id attribute of an element""" 191 | self.window.evaluate_js(f'{self.js_path}.id = {json.dumps(value)}') 192 | 193 | @property 194 | def isContentEditable(self) -> bool: 195 | """Returns true if an element's content is editable""" 196 | return self.window.evaluate_js(f'{self.js_path}.isContentEditable') 197 | 198 | @property 199 | def lang(self) -> str: 200 | """Sets or returns the value of the lang attribute of an element""" 201 | return self.window.evaluate_js(f'{self.js_path}.lang') 202 | 203 | @lang.setter 204 | def lang(self, value: str): 205 | """Sets the value of the lang attribute of an element""" 206 | self.window.evaluate_js(f'{self.js_path}.lang = {json.dumps(value)}') 207 | 208 | @property 209 | def lastChild(self): 210 | """Returns the last child node of an element""" 211 | return Element(self.window, f'{self.js_path}.lastChild') 212 | 213 | @property 214 | def lastElementChild(self): 215 | """Returns the last child element of an element""" 216 | return Element(self.window, f'{self.js_path}.lastElementChild') 217 | 218 | @property 219 | def namespaceURI(self) -> str: 220 | """Returns the namespace URI of an element""" 221 | return self.window.evaluate_js(f'{self.js_path}.namespaceURI') 222 | 223 | @property 224 | def nextSibling(self): 225 | """Returns the next node at the same node tree level""" 226 | return Element(self.window, f'{self.js_path}.nextSibling') 227 | 228 | @property 229 | def nextElementSibling(self): 230 | """Returns the next element at the same node tree level""" 231 | return Element(self.window, f'{self.js_path}.nextElementSibling') 232 | 233 | @property 234 | def nodeName(self) -> str: 235 | """Returns the name of a node""" 236 | return self.window.evaluate_js(f'{self.js_path}.nodeName') 237 | 238 | @property 239 | def nodeType(self) -> int: 240 | """Returns the node type of a node""" 241 | return self.window.evaluate_js(f'{self.js_path}.nodeType') or 0 242 | 243 | @property 244 | def nodeValue(self) -> str: 245 | """Sets or returns the value of a node""" 246 | return self.window.evaluate_js(f'{self.js_path}.nodeValue') 247 | 248 | @nodeValue.setter 249 | def nodeValue(self, value: str): 250 | """Sets the value of a node""" 251 | self.window.evaluate_js(f'{self.js_path}.nodeValue = {json.dumps(value)}') 252 | 253 | @property 254 | def offsetHeight(self) -> int: 255 | """Returns the height of an element, including padding, border and scrollbar""" 256 | return self.window.evaluate_js(f'{self.js_path}.offsetHeight') or 0 257 | 258 | @property 259 | def offsetWidth(self) -> int: 260 | """Returns the width of an element, including padding, border and scrollbar""" 261 | return self.window.evaluate_js(f'{self.js_path}.offsetWidth') or 0 262 | 263 | @property 264 | def offsetLeft(self) -> int: 265 | """Returns the horizontal offset position of an element""" 266 | return self.window.evaluate_js(f'{self.js_path}.offsetLeft') or 0 267 | 268 | @property 269 | def offsetParent(self): 270 | """Returns the offset container of an element""" 271 | return Element(self.window, f'{self.js_path}.offsetParent') 272 | 273 | @property 274 | def offsetTop(self) -> int: 275 | """Returns the vertical offset position of an element""" 276 | return self.window.evaluate_js(f'{self.js_path}.offsetTop') or 0 277 | 278 | @property 279 | def outerHTML(self) -> str: 280 | """Sets or returns the content of an element (including the start tag and the end tag)""" 281 | return self.window.evaluate_js(f'{self.js_path}.outerHTML') 282 | 283 | @outerHTML.setter 284 | def outerHTML(self, value: str): 285 | """Sets the content of an element (including the start tag and the end tag)""" 286 | self.window.evaluate_js(f'{self.js_path}.outerHTML = {json.dumps(value)}') 287 | 288 | @property 289 | def outerText(self) -> str: 290 | """Sets or returns the outer text content of a node and its descendants""" 291 | return self.window.evaluate_js(f'{self.js_path}.outerText') 292 | 293 | @outerText.setter 294 | def outerText(self, value: str): 295 | """Sets the outer text content of a node and its descendants""" 296 | self.window.evaluate_js(f'{self.js_path}.outerText = {json.dumps(value)}') 297 | 298 | @property 299 | def ownerDocument(self): 300 | """Returns the root element (document object) for an element""" 301 | return self.window.evaluate_js(f'{self.js_path}.ownerDocument') 302 | 303 | @property 304 | def parentNode(self): 305 | """Returns the parent node of an element""" 306 | return Element(self.window, f'{self.js_path}.parentNode') 307 | 308 | @property 309 | def parentElement(self): 310 | """Returns the parent element node of an element""" 311 | return Element(self.window, f'{self.js_path}.parentElement') 312 | 313 | @property 314 | def previousSibling(self): 315 | """Returns the previous node at the same node tree level""" 316 | return Element(self.window, f'{self.js_path}.previousSibling') 317 | 318 | @property 319 | def previousElementSibling(self): 320 | """Returns the previous element at the same node tree level""" 321 | return Element(self.window, f'{self.js_path}.previousElementSibling') 322 | 323 | @property 324 | def scrollHeight(self) -> int: 325 | """Returns the entire height of an element, including padding""" 326 | return self.window.evaluate_js(f'{self.js_path}.scrollHeight') or 0 327 | 328 | @property 329 | def scrollLeft(self) -> int: 330 | """Sets or returns the number of pixels an element's content is scrolled horizontally""" 331 | return self.window.evaluate_js(f'{self.js_path}.scrollLeft') or 0 332 | 333 | @scrollLeft.setter 334 | def scrollLeft(self, value: int): 335 | """Sets the number of pixels an element's content is scrolled horizontally""" 336 | self.window.evaluate_js(f'{self.js_path}.scrollLeft = {value}') 337 | 338 | @property 339 | def scrollTop(self) -> int: 340 | """Sets or returns the number of pixels an element's content is scrolled vertically""" 341 | return self.window.evaluate_js(f'{self.js_path}.scrollTop') or 0 342 | 343 | @scrollTop.setter 344 | def scrollTop(self, value: int): 345 | """Sets the number of pixels an element's content is scrolled vertically""" 346 | self.window.evaluate_js(f'{self.js_path}.scrollTop = {value}') 347 | 348 | @property 349 | def scrollWidth(self) -> int: 350 | """Returns the entire width of an element, including padding""" 351 | return self.window.evaluate_js(f'{self.js_path}.scrollWidth') or 0 352 | 353 | @property 354 | def tabIndex(self) -> int: 355 | """Sets or returns the value of the tabindex attribute of an element""" 356 | return self.window.evaluate_js(f'{self.js_path}.tabIndex') or 0 357 | 358 | @tabIndex.setter 359 | def tabIndex(self, value: int): 360 | """Sets the value of the tabindex attribute of an element""" 361 | self.window.evaluate_js(f'{self.js_path}.tabIndex = {value}') 362 | 363 | @property 364 | def tagName(self) -> str: 365 | """Returns the tag name of an element""" 366 | return self.window.evaluate_js(f'{self.js_path}.tagName') 367 | 368 | @property 369 | def textContent(self) -> str: 370 | """Sets or returns the textual content of a node and its descendants""" 371 | return self.window.evaluate_js(f'{self.js_path}.textContent') 372 | 373 | @textContent.setter 374 | def textContent(self, value: str): 375 | """Sets the textual content of a node and its descendants""" 376 | self.window.evaluate_js(f'{self.js_path}.textContent = {json.dumps(value)}') 377 | 378 | @property 379 | def title(self) -> str: 380 | """Sets or returns the value of the title attribute of an element""" 381 | return self.window.evaluate_js(f'{self.js_path}.title') 382 | 383 | @title.setter 384 | def title(self, value: str): 385 | """Sets the value of the title attribute of an element""" 386 | self.window.evaluate_js(f'{self.js_path}.title = {json.dumps(value)}') 387 | 388 | # Existing properties 389 | @property 390 | def innerText(self) -> str: 391 | """Get inner text""" 392 | return self.window.evaluate_js(f'{self.js_path}.innerText') 393 | @innerText.setter 394 | def innerText(self, value): 395 | """Set inner text""" 396 | self.window.evaluate_js(f'{self.js_path}.innerText = {json.dumps(value)}') 397 | 398 | @property 399 | def innerHTML(self) -> str: 400 | """Get inner HTML""" 401 | return self.window.evaluate_js(f'{self.js_path}.innerHTML') 402 | @innerHTML.setter 403 | def innerHTML(self, value): 404 | """Set inner HTML - Warning: this can lead to XSS vulnerabilities if not sanitized properly. Use with caution.""" 405 | self.window.evaluate_js(f'{self.js_path}.innerHTML = {json.dumps(value)}') 406 | 407 | @property 408 | def value(self): 409 | """Get value of form element.""" 410 | return self.window.evaluate_js(f'{self.js_path}.value') 411 | @value.setter 412 | def value(self, value): 413 | """Set value of form element.""" 414 | self.window.evaluate_js(f'{self.js_path}.value = {json.dumps(value)}') 415 | 416 | @property 417 | def style(self): 418 | """Get style object""" 419 | return Style(self.window, f'{self.js_path}.style') 420 | 421 | # Methods 422 | def after(self, *nodes): 423 | """Inserts one or more nodes (elements) or strings after an element""" 424 | for node in nodes: 425 | if isinstance(node, Element): 426 | self.window.evaluate_js(f'{self.js_path}.after({node.js_path})') 427 | else: 428 | self.window.evaluate_js(f'{self.js_path}.after({json.dumps(str(node))})') 429 | 430 | def append(self, *nodes): 431 | """Adds (appends) one or several nodes (element) or strings after the last child of an element""" 432 | for node in nodes: 433 | if isinstance(node, Element): 434 | self.window.evaluate_js(f'{self.js_path}.append({node.js_path})') 435 | else: 436 | self.window.evaluate_js(f'{self.js_path}.append({json.dumps(str(node))})') 437 | 438 | def before(self, *nodes): 439 | """Inserts one or more nodes (elements) or strings before an element""" 440 | for node in nodes: 441 | if isinstance(node, Element): 442 | self.window.evaluate_js(f'{self.js_path}.before({node.js_path})') 443 | else: 444 | self.window.evaluate_js(f'{self.js_path}.before({json.dumps(str(node))})') 445 | 446 | def blur(self): 447 | """Removes focus from an element""" 448 | self.window.evaluate_js(f'{self.js_path}.blur()') 449 | 450 | def click(self): 451 | """Simulates a mouse-click on an element""" 452 | self.window.evaluate_js(f'{self.js_path}.click()') 453 | 454 | def cloneNode(self, deep: bool = False): 455 | """Clones an element""" 456 | return Element(self.window, f'{self.js_path}.cloneNode({json.dumps(deep)})') 457 | 458 | def closest(self, selector: str): 459 | """Searches the DOM tree for the closest element that matches a CSS selector""" 460 | return Element(self.window, f'{self.js_path}.closest({json.dumps(selector)})') 461 | 462 | def compareDocumentPosition(self, other): 463 | """Compares the document position of two elements""" 464 | if isinstance(other, Element): 465 | return self.window.evaluate_js(f'{self.js_path}.compareDocumentPosition({other.js_path})') 466 | else: 467 | raise TypeError("compareDocumentPosition expects an Element") 468 | 469 | def contains(self, other): 470 | """Returns true if a node is a descendant of a node""" 471 | if isinstance(other, Element): 472 | return self.window.evaluate_js(f'{self.js_path}.contains({other.js_path})') 473 | else: 474 | raise TypeError("contains expects an Element") 475 | 476 | def focus(self): 477 | """Gives focus to an element""" 478 | self.window.evaluate_js(f'{self.js_path}.focus()') 479 | 480 | def getAttribute(self, name: str) -> str: 481 | """Returns the value of an element's attribute""" 482 | return self.window.evaluate_js(f'{self.js_path}.getAttribute({json.dumps(name)})') 483 | 484 | def getAttributeNode(self, name: str): 485 | """Returns an attribute node""" 486 | return self.window.evaluate_js(f'{self.js_path}.getAttributeNode({json.dumps(name)})') 487 | 488 | def getBoundingClientRect(self): 489 | """Returns the size of an element and its position relative to the viewport""" 490 | return self.window.evaluate_js(f'{self.js_path}.getBoundingClientRect()') 491 | 492 | def getElementsByClassName(self, class_name: str): 493 | """Returns a collection of child elements with a given class name""" 494 | return ElementList(self.window, f'{self.js_path}.getElementsByClassName({json.dumps(class_name)})') 495 | 496 | def getElementsByTagName(self, tag_name: str): 497 | """Returns a collection of child elements with a given tag name""" 498 | return ElementList(self.window, f'{self.js_path}.getElementsByTagName({json.dumps(tag_name)})') 499 | 500 | def hasAttribute(self, name: str) -> bool: 501 | """Returns true if an element has a given attribute""" 502 | return self.window.evaluate_js(f'{self.js_path}.hasAttribute({json.dumps(name)})') 503 | 504 | def hasAttributes(self) -> bool: 505 | """Returns true if an element has any attributes""" 506 | return self.window.evaluate_js(f'{self.js_path}.hasAttributes()') 507 | 508 | def hasChildNodes(self) -> bool: 509 | """Returns true if an element has any child nodes""" 510 | return self.window.evaluate_js(f'{self.js_path}.hasChildNodes()') 511 | 512 | def insertAdjacentElement(self, position: str, element): 513 | """Inserts a new HTML element at a position relative to an element""" 514 | if isinstance(element, Element): 515 | return Element(self.window, f'{self.js_path}.insertAdjacentElement({json.dumps(position)}, {element.js_path})') 516 | else: 517 | raise TypeError("insertAdjacentElement expects an Element") 518 | 519 | def insertAdjacentHTML(self, position: str, html: str): 520 | """Inserts an HTML formatted text at a position relative to an element""" 521 | self.window.evaluate_js(f'{self.js_path}.insertAdjacentHTML({json.dumps(position)}, {json.dumps(html)})') 522 | 523 | def insertAdjacentText(self, position: str, text: str): 524 | """Inserts text into a position relative to an element""" 525 | self.window.evaluate_js(f'{self.js_path}.insertAdjacentText({json.dumps(position)}, {json.dumps(text)})') 526 | 527 | def insertBefore(self, new_node, reference_node): 528 | """Inserts a new child node before an existing child node""" 529 | if isinstance(new_node, Element) and isinstance(reference_node, Element): 530 | return Element(self.window, f'{self.js_path}.insertBefore({new_node.js_path}, {reference_node.js_path})') 531 | else: 532 | raise TypeError("insertBefore expects Element objects") 533 | 534 | def isDefaultNamespace(self, namespace_uri: str) -> bool: 535 | """Returns true if a given namespaceURI is the default""" 536 | return self.window.evaluate_js(f'{self.js_path}.isDefaultNamespace({json.dumps(namespace_uri)})') 537 | 538 | def isEqualNode(self, other): 539 | """Checks if two elements are equal""" 540 | if isinstance(other, Element): 541 | return self.window.evaluate_js(f'{self.js_path}.isEqualNode({other.js_path})') 542 | else: 543 | raise TypeError("isEqualNode expects an Element") 544 | 545 | def isSameNode(self, other): 546 | """Checks if two elements are the same node""" 547 | if isinstance(other, Element): 548 | return self.window.evaluate_js(f'{self.js_path}.isSameNode({other.js_path})') 549 | else: 550 | raise TypeError("isSameNode expects an Element") 551 | 552 | def matches(self, selector: str) -> bool: 553 | """Returns true if an element is matched by a given CSS selector""" 554 | return self.window.evaluate_js(f'{self.js_path}.matches({json.dumps(selector)})') 555 | 556 | def normalize(self): 557 | """Joins adjacent text nodes and removes empty text nodes in an element""" 558 | self.window.evaluate_js(f'{self.js_path}.normalize()') 559 | 560 | def querySelector(self, selector: str): 561 | """Returns the first child element that matches a CSS selector(s)""" 562 | return Element(self.window, f'{self.js_path}.querySelector({json.dumps(selector)})') 563 | 564 | def querySelectorAll(self, selector: str): 565 | """Returns all child elements that matches a CSS selector(s)""" 566 | return ElementList(self.window, f'{self.js_path}.querySelectorAll({json.dumps(selector)})') 567 | 568 | def remove(self): 569 | """Removes an element from the DOM""" 570 | self.window.evaluate_js(f'{self.js_path}.remove()') 571 | 572 | def removeAttribute(self, name: str): 573 | """Removes an attribute from an element""" 574 | self.window.evaluate_js(f'{self.js_path}.removeAttribute({json.dumps(name)})') 575 | 576 | def removeAttributeNode(self, attr_node): 577 | """Removes an attribute node, and returns the removed node""" 578 | return self.window.evaluate_js(f'{self.js_path}.removeAttributeNode({attr_node})') 579 | 580 | def removeEventListener(self, event_type: str, callback=None): 581 | """Removes an event handler that has been attached with the addEventListener() method""" 582 | # Get element ID for handler removal 583 | element_id = self.window.evaluate_js(f""" 584 | (function() {{ 585 | var el = {self.js_path}; 586 | return el ? (el.id || 'anonymous_' + Math.random().toString(36).substr(2, 9)) : null; 587 | }})() 588 | """) 589 | 590 | if element_id and hasattr(self.window, '_py_context'): 591 | key = f"{element_id}_{event_type}" 592 | if key in self.window._py_context.event_handlers: 593 | del self.window._py_context.event_handlers[key] 594 | 595 | # Note: JavaScript removeEventListener requires the exact same function reference 596 | # This is a simplified implementation 597 | js_code = f""" 598 | console.log("removeEventListener called for {event_type} on element"); 599 | """ 600 | self.window.evaluate_js(js_code) 601 | 602 | def scrollIntoView(self, align_to_top: bool = True): 603 | """Scrolls the element into the visible area of the browser window""" 604 | self.window.evaluate_js(f'{self.js_path}.scrollIntoView({json.dumps(align_to_top)})') 605 | 606 | def setAttributeNode(self, attr_node): 607 | """Sets or changes an attribute node""" 608 | return self.window.evaluate_js(f'{self.js_path}.setAttributeNode({attr_node})') 609 | 610 | def toString(self) -> str: 611 | """Converts an element to a string""" 612 | return self.window.evaluate_js(f'{self.js_path}.toString()') 613 | 614 | # Existing methods 615 | def setAttribute(self, attr_name, value): 616 | """Set attribute""" 617 | self.window.evaluate_js(f'{self.js_path}.setAttribute("{attr_name}", {json.dumps(value)})') 618 | 619 | def appendChild(self, child): 620 | """Append child""" 621 | if isinstance(child, Element): 622 | self.window.evaluate_js(f'{self.js_path}.appendChild({child.js_path})') 623 | else: 624 | raise TypeError("appendChild expects an Element") 625 | def removeChild(self, child): 626 | """Remove child""" 627 | if isinstance(child, Element): 628 | self.window.evaluate_js(f'{self.js_path}.removeChild({child.js_path})') 629 | else: 630 | raise TypeError("removeChild expects an Element") 631 | 632 | def replaceChild(self, new_child, old_child): 633 | """Replace child""" 634 | if isinstance(new_child, Element) and isinstance(old_child, Element): 635 | self.window.evaluate_js(f'{self.js_path}.replaceChild({new_child.js_path}, {old_child.js_path})') 636 | else: 637 | raise TypeError("replaceChild expects Element objects") 638 | 639 | def addEventListener(self, event_type, callback)-> bool: 640 | """Add event listener. Returns success. Example: 641 | >>> element.addEventListener("click",callback_function) 642 | -> True (if successful)""" 643 | 644 | element_id = self.window.evaluate_js(f""" 645 | (function() {{ 646 | var el = {self.js_path}; 647 | return el ? (el.id || 'anonymous_' + Math.random().toString(36).substr(2, 9)) : null; 648 | }})() 649 | """) 650 | 651 | if not element_id: 652 | print(f"WARNING: Could not get ID for element: {self.js_path}") 653 | return False 654 | 655 | # Get the PyContext from the window 656 | context = None 657 | if hasattr(self.window, '_py_context'): 658 | context = self.window._py_context 659 | 660 | if not context: 661 | # If no context found, create a temporary one just for this handler 662 | context = PositronContext(self.window) 663 | self.window._py_context = context 664 | 665 | # Register the event handler with the context 666 | handler_key = context.register_event_handler(element_id, event_type, callback) 667 | 668 | # Create global event handler if not already created 669 | if not hasattr(self.window, 'handle_py_event'): 670 | def handle_py_event(element_id, event_type): 671 | key = f"{element_id}_{event_type}" 672 | if hasattr(self.window, '_py_context') and key in self.window._py_context.event_handlers: 673 | try: 674 | return self.window._py_context.event_handlers[key]() 675 | except Exception as e: 676 | print(f"[ERROR] handling event: {e}") 677 | traceback.print_exc() 678 | return str(e) 679 | print(f"WARNING: No handler found for {key}") 680 | return False 681 | 682 | self.window.handle_py_event = handle_py_event 683 | self.window.expose(handle_py_event) 684 | 685 | # Add the event listener in JavaScript 686 | js_code = f""" 687 | (function() {{ 688 | var element = {self.js_path}; 689 | if (!element) return false; 690 | 691 | element.addEventListener("{event_type}", function(event) {{ 692 | console.log("Event triggered: {event_type} on {element_id}"); 693 | window.pywebview.api.handle_py_event("{element_id}", "{event_type}"); 694 | }}); 695 | return true; 696 | }})(); 697 | """ 698 | 699 | success = self.window.evaluate_js(js_code) 700 | 701 | return success 702 | class Style: 703 | def __init__(self, window, js_path): 704 | self.window = window 705 | self.js_path = js_path 706 | 707 | def __setattr__(self, name, value): 708 | if name in ['window', 'js_path']: 709 | super().__setattr__(name, value) 710 | else: 711 | self.window.evaluate_js(f'{self.js_path}.{name} = {json.dumps(value)}') 712 | def __getattr__(self, name): 713 | return self.window.evaluate_js(f'{self.js_path}.{name}') 714 | 715 | class ElementList: 716 | def __init__(self, window, js_path): 717 | self.window = window 718 | self.js_path = js_path 719 | self.length = self.window.evaluate_js(f'{self.js_path}.length') or 0 720 | def __getitem__(self, index): 721 | if 0 <= index < self.length: 722 | return Element(self.window, f'{self.js_path}[{index}]') 723 | raise IndexError("ElementList index out of range") 724 | def __len__(self): 725 | return self.length 726 | 727 | def __iter__(self): 728 | for i in range(self.length): 729 | yield self[i] 730 | yield self[i] 731 | 732 | class Console: 733 | """Console object for debugging and logging""" 734 | def __init__(self, window): 735 | self.window = window 736 | 737 | def assert_(self, assertion, *message): 738 | """Writes an error message to the console if a assertion is false""" 739 | if not assertion: 740 | msg = ' '.join(str(arg) for arg in message) if message else 'Assertion failed' 741 | self.window.evaluate_js(f'console.assert({json.dumps(assertion)}, {json.dumps(msg)})') 742 | 743 | def clear(self): 744 | """Clears the console""" 745 | self.window.evaluate_js('console.clear()') 746 | 747 | def count(self, label='default'): 748 | """Logs the number of times that this particular call to count() has been called""" 749 | self.window.evaluate_js(f'console.count({json.dumps(label)})') 750 | 751 | def error(self, *args): 752 | """Outputs an error message to the console""" 753 | message = ' '.join(str(arg) for arg in args) 754 | self.window.evaluate_js(f'console.error({json.dumps(message)})') 755 | 756 | def group(self, *args): 757 | """Creates a new inline group in the console""" 758 | message = ' '.join(str(arg) for arg in args) if args else '' 759 | self.window.evaluate_js(f'console.group({json.dumps(message)})') 760 | 761 | def groupCollapsed(self, *args): 762 | """Creates a new inline group in the console, but collapsed""" 763 | message = ' '.join(str(arg) for arg in args) if args else '' 764 | self.window.evaluate_js(f'console.groupCollapsed({json.dumps(message)})') 765 | 766 | def groupEnd(self): 767 | """Exits the current inline group in the console""" 768 | self.window.evaluate_js('console.groupEnd()') 769 | 770 | def info(self, *args): 771 | """Outputs an informational message to the console""" 772 | message = ' '.join(str(arg) for arg in args) 773 | self.window.evaluate_js(f'console.info({json.dumps(message)})') 774 | 775 | def log(self, *args): 776 | """Outputs a message to the console""" 777 | message = ' '.join(str(arg) for arg in args) 778 | self.window.evaluate_js(f'console.log({json.dumps(message)})') 779 | 780 | def table(self, data): 781 | """Displays tabular data as a table""" 782 | self.window.evaluate_js(f'console.table({json.dumps(data)})') 783 | 784 | def time(self, label='default'): 785 | """Starts a timer""" 786 | self.window.evaluate_js(f'console.time({json.dumps(label)})') 787 | 788 | def timeEnd(self, label='default'): 789 | """Stops a timer that was previously started by console.time()""" 790 | self.window.evaluate_js(f'console.timeEnd({json.dumps(label)})') 791 | 792 | def trace(self, *args): 793 | """Outputs a stack trace to the console""" 794 | message = ' '.join(str(arg) for arg in args) if args else '' 795 | self.window.evaluate_js(f'console.trace({json.dumps(message)})') 796 | 797 | def warn(self, *args): 798 | """Outputs a warning message to the console""" 799 | message = ' '.join(str(arg) for arg in args) 800 | self.window.evaluate_js(f'console.warn({json.dumps(message)})') 801 | 802 | class History: 803 | """History object for navigation""" 804 | def __init__(self, window): 805 | self.window = window 806 | 807 | @property 808 | def length(self) -> int: 809 | """Returns the number of URLs (pages) in the history list""" 810 | return self.window.evaluate_js('history.length') or 0 811 | 812 | def back(self): 813 | """Loads the previous URL (page) in the history list""" 814 | self.window.evaluate_js('history.back()') 815 | 816 | def forward(self): 817 | """Loads the next URL (page) in the history list""" 818 | self.window.evaluate_js('history.forward()') 819 | 820 | def go(self, number: int): 821 | """Loads a specific URL (page) from the history list""" 822 | self.window.evaluate_js(f'history.go({number})') 823 | 824 | class Location: 825 | """Location object for URL manipulation""" 826 | def __init__(self, window): 827 | self.window = window 828 | 829 | @property 830 | def hash(self) -> str: 831 | """Sets or returns the anchor part (#) of a URL""" 832 | return self.window.evaluate_js('location.hash') or '' 833 | 834 | @hash.setter 835 | def hash(self, value: str): 836 | """Sets the anchor part (#) of a URL""" 837 | self.window.evaluate_js(f'location.hash = {json.dumps(value)}') 838 | 839 | @property 840 | def host(self) -> str: 841 | """Sets or returns the hostname and port number of a URL""" 842 | return self.window.evaluate_js('location.host') or '' 843 | 844 | @host.setter 845 | def host(self, value: str): 846 | """Sets the hostname and port number of a URL""" 847 | self.window.evaluate_js(f'location.host = {json.dumps(value)}') 848 | 849 | @property 850 | def hostname(self) -> str: 851 | """Sets or returns the hostname of a URL""" 852 | return self.window.evaluate_js('location.hostname') or '' 853 | 854 | @hostname.setter 855 | def hostname(self, value: str): 856 | """Sets the hostname of a URL""" 857 | self.window.evaluate_js(f'location.hostname = {json.dumps(value)}') 858 | 859 | @property 860 | def href(self) -> str: 861 | """Sets or returns the entire URL""" 862 | return self.window.evaluate_js('location.href') or '' 863 | 864 | @href.setter 865 | def href(self, value: str): 866 | """Sets the entire URL""" 867 | self.window.evaluate_js(f'location.href = {json.dumps(value)}') 868 | 869 | @property 870 | def origin(self) -> str: 871 | """Returns the protocol, hostname and port number of a URL""" 872 | return self.window.evaluate_js('location.origin') or '' 873 | 874 | @property 875 | def pathname(self) -> str: 876 | """Sets or returns the path name of a URL""" 877 | return self.window.evaluate_js('location.pathname') or '' 878 | 879 | @pathname.setter 880 | def pathname(self, value: str): 881 | """Sets the path name of a URL""" 882 | self.window.evaluate_js(f'location.pathname = {json.dumps(value)}') 883 | 884 | @property 885 | def port(self) -> str: 886 | """Sets or returns the port number of a URL""" 887 | return self.window.evaluate_js('location.port') or '' 888 | 889 | @port.setter 890 | def port(self, value: str): 891 | """Sets the port number of a URL""" 892 | self.window.evaluate_js(f'location.port = {json.dumps(value)}') 893 | 894 | @property 895 | def protocol(self) -> str: 896 | """Sets or returns the protocol of a URL""" 897 | return self.window.evaluate_js('location.protocol') or '' 898 | 899 | @protocol.setter 900 | def protocol(self, value: str): 901 | """Sets the protocol of a URL""" 902 | self.window.evaluate_js(f'location.protocol = {json.dumps(value)}') 903 | 904 | @property 905 | def search(self) -> str: 906 | """Sets or returns the querystring part of a URL""" 907 | return self.window.evaluate_js('location.search') or '' 908 | 909 | @search.setter 910 | def search(self, value: str): 911 | """Sets the querystring part of a URL""" 912 | self.window.evaluate_js(f'location.search = {json.dumps(value)}') 913 | 914 | def assign(self, url: str): 915 | """Loads a new document""" 916 | self.window.evaluate_js(f'location.assign({json.dumps(url)})') 917 | 918 | def reload(self, force_reload: bool = False): 919 | """Reloads the current document""" 920 | self.window.evaluate_js(f'location.reload({json.dumps(force_reload)})') 921 | 922 | def replace(self, url: str): 923 | """Replaces the current document with a new one""" 924 | self.window.evaluate_js(f'location.replace({json.dumps(url)})') 925 | 926 | class Navigator: 927 | """Navigator object for browser information""" 928 | def __init__(self, window): 929 | self.window = window 930 | 931 | @property 932 | def appCodeName(self) -> str: 933 | """Returns the application code name of the browser""" 934 | return self.window.evaluate_js('navigator.appCodeName') or '' 935 | 936 | @property 937 | def appName(self) -> str: 938 | """Returns the application name of the browser""" 939 | return self.window.evaluate_js('navigator.appName') or '' 940 | 941 | @property 942 | def appVersion(self) -> str: 943 | """Returns the version information of the browser""" 944 | return self.window.evaluate_js('navigator.appVersion') or '' 945 | 946 | @property 947 | def cookieEnabled(self) -> bool: 948 | """Returns true if browser cookies are enabled""" 949 | return self.window.evaluate_js('navigator.cookieEnabled') or False 950 | 951 | @property 952 | def geolocation(self): 953 | """Returns a geolocation object for the user's location""" 954 | return self.window.evaluate_js('navigator.geolocation') 955 | 956 | @property 957 | def language(self) -> str: 958 | """Returns browser language""" 959 | return self.window.evaluate_js('navigator.language') or '' 960 | 961 | @property 962 | def onLine(self) -> bool: 963 | """Returns true if the browser is online""" 964 | return self.window.evaluate_js('navigator.onLine') or False 965 | 966 | @property 967 | def platform(self) -> str: 968 | """Returns the platform of the browser""" 969 | return self.window.evaluate_js('navigator.platform') or '' 970 | 971 | @property 972 | def product(self) -> str: 973 | """Returns the product name of the browser""" 974 | return self.window.evaluate_js('navigator.product') or '' 975 | 976 | @property 977 | def userAgent(self) -> str: 978 | """Returns browser user-agent header""" 979 | return self.window.evaluate_js('navigator.userAgent') or '' 980 | 981 | def javaEnabled(self) -> bool: 982 | """Returns whether Java is enabled in the browser""" 983 | return self.window.evaluate_js('navigator.javaEnabled()') or False 984 | 985 | class Screen: 986 | """Screen object for screen information""" 987 | def __init__(self, window): 988 | self.window = window 989 | 990 | @property 991 | def availHeight(self) -> int: 992 | """Returns the height of the screen (excluding the Windows Taskbar)""" 993 | return self.window.evaluate_js('screen.availHeight') or 0 994 | 995 | @property 996 | def availWidth(self) -> int: 997 | """Returns the width of the screen (excluding the Windows Taskbar)""" 998 | return self.window.evaluate_js('screen.availWidth') or 0 999 | 1000 | @property 1001 | def colorDepth(self) -> int: 1002 | """Returns the bit depth of the color palette for displaying images""" 1003 | return self.window.evaluate_js('screen.colorDepth') or 0 1004 | 1005 | @property 1006 | def height(self) -> int: 1007 | """Returns the total height of the screen""" 1008 | return self.window.evaluate_js('screen.height') or 0 1009 | 1010 | @property 1011 | def pixelDepth(self) -> int: 1012 | """Returns the color resolution (in bits per pixel) of the screen""" 1013 | return self.window.evaluate_js('screen.pixelDepth') or 0 1014 | 1015 | @property 1016 | def width(self) -> int: 1017 | """Returns the total width of the screen""" 1018 | return self.window.evaluate_js('screen.width') or 0 1019 | 1020 | class HTMLWindow: 1021 | """HTML Window object providing JavaScript window functionality""" 1022 | def __init__(self, window): 1023 | self.window = window 1024 | self._console = Console(window) 1025 | self._history = History(window) 1026 | self._location = Location(window) 1027 | self._navigator = Navigator(window) 1028 | self._screen = Screen(window) 1029 | 1030 | # Properties 1031 | @property 1032 | def closed(self) -> bool: 1033 | """Returns a boolean true if a window is closed""" 1034 | return self.window.evaluate_js('window.closed') or False 1035 | 1036 | @property 1037 | def console(self) -> Console: 1038 | """Returns the Console Object for the window""" 1039 | return self._console 1040 | 1041 | @property 1042 | def document(self): 1043 | """Returns the Document object for the window""" 1044 | return Document(self.window) 1045 | 1046 | @property 1047 | def frameElement(self): 1048 | """Returns the frame in which the window runs""" 1049 | return self.window.evaluate_js('window.frameElement') 1050 | 1051 | @property 1052 | def frames(self): 1053 | """Returns all window objects running in the window""" 1054 | return self.window.evaluate_js('window.frames') 1055 | 1056 | @property 1057 | def history(self) -> History: 1058 | """Returns the History object for the window""" 1059 | return self._history 1060 | 1061 | @property 1062 | def innerHeight(self) -> int: 1063 | """Returns the height of the window's content area (viewport) including scrollbars""" 1064 | return self.window.evaluate_js('window.innerHeight') or 0 1065 | 1066 | @property 1067 | def innerWidth(self) -> int: 1068 | """Returns the width of a window's content area (viewport) including scrollbars""" 1069 | return self.window.evaluate_js('window.innerWidth') or 0 1070 | 1071 | @property 1072 | def length(self) -> int: 1073 | """Returns the number of