pymarklet 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pymarklet/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Create browser bookmarklets that run Python with Pyodide."""
2
+
3
+ from .generator import build_bookmarklet, declared_pypi_packages, imported_package_modules
4
+
5
+ __all__ = ["build_bookmarklet", "declared_pypi_packages", "imported_package_modules"]
6
+ __version__ = "0.1.0"
pymarklet/cli.py ADDED
@@ -0,0 +1,35 @@
1
+ """Command-line interface for PyMarklet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+
9
+ from .generator import generate_bookmarklet
10
+
11
+
12
+ def build_parser() -> argparse.ArgumentParser:
13
+ """Build the command-line argument parser."""
14
+ parser = argparse.ArgumentParser(
15
+ description="Generate a Pyodide-powered browser bookmarklet from a Python script.",
16
+ )
17
+ parser.add_argument("python_file", type=Path, help="UTF-8 Python source file to embed")
18
+ parser.add_argument(
19
+ "-o",
20
+ "--output",
21
+ type=Path,
22
+ default=Path("bookmarklet.js"),
23
+ help="destination for the bookmarklet URL (default: bookmarklet.js)",
24
+ )
25
+ return parser
26
+
27
+
28
+ def main(argv: Sequence[str] | None = None) -> int:
29
+ """Run the PyMarklet CLI and return a process exit status."""
30
+ args = build_parser().parse_args(argv)
31
+ return 0 if generate_bookmarklet(args.python_file, args.output) else 1
32
+
33
+
34
+ if __name__ == "__main__":
35
+ raise SystemExit(main())
pymarklet/generator.py ADDED
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import json
5
+ import re
6
+ from pathlib import Path
7
+ from urllib.parse import quote
8
+
9
+ PYODIDE_VERSION = "0.27.5"
10
+ PYODIDE_BASE_URL = f"https://cdn.jsdelivr.net/pyodide/v{PYODIDE_VERSION}/full/"
11
+ PYODIDE_SCRIPT_URL = f"{PYODIDE_BASE_URL}pyodide.js"
12
+
13
+
14
+ # Final optimized, pre-minified JavaScript layout shell
15
+ JAVASCRIPT_TEMPLATE = r"""(() => {
16
+ const cId = "py-bookmarklet-container";
17
+ if (document.getElementById(cId)) return;
18
+ const c = document.createElement("section");
19
+ c.id = cId;
20
+ c.style.cssText = "position:fixed;top:10px;right:10px;width:400px;height:500px;z-index:2147483647;display:flex;flex-direction:column;overflow:hidden;box-sizing:border-box;border:1px solid #333;border-radius:8px;background:#1e1e1e;color:#f4f4f4;font-family:monospace;box-shadow:0 4px 15px rgba(0,0,0,.3)";
21
+ const h = document.createElement("header");
22
+ h.style.cssText = "display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#2d2d2d;border-bottom:1px solid #333;cursor:grab;user-select:none;touch-action:none";
23
+ const s = document.createElement("span");
24
+ s.textContent = "Loading Pyodide...";
25
+ s.style.cssText = "overflow:hidden;color:#6f6;font-size:12px;font-weight:bold;text-overflow:ellipsis;white-space:nowrap";
26
+ const cb = document.createElement("button");
27
+ cb.type = "button"; cb.textContent = "×"; cb.setAttribute("aria-label", "Close");
28
+ cb.style.cssText = "margin-left:12px;border:0;background:transparent;color:#f66;cursor:pointer;font-size:20px;font-weight:bold;line-height:1";
29
+ const ct = document.createElement("div");
30
+ ct.style.cssText = "flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden";
31
+ const cv = document.createElement("canvas");
32
+ cv.id = "canvas"; cv.tabIndex = 0; cv.style.cssText = "display:block;max-width:100%;max-height:70%;margin:0 auto;background:#000;image-rendering:pixelated;outline:none";
33
+ cv.addEventListener("pointerdown", () => cv.focus());
34
+ const o = document.createElement("pre");
35
+ o.style.cssText = "flex:1;min-height:0;overflow:auto;margin:0;padding:15px;box-sizing:border-box;white-space:pre-wrap;overflow-wrap:anywhere";
36
+ h.append(s, cb); ct.append(cv, o); c.append(h, ct); document.body.appendChild(c);
37
+ const setStatus = (msg, isErr = false) => { s.textContent = msg; s.style.color = isErr ? "#f66" : "#6f6"; };
38
+ const log = (val) => { o.textContent += `${String(val)}\n`; o.scrollTop = o.scrollHeight; };
39
+ cb.addEventListener("click", () => c.remove());
40
+ let dx = 0, dy = 0;
41
+ h.addEventListener("pointerdown", (e) => {
42
+ if (e.target === cb) return;
43
+ const b = c.getBoundingClientRect();
44
+ dx = e.clientX - b.left; dy = e.clientY - b.top;
45
+ h.setPointerCapture(e.pointerId); h.style.cursor = "grabbing";
46
+ });
47
+ h.addEventListener("pointermove", (e) => {
48
+ if (!h.hasPointerCapture(e.pointerId)) return;
49
+ const ml = Math.max(0, window.innerWidth - c.offsetWidth);
50
+ const mt = Math.max(0, window.innerHeight - c.offsetHeight);
51
+ const left = Math.min(ml, Math.max(0, e.clientX - dx));
52
+ const top = Math.min(mt, Math.max(0, e.clientY - dy));
53
+ c.style.left = `${left}px`; c.style.top = `${top}px`; c.style.right = "auto";
54
+ });
55
+ const stop = (e) => { if (h.hasPointerCapture(e.pointerId)) h.releasePointerCapture(e.pointerId); h.style.cursor = "grab"; };
56
+ h.addEventListener("pointerup", stop); h.addEventListener("pointercancel", stop);
57
+
58
+ __DATA_SPLICING_PLACEHOLDER__
59
+
60
+ async function runPython() {
61
+ try {
62
+ setStatus("Starting Python...");
63
+ const pyodide = await globalThis.loadPyodide({ indexURL: "__PYODIDE_BASE_URL__", stdout: log, stderr: log });
64
+ pyodide._api._skip_unwind_fatal_error = true;
65
+ pyodide.canvas.setCanvas2D(cv);
66
+
67
+ setStatus("Resolving Pyodide imports...");
68
+ await pyodide.loadPackagesFromImports(pyCode);
69
+
70
+ const installPyPiPackages = async (packages) => {
71
+ if (!packages.length) return;
72
+ setStatus(`Installing PyPI packages: ${packages.join(", ")}...`);
73
+ await pyodide.loadPackage("micropip");
74
+ const micropip = pyodide.pyimport("micropip");
75
+ try {
76
+ await micropip.install(packages);
77
+ } finally {
78
+ micropip.destroy();
79
+ }
80
+ };
81
+
82
+ // The optional first-line # PKGS: manifest specifies PyPI distribution
83
+ // names, covering cases such as `scikit-learn` -> `sklearn`.
84
+ await installPyPiPackages(declaredPackages);
85
+
86
+ // Check after both resolvers so conventional import names still install
87
+ // automatically when a script does not declare a distribution.
88
+ const pypiPackages = packageModules.filter((moduleName) => !pyodide.runPython(
89
+ `import importlib.util\nimportlib.util.find_spec(${JSON.stringify(moduleName)}) is not None`,
90
+ ));
91
+ await installPyPiPackages(pypiPackages);
92
+
93
+ setStatus("Starting Python...");
94
+ await pyodide.runPythonAsync(pyCode);
95
+ setStatus("Execution finished.");
96
+ } catch (err) {
97
+ setStatus("Python execution failed.", true);
98
+ log(err instanceof Error ? err.stack || err.message : err);
99
+ }
100
+ }
101
+ function loadPyodideScript() {
102
+ if (typeof globalThis.loadPyodide === "function") { void runPython(); return; }
103
+ const scriptNode = document.createElement("script"); scriptNode.src = "__PYODIDE_SCRIPT_URL__"; scriptNode.async = true;
104
+ scriptNode.onload = () => void runPython();
105
+ scriptNode.onerror = () => setStatus("Could not load Pyodide (possibly blocked by CSP).", true);
106
+ document.body.appendChild(scriptNode);
107
+ }
108
+ loadPyodideScript();
109
+ })();"""
110
+
111
+ def clean_python_code(code: str) -> str:
112
+ """Preserve the user's program exactly; JSON encoding handles URL embedding."""
113
+ return code
114
+
115
+ def declared_pypi_packages(python_code: str) -> list[str]:
116
+ """Read comma-separated PyPI requirements from a first-line # PKGS: header."""
117
+ first_line = python_code.split("\n", 1)[0].lstrip("\ufeff")
118
+ match = re.fullmatch(r"\s*#\s*PKGS\s*:\s*(.*)\s*", first_line, flags=re.IGNORECASE)
119
+ if not match or not match.group(1):
120
+ return []
121
+
122
+ packages = [package.strip() for package in match.group(1).split(",")]
123
+ return list(dict.fromkeys(package for package in packages if package))
124
+
125
+
126
+ def imported_package_modules(python_code: str) -> list[str]:
127
+ """Return third-party top-level modules imported by *python_code*."""
128
+ try:
129
+ tree = ast.parse(python_code)
130
+ except SyntaxError:
131
+ # Let Pyodide report the original syntax error when the bookmarklet runs.
132
+ return []
133
+
134
+ imported_modules: list[str] = []
135
+ for node in ast.walk(tree):
136
+ if isinstance(node, ast.Import):
137
+ imported_modules.extend(alias.name.split(".", 1)[0] for alias in node.names)
138
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
139
+ imported_modules.append(node.module.split(".", 1)[0])
140
+
141
+ return list(dict.fromkeys(imported_modules))
142
+
143
+
144
+ def build_bookmarklet(python_code: str) -> str:
145
+ cleaned_py = clean_python_code(python_code)
146
+ package_modules = imported_package_modules(cleaned_py)
147
+ declared_packages = declared_pypi_packages(cleaned_py)
148
+
149
+ # Inject variables securely as valid JS.
150
+ js_variables = (
151
+ f"const pyCode = {json.dumps(cleaned_py)};\n"
152
+ f"const declaredPackages = {json.dumps(declared_packages)};\n"
153
+ f"const packageModules = {json.dumps(package_modules)};"
154
+ )
155
+
156
+ payload = JAVASCRIPT_TEMPLATE.replace('__DATA_SPLICING_PLACEHOLDER__', js_variables)
157
+ payload = payload.replace("__PYODIDE_BASE_URL__", PYODIDE_BASE_URL)
158
+ payload = payload.replace("__PYODIDE_SCRIPT_URL__", PYODIDE_SCRIPT_URL)
159
+
160
+ # Keep line breaks: collapsing them can make a `//` comment consume the
161
+ # remaining JavaScript. `quote` encodes them safely for a bookmarklet URL.
162
+ return f"javascript:{quote(payload, safe='')}"
163
+
164
+ def generate_bookmarklet(python_file_path: Path, output_path: Path) -> bool:
165
+ """Generate a bookmarklet from *python_file_path* and write it to *output_path*."""
166
+ try:
167
+ python_code = python_file_path.read_text(encoding="utf-8")
168
+ except (OSError, UnicodeError) as error:
169
+ print(f"Error reading {python_file_path}: {error}")
170
+ return False
171
+
172
+ bookmarklet = build_bookmarklet(python_code)
173
+ try:
174
+ output_path.write_text(bookmarklet, encoding="utf-8")
175
+ except OSError as error:
176
+ print(f"Error writing {output_path}: {error}")
177
+ return False
178
+
179
+ print(f"Bookmarklet written to {output_path} ({len(bookmarklet)} characters).")
180
+ return True
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: pymarklet
3
+ Version: 0.1.0
4
+ Summary: Generate browser bookmarklets that run Python with Pyodide.
5
+ Keywords: bookmarklet,browser,pyodide,python,webassembly
6
+ Author: PyMarklet contributors
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Web Environment
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Internet :: WWW/HTTP
20
+ Classifier: Topic :: Software Development :: Code Generators
21
+ Requires-Python: >=3.11
22
+ Project-URL: Homepage, https://github.com/Block120/PyMarklet
23
+ Project-URL: Repository, https://github.com/Block120/PyMarklet
24
+ Project-URL: Issues, https://github.com/Block120/PyMarklet/issues
25
+ Project-URL: Changelog, https://github.com/Block120/PyMarklet/releases
26
+ Provides-Extra: test
27
+ Description-Content-Type: text/markdown
28
+
29
+ # PyMarklet
30
+
31
+ [![CI](https://github.com/Block120/PyMarklet/actions/workflows/ci.yml/badge.svg)](https://github.com/Block120/PyMarklet/actions/workflows/ci.yml)
32
+ [![PyPI](https://img.shields.io/pypi/v/pymarklet.svg)](https://pypi.org/project/pymarklet/)
33
+ [![Python](https://img.shields.io/pypi/pyversions/pymarklet.svg)](https://pypi.org/project/pymarklet/)
34
+
35
+ Generate a browser bookmarklet that runs a Python script with [Pyodide](https://pyodide.org/). The generated bookmarklet adds a draggable output panel to the current page, loads Python in WebAssembly, and runs your embedded script in the browser.
36
+
37
+ > **Experimental:** PyMarklet executes code in the context of the web page where the bookmarklet is activated. Read the [Security and privacy](#security-and-privacy) section before sharing or running a bookmarklet.
38
+
39
+ ## Installation
40
+
41
+ ```sh
42
+ python -m pip install pymarklet
43
+ ```
44
+
45
+ PyMarklet requires Python 3.11 or later to generate bookmarklets. The resulting bookmarklet runs Python in a supported modern browser; it does not use your local Python environment at runtime.
46
+
47
+ ## Quick start
48
+
49
+ Create a Python script:
50
+
51
+ ```python
52
+ # hello.py
53
+ print("Hello from Python in your browser!")
54
+ ```
55
+
56
+ Generate a bookmarklet URL:
57
+
58
+ ```sh
59
+ pymarklet hello.py -o hello-bookmarklet.txt
60
+ ```
61
+
62
+ 1. Open `hello-bookmarklet.txt` and copy its complete contents.
63
+ 2. Create a browser bookmark named **PyMarklet: hello**.
64
+ 3. Paste the copied `javascript:` URL into the bookmark's URL/location field.
65
+ 4. Visit a webpage and activate the bookmark.
66
+
67
+ A panel appears in the upper-right corner with status and Python output. Drag its header to move it, click **×** to remove it, and click the canvas before sending keyboard input to graphical applications.
68
+
69
+ > Some browsers block pasting `javascript:` URLs into the address bar. Paste the URL into a bookmark's URL field instead.
70
+
71
+ ## Command line
72
+
73
+ ```text
74
+ pymarklet [-h] [-o OUTPUT] python_file
75
+ ```
76
+
77
+ | Argument | Description |
78
+ | --- | --- |
79
+ | `python_file` | UTF-8 Python source file to embed. |
80
+ | `-o`, `--output` | Destination file for the bookmarklet URL. Defaults to `bookmarklet.js` in the current directory. |
81
+
82
+ The command returns a nonzero status if it cannot read the source or write the output file.
83
+
84
+ ## Dependencies
85
+
86
+ PyMarklet determines dependencies in two stages when the bookmarklet runs:
87
+
88
+ 1. It asks Pyodide to load packages it recognizes from normal `import` statements.
89
+ 2. For missing imports, it attempts to install a package with the same name using `micropip`.
90
+
91
+ When a PyPI distribution name differs from its import name, declare it in a **first-line** manifest:
92
+
93
+ ```python
94
+ # PKGS: python-dateutil, more-itertools
95
+ from dateutil import parser
96
+ from more_itertools import chunked
97
+
98
+ print(parser.isoparse("2026-10-10T12:34:56Z"))
99
+ print(list(chunked(range(6), 2)))
100
+ ```
101
+
102
+ The manifest is comma-separated, case-insensitive (`# pkgs:` also works), trims duplicates, and accepts normal PyPI requirement specifiers such as `requests>=2.32`. Use it for every dependency whose import name is not its distribution name—for example, `scikit-learn` / `sklearn`.
103
+
104
+ ## How it works
105
+
106
+ The generated URL embeds your source, creates a small page overlay, and loads **Pyodide 0.27.5** from jsDelivr. Standard output and errors appear in the overlay. The canvas is available to Pyodide-compatible graphical libraries such as Pygame.
107
+
108
+ The source in [`examples/integration_stress_test.py`](examples/integration_stress_test.py) and the page in [`examples/sandbox.html`](examples/sandbox.html) are a manual integration example:
109
+
110
+ ```sh
111
+ pymarklet examples/integration_stress_test.py
112
+ ```
113
+
114
+ Open `examples/sandbox.html` in a browser, then use the generated bookmarklet on that page.
115
+
116
+ ## Limitations
117
+
118
+ - **Network access is required.** The browser fetches Pyodide from jsDelivr and may fetch packages from PyPI-compatible hosts.
119
+ - **Content Security Policy (CSP) can prevent execution.** Sites may block the injected Pyodide script, package downloads, or required browser APIs.
120
+ - **Only Pyodide-compatible packages work.** Native packages without compatible WebAssembly wheels cannot be installed.
121
+ - **Import detection is static and best-effort.** Dynamic imports and relative imports are not detected; use `# PKGS:` for dependencies that need explicit installation.
122
+ - **Bookmark URLs have practical length limits.** The loader is already several thousand characters long, and the URL grows with your script. Keep scripts small.
123
+ - Closing the overlay removes its UI but does not forcibly cancel Python work already scheduled in the page.
124
+
125
+ ## Security and privacy
126
+
127
+ - A bookmarklet is executable code. Only run bookmarklets you trust.
128
+ - Your Python source is embedded in the bookmarklet URL. **Never include secrets, credentials, private endpoints, or sensitive data.** Anyone with the URL can extract the source.
129
+ - The bookmarklet executes with the permissions and network environment of the page where you activate it. Review site policies and do not use it on pages where injected code is inappropriate.
130
+ - Dependencies are downloaded and executed in the browser. Pin or constrain declared requirements where reproducibility matters.
131
+ - PyMarklet relies on jsDelivr to serve its pinned Pyodide runtime. Ad blockers, offline mode, proxies, or supply-chain policy may block it.
132
+
133
+ See [`SECURITY.md`](SECURITY.md) for vulnerability reporting.
134
+
135
+ ## Development
136
+
137
+ ```sh
138
+ uv sync
139
+ uv run python -m unittest discover -s tests -v
140
+ uv build
141
+ ```
142
+
143
+ Contributions are welcome; see [`CONTRIBUTING.md`](CONTRIBUTING.md). This project is licensed under the [MIT License](LICENSE).
144
+
145
+ ## Publishing checklist
146
+
147
+ Before the first public release, confirm the `pymarklet` name is available on PyPI and configure PyPI Trusted Publishing for this repository and the `pypi` GitHub environment.
@@ -0,0 +1,8 @@
1
+ pymarklet/__init__.py,sha256=L1JChUeGKvKgb3RcUq--UGvWzwq2Iyh_M8F-W0deTmU,265
2
+ pymarklet/cli.py,sha256=FaCiDMbLvkdLVJIDHUnhuxTl0XznpPwiiYO9wEDz2wY,1047
3
+ pymarklet/generator.py,sha256=0LFwOGnRM8dBvGR7uF0w0D5dHSQ_51CFR3Zzz0hEKvk,8374
4
+ pymarklet-0.1.0.dist-info/licenses/LICENSE,sha256=CjOdOot5xuiS9QoMisbF2iNRVseoHihq8SAdwIdK6TE,1079
5
+ pymarklet-0.1.0.dist-info/WHEEL,sha256=xTCBFIZIIyRSxyowOv5Wqg_jFU6KsdIffuulBB09Ph8,80
6
+ pymarklet-0.1.0.dist-info/entry_points.txt,sha256=yu0HNdKBY0K5Xuzc3WVMlWqgSVZNdOlW9Pxx7qD3HXk,50
7
+ pymarklet-0.1.0.dist-info/METADATA,sha256=kXwIdSMLmGO1-W1uKfCIG56SmHwkIFUfhSP5TASCxng,6949
8
+ pymarklet-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ pymarklet = pymarklet.cli:main
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PyMarklet contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.