From 205277d5f4b6a2754f4e1aa21b084b7c9829bc31 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 30 Jun 2026 21:55:56 +0000 Subject: [PATCH 01/16] Limit autodoc bootstrap to in-tree :src: only Stop falling back to packages= for installed modules in _resolve_autodoc_bootstrap. In-tree modules still get silent :src: bootstrap; out-of-tree modules replay doctest input only until explicit wheel paths are configured in a follow-up. Co-authored-by: chrizzftd --- README.md | 2 +- docs/example.rst | 4 ++-- sphinx_pyrepl_web/__init__.py | 9 +++++++-- tests/test_autodoc_bootstrap.py | 4 ++-- tests/test_autodoc_doctest.py | 2 +- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d66e548..ce3de88 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ pyrepl_doctest_blocks = "autodoc" | | `pyrepl_autodoc_bootstrap` options | |------------------|------------------------------------------------------------------------------| -| `True` (default) | Bootstrap REPL: in-tree modules via silent `:src:`, packages via `packages=` | +| `True` (default) | Bootstrap REPL: in-tree modules via silent `:src:` only | | `False` | Replay doctest input only; documented names are not pre-defined | ## Updating pyrepl-web diff --git a/docs/example.rst b/docs/example.rst index 3dccde5..ba8bbf9 100644 --- a/docs/example.rst +++ b/docs/example.rst @@ -124,8 +124,8 @@ Autodoc ------- The documented module's source is loaded in advance before replay, so -module members are available in the REPL namespace. Modules under the Sphinx -source tree use silent ``:src:``; installed packages use ``packages=``. +module members are available in the REPL namespace when the module lives +under the Sphinx source tree (silent ``:src:``). Source module: diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index 95eaf82..26af36f 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -113,7 +113,12 @@ def _find_autodoc_desc(node: nodes.Node) -> addnodes.desc | None: def _resolve_autodoc_bootstrap( app: Sphinx, env, docname: str, desc: addnodes.desc ) -> tuple[str | None, str | None]: - """Return (startup src path, packages) for autodoc REPLs.""" + """Return (startup src path, packages) for autodoc REPLs. + + Only modules whose source lives under the Sphinx source directory are + bootstrapped via silent ``:src:``. Installed or out-of-tree modules are + left without bootstrap; use explicit ``:packages:`` wheel paths instead. + """ if not app.config.pyrepl_autodoc_bootstrap: return None, None @@ -142,7 +147,7 @@ def _resolve_autodoc_bootstrap( source_path.relative_to(srcdir) return register_startup_file(env, docname, source_path), None except ValueError: - return None, module_name.split(".")[0] + return None, None except (AttributeError, ImportError, OSError, TypeError) as exc: logger.error( "Could not bootstrap autodoc REPL for %s: %s", diff --git a/tests/test_autodoc_bootstrap.py b/tests/test_autodoc_bootstrap.py index 2f2d473..1084478 100644 --- a/tests/test_autodoc_bootstrap.py +++ b/tests/test_autodoc_bootstrap.py @@ -80,7 +80,7 @@ def greet(): assert list(replay_files) == ["index-1.py"] -def test_autodoc_bootstrap_uses_packages_for_installed_module(tmp_path): +def test_autodoc_bootstrap_skips_installed_module(tmp_path): pkg_dir = tmp_path / "installed_pkg" pkg_dir.mkdir() (pkg_dir / "__init__.py").write_text( @@ -143,10 +143,10 @@ class Widget: app.build() html = (outdir / "index.html").read_text(encoding="utf-8") - assert 'packages="installed_pkg"' in html assert 'replay-src="_static/pyrepl/index-1.py"' in html assert "", html.index("")] + assert 'packages="' not in pyrepl_tag assert ' src="' not in pyrepl_tag replay_files = json.loads( diff --git a/tests/test_autodoc_doctest.py b/tests/test_autodoc_doctest.py index 42556ca..1433396 100644 --- a/tests/test_autodoc_doctest.py +++ b/tests/test_autodoc_doctest.py @@ -103,7 +103,7 @@ def test_autodoc_doctest_becomes_pyrepl(autodoc_project): html = (outdir / "index.html").read_text(encoding="utf-8") assert f'replay-src="_static/pyrepl/{script_name}"' in html - assert 'packages="repl_test_demo"' in html + assert 'packages="' not in html assert "no-header" in html assert "no-banner" in html From 6e52874b44012a450376eaf6ad2e3988d82d55c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 30 Jun 2026 21:57:55 +0000 Subject: [PATCH 02/16] Complete Phase 0: local wheel tests, docs, and pyrepl re-vendor - Add wheel fixture and tests/test_local_wheel.py (doctree, full build, packages+src+replay, comma-separated packages) - Document local Pyodide wheel workflow in README and docs/example.rst - Set html_static_path in docs/conf.py so wheels are copied to output - Re-vendor pyrepl-web from cursor/resolve-package-urls-0bbd (PR #5) for runtime relative wheel URL resolution Co-authored-by: chrizzftd --- README.md | 36 ++++- .../pyrepl_test_pkg-1.0.0-py3-none-any.whl | Bin 0 -> 1115 bytes docs/conf.py | 1 + docs/example.rst | 25 +++ .../{chunk-e4mhg83d.js => chunk-es2qkm30.js} | 2 +- sphinx_pyrepl_web/pyrepl/pyrepl.esm.js | 129 +++------------ sphinx_pyrepl_web/pyrepl/version.txt | 2 +- tests/fixtures/pyrepl_test_pkg/pyproject.toml | 8 + .../pyrepl_test_pkg/__init__.py | 5 + .../pyrepl_test_pkg-1.0.0-py3-none-any.whl | Bin 0 -> 1115 bytes tests/test_local_wheel.py | 148 ++++++++++++++++++ 11 files changed, 245 insertions(+), 111 deletions(-) create mode 100644 docs/_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl rename sphinx_pyrepl_web/pyrepl/{chunk-e4mhg83d.js => chunk-es2qkm30.js} (99%) create mode 100644 tests/fixtures/pyrepl_test_pkg/pyproject.toml create mode 100644 tests/fixtures/pyrepl_test_pkg/pyrepl_test_pkg/__init__.py create mode 100644 tests/fixtures/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl create mode 100644 tests/test_local_wheel.py diff --git a/README.md b/README.md index ce3de88..f281a66 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ All options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attrib | Option | Description | |--------|-------------| | `:theme:` | Color theme (`catppuccin-mocha`, `catppuccin-latte`) | -| `:packages:` | Comma-separated PyPI packages to preload | +| `:packages:` | Comma-separated PyPI packages or local wheel paths under `_static/wheels/` | | `:repl-title:` | Title in the REPL header | | `:src:` | Path to a Python startup script | | `:replay:` | Replay `:src:` with interactive prompts instead of silent load | @@ -96,6 +96,40 @@ pyrepl_doctest_blocks = "autodoc" | `True` (default) | Bootstrap REPL: in-tree modules via silent `:src:` only | | `False` | Replay doctest input only; documented names are not pre-defined | +### Local Pyodide wheels + +Preload a wheel built for Pyodide (for example a wasm extension or an unreleased +branch build) by placing it under the Sphinx static path and referencing it from +``:packages:``. Ensure ``html_static_path`` includes ``"_static"``: + +```python +# conf.py +html_static_path = ["_static"] +``` + +```rst +.. py-repl:: + :packages: _static/wheels/myext-pyodide.whl + :src: _static/bootstrap.py + :no-banner: + + >>> import myext + >>> myext.ping() +``` + +Wheels under ``_static/`` are copied into the HTML output when ``_static`` is listed +in ``html_static_path`` (Sphinx does not copy project static files automatically +unless configured). At runtime, [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web) +resolves site-relative wheel paths to absolute URLs before calling +``micropip.install()``. + +**CI tip:** copy each build artifact to a stable docs filename so RST does not +need updating per release, for example +``cp dist/myext-1.2.3-*.whl docs/_static/wheels/myext-pyodide.whl``. + +Ensure the web server serves ``.whl`` files with MIME type ``application/zip`` +(Read the Docs does this by default). + ## Updating pyrepl-web Since [chrizzFTD/pyrepl-web](https://github.com/chrizzFTD/pyrepl-web) is a fork, this sphinx extension vendors the JavaScript assets for easier distribution. To update them, run: diff --git a/docs/_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl b/docs/_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..527687e7961e7f657de0a858b95a7e3b29c7f67f GIT binary patch literal 1115 zcmWIWW@Zs#U|`^2P+os8CV}5ZI2p(*24ZO-E~qR@Ey#&4Ni8mkFUU^UkB`sH%PfhH z*DI*h?Y8D)GUQ>cp8x2isK^^e4GUKB4V(NrPWW#OKk(LbawC%m1WVIA|X;QpFMrP^p)DP&_gagf=Udi4%>0*gJv$!L3Kc^L4?D6T|*pQ z977y!&m81xHsE2nP`zKt{=%bUet+5b@{~>8;mW(sbcx{C_s1nF>i10JvCnuBzhvrl zp@(vE>kj(}J_(t0MLP6#>dZT?c?&svtMe=u&l8N&n`7Acp!#5ELX7CDGckPs_5b)W zzcCJMn#g~9%7f`)-&s+eoS^pg|7D<4Eg2aY)QE6$kgKzQkW1^C-DOP%3=9|U=5x+s zaGS=r(DSw98Zm(*s_ZMdc$2&ionD_W-tISVcf6&UdSqd3&@Au5nT2uJmOiV|a$nGO zX3k^r!%pA6-co;i>aX6Kiv2fat{c6#%a|2xy(Uj*-IB+iKUd~7T&Pj{Kb>vXA^Z5H zm)s)f=Wk9qIphC{mn#Zi%-igvy5^SYZif!5I%W1CwJ<-U!+~Q3Ao?|O-uT6I9mwIpMpjP*b{Y|NG0E628 z0B=Sn5oX*O02nr4u%r=0;mHoj24PFB5Mvk^mNd@6GzfdzMK=;Xogs`g1x6#9+c1oT jr8;y|(UStg)E7jU3P~6N-mGjOeJnue2GsSR3B&^cqbGkE literal 0 HcmV?d00001 diff --git a/docs/conf.py b/docs/conf.py index 5ee02fd..f1c65f0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,6 +18,7 @@ "sphinx_pyrepl_web", ] pyrepl_doctest_blocks = "autodoc" +html_static_path = ["_static"] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] html_sidebars = { diff --git a/docs/example.rst b/docs/example.rst index ba8bbf9..4c1ac71 100644 --- a/docs/example.rst +++ b/docs/example.rst @@ -141,3 +141,28 @@ RST content: Rendered result: .. autofunction:: autodoc_demo.example_generator + +Local Pyodide wheels +-------------------- + +Preload a Pyodide-compatible wheel from ``_static/wheels/`` via ``:packages:``. +Combine with ``:src:`` for optional post-install bootstrap and a doctest replay +body: + +.. code-block:: rst + + .. py-repl:: + :packages: _static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl + :no-header: + :no-banner: + + >>> import pyrepl_test_pkg + >>> pyrepl_test_pkg.ping() + +.. py-repl:: + :packages: _static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl + :no-header: + :no-banner: + + >>> import pyrepl_test_pkg + >>> pyrepl_test_pkg.ping() diff --git a/sphinx_pyrepl_web/pyrepl/chunk-e4mhg83d.js b/sphinx_pyrepl_web/pyrepl/chunk-es2qkm30.js similarity index 99% rename from sphinx_pyrepl_web/pyrepl/chunk-e4mhg83d.js rename to sphinx_pyrepl_web/pyrepl/chunk-es2qkm30.js index 108bd2a..d350a08 100644 --- a/sphinx_pyrepl_web/pyrepl/chunk-e4mhg83d.js +++ b/sphinx_pyrepl_web/pyrepl/chunk-es2qkm30.js @@ -1,3 +1,3 @@ -import{i as $}from"./chunk-ftjk4vft.js";var __dirname="/tmp/vendor-pyrepl-oxf1jz4v/pyrepl-web/node_modules/pyodide";var O0=Object.defineProperty,H=(G,Q)=>O0(G,"name",{value:Q,configurable:!0}),m=((G)=>$)(function(G){return $.apply(this,arguments)}),R0=(()=>{for(var G=new Uint8Array(128),Q=0;Q<64;Q++)G[Q<26?Q+65:Q<52?Q+71:Q<62?Q-4:Q*4-205]=Q;return(W)=>{for(var z=W.length,Y=new Uint8Array((z-(W[z-1]=="=")-(W[z-2]=="="))*3/4|0),X=0,B=0;X>4,Y[B++]=K<<4|M>>2,Y[B++]=M<<6|V}return Y}})();function p(G){return!isNaN(parseFloat(G))&&isFinite(G)}H(p,"_isNumber");function T(G){return G.charAt(0).toUpperCase()+G.substring(1)}H(T,"_capitalize");function g(G){return function(){return this[G]}}H(g,"_getter");var R=["isConstructor","isEval","isNative","isToplevel"],U=["columnNumber","lineNumber"],F=["fileName","functionName","source"],U0=["args"],F0=["evalOrigin"],S=R.concat(U,F,U0,F0);function j(G){if(G)for(var Q=0;QO0(G,"name",{value:Q,configurable:!0}),m=((G)=>$)(function(G){return $.apply(this,arguments)}),R0=(()=>{for(var G=new Uint8Array(128),Q=0;Q<64;Q++)G[Q<26?Q+65:Q<52?Q+71:Q<62?Q-4:Q*4-205]=Q;return(W)=>{for(var z=W.length,Y=new Uint8Array((z-(W[z-1]=="=")-(W[z-2]=="="))*3/4|0),X=0,B=0;X>4,Y[B++]=K<<4|M>>2,Y[B++]=M<<6|V}return Y}})();function p(G){return!isNaN(parseFloat(G))&&isFinite(G)}H(p,"_isNumber");function T(G){return G.charAt(0).toUpperCase()+G.substring(1)}H(T,"_capitalize");function g(G){return function(){return this[G]}}H(g,"_getter");var R=["isConstructor","isEval","isNative","isToplevel"],U=["columnNumber","lineNumber"],F=["fileName","functionName","source"],U0=["args"],F0=["evalOrigin"],S=R.concat(U,F,U0,F0);function j(G){if(G)for(var Q=0;Q-1&&(Y=Y.replace(/eval code/g,"eval").replace(/(\(eval at [^()]*)|(,.*$)/g,""));var X=Y.replace(/^\s+/,"").replace(/\(eval code/g,"(").replace(/^.*?\s+/,""),B=X.match(/ (\(.+\)$)/);X=B?X.replace(B[0],""):X;var J=this.extractLocation(B?B[1]:X),K=B&&X||void 0,M=["eval",""].indexOf(J[0])>-1?void 0:J[0];return new w({functionName:K,fileName:M,lineNumber:J[1],columnNumber:J[2],source:Y})},this)},"ErrorStackParser$$parseV8OrIE"),parseFFOrSafari:H(function(W){var z=W.stack.split(` `).filter(function(Y){return!Y.match(Q)},this);return z.map(function(Y){if(Y.indexOf(" > eval")>-1&&(Y=Y.replace(/ line (\d+)(?: > eval line \d+)* > eval:\d+:\d+/g,":$1")),Y.indexOf("@")===-1&&Y.indexOf(":")===-1)return new w({functionName:Y});var X=/((.*".+"[^@]*)?[^@]*)(?:@)/,B=Y.match(X),J=B&&B[1]?B[1]:void 0,K=this.extractLocation(Y.replace(X,""));return new w({functionName:J,fileName:K[0],lineNumber:K[1],columnNumber:K[2],source:Y})},this)},"ErrorStackParser$$parseFFOrSafari")}}H(u,"ErrorStackParser");var k0=new u,I0=k0;function l(){if(typeof API<"u"&&API!==globalThis.API)return API.runtimeEnv;let G=typeof Bun<"u",Q=typeof Deno<"u",W=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&!1,z=typeof navigator=="object"&&typeof navigator.userAgent=="string"&&navigator.userAgent.indexOf("Chrome")===-1&&navigator.userAgent.indexOf("Safari")>-1,Y=typeof read=="function"&&typeof load=="function",X=typeof navigator=="object"&&navigator.userAgent?.includes("Cloudflare-Workers");return d({IN_BUN:G,IN_DENO:Q,IN_NODE:W,IN_SAFARI:z,IN_SHELL:Y,IN_WORKERD:X})}H(l,"getGlobalRuntimeEnv");var x=l();function d(G){let Q=G.IN_NODE&&typeof f<"u"&&P0&&typeof m=="function"&&typeof __dirname=="string",W=G.IN_NODE&&!Q,z=!G.IN_NODE&&!G.IN_DENO&&!G.IN_BUN,Y=z&&typeof window<"u"&&typeof window.document<"u"&&typeof document.createElement=="function"&&"sessionStorage"in window&&typeof globalThis.importScripts!="function",X=z&&typeof globalThis.WorkerGlobalScope<"u"&&typeof globalThis.self<"u"&&globalThis.self instanceof globalThis.WorkerGlobalScope;if(X&&c())throw Error("Classic web workers are not supported");let B={...G,IN_BROWSER:z,IN_BROWSER_MAIN_THREAD:Y,IN_BROWSER_WEB_WORKER:X,IN_NODE_COMMONJS:Q,IN_NODE_ESM:W};if(!(B.IN_BROWSER_MAIN_THREAD||B.IN_BROWSER_WEB_WORKER||B.IN_NODE||B.IN_SHELL||B.IN_WORKERD))throw Error(`Cannot determine runtime environment: ${JSON.stringify(B)}`);return B}H(d,"calculateDerivedFlags");function c(){try{return globalThis.importScripts("data:text/javascript,"),!0}catch{return!1}}H(c,"isClassicWorker");var s,E,N0,_,h;async function y(){if(!x.IN_NODE||(s=(await import("./chunk-vx91qfkd.js")).default,_=await import("node:fs"),h=await import("node:fs/promises"),N0=(await import("node:vm")).default,E=await import("./chunk-94xkfg72.js"),v=E.sep,typeof m<"u"))return;let G=_,Q=await import("./chunk-x20ze186.js"),W=await import("ws"),z=await import("node:child_process"),Y={fs:G,crypto:Q,ws:W,child_process:z};globalThis.require=function(X){return Y[X]}}H(y,"initNodeModules");function a(G,Q){return E.resolve(Q||".",G)}H(a,"node_resolvePath");function i(G,Q){return Q===void 0&&(Q=location),new URL(G,Q).toString()}H(i,"browser_resolvePath");var I;x.IN_NODE?I=a:x.IN_SHELL?I=H((G)=>G,"resolvePath"):I=i;var v;x.IN_NODE||(v="/");function o(G,Q){return G.startsWith("file://")&&(G=G.slice(7)),G.includes("://")?{response:fetch(G)}:{binary:h.readFile(G).then((W)=>new Uint8Array(W.buffer,W.byteOffset,W.byteLength))}}H(o,"node_getBinaryResponse");function n(G,Q){if(G.startsWith("file://")&&(G=G.slice(7)),G.includes("://"))throw Error("Shell cannot fetch urls");return{binary:Promise.resolve(new Uint8Array(readbuffer(G)))}}H(n,"shell_getBinaryResponse");function r(G,Q){let W=new URL(G,location);return{response:fetch(W,Q?{integrity:Q}:{})}}H(r,"browser_getBinaryResponse");var N;x.IN_NODE?N=o:x.IN_SHELL?N=n:N=r;async function t(G,Q){let{response:W,binary:z}=N(G,Q);if(z)return z;let Y=await W;if(!Y.ok)throw Error(`Failed to load '${G}': request failed.`);return new Uint8Array(await Y.arrayBuffer())}H(t,"loadBinaryFile");var b;x.IN_NODE?b=e:b=H(async(G)=>await import(G),"loadScript");async function e(G){return G.startsWith("file://")&&(G=G.slice(7)),G.includes("://")?await import(G):await import(s.pathToFileURL(G).href)}H(e,"nodeLoadScript");async function G0(G){if(x.IN_NODE){await y();let Q=await h.readFile(G,{encoding:"utf8"});return JSON.parse(Q)}else if(x.IN_SHELL){let Q=read(G);return JSON.parse(Q)}else return await(await fetch(G)).json()}H(G0,"loadLockFile");async function Q0(){if(x.IN_NODE_COMMONJS)return __dirname;let G;try{throw Error()}catch(z){G=z}let Q=I0.parse(G)[0].fileName;if(x.IN_NODE&&!Q.startsWith("file://")&&(Q=`file://${Q}`),x.IN_NODE_ESM){let z=await import("./chunk-94xkfg72.js");return(await import("./chunk-vx91qfkd.js")).fileURLToPath(z.dirname(Q))}let W=Q.lastIndexOf(v);if(W===-1)throw Error("Could not extract indexURL path from pyodide module location. Please pass the indexURL explicitly to loadPyodide.");return Q.slice(0,W)}H(Q0,"calculateDirname");function W0(G){return G.substring(0,G.lastIndexOf("/")+1)||globalThis.location?.toString()||"."}H(W0,"calculateInstallBaseUrl");function X0(G){let Q=G.FS,W=G.FS.filesystems.MEMFS,z=G.PATH,Y={DIR_MODE:16895,FILE_MODE:33279,mount:H(function(X){if(!X.opts.fileSystemHandle)throw Error("opts.fileSystemHandle is required");return W.mount.apply(null,arguments)},"mount"),syncfs:H(async(X,B,J)=>{try{let K=Y.getLocalSet(X),M=await Y.getRemoteSet(X),V=B?M:K,C=B?K:M;await Y.reconcile(X,V,C),J(null)}catch(K){J(K)}},"syncfs"),getLocalSet:H((X)=>{let B=Object.create(null);function J(V){return V!=="."&&V!==".."}H(J,"isRealDir");function K(V){return(C)=>z.join2(V,C)}H(K,"toAbsolute");let M=Q.readdir(X.mountpoint).filter(J).map(K(X.mountpoint));for(;M.length;){let V=M.pop(),C=Q.stat(V);Q.isDir(C.mode)&&M.push.apply(M,Q.readdir(V).filter(J).map(K(V))),B[V]={timestamp:C.mtime,mode:C.mode}}return{type:"local",entries:B}},"getLocalSet"),getRemoteSet:H(async(X)=>{let B=Object.create(null),J=await S0(X.opts.fileSystemHandle);for(let[K,M]of J)K!=="."&&(B[z.join2(X.mountpoint,K)]={timestamp:M.kind==="file"?new Date((await M.getFile()).lastModified):new Date,mode:M.kind==="file"?Y.FILE_MODE:Y.DIR_MODE});return{type:"remote",entries:B,handles:J}},"getRemoteSet"),loadLocalEntry:H((X)=>{let B=Q.lookupPath(X,{}).node,J=Q.stat(X);if(Q.isDir(J.mode))return{timestamp:J.mtime,mode:J.mode};if(Q.isFile(J.mode))return B.contents=W.getFileDataAsTypedArray(B),{timestamp:J.mtime,mode:J.mode,contents:B.contents};throw Error("node type not supported")},"loadLocalEntry"),storeLocalEntry:H((X,B)=>{if(Q.isDir(B.mode))Q.mkdirTree(X,B.mode);else if(Q.isFile(B.mode))Q.writeFile(X,B.contents,{canOwn:!0});else throw Error("node type not supported");Q.chmod(X,B.mode),Q.utime(X,B.timestamp,B.timestamp)},"storeLocalEntry"),removeLocalEntry:H((X)=>{var B=Q.stat(X);Q.isDir(B.mode)?Q.rmdir(X):Q.isFile(B.mode)&&Q.unlink(X)},"removeLocalEntry"),loadRemoteEntry:H(async(X)=>{if(X.kind==="file"){let B=await X.getFile();return{contents:new Uint8Array(await B.arrayBuffer()),mode:Y.FILE_MODE,timestamp:new Date(B.lastModified)}}else{if(X.kind==="directory")return{mode:Y.DIR_MODE,timestamp:new Date};throw Error("unknown kind: "+X.kind)}},"loadRemoteEntry"),storeRemoteEntry:H(async(X,B,J)=>{let K=X.get(z.dirname(B)),M=Q.isFile(J.mode)?await K.getFileHandle(z.basename(B),{create:!0}):await K.getDirectoryHandle(z.basename(B),{create:!0});if(M.kind==="file"){let V=await M.createWritable();await V.write(J.contents),await V.close()}X.set(B,M)},"storeRemoteEntry"),removeRemoteEntry:H(async(X,B)=>{await X.get(z.dirname(B)).removeEntry(z.basename(B)),X.delete(B)},"removeRemoteEntry"),reconcile:H(async(X,B,J)=>{let K=0,M=[];Object.keys(B.entries).forEach(function(Z){let q=B.entries[Z],O=J.entries[Z];(!O||Q.isFile(q.mode)&&q.timestamp.getTime()>O.timestamp.getTime())&&(M.push(Z),K++)}),M.sort();let V=[];if(Object.keys(J.entries).forEach(function(Z){B.entries[Z]||(V.push(Z),K++)}),V.sort().reverse(),!K)return;let C=B.type==="remote"?B.handles:J.handles;for(let Z of M){let q=z.normalize(Z.replace(X.mountpoint,"/")).substring(1);if(J.type==="local"){let O=C.get(q),A0=await Y.loadRemoteEntry(O);Y.storeLocalEntry(Z,A0)}else{let O=Y.loadLocalEntry(Z);await Y.storeRemoteEntry(C,q,O)}}for(let Z of V)if(J.type==="local")Y.removeLocalEntry(Z);else{let q=z.normalize(Z.replace(X.mountpoint,"/")).substring(1);await Y.removeRemoteEntry(C,q)}},"reconcile")};G.FS.filesystems.NATIVEFS_ASYNC=Y}H(X0,"initializeNativeFS");var S0=H(async(G)=>{let Q=[];async function W(Y){for await(let X of Y.values())Q.push(X),X.kind==="directory"&&await W(X)}H(W,"collect"),await W(G);let z=new Map;z.set(".",G);for(let Y of Q){let X=(await G.resolve(Y)).join("/");z.set(X,Y)}return z},"getFsHandles"),g0=R0("AGFzbQEAAAABDANfAGAAAW9gAW8BfwMDAgECBygCE0pzdl9HZXRFcnJvcl9pbXBvcnQAAA5Kc3ZFcnJvcl9DaGVjawABChMCBwD7AQD7GwsJACAA+xr7FAAL"),w0=async function(){if(!(globalThis.navigator&&(/iPad|iPhone|iPod/.test(navigator.userAgent)||navigator.platform==="MacIntel"&&typeof navigator.maxTouchPoints<"u"&&navigator.maxTouchPoints>1)))try{let G=await WebAssembly.compile(g0);return await WebAssembly.instantiate(G)}catch(G){if(G instanceof WebAssembly.CompileError)return;throw G}}();async function Y0(){let G=await w0;if(G)return G.exports;let Q=Symbol("error marker");return{Jsv_GetError_import:H(()=>Q,"Jsv_GetError_import"),JsvError_Check:H((W)=>W===Q,"JsvError_Check")}}H(Y0,"getJsvErrorImport");function z0(G){let Q={config:G,runtimeEnv:x},W={noImageDecoding:!0,noAudioDecoding:!0,noWasmDecoding:!1,preRun:V0(G),print:G.stdout,printErr:G.stderr,onExit(z){W.exitCode=z},thisProgram:G._sysExecutable,arguments:G.args,API:Q,locateFile:H((z)=>G.indexURL+z,"locateFile"),instantiateWasm:Z0(G.indexURL)};return W}H(z0,"createSettings");function B0(G){return function(Q){let W="/";try{Q.FS.mkdirTree(G)}catch(z){console.error(`Error occurred while making a home directory '${G}':`),console.error(z),console.error(`Using '${W}' for a home directory instead`),G=W}Q.FS.chdir(G)}}H(B0,"createHomeDirectory");function H0(G){return function(Q){Object.assign(Q.ENV,G)}}H(H0,"setEnvironment");function J0(G){return G?[async(Q)=>{Q.addRunDependency("fsInitHook");try{await G(Q.FS,{sitePackages:Q.API.sitePackages})}finally{Q.removeRunDependency("fsInitHook")}}]:[]}H(J0,"callFsInitHook");function K0(G){let Q=G.HEAPU32[G._Py_Version>>>2],W=Q>>>24&255,z=Q>>>16&255,Y=Q>>>8&255;return[W,z,Y]}H(K0,"computeVersionTuple");function M0(G){let Q=t(G);return async(W)=>{W.API.pyVersionTuple=K0(W);let[z,Y]=W.API.pyVersionTuple;W.FS.mkdirTree("/lib"),W.API.sitePackages=`/lib/python${z}.${Y}/site-packages`,W.FS.mkdirTree(W.API.sitePackages),W.addRunDependency("install-stdlib");try{let X=await Q;W.FS.writeFile(`/lib/python${z}${Y}.zip`,X)}catch(X){console.error("Error occurred while installing the standard library:"),console.error(X)}finally{W.removeRunDependency("install-stdlib")}}}H(M0,"installStdlib");function V0(G){let Q;return G.stdLibURL!=null?Q=G.stdLibURL:Q=G.indexURL+"python_stdlib.zip",[M0(Q),B0(G.env.HOME),H0(G.env),X0,...J0(G.fsInit)]}H(V0,"getFileSystemInitializationFuncs");function Z0(G){if(typeof WasmOffsetConverter<"u")return;let{binary:Q,response:W}=N(G+"pyodide.asm.wasm"),z=Y0();return function(Y,X){return async function(){let{Jsv_GetError_import:B,JsvError_Check:J}=await z;Y.env.Jsv_GetError_import=B,Y.env.JsvError_Check=J;try{let K;W?K=await WebAssembly.instantiateStreaming(W,Y):K=await WebAssembly.instantiate(await Q,Y);let{instance:M,module:V}=K;X(M,V)}catch(K){console.warn("wasm instantiation failed!"),console.warn(K)}}(),{}}}H(Z0,"getInstantiateWasmFunc");var E0="314.0.1";function k(G){return G===void 0||G.endsWith("/")?G:G+"/"}H(k,"withTrailingSlash");var P=E0;async function $0(G={}){if(await y(),G.lockFileContents&&G.lockFileURL)throw Error("Can't pass both lockFileContents and lockFileURL");let Q=G.indexURL||await Q0();if(Q=k(I(Q)),G.packageBaseUrl=k(G.packageBaseUrl),G.cdnUrl=k(G.packageBaseUrl??`https://cdn.jsdelivr.net/pyodide/v${P}/full/`),!G.lockFileContents){let Y=G.lockFileURL??Q+"pyodide-lock.json";G.lockFileContents=G0(Y),G.packageBaseUrl??=W0(Y)}G.indexURL=Q,G.packageCacheDir&&(G.packageCacheDir=k(I(G.packageCacheDir)));let W={jsglobals:globalThis,stdin:globalThis.prompt?()=>globalThis.prompt():void 0,args:[],env:{},packages:[],packageCacheDir:G.packageBaseUrl,enableRunUntilComplete:!0,checkAPIVersion:!0,BUILD_ID:"2e12ea28d5e4e258977cb79dd418ccf6593712466fd333300c63fda4fc9257c2"},z=Object.assign(W,G);return z.env.HOME??="/home/pyodide",z.env.PYTHONINSPECT??="1",z}H($0,"initializeConfiguration");function j0(G){let Q=z0(G),W=Q.API;return W.lockFilePromise=Promise.resolve(G.lockFileContents),Q}H(j0,"createEmscriptenSettings");async function x0(G){if(G.createPyodideModule)return G.createPyodideModule;let Q=`${G.indexURL}pyodide.asm.mjs`;return(await b(Q)).default}H(x0,"loadWasmScript");async function C0(G,Q){if(!G._loadSnapshot)return;let W=await G._loadSnapshot,z=ArrayBuffer.isView(W)?W:new Uint8Array(W);return Q.noInitialRun=!0,Q.INITIAL_MEMORY=z.length,z}H(C0,"prepareSnapshot");async function T0(G,Q){let W=await G(Q);if(Q.exitCode!==void 0)throw new W.ExitStatus(Q.exitCode);return W}H(T0,"instantiatePyodideModule");function q0(G,Q){let W=G.API;if(Q.pyproxyToStringRepr&&W.setPyProxyToStringMethod(!0),Q.convertNullToNone&&W.setCompatNullToNone(!0),Q.toJsLiteralMap&&W.setCompatToJsLiteralMap(!0),W.version!==P&&Q.checkAPIVersion)throw Error(`Pyodide version does not match: '${P}' <==> '${W.version}'. If you updated the Pyodide version, make sure you also updated the 'indexURL' parameter passed to loadPyodide.`);G.locateFile=(z)=>{throw z.endsWith(".so")?Error(`Failed to find dynamic library "${z}"`):Error(`Unexpected call to locateFile("${z}")`)}}H(q0,"configureAPI");function D0(G,Q,W){let z=G.API,Y;return Q&&(Y=z.restoreSnapshot(Q)),z.finalizeBootstrap(Y,W._snapshotDeserializer)}H(D0,"bootstrapPyodide");async function L0(G,Q){let W=G._api;return W.sys.path.insert(0,""),W._pyodide.set_excepthook(),await W.packageIndexReady,W.initializeStreams(Q.stdin,Q.stdout,Q.stderr),G}H(L0,"finalizeSetup");async function b0(G={}){let Q=await $0(G),W=j0(Q),z=await x0(Q),Y=await C0(Q,W),X=await T0(z,W);q0(X,Q);let B=D0(X,Y,Q);return await L0(B,Q)}H(b0,"loadPyodide");export{P as version,b0 as loadPyodide}; diff --git a/sphinx_pyrepl_web/pyrepl/pyrepl.esm.js b/sphinx_pyrepl_web/pyrepl/pyrepl.esm.js index 9dca250..35dfb3f 100644 --- a/sphinx_pyrepl_web/pyrepl/pyrepl.esm.js +++ b/sphinx_pyrepl_web/pyrepl/pyrepl.esm.js @@ -1,8 +1,8 @@ -import{d as On,e as Bn,f as Cn,g as Kn,i as Nn}from"./chunk-ftjk4vft.js";var K=globalThis,I=K.ShadowRoot&&(K.ShadyCSS===void 0||K.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,A=Symbol(),U=new WeakMap;class D{constructor(n,r,e){if(this._$cssResult$=!0,e!==A)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=n,this.t=r}get styleSheet(){let n=this.o,r=this.t;if(I&&n===void 0){let e=r!==void 0&&r.length===1;e&&(n=U.get(r)),n===void 0&&((this.o=n=new CSSStyleSheet).replaceSync(this.cssText),e&&U.set(r,n))}return n}toString(){return this.cssText}}var M=(n)=>new D(typeof n=="string"?n:n+"",void 0,A);var nn=(n,r)=>{if(I)n.adoptedStyleSheets=r.map((e)=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(let e of r){let l=document.createElement("style"),o=K.litNonce;o!==void 0&&l.setAttribute("nonce",o),l.textContent=e.cssText,n.appendChild(l)}},T=I?(n)=>n:(n)=>n instanceof CSSStyleSheet?((r)=>{let e="";for(let l of r.cssRules)e+=l.cssText;return M(e)})(n):n;var{is:In,defineProperty:Wn,getOwnPropertyDescriptor:Yn,getOwnPropertyNames:Xn,getOwnPropertySymbols:$n,getPrototypeOf:Dn}=Object,Y=globalThis,en=Y.trustedTypes,Tn=en?en.emptyScript:"",Vn=Y.reactiveElementPolyfillSupport,N=(n,r)=>n,W={toAttribute(n,r){switch(r){case Boolean:n=n?Tn:null;break;case Object:case Array:n=n==null?n:JSON.stringify(n)}return n},fromAttribute(n,r){let e=n;switch(r){case Boolean:e=n!==null;break;case Number:e=n===null?null:Number(n);break;case Object:case Array:try{e=JSON.parse(n)}catch(l){e=null}}return e}},V=(n,r)=>!In(n,r),rn={attribute:!0,type:String,converter:W,reflect:!1,useDefault:!1,hasChanged:V};Symbol.metadata??=Symbol("metadata"),Y.litPropertyMetadata??=new WeakMap;class m extends HTMLElement{static addInitializer(n){this._$Ei(),(this.l??=[]).push(n)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(n,r=rn){if(r.state&&(r.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(n)&&((r=Object.create(r)).wrapped=!0),this.elementProperties.set(n,r),!r.noAccessor){let e=Symbol(),l=this.getPropertyDescriptor(n,e,r);l!==void 0&&Wn(this.prototype,n,l)}}static getPropertyDescriptor(n,r,e){let{get:l,set:o}=Yn(this.prototype,n)??{get(){return this[r]},set(_){this[r]=_}};return{get:l,set(_){let s=l?.call(this);o?.call(this,_),this.requestUpdate(n,s,e)},configurable:!0,enumerable:!0}}static getPropertyOptions(n){return this.elementProperties.get(n)??rn}static _$Ei(){if(this.hasOwnProperty(N("elementProperties")))return;let n=Dn(this);n.finalize(),n.l!==void 0&&(this.l=[...n.l]),this.elementProperties=new Map(n.elementProperties)}static finalize(){if(this.hasOwnProperty(N("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(N("properties"))){let r=this.properties,e=[...Xn(r),...$n(r)];for(let l of e)this.createProperty(l,r[l])}let n=this[Symbol.metadata];if(n!==null){let r=litPropertyMetadata.get(n);if(r!==void 0)for(let[e,l]of r)this.elementProperties.set(e,l)}this._$Eh=new Map;for(let[r,e]of this.elementProperties){let l=this._$Eu(r,e);l!==void 0&&this._$Eh.set(l,r)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(n){let r=[];if(Array.isArray(n)){let e=new Set(n.flat(1/0).reverse());for(let l of e)r.unshift(T(l))}else n!==void 0&&r.push(T(n));return r}static _$Eu(n,r){let e=r.attribute;return e===!1?void 0:typeof e=="string"?e:typeof n=="string"?n.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise((n)=>this.enableUpdating=n),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach((n)=>n(this))}addController(n){(this._$EO??=new Set).add(n),this.renderRoot!==void 0&&this.isConnected&&n.hostConnected?.()}removeController(n){this._$EO?.delete(n)}_$E_(){let n=new Map,r=this.constructor.elementProperties;for(let e of r.keys())this.hasOwnProperty(e)&&(n.set(e,this[e]),delete this[e]);n.size>0&&(this._$Ep=n)}createRenderRoot(){let n=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return nn(n,this.constructor.elementStyles),n}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach((n)=>n.hostConnected?.())}enableUpdating(n){}disconnectedCallback(){this._$EO?.forEach((n)=>n.hostDisconnected?.())}attributeChangedCallback(n,r,e){this._$AK(n,e)}_$ET(n,r){let e=this.constructor.elementProperties.get(n),l=this.constructor._$Eu(n,e);if(l!==void 0&&e.reflect===!0){let o=(e.converter?.toAttribute!==void 0?e.converter:W).toAttribute(r,e.type);this._$Em=n,o==null?this.removeAttribute(l):this.setAttribute(l,o),this._$Em=null}}_$AK(n,r){let e=this.constructor,l=e._$Eh.get(n);if(l!==void 0&&this._$Em!==l){let o=e.getPropertyOptions(l),_=typeof o.converter=="function"?{fromAttribute:o.converter}:o.converter?.fromAttribute!==void 0?o.converter:W;this._$Em=l;let s=_.fromAttribute(r,o.type);this[l]=s??this._$Ej?.get(l)??s,this._$Em=null}}requestUpdate(n,r,e,l=!1,o){if(n!==void 0){let _=this.constructor;if(l===!1&&(o=this[n]),e??=_.getPropertyOptions(n),!((e.hasChanged??V)(o,r)||e.useDefault&&e.reflect&&o===this._$Ej?.get(n)&&!this.hasAttribute(_._$Eu(n,e))))return;this.C(n,r,e)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(n,r,{useDefault:e,reflect:l,wrapped:o},_){e&&!(this._$Ej??=new Map).has(n)&&(this._$Ej.set(n,_??r??this[n]),o!==!0||_!==void 0)||(this._$AL.has(n)||(this.hasUpdated||e||(r=void 0),this._$AL.set(n,r)),l===!0&&this._$Em!==n&&(this._$Eq??=new Set).add(n))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(r){Promise.reject(r)}let n=this.scheduleUpdate();return n!=null&&await n,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[l,o]of this._$Ep)this[l]=o;this._$Ep=void 0}let e=this.constructor.elementProperties;if(e.size>0)for(let[l,o]of e){let{wrapped:_}=o,s=this[l];_!==!0||this._$AL.has(l)||s===void 0||this.C(l,void 0,o,s)}}let n=!1,r=this._$AL;try{n=this.shouldUpdate(r),n?(this.willUpdate(r),this._$EO?.forEach((e)=>e.hostUpdate?.()),this.update(r)):this._$EM()}catch(e){throw n=!1,this._$EM(),e}n&&this._$AE(r)}willUpdate(n){}_$AE(n){this._$EO?.forEach((r)=>r.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(n)),this.updated(n)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(n){return!0}update(n){this._$Eq&&=this._$Eq.forEach((r)=>this._$ET(r,this[r])),this._$EM()}updated(n){}firstUpdated(n){}}m.elementStyles=[],m.shadowRootOptions={mode:"open"},m[N("elementProperties")]=new Map,m[N("finalized")]=new Map,Vn?.({ReactiveElement:m}),(Y.reactiveElementVersions??=[]).push("2.1.2");var Z=globalThis,ln=(n)=>n,X=Z.trustedTypes,on=X?X.createPolicy("lit-html",{createHTML:(n)=>n}):void 0;var u=`lit$${Math.random().toFixed(9).slice(2)}$`,cn="?"+u,Zn=`<${cn}>`,b=document,q=()=>b.createComment(""),G=(n)=>n===null||typeof n!="object"&&typeof n!="function",z=Array.isArray,zn=(n)=>z(n)||typeof n?.[Symbol.iterator]=="function";var F=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,_n=/-->/g,sn=/>/g,t=RegExp(`>|[ +import{d as Cn,e as On,f as In,g as Wn,i as Gn}from"./chunk-ftjk4vft.js";var I=globalThis,W=I.ShadowRoot&&(I.ShadyCSS===void 0||I.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,M=Symbol(),U=new WeakMap;class T{constructor(n,e,r){if(this._$cssResult$=!0,r!==M)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=n,this.t=e}get styleSheet(){let n=this.o,e=this.t;if(W&&n===void 0){let r=e!==void 0&&e.length===1;r&&(n=U.get(e)),n===void 0&&((this.o=n=new CSSStyleSheet).replaceSync(this.cssText),r&&U.set(e,n))}return n}toString(){return this.cssText}}var g=(n)=>new T(typeof n=="string"?n:n+"",void 0,M);var nn=(n,e)=>{if(W)n.adoptedStyleSheets=e.map((r)=>r instanceof CSSStyleSheet?r:r.styleSheet);else for(let r of e){let l=document.createElement("style"),o=I.litNonce;o!==void 0&&l.setAttribute("nonce",o),l.textContent=r.cssText,n.appendChild(l)}},V=W?(n)=>n:(n)=>n instanceof CSSStyleSheet?((e)=>{let r="";for(let l of e.cssRules)r+=l.cssText;return g(r)})(n):n;var{is:Yn,defineProperty:Xn,getOwnPropertyDescriptor:$n,getOwnPropertyNames:Dn,getOwnPropertySymbols:Tn,getPrototypeOf:Vn}=Object,X=globalThis,en=X.trustedTypes,Zn=en?en.emptyScript:"",zn=X.reactiveElementPolyfillSupport,q=(n,e)=>n,Y={toAttribute(n,e){switch(e){case Boolean:n=n?Zn:null;break;case Object:case Array:n=n==null?n:JSON.stringify(n)}return n},fromAttribute(n,e){let r=n;switch(e){case Boolean:r=n!==null;break;case Number:r=n===null?null:Number(n);break;case Object:case Array:try{r=JSON.parse(n)}catch(l){r=null}}return r}},Z=(n,e)=>!Yn(n,e),rn={attribute:!0,type:String,converter:Y,reflect:!1,useDefault:!1,hasChanged:Z};Symbol.metadata??=Symbol("metadata"),X.litPropertyMetadata??=new WeakMap;class f extends HTMLElement{static addInitializer(n){this._$Ei(),(this.l??=[]).push(n)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(n,e=rn){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(n)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(n,e),!e.noAccessor){let r=Symbol(),l=this.getPropertyDescriptor(n,r,e);l!==void 0&&Xn(this.prototype,n,l)}}static getPropertyDescriptor(n,e,r){let{get:l,set:o}=$n(this.prototype,n)??{get(){return this[e]},set(_){this[e]=_}};return{get:l,set(_){let s=l?.call(this);o?.call(this,_),this.requestUpdate(n,s,r)},configurable:!0,enumerable:!0}}static getPropertyOptions(n){return this.elementProperties.get(n)??rn}static _$Ei(){if(this.hasOwnProperty(q("elementProperties")))return;let n=Vn(this);n.finalize(),n.l!==void 0&&(this.l=[...n.l]),this.elementProperties=new Map(n.elementProperties)}static finalize(){if(this.hasOwnProperty(q("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(q("properties"))){let e=this.properties,r=[...Dn(e),...Tn(e)];for(let l of r)this.createProperty(l,e[l])}let n=this[Symbol.metadata];if(n!==null){let e=litPropertyMetadata.get(n);if(e!==void 0)for(let[r,l]of e)this.elementProperties.set(r,l)}this._$Eh=new Map;for(let[e,r]of this.elementProperties){let l=this._$Eu(e,r);l!==void 0&&this._$Eh.set(l,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(n){let e=[];if(Array.isArray(n)){let r=new Set(n.flat(1/0).reverse());for(let l of r)e.unshift(V(l))}else n!==void 0&&e.push(V(n));return e}static _$Eu(n,e){let r=e.attribute;return r===!1?void 0:typeof r=="string"?r:typeof n=="string"?n.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise((n)=>this.enableUpdating=n),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach((n)=>n(this))}addController(n){(this._$EO??=new Set).add(n),this.renderRoot!==void 0&&this.isConnected&&n.hostConnected?.()}removeController(n){this._$EO?.delete(n)}_$E_(){let n=new Map,e=this.constructor.elementProperties;for(let r of e.keys())this.hasOwnProperty(r)&&(n.set(r,this[r]),delete this[r]);n.size>0&&(this._$Ep=n)}createRenderRoot(){let n=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return nn(n,this.constructor.elementStyles),n}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach((n)=>n.hostConnected?.())}enableUpdating(n){}disconnectedCallback(){this._$EO?.forEach((n)=>n.hostDisconnected?.())}attributeChangedCallback(n,e,r){this._$AK(n,r)}_$ET(n,e){let r=this.constructor.elementProperties.get(n),l=this.constructor._$Eu(n,r);if(l!==void 0&&r.reflect===!0){let o=(r.converter?.toAttribute!==void 0?r.converter:Y).toAttribute(e,r.type);this._$Em=n,o==null?this.removeAttribute(l):this.setAttribute(l,o),this._$Em=null}}_$AK(n,e){let r=this.constructor,l=r._$Eh.get(n);if(l!==void 0&&this._$Em!==l){let o=r.getPropertyOptions(l),_=typeof o.converter=="function"?{fromAttribute:o.converter}:o.converter?.fromAttribute!==void 0?o.converter:Y;this._$Em=l;let s=_.fromAttribute(e,o.type);this[l]=s??this._$Ej?.get(l)??s,this._$Em=null}}requestUpdate(n,e,r,l=!1,o){if(n!==void 0){let _=this.constructor;if(l===!1&&(o=this[n]),r??=_.getPropertyOptions(n),!((r.hasChanged??Z)(o,e)||r.useDefault&&r.reflect&&o===this._$Ej?.get(n)&&!this.hasAttribute(_._$Eu(n,r))))return;this.C(n,e,r)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(n,e,{useDefault:r,reflect:l,wrapped:o},_){r&&!(this._$Ej??=new Map).has(n)&&(this._$Ej.set(n,_??e??this[n]),o!==!0||_!==void 0)||(this._$AL.has(n)||(this.hasUpdated||r||(e=void 0),this._$AL.set(n,e)),l===!0&&this._$Em!==n&&(this._$Eq??=new Set).add(n))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}let n=this.scheduleUpdate();return n!=null&&await n,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[l,o]of this._$Ep)this[l]=o;this._$Ep=void 0}let r=this.constructor.elementProperties;if(r.size>0)for(let[l,o]of r){let{wrapped:_}=o,s=this[l];_!==!0||this._$AL.has(l)||s===void 0||this.C(l,void 0,o,s)}}let n=!1,e=this._$AL;try{n=this.shouldUpdate(e),n?(this.willUpdate(e),this._$EO?.forEach((r)=>r.hostUpdate?.()),this.update(e)):this._$EM()}catch(r){throw n=!1,this._$EM(),r}n&&this._$AE(e)}willUpdate(n){}_$AE(n){this._$EO?.forEach((e)=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(n)),this.updated(n)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(n){return!0}update(n){this._$Eq&&=this._$Eq.forEach((e)=>this._$ET(e,this[e])),this._$EM()}updated(n){}firstUpdated(n){}}f.elementStyles=[],f.shadowRootOptions={mode:"open"},f[q("elementProperties")]=new Map,f[q("finalized")]=new Map,zn?.({ReactiveElement:f}),(X.reactiveElementVersions??=[]).push("2.1.2");var z=globalThis,ln=(n)=>n,$=z.trustedTypes,on=$?$.createPolicy("lit-html",{createHTML:(n)=>n}):void 0;var i=`lit$${Math.random().toFixed(9).slice(2)}$`,xn="?"+i,Qn=`<${xn}>`,v=document,J=()=>v.createComment(""),K=(n)=>n===null||typeof n!="object"&&typeof n!="function",Q=Array.isArray,jn=(n)=>Q(n)||typeof n?.[Symbol.iterator]=="function";var G=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,_n=/-->/g,sn=/>/g,b=RegExp(`>|[ \f\r](?:([^\\s"'>=/]+)([ \f\r]*=[ \f\r]*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`,"g"),pn=/'/g,yn=/"/g,an=/^(?:script|style|textarea|title)$/i,j=(n)=>(r,...e)=>({_$litType$:n,strings:r,values:e}),xn=j(1),pe=j(2),ye=j(3),h=Symbol.for("lit-noChange"),c=Symbol.for("lit-nothing"),wn=new WeakMap,f=b.createTreeWalker(b,129);function dn(n,r){if(!z(n)||!n.hasOwnProperty("raw"))throw Error("invalid template strings array");return on!==void 0?on.createHTML(r):r}var jn=(n,r)=>{let e=n.length-1,l=[],o,_=r===2?"":r===3?"":"",s=F;for(let y=0;y"?(s=o??F,a=-1):p[1]===void 0?a=-2:(a=s.lastIndex-p[2].length,i=p[1],s=p[3]===void 0?t:p[3]==='"'?yn:pn):s===yn||s===pn?s=t:s===_n||s===sn?s=F:(s=t,o=void 0);let d=s===t&&n[y+1].startsWith("/>")?" ":"";_+=s===F?w+Zn:a>=0?(l.push(i),w.slice(0,a)+"$lit$"+w.slice(a)+u+d):w+u+(a===-2?y:d)}return[dn(n,_+(n[e]||"")+(r===2?"":r===3?"":"")),l]};class J{constructor({strings:n,_$litType$:r},e){let l;this.parts=[];let o=0,_=0,s=n.length-1,y=this.parts,[w,i]=jn(n,r);if(this.el=J.createElement(w,e),f.currentNode=this.el.content,r===2||r===3){let p=this.el.content.firstChild;p.replaceWith(...p.childNodes)}for(;(l=f.nextNode())!==null&&y.length0){l.textContent=X?X.emptyScript:"";for(let x=0;x2||e[0]!==""||e[1]!==""?(this._$AH=Array(e.length-1).fill(new String),this.strings=e):this._$AH=c}_$AI(n,r=this,e,l){let o=this.strings,_=!1;if(o===void 0)n=S(this,n,r,0),_=!G(n)||n!==this._$AH&&n!==h,_&&(this._$AH=n);else{let s=n,y,w;for(n=o[0],y=0;y{let l=e?.renderBefore??r,o=l._$litPart$;if(o===void 0){let _=e?.renderBefore??null;l._$litPart$=o=new O(r.insertBefore(q(),_),_,void 0,e??{})}return o._$AI(n),o};var Q=globalThis;class v extends m{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let n=super.createRenderRoot();return this.renderOptions.renderBefore??=n.firstChild,n}update(n){let r=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(n),this._$Do=hn(r,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return h}}v._$litElement$=!0,v.finalized=!0,Q.litElementHydrateSupport?.({LitElement:v});var gn=Q.litElementPolyfillSupport;gn?.({LitElement:v});(Q.litElementVersions??=[]).push("4.2.2");var vn=(n)=>(r,e)=>{e!==void 0?e.addInitializer(()=>{customElements.define(n,r)}):customElements.define(n,r)};var Sn=`from codeop import compile_command +\f\r"'\`<>=]|("|')|))|$)`,"g"),wn=/'/g,pn=/"/g,dn=/^(?:script|style|textarea|title)$/i,j=(n)=>(e,...r)=>({_$litType$:n,strings:e,values:r}),cn=j(1),xe=j(2),de=j(3),h=Symbol.for("lit-noChange"),x=Symbol.for("lit-nothing"),yn=new WeakMap,a=v.createTreeWalker(v,129);function mn(n,e){if(!Q(n)||!n.hasOwnProperty("raw"))throw Error("invalid template strings array");return on!==void 0?on.createHTML(e):e}var Hn=(n,e)=>{let r=n.length-1,l=[],o,_=e===2?"":e===3?"":"",s=G;for(let w=0;w"?(s=o??G,c=-1):p[1]===void 0?c=-2:(c=s.lastIndex-p[2].length,d=p[1],s=p[3]===void 0?b:p[3]==='"'?pn:wn):s===pn||s===wn?s=b:s===_n||s===sn?s=G:(s=b,o=void 0);let u=s===b&&n[w+1].startsWith("/>")?" ":"";_+=s===G?y+Qn:c>=0?(l.push(d),y.slice(0,c)+"$lit$"+y.slice(c)+i+u):y+i+(c===-2?w:u)}return[mn(n,_+(n[r]||"")+(e===2?"":e===3?"":"")),l]};class P{constructor({strings:n,_$litType$:e},r){let l;this.parts=[];let o=0,_=0,s=n.length-1,w=this.parts,[y,d]=Hn(n,e);if(this.el=P.createElement(y,r),a.currentNode=this.el.content,e===2||e===3){let p=this.el.content.firstChild;p.replaceWith(...p.childNodes)}for(;(l=a.nextNode())!==null&&w.length0){l.textContent=$?$.emptyScript:"";for(let m=0;m2||r[0]!==""||r[1]!==""?(this._$AH=Array(r.length-1).fill(new String),this.strings=r):this._$AH=x}_$AI(n,e=this,r,l){let o=this.strings,_=!1;if(o===void 0)n=t(this,n,e,0),_=!K(n)||n!==this._$AH&&n!==h,_&&(this._$AH=n);else{let s=n,w,y;for(n=o[0],w=0;w{let l=r?.renderBefore??e,o=l._$litPart$;if(o===void 0){let _=r?.renderBefore??null;l._$litPart$=o=new B(e.insertBefore(J(),_),_,void 0,r??{})}return o._$AI(n),o};var H=globalThis;class S extends f{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let n=super.createRenderRoot();return this.renderOptions.renderBefore??=n.firstChild,n}update(n){let e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(n),this._$Do=hn(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return h}}S._$litElement$=!0,S.finalized=!0,H.litElementHydrateSupport?.({LitElement:S});var Ln=H.litElementPolyfillSupport;Ln?.({LitElement:S});(H.litElementVersions??=[]).push("4.2.2");var Sn=(n)=>(e,r)=>{r!==void 0?r.addInitializer(()=>{customElements.define(n,e)}):customElements.define(n,e)};var tn=`from codeop import compile_command import sys from _pyrepl.console import Console, Event from collections import deque @@ -140,81 +140,12 @@ class BrowserConsole(Console): pass -async def replay_script( - source, - browser_console, - repl_globals, - exec_with_redirect, - syntax_highlight, - PS1, - PS2, - history, -): - """Execute source line-by-line with REPL prompts and highlighting.""" - lines = source.splitlines() - i = 0 - while i < len(lines): - while i < len(lines) and not lines[i].strip(): - i += 1 - if i >= len(lines): - break - - buffer = [] - while i < len(lines): - line = lines[i] - i += 1 - - if not buffer and not line.strip(): - continue - - buffer.append(line) - source_so_far = "\\n".join(buffer) - - if len(buffer) == 1: - browser_console.term.write( - PS1 + syntax_highlight(line) + "\\r\\n" - ) - else: - browser_console.term.write( - PS2 + syntax_highlight(line) + "\\r\\n" - ) - - try: - code = compile_command(source_so_far, "", "single") - except (OverflowError, SyntaxError) as e: - browser_console.term.write( - f"\\x1b[31mSyntaxError: {e}\\x1b[0m\\r\\n" - ) - buffer = [] - break - - if code is None: - continue - - try: - exec_with_redirect(code, repl_globals) - except SystemExit: - pass - except Exception as e: - browser_console.term.write( - f"\\x1b[31m{type(e).__name__}: {e}\\x1b[0m\\r\\n" - ) - - if source_so_far.strip(): - history.append(source_so_far) - break - - return history - - async def start_repl(): # Create a new console for this terminal instance browser_console = BrowserConsole(js.term) - # Capture startup scripts before JS moves to next REPL and overwrites them + # Capture startup script before JS moves to next REPL and overwrites it startup_script = getattr(js, "pyreplStartupScript", None) - replay_script_content = getattr(js, "pyreplReplayScript", None) - replay_startup = getattr(js, "pyreplReplayStartup", False) theme_name = getattr(js, "pyreplTheme", "catppuccin-mocha") pygments_fallback = getattr(js, "pyreplPygmentsFallback", "catppuccin-mocha") info_line = getattr(js, "pyreplInfo", "Python (Pyodide)") @@ -279,7 +210,8 @@ async def start_repl(): except Exception as e: browser_console.term.write(f"[ERROR] Pygments load failed: {e}\\r\\n") - pygments_task = asyncio.create_task(load_pygments()) + # Start loading Pygments in background (non-blocking) + asyncio.create_task(load_pygments()) def syntax_highlight(code): if not code: @@ -345,12 +277,10 @@ async def start_repl(): } completer = rlcompleter.Completer(repl_globals) - history = [] - history_index = 0 - - # Silent bootstrap script (:file: / src without replay) - if startup_script and not replay_startup: + # Run startup script if one was provided (silently, just to populate namespace) + if startup_script: try: + # Temporarily suppress stdout/stderr during startup old_stdout, old_stderr = sys.stdout, sys.stderr sys.stdout = sys.stderr = type( "null", (), {"write": lambda s, x: None, "flush": lambda s: None} @@ -364,6 +294,7 @@ async def start_repl(): f"\\x1b[31mStartup script error - {type(e).__name__}: {e}\\x1b[0m\\r\\n" ) + # If startup script defined a setup() function, call it with output visible if "setup" in repl_globals and callable(repl_globals["setup"]): try: exec_with_redirect(compile("setup()", "", "exec"), repl_globals) @@ -372,25 +303,6 @@ async def start_repl(): f"\\x1b[31msetup() error - {type(e).__name__}: {e}\\x1b[0m\\r\\n" ) - # Replay script with interactive prompts - replay_source = replay_script_content - if replay_source is None and startup_script and replay_startup: - replay_source = startup_script - - if replay_source: - await pygments_task - history = await replay_script( - replay_source, - browser_console, - repl_globals, - exec_with_redirect, - syntax_highlight, - PS1, - PS2, - history, - ) - history_index = len(history) - def get_completions(text): """Get all completions for the given text.""" completions = [] @@ -416,6 +328,9 @@ async def start_repl(): lines = [] current_line = "" + history = [] + history_index = 0 + while True: event = await browser_console.get_event(block=True) if event is None: @@ -588,7 +503,7 @@ async def start_repl(): browser_console.term.write("\\r\\x1b[K") # Go to start, clear line prompt = PS1 if len(lines) == 0 else PS2 browser_console.term.write(prompt + syntax_highlight(current_line)) -`;var g=null,H=null,k=Promise.resolve(),C={"catppuccin-mocha":{background:"#1e1e2e",foreground:"#cdd6f4",cursor:"#f5e0dc",cursorAccent:"#1e1e2e",selectionBackground:"#585b70",black:"#45475a",red:"#f38ba8",green:"#a6e3a1",yellow:"#f9e2af",blue:"#89b4fa",magenta:"#f5c2e7",cyan:"#94e2d5",white:"#bac2de",brightBlack:"#585b70",brightRed:"#f38ba8",brightGreen:"#a6e3a1",brightYellow:"#f9e2af",brightBlue:"#89b4fa",brightMagenta:"#f5c2e7",brightCyan:"#94e2d5",brightWhite:"#a6adc8",headerBackground:"#181825",headerForeground:"#6c7086"},"catppuccin-latte":{background:"#eff1f5",foreground:"#4c4f69",cursor:"#dc8a78",cursorAccent:"#eff1f5",selectionBackground:"#acb0be",black:"#5c5f77",red:"#d20f39",green:"#40a02b",yellow:"#df8e1d",blue:"#1e66f5",magenta:"#ea76cb",cyan:"#179299",white:"#acb0be",brightBlack:"#6c6f85",brightRed:"#d20f39",brightGreen:"#40a02b",brightYellow:"#df8e1d",brightBlue:"#1e66f5",brightMagenta:"#ea76cb",brightCyan:"#179299",brightWhite:"#bcc0cc",headerBackground:"#dce0e8",headerForeground:"#8c8fa1"}},E="catppuccin-mocha";function Fn(n){if(!n.startsWith("#"))return!0;let r=n.slice(1),e=parseInt(r.slice(0,2),16),l=parseInt(r.slice(2,4),16),o=parseInt(r.slice(4,6),16);return(0.299*e+0.587*l+0.114*o)/255<0.5}function En(n){if("black"in n&&"red"in n)return n;let r=Fn(n.background)?"catppuccin-mocha":"catppuccin-latte",e=C[r];return{cursor:e.cursor,cursorAccent:e.cursorAccent,selectionBackground:e.selectionBackground,black:e.black,red:e.red,green:e.green,yellow:e.yellow,blue:e.blue,magenta:e.magenta,cyan:e.cyan,white:e.white,brightBlack:e.brightBlack,brightRed:e.brightRed,brightGreen:e.brightGreen,brightYellow:e.brightYellow,brightBlue:e.brightBlue,brightMagenta:e.brightMagenta,brightCyan:e.brightCyan,brightWhite:e.brightWhite,background:n.background,foreground:n.foreground,headerBackground:n.headerBackground??e.headerBackground,headerForeground:n.headerForeground??e.headerForeground,promptColor:n.promptColor,pygmentsStyle:n.pygmentsStyle}}var kn={copy:'',clear:''};function Hn(n){let r,e,l=n.dataset.themeConfig;if(l)try{let s=JSON.parse(l);r=En(s),e="custom"}catch{console.warn("pyrepl-web: invalid data-theme-config JSON, falling back to default"),r=C[E],e=E}else{e=n.dataset.theme||E;let s=window.pyreplThemes?.[e]||C[e]||C[E];if(r=En(s),!window.pyreplThemes?.[e]&&!C[e])console.warn(`pyrepl-web: unknown theme "${e}", falling back to default`),e=E}let o=n.dataset.packages,_=o?o.split(",").map((s)=>s.trim()).filter(Boolean):[];return{theme:r,themeName:e,showHeader:n.dataset.header!=="false",showButtons:n.dataset.buttons!=="false",title:n.dataset.title||"python",packages:_,src:n.dataset.src||null,replaySrc:n.dataset.replaySrc||null,replayStartup:n.dataset.replay==="true",readonly:n.dataset.readonly==="true",showBanner:n.dataset.noBanner!=="true"}}async function Ln(){if(!g){let{loadPyodide:n}=await import("./chunk-e4mhg83d.js");g=n({indexURL:"https://cdn.jsdelivr.net/pyodide/v314.0.1/full/",stdout:()=>{},stderr:()=>{}})}return await g}function Rn(){if(!H)H=Promise.resolve(Sn);return H}class L{container;theme;packages;readonly;src;replaySrc;replayStartup;showHeader;showButtons;title;showBanner;constructor(n){this.container=n.container,this.theme=n.theme||E,this.packages=n.packages||[],this.readonly=n.readonly||!1,this.src=n.src,this.replaySrc=n.replaySrc,this.replayStartup=n.replayStartup||!1,this.showHeader=n.showHeader!==void 0?n.showHeader:!0,this.showButtons=n.showButtons!==void 0?n.showButtons:!0,this.title=n.title||"python",this.showBanner=n.showBanner!==void 0?n.showBanner:!0}async init(){if(this.container.dataset.theme=this.theme,this.container.dataset.packages=this.packages.join(","),this.container.dataset.readonly=this.readonly?"true":"false",this.src)this.container.dataset.src=this.src;if(this.replaySrc)this.container.dataset.replaySrc=this.replaySrc;this.container.dataset.replay=this.replayStartup?"true":"false",this.container.dataset.header=this.showHeader?"true":"false",this.container.dataset.buttons=this.showButtons?"true":"false",this.container.dataset.title=this.title,this.container.dataset.noBanner=this.showBanner?"false":"true";let{term:n,config:r}=await Gn(this.container);k=k.then(()=>Jn(this.container,n,r)),await k}}function Un(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",Pn);else Pn()}function An(){if(document.getElementById("pyrepl-styles"))return;let n=document.createElement("link");n.rel="stylesheet",n.href="https://cdn.jsdelivr.net/npm/@xterm/xterm/css/xterm.css",document.head.appendChild(n);let r=document.createElement("style");r.id="pyrepl-styles",r.textContent=` +`;var An=/^(https?:|emfs:)/i;function Rn(n,e=document.baseURI){if(An.test(n))return n;if(n.includes(" @ "))return n;if(!n.includes("/")&&!n.endsWith(".whl"))return n;return new URL(n,e).href}function En(n,e=document.baseURI){return n.map((r)=>Rn(r,e))}var k=null,L=null,F=Promise.resolve(),O={"catppuccin-mocha":{background:"#1e1e2e",foreground:"#cdd6f4",cursor:"#f5e0dc",cursorAccent:"#1e1e2e",selectionBackground:"#585b70",black:"#45475a",red:"#f38ba8",green:"#a6e3a1",yellow:"#f9e2af",blue:"#89b4fa",magenta:"#f5c2e7",cyan:"#94e2d5",white:"#bac2de",brightBlack:"#585b70",brightRed:"#f38ba8",brightGreen:"#a6e3a1",brightYellow:"#f9e2af",brightBlue:"#89b4fa",brightMagenta:"#f5c2e7",brightCyan:"#94e2d5",brightWhite:"#a6adc8",headerBackground:"#181825",headerForeground:"#6c7086"},"catppuccin-latte":{background:"#eff1f5",foreground:"#4c4f69",cursor:"#dc8a78",cursorAccent:"#eff1f5",selectionBackground:"#acb0be",black:"#5c5f77",red:"#d20f39",green:"#40a02b",yellow:"#df8e1d",blue:"#1e66f5",magenta:"#ea76cb",cyan:"#179299",white:"#acb0be",brightBlack:"#6c6f85",brightRed:"#d20f39",brightGreen:"#40a02b",brightYellow:"#df8e1d",brightBlue:"#1e66f5",brightMagenta:"#ea76cb",brightCyan:"#179299",brightWhite:"#bcc0cc",headerBackground:"#dce0e8",headerForeground:"#8c8fa1"}},E="catppuccin-mocha";function Jn(n){if(!n.startsWith("#"))return!0;let e=n.slice(1),r=parseInt(e.slice(0,2),16),l=parseInt(e.slice(2,4),16),o=parseInt(e.slice(4,6),16);return(0.299*r+0.587*l+0.114*o)/255<0.5}function Fn(n){if("black"in n&&"red"in n)return n;let e=Jn(n.background)?"catppuccin-mocha":"catppuccin-latte",r=O[e];return{cursor:r.cursor,cursorAccent:r.cursorAccent,selectionBackground:r.selectionBackground,black:r.black,red:r.red,green:r.green,yellow:r.yellow,blue:r.blue,magenta:r.magenta,cyan:r.cyan,white:r.white,brightBlack:r.brightBlack,brightRed:r.brightRed,brightGreen:r.brightGreen,brightYellow:r.brightYellow,brightBlue:r.brightBlue,brightMagenta:r.brightMagenta,brightCyan:r.brightCyan,brightWhite:r.brightWhite,background:n.background,foreground:n.foreground,headerBackground:n.headerBackground??r.headerBackground,headerForeground:n.headerForeground??r.headerForeground,promptColor:n.promptColor,pygmentsStyle:n.pygmentsStyle}}var Nn={copy:'',clear:''};function Un(n){let e,r,l=n.dataset.themeConfig;if(l)try{let s=JSON.parse(l);e=Fn(s),r="custom"}catch{console.warn("pyrepl-web: invalid data-theme-config JSON, falling back to default"),e=O[E],r=E}else{r=n.dataset.theme||E;let s=window.pyreplThemes?.[r]||O[r]||O[E];if(e=Fn(s),!window.pyreplThemes?.[r]&&!O[r])console.warn(`pyrepl-web: unknown theme "${r}", falling back to default`),r=E}let o=n.dataset.packages,_=o?o.split(",").map((s)=>s.trim()).filter(Boolean):[];return{theme:e,themeName:r,showHeader:n.dataset.header!=="false",showButtons:n.dataset.buttons!=="false",title:n.dataset.title||"python",packages:_,src:n.dataset.src||null,readonly:n.dataset.readonly==="true",showBanner:n.dataset.noBanner!=="true"}}async function Mn(){if(!k){let{loadPyodide:n}=await import("./chunk-es2qkm30.js");k=n({indexURL:"https://cdn.jsdelivr.net/pyodide/v314.0.1/full/",stdout:()=>{},stderr:()=>{}})}return await k}function gn(){if(!L)L=Promise.resolve(tn);return L}class A{container;theme;packages;readonly;src;showHeader;showButtons;title;showBanner;constructor(n){this.container=n.container,this.theme=n.theme||E,this.packages=n.packages||[],this.readonly=n.readonly||!1,this.src=n.src,this.showHeader=n.showHeader!==void 0?n.showHeader:!0,this.showButtons=n.showButtons!==void 0?n.showButtons:!0,this.title=n.title||"python",this.showBanner=n.showBanner!==void 0?n.showBanner:!0}async init(){if(this.container.dataset.theme=this.theme,this.container.dataset.packages=this.packages.join(","),this.container.dataset.readonly=this.readonly?"true":"false",this.src)this.container.dataset.src=this.src;this.container.dataset.header=this.showHeader?"true":"false",this.container.dataset.buttons=this.showButtons?"true":"false",this.container.dataset.title=this.title,this.container.dataset.noBanner=this.showBanner?"false":"true";let{term:n,config:e}=await Pn(this.container);F=F.then(()=>Bn(this.container,n,e)),await F}}function ne(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",qn);else qn()}function ee(){if(document.getElementById("pyrepl-styles"))return;let n=document.createElement("link");n.rel="stylesheet",n.href="https://cdn.jsdelivr.net/npm/@xterm/xterm/css/xterm.css",document.head.appendChild(n);let e=document.createElement("style");e.id="pyrepl-styles",e.textContent=` .pyrepl { display: inline-block; position: relative; @@ -682,16 +597,14 @@ async def start_repl(): scrollbar-width: none; background-color: var(--pyrepl-bg) !important; } - `,document.head.appendChild(r)}function Mn(n,r){let e=r.headerBackground||r.black,l=r.headerForeground||r.brightBlack;n.style.setProperty("--pyrepl-bg",r.background),n.style.setProperty("--pyrepl-header-bg",e),n.style.setProperty("--pyrepl-header-title",l),n.style.setProperty("--pyrepl-red",r.red),n.style.setProperty("--pyrepl-yellow",r.yellow),n.style.setProperty("--pyrepl-green",r.green),n.style.setProperty("--pyrepl-shadow","0 4px 24px rgba(0, 0, 0, 0.3)")}function qn(){let n=document.createElement("div");return n.className="pyrepl-header-buttons",n.innerHTML=` - - - `,n}function ne(n){let r=document.createElement("div");r.className="pyrepl-header";let e=document.createElement("div");e.className="pyrepl-header-dots",e.innerHTML=` + `,document.head.appendChild(e)}function re(n,e){let r=e.headerBackground||e.black,l=e.headerForeground||e.brightBlack;n.style.setProperty("--pyrepl-bg",e.background),n.style.setProperty("--pyrepl-header-bg",r),n.style.setProperty("--pyrepl-header-title",l),n.style.setProperty("--pyrepl-red",e.red),n.style.setProperty("--pyrepl-yellow",e.yellow),n.style.setProperty("--pyrepl-green",e.green),n.style.setProperty("--pyrepl-shadow","0 4px 24px rgba(0, 0, 0, 0.3)")}function Kn(){let n=document.createElement("div");return n.className="pyrepl-header-buttons",n.innerHTML=` + + + `,n}function le(n){let e=document.createElement("div");e.className="pyrepl-header";let r=document.createElement("div");r.className="pyrepl-header-dots",r.innerHTML=`
- `;let l=document.createElement("div");if(l.className="pyrepl-header-title",l.textContent=n.title,r.appendChild(e),r.appendChild(l),n.showButtons)r.appendChild(qn());else{let o=document.createElement("div");o.style.width="48px",r.appendChild(o)}return r}async function Gn(n){An();let r=Hn(n);if(Mn(n,r.theme),r.showHeader)n.appendChild(ne(r));else if(r.showButtons){let _=qn();_.classList.add("pyrepl-floating-buttons"),n.classList.add("pyrepl--floating-buttons"),n.appendChild(_)}let e=await import("./chunk-jfxv8vy0.js"),l=document.createElement("div");n.appendChild(l);let o=new e.Terminal({cursorBlink:!r.readonly,cursorStyle:r.readonly?"bar":"block",fontSize:14,fontFamily:"monospace",theme:r.theme,disableStdin:r.readonly});return o.open(l),{term:o,config:r}}async function Jn(n,r,e){let l=await Ln();if(await l.loadPackage("micropip"),e.packages.length>0)await l.pyimport("micropip").install(e.packages);let o=e.packages.length>0?` (installed packages: ${e.packages.join(", ")})`:"",s=`${await l.runPythonAsync("import sys; sys.version.split()[0]")}${o}`;if(e.showBanner)r.write(`\x1B[90m${s}\x1B[0m\r -`);if(globalThis.term=r,globalThis.pyreplTheme=e.themeName,globalThis.pyreplPygmentsFallback=Fn(e.theme.background)?"catppuccin-mocha":"catppuccin-latte",globalThis.pyreplInfo=s,globalThis.pyreplReadonly=e.readonly,globalThis.pyreplPromptColor=e.theme.promptColor||"green",globalThis.pyreplPygmentsStyle=e.theme.pygmentsStyle,globalThis.pyreplBanner=e.showBanner,e.src)try{let i=await fetch(e.src);if(i.ok)globalThis.pyreplStartupScript=await i.text();else console.warn(`pyrepl-web: failed to fetch script from ${e.src}`)}catch(i){console.warn(`pyrepl-web: error fetching script from ${e.src}`,i)}if(e.replaySrc)try{let i=await fetch(e.replaySrc);if(i.ok)globalThis.pyreplReplayScript=await i.text();else{let p=`pyrepl-web: failed to fetch replay script from ${e.replaySrc}`;console.warn(p),r.write(`\x1B[31m${p}\x1B[0m\r -`)}}catch(i){let p=`pyrepl-web: error fetching replay script from ${e.replaySrc}`;console.warn(p,i),r.write(`\x1B[31m${p}\x1B[0m\r -`)}globalThis.pyreplReplayStartup=e.replayStartup;let y=await Rn();l.runPython(y),l.runPythonAsync("await start_repl()");while(!globalThis.currentBrowserConsole)await new Promise((i)=>setTimeout(i,10));let w=globalThis.currentBrowserConsole;if(globalThis.currentBrowserConsole=null,globalThis.term=null,globalThis.pyreplStartupScript=void 0,globalThis.pyreplReplayScript=void 0,globalThis.pyreplReplayStartup=!1,globalThis.pyreplTheme="",globalThis.pyreplPygmentsFallback="",globalThis.pyreplInfo="",globalThis.pyreplReadonly=!1,globalThis.pyreplPromptColor="",globalThis.pyreplPygmentsStyle=void 0,globalThis.pyreplBanner=!1,!e.readonly)r.onData((i)=>{for(let p of i)w.push_char(p.charCodeAt(0))});if(e.showButtons){let i=n.querySelector('[data-action="copy"]'),p=n.querySelector('[data-action="clear"]');i?.addEventListener("click",()=>{let a=r.buffer.active,x="";for(let d=0;d{if(r.reset(),e.showBanner)r.write(`\x1B[90m${s}\x1B[0m\r -`);r.write("\x1B[32m>>> \x1B[0m")})}}async function Pn(){let n=document.querySelectorAll(".pyrepl"),r=Array.from(n).filter((l)=>!l.closest("py-repl")&&!l.dataset.pyreplInitialized);if(r.length===0)return;for(let l of r)l.dataset.pyreplInitialized="true";let e=await Promise.all(Array.from(r).map(async(l)=>({container:l,...await Gn(l)})));for(let{container:l,term:o,config:_}of e)k=k.then(()=>Jn(l,o,_));await k}Un();var lr=[vn("py-repl")],rr=v,ee=On(rr);class P extends rr{static properties={theme:{type:String},packages:{type:String},replTitle:{type:String,attribute:"repl-title"},noBanner:{type:Boolean,attribute:"no-banner"},isReadonly:{type:Boolean,attribute:"readonly"},noButtons:{type:Boolean,attribute:"no-buttons"},noHeader:{type:Boolean,attribute:"no-header"},src:{type:String},replaySrc:{type:String,attribute:"replay-src"},replayStartup:{type:Boolean,attribute:"replay"}};constructor(){super();this.theme="catppuccin-mocha",this.packages="",this.replTitle="Python REPL",this.noBanner=!1,this.isReadonly=!1,this.noButtons=!1,this.noHeader=!1,this.src="",this.replaySrc="",this.replayStartup=!1}createRenderRoot(){return this}firstUpdated(n){super.firstUpdated(n);let r=this.querySelector(".pyrepl");if(!r){console.error("pyrepl-web: .pyrepl container not found in ");return}r.dataset.pyreplInitialized="true",new L({container:r,theme:this.theme,packages:this.packages.split(",").map((l)=>l.trim()).filter((l)=>l.length>0),readonly:this.isReadonly,src:this.src||void 0,replaySrc:this.replaySrc||void 0,replayStartup:this.replayStartup,showHeader:!this.noHeader,showButtons:!this.noButtons,title:this.replTitle,showBanner:!this.noBanner}).init()}render(){return xn`
`}}P=Kn(ee,0,"PyRepl",lr,P),Cn(ee,1,P),Bn(ee,P);let _PyRepl=P;export{P as PyRepl}; + `;let l=document.createElement("div");if(l.className="pyrepl-header-title",l.textContent=n.title,e.appendChild(r),e.appendChild(l),n.showButtons)e.appendChild(Kn());else{let o=document.createElement("div");o.style.width="48px",e.appendChild(o)}return e}async function Pn(n){ee();let e=Un(n);if(re(n,e.theme),e.showHeader)n.appendChild(le(e));else if(e.showButtons){let _=Kn();_.classList.add("pyrepl-floating-buttons"),n.classList.add("pyrepl--floating-buttons"),n.appendChild(_)}let r=await import("./chunk-jfxv8vy0.js"),l=document.createElement("div");n.appendChild(l);let o=new r.Terminal({cursorBlink:!e.readonly,cursorStyle:e.readonly?"bar":"block",fontSize:14,fontFamily:"monospace",theme:e.theme,disableStdin:e.readonly});return o.open(l),{term:o,config:e}}async function Bn(n,e,r){let l=await Mn();if(await l.loadPackage("micropip"),r.packages.length>0)await l.pyimport("micropip").install(En(r.packages));let o=r.packages.length>0?` (installed packages: ${r.packages.join(", ")})`:"",s=`${await l.runPythonAsync("import sys; sys.version.split()[0]")}${o}`;if(r.showBanner)e.write(`\x1B[90m${s}\x1B[0m\r +`);if(globalThis.term=e,globalThis.pyreplTheme=r.themeName,globalThis.pyreplPygmentsFallback=Jn(r.theme.background)?"catppuccin-mocha":"catppuccin-latte",globalThis.pyreplInfo=s,globalThis.pyreplReadonly=r.readonly,globalThis.pyreplPromptColor=r.theme.promptColor||"green",globalThis.pyreplPygmentsStyle=r.theme.pygmentsStyle,globalThis.pyreplBanner=r.showBanner,r.src)try{let d=await fetch(r.src);if(d.ok)globalThis.pyreplStartupScript=await d.text();else console.warn(`pyrepl-web: failed to fetch script from ${r.src}`)}catch(d){console.warn(`pyrepl-web: error fetching script from ${r.src}`,d)}let w=await gn();l.runPython(w),l.runPythonAsync("await start_repl()");while(!globalThis.currentBrowserConsole)await new Promise((d)=>setTimeout(d,10));let y=globalThis.currentBrowserConsole;if(globalThis.currentBrowserConsole=null,globalThis.term=null,globalThis.pyreplStartupScript=void 0,globalThis.pyreplTheme="",globalThis.pyreplPygmentsFallback="",globalThis.pyreplInfo="",globalThis.pyreplReadonly=!1,globalThis.pyreplPromptColor="",globalThis.pyreplPygmentsStyle=void 0,globalThis.pyreplBanner=!1,!r.readonly)e.onData((d)=>{for(let p of d)y.push_char(p.charCodeAt(0))});if(r.showButtons){let d=n.querySelector('[data-action="copy"]'),p=n.querySelector('[data-action="clear"]');d?.addEventListener("click",()=>{let c=e.buffer.active,m="";for(let u=0;u{if(e.reset(),r.showBanner)e.write(`\x1B[90m${s}\x1B[0m\r +`);e.write("\x1B[32m>>> \x1B[0m")})}}async function qn(){let n=document.querySelectorAll(".pyrepl"),e=Array.from(n).filter((l)=>!l.closest("py-repl")&&!l.dataset.pyreplInitialized);if(e.length===0)return;for(let l of e)l.dataset.pyreplInitialized="true";let r=await Promise.all(Array.from(e).map(async(l)=>({container:l,...await Pn(l)})));for(let{container:l,term:o,config:_}of r)F=F.then(()=>Bn(l,o,_));await F}ne();var pr=[Sn("py-repl")],wr=S,oe=Cn(wr);class N extends wr{static properties={theme:{type:String},packages:{type:String},replTitle:{type:String,attribute:"repl-title"},noBanner:{type:Boolean,attribute:"no-banner"},isReadonly:{type:Boolean,attribute:"readonly"},noButtons:{type:Boolean,attribute:"no-buttons"},noHeader:{type:Boolean,attribute:"no-header"},src:{type:String}};constructor(){super();this.theme="catppuccin-mocha",this.packages="",this.replTitle="Python REPL",this.noBanner=!1,this.isReadonly=!1,this.noButtons=!1,this.noHeader=!1,this.src=""}createRenderRoot(){return this}firstUpdated(n){super.firstUpdated(n);let e=this.querySelector(".pyrepl");if(!e){console.error("pyrepl-web: .pyrepl container not found in ");return}e.dataset.pyreplInitialized="true",new A({container:e,theme:this.theme,packages:this.packages.split(",").map((l)=>l.trim()).filter((l)=>l.length>0),readonly:this.isReadonly,src:this.src||void 0,showHeader:!this.noHeader,showButtons:!this.noButtons,title:this.replTitle,showBanner:!this.noBanner}).init()}render(){return cn`
`}}N=Wn(oe,0,"PyRepl",pr,N),In(oe,1,N),On(oe,N);let _PyRepl=N;export{N as PyRepl}; diff --git a/sphinx_pyrepl_web/pyrepl/version.txt b/sphinx_pyrepl_web/pyrepl/version.txt index 9531d95..8d18685 100644 --- a/sphinx_pyrepl_web/pyrepl/version.txt +++ b/sphinx_pyrepl_web/pyrepl/version.txt @@ -1 +1 @@ -cursor/repl-startup-replay-2e3f@5299fa92dc9aada6ae0fe757ea93bee824088a15 +cursor/resolve-package-urls-0bbd@c21baf4d73d347181d346b1db30384540e80ee5b diff --git a/tests/fixtures/pyrepl_test_pkg/pyproject.toml b/tests/fixtures/pyrepl_test_pkg/pyproject.toml new file mode 100644 index 0000000..f9f3e3f --- /dev/null +++ b/tests/fixtures/pyrepl_test_pkg/pyproject.toml @@ -0,0 +1,8 @@ +[build-system] +requires = ["flit_core >=3.2,<4"] +build-backend = "flit_core.buildapi" + +[project] +name = "pyrepl_test_pkg" +version = "1.0.0" +description = "Minimal package for sphinx-pyrepl-web wheel fixture tests" diff --git a/tests/fixtures/pyrepl_test_pkg/pyrepl_test_pkg/__init__.py b/tests/fixtures/pyrepl_test_pkg/pyrepl_test_pkg/__init__.py new file mode 100644 index 0000000..39d39ca --- /dev/null +++ b/tests/fixtures/pyrepl_test_pkg/pyrepl_test_pkg/__init__.py @@ -0,0 +1,5 @@ +"""Tiny package used as a committed wheel fixture for local wheel tests.""" + + +def ping() -> str: + return "pong" diff --git a/tests/fixtures/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl b/tests/fixtures/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..527687e7961e7f657de0a858b95a7e3b29c7f67f GIT binary patch literal 1115 zcmWIWW@Zs#U|`^2P+os8CV}5ZI2p(*24ZO-E~qR@Ey#&4Ni8mkFUU^UkB`sH%PfhH z*DI*h?Y8D)GUQ>cp8x2isK^^e4GUKB4V(NrPWW#OKk(LbawC%m1WVIA|X;QpFMrP^p)DP&_gagf=Udi4%>0*gJv$!L3Kc^L4?D6T|*pQ z977y!&m81xHsE2nP`zKt{=%bUet+5b@{~>8;mW(sbcx{C_s1nF>i10JvCnuBzhvrl zp@(vE>kj(}J_(t0MLP6#>dZT?c?&svtMe=u&l8N&n`7Acp!#5ELX7CDGckPs_5b)W zzcCJMn#g~9%7f`)-&s+eoS^pg|7D<4Eg2aY)QE6$kgKzQkW1^C-DOP%3=9|U=5x+s zaGS=r(DSw98Zm(*s_ZMdc$2&ionD_W-tISVcf6&UdSqd3&@Au5nT2uJmOiV|a$nGO zX3k^r!%pA6-co;i>aX6Kiv2fat{c6#%a|2xy(Uj*-IB+iKUd~7T&Pj{Kb>vXA^Z5H zm)s)f=Wk9qIphC{mn#Zi%-igvy5^SYZif!5I%W1CwJ<-U!+~Q3Ao?|O-uT6I9mwIpMpjP*b{Y|NG0E628 z0B=Sn5oX*O02nr4u%r=0;mHoj24PFB5Mvk^mNd@6GzfdzMK=;Xogs`g1x6#9+c1oT jr8;y|(UStg)E7jU3P~6N-mGjOeJnue2GsSR3B&^cqbGkE literal 0 HcmV?d00001 diff --git a/tests/test_local_wheel.py b/tests/test_local_wheel.py new file mode 100644 index 0000000..8cb19f4 --- /dev/null +++ b/tests/test_local_wheel.py @@ -0,0 +1,148 @@ +import json +import shutil +import sys +from pathlib import Path + +import pytest +from sphinx.application import Sphinx +from sphinx_pytest.plugin import CreateDoctree + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = Path(__file__).resolve().parent / "fixtures" +WHEEL_NAME = "pyrepl_test_pkg-1.0.0-py3-none-any.whl" +WHEEL_PATH = f"_static/wheels/{WHEEL_NAME}" + +sys.path.insert(0, str(ROOT)) + + +def _build_sphinx(srcdir: Path, outdir: Path, doctreedir: Path, **kwargs) -> Sphinx: + outdir.mkdir(parents=True, exist_ok=True) + doctreedir.mkdir(parents=True, exist_ok=True) + with open(outdir / "warnings.txt", "w", encoding="utf-8") as warning_file: + app = Sphinx( + srcdir=str(srcdir), + confdir=str(srcdir), + outdir=str(outdir), + doctreedir=str(doctreedir), + buildername="html", + warning=warning_file, + freshenv=True, + **kwargs, + ) + app.build() + return app + + +@pytest.fixture +def wheel_project(tmp_path): + srcdir = tmp_path / "docs" + wheels_dir = srcdir / "_static" / "wheels" + wheels_dir.mkdir(parents=True) + shutil.copy2(FIXTURES / "wheels" / WHEEL_NAME, wheels_dir / WHEEL_NAME) + (srcdir / "_static" / "bootstrap.py").write_text( + "# Optional post-install bootstrap for local wheel REPLs.\n", + encoding="utf-8", + ) + + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + (srcdir / "conf.py").write_text( + """ +extensions = ["sphinx_pyrepl_web"] +master_doc = "index" +pyrepl_js = "pyrepl.js" +html_static_path = ["_static"] +""", + encoding="utf-8", + ) + return srcdir, outdir, doctreedir + + +def test_local_wheel_packages_emitted_in_doctree(sphinx_doctree: CreateDoctree): + sphinx_doctree.set_conf({"extensions": ["sphinx_pyrepl_web"]}) + sphinx_doctree.buildername = "html" + result = sphinx_doctree( + f""" +.. py-repl:: + :packages: {WHEEL_PATH} + :no-header: +""" + ) + html = result.pformat() + assert f'packages="{WHEEL_PATH}"' in html + + +def test_local_wheel_copied_to_output_tree(wheel_project): + srcdir, outdir, doctreedir = wheel_project + (srcdir / "index.rst").write_text( + f""" +Example +======= + +.. py-repl:: + :packages: {WHEEL_PATH} + :no-header: +""", + encoding="utf-8", + ) + + _build_sphinx(srcdir, outdir, doctreedir) + + wheel_out = outdir / "_static" / "wheels" / WHEEL_NAME + assert wheel_out.is_file(), f"missing wheel at {wheel_out}" + + html = (outdir / "index.html").read_text(encoding="utf-8") + assert f'packages="{WHEEL_PATH}"' in html + assert "pyrepl.js" in html + + +def test_local_wheel_with_src_and_replay_body(wheel_project): + srcdir, outdir, doctreedir = wheel_project + (srcdir / "index.rst").write_text( + f""" +Example +======= + +.. py-repl:: + :packages: {WHEEL_PATH} + :src: _static/bootstrap.py + :repl-title: test + :no-banner: + + >>> import pyrepl_test_pkg + >>> pyrepl_test_pkg.ping() +""", + encoding="utf-8", + ) + + app = _build_sphinx(srcdir, outdir, doctreedir) + + assert (outdir / "_static" / "wheels" / WHEEL_NAME).is_file() + assert (outdir / "_static" / "bootstrap.py").is_file() + + replay_files = json.loads( + app.env.metadata["index"].get("pyrepl-replay-files", "{}") + ) + assert len(replay_files) == 1 + script_name = next(iter(replay_files)) + assert (outdir / "_static" / "pyrepl" / script_name).is_file() + + html = (outdir / "index.html").read_text(encoding="utf-8") + assert f'packages="{WHEEL_PATH}"' in html + assert 'src="_static/bootstrap.py"' in html + assert f'replay-src="_static/pyrepl/{script_name}"' in html + + +def test_comma_separated_local_wheel_and_pypi_package(sphinx_doctree: CreateDoctree): + sphinx_doctree.set_conf({"extensions": ["sphinx_pyrepl_web"]}) + sphinx_doctree.buildername = "html" + packages = f"numpy, {WHEEL_PATH}" + result = sphinx_doctree( + f""" +.. py-repl:: + :packages: {packages} + :no-header: +""" + ) + html = result.pformat() + assert f'packages="{packages}"' in html From f2d1d8e187b7efb0a8de17bc53084574e659bf2e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 30 Jun 2026 22:45:23 +0000 Subject: [PATCH 03/16] Re-vendor pyrepl-web from updated PR #5 branch Vendor cursor/resolve-package-urls-0bbd@1d95c619, which is rebased onto grill with replay mode (#4) and relative wheel URL resolution. Restores replay-src runtime support that was lost in the earlier pre-rebase vendor. Co-authored-by: chrizzftd --- .../{chunk-es2qkm30.js => chunk-cnc6ympw.js} | 2 +- sphinx_pyrepl_web/pyrepl/pyrepl.esm.js | 127 +++++++++++++++--- sphinx_pyrepl_web/pyrepl/version.txt | 2 +- 3 files changed, 109 insertions(+), 22 deletions(-) rename sphinx_pyrepl_web/pyrepl/{chunk-es2qkm30.js => chunk-cnc6ympw.js} (99%) diff --git a/sphinx_pyrepl_web/pyrepl/chunk-es2qkm30.js b/sphinx_pyrepl_web/pyrepl/chunk-cnc6ympw.js similarity index 99% rename from sphinx_pyrepl_web/pyrepl/chunk-es2qkm30.js rename to sphinx_pyrepl_web/pyrepl/chunk-cnc6ympw.js index d350a08..c61b807 100644 --- a/sphinx_pyrepl_web/pyrepl/chunk-es2qkm30.js +++ b/sphinx_pyrepl_web/pyrepl/chunk-cnc6ympw.js @@ -1,3 +1,3 @@ -import{i as $}from"./chunk-ftjk4vft.js";var __dirname="/tmp/vendor-pyrepl-5np9yyty/pyrepl-web/node_modules/pyodide";var O0=Object.defineProperty,H=(G,Q)=>O0(G,"name",{value:Q,configurable:!0}),m=((G)=>$)(function(G){return $.apply(this,arguments)}),R0=(()=>{for(var G=new Uint8Array(128),Q=0;Q<64;Q++)G[Q<26?Q+65:Q<52?Q+71:Q<62?Q-4:Q*4-205]=Q;return(W)=>{for(var z=W.length,Y=new Uint8Array((z-(W[z-1]=="=")-(W[z-2]=="="))*3/4|0),X=0,B=0;X>4,Y[B++]=K<<4|M>>2,Y[B++]=M<<6|V}return Y}})();function p(G){return!isNaN(parseFloat(G))&&isFinite(G)}H(p,"_isNumber");function T(G){return G.charAt(0).toUpperCase()+G.substring(1)}H(T,"_capitalize");function g(G){return function(){return this[G]}}H(g,"_getter");var R=["isConstructor","isEval","isNative","isToplevel"],U=["columnNumber","lineNumber"],F=["fileName","functionName","source"],U0=["args"],F0=["evalOrigin"],S=R.concat(U,F,U0,F0);function j(G){if(G)for(var Q=0;QO0(G,"name",{value:Q,configurable:!0}),m=((G)=>$)(function(G){return $.apply(this,arguments)}),R0=(()=>{for(var G=new Uint8Array(128),Q=0;Q<64;Q++)G[Q<26?Q+65:Q<52?Q+71:Q<62?Q-4:Q*4-205]=Q;return(W)=>{for(var z=W.length,Y=new Uint8Array((z-(W[z-1]=="=")-(W[z-2]=="="))*3/4|0),X=0,B=0;X>4,Y[B++]=K<<4|M>>2,Y[B++]=M<<6|V}return Y}})();function p(G){return!isNaN(parseFloat(G))&&isFinite(G)}H(p,"_isNumber");function T(G){return G.charAt(0).toUpperCase()+G.substring(1)}H(T,"_capitalize");function g(G){return function(){return this[G]}}H(g,"_getter");var R=["isConstructor","isEval","isNative","isToplevel"],U=["columnNumber","lineNumber"],F=["fileName","functionName","source"],U0=["args"],F0=["evalOrigin"],S=R.concat(U,F,U0,F0);function j(G){if(G)for(var Q=0;Q-1&&(Y=Y.replace(/eval code/g,"eval").replace(/(\(eval at [^()]*)|(,.*$)/g,""));var X=Y.replace(/^\s+/,"").replace(/\(eval code/g,"(").replace(/^.*?\s+/,""),B=X.match(/ (\(.+\)$)/);X=B?X.replace(B[0],""):X;var J=this.extractLocation(B?B[1]:X),K=B&&X||void 0,M=["eval",""].indexOf(J[0])>-1?void 0:J[0];return new w({functionName:K,fileName:M,lineNumber:J[1],columnNumber:J[2],source:Y})},this)},"ErrorStackParser$$parseV8OrIE"),parseFFOrSafari:H(function(W){var z=W.stack.split(` `).filter(function(Y){return!Y.match(Q)},this);return z.map(function(Y){if(Y.indexOf(" > eval")>-1&&(Y=Y.replace(/ line (\d+)(?: > eval line \d+)* > eval:\d+:\d+/g,":$1")),Y.indexOf("@")===-1&&Y.indexOf(":")===-1)return new w({functionName:Y});var X=/((.*".+"[^@]*)?[^@]*)(?:@)/,B=Y.match(X),J=B&&B[1]?B[1]:void 0,K=this.extractLocation(Y.replace(X,""));return new w({functionName:J,fileName:K[0],lineNumber:K[1],columnNumber:K[2],source:Y})},this)},"ErrorStackParser$$parseFFOrSafari")}}H(u,"ErrorStackParser");var k0=new u,I0=k0;function l(){if(typeof API<"u"&&API!==globalThis.API)return API.runtimeEnv;let G=typeof Bun<"u",Q=typeof Deno<"u",W=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&!1,z=typeof navigator=="object"&&typeof navigator.userAgent=="string"&&navigator.userAgent.indexOf("Chrome")===-1&&navigator.userAgent.indexOf("Safari")>-1,Y=typeof read=="function"&&typeof load=="function",X=typeof navigator=="object"&&navigator.userAgent?.includes("Cloudflare-Workers");return d({IN_BUN:G,IN_DENO:Q,IN_NODE:W,IN_SAFARI:z,IN_SHELL:Y,IN_WORKERD:X})}H(l,"getGlobalRuntimeEnv");var x=l();function d(G){let Q=G.IN_NODE&&typeof f<"u"&&P0&&typeof m=="function"&&typeof __dirname=="string",W=G.IN_NODE&&!Q,z=!G.IN_NODE&&!G.IN_DENO&&!G.IN_BUN,Y=z&&typeof window<"u"&&typeof window.document<"u"&&typeof document.createElement=="function"&&"sessionStorage"in window&&typeof globalThis.importScripts!="function",X=z&&typeof globalThis.WorkerGlobalScope<"u"&&typeof globalThis.self<"u"&&globalThis.self instanceof globalThis.WorkerGlobalScope;if(X&&c())throw Error("Classic web workers are not supported");let B={...G,IN_BROWSER:z,IN_BROWSER_MAIN_THREAD:Y,IN_BROWSER_WEB_WORKER:X,IN_NODE_COMMONJS:Q,IN_NODE_ESM:W};if(!(B.IN_BROWSER_MAIN_THREAD||B.IN_BROWSER_WEB_WORKER||B.IN_NODE||B.IN_SHELL||B.IN_WORKERD))throw Error(`Cannot determine runtime environment: ${JSON.stringify(B)}`);return B}H(d,"calculateDerivedFlags");function c(){try{return globalThis.importScripts("data:text/javascript,"),!0}catch{return!1}}H(c,"isClassicWorker");var s,E,N0,_,h;async function y(){if(!x.IN_NODE||(s=(await import("./chunk-vx91qfkd.js")).default,_=await import("node:fs"),h=await import("node:fs/promises"),N0=(await import("node:vm")).default,E=await import("./chunk-94xkfg72.js"),v=E.sep,typeof m<"u"))return;let G=_,Q=await import("./chunk-x20ze186.js"),W=await import("ws"),z=await import("node:child_process"),Y={fs:G,crypto:Q,ws:W,child_process:z};globalThis.require=function(X){return Y[X]}}H(y,"initNodeModules");function a(G,Q){return E.resolve(Q||".",G)}H(a,"node_resolvePath");function i(G,Q){return Q===void 0&&(Q=location),new URL(G,Q).toString()}H(i,"browser_resolvePath");var I;x.IN_NODE?I=a:x.IN_SHELL?I=H((G)=>G,"resolvePath"):I=i;var v;x.IN_NODE||(v="/");function o(G,Q){return G.startsWith("file://")&&(G=G.slice(7)),G.includes("://")?{response:fetch(G)}:{binary:h.readFile(G).then((W)=>new Uint8Array(W.buffer,W.byteOffset,W.byteLength))}}H(o,"node_getBinaryResponse");function n(G,Q){if(G.startsWith("file://")&&(G=G.slice(7)),G.includes("://"))throw Error("Shell cannot fetch urls");return{binary:Promise.resolve(new Uint8Array(readbuffer(G)))}}H(n,"shell_getBinaryResponse");function r(G,Q){let W=new URL(G,location);return{response:fetch(W,Q?{integrity:Q}:{})}}H(r,"browser_getBinaryResponse");var N;x.IN_NODE?N=o:x.IN_SHELL?N=n:N=r;async function t(G,Q){let{response:W,binary:z}=N(G,Q);if(z)return z;let Y=await W;if(!Y.ok)throw Error(`Failed to load '${G}': request failed.`);return new Uint8Array(await Y.arrayBuffer())}H(t,"loadBinaryFile");var b;x.IN_NODE?b=e:b=H(async(G)=>await import(G),"loadScript");async function e(G){return G.startsWith("file://")&&(G=G.slice(7)),G.includes("://")?await import(G):await import(s.pathToFileURL(G).href)}H(e,"nodeLoadScript");async function G0(G){if(x.IN_NODE){await y();let Q=await h.readFile(G,{encoding:"utf8"});return JSON.parse(Q)}else if(x.IN_SHELL){let Q=read(G);return JSON.parse(Q)}else return await(await fetch(G)).json()}H(G0,"loadLockFile");async function Q0(){if(x.IN_NODE_COMMONJS)return __dirname;let G;try{throw Error()}catch(z){G=z}let Q=I0.parse(G)[0].fileName;if(x.IN_NODE&&!Q.startsWith("file://")&&(Q=`file://${Q}`),x.IN_NODE_ESM){let z=await import("./chunk-94xkfg72.js");return(await import("./chunk-vx91qfkd.js")).fileURLToPath(z.dirname(Q))}let W=Q.lastIndexOf(v);if(W===-1)throw Error("Could not extract indexURL path from pyodide module location. Please pass the indexURL explicitly to loadPyodide.");return Q.slice(0,W)}H(Q0,"calculateDirname");function W0(G){return G.substring(0,G.lastIndexOf("/")+1)||globalThis.location?.toString()||"."}H(W0,"calculateInstallBaseUrl");function X0(G){let Q=G.FS,W=G.FS.filesystems.MEMFS,z=G.PATH,Y={DIR_MODE:16895,FILE_MODE:33279,mount:H(function(X){if(!X.opts.fileSystemHandle)throw Error("opts.fileSystemHandle is required");return W.mount.apply(null,arguments)},"mount"),syncfs:H(async(X,B,J)=>{try{let K=Y.getLocalSet(X),M=await Y.getRemoteSet(X),V=B?M:K,C=B?K:M;await Y.reconcile(X,V,C),J(null)}catch(K){J(K)}},"syncfs"),getLocalSet:H((X)=>{let B=Object.create(null);function J(V){return V!=="."&&V!==".."}H(J,"isRealDir");function K(V){return(C)=>z.join2(V,C)}H(K,"toAbsolute");let M=Q.readdir(X.mountpoint).filter(J).map(K(X.mountpoint));for(;M.length;){let V=M.pop(),C=Q.stat(V);Q.isDir(C.mode)&&M.push.apply(M,Q.readdir(V).filter(J).map(K(V))),B[V]={timestamp:C.mtime,mode:C.mode}}return{type:"local",entries:B}},"getLocalSet"),getRemoteSet:H(async(X)=>{let B=Object.create(null),J=await S0(X.opts.fileSystemHandle);for(let[K,M]of J)K!=="."&&(B[z.join2(X.mountpoint,K)]={timestamp:M.kind==="file"?new Date((await M.getFile()).lastModified):new Date,mode:M.kind==="file"?Y.FILE_MODE:Y.DIR_MODE});return{type:"remote",entries:B,handles:J}},"getRemoteSet"),loadLocalEntry:H((X)=>{let B=Q.lookupPath(X,{}).node,J=Q.stat(X);if(Q.isDir(J.mode))return{timestamp:J.mtime,mode:J.mode};if(Q.isFile(J.mode))return B.contents=W.getFileDataAsTypedArray(B),{timestamp:J.mtime,mode:J.mode,contents:B.contents};throw Error("node type not supported")},"loadLocalEntry"),storeLocalEntry:H((X,B)=>{if(Q.isDir(B.mode))Q.mkdirTree(X,B.mode);else if(Q.isFile(B.mode))Q.writeFile(X,B.contents,{canOwn:!0});else throw Error("node type not supported");Q.chmod(X,B.mode),Q.utime(X,B.timestamp,B.timestamp)},"storeLocalEntry"),removeLocalEntry:H((X)=>{var B=Q.stat(X);Q.isDir(B.mode)?Q.rmdir(X):Q.isFile(B.mode)&&Q.unlink(X)},"removeLocalEntry"),loadRemoteEntry:H(async(X)=>{if(X.kind==="file"){let B=await X.getFile();return{contents:new Uint8Array(await B.arrayBuffer()),mode:Y.FILE_MODE,timestamp:new Date(B.lastModified)}}else{if(X.kind==="directory")return{mode:Y.DIR_MODE,timestamp:new Date};throw Error("unknown kind: "+X.kind)}},"loadRemoteEntry"),storeRemoteEntry:H(async(X,B,J)=>{let K=X.get(z.dirname(B)),M=Q.isFile(J.mode)?await K.getFileHandle(z.basename(B),{create:!0}):await K.getDirectoryHandle(z.basename(B),{create:!0});if(M.kind==="file"){let V=await M.createWritable();await V.write(J.contents),await V.close()}X.set(B,M)},"storeRemoteEntry"),removeRemoteEntry:H(async(X,B)=>{await X.get(z.dirname(B)).removeEntry(z.basename(B)),X.delete(B)},"removeRemoteEntry"),reconcile:H(async(X,B,J)=>{let K=0,M=[];Object.keys(B.entries).forEach(function(Z){let q=B.entries[Z],O=J.entries[Z];(!O||Q.isFile(q.mode)&&q.timestamp.getTime()>O.timestamp.getTime())&&(M.push(Z),K++)}),M.sort();let V=[];if(Object.keys(J.entries).forEach(function(Z){B.entries[Z]||(V.push(Z),K++)}),V.sort().reverse(),!K)return;let C=B.type==="remote"?B.handles:J.handles;for(let Z of M){let q=z.normalize(Z.replace(X.mountpoint,"/")).substring(1);if(J.type==="local"){let O=C.get(q),A0=await Y.loadRemoteEntry(O);Y.storeLocalEntry(Z,A0)}else{let O=Y.loadLocalEntry(Z);await Y.storeRemoteEntry(C,q,O)}}for(let Z of V)if(J.type==="local")Y.removeLocalEntry(Z);else{let q=z.normalize(Z.replace(X.mountpoint,"/")).substring(1);await Y.removeRemoteEntry(C,q)}},"reconcile")};G.FS.filesystems.NATIVEFS_ASYNC=Y}H(X0,"initializeNativeFS");var S0=H(async(G)=>{let Q=[];async function W(Y){for await(let X of Y.values())Q.push(X),X.kind==="directory"&&await W(X)}H(W,"collect"),await W(G);let z=new Map;z.set(".",G);for(let Y of Q){let X=(await G.resolve(Y)).join("/");z.set(X,Y)}return z},"getFsHandles"),g0=R0("AGFzbQEAAAABDANfAGAAAW9gAW8BfwMDAgECBygCE0pzdl9HZXRFcnJvcl9pbXBvcnQAAA5Kc3ZFcnJvcl9DaGVjawABChMCBwD7AQD7GwsJACAA+xr7FAAL"),w0=async function(){if(!(globalThis.navigator&&(/iPad|iPhone|iPod/.test(navigator.userAgent)||navigator.platform==="MacIntel"&&typeof navigator.maxTouchPoints<"u"&&navigator.maxTouchPoints>1)))try{let G=await WebAssembly.compile(g0);return await WebAssembly.instantiate(G)}catch(G){if(G instanceof WebAssembly.CompileError)return;throw G}}();async function Y0(){let G=await w0;if(G)return G.exports;let Q=Symbol("error marker");return{Jsv_GetError_import:H(()=>Q,"Jsv_GetError_import"),JsvError_Check:H((W)=>W===Q,"JsvError_Check")}}H(Y0,"getJsvErrorImport");function z0(G){let Q={config:G,runtimeEnv:x},W={noImageDecoding:!0,noAudioDecoding:!0,noWasmDecoding:!1,preRun:V0(G),print:G.stdout,printErr:G.stderr,onExit(z){W.exitCode=z},thisProgram:G._sysExecutable,arguments:G.args,API:Q,locateFile:H((z)=>G.indexURL+z,"locateFile"),instantiateWasm:Z0(G.indexURL)};return W}H(z0,"createSettings");function B0(G){return function(Q){let W="/";try{Q.FS.mkdirTree(G)}catch(z){console.error(`Error occurred while making a home directory '${G}':`),console.error(z),console.error(`Using '${W}' for a home directory instead`),G=W}Q.FS.chdir(G)}}H(B0,"createHomeDirectory");function H0(G){return function(Q){Object.assign(Q.ENV,G)}}H(H0,"setEnvironment");function J0(G){return G?[async(Q)=>{Q.addRunDependency("fsInitHook");try{await G(Q.FS,{sitePackages:Q.API.sitePackages})}finally{Q.removeRunDependency("fsInitHook")}}]:[]}H(J0,"callFsInitHook");function K0(G){let Q=G.HEAPU32[G._Py_Version>>>2],W=Q>>>24&255,z=Q>>>16&255,Y=Q>>>8&255;return[W,z,Y]}H(K0,"computeVersionTuple");function M0(G){let Q=t(G);return async(W)=>{W.API.pyVersionTuple=K0(W);let[z,Y]=W.API.pyVersionTuple;W.FS.mkdirTree("/lib"),W.API.sitePackages=`/lib/python${z}.${Y}/site-packages`,W.FS.mkdirTree(W.API.sitePackages),W.addRunDependency("install-stdlib");try{let X=await Q;W.FS.writeFile(`/lib/python${z}${Y}.zip`,X)}catch(X){console.error("Error occurred while installing the standard library:"),console.error(X)}finally{W.removeRunDependency("install-stdlib")}}}H(M0,"installStdlib");function V0(G){let Q;return G.stdLibURL!=null?Q=G.stdLibURL:Q=G.indexURL+"python_stdlib.zip",[M0(Q),B0(G.env.HOME),H0(G.env),X0,...J0(G.fsInit)]}H(V0,"getFileSystemInitializationFuncs");function Z0(G){if(typeof WasmOffsetConverter<"u")return;let{binary:Q,response:W}=N(G+"pyodide.asm.wasm"),z=Y0();return function(Y,X){return async function(){let{Jsv_GetError_import:B,JsvError_Check:J}=await z;Y.env.Jsv_GetError_import=B,Y.env.JsvError_Check=J;try{let K;W?K=await WebAssembly.instantiateStreaming(W,Y):K=await WebAssembly.instantiate(await Q,Y);let{instance:M,module:V}=K;X(M,V)}catch(K){console.warn("wasm instantiation failed!"),console.warn(K)}}(),{}}}H(Z0,"getInstantiateWasmFunc");var E0="314.0.1";function k(G){return G===void 0||G.endsWith("/")?G:G+"/"}H(k,"withTrailingSlash");var P=E0;async function $0(G={}){if(await y(),G.lockFileContents&&G.lockFileURL)throw Error("Can't pass both lockFileContents and lockFileURL");let Q=G.indexURL||await Q0();if(Q=k(I(Q)),G.packageBaseUrl=k(G.packageBaseUrl),G.cdnUrl=k(G.packageBaseUrl??`https://cdn.jsdelivr.net/pyodide/v${P}/full/`),!G.lockFileContents){let Y=G.lockFileURL??Q+"pyodide-lock.json";G.lockFileContents=G0(Y),G.packageBaseUrl??=W0(Y)}G.indexURL=Q,G.packageCacheDir&&(G.packageCacheDir=k(I(G.packageCacheDir)));let W={jsglobals:globalThis,stdin:globalThis.prompt?()=>globalThis.prompt():void 0,args:[],env:{},packages:[],packageCacheDir:G.packageBaseUrl,enableRunUntilComplete:!0,checkAPIVersion:!0,BUILD_ID:"2e12ea28d5e4e258977cb79dd418ccf6593712466fd333300c63fda4fc9257c2"},z=Object.assign(W,G);return z.env.HOME??="/home/pyodide",z.env.PYTHONINSPECT??="1",z}H($0,"initializeConfiguration");function j0(G){let Q=z0(G),W=Q.API;return W.lockFilePromise=Promise.resolve(G.lockFileContents),Q}H(j0,"createEmscriptenSettings");async function x0(G){if(G.createPyodideModule)return G.createPyodideModule;let Q=`${G.indexURL}pyodide.asm.mjs`;return(await b(Q)).default}H(x0,"loadWasmScript");async function C0(G,Q){if(!G._loadSnapshot)return;let W=await G._loadSnapshot,z=ArrayBuffer.isView(W)?W:new Uint8Array(W);return Q.noInitialRun=!0,Q.INITIAL_MEMORY=z.length,z}H(C0,"prepareSnapshot");async function T0(G,Q){let W=await G(Q);if(Q.exitCode!==void 0)throw new W.ExitStatus(Q.exitCode);return W}H(T0,"instantiatePyodideModule");function q0(G,Q){let W=G.API;if(Q.pyproxyToStringRepr&&W.setPyProxyToStringMethod(!0),Q.convertNullToNone&&W.setCompatNullToNone(!0),Q.toJsLiteralMap&&W.setCompatToJsLiteralMap(!0),W.version!==P&&Q.checkAPIVersion)throw Error(`Pyodide version does not match: '${P}' <==> '${W.version}'. If you updated the Pyodide version, make sure you also updated the 'indexURL' parameter passed to loadPyodide.`);G.locateFile=(z)=>{throw z.endsWith(".so")?Error(`Failed to find dynamic library "${z}"`):Error(`Unexpected call to locateFile("${z}")`)}}H(q0,"configureAPI");function D0(G,Q,W){let z=G.API,Y;return Q&&(Y=z.restoreSnapshot(Q)),z.finalizeBootstrap(Y,W._snapshotDeserializer)}H(D0,"bootstrapPyodide");async function L0(G,Q){let W=G._api;return W.sys.path.insert(0,""),W._pyodide.set_excepthook(),await W.packageIndexReady,W.initializeStreams(Q.stdin,Q.stdout,Q.stderr),G}H(L0,"finalizeSetup");async function b0(G={}){let Q=await $0(G),W=j0(Q),z=await x0(Q),Y=await C0(Q,W),X=await T0(z,W);q0(X,Q);let B=D0(X,Y,Q);return await L0(B,Q)}H(b0,"loadPyodide");export{P as version,b0 as loadPyodide}; diff --git a/sphinx_pyrepl_web/pyrepl/pyrepl.esm.js b/sphinx_pyrepl_web/pyrepl/pyrepl.esm.js index 35dfb3f..4b57b92 100644 --- a/sphinx_pyrepl_web/pyrepl/pyrepl.esm.js +++ b/sphinx_pyrepl_web/pyrepl/pyrepl.esm.js @@ -1,8 +1,8 @@ -import{d as Cn,e as On,f as In,g as Wn,i as Gn}from"./chunk-ftjk4vft.js";var I=globalThis,W=I.ShadowRoot&&(I.ShadyCSS===void 0||I.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,M=Symbol(),U=new WeakMap;class T{constructor(n,e,r){if(this._$cssResult$=!0,r!==M)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=n,this.t=e}get styleSheet(){let n=this.o,e=this.t;if(W&&n===void 0){let r=e!==void 0&&e.length===1;r&&(n=U.get(e)),n===void 0&&((this.o=n=new CSSStyleSheet).replaceSync(this.cssText),r&&U.set(e,n))}return n}toString(){return this.cssText}}var g=(n)=>new T(typeof n=="string"?n:n+"",void 0,M);var nn=(n,e)=>{if(W)n.adoptedStyleSheets=e.map((r)=>r instanceof CSSStyleSheet?r:r.styleSheet);else for(let r of e){let l=document.createElement("style"),o=I.litNonce;o!==void 0&&l.setAttribute("nonce",o),l.textContent=r.cssText,n.appendChild(l)}},V=W?(n)=>n:(n)=>n instanceof CSSStyleSheet?((e)=>{let r="";for(let l of e.cssRules)r+=l.cssText;return g(r)})(n):n;var{is:Yn,defineProperty:Xn,getOwnPropertyDescriptor:$n,getOwnPropertyNames:Dn,getOwnPropertySymbols:Tn,getPrototypeOf:Vn}=Object,X=globalThis,en=X.trustedTypes,Zn=en?en.emptyScript:"",zn=X.reactiveElementPolyfillSupport,q=(n,e)=>n,Y={toAttribute(n,e){switch(e){case Boolean:n=n?Zn:null;break;case Object:case Array:n=n==null?n:JSON.stringify(n)}return n},fromAttribute(n,e){let r=n;switch(e){case Boolean:r=n!==null;break;case Number:r=n===null?null:Number(n);break;case Object:case Array:try{r=JSON.parse(n)}catch(l){r=null}}return r}},Z=(n,e)=>!Yn(n,e),rn={attribute:!0,type:String,converter:Y,reflect:!1,useDefault:!1,hasChanged:Z};Symbol.metadata??=Symbol("metadata"),X.litPropertyMetadata??=new WeakMap;class f extends HTMLElement{static addInitializer(n){this._$Ei(),(this.l??=[]).push(n)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(n,e=rn){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(n)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(n,e),!e.noAccessor){let r=Symbol(),l=this.getPropertyDescriptor(n,r,e);l!==void 0&&Xn(this.prototype,n,l)}}static getPropertyDescriptor(n,e,r){let{get:l,set:o}=$n(this.prototype,n)??{get(){return this[e]},set(_){this[e]=_}};return{get:l,set(_){let s=l?.call(this);o?.call(this,_),this.requestUpdate(n,s,r)},configurable:!0,enumerable:!0}}static getPropertyOptions(n){return this.elementProperties.get(n)??rn}static _$Ei(){if(this.hasOwnProperty(q("elementProperties")))return;let n=Vn(this);n.finalize(),n.l!==void 0&&(this.l=[...n.l]),this.elementProperties=new Map(n.elementProperties)}static finalize(){if(this.hasOwnProperty(q("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(q("properties"))){let e=this.properties,r=[...Dn(e),...Tn(e)];for(let l of r)this.createProperty(l,e[l])}let n=this[Symbol.metadata];if(n!==null){let e=litPropertyMetadata.get(n);if(e!==void 0)for(let[r,l]of e)this.elementProperties.set(r,l)}this._$Eh=new Map;for(let[e,r]of this.elementProperties){let l=this._$Eu(e,r);l!==void 0&&this._$Eh.set(l,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(n){let e=[];if(Array.isArray(n)){let r=new Set(n.flat(1/0).reverse());for(let l of r)e.unshift(V(l))}else n!==void 0&&e.push(V(n));return e}static _$Eu(n,e){let r=e.attribute;return r===!1?void 0:typeof r=="string"?r:typeof n=="string"?n.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise((n)=>this.enableUpdating=n),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach((n)=>n(this))}addController(n){(this._$EO??=new Set).add(n),this.renderRoot!==void 0&&this.isConnected&&n.hostConnected?.()}removeController(n){this._$EO?.delete(n)}_$E_(){let n=new Map,e=this.constructor.elementProperties;for(let r of e.keys())this.hasOwnProperty(r)&&(n.set(r,this[r]),delete this[r]);n.size>0&&(this._$Ep=n)}createRenderRoot(){let n=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return nn(n,this.constructor.elementStyles),n}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach((n)=>n.hostConnected?.())}enableUpdating(n){}disconnectedCallback(){this._$EO?.forEach((n)=>n.hostDisconnected?.())}attributeChangedCallback(n,e,r){this._$AK(n,r)}_$ET(n,e){let r=this.constructor.elementProperties.get(n),l=this.constructor._$Eu(n,r);if(l!==void 0&&r.reflect===!0){let o=(r.converter?.toAttribute!==void 0?r.converter:Y).toAttribute(e,r.type);this._$Em=n,o==null?this.removeAttribute(l):this.setAttribute(l,o),this._$Em=null}}_$AK(n,e){let r=this.constructor,l=r._$Eh.get(n);if(l!==void 0&&this._$Em!==l){let o=r.getPropertyOptions(l),_=typeof o.converter=="function"?{fromAttribute:o.converter}:o.converter?.fromAttribute!==void 0?o.converter:Y;this._$Em=l;let s=_.fromAttribute(e,o.type);this[l]=s??this._$Ej?.get(l)??s,this._$Em=null}}requestUpdate(n,e,r,l=!1,o){if(n!==void 0){let _=this.constructor;if(l===!1&&(o=this[n]),r??=_.getPropertyOptions(n),!((r.hasChanged??Z)(o,e)||r.useDefault&&r.reflect&&o===this._$Ej?.get(n)&&!this.hasAttribute(_._$Eu(n,r))))return;this.C(n,e,r)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(n,e,{useDefault:r,reflect:l,wrapped:o},_){r&&!(this._$Ej??=new Map).has(n)&&(this._$Ej.set(n,_??e??this[n]),o!==!0||_!==void 0)||(this._$AL.has(n)||(this.hasUpdated||r||(e=void 0),this._$AL.set(n,e)),l===!0&&this._$Em!==n&&(this._$Eq??=new Set).add(n))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}let n=this.scheduleUpdate();return n!=null&&await n,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[l,o]of this._$Ep)this[l]=o;this._$Ep=void 0}let r=this.constructor.elementProperties;if(r.size>0)for(let[l,o]of r){let{wrapped:_}=o,s=this[l];_!==!0||this._$AL.has(l)||s===void 0||this.C(l,void 0,o,s)}}let n=!1,e=this._$AL;try{n=this.shouldUpdate(e),n?(this.willUpdate(e),this._$EO?.forEach((r)=>r.hostUpdate?.()),this.update(e)):this._$EM()}catch(r){throw n=!1,this._$EM(),r}n&&this._$AE(e)}willUpdate(n){}_$AE(n){this._$EO?.forEach((e)=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(n)),this.updated(n)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(n){return!0}update(n){this._$Eq&&=this._$Eq.forEach((e)=>this._$ET(e,this[e])),this._$EM()}updated(n){}firstUpdated(n){}}f.elementStyles=[],f.shadowRootOptions={mode:"open"},f[q("elementProperties")]=new Map,f[q("finalized")]=new Map,zn?.({ReactiveElement:f}),(X.reactiveElementVersions??=[]).push("2.1.2");var z=globalThis,ln=(n)=>n,$=z.trustedTypes,on=$?$.createPolicy("lit-html",{createHTML:(n)=>n}):void 0;var i=`lit$${Math.random().toFixed(9).slice(2)}$`,xn="?"+i,Qn=`<${xn}>`,v=document,J=()=>v.createComment(""),K=(n)=>n===null||typeof n!="object"&&typeof n!="function",Q=Array.isArray,jn=(n)=>Q(n)||typeof n?.[Symbol.iterator]=="function";var G=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,_n=/-->/g,sn=/>/g,b=RegExp(`>|[ +import{d as Bn,e as Cn,f as In,g as Wn,i as qn}from"./chunk-ftjk4vft.js";var I=globalThis,W=I.ShadowRoot&&(I.ShadyCSS===void 0||I.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,M=Symbol(),U=new WeakMap;class V{constructor(n,e,r){if(this._$cssResult$=!0,r!==M)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=n,this.t=e}get styleSheet(){let n=this.o,e=this.t;if(W&&n===void 0){let r=e!==void 0&&e.length===1;r&&(n=U.get(e)),n===void 0&&((this.o=n=new CSSStyleSheet).replaceSync(this.cssText),r&&U.set(e,n))}return n}toString(){return this.cssText}}var g=(n)=>new V(typeof n=="string"?n:n+"",void 0,M);var nn=(n,e)=>{if(W)n.adoptedStyleSheets=e.map((r)=>r instanceof CSSStyleSheet?r:r.styleSheet);else for(let r of e){let l=document.createElement("style"),o=I.litNonce;o!==void 0&&l.setAttribute("nonce",o),l.textContent=r.cssText,n.appendChild(l)}},T=W?(n)=>n:(n)=>n instanceof CSSStyleSheet?((e)=>{let r="";for(let l of e.cssRules)r+=l.cssText;return g(r)})(n):n;var{is:Yn,defineProperty:Xn,getOwnPropertyDescriptor:$n,getOwnPropertyNames:Dn,getOwnPropertySymbols:Vn,getPrototypeOf:Tn}=Object,X=globalThis,en=X.trustedTypes,Zn=en?en.emptyScript:"",zn=X.reactiveElementPolyfillSupport,F=(n,e)=>n,Y={toAttribute(n,e){switch(e){case Boolean:n=n?Zn:null;break;case Object:case Array:n=n==null?n:JSON.stringify(n)}return n},fromAttribute(n,e){let r=n;switch(e){case Boolean:r=n!==null;break;case Number:r=n===null?null:Number(n);break;case Object:case Array:try{r=JSON.parse(n)}catch(l){r=null}}return r}},Z=(n,e)=>!Yn(n,e),rn={attribute:!0,type:String,converter:Y,reflect:!1,useDefault:!1,hasChanged:Z};Symbol.metadata??=Symbol("metadata"),X.litPropertyMetadata??=new WeakMap;class u extends HTMLElement{static addInitializer(n){this._$Ei(),(this.l??=[]).push(n)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(n,e=rn){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(n)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(n,e),!e.noAccessor){let r=Symbol(),l=this.getPropertyDescriptor(n,r,e);l!==void 0&&Xn(this.prototype,n,l)}}static getPropertyDescriptor(n,e,r){let{get:l,set:o}=$n(this.prototype,n)??{get(){return this[e]},set(_){this[e]=_}};return{get:l,set(_){let s=l?.call(this);o?.call(this,_),this.requestUpdate(n,s,r)},configurable:!0,enumerable:!0}}static getPropertyOptions(n){return this.elementProperties.get(n)??rn}static _$Ei(){if(this.hasOwnProperty(F("elementProperties")))return;let n=Tn(this);n.finalize(),n.l!==void 0&&(this.l=[...n.l]),this.elementProperties=new Map(n.elementProperties)}static finalize(){if(this.hasOwnProperty(F("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(F("properties"))){let e=this.properties,r=[...Dn(e),...Vn(e)];for(let l of r)this.createProperty(l,e[l])}let n=this[Symbol.metadata];if(n!==null){let e=litPropertyMetadata.get(n);if(e!==void 0)for(let[r,l]of e)this.elementProperties.set(r,l)}this._$Eh=new Map;for(let[e,r]of this.elementProperties){let l=this._$Eu(e,r);l!==void 0&&this._$Eh.set(l,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(n){let e=[];if(Array.isArray(n)){let r=new Set(n.flat(1/0).reverse());for(let l of r)e.unshift(T(l))}else n!==void 0&&e.push(T(n));return e}static _$Eu(n,e){let r=e.attribute;return r===!1?void 0:typeof r=="string"?r:typeof n=="string"?n.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise((n)=>this.enableUpdating=n),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach((n)=>n(this))}addController(n){(this._$EO??=new Set).add(n),this.renderRoot!==void 0&&this.isConnected&&n.hostConnected?.()}removeController(n){this._$EO?.delete(n)}_$E_(){let n=new Map,e=this.constructor.elementProperties;for(let r of e.keys())this.hasOwnProperty(r)&&(n.set(r,this[r]),delete this[r]);n.size>0&&(this._$Ep=n)}createRenderRoot(){let n=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return nn(n,this.constructor.elementStyles),n}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach((n)=>n.hostConnected?.())}enableUpdating(n){}disconnectedCallback(){this._$EO?.forEach((n)=>n.hostDisconnected?.())}attributeChangedCallback(n,e,r){this._$AK(n,r)}_$ET(n,e){let r=this.constructor.elementProperties.get(n),l=this.constructor._$Eu(n,r);if(l!==void 0&&r.reflect===!0){let o=(r.converter?.toAttribute!==void 0?r.converter:Y).toAttribute(e,r.type);this._$Em=n,o==null?this.removeAttribute(l):this.setAttribute(l,o),this._$Em=null}}_$AK(n,e){let r=this.constructor,l=r._$Eh.get(n);if(l!==void 0&&this._$Em!==l){let o=r.getPropertyOptions(l),_=typeof o.converter=="function"?{fromAttribute:o.converter}:o.converter?.fromAttribute!==void 0?o.converter:Y;this._$Em=l;let s=_.fromAttribute(e,o.type);this[l]=s??this._$Ej?.get(l)??s,this._$Em=null}}requestUpdate(n,e,r,l=!1,o){if(n!==void 0){let _=this.constructor;if(l===!1&&(o=this[n]),r??=_.getPropertyOptions(n),!((r.hasChanged??Z)(o,e)||r.useDefault&&r.reflect&&o===this._$Ej?.get(n)&&!this.hasAttribute(_._$Eu(n,r))))return;this.C(n,e,r)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(n,e,{useDefault:r,reflect:l,wrapped:o},_){r&&!(this._$Ej??=new Map).has(n)&&(this._$Ej.set(n,_??e??this[n]),o!==!0||_!==void 0)||(this._$AL.has(n)||(this.hasUpdated||r||(e=void 0),this._$AL.set(n,e)),l===!0&&this._$Em!==n&&(this._$Eq??=new Set).add(n))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}let n=this.scheduleUpdate();return n!=null&&await n,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[l,o]of this._$Ep)this[l]=o;this._$Ep=void 0}let r=this.constructor.elementProperties;if(r.size>0)for(let[l,o]of r){let{wrapped:_}=o,s=this[l];_!==!0||this._$AL.has(l)||s===void 0||this.C(l,void 0,o,s)}}let n=!1,e=this._$AL;try{n=this.shouldUpdate(e),n?(this.willUpdate(e),this._$EO?.forEach((r)=>r.hostUpdate?.()),this.update(e)):this._$EM()}catch(r){throw n=!1,this._$EM(),r}n&&this._$AE(e)}willUpdate(n){}_$AE(n){this._$EO?.forEach((e)=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(n)),this.updated(n)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(n){return!0}update(n){this._$Eq&&=this._$Eq.forEach((e)=>this._$ET(e,this[e])),this._$EM()}updated(n){}firstUpdated(n){}}u.elementStyles=[],u.shadowRootOptions={mode:"open"},u[F("elementProperties")]=new Map,u[F("finalized")]=new Map,zn?.({ReactiveElement:u}),(X.reactiveElementVersions??=[]).push("2.1.2");var z=globalThis,ln=(n)=>n,$=z.trustedTypes,on=$?$.createPolicy("lit-html",{createHTML:(n)=>n}):void 0;var a=`lit$${Math.random().toFixed(9).slice(2)}$`,xn="?"+a,Qn=`<${xn}>`,b=document,G=()=>b.createComment(""),J=(n)=>n===null||typeof n!="object"&&typeof n!="function",Q=Array.isArray,jn=(n)=>Q(n)||typeof n?.[Symbol.iterator]=="function";var q=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,_n=/-->/g,sn=/>/g,f=RegExp(`>|[ \f\r](?:([^\\s"'>=/]+)([ \f\r]*=[ \f\r]*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`,"g"),wn=/'/g,pn=/"/g,dn=/^(?:script|style|textarea|title)$/i,j=(n)=>(e,...r)=>({_$litType$:n,strings:e,values:r}),cn=j(1),xe=j(2),de=j(3),h=Symbol.for("lit-noChange"),x=Symbol.for("lit-nothing"),yn=new WeakMap,a=v.createTreeWalker(v,129);function mn(n,e){if(!Q(n)||!n.hasOwnProperty("raw"))throw Error("invalid template strings array");return on!==void 0?on.createHTML(e):e}var Hn=(n,e)=>{let r=n.length-1,l=[],o,_=e===2?"":e===3?"":"",s=G;for(let w=0;w"?(s=o??G,c=-1):p[1]===void 0?c=-2:(c=s.lastIndex-p[2].length,d=p[1],s=p[3]===void 0?b:p[3]==='"'?pn:wn):s===pn||s===wn?s=b:s===_n||s===sn?s=G:(s=b,o=void 0);let u=s===b&&n[w+1].startsWith("/>")?" ":"";_+=s===G?y+Qn:c>=0?(l.push(d),y.slice(0,c)+"$lit$"+y.slice(c)+i+u):y+i+(c===-2?w:u)}return[mn(n,_+(n[r]||"")+(e===2?"":e===3?"":"")),l]};class P{constructor({strings:n,_$litType$:e},r){let l;this.parts=[];let o=0,_=0,s=n.length-1,w=this.parts,[y,d]=Hn(n,e);if(this.el=P.createElement(y,r),a.currentNode=this.el.content,e===2||e===3){let p=this.el.content.firstChild;p.replaceWith(...p.childNodes)}for(;(l=a.nextNode())!==null&&w.length0){l.textContent=$?$.emptyScript:"";for(let m=0;m2||r[0]!==""||r[1]!==""?(this._$AH=Array(r.length-1).fill(new String),this.strings=r):this._$AH=x}_$AI(n,e=this,r,l){let o=this.strings,_=!1;if(o===void 0)n=t(this,n,e,0),_=!K(n)||n!==this._$AH&&n!==h,_&&(this._$AH=n);else{let s=n,w,y;for(n=o[0],w=0;w{let l=r?.renderBefore??e,o=l._$litPart$;if(o===void 0){let _=r?.renderBefore??null;l._$litPart$=o=new B(e.insertBefore(J(),_),_,void 0,r??{})}return o._$AI(n),o};var H=globalThis;class S extends f{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let n=super.createRenderRoot();return this.renderOptions.renderBefore??=n.firstChild,n}update(n){let e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(n),this._$Do=hn(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return h}}S._$litElement$=!0,S.finalized=!0,H.litElementHydrateSupport?.({LitElement:S});var Ln=H.litElementPolyfillSupport;Ln?.({LitElement:S});(H.litElementVersions??=[]).push("4.2.2");var Sn=(n)=>(e,r)=>{r!==void 0?r.addInitializer(()=>{customElements.define(n,e)}):customElements.define(n,e)};var tn=`from codeop import compile_command +\f\r"'\`<>=]|("|')|))|$)`,"g"),pn=/'/g,yn=/"/g,cn=/^(?:script|style|textarea|title)$/i,j=(n)=>(e,...r)=>({_$litType$:n,strings:e,values:r}),dn=j(1),ie=j(2),xe=j(3),h=Symbol.for("lit-noChange"),x=Symbol.for("lit-nothing"),wn=new WeakMap,t=b.createTreeWalker(b,129);function mn(n,e){if(!Q(n)||!n.hasOwnProperty("raw"))throw Error("invalid template strings array");return on!==void 0?on.createHTML(e):e}var kn=(n,e)=>{let r=n.length-1,l=[],o,_=e===2?"":e===3?"":"",s=q;for(let y=0;y"?(s=o??q,c=-1):p[1]===void 0?c=-2:(c=s.lastIndex-p[2].length,i=p[1],s=p[3]===void 0?f:p[3]==='"'?yn:pn):s===yn||s===pn?s=f:s===_n||s===sn?s=q:(s=f,o=void 0);let m=s===f&&n[y+1].startsWith("/>")?" ":"";_+=s===q?w+Qn:c>=0?(l.push(i),w.slice(0,c)+"$lit$"+w.slice(c)+a+m):w+a+(c===-2?y:m)}return[mn(n,_+(n[r]||"")+(e===2?"":e===3?"":"")),l]};class K{constructor({strings:n,_$litType$:e},r){let l;this.parts=[];let o=0,_=0,s=n.length-1,y=this.parts,[w,i]=kn(n,e);if(this.el=K.createElement(w,r),t.currentNode=this.el.content,e===2||e===3){let p=this.el.content.firstChild;p.replaceWith(...p.childNodes)}for(;(l=t.nextNode())!==null&&y.length0){l.textContent=$?$.emptyScript:"";for(let d=0;d2||r[0]!==""||r[1]!==""?(this._$AH=Array(r.length-1).fill(new String),this.strings=r):this._$AH=x}_$AI(n,e=this,r,l){let o=this.strings,_=!1;if(o===void 0)n=v(this,n,e,0),_=!J(n)||n!==this._$AH&&n!==h,_&&(this._$AH=n);else{let s=n,y,w;for(n=o[0],y=0;y{let l=r?.renderBefore??e,o=l._$litPart$;if(o===void 0){let _=r?.renderBefore??null;l._$litPart$=o=new O(e.insertBefore(G(),_),_,void 0,r??{})}return o._$AI(n),o};var k=globalThis;class S extends u{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let n=super.createRenderRoot();return this.renderOptions.renderBefore??=n.firstChild,n}update(n){let e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(n),this._$Do=hn(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return h}}S._$litElement$=!0,S.finalized=!0,k.litElementHydrateSupport?.({LitElement:S});var Ln=k.litElementPolyfillSupport;Ln?.({LitElement:S});(k.litElementVersions??=[]).push("4.2.2");var Sn=(n)=>(e,r)=>{r!==void 0?r.addInitializer(()=>{customElements.define(n,e)}):customElements.define(n,e)};var vn=`from codeop import compile_command import sys from _pyrepl.console import Console, Event from collections import deque @@ -140,12 +140,81 @@ class BrowserConsole(Console): pass +async def replay_script( + source, + browser_console, + repl_globals, + exec_with_redirect, + syntax_highlight, + PS1, + PS2, + history, +): + """Execute source line-by-line with REPL prompts and highlighting.""" + lines = source.splitlines() + i = 0 + while i < len(lines): + while i < len(lines) and not lines[i].strip(): + i += 1 + if i >= len(lines): + break + + buffer = [] + while i < len(lines): + line = lines[i] + i += 1 + + if not buffer and not line.strip(): + continue + + buffer.append(line) + source_so_far = "\\n".join(buffer) + + if len(buffer) == 1: + browser_console.term.write( + PS1 + syntax_highlight(line) + "\\r\\n" + ) + else: + browser_console.term.write( + PS2 + syntax_highlight(line) + "\\r\\n" + ) + + try: + code = compile_command(source_so_far, "", "single") + except (OverflowError, SyntaxError) as e: + browser_console.term.write( + f"\\x1b[31mSyntaxError: {e}\\x1b[0m\\r\\n" + ) + buffer = [] + break + + if code is None: + continue + + try: + exec_with_redirect(code, repl_globals) + except SystemExit: + pass + except Exception as e: + browser_console.term.write( + f"\\x1b[31m{type(e).__name__}: {e}\\x1b[0m\\r\\n" + ) + + if source_so_far.strip(): + history.append(source_so_far) + break + + return history + + async def start_repl(): # Create a new console for this terminal instance browser_console = BrowserConsole(js.term) - # Capture startup script before JS moves to next REPL and overwrites it + # Capture startup scripts before JS moves to next REPL and overwrites them startup_script = getattr(js, "pyreplStartupScript", None) + replay_script_content = getattr(js, "pyreplReplayScript", None) + replay_startup = getattr(js, "pyreplReplayStartup", False) theme_name = getattr(js, "pyreplTheme", "catppuccin-mocha") pygments_fallback = getattr(js, "pyreplPygmentsFallback", "catppuccin-mocha") info_line = getattr(js, "pyreplInfo", "Python (Pyodide)") @@ -210,8 +279,7 @@ async def start_repl(): except Exception as e: browser_console.term.write(f"[ERROR] Pygments load failed: {e}\\r\\n") - # Start loading Pygments in background (non-blocking) - asyncio.create_task(load_pygments()) + pygments_task = asyncio.create_task(load_pygments()) def syntax_highlight(code): if not code: @@ -277,10 +345,12 @@ async def start_repl(): } completer = rlcompleter.Completer(repl_globals) - # Run startup script if one was provided (silently, just to populate namespace) - if startup_script: + history = [] + history_index = 0 + + # Silent bootstrap script (:file: / src without replay) + if startup_script and not replay_startup: try: - # Temporarily suppress stdout/stderr during startup old_stdout, old_stderr = sys.stdout, sys.stderr sys.stdout = sys.stderr = type( "null", (), {"write": lambda s, x: None, "flush": lambda s: None} @@ -294,7 +364,6 @@ async def start_repl(): f"\\x1b[31mStartup script error - {type(e).__name__}: {e}\\x1b[0m\\r\\n" ) - # If startup script defined a setup() function, call it with output visible if "setup" in repl_globals and callable(repl_globals["setup"]): try: exec_with_redirect(compile("setup()", "", "exec"), repl_globals) @@ -303,6 +372,25 @@ async def start_repl(): f"\\x1b[31msetup() error - {type(e).__name__}: {e}\\x1b[0m\\r\\n" ) + # Replay script with interactive prompts + replay_source = replay_script_content + if replay_source is None and startup_script and replay_startup: + replay_source = startup_script + + if replay_source: + await pygments_task + history = await replay_script( + replay_source, + browser_console, + repl_globals, + exec_with_redirect, + syntax_highlight, + PS1, + PS2, + history, + ) + history_index = len(history) + def get_completions(text): """Get all completions for the given text.""" completions = [] @@ -328,9 +416,6 @@ async def start_repl(): lines = [] current_line = "" - history = [] - history_index = 0 - while True: event = await browser_console.get_event(block=True) if event is None: @@ -503,7 +588,7 @@ async def start_repl(): browser_console.term.write("\\r\\x1b[K") # Go to start, clear line prompt = PS1 if len(lines) == 0 else PS2 browser_console.term.write(prompt + syntax_highlight(current_line)) -`;var An=/^(https?:|emfs:)/i;function Rn(n,e=document.baseURI){if(An.test(n))return n;if(n.includes(" @ "))return n;if(!n.includes("/")&&!n.endsWith(".whl"))return n;return new URL(n,e).href}function En(n,e=document.baseURI){return n.map((r)=>Rn(r,e))}var k=null,L=null,F=Promise.resolve(),O={"catppuccin-mocha":{background:"#1e1e2e",foreground:"#cdd6f4",cursor:"#f5e0dc",cursorAccent:"#1e1e2e",selectionBackground:"#585b70",black:"#45475a",red:"#f38ba8",green:"#a6e3a1",yellow:"#f9e2af",blue:"#89b4fa",magenta:"#f5c2e7",cyan:"#94e2d5",white:"#bac2de",brightBlack:"#585b70",brightRed:"#f38ba8",brightGreen:"#a6e3a1",brightYellow:"#f9e2af",brightBlue:"#89b4fa",brightMagenta:"#f5c2e7",brightCyan:"#94e2d5",brightWhite:"#a6adc8",headerBackground:"#181825",headerForeground:"#6c7086"},"catppuccin-latte":{background:"#eff1f5",foreground:"#4c4f69",cursor:"#dc8a78",cursorAccent:"#eff1f5",selectionBackground:"#acb0be",black:"#5c5f77",red:"#d20f39",green:"#40a02b",yellow:"#df8e1d",blue:"#1e66f5",magenta:"#ea76cb",cyan:"#179299",white:"#acb0be",brightBlack:"#6c6f85",brightRed:"#d20f39",brightGreen:"#40a02b",brightYellow:"#df8e1d",brightBlue:"#1e66f5",brightMagenta:"#ea76cb",brightCyan:"#179299",brightWhite:"#bcc0cc",headerBackground:"#dce0e8",headerForeground:"#8c8fa1"}},E="catppuccin-mocha";function Jn(n){if(!n.startsWith("#"))return!0;let e=n.slice(1),r=parseInt(e.slice(0,2),16),l=parseInt(e.slice(2,4),16),o=parseInt(e.slice(4,6),16);return(0.299*r+0.587*l+0.114*o)/255<0.5}function Fn(n){if("black"in n&&"red"in n)return n;let e=Jn(n.background)?"catppuccin-mocha":"catppuccin-latte",r=O[e];return{cursor:r.cursor,cursorAccent:r.cursorAccent,selectionBackground:r.selectionBackground,black:r.black,red:r.red,green:r.green,yellow:r.yellow,blue:r.blue,magenta:r.magenta,cyan:r.cyan,white:r.white,brightBlack:r.brightBlack,brightRed:r.brightRed,brightGreen:r.brightGreen,brightYellow:r.brightYellow,brightBlue:r.brightBlue,brightMagenta:r.brightMagenta,brightCyan:r.brightCyan,brightWhite:r.brightWhite,background:n.background,foreground:n.foreground,headerBackground:n.headerBackground??r.headerBackground,headerForeground:n.headerForeground??r.headerForeground,promptColor:n.promptColor,pygmentsStyle:n.pygmentsStyle}}var Nn={copy:'',clear:''};function Un(n){let e,r,l=n.dataset.themeConfig;if(l)try{let s=JSON.parse(l);e=Fn(s),r="custom"}catch{console.warn("pyrepl-web: invalid data-theme-config JSON, falling back to default"),e=O[E],r=E}else{r=n.dataset.theme||E;let s=window.pyreplThemes?.[r]||O[r]||O[E];if(e=Fn(s),!window.pyreplThemes?.[r]&&!O[r])console.warn(`pyrepl-web: unknown theme "${r}", falling back to default`),r=E}let o=n.dataset.packages,_=o?o.split(",").map((s)=>s.trim()).filter(Boolean):[];return{theme:e,themeName:r,showHeader:n.dataset.header!=="false",showButtons:n.dataset.buttons!=="false",title:n.dataset.title||"python",packages:_,src:n.dataset.src||null,readonly:n.dataset.readonly==="true",showBanner:n.dataset.noBanner!=="true"}}async function Mn(){if(!k){let{loadPyodide:n}=await import("./chunk-es2qkm30.js");k=n({indexURL:"https://cdn.jsdelivr.net/pyodide/v314.0.1/full/",stdout:()=>{},stderr:()=>{}})}return await k}function gn(){if(!L)L=Promise.resolve(tn);return L}class A{container;theme;packages;readonly;src;showHeader;showButtons;title;showBanner;constructor(n){this.container=n.container,this.theme=n.theme||E,this.packages=n.packages||[],this.readonly=n.readonly||!1,this.src=n.src,this.showHeader=n.showHeader!==void 0?n.showHeader:!0,this.showButtons=n.showButtons!==void 0?n.showButtons:!0,this.title=n.title||"python",this.showBanner=n.showBanner!==void 0?n.showBanner:!0}async init(){if(this.container.dataset.theme=this.theme,this.container.dataset.packages=this.packages.join(","),this.container.dataset.readonly=this.readonly?"true":"false",this.src)this.container.dataset.src=this.src;this.container.dataset.header=this.showHeader?"true":"false",this.container.dataset.buttons=this.showButtons?"true":"false",this.container.dataset.title=this.title,this.container.dataset.noBanner=this.showBanner?"false":"true";let{term:n,config:e}=await Pn(this.container);F=F.then(()=>Bn(this.container,n,e)),await F}}function ne(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",qn);else qn()}function ee(){if(document.getElementById("pyrepl-styles"))return;let n=document.createElement("link");n.rel="stylesheet",n.href="https://cdn.jsdelivr.net/npm/@xterm/xterm/css/xterm.css",document.head.appendChild(n);let e=document.createElement("style");e.id="pyrepl-styles",e.textContent=` +`;var Rn=/^(https?:|emfs:)/i;function An(n,e=document.baseURI){if(Rn.test(n))return n;if(n.includes(" @ "))return n;if(!n.includes("/")&&!n.endsWith(".whl"))return n;return new URL(n,e).href}function En(n,e=document.baseURI){return n.map((r)=>An(r,e))}var H=null,L=null,N=Promise.resolve(),C={"catppuccin-mocha":{background:"#1e1e2e",foreground:"#cdd6f4",cursor:"#f5e0dc",cursorAccent:"#1e1e2e",selectionBackground:"#585b70",black:"#45475a",red:"#f38ba8",green:"#a6e3a1",yellow:"#f9e2af",blue:"#89b4fa",magenta:"#f5c2e7",cyan:"#94e2d5",white:"#bac2de",brightBlack:"#585b70",brightRed:"#f38ba8",brightGreen:"#a6e3a1",brightYellow:"#f9e2af",brightBlue:"#89b4fa",brightMagenta:"#f5c2e7",brightCyan:"#94e2d5",brightWhite:"#a6adc8",headerBackground:"#181825",headerForeground:"#6c7086"},"catppuccin-latte":{background:"#eff1f5",foreground:"#4c4f69",cursor:"#dc8a78",cursorAccent:"#eff1f5",selectionBackground:"#acb0be",black:"#5c5f77",red:"#d20f39",green:"#40a02b",yellow:"#df8e1d",blue:"#1e66f5",magenta:"#ea76cb",cyan:"#179299",white:"#acb0be",brightBlack:"#6c6f85",brightRed:"#d20f39",brightGreen:"#40a02b",brightYellow:"#df8e1d",brightBlue:"#1e66f5",brightMagenta:"#ea76cb",brightCyan:"#179299",brightWhite:"#bcc0cc",headerBackground:"#dce0e8",headerForeground:"#8c8fa1"}},E="catppuccin-mocha";function Gn(n){if(!n.startsWith("#"))return!0;let e=n.slice(1),r=parseInt(e.slice(0,2),16),l=parseInt(e.slice(2,4),16),o=parseInt(e.slice(4,6),16);return(0.299*r+0.587*l+0.114*o)/255<0.5}function Nn(n){if("black"in n&&"red"in n)return n;let e=Gn(n.background)?"catppuccin-mocha":"catppuccin-latte",r=C[e];return{cursor:r.cursor,cursorAccent:r.cursorAccent,selectionBackground:r.selectionBackground,black:r.black,red:r.red,green:r.green,yellow:r.yellow,blue:r.blue,magenta:r.magenta,cyan:r.cyan,white:r.white,brightBlack:r.brightBlack,brightRed:r.brightRed,brightGreen:r.brightGreen,brightYellow:r.brightYellow,brightBlue:r.brightBlue,brightMagenta:r.brightMagenta,brightCyan:r.brightCyan,brightWhite:r.brightWhite,background:n.background,foreground:n.foreground,headerBackground:n.headerBackground??r.headerBackground,headerForeground:n.headerForeground??r.headerForeground,promptColor:n.promptColor,pygmentsStyle:n.pygmentsStyle}}var Pn={copy:'',clear:''};function Un(n){let e,r,l=n.dataset.themeConfig;if(l)try{let s=JSON.parse(l);e=Nn(s),r="custom"}catch{console.warn("pyrepl-web: invalid data-theme-config JSON, falling back to default"),e=C[E],r=E}else{r=n.dataset.theme||E;let s=window.pyreplThemes?.[r]||C[r]||C[E];if(e=Nn(s),!window.pyreplThemes?.[r]&&!C[r])console.warn(`pyrepl-web: unknown theme "${r}", falling back to default`),r=E}let o=n.dataset.packages,_=o?o.split(",").map((s)=>s.trim()).filter(Boolean):[];return{theme:e,themeName:r,showHeader:n.dataset.header!=="false",showButtons:n.dataset.buttons!=="false",title:n.dataset.title||"python",packages:_,src:n.dataset.src||null,replaySrc:n.dataset.replaySrc||null,replayStartup:n.dataset.replay==="true",readonly:n.dataset.readonly==="true",showBanner:n.dataset.noBanner!=="true"}}async function Mn(){if(!H){let{loadPyodide:n}=await import("./chunk-cnc6ympw.js");H=n({indexURL:"https://cdn.jsdelivr.net/pyodide/v314.0.1/full/",stdout:()=>{},stderr:()=>{}})}return await H}function gn(){if(!L)L=Promise.resolve(vn);return L}class R{container;theme;packages;readonly;src;replaySrc;replayStartup;showHeader;showButtons;title;showBanner;constructor(n){this.container=n.container,this.theme=n.theme||E,this.packages=n.packages||[],this.readonly=n.readonly||!1,this.src=n.src,this.replaySrc=n.replaySrc,this.replayStartup=n.replayStartup||!1,this.showHeader=n.showHeader!==void 0?n.showHeader:!0,this.showButtons=n.showButtons!==void 0?n.showButtons:!0,this.title=n.title||"python",this.showBanner=n.showBanner!==void 0?n.showBanner:!0}async init(){if(this.container.dataset.theme=this.theme,this.container.dataset.packages=this.packages.join(","),this.container.dataset.readonly=this.readonly?"true":"false",this.src)this.container.dataset.src=this.src;if(this.replaySrc)this.container.dataset.replaySrc=this.replaySrc;this.container.dataset.replay=this.replayStartup?"true":"false",this.container.dataset.header=this.showHeader?"true":"false",this.container.dataset.buttons=this.showButtons?"true":"false",this.container.dataset.title=this.title,this.container.dataset.noBanner=this.showBanner?"false":"true";let{term:n,config:e}=await Kn(this.container);N=N.then(()=>On(this.container,n,e)),await N}}function ne(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",Fn);else Fn()}function ee(){if(document.getElementById("pyrepl-styles"))return;let n=document.createElement("link");n.rel="stylesheet",n.href="https://cdn.jsdelivr.net/npm/@xterm/xterm/css/xterm.css",document.head.appendChild(n);let e=document.createElement("style");e.id="pyrepl-styles",e.textContent=` .pyrepl { display: inline-block; position: relative; @@ -597,14 +682,16 @@ async def start_repl(): scrollbar-width: none; background-color: var(--pyrepl-bg) !important; } - `,document.head.appendChild(e)}function re(n,e){let r=e.headerBackground||e.black,l=e.headerForeground||e.brightBlack;n.style.setProperty("--pyrepl-bg",e.background),n.style.setProperty("--pyrepl-header-bg",r),n.style.setProperty("--pyrepl-header-title",l),n.style.setProperty("--pyrepl-red",e.red),n.style.setProperty("--pyrepl-yellow",e.yellow),n.style.setProperty("--pyrepl-green",e.green),n.style.setProperty("--pyrepl-shadow","0 4px 24px rgba(0, 0, 0, 0.3)")}function Kn(){let n=document.createElement("div");return n.className="pyrepl-header-buttons",n.innerHTML=` - - + `,document.head.appendChild(e)}function re(n,e){let r=e.headerBackground||e.black,l=e.headerForeground||e.brightBlack;n.style.setProperty("--pyrepl-bg",e.background),n.style.setProperty("--pyrepl-header-bg",r),n.style.setProperty("--pyrepl-header-title",l),n.style.setProperty("--pyrepl-red",e.red),n.style.setProperty("--pyrepl-yellow",e.yellow),n.style.setProperty("--pyrepl-green",e.green),n.style.setProperty("--pyrepl-shadow","0 4px 24px rgba(0, 0, 0, 0.3)")}function Jn(){let n=document.createElement("div");return n.className="pyrepl-header-buttons",n.innerHTML=` + + `,n}function le(n){let e=document.createElement("div");e.className="pyrepl-header";let r=document.createElement("div");r.className="pyrepl-header-dots",r.innerHTML=`
- `;let l=document.createElement("div");if(l.className="pyrepl-header-title",l.textContent=n.title,e.appendChild(r),e.appendChild(l),n.showButtons)e.appendChild(Kn());else{let o=document.createElement("div");o.style.width="48px",e.appendChild(o)}return e}async function Pn(n){ee();let e=Un(n);if(re(n,e.theme),e.showHeader)n.appendChild(le(e));else if(e.showButtons){let _=Kn();_.classList.add("pyrepl-floating-buttons"),n.classList.add("pyrepl--floating-buttons"),n.appendChild(_)}let r=await import("./chunk-jfxv8vy0.js"),l=document.createElement("div");n.appendChild(l);let o=new r.Terminal({cursorBlink:!e.readonly,cursorStyle:e.readonly?"bar":"block",fontSize:14,fontFamily:"monospace",theme:e.theme,disableStdin:e.readonly});return o.open(l),{term:o,config:e}}async function Bn(n,e,r){let l=await Mn();if(await l.loadPackage("micropip"),r.packages.length>0)await l.pyimport("micropip").install(En(r.packages));let o=r.packages.length>0?` (installed packages: ${r.packages.join(", ")})`:"",s=`${await l.runPythonAsync("import sys; sys.version.split()[0]")}${o}`;if(r.showBanner)e.write(`\x1B[90m${s}\x1B[0m\r -`);if(globalThis.term=e,globalThis.pyreplTheme=r.themeName,globalThis.pyreplPygmentsFallback=Jn(r.theme.background)?"catppuccin-mocha":"catppuccin-latte",globalThis.pyreplInfo=s,globalThis.pyreplReadonly=r.readonly,globalThis.pyreplPromptColor=r.theme.promptColor||"green",globalThis.pyreplPygmentsStyle=r.theme.pygmentsStyle,globalThis.pyreplBanner=r.showBanner,r.src)try{let d=await fetch(r.src);if(d.ok)globalThis.pyreplStartupScript=await d.text();else console.warn(`pyrepl-web: failed to fetch script from ${r.src}`)}catch(d){console.warn(`pyrepl-web: error fetching script from ${r.src}`,d)}let w=await gn();l.runPython(w),l.runPythonAsync("await start_repl()");while(!globalThis.currentBrowserConsole)await new Promise((d)=>setTimeout(d,10));let y=globalThis.currentBrowserConsole;if(globalThis.currentBrowserConsole=null,globalThis.term=null,globalThis.pyreplStartupScript=void 0,globalThis.pyreplTheme="",globalThis.pyreplPygmentsFallback="",globalThis.pyreplInfo="",globalThis.pyreplReadonly=!1,globalThis.pyreplPromptColor="",globalThis.pyreplPygmentsStyle=void 0,globalThis.pyreplBanner=!1,!r.readonly)e.onData((d)=>{for(let p of d)y.push_char(p.charCodeAt(0))});if(r.showButtons){let d=n.querySelector('[data-action="copy"]'),p=n.querySelector('[data-action="clear"]');d?.addEventListener("click",()=>{let c=e.buffer.active,m="";for(let u=0;u{if(e.reset(),r.showBanner)e.write(`\x1B[90m${s}\x1B[0m\r -`);e.write("\x1B[32m>>> \x1B[0m")})}}async function qn(){let n=document.querySelectorAll(".pyrepl"),e=Array.from(n).filter((l)=>!l.closest("py-repl")&&!l.dataset.pyreplInitialized);if(e.length===0)return;for(let l of e)l.dataset.pyreplInitialized="true";let r=await Promise.all(Array.from(e).map(async(l)=>({container:l,...await Pn(l)})));for(let{container:l,term:o,config:_}of r)F=F.then(()=>Bn(l,o,_));await F}ne();var pr=[Sn("py-repl")],wr=S,oe=Cn(wr);class N extends wr{static properties={theme:{type:String},packages:{type:String},replTitle:{type:String,attribute:"repl-title"},noBanner:{type:Boolean,attribute:"no-banner"},isReadonly:{type:Boolean,attribute:"readonly"},noButtons:{type:Boolean,attribute:"no-buttons"},noHeader:{type:Boolean,attribute:"no-header"},src:{type:String}};constructor(){super();this.theme="catppuccin-mocha",this.packages="",this.replTitle="Python REPL",this.noBanner=!1,this.isReadonly=!1,this.noButtons=!1,this.noHeader=!1,this.src=""}createRenderRoot(){return this}firstUpdated(n){super.firstUpdated(n);let e=this.querySelector(".pyrepl");if(!e){console.error("pyrepl-web: .pyrepl container not found in ");return}e.dataset.pyreplInitialized="true",new A({container:e,theme:this.theme,packages:this.packages.split(",").map((l)=>l.trim()).filter((l)=>l.length>0),readonly:this.isReadonly,src:this.src||void 0,showHeader:!this.noHeader,showButtons:!this.noButtons,title:this.replTitle,showBanner:!this.noBanner}).init()}render(){return cn`
`}}N=Wn(oe,0,"PyRepl",pr,N),In(oe,1,N),On(oe,N);let _PyRepl=N;export{N as PyRepl}; + `;let l=document.createElement("div");if(l.className="pyrepl-header-title",l.textContent=n.title,e.appendChild(r),e.appendChild(l),n.showButtons)e.appendChild(Jn());else{let o=document.createElement("div");o.style.width="48px",e.appendChild(o)}return e}async function Kn(n){ee();let e=Un(n);if(re(n,e.theme),e.showHeader)n.appendChild(le(e));else if(e.showButtons){let _=Jn();_.classList.add("pyrepl-floating-buttons"),n.classList.add("pyrepl--floating-buttons"),n.appendChild(_)}let r=await import("./chunk-jfxv8vy0.js"),l=document.createElement("div");n.appendChild(l);let o=new r.Terminal({cursorBlink:!e.readonly,cursorStyle:e.readonly?"bar":"block",fontSize:14,fontFamily:"monospace",theme:e.theme,disableStdin:e.readonly});return o.open(l),{term:o,config:e}}async function On(n,e,r){let l=await Mn();if(await l.loadPackage("micropip"),r.packages.length>0)await l.pyimport("micropip").install(En(r.packages));let o=r.packages.length>0?` (installed packages: ${r.packages.join(", ")})`:"",s=`${await l.runPythonAsync("import sys; sys.version.split()[0]")}${o}`;if(r.showBanner)e.write(`\x1B[90m${s}\x1B[0m\r +`);if(globalThis.term=e,globalThis.pyreplTheme=r.themeName,globalThis.pyreplPygmentsFallback=Gn(r.theme.background)?"catppuccin-mocha":"catppuccin-latte",globalThis.pyreplInfo=s,globalThis.pyreplReadonly=r.readonly,globalThis.pyreplPromptColor=r.theme.promptColor||"green",globalThis.pyreplPygmentsStyle=r.theme.pygmentsStyle,globalThis.pyreplBanner=r.showBanner,r.src)try{let i=await fetch(r.src);if(i.ok)globalThis.pyreplStartupScript=await i.text();else console.warn(`pyrepl-web: failed to fetch script from ${r.src}`)}catch(i){console.warn(`pyrepl-web: error fetching script from ${r.src}`,i)}if(r.replaySrc)try{let i=await fetch(r.replaySrc);if(i.ok)globalThis.pyreplReplayScript=await i.text();else{let p=`pyrepl-web: failed to fetch replay script from ${r.replaySrc}`;console.warn(p),e.write(`\x1B[31m${p}\x1B[0m\r +`)}}catch(i){let p=`pyrepl-web: error fetching replay script from ${r.replaySrc}`;console.warn(p,i),e.write(`\x1B[31m${p}\x1B[0m\r +`)}globalThis.pyreplReplayStartup=r.replayStartup;let y=await gn();l.runPython(y),l.runPythonAsync("await start_repl()");while(!globalThis.currentBrowserConsole)await new Promise((i)=>setTimeout(i,10));let w=globalThis.currentBrowserConsole;if(globalThis.currentBrowserConsole=null,globalThis.term=null,globalThis.pyreplStartupScript=void 0,globalThis.pyreplReplayScript=void 0,globalThis.pyreplReplayStartup=!1,globalThis.pyreplTheme="",globalThis.pyreplPygmentsFallback="",globalThis.pyreplInfo="",globalThis.pyreplReadonly=!1,globalThis.pyreplPromptColor="",globalThis.pyreplPygmentsStyle=void 0,globalThis.pyreplBanner=!1,!r.readonly)e.onData((i)=>{for(let p of i)w.push_char(p.charCodeAt(0))});if(r.showButtons){let i=n.querySelector('[data-action="copy"]'),p=n.querySelector('[data-action="clear"]');i?.addEventListener("click",()=>{let c=e.buffer.active,d="";for(let m=0;m{if(e.reset(),r.showBanner)e.write(`\x1B[90m${s}\x1B[0m\r +`);e.write("\x1B[32m>>> \x1B[0m")})}}async function Fn(){let n=document.querySelectorAll(".pyrepl"),e=Array.from(n).filter((l)=>!l.closest("py-repl")&&!l.dataset.pyreplInitialized);if(e.length===0)return;for(let l of e)l.dataset.pyreplInitialized="true";let r=await Promise.all(Array.from(e).map(async(l)=>({container:l,...await Kn(l)})));for(let{container:l,term:o,config:_}of r)N=N.then(()=>On(l,o,_));await N}ne();var yr=[Sn("py-repl")],pr=S,oe=Bn(pr);class P extends pr{static properties={theme:{type:String},packages:{type:String},replTitle:{type:String,attribute:"repl-title"},noBanner:{type:Boolean,attribute:"no-banner"},isReadonly:{type:Boolean,attribute:"readonly"},noButtons:{type:Boolean,attribute:"no-buttons"},noHeader:{type:Boolean,attribute:"no-header"},src:{type:String},replaySrc:{type:String,attribute:"replay-src"},replayStartup:{type:Boolean,attribute:"replay"}};constructor(){super();this.theme="catppuccin-mocha",this.packages="",this.replTitle="Python REPL",this.noBanner=!1,this.isReadonly=!1,this.noButtons=!1,this.noHeader=!1,this.src="",this.replaySrc="",this.replayStartup=!1}createRenderRoot(){return this}firstUpdated(n){super.firstUpdated(n);let e=this.querySelector(".pyrepl");if(!e){console.error("pyrepl-web: .pyrepl container not found in ");return}e.dataset.pyreplInitialized="true",new R({container:e,theme:this.theme,packages:this.packages.split(",").map((l)=>l.trim()).filter((l)=>l.length>0),readonly:this.isReadonly,src:this.src||void 0,replaySrc:this.replaySrc||void 0,replayStartup:this.replayStartup,showHeader:!this.noHeader,showButtons:!this.noButtons,title:this.replTitle,showBanner:!this.noBanner}).init()}render(){return dn`
`}}P=Wn(oe,0,"PyRepl",yr,P),In(oe,1,P),Cn(oe,P);let _PyRepl=P;export{P as PyRepl}; diff --git a/sphinx_pyrepl_web/pyrepl/version.txt b/sphinx_pyrepl_web/pyrepl/version.txt index 8d18685..5e915e6 100644 --- a/sphinx_pyrepl_web/pyrepl/version.txt +++ b/sphinx_pyrepl_web/pyrepl/version.txt @@ -1 +1 @@ -cursor/resolve-package-urls-0bbd@c21baf4d73d347181d346b1db30384540e80ee5b +cursor/resolve-package-urls-0bbd@1d95c619c6251475f41ea20eec631c45903b1498 From b773213074533afe0fdc084ae279b43a2cf1045d Mon Sep 17 00:00:00 2001 From: chrizzftd Date: Thu, 2 Jul 2026 19:56:58 +1000 Subject: [PATCH 04/16] autodoc packages via wheel config (#17) * Config-driven autodoc packages via wheel path Replace module introspection and silent :src: bootstrap for autodoc REPLs with a single pyrepl_autodoc_packages config value. Remove pyrepl_autodoc_bootstrap. --------- Co-authored-by: Cursor Agent Co-authored-by: chrizzftd --- README.md | 25 ++- .../pyrepl_test_pkg-1.0.0-py3-none-any.whl | Bin 1115 -> 1451 bytes docs/conf.py | 5 +- docs/example.rst | 27 ++- pyproject.toml | 1 + scripts/build_test_pkg_wheel.py | 59 +++++ sphinx_pyrepl_web/__init__.py | 137 ++++++------ .../pyrepl_test_pkg/pyrepl_test_pkg/demo.py | 1 + .../pyrepl_test_pkg-1.0.0-py3-none-any.whl | Bin 1115 -> 1451 bytes tests/test_autodoc_bootstrap.py | 210 ++++++++++-------- tests/test_autodoc_bootstrap_source.py | 29 +++ tests/test_autodoc_include.py | 3 +- 12 files changed, 313 insertions(+), 184 deletions(-) create mode 100644 scripts/build_test_pkg_wheel.py rename docs/_static/autodoc_demo.py => tests/fixtures/pyrepl_test_pkg/pyrepl_test_pkg/demo.py (99%) create mode 100644 tests/test_autodoc_bootstrap_source.py diff --git a/README.md b/README.md index f281a66..c9353e7 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Optional Sphinx config: ```python pyrepl_js = "../pyrepl.js" # default; path to the pyrepl-web loader script pyrepl_doctest_blocks = False # default; see Docstring conversion below -pyrepl_autodoc_bootstrap = True # default; silent :src: bootstrap for autodoc REPLs +pyrepl_autodoc_packages = None # optional; wheel path or PyPI name for autodoc REPLs ``` ### Docstring conversion @@ -82,6 +82,7 @@ extensions = [ "sphinx_pyrepl_web", ] pyrepl_doctest_blocks = "autodoc" +pyrepl_autodoc_packages = "_static/wheels/my_package-1.0.0-py3-none-any.whl" ``` | | `pyrepl_doctest_blocks` options | @@ -91,10 +92,24 @@ pyrepl_doctest_blocks = "autodoc" | `"all"` | Transform every doctest block found | -| | `pyrepl_autodoc_bootstrap` options | -|------------------|------------------------------------------------------------------------------| -| `True` (default) | Bootstrap REPL: in-tree modules via silent `:src:` only | -| `False` | Replay doctest input only; documented names are not pre-defined | +| | `pyrepl_autodoc_packages` options | +|-------------------------|------------------------------------------------------------------| +| unset / `None` / `""` | Replay doctest input only (no wheel install or auto-import) | +| wheel path or PyPI name | Install the package and import the documented object before replay (comma-separated) | + +Autodoc integration assumes a single documented package. The wheel (or PyPI +name) is installed in the browser REPL and the documented name is imported +automatically so doctest examples can use unqualified names. Autodoc still +imports the package on the host at build time. + +To build this project's docs locally: + +```bash +pip install -e ".[docs]" +``` + +The `[docs]` extra installs doc build dependencies plus the `pyrepl_test_pkg` +fixture used in the examples. ### Local Pyodide wheels diff --git a/docs/_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl b/docs/_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl index 527687e7961e7f657de0a858b95a7e3b29c7f67f..d8a2685440b2e115ab04f17e457d212d248abd3b 100644 GIT binary patch delta 665 zcmcc3v6`DVz?+#xgn@y9gW;~{!->2z>r4C|#>k$WF?A(SkeQKzK^#aIR2HQcS#uvU5V-eQyU#~gfXO^-|iaF{0<-WA^ z>nH5T3;W+6w6tHOxO! zs6zT;qTQtRF|`R3Cf56%Hag(Fc;W{EwZsOI09E7YmmTrGW}9~{D%ZHoo_qVylg-sv zUpt)){P*MC>xKU&{wigj&9;sA4coROGpjil*+hOg)H~zzCi9JRq8K^P`SF!A0DaUk z`9Gt6{m!B$2L^@<_wpmBus+oYYS=xIAx@`hNuogDgly(+rug|L=TdU!T(`F};na1S zwnb;j&r-3r$f+9*=3HXjlBuGzgx{~_^QM{~bC=!^x@djs_T^c}zVmV=nW($=?rqXi zTl%|oXN?yGE*@GvQPiwR8pHO(Wcz@UK6SB%T z6_?vEOn83(S@xSOkK(P*>zD6Yw{+W=7p1!||597K#^!+1Zf});hSE!4m31AM-4T71 zf$P%k`v*_2ZDyG9hlxMHn~_O`86NeM`Iy}VG2(u5DzgF~EV>~&CN7ZT0VWABSkhR< zI606}Y4S8?eI{WhAX7^R*<1z&MGttW14CX7rV&UlX*>qfpfuT@g^!Pwft8^fsDO(F Gqyhk5oC!n# delta 361 zcmZ3@eVc3%)+>9OP|$f zxi9ECGv~4RVW)3jZ>hgM^;d6A#r_*I*NxuWWy}h;UX!P@ZpmZMpDS}3F4QRfpUyVx zkbV5pOKy?#^EaoQobms}%N2z$=50>&QC)M(bhkr?Rh=^XE}q48J0foD>`L!eu;~&} z;F$j`TTIYP>C+Eu&e)(IJLF^bt2zf?(Vl4JU2buve$TO$x7Q{+^-I0CbWp4N#r~#L zIDkQIe}Fe5lL#~1-;*U-+$Jw!k(*r0B0SlFNpSKr7JVjD#>x6jS~|!|85k5j;6cO4 hz@P>UA{bcG_yVLsX>va+A0G None: + print(f"+ {' '.join(cmd)}") + subprocess.run(cmd, cwd=cwd, check=True) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--keep-dist", + action="store_true", + help="Leave the build dist/ directory in the fixture package tree", + ) + args = parser.parse_args() + + dist_dir = PKG_DIR / "dist" + if dist_dir.exists(): + shutil.rmtree(dist_dir) + + run([sys.executable, "-m", "pip", "wheel", ".", "-w", "dist"], cwd=PKG_DIR) + + built = dist_dir / WHEEL_NAME + if not built.is_file(): + wheels = sorted(dist_dir.glob("*.whl")) + if len(wheels) != 1: + sys.exit(f"expected one wheel in {dist_dir}, found: {wheels!r}") + built = wheels[0] + + for dest_dir in DEST_DIRS: + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / WHEEL_NAME + shutil.copy2(built, dest) + print(f"copied {built.name} -> {dest}") + + if not args.keep_dist: + shutil.rmtree(dist_dir) + + +if __name__ == "__main__": + main() diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index 26af36f..881a4d7 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -2,12 +2,9 @@ __version__ = "0.2.0" -import importlib -import inspect import json from doctest import DocTestParser from pathlib import Path -import sys from docutils import nodes from docutils.parsers.rst import directives @@ -20,6 +17,7 @@ PYREPL_DIR = Path(__file__).parent / "pyrepl" STARTUP_FILES_KEY = "pyrepl-startup-files" REPLAY_FILES_KEY = "pyrepl-replay-files" +BOOTSTRAP_FILES_KEY = "pyrepl-bootstrap-files" _DOCTEST_PARSER = DocTestParser() logger = logging.getLogger(__name__) @@ -28,7 +26,7 @@ def setup(app: Sphinx): """Setup the extension.""" app.add_config_value("pyrepl_js", "../pyrepl.js", "env") app.add_config_value("pyrepl_doctest_blocks", False, "env") - app.add_config_value("pyrepl_autodoc_bootstrap", True, "env") + app.add_config_value("pyrepl_autodoc_packages", None, "env") app.add_directive("py-repl", PyRepl) app.connect("doctree-read", doctree_read) app.connect("doctree-read", transform_doctest_blocks) @@ -59,8 +57,8 @@ def register_autodoc_repl( env, docname: str, replay_text: str, -) -> str: - """Record a replay script in env metadata and return its replay-src path.""" +) -> tuple[str, str]: + """Record a replay script in env metadata and return (replay-src path, name).""" replay_files = json.loads( env.metadata[docname].setdefault(REPLAY_FILES_KEY, "{}") ) @@ -68,27 +66,48 @@ def register_autodoc_repl( replay_name = f"{docname.replace('/', '-')}-{counter}.py" replay_files[replay_name] = replay_text env.metadata[docname][REPLAY_FILES_KEY] = json.dumps(replay_files) - return f"_static/pyrepl/{replay_name}" + return f"_static/pyrepl/{replay_name}", replay_name -def register_startup_file(env, docname: str, path: Path) -> str: - """Track a startup script under srcdir for copying into HTML output.""" - env.note_dependency(path) - rel_src = path.relative_to(Path(env.srcdir)).as_posix() - startup_files = json.loads( - env.metadata[docname].setdefault(STARTUP_FILES_KEY, "[]") +def register_autodoc_bootstrap( + env, + docname: str, + bootstrap_text: str, + replay_name: str, +) -> str: + """Record a silent bootstrap script paired with a replay file.""" + bootstrap_files = json.loads( + env.metadata[docname].setdefault(BOOTSTRAP_FILES_KEY, "{}") ) - abs_path = str(path.resolve()) - if abs_path not in startup_files: - startup_files.append(abs_path) - env.metadata[docname][STARTUP_FILES_KEY] = json.dumps(startup_files) - return rel_src + bootstrap_name = replay_name.replace(".py", "-bootstrap.py") + bootstrap_files[bootstrap_name] = bootstrap_text + env.metadata[docname][BOOTSTRAP_FILES_KEY] = json.dumps(bootstrap_files) + return f"_static/pyrepl/{bootstrap_name}" + + +def autodoc_bootstrap_source( + module: str | None, + fullname: str | None, + objtype: str | None, +) -> str | None: + """Return a silent import script for the documented autodoc object.""" + if not module: + return None + + if objtype == "module" or not fullname: + return f"import {module}\n" + + if "." in fullname: + root = fullname.split(".", 1)[0] + return f"from {module} import {root}\n" + + return f"from {module} import {fullname}\n" def make_pyrepl_raw( replay_src: str, - src: str | None = None, packages: str | None = None, + src: str | None = None, ) -> nodes.raw: """Build a raw HTML node for an autodoc doctest replay widget.""" attrs = ["no-header", "no-banner", f'replay-src="{replay_src}"'] @@ -110,51 +129,9 @@ def _find_autodoc_desc(node: nodes.Node) -> addnodes.desc | None: return None -def _resolve_autodoc_bootstrap( - app: Sphinx, env, docname: str, desc: addnodes.desc -) -> tuple[str | None, str | None]: - """Return (startup src path, packages) for autodoc REPLs. - - Only modules whose source lives under the Sphinx source directory are - bootstrapped via silent ``:src:``. Installed or out-of-tree modules are - left without bootstrap; use explicit ``:packages:`` wheel paths instead. - """ - if not app.config.pyrepl_autodoc_bootstrap: - return None, None - - sig = desc.next_node(addnodes.desc_signature) - if sig is None: - return None, None - - module_name = sig.get("module") - fullname = sig.get("fullname") - if not module_name: - return None, None - - target = f"{module_name}.{fullname}" if fullname else module_name - try: - mod = sys.modules.get(module_name) - if mod is None: - mod = importlib.import_module(module_name) - obj = mod - if fullname: - for part in fullname.split("."): - obj = getattr(obj, part) - mod_obj = inspect.getmodule(obj) or mod - source_path = Path(inspect.getfile(mod_obj)).resolve() - srcdir = Path(env.srcdir).resolve() - try: - source_path.relative_to(srcdir) - return register_startup_file(env, docname, source_path), None - except ValueError: - return None, None - except (AttributeError, ImportError, OSError, TypeError) as exc: - logger.error( - "Could not bootstrap autodoc REPL for %s: %s", - target, - exc, - ) - return None, None +def _autodoc_packages(app: Sphinx) -> str | None: + """Return configured package preload for autodoc doctest REPLs.""" + return app.config.pyrepl_autodoc_packages or None def _inside_autodoc_desc(node: nodes.Node) -> bool: @@ -177,15 +154,27 @@ def transform_doctest_blocks(app: Sphinx, doctree: nodes.document): source = doctest_to_replay_source(node.astext()) if not source.strip(): continue - bootstrap_src = None packages = None + bootstrap_src = None desc = _find_autodoc_desc(node) if desc is not None: - bootstrap_src, packages = _resolve_autodoc_bootstrap( - app, env, docname, desc - ) - replay_src = register_autodoc_repl(env, docname, source) - node.replace_self(make_pyrepl_raw(replay_src, bootstrap_src, packages)) + packages = _autodoc_packages(app) + replay_src, replay_name = register_autodoc_repl(env, docname, source) + if desc is not None and packages: + sig = desc.next_node(addnodes.desc_signature) + if sig is not None: + bootstrap_text = autodoc_bootstrap_source( + sig.get("module"), + sig.get("fullname"), + desc.get("objtype"), + ) + if bootstrap_text: + bootstrap_src = register_autodoc_bootstrap( + env, docname, bootstrap_text, replay_name + ) + node.replace_self( + make_pyrepl_raw(replay_src, src=bootstrap_src, packages=packages) + ) replaced = True if replaced: @@ -258,7 +247,7 @@ def run(self): if has_body: body_text = doctest_to_replay_source(list(self.content)) - replay_src = register_autodoc_repl(env, env.docname, body_text) + replay_src, _ = register_autodoc_repl(env, env.docname, body_text) attrs.append(f'replay-src="{replay_src}"') self.env.metadata[self.env.docname]["pyrepl"] = True @@ -303,6 +292,12 @@ def copy_asset_files(app, _): for name, content in replay_files.items(): (replay_dest / name).write_text(content, encoding="utf-8") + raw_bootstrap = metadata.get(BOOTSTRAP_FILES_KEY) + if raw_bootstrap: + bootstrap_files = json.loads(raw_bootstrap) + for name, content in bootstrap_files.items(): + (replay_dest / name).write_text(content, encoding="utf-8") + srcdir = Path(app.builder.srcdir) copied = set() for docname, metadata in app.env.metadata.items(): diff --git a/docs/_static/autodoc_demo.py b/tests/fixtures/pyrepl_test_pkg/pyrepl_test_pkg/demo.py similarity index 99% rename from docs/_static/autodoc_demo.py rename to tests/fixtures/pyrepl_test_pkg/pyrepl_test_pkg/demo.py index 09f42fb..dbc19f6 100644 --- a/docs/_static/autodoc_demo.py +++ b/tests/fixtures/pyrepl_test_pkg/pyrepl_test_pkg/demo.py @@ -1,5 +1,6 @@ """Demo module for autodoc doctest REPL integration.""" + def example_generator(n): """Generators yield values useful for iteration. diff --git a/tests/fixtures/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl b/tests/fixtures/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl index 527687e7961e7f657de0a858b95a7e3b29c7f67f..d8a2685440b2e115ab04f17e457d212d248abd3b 100644 GIT binary patch delta 665 zcmcc3v6`DVz?+#xgn@y9gW;~{!->2z>r4C|#>k$WF?A(SkeQKzK^#aIR2HQcS#uvU5V-eQyU#~gfXO^-|iaF{0<-WA^ z>nH5T3;W+6w6tHOxO! zs6zT;qTQtRF|`R3Cf56%Hag(Fc;W{EwZsOI09E7YmmTrGW}9~{D%ZHoo_qVylg-sv zUpt)){P*MC>xKU&{wigj&9;sA4coROGpjil*+hOg)H~zzCi9JRq8K^P`SF!A0DaUk z`9Gt6{m!B$2L^@<_wpmBus+oYYS=xIAx@`hNuogDgly(+rug|L=TdU!T(`F};na1S zwnb;j&r-3r$f+9*=3HXjlBuGzgx{~_^QM{~bC=!^x@djs_T^c}zVmV=nW($=?rqXi zTl%|oXN?yGE*@GvQPiwR8pHO(Wcz@UK6SB%T z6_?vEOn83(S@xSOkK(P*>zD6Yw{+W=7p1!||597K#^!+1Zf});hSE!4m31AM-4T71 zf$P%k`v*_2ZDyG9hlxMHn~_O`86NeM`Iy}VG2(u5DzgF~EV>~&CN7ZT0VWABSkhR< zI606}Y4S8?eI{WhAX7^R*<1z&MGttW14CX7rV&UlX*>qfpfuT@g^!Pwft8^fsDO(F Gqyhk5oC!n# delta 361 zcmZ3@eVc3%)+>9OP|$f zxi9ECGv~4RVW)3jZ>hgM^;d6A#r_*I*NxuWWy}h;UX!P@ZpmZMpDS}3F4QRfpUyVx zkbV5pOKy?#^EaoQobms}%N2z$=50>&QC)M(bhkr?Rh=^XE}q48J0foD>`L!eu;~&} z;F$j`TTIYP>C+Eu&e)(IJLF^bt2zf?(Vl4JU2buve$TO$x7Q{+^-I0CbWp4N#r~#L zIDkQIe}Fe5lL#~1-;*U-+$Jw!k(*r0B0SlFNpSKr7JVjD#>x6jS~|!|85k5j;6cO4 hz@P>UA{bcG_yVLsX>va+A0G Sphinx: + outdir.mkdir(parents=True, exist_ok=True) + doctreedir.mkdir(parents=True, exist_ok=True) + with open(outdir / "warnings.txt", "w", encoding="utf-8") as warning_file: + app = Sphinx( + srcdir=str(srcdir), + confdir=str(srcdir), + outdir=str(outdir), + doctreedir=str(doctreedir), + buildername="html", + warning=warning_file, + freshenv=True, + ) + app.build() + return app - Examples: - >>> greet() - 'hi' - """ - return "hi" -'''.strip() - + "\n", - encoding="utf-8", - ) +def _wheel_conf_extra() -> str: + return f""" +html_static_path = ["_static"] +pyrepl_autodoc_packages = {WHEEL_PATH!r} +""" + + +def test_autodoc_packages_emits_configured_wheel(tmp_path): + wheels_dir = tmp_path / "docs" / "_static" / "wheels" + wheels_dir.mkdir(parents=True) + shutil.copy2(FIXTURES / "wheels" / WHEEL_NAME, wheels_dir / WHEEL_NAME) + + srcdir = tmp_path / "docs" outdir = tmp_path / "_build" doctreedir = tmp_path / "_doctree" (srcdir / "conf.py").write_text( - """ + f""" import sys -from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent / "_static")) +sys.path.insert(0, {str(FIXTURES / "pyrepl_test_pkg")!r}) extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx_pyrepl_web"] master_doc = "index" pyrepl_js = "pyrepl.js" pyrepl_doctest_blocks = "autodoc" +{_wheel_conf_extra()} """, encoding="utf-8", ) - (srcdir / "index.rst").write_text(".. autofunction:: demo.greet\n", encoding="utf-8") + (srcdir / "index.rst").write_text( + ".. autofunction:: pyrepl_test_pkg.demo.example_generator\n", + encoding="utf-8", + ) - outdir.mkdir(parents=True, exist_ok=True) - doctreedir.mkdir(parents=True, exist_ok=True) - with open(outdir / "warnings.txt", "w", encoding="utf-8") as warning_file: - app = Sphinx( - srcdir=str(srcdir), - confdir=str(srcdir), - outdir=str(outdir), - doctreedir=str(doctreedir), - buildername="html", - warning=warning_file, - freshenv=True, - ) - app.build() + app = _build_sphinx(srcdir, outdir, doctreedir) html = (outdir / "index.html").read_text(encoding="utf-8") - assert 'src="_static/demo.py"' in html + assert f'packages="{WHEEL_PATH}"' in html assert 'replay-src="_static/pyrepl/index-1.py"' in html - assert "pyrepl.js" in html - assert (outdir / "_static" / "demo.py").is_file() + pyrepl_tag = html[html.index("
", html.index("
")] + assert 'src="_static/pyrepl/index-1-bootstrap.py"' in pyrepl_tag + assert (outdir / "_static" / "wheels" / WHEEL_NAME).is_file() doctree = app.env.get_doctree("index") assert doctree.get("pyrepl") @@ -75,19 +82,26 @@ def greet(): app.env.metadata["index"].get("pyrepl-replay-files", "{}") ) assert list(replay_files) == ["index-1.py"] + assert replay_files["index-1.py"] == ( + "print([i for i in example_generator(4)])\n" + ) - - assert list(replay_files) == ["index-1.py"] + bootstrap_files = json.loads( + app.env.metadata["index"].get("pyrepl-bootstrap-files", "{}") + ) + assert list(bootstrap_files) == ["index-1-bootstrap.py"] + assert bootstrap_files["index-1-bootstrap.py"] == ( + "from pyrepl_test_pkg.demo import example_generator\n" + ) + assert (outdir / "_static" / "pyrepl" / "index-1-bootstrap.py").is_file() -def test_autodoc_bootstrap_skips_installed_module(tmp_path): +def test_autodoc_packages_for_out_of_tree_module(tmp_path): pkg_dir = tmp_path / "installed_pkg" pkg_dir.mkdir() (pkg_dir / "__init__.py").write_text( ''' -from .core import Widget as _Widget - -class Widget(_Widget): +class Widget: """A demo widget. Example: @@ -96,14 +110,62 @@ class Widget(_Widget): 'ready' """ - pass + label = "ready" '''.strip() + "\n", encoding="utf-8", ) - (pkg_dir / "core.py").write_text( + + wheels_dir = tmp_path / "docs" / "_static" / "wheels" + wheels_dir.mkdir(parents=True) + shutil.copy2(FIXTURES / "wheels" / WHEEL_NAME, wheels_dir / WHEEL_NAME) + + srcdir = tmp_path / "docs" + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + + (srcdir / "conf.py").write_text( + f""" +import sys +sys.path.insert(0, {str(pkg_dir.parent)!r}) +extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx_pyrepl_web"] +master_doc = "index" +pyrepl_js = "pyrepl.js" +pyrepl_doctest_blocks = "autodoc" +{_wheel_conf_extra()} +""", + encoding="utf-8", + ) + (srcdir / "index.rst").write_text(".. autoclass:: installed_pkg.Widget\n", encoding="utf-8") + + app = _build_sphinx(srcdir, outdir, doctreedir) + + html = (outdir / "index.html").read_text(encoding="utf-8") + assert f'packages="{WHEEL_PATH}"' in html + assert 'replay-src="_static/pyrepl/index-1.py"' in html + pyrepl_tag = html[html.index("
", html.index("
")] + assert 'src="_static/pyrepl/index-1-bootstrap.py"' in pyrepl_tag + + bootstrap_files = json.loads( + app.env.metadata["index"].get("pyrepl-bootstrap-files", "{}") + ) + assert bootstrap_files["index-1-bootstrap.py"] == "from installed_pkg import Widget\n" + + +def test_autodoc_without_packages_is_replay_only(tmp_path): + pkg_dir = tmp_path / "installed_pkg" + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text( ''' class Widget: + """A demo widget. + + Example: + >>> w = Widget() + >>> w.label + 'ready' + + """ label = "ready" '''.strip() + "\n", @@ -128,60 +190,22 @@ class Widget: ) (srcdir / "index.rst").write_text(".. autoclass:: installed_pkg.Widget\n", encoding="utf-8") - outdir.mkdir(parents=True, exist_ok=True) - doctreedir.mkdir(parents=True, exist_ok=True) - with open(outdir / "warnings.txt", "w", encoding="utf-8") as warning_file: - app = Sphinx( - srcdir=str(srcdir), - confdir=str(srcdir), - outdir=str(outdir), - doctreedir=str(doctreedir), - buildername="html", - warning=warning_file, - freshenv=True, - ) - app.build() + _build_sphinx(srcdir, outdir, doctreedir) html = (outdir / "index.html").read_text(encoding="utf-8") assert 'replay-src="_static/pyrepl/index-1.py"' in html - assert "", html.index("")] assert 'packages="' not in pyrepl_tag assert ' src="' not in pyrepl_tag - replay_files = json.loads( - app.env.metadata["index"].get("pyrepl-replay-files", "{}") - ) - assert len(replay_files) == 1 - script = next(iter(replay_files.values())) - assert script == "w = Widget()\n\nw.label\n" - - -def test_bootstrap_failure_logs_error(caplog): - caplog.set_level(logging.ERROR, logger="sphinx_pyrepl_web") +def test_autodoc_packages_config_helper(): app = MagicMock() - app.config.pyrepl_autodoc_bootstrap = True - - env = MagicMock() - env.srcdir = "/tmp/docs" - env.metadata = {"index": {}} - env.note_dependency = MagicMock() + app.config.pyrepl_autodoc_packages = WHEEL_PATH + assert _autodoc_packages(app) == WHEEL_PATH - sig = MagicMock() - sig.get.side_effect = lambda key, default=None: { - "module": "nonexistent_bootstrap_mod_xyz", - "fullname": "missing", - }.get(key, default) + app.config.pyrepl_autodoc_packages = "" + assert _autodoc_packages(app) is None - desc = MagicMock() - desc.next_node.return_value = sig - - result = _resolve_autodoc_bootstrap(app, env, "index", desc) - - assert result == (None, None) - assert any( - "Could not bootstrap autodoc REPL for nonexistent_bootstrap_mod_xyz.missing" - in record.message - for record in caplog.records - ) + app.config.pyrepl_autodoc_packages = None + assert _autodoc_packages(app) is None diff --git a/tests/test_autodoc_bootstrap_source.py b/tests/test_autodoc_bootstrap_source.py new file mode 100644 index 0000000..4a0e35b --- /dev/null +++ b/tests/test_autodoc_bootstrap_source.py @@ -0,0 +1,29 @@ +from sphinx_pyrepl_web import autodoc_bootstrap_source + + +def test_function_import(): + assert autodoc_bootstrap_source( + "pyrepl_test_pkg.demo", "example_generator", "function" + ) == "from pyrepl_test_pkg.demo import example_generator\n" + + +def test_class_import(): + assert autodoc_bootstrap_source("installed_pkg", "Widget", "class") == ( + "from installed_pkg import Widget\n" + ) + + +def test_method_imports_outer_class(): + assert autodoc_bootstrap_source("pkg.mod", "Widget.label", "method") == ( + "from pkg.mod import Widget\n" + ) + + +def test_module_import(): + assert autodoc_bootstrap_source("pyrepl_test_pkg.demo", "", "module") == ( + "import pyrepl_test_pkg.demo\n" + ) + + +def test_missing_module_returns_none(): + assert autodoc_bootstrap_source(None, "foo", "function") is None diff --git a/tests/test_autodoc_include.py b/tests/test_autodoc_include.py index 01464ce..500229c 100644 --- a/tests/test_autodoc_include.py +++ b/tests/test_autodoc_include.py @@ -91,10 +91,9 @@ def test_included_example_writes_all_replay_scripts(included_example_project): assert len(replay_files) == 2 html = (outdir / "index.html").read_text(encoding="utf-8") - assert 'src="_static/repl_include_demo.py"' in html assert html.count("replay-src=") == 2 + assert 'src="_static/repl_include_demo.py"' not in html for script_name in replay_files: script_path = outdir / "_static" / "pyrepl" / script_name assert script_path.is_file(), f"missing replay script at {script_path}" - assert (outdir / "_static" / "repl_include_demo.py").is_file() From 544456406c1e3200d865f5a7131ded9b1860bf41 Mon Sep 17 00:00:00 2001 From: chrizzftd Date: Thu, 2 Jul 2026 21:54:34 +1000 Subject: [PATCH 05/16] Normalize emitted asset paths for nested Sphinx pages (#20) * Normalize emitted asset paths for nested Sphinx pages Rewrite file-like paths in py-repl attributes to page-relative URLs using Sphinx get_target_uri and relative_uri. This fixes REPL asset loading on nested doc pages while preserving flat-layout behavior and compatibility with RTD-style path prefixes. Adds asset_href helpers, unit tests, a nested-page build fixture, and README documentation. Closes #19. --------- Co-authored-by: Cursor Agent Co-authored-by: chrizzftd --- README.md | 2 + sphinx_pyrepl_web/__init__.py | 60 ++++++++++++++++--- tests/test_asset_href.py | 98 +++++++++++++++++++++++++++++++ tests/test_nested_static_paths.py | 77 ++++++++++++++++++++++++ 4 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 tests/test_asset_href.py create mode 100644 tests/test_nested_static_paths.py diff --git a/README.md b/README.md index c9353e7..4c6d69c 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ All options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attrib Python code within the `.. py-repl::` directive is written to `_static/pyrepl/` at build time and emitted as `replay-src`. +File paths in `:packages:`, `:src:`, `replay-src`, and `pyrepl_autodoc_packages` are rewritten to page-relative URLs at build time so REPLs work on nested pages (for example `docs/api/...`). PyPI package names, absolute URLs, and paths you write as root-absolute (`/_static/...`) are left unchanged. + Optional Sphinx config: ```python diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index 881a4d7..098f020 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -10,9 +10,11 @@ from docutils.parsers.rst import directives from sphinx import addnodes from sphinx.application import Sphinx +from sphinx.builders import Builder from sphinx.util import logging from sphinx.util.docutils import SphinxDirective from sphinx.util.fileutil import copy_asset_file +from sphinx.util.osutil import relative_uri PYREPL_DIR = Path(__file__).parent / "pyrepl" STARTUP_FILES_KEY = "pyrepl-startup-files" @@ -20,6 +22,32 @@ BOOTSTRAP_FILES_KEY = "pyrepl-bootstrap-files" _DOCTEST_PARSER = DocTestParser() logger = logging.getLogger(__name__) +_ABSOLUTE_PATH_PREFIXES = ("/", "http://", "https://", "emfs:") + + +def _is_file_like_path(path: str) -> bool: + """Return True if *path* should be rewritten as a page-relative asset URL.""" + if path.startswith(_ABSOLUTE_PATH_PREFIXES): + return False + if " @ " in path: + return False + return "/" in path or path.endswith((".whl", ".py")) + + +def _asset_href(builder: Builder, docname: str, path: str) -> str: + """Rewrite a file path for the HTML page that will emit it.""" + if not _is_file_like_path(path): + return path + if builder.format != "html": + return path + return relative_uri(builder.get_target_uri(docname), path) + + +def _asset_href_packages(builder: Builder, docname: str, packages: str) -> str: + """Rewrite comma-separated package entries that refer to local files.""" + return ", ".join( + _asset_href(builder, docname, part.strip()) for part in packages.split(",") + ) def setup(app: Sphinx): @@ -105,16 +133,24 @@ def autodoc_bootstrap_source( def make_pyrepl_raw( + builder: Builder, + docname: str, replay_src: str, packages: str | None = None, src: str | None = None, ) -> nodes.raw: """Build a raw HTML node for an autodoc doctest replay widget.""" - attrs = ["no-header", "no-banner", f'replay-src="{replay_src}"'] + attrs = [ + "no-header", + "no-banner", + f'replay-src="{_asset_href(builder, docname, replay_src)}"', + ] if packages: - attrs.insert(0, f'packages="{packages}"') + attrs.insert( + 0, f'packages="{_asset_href_packages(builder, docname, packages)}"' + ) if src: - attrs.insert(0, f'src="{src}"') + attrs.insert(0, f'src="{_asset_href(builder, docname, src)}"') attr_str = " ".join(attrs) return nodes.raw("", f"\n", format="html") @@ -173,7 +209,9 @@ def transform_doctest_blocks(app: Sphinx, doctree: nodes.document): env, docname, bootstrap_text, replay_name ) node.replace_self( - make_pyrepl_raw(replay_src, src=bootstrap_src, packages=packages) + make_pyrepl_raw( + app.builder, docname, replay_src, src=bootstrap_src, packages=packages + ) ) replaced = True @@ -201,6 +239,8 @@ class PyRepl(SphinxDirective): def run(self): env = self.env + builder = env._app.builder + docname = env.docname attrs: list[str] = [] for option, attr in ( @@ -210,6 +250,8 @@ def run(self): ): if option in self.options: value = self.options[option] + if option == "packages": + value = _asset_href_packages(builder, docname, value) attrs.append(f'{attr}="{value}"') for flag in ("no-header", "no-buttons", "readonly", "no-banner"): @@ -228,7 +270,11 @@ def run(self): except OSError as exc: raise self.error(f"Could not read file: {exc}") from exc self.env.note_dependency(path) - rel_src = path.relative_to(Path(self.env.srcdir)).as_posix() + rel_src = _asset_href( + builder, + docname, + path.relative_to(Path(self.env.srcdir)).as_posix(), + ) startup_files = json.loads( self.env.metadata[self.env.docname].setdefault( STARTUP_FILES_KEY, "[]" @@ -247,8 +293,8 @@ def run(self): if has_body: body_text = doctest_to_replay_source(list(self.content)) - replay_src, _ = register_autodoc_repl(env, env.docname, body_text) - attrs.append(f'replay-src="{replay_src}"') + replay_src, _ = register_autodoc_repl(env, docname, body_text) + attrs.append(f'replay-src="{_asset_href(builder, docname, replay_src)}"') self.env.metadata[self.env.docname]["pyrepl"] = True attr_str = (" " + " ".join(attrs)) if attrs else "" diff --git a/tests/test_asset_href.py b/tests/test_asset_href.py new file mode 100644 index 0000000..fca2fb0 --- /dev/null +++ b/tests/test_asset_href.py @@ -0,0 +1,98 @@ +import shutil +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from sphinx.application import Sphinx + +from sphinx_pyrepl_web import _asset_href, _asset_href_packages + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + + +def _build_sphinx(srcdir: Path, outdir: Path, doctreedir: Path) -> Sphinx: + outdir.mkdir(parents=True, exist_ok=True) + doctreedir.mkdir(parents=True, exist_ok=True) + with open(outdir / "warnings.txt", "w", encoding="utf-8") as warning_file: + app = Sphinx( + srcdir=str(srcdir), + confdir=str(srcdir), + outdir=str(outdir), + doctreedir=str(doctreedir), + buildername="html", + warning=warning_file, + freshenv=True, + ) + app.build() + return app + + +@pytest.fixture +def html_builder(tmp_path): + srcdir = tmp_path / "docs" + srcdir.mkdir() + (srcdir / "conf.py").write_text("", encoding="utf-8") + (srcdir / "index.rst").write_text("Test\n====\n", encoding="utf-8") + (srcdir / "api").mkdir() + (srcdir / "api" / "module.rst").write_text("API\n===\n", encoding="utf-8") + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + app = _build_sphinx(srcdir, outdir, doctreedir) + return app.builder + + +def test_asset_href_rewrites_static_path_on_nested_page(html_builder): + assert ( + _asset_href(html_builder, "api/module", "_static/wheels/foo.whl") + == "../_static/wheels/foo.whl" + ) + + +def test_asset_href_leaves_root_page_static_path_unchanged(html_builder): + assert ( + _asset_href(html_builder, "index", "_static/wheels/foo.whl") + == "_static/wheels/foo.whl" + ) + + +def test_asset_href_leaves_root_absolute_path_unchanged(html_builder): + assert ( + _asset_href(html_builder, "api/module", "/_static/wheels/foo.whl") + == "/_static/wheels/foo.whl" + ) + + +def test_asset_href_leaves_https_url_unchanged(html_builder): + url = "https://cdn.example/w.whl" + assert _asset_href(html_builder, "api/module", url) == url + + +def test_asset_href_leaves_pypi_name_unchanged(html_builder): + assert _asset_href(html_builder, "api/module", "numpy") == "numpy" + + +def test_asset_href_rewrites_src_relative_to_source_root(html_builder): + assert _asset_href(html_builder, "api/module", "demo.py") == "../demo.py" + + +def test_asset_href_leaves_micropip_spec_unchanged(html_builder): + spec = "mypkg @ https://example.com/wheels/mypkg.whl" + assert _asset_href(html_builder, "api/module", spec) == spec + + +def test_asset_href_packages_rewrites_only_file_like_entries(html_builder): + packages = "numpy, _static/wheels/foo.whl" + assert _asset_href_packages(html_builder, "api/module", packages) == ( + "numpy, ../_static/wheels/foo.whl" + ) + + +def test_asset_href_skips_normalization_for_non_html_builder(): + builder = MagicMock() + builder.format = "" + assert ( + _asset_href(builder, "api/module", "_static/wheels/foo.whl") + == "_static/wheels/foo.whl" + ) diff --git a/tests/test_nested_static_paths.py b/tests/test_nested_static_paths.py new file mode 100644 index 0000000..2acc8cc --- /dev/null +++ b/tests/test_nested_static_paths.py @@ -0,0 +1,77 @@ +import shutil +import sys +from pathlib import Path + +from sphinx.application import Sphinx + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = Path(__file__).resolve().parent / "fixtures" +WHEEL_NAME = "pyrepl_test_pkg-1.0.0-py3-none-any.whl" +WHEEL_PATH = f"_static/wheels/{WHEEL_NAME}" + +sys.path.insert(0, str(ROOT)) + + +def _build_sphinx(srcdir: Path, outdir: Path, doctreedir: Path) -> Sphinx: + outdir.mkdir(parents=True, exist_ok=True) + doctreedir.mkdir(parents=True, exist_ok=True) + with open(outdir / "warnings.txt", "w", encoding="utf-8") as warning_file: + app = Sphinx( + srcdir=str(srcdir), + confdir=str(srcdir), + outdir=str(outdir), + doctreedir=str(doctreedir), + buildername="html", + warning=warning_file, + freshenv=True, + ) + app.build() + return app + + +def test_nested_page_emits_page_relative_static_paths(tmp_path): + srcdir = tmp_path / "docs" + wheels_dir = srcdir / "_static" / "wheels" + wheels_dir.mkdir(parents=True) + shutil.copy2(FIXTURES / "wheels" / WHEEL_NAME, wheels_dir / WHEEL_NAME) + (srcdir / "api").mkdir() + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + + (srcdir / "conf.py").write_text( + """ +extensions = ["sphinx_pyrepl_web"] +master_doc = "index" +pyrepl_js = "pyrepl.js" +html_static_path = ["_static"] +""", + encoding="utf-8", + ) + (srcdir / "index.rst").write_text("Home\n====\n", encoding="utf-8") + (srcdir / "api" / "index.rst").write_text( + f""" +API +=== + +.. py-repl:: + :packages: {WHEEL_PATH} + :no-header: + + >>> 1 + 1 +""", + encoding="utf-8", + ) + + app = _build_sphinx(srcdir, outdir, doctreedir) + + html = (outdir / "api" / "index.html").read_text(encoding="utf-8") + assert 'packages="../_static/wheels/' in html + assert 'packages="api/_static/' not in html + assert 'packages="/_static/' not in html + assert 'replay-src="../_static/pyrepl/api-index-1.py"' in html + + root_html = (outdir / "index.html").read_text(encoding="utf-8") + assert "py-repl" not in root_html + + replay_files = app.env.metadata["api/index"].get("pyrepl-replay-files") + assert replay_files is not None From 3251ca0b2e69082bcad4d7a6bfd88b83caae7ee1 Mon Sep 17 00:00:00 2001 From: chrizzftd Date: Thu, 2 Jul 2026 22:44:09 +1000 Subject: [PATCH 06/16] Refactor test suite: layered unit/doctree/integration with shared fixtures (#21) --- tests/__init__.py | 1 + tests/conftest.py | 58 +++++ tests/doctree/test_autodoc_scope.py | 70 ++++++ .../test_pyrepl_directive.py} | 72 ++++-- tests/fixtures/__init__.py | 1 + tests/fixtures/sources.py | 45 ++++ tests/helpers.py | 64 ++++++ tests/integration/test_autodoc_wiring.py | 70 ++++++ tests/integration/test_build_output.py | 125 +++++++++++ .../test_include.py} | 30 +-- tests/integration/test_nested_pages.py | 42 ++++ tests/support.py | 77 +++++++ tests/test_asset_href.py | 98 -------- tests/test_autodoc_bootstrap.py | 211 ------------------ tests/test_autodoc_bootstrap_source.py | 29 --- tests/test_autodoc_doctest.py | 158 ------------- tests/test_build_replay.py | 134 ----------- tests/test_doctest_to_replay_source.py | 55 ----- tests/test_local_wheel.py | 148 ------------ tests/test_nested_static_paths.py | 77 ------- tests/unit/test_asset_href.py | 53 +++++ tests/unit/test_autodoc_bootstrap_source.py | 38 ++++ tests/unit/test_config.py | 20 ++ tests/unit/test_copy_assets.py | 55 +++++ tests/unit/test_doctest_to_replay_source.py | 64 ++++++ 25 files changed, 843 insertions(+), 952 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/doctree/test_autodoc_scope.py rename tests/{test_basic.py => doctree/test_pyrepl_directive.py} (53%) create mode 100644 tests/fixtures/__init__.py create mode 100644 tests/fixtures/sources.py create mode 100644 tests/helpers.py create mode 100644 tests/integration/test_autodoc_wiring.py create mode 100644 tests/integration/test_build_output.py rename tests/{test_autodoc_include.py => integration/test_include.py} (70%) create mode 100644 tests/integration/test_nested_pages.py create mode 100644 tests/support.py delete mode 100644 tests/test_asset_href.py delete mode 100644 tests/test_autodoc_bootstrap.py delete mode 100644 tests/test_autodoc_bootstrap_source.py delete mode 100644 tests/test_autodoc_doctest.py delete mode 100644 tests/test_build_replay.py delete mode 100644 tests/test_doctest_to_replay_source.py delete mode 100644 tests/test_local_wheel.py delete mode 100644 tests/test_nested_static_paths.py create mode 100644 tests/unit/test_asset_href.py create mode 100644 tests/unit/test_autodoc_bootstrap_source.py create mode 100644 tests/unit/test_config.py create mode 100644 tests/unit/test_copy_assets.py create mode 100644 tests/unit/test_doctest_to_replay_source.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..9a1763a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for sphinx-pyrepl-web.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..fa87a25 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,58 @@ +"""Shared pytest fixtures.""" + +import sys +from pathlib import Path + +import pytest + +from tests.support import build_sphinx, copy_wheel_to, pyrepl_conf_header + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +@pytest.fixture +def sphinx_project(tmp_path): + """Minimal Sphinx project with a py-repl directive body.""" + srcdir = tmp_path / "docs" + srcdir.mkdir() + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + (srcdir / "conf.py").write_text( + pyrepl_conf_header(), + encoding="utf-8", + ) + (srcdir / "index.rst").write_text( + """ +Example +======= + +.. py-repl:: + :no-header: + + >>> x = 2 + 2 + >>> print(x) +""".strip() + + "\n", + encoding="utf-8", + ) + return srcdir, outdir, doctreedir + + +@pytest.fixture +def wheel_project(tmp_path): + """Sphinx project with the test wheel and bootstrap script in _static.""" + srcdir = tmp_path / "docs" + copy_wheel_to(srcdir) + (srcdir / "_static" / "bootstrap.py").write_text( + "# Optional post-install bootstrap for local wheel REPLs.\n", + encoding="utf-8", + ) + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + (srcdir / "conf.py").write_text( + pyrepl_conf_header(extra='html_static_path = ["_static"]\n'), + encoding="utf-8", + ) + return srcdir, outdir, doctreedir diff --git a/tests/doctree/test_autodoc_scope.py b/tests/doctree/test_autodoc_scope.py new file mode 100644 index 0000000..fdc7821 --- /dev/null +++ b/tests/doctree/test_autodoc_scope.py @@ -0,0 +1,70 @@ +import pytest +from sphinx_pytest.plugin import CreateDoctree + +from tests.fixtures.sources import AUTODOC_SCOPE_RST, EXAMPLE_GENERATOR_SOURCE + + +def _write_autodoc_conf(srcdir, scope) -> None: + scope_literal = repr(scope) + (srcdir / "conf.py").write_text( + f""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx_pyrepl_web", +] +pyrepl_doctest_blocks = {scope_literal} +pyrepl_js = "pyrepl.js" +""".strip() + + "\n", + encoding="utf-8", + ) + + +@pytest.fixture +def autodoc_doctree(sphinx_doctree: CreateDoctree): + """Configure sphinx_doctree with an autodoc module and doctest scope RST.""" + (sphinx_doctree.srcdir / "repl_test_demo.py").write_text( + EXAMPLE_GENERATOR_SOURCE, + encoding="utf-8", + ) + _write_autodoc_conf(sphinx_doctree.srcdir, "autodoc") + sphinx_doctree.buildername = "html" + return sphinx_doctree + + +def count_pyrepl_nodes(result) -> int: + """Return the number of py-repl raw HTML nodes in a doctree result.""" + return result.pformat().count(" @@ -32,22 +32,6 @@ def test_basic(sphinx_doctree: CreateDoctree): """.strip().splitlines() -def test_replay_body(sphinx_doctree: CreateDoctree): - sphinx_doctree.set_conf({"extensions": ["sphinx_pyrepl_web"]}) - sphinx_doctree.buildername = "html" - result = sphinx_doctree( - """ -.. py-repl:: - :no-header: - - >>> x = 1 - >>> x + 1 - """ - ) - html = result.pformat() - assert 'replay-src="_static/pyrepl/index-1.py"' in html - - def test_replay_file_flag(sphinx_doctree: CreateDoctree): sphinx_doctree.set_conf( { @@ -67,3 +51,57 @@ def test_replay_file_flag(sphinx_doctree: CreateDoctree): html = result.pformat() assert 'src="demo.py"' in html assert "replay" in html + + +@pytest.mark.parametrize( + "options,expected_fragments", + [ + (":no-buttons:\n :readonly:", ["no-buttons", "readonly"]), + (":repl-title: My REPL", ['repl-title="My REPL"']), + ], + ids=["flag-options", "repl-title"], +) +def test_pyrepl_directive_options( + sphinx_doctree: CreateDoctree, options, expected_fragments +): + sphinx_doctree.set_conf({"extensions": ["sphinx_pyrepl_web"], "root_doc": "index"}) + sphinx_doctree.buildername = "html" + (sphinx_doctree.srcdir / "demo.py").write_text("print('hi')\n", encoding="utf-8") + result = sphinx_doctree( + f""" +.. py-repl:: + {options} + + >>> 1 + 1 +""" + ) + html = result.pformat() + for fragment in expected_fragments: + assert fragment in html + + +def test_silent_src_omitted_without_body(sphinx_doctree: CreateDoctree): + sphinx_doctree.set_conf({"extensions": ["sphinx_pyrepl_web"], "root_doc": "index"}) + sphinx_doctree.buildername = "html" + (sphinx_doctree.srcdir / "demo.py").write_text("print('hi')\n", encoding="utf-8") + result = sphinx_doctree( + """ +.. py-repl:: + :silent: + :src: demo.py +""" + ) + html = result.pformat() + assert 'src="demo.py"' not in html + + +def test_missing_src_file_reports_error(sphinx_doctree: CreateDoctree): + sphinx_doctree.set_conf({"extensions": ["sphinx_pyrepl_web"]}) + sphinx_doctree.buildername = "html" + result = sphinx_doctree( + """ +.. py-repl:: + :src: missing.py +""" + ) + assert "Could not read file" in result.warnings diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..315591d --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1 @@ +"""Shared test fixtures and sample sources.""" diff --git a/tests/fixtures/sources.py b/tests/fixtures/sources.py new file mode 100644 index 0000000..51600d1 --- /dev/null +++ b/tests/fixtures/sources.py @@ -0,0 +1,45 @@ +"""Shared RST snippets and inline module sources for doctree tests.""" + +EXAMPLE_GENERATOR_SOURCE = ''' +def example_generator(n): + """Generators have a ``Yields`` section instead of a ``Returns`` section. + + Examples: + Examples should be written in doctest format, and should illustrate how + to use the function. + + >>> print([i for i in example_generator(4)]) + [0, 1, 2, 3] + + """ + yield from range(n) +'''.strip() + "\n" + +AUTODOC_SCOPE_RST = """ +API +=== + +.. autofunction:: repl_test_demo.example_generator + +Tutorial +======== + +Plain doctest (outside autodoc): + +>>> x = 1 +>>> x + 1 +2 +""".strip() + +WIDGET_SOURCE = ''' +class Widget: + """A demo widget. + + Example: + >>> w = Widget() + >>> w.label + 'ready' + + """ + label = "ready" +'''.strip() + "\n" diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..45307ae --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,64 @@ +"""Shared assertion helpers for sphinx-pyrepl-web tests.""" + +import json +from pathlib import Path +from unittest.mock import MagicMock + +from sphinx.application import Sphinx + + +def mock_html_builder(docname: str = "api/module") -> MagicMock: + """Return a mock HTML builder whose target URIs match a nested page layout.""" + builder = MagicMock() + builder.format = "html" + + def get_target_uri(name: str) -> str: + if name == "index": + return "index.html" + return f"{name}.html" + + builder.get_target_uri.side_effect = get_target_uri + return builder + + +def pyrepl_tag(html: str) -> str: + """Extract the first ```` tag from HTML.""" + start = html.index("", start) + len(">") + return html[start:end] + + +def load_replay_files(app: Sphinx, docname: str) -> dict[str, str]: + """Load replay script metadata for a document.""" + return json.loads( + app.env.metadata[docname].get("pyrepl-replay-files", "{}") + ) + + +def load_bootstrap_files(app: Sphinx, docname: str) -> dict[str, str]: + """Load bootstrap script metadata for a document.""" + return json.loads( + app.env.metadata[docname].get("pyrepl-bootstrap-files", "{}") + ) + + +def assert_replay_artifacts( + app: Sphinx, + outdir: Path, + docname: str, + *, + count: int | None = None, + html: str | None = None, +) -> dict[str, str]: + """Assert replay metadata, on-disk scripts, and optional HTML references.""" + replay_files = load_replay_files(app, docname) + if count is not None: + assert len(replay_files) == count + + for script_name in replay_files: + script_path = outdir / "_static" / "pyrepl" / script_name + assert script_path.is_file(), f"missing replay script at {script_path}" + if html is not None: + assert f'replay-src="_static/pyrepl/{script_name}"' in html + + return replay_files diff --git a/tests/integration/test_autodoc_wiring.py b/tests/integration/test_autodoc_wiring.py new file mode 100644 index 0000000..d955d71 --- /dev/null +++ b/tests/integration/test_autodoc_wiring.py @@ -0,0 +1,70 @@ +from tests.helpers import load_bootstrap_files, pyrepl_tag +from tests.support import ( + FIXTURES, + WHEEL_NAME, + WHEEL_PATH, + autodoc_conf_header, + build_sphinx, + copy_wheel_to, + wheel_conf_extra, +) + + +def test_autodoc_with_packages_writes_bootstrap_and_wheel(tmp_path): + srcdir = tmp_path / "docs" + copy_wheel_to(srcdir) + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + + (srcdir / "conf.py").write_text( + autodoc_conf_header( + sys_path=str(FIXTURES / "pyrepl_test_pkg"), + extra=wheel_conf_extra(), + ), + encoding="utf-8", + ) + (srcdir / "index.rst").write_text( + ".. autofunction:: pyrepl_test_pkg.demo.example_generator\n", + encoding="utf-8", + ) + + app = build_sphinx(srcdir, outdir, doctreedir) + + html = (outdir / "index.html").read_text(encoding="utf-8") + assert f'packages="{WHEEL_PATH}"' in html + assert 'replay-src="_static/pyrepl/index-1.py"' in html + tag = pyrepl_tag(html) + assert 'src="_static/pyrepl/index-1-bootstrap.py"' in tag + assert (outdir / "_static" / "wheels" / WHEEL_NAME).is_file() + assert (outdir / "_static" / "pyrepl" / "index-1.py").is_file() + assert (outdir / "_static" / "pyrepl" / "index-1-bootstrap.py").is_file() + + assert app.env.get_doctree("index").get("pyrepl") + assert list(load_bootstrap_files(app, "index")) == ["index-1-bootstrap.py"] + + +def test_autodoc_without_packages_is_replay_only(tmp_path): + from tests.fixtures.sources import WIDGET_SOURCE + + pkg_dir = tmp_path / "installed_pkg" + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text(WIDGET_SOURCE, encoding="utf-8") + + srcdir = tmp_path / "docs" + srcdir.mkdir() + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + + (srcdir / "conf.py").write_text( + autodoc_conf_header(sys_path=str(pkg_dir.parent)), + encoding="utf-8", + ) + (srcdir / "index.rst").write_text(".. autoclass:: installed_pkg.Widget\n", encoding="utf-8") + + build_sphinx(srcdir, outdir, doctreedir) + + html = (outdir / "index.html").read_text(encoding="utf-8") + assert 'replay-src="_static/pyrepl/index-1.py"' in html + tag = pyrepl_tag(html) + assert 'packages="' not in tag + assert ' src="' not in tag diff --git a/tests/integration/test_build_output.py b/tests/integration/test_build_output.py new file mode 100644 index 0000000..09b1713 --- /dev/null +++ b/tests/integration/test_build_output.py @@ -0,0 +1,125 @@ +from tests.helpers import assert_replay_artifacts +from tests.support import WHEEL_NAME, WHEEL_PATH, build_sphinx + +from sphinx_pyrepl_web import PYREPL_DIR + + +def test_build_writes_replay_script_from_metadata(sphinx_project): + srcdir, outdir, doctreedir = sphinx_project + app = build_sphinx(srcdir, outdir, doctreedir) + + replay_files = assert_replay_artifacts(app, outdir, "index") + script_name = next(iter(replay_files)) + script_path = outdir / "_static" / "pyrepl" / script_name + assert "x = 2 + 2" in script_path.read_text(encoding="utf-8") + + html = (outdir / "index.html").read_text(encoding="utf-8") + assert f'replay-src="_static/pyrepl/{script_name}"' in html + + +def test_build_writes_replay_script_with_parallel_read(sphinx_project): + srcdir, outdir, doctreedir = sphinx_project + build_sphinx(srcdir, outdir, doctreedir, parallel=2) + + script_path = outdir / "_static" / "pyrepl" / "index-1.py" + assert script_path.is_file(), f"missing replay script at {script_path}" + + +def test_local_wheel_copied_to_output_tree(wheel_project): + srcdir, outdir, doctreedir = wheel_project + (srcdir / "index.rst").write_text( + f""" +Example +======= + +.. py-repl:: + :packages: {WHEEL_PATH} + :no-header: +""", + encoding="utf-8", + ) + + build_sphinx(srcdir, outdir, doctreedir) + + wheel_out = outdir / "_static" / "wheels" / WHEEL_NAME + assert wheel_out.is_file(), f"missing wheel at {wheel_out}" + + html = (outdir / "index.html").read_text(encoding="utf-8") + assert f'packages="{WHEEL_PATH}"' in html + assert "pyrepl.js" in html + + +def test_local_wheel_with_src_and_replay_body(wheel_project): + srcdir, outdir, doctreedir = wheel_project + (srcdir / "index.rst").write_text( + f""" +Example +======= + +.. py-repl:: + :packages: {WHEEL_PATH} + :src: _static/bootstrap.py + :repl-title: test + :no-banner: + + >>> import pyrepl_test_pkg + >>> pyrepl_test_pkg.ping() +""", + encoding="utf-8", + ) + + app = build_sphinx(srcdir, outdir, doctreedir) + + assert (outdir / "_static" / "wheels" / WHEEL_NAME).is_file() + assert (outdir / "_static" / "bootstrap.py").is_file() + + replay_files = assert_replay_artifacts(app, outdir, "index", count=1) + script_name = next(iter(replay_files)) + + html = (outdir / "index.html").read_text(encoding="utf-8") + assert f'packages="{WHEEL_PATH}"' in html + assert 'src="_static/bootstrap.py"' in html + assert f'replay-src="_static/pyrepl/{script_name}"' in html + + +def test_vendored_pyrepl_assets_copied(sphinx_project): + srcdir, outdir, doctreedir = sphinx_project + build_sphinx(srcdir, outdir, doctreedir) + + copied = {path.name for path in PYREPL_DIR.iterdir() if path.is_file()} + assert copied + for name in copied: + assert (outdir / name).is_file(), f"missing vendored asset {name}" + + +def test_startup_src_deduplicated_on_build(tmp_path): + srcdir = tmp_path / "docs" + srcdir.mkdir() + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + (srcdir / "shared.py").write_text("print('shared')\n", encoding="utf-8") + (srcdir / "conf.py").write_text( + """ +extensions = ["sphinx_pyrepl_web"] +master_doc = "index" +pyrepl_js = "pyrepl.js" +""", + encoding="utf-8", + ) + (srcdir / "index.rst").write_text( + """ +Example +======= + +.. py-repl:: + :src: shared.py + +.. py-repl:: + :src: shared.py +""", + encoding="utf-8", + ) + + build_sphinx(srcdir, outdir, doctreedir) + assert (outdir / "shared.py").is_file() + assert (outdir / "shared.py").read_text(encoding="utf-8") == "print('shared')\n" diff --git a/tests/test_autodoc_include.py b/tests/integration/test_include.py similarity index 70% rename from tests/test_autodoc_include.py rename to tests/integration/test_include.py index 500229c..0bc3741 100644 --- a/tests/test_autodoc_include.py +++ b/tests/integration/test_include.py @@ -1,12 +1,7 @@ -import json -import sys -from pathlib import Path - import pytest -from sphinx.application import Sphinx -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT)) +from tests.helpers import assert_replay_artifacts +from tests.support import build_sphinx @pytest.fixture @@ -18,7 +13,7 @@ def included_example_project(tmp_path): doctreedir = tmp_path / "_doctree" (srcdir / "conf.py").write_text( - f""" + """ import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent / "_static")) @@ -71,24 +66,9 @@ def example_generator(n): def test_included_example_writes_all_replay_scripts(included_example_project): srcdir, outdir, doctreedir = included_example_project - outdir.mkdir(parents=True, exist_ok=True) - doctreedir.mkdir(parents=True, exist_ok=True) - with open(outdir / "warnings.txt", "w", encoding="utf-8") as warning_file: - app = Sphinx( - srcdir=str(srcdir), - confdir=str(srcdir), - outdir=str(outdir), - doctreedir=str(doctreedir), - buildername="html", - warning=warning_file, - freshenv=True, - ) - app.build() + app = build_sphinx(srcdir, outdir, doctreedir) - replay_files = json.loads( - app.env.metadata["index"].get("pyrepl-replay-files", "{}") - ) - assert len(replay_files) == 2 + replay_files = assert_replay_artifacts(app, outdir, "index", count=2) html = (outdir / "index.html").read_text(encoding="utf-8") assert html.count("replay-src=") == 2 diff --git a/tests/integration/test_nested_pages.py b/tests/integration/test_nested_pages.py new file mode 100644 index 0000000..4c6c955 --- /dev/null +++ b/tests/integration/test_nested_pages.py @@ -0,0 +1,42 @@ +from tests.support import WHEEL_PATH, build_sphinx, copy_wheel_to, pyrepl_conf_header + + +def test_nested_page_emits_page_relative_static_paths(tmp_path): + srcdir = tmp_path / "docs" + copy_wheel_to(srcdir) + (srcdir / "api").mkdir() + outdir = tmp_path / "_build" + doctreedir = tmp_path / "_doctree" + + (srcdir / "conf.py").write_text( + pyrepl_conf_header(extra='html_static_path = ["_static"]\n'), + encoding="utf-8", + ) + (srcdir / "index.rst").write_text("Home\n====\n", encoding="utf-8") + (srcdir / "api" / "index.rst").write_text( + f""" +API +=== + +.. py-repl:: + :packages: {WHEEL_PATH} + :no-header: + + >>> 1 + 1 +""", + encoding="utf-8", + ) + + app = build_sphinx(srcdir, outdir, doctreedir) + + html = (outdir / "api" / "index.html").read_text(encoding="utf-8") + assert 'packages="../_static/wheels/' in html + assert 'packages="api/_static/' not in html + assert 'packages="/_static/' not in html + assert 'replay-src="../_static/pyrepl/api-index-1.py"' in html + + root_html = (outdir / "index.html").read_text(encoding="utf-8") + assert "py-repl" not in root_html + + replay_files = app.env.metadata["api/index"].get("pyrepl-replay-files") + assert replay_files is not None diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..7742620 --- /dev/null +++ b/tests/support.py @@ -0,0 +1,77 @@ +"""Shared constants and Sphinx project setup helpers.""" + +import shutil +from pathlib import Path + +from sphinx.application import Sphinx + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = Path(__file__).resolve().parent / "fixtures" +WHEEL_NAME = "pyrepl_test_pkg-1.0.0-py3-none-any.whl" +WHEEL_PATH = f"_static/wheels/{WHEEL_NAME}" + + +def build_sphinx( + srcdir: Path, + outdir: Path, + doctreedir: Path, + *, + parallel: int = 0, + **kwargs, +) -> Sphinx: + """Run a full Sphinx HTML build and return the application.""" + outdir.mkdir(parents=True, exist_ok=True) + doctreedir.mkdir(parents=True, exist_ok=True) + with open(outdir / "warnings.txt", "w", encoding="utf-8") as warning_file: + app = Sphinx( + srcdir=str(srcdir), + confdir=str(srcdir), + outdir=str(outdir), + doctreedir=str(doctreedir), + buildername="html", + warning=warning_file, + freshenv=True, + parallel=parallel, + **kwargs, + ) + app.build() + return app + + +def copy_wheel_to(srcdir: Path) -> Path: + """Copy the test wheel into ``srcdir/_static/wheels`` and return that directory.""" + wheels_dir = srcdir / "_static" / "wheels" + wheels_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(FIXTURES / "wheels" / WHEEL_NAME, wheels_dir / WHEEL_NAME) + return wheels_dir + + +def wheel_conf_extra() -> str: + """Return conf.py snippet enabling the test wheel for autodoc REPLs.""" + return f""" +html_static_path = ["_static"] +pyrepl_autodoc_packages = {WHEEL_PATH!r} +""" + + +def autodoc_conf_header(*, sys_path: str, extra: str = "") -> str: + """Return a minimal autodoc-enabled conf.py header.""" + return f""" +import sys +sys.path.insert(0, {sys_path!r}) +extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx_pyrepl_web"] +master_doc = "index" +pyrepl_js = "pyrepl.js" +pyrepl_doctest_blocks = "autodoc" +{extra} +""" + + +def pyrepl_conf_header(*, extra: str = "") -> str: + """Return a minimal pyrepl-only conf.py header.""" + return f""" +extensions = ["sphinx_pyrepl_web"] +master_doc = "index" +pyrepl_js = "pyrepl.js" +{extra} +""" diff --git a/tests/test_asset_href.py b/tests/test_asset_href.py deleted file mode 100644 index fca2fb0..0000000 --- a/tests/test_asset_href.py +++ /dev/null @@ -1,98 +0,0 @@ -import shutil -import sys -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -from sphinx.application import Sphinx - -from sphinx_pyrepl_web import _asset_href, _asset_href_packages - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT)) - - -def _build_sphinx(srcdir: Path, outdir: Path, doctreedir: Path) -> Sphinx: - outdir.mkdir(parents=True, exist_ok=True) - doctreedir.mkdir(parents=True, exist_ok=True) - with open(outdir / "warnings.txt", "w", encoding="utf-8") as warning_file: - app = Sphinx( - srcdir=str(srcdir), - confdir=str(srcdir), - outdir=str(outdir), - doctreedir=str(doctreedir), - buildername="html", - warning=warning_file, - freshenv=True, - ) - app.build() - return app - - -@pytest.fixture -def html_builder(tmp_path): - srcdir = tmp_path / "docs" - srcdir.mkdir() - (srcdir / "conf.py").write_text("", encoding="utf-8") - (srcdir / "index.rst").write_text("Test\n====\n", encoding="utf-8") - (srcdir / "api").mkdir() - (srcdir / "api" / "module.rst").write_text("API\n===\n", encoding="utf-8") - outdir = tmp_path / "_build" - doctreedir = tmp_path / "_doctree" - app = _build_sphinx(srcdir, outdir, doctreedir) - return app.builder - - -def test_asset_href_rewrites_static_path_on_nested_page(html_builder): - assert ( - _asset_href(html_builder, "api/module", "_static/wheels/foo.whl") - == "../_static/wheels/foo.whl" - ) - - -def test_asset_href_leaves_root_page_static_path_unchanged(html_builder): - assert ( - _asset_href(html_builder, "index", "_static/wheels/foo.whl") - == "_static/wheels/foo.whl" - ) - - -def test_asset_href_leaves_root_absolute_path_unchanged(html_builder): - assert ( - _asset_href(html_builder, "api/module", "/_static/wheels/foo.whl") - == "/_static/wheels/foo.whl" - ) - - -def test_asset_href_leaves_https_url_unchanged(html_builder): - url = "https://cdn.example/w.whl" - assert _asset_href(html_builder, "api/module", url) == url - - -def test_asset_href_leaves_pypi_name_unchanged(html_builder): - assert _asset_href(html_builder, "api/module", "numpy") == "numpy" - - -def test_asset_href_rewrites_src_relative_to_source_root(html_builder): - assert _asset_href(html_builder, "api/module", "demo.py") == "../demo.py" - - -def test_asset_href_leaves_micropip_spec_unchanged(html_builder): - spec = "mypkg @ https://example.com/wheels/mypkg.whl" - assert _asset_href(html_builder, "api/module", spec) == spec - - -def test_asset_href_packages_rewrites_only_file_like_entries(html_builder): - packages = "numpy, _static/wheels/foo.whl" - assert _asset_href_packages(html_builder, "api/module", packages) == ( - "numpy, ../_static/wheels/foo.whl" - ) - - -def test_asset_href_skips_normalization_for_non_html_builder(): - builder = MagicMock() - builder.format = "" - assert ( - _asset_href(builder, "api/module", "_static/wheels/foo.whl") - == "_static/wheels/foo.whl" - ) diff --git a/tests/test_autodoc_bootstrap.py b/tests/test_autodoc_bootstrap.py deleted file mode 100644 index c291a0a..0000000 --- a/tests/test_autodoc_bootstrap.py +++ /dev/null @@ -1,211 +0,0 @@ -import json -import shutil -import sys -from pathlib import Path -from unittest.mock import MagicMock - -from sphinx.application import Sphinx - -from sphinx_pyrepl_web import _autodoc_packages - -ROOT = Path(__file__).resolve().parents[1] -FIXTURES = Path(__file__).resolve().parent / "fixtures" -WHEEL_NAME = "pyrepl_test_pkg-1.0.0-py3-none-any.whl" -WHEEL_PATH = f"_static/wheels/{WHEEL_NAME}" - -sys.path.insert(0, str(ROOT)) - - -def _build_sphinx(srcdir: Path, outdir: Path, doctreedir: Path) -> Sphinx: - outdir.mkdir(parents=True, exist_ok=True) - doctreedir.mkdir(parents=True, exist_ok=True) - with open(outdir / "warnings.txt", "w", encoding="utf-8") as warning_file: - app = Sphinx( - srcdir=str(srcdir), - confdir=str(srcdir), - outdir=str(outdir), - doctreedir=str(doctreedir), - buildername="html", - warning=warning_file, - freshenv=True, - ) - app.build() - return app - - -def _wheel_conf_extra() -> str: - return f""" -html_static_path = ["_static"] -pyrepl_autodoc_packages = {WHEEL_PATH!r} -""" - - -def test_autodoc_packages_emits_configured_wheel(tmp_path): - wheels_dir = tmp_path / "docs" / "_static" / "wheels" - wheels_dir.mkdir(parents=True) - shutil.copy2(FIXTURES / "wheels" / WHEEL_NAME, wheels_dir / WHEEL_NAME) - - srcdir = tmp_path / "docs" - outdir = tmp_path / "_build" - doctreedir = tmp_path / "_doctree" - - (srcdir / "conf.py").write_text( - f""" -import sys -sys.path.insert(0, {str(FIXTURES / "pyrepl_test_pkg")!r}) -extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx_pyrepl_web"] -master_doc = "index" -pyrepl_js = "pyrepl.js" -pyrepl_doctest_blocks = "autodoc" -{_wheel_conf_extra()} -""", - encoding="utf-8", - ) - (srcdir / "index.rst").write_text( - ".. autofunction:: pyrepl_test_pkg.demo.example_generator\n", - encoding="utf-8", - ) - - app = _build_sphinx(srcdir, outdir, doctreedir) - - html = (outdir / "index.html").read_text(encoding="utf-8") - assert f'packages="{WHEEL_PATH}"' in html - assert 'replay-src="_static/pyrepl/index-1.py"' in html - pyrepl_tag = html[html.index("", html.index("")] - assert 'src="_static/pyrepl/index-1-bootstrap.py"' in pyrepl_tag - assert (outdir / "_static" / "wheels" / WHEEL_NAME).is_file() - - doctree = app.env.get_doctree("index") - assert doctree.get("pyrepl") - - replay_files = json.loads( - app.env.metadata["index"].get("pyrepl-replay-files", "{}") - ) - assert list(replay_files) == ["index-1.py"] - assert replay_files["index-1.py"] == ( - "print([i for i in example_generator(4)])\n" - ) - - bootstrap_files = json.loads( - app.env.metadata["index"].get("pyrepl-bootstrap-files", "{}") - ) - assert list(bootstrap_files) == ["index-1-bootstrap.py"] - assert bootstrap_files["index-1-bootstrap.py"] == ( - "from pyrepl_test_pkg.demo import example_generator\n" - ) - assert (outdir / "_static" / "pyrepl" / "index-1-bootstrap.py").is_file() - - -def test_autodoc_packages_for_out_of_tree_module(tmp_path): - pkg_dir = tmp_path / "installed_pkg" - pkg_dir.mkdir() - (pkg_dir / "__init__.py").write_text( - ''' -class Widget: - """A demo widget. - - Example: - >>> w = Widget() - >>> w.label - 'ready' - - """ - label = "ready" -'''.strip() - + "\n", - encoding="utf-8", - ) - - wheels_dir = tmp_path / "docs" / "_static" / "wheels" - wheels_dir.mkdir(parents=True) - shutil.copy2(FIXTURES / "wheels" / WHEEL_NAME, wheels_dir / WHEEL_NAME) - - srcdir = tmp_path / "docs" - outdir = tmp_path / "_build" - doctreedir = tmp_path / "_doctree" - - (srcdir / "conf.py").write_text( - f""" -import sys -sys.path.insert(0, {str(pkg_dir.parent)!r}) -extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx_pyrepl_web"] -master_doc = "index" -pyrepl_js = "pyrepl.js" -pyrepl_doctest_blocks = "autodoc" -{_wheel_conf_extra()} -""", - encoding="utf-8", - ) - (srcdir / "index.rst").write_text(".. autoclass:: installed_pkg.Widget\n", encoding="utf-8") - - app = _build_sphinx(srcdir, outdir, doctreedir) - - html = (outdir / "index.html").read_text(encoding="utf-8") - assert f'packages="{WHEEL_PATH}"' in html - assert 'replay-src="_static/pyrepl/index-1.py"' in html - pyrepl_tag = html[html.index("", html.index("")] - assert 'src="_static/pyrepl/index-1-bootstrap.py"' in pyrepl_tag - - bootstrap_files = json.loads( - app.env.metadata["index"].get("pyrepl-bootstrap-files", "{}") - ) - assert bootstrap_files["index-1-bootstrap.py"] == "from installed_pkg import Widget\n" - - -def test_autodoc_without_packages_is_replay_only(tmp_path): - pkg_dir = tmp_path / "installed_pkg" - pkg_dir.mkdir() - (pkg_dir / "__init__.py").write_text( - ''' -class Widget: - """A demo widget. - - Example: - >>> w = Widget() - >>> w.label - 'ready' - - """ - label = "ready" -'''.strip() - + "\n", - encoding="utf-8", - ) - - srcdir = tmp_path / "docs" - srcdir.mkdir() - outdir = tmp_path / "_build" - doctreedir = tmp_path / "_doctree" - - (srcdir / "conf.py").write_text( - f""" -import sys -sys.path.insert(0, {str(pkg_dir.parent)!r}) -extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx_pyrepl_web"] -master_doc = "index" -pyrepl_js = "pyrepl.js" -pyrepl_doctest_blocks = "autodoc" -""", - encoding="utf-8", - ) - (srcdir / "index.rst").write_text(".. autoclass:: installed_pkg.Widget\n", encoding="utf-8") - - _build_sphinx(srcdir, outdir, doctreedir) - - html = (outdir / "index.html").read_text(encoding="utf-8") - assert 'replay-src="_static/pyrepl/index-1.py"' in html - pyrepl_tag = html[html.index("", html.index("")] - assert 'packages="' not in pyrepl_tag - assert ' src="' not in pyrepl_tag - - -def test_autodoc_packages_config_helper(): - app = MagicMock() - app.config.pyrepl_autodoc_packages = WHEEL_PATH - assert _autodoc_packages(app) == WHEEL_PATH - - app.config.pyrepl_autodoc_packages = "" - assert _autodoc_packages(app) is None - - app.config.pyrepl_autodoc_packages = None - assert _autodoc_packages(app) is None diff --git a/tests/test_autodoc_bootstrap_source.py b/tests/test_autodoc_bootstrap_source.py deleted file mode 100644 index 4a0e35b..0000000 --- a/tests/test_autodoc_bootstrap_source.py +++ /dev/null @@ -1,29 +0,0 @@ -from sphinx_pyrepl_web import autodoc_bootstrap_source - - -def test_function_import(): - assert autodoc_bootstrap_source( - "pyrepl_test_pkg.demo", "example_generator", "function" - ) == "from pyrepl_test_pkg.demo import example_generator\n" - - -def test_class_import(): - assert autodoc_bootstrap_source("installed_pkg", "Widget", "class") == ( - "from installed_pkg import Widget\n" - ) - - -def test_method_imports_outer_class(): - assert autodoc_bootstrap_source("pkg.mod", "Widget.label", "method") == ( - "from pkg.mod import Widget\n" - ) - - -def test_module_import(): - assert autodoc_bootstrap_source("pyrepl_test_pkg.demo", "", "module") == ( - "import pyrepl_test_pkg.demo\n" - ) - - -def test_missing_module_returns_none(): - assert autodoc_bootstrap_source(None, "foo", "function") is None diff --git a/tests/test_autodoc_doctest.py b/tests/test_autodoc_doctest.py deleted file mode 100644 index 1433396..0000000 --- a/tests/test_autodoc_doctest.py +++ /dev/null @@ -1,158 +0,0 @@ -import json -from pathlib import Path - -import pytest -from sphinx.application import Sphinx - -ROOT = Path(__file__).resolve().parents[1] - -EXAMPLE_GENERATOR_SOURCE = ''' -def example_generator(n): - """Generators have a ``Yields`` section instead of a ``Returns`` section. - - Examples: - Examples should be written in doctest format, and should illustrate how - to use the function. - - >>> print([i for i in example_generator(4)]) - [0, 1, 2, 3] - - """ - yield from range(n) -'''.strip() + "\n" - - -@pytest.fixture -def autodoc_project(tmp_path): - mod_dir = tmp_path / "pkg" - mod_dir.mkdir() - (mod_dir / "repl_test_demo.py").write_text(EXAMPLE_GENERATOR_SOURCE, encoding="utf-8") - - srcdir = tmp_path / "docs" - srcdir.mkdir() - outdir = tmp_path / "_build" - doctreedir = tmp_path / "_doctree" - - (srcdir / "conf.py").write_text( - f""" -import sys -sys.path.insert(0, {str(mod_dir)!r}) -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.napoleon", - "sphinx_pyrepl_web", -] -master_doc = "index" -pyrepl_js = "pyrepl.js" -pyrepl_doctest_blocks = "autodoc" -""", - encoding="utf-8", - ) - (srcdir / "index.rst").write_text( - """ -API -=== - -.. autofunction:: repl_test_demo.example_generator - -Tutorial -======== - -Plain doctest (outside autodoc): - ->>> x = 1 ->>> x + 1 -2 -""".strip() - + "\n", - encoding="utf-8", - ) - return srcdir, outdir, doctreedir, mod_dir - - -def _build_sphinx(srcdir, outdir, doctreedir, **kwargs): - outdir.mkdir(parents=True, exist_ok=True) - doctreedir.mkdir(parents=True, exist_ok=True) - with open(outdir / "warnings.txt", "w", encoding="utf-8") as warning_file: - app = Sphinx( - srcdir=str(srcdir), - confdir=str(srcdir), - outdir=str(outdir), - doctreedir=str(doctreedir), - buildername="html", - warning=warning_file, - freshenv=True, - **kwargs, - ) - app.build() - return app - - -def test_autodoc_doctest_becomes_pyrepl(autodoc_project): - srcdir, outdir, doctreedir, _ = autodoc_project - app = _build_sphinx(srcdir, outdir, doctreedir) - - replay_files = json.loads( - app.env.metadata["index"].get("pyrepl-replay-files", "{}") - ) - assert len(replay_files) == 1 - script_name = next(iter(replay_files)) - script = replay_files[script_name] - assert script == "print([i for i in example_generator(4)])\n" - assert "[0, 1, 2, 3]" not in script - - html = (outdir / "index.html").read_text(encoding="utf-8") - assert f'replay-src="_static/pyrepl/{script_name}"' in html - assert 'packages="' not in html - assert "no-header" in html - assert "no-banner" in html - - script_path = outdir / "_static" / "pyrepl" / script_name - assert script_path.is_file(), f"missing replay script at {script_path}" - - -def test_autodoc_scope_skips_plain_rst_doctest(autodoc_project): - srcdir, outdir, doctreedir, _ = autodoc_project - app = _build_sphinx(srcdir, outdir, doctreedir) - - replay_files = json.loads( - app.env.metadata["index"].get("pyrepl-replay-files", "{}") - ) - assert len(replay_files) == 1 - - html = (outdir / "index.html").read_text(encoding="utf-8") - assert html.count("replay-src=") == 1 - - -def test_all_scope_transforms_plain_rst_doctest(autodoc_project): - srcdir, outdir, doctreedir, _ = autodoc_project - conf = (srcdir / "conf.py").read_text(encoding="utf-8") - (srcdir / "conf.py").write_text( - conf + '\npyrepl_doctest_blocks = "all"\n', encoding="utf-8" - ) - app = _build_sphinx(srcdir, outdir, doctreedir) - - replay_files = json.loads( - app.env.metadata["index"].get("pyrepl-replay-files", "{}") - ) - assert len(replay_files) == 2 - - html = (outdir / "index.html").read_text(encoding="utf-8") - assert html.count("replay-src=") == 2 - - -def test_default_scope_leaves_doctest_static(autodoc_project): - srcdir, outdir, doctreedir, _ = autodoc_project - conf = (srcdir / "conf.py").read_text(encoding="utf-8") - (srcdir / "conf.py").write_text( - conf.replace('pyrepl_doctest_blocks = "autodoc"\n', ""), encoding="utf-8" - ) - app = _build_sphinx(srcdir, outdir, doctreedir) - - replay_files = json.loads( - app.env.metadata["index"].get("pyrepl-replay-files", "{}") - ) - assert replay_files == {} - - html = (outdir / "index.html").read_text(encoding="utf-8") - assert "replay-src=" not in html diff --git a/tests/test_build_replay.py b/tests/test_build_replay.py deleted file mode 100644 index 622bb94..0000000 --- a/tests/test_build_replay.py +++ /dev/null @@ -1,134 +0,0 @@ -import json -import sys -from pathlib import Path - -import pytest -from sphinx.application import Sphinx - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT)) - - -@pytest.fixture -def sphinx_project(tmp_path): - srcdir = tmp_path / "docs" - srcdir.mkdir() - outdir = tmp_path / "_build" - doctreedir = tmp_path / "_doctree" - (srcdir / "conf.py").write_text( - "extensions = ['sphinx_pyrepl_web']\n" - "pyrepl_js = 'pyrepl.js'\n" - "master_doc = 'index'\n", - encoding="utf-8", - ) - (srcdir / "index.rst").write_text( - """ -Example -======= - -.. py-repl:: - :no-header: - - >>> x = 2 + 2 - >>> print(x) -""".strip() - + "\n", - encoding="utf-8", - ) - return srcdir, outdir, doctreedir - - -def test_build_writes_replay_script_from_metadata(sphinx_project): - srcdir, outdir, doctreedir = sphinx_project - outdir.mkdir(parents=True, exist_ok=True) - doctreedir.mkdir(parents=True, exist_ok=True) - with open(outdir / "warnings.txt", "w", encoding="utf-8") as warning_file: - app = Sphinx( - srcdir=str(srcdir), - confdir=str(srcdir), - outdir=str(outdir), - doctreedir=str(doctreedir), - buildername="html", - warning=warning_file, - freshenv=True, - ) - app.build() - - replay_files = json.loads(app.env.metadata["index"].get("pyrepl-replay-files", "{}")) - assert replay_files - script_name = next(iter(replay_files)) - script_path = outdir / "_static" / "pyrepl" / script_name - assert script_path.is_file(), f"missing replay script at {script_path}" - assert "x = 2 + 2" in script_path.read_text(encoding="utf-8") - - html = (outdir / "index.html").read_text(encoding="utf-8") - assert f'replay-src="_static/pyrepl/{script_name}"' in html - - -def test_build_writes_replay_script_with_parallel_read(sphinx_project): - srcdir, outdir, doctreedir = sphinx_project - outdir.mkdir(parents=True, exist_ok=True) - doctreedir.mkdir(parents=True, exist_ok=True) - with open(outdir / "warnings.txt", "w", encoding="utf-8") as warning_file: - app = Sphinx( - srcdir=str(srcdir), - confdir=str(srcdir), - outdir=str(outdir), - doctreedir=str(doctreedir), - buildername="html", - warning=warning_file, - freshenv=True, - parallel=2, - ) - app.build() - - script_path = outdir / "_static" / "pyrepl" / "index-1.py" - assert script_path.is_file(), f"missing replay script at {script_path}" - - -def test_build_omits_bare_doctest_terminator(tmp_path): - srcdir = tmp_path / "docs" - srcdir.mkdir() - outdir = tmp_path / "_build" - doctreedir = tmp_path / "_doctree" - (srcdir / "conf.py").write_text( - "extensions = ['sphinx_pyrepl_web']\n" - "pyrepl_js = 'pyrepl.js'\n" - "master_doc = 'index'\n", - encoding="utf-8", - ) - (srcdir / "index.rst").write_text( - """ -Example -======= - -.. py-repl:: - :no-header: - - >>> class Foo: - ... x = 1 - ... - >>> Foo() -""".strip() - + "\n", - encoding="utf-8", - ) - outdir.mkdir(parents=True, exist_ok=True) - doctreedir.mkdir(parents=True, exist_ok=True) - with open(outdir / "warnings.txt", "w", encoding="utf-8") as warning_file: - app = Sphinx( - srcdir=str(srcdir), - confdir=str(srcdir), - outdir=str(outdir), - doctreedir=str(doctreedir), - buildername="html", - warning=warning_file, - freshenv=True, - ) - app.build() - - script_path = outdir / "_static" / "pyrepl" / "index-1.py" - script = script_path.read_text(encoding="utf-8") - assert "class Foo:" in script - assert "\n...\n" not in script - assert " x = 1\n\nFoo()" in script diff --git a/tests/test_doctest_to_replay_source.py b/tests/test_doctest_to_replay_source.py deleted file mode 100644 index d1fb725..0000000 --- a/tests/test_doctest_to_replay_source.py +++ /dev/null @@ -1,55 +0,0 @@ -from sphinx_pyrepl_web import doctest_to_replay_source - - -def test_strips_expected_output(): - block = ">>> add(1, 2)\n3\n>>> add(0, 0)\n0" - assert doctest_to_replay_source(block) == "add(1, 2)\n\nadd(0, 0)\n" - - -def test_example_generator(): - block = ">>> print([i for i in example_generator(4)])\n[0, 1, 2, 3]" - assert doctest_to_replay_source(block) == ( - "print([i for i in example_generator(4)])\n" - ) - - -def test_multiline_class(): - block = ">>> class Foo:\n... x = 1\n...\n>>> Foo()\n" - assert doctest_to_replay_source(block) == "class Foo:\n x = 1\n\nFoo()\n" - - -def test_name_class_example(): - block = ( - ">>> from naming import Name\n" - ">>> class MyName(Name):\n" - "... config = dict(base=r'\\w+')\n" - "...\n" - ">>> n = MyName()\n" - "'{base}'" - ) - assert doctest_to_replay_source(block) == ( - "from naming import Name\n" - "\n" - "class MyName(Name):\n" - " config = dict(base=r'\\w+')\n" - "\n" - "n = MyName()\n" - ) - - -def test_from_directive_lines(): - lines = [ - ">>> class Foo:", - "... x = 1", - "...", - ">>> Foo()", - ] - assert doctest_to_replay_source(lines) == "class Foo:\n x = 1\n\nFoo()\n" - - -def test_empty_for_non_doctest(): - assert doctest_to_replay_source("not a doctest") == "" - - -def test_explicit_ellipsis(): - assert doctest_to_replay_source([">>> ..."]) == "...\n" diff --git a/tests/test_local_wheel.py b/tests/test_local_wheel.py deleted file mode 100644 index 8cb19f4..0000000 --- a/tests/test_local_wheel.py +++ /dev/null @@ -1,148 +0,0 @@ -import json -import shutil -import sys -from pathlib import Path - -import pytest -from sphinx.application import Sphinx -from sphinx_pytest.plugin import CreateDoctree - -ROOT = Path(__file__).resolve().parents[1] -FIXTURES = Path(__file__).resolve().parent / "fixtures" -WHEEL_NAME = "pyrepl_test_pkg-1.0.0-py3-none-any.whl" -WHEEL_PATH = f"_static/wheels/{WHEEL_NAME}" - -sys.path.insert(0, str(ROOT)) - - -def _build_sphinx(srcdir: Path, outdir: Path, doctreedir: Path, **kwargs) -> Sphinx: - outdir.mkdir(parents=True, exist_ok=True) - doctreedir.mkdir(parents=True, exist_ok=True) - with open(outdir / "warnings.txt", "w", encoding="utf-8") as warning_file: - app = Sphinx( - srcdir=str(srcdir), - confdir=str(srcdir), - outdir=str(outdir), - doctreedir=str(doctreedir), - buildername="html", - warning=warning_file, - freshenv=True, - **kwargs, - ) - app.build() - return app - - -@pytest.fixture -def wheel_project(tmp_path): - srcdir = tmp_path / "docs" - wheels_dir = srcdir / "_static" / "wheels" - wheels_dir.mkdir(parents=True) - shutil.copy2(FIXTURES / "wheels" / WHEEL_NAME, wheels_dir / WHEEL_NAME) - (srcdir / "_static" / "bootstrap.py").write_text( - "# Optional post-install bootstrap for local wheel REPLs.\n", - encoding="utf-8", - ) - - outdir = tmp_path / "_build" - doctreedir = tmp_path / "_doctree" - (srcdir / "conf.py").write_text( - """ -extensions = ["sphinx_pyrepl_web"] -master_doc = "index" -pyrepl_js = "pyrepl.js" -html_static_path = ["_static"] -""", - encoding="utf-8", - ) - return srcdir, outdir, doctreedir - - -def test_local_wheel_packages_emitted_in_doctree(sphinx_doctree: CreateDoctree): - sphinx_doctree.set_conf({"extensions": ["sphinx_pyrepl_web"]}) - sphinx_doctree.buildername = "html" - result = sphinx_doctree( - f""" -.. py-repl:: - :packages: {WHEEL_PATH} - :no-header: -""" - ) - html = result.pformat() - assert f'packages="{WHEEL_PATH}"' in html - - -def test_local_wheel_copied_to_output_tree(wheel_project): - srcdir, outdir, doctreedir = wheel_project - (srcdir / "index.rst").write_text( - f""" -Example -======= - -.. py-repl:: - :packages: {WHEEL_PATH} - :no-header: -""", - encoding="utf-8", - ) - - _build_sphinx(srcdir, outdir, doctreedir) - - wheel_out = outdir / "_static" / "wheels" / WHEEL_NAME - assert wheel_out.is_file(), f"missing wheel at {wheel_out}" - - html = (outdir / "index.html").read_text(encoding="utf-8") - assert f'packages="{WHEEL_PATH}"' in html - assert "pyrepl.js" in html - - -def test_local_wheel_with_src_and_replay_body(wheel_project): - srcdir, outdir, doctreedir = wheel_project - (srcdir / "index.rst").write_text( - f""" -Example -======= - -.. py-repl:: - :packages: {WHEEL_PATH} - :src: _static/bootstrap.py - :repl-title: test - :no-banner: - - >>> import pyrepl_test_pkg - >>> pyrepl_test_pkg.ping() -""", - encoding="utf-8", - ) - - app = _build_sphinx(srcdir, outdir, doctreedir) - - assert (outdir / "_static" / "wheels" / WHEEL_NAME).is_file() - assert (outdir / "_static" / "bootstrap.py").is_file() - - replay_files = json.loads( - app.env.metadata["index"].get("pyrepl-replay-files", "{}") - ) - assert len(replay_files) == 1 - script_name = next(iter(replay_files)) - assert (outdir / "_static" / "pyrepl" / script_name).is_file() - - html = (outdir / "index.html").read_text(encoding="utf-8") - assert f'packages="{WHEEL_PATH}"' in html - assert 'src="_static/bootstrap.py"' in html - assert f'replay-src="_static/pyrepl/{script_name}"' in html - - -def test_comma_separated_local_wheel_and_pypi_package(sphinx_doctree: CreateDoctree): - sphinx_doctree.set_conf({"extensions": ["sphinx_pyrepl_web"]}) - sphinx_doctree.buildername = "html" - packages = f"numpy, {WHEEL_PATH}" - result = sphinx_doctree( - f""" -.. py-repl:: - :packages: {packages} - :no-header: -""" - ) - html = result.pformat() - assert f'packages="{packages}"' in html diff --git a/tests/test_nested_static_paths.py b/tests/test_nested_static_paths.py deleted file mode 100644 index 2acc8cc..0000000 --- a/tests/test_nested_static_paths.py +++ /dev/null @@ -1,77 +0,0 @@ -import shutil -import sys -from pathlib import Path - -from sphinx.application import Sphinx - -ROOT = Path(__file__).resolve().parents[1] -FIXTURES = Path(__file__).resolve().parent / "fixtures" -WHEEL_NAME = "pyrepl_test_pkg-1.0.0-py3-none-any.whl" -WHEEL_PATH = f"_static/wheels/{WHEEL_NAME}" - -sys.path.insert(0, str(ROOT)) - - -def _build_sphinx(srcdir: Path, outdir: Path, doctreedir: Path) -> Sphinx: - outdir.mkdir(parents=True, exist_ok=True) - doctreedir.mkdir(parents=True, exist_ok=True) - with open(outdir / "warnings.txt", "w", encoding="utf-8") as warning_file: - app = Sphinx( - srcdir=str(srcdir), - confdir=str(srcdir), - outdir=str(outdir), - doctreedir=str(doctreedir), - buildername="html", - warning=warning_file, - freshenv=True, - ) - app.build() - return app - - -def test_nested_page_emits_page_relative_static_paths(tmp_path): - srcdir = tmp_path / "docs" - wheels_dir = srcdir / "_static" / "wheels" - wheels_dir.mkdir(parents=True) - shutil.copy2(FIXTURES / "wheels" / WHEEL_NAME, wheels_dir / WHEEL_NAME) - (srcdir / "api").mkdir() - outdir = tmp_path / "_build" - doctreedir = tmp_path / "_doctree" - - (srcdir / "conf.py").write_text( - """ -extensions = ["sphinx_pyrepl_web"] -master_doc = "index" -pyrepl_js = "pyrepl.js" -html_static_path = ["_static"] -""", - encoding="utf-8", - ) - (srcdir / "index.rst").write_text("Home\n====\n", encoding="utf-8") - (srcdir / "api" / "index.rst").write_text( - f""" -API -=== - -.. py-repl:: - :packages: {WHEEL_PATH} - :no-header: - - >>> 1 + 1 -""", - encoding="utf-8", - ) - - app = _build_sphinx(srcdir, outdir, doctreedir) - - html = (outdir / "api" / "index.html").read_text(encoding="utf-8") - assert 'packages="../_static/wheels/' in html - assert 'packages="api/_static/' not in html - assert 'packages="/_static/' not in html - assert 'replay-src="../_static/pyrepl/api-index-1.py"' in html - - root_html = (outdir / "index.html").read_text(encoding="utf-8") - assert "py-repl" not in root_html - - replay_files = app.env.metadata["api/index"].get("pyrepl-replay-files") - assert replay_files is not None diff --git a/tests/unit/test_asset_href.py b/tests/unit/test_asset_href.py new file mode 100644 index 0000000..66d39f6 --- /dev/null +++ b/tests/unit/test_asset_href.py @@ -0,0 +1,53 @@ +from unittest.mock import MagicMock + +import pytest + +from tests.helpers import mock_html_builder +from sphinx_pyrepl_web import _asset_href, _asset_href_packages + + +@pytest.mark.parametrize( + ("docname", "path", "expected"), + [ + ("api/module", "_static/wheels/foo.whl", "../_static/wheels/foo.whl"), + ("index", "_static/wheels/foo.whl", "_static/wheels/foo.whl"), + ("api/module", "/_static/wheels/foo.whl", "/_static/wheels/foo.whl"), + ("api/module", "https://cdn.example/w.whl", "https://cdn.example/w.whl"), + ("api/module", "numpy", "numpy"), + ("api/module", "demo.py", "../demo.py"), + ( + "api/module", + "mypkg @ https://example.com/wheels/mypkg.whl", + "mypkg @ https://example.com/wheels/mypkg.whl", + ), + ], + ids=[ + "nested-static", + "root-static", + "root-absolute", + "https-url", + "pypi-name", + "src-relative", + "micropip-spec", + ], +) +def test_asset_href(docname, path, expected): + builder = mock_html_builder() + assert _asset_href(builder, docname, path) == expected + + +def test_asset_href_packages_rewrites_only_file_like_entries(): + builder = mock_html_builder() + packages = "numpy, _static/wheels/foo.whl" + assert _asset_href_packages(builder, "api/module", packages) == ( + "numpy, ../_static/wheels/foo.whl" + ) + + +def test_asset_href_skips_normalization_for_non_html_builder(): + builder = MagicMock() + builder.format = "" + assert ( + _asset_href(builder, "api/module", "_static/wheels/foo.whl") + == "_static/wheels/foo.whl" + ) diff --git a/tests/unit/test_autodoc_bootstrap_source.py b/tests/unit/test_autodoc_bootstrap_source.py new file mode 100644 index 0000000..32c4ca6 --- /dev/null +++ b/tests/unit/test_autodoc_bootstrap_source.py @@ -0,0 +1,38 @@ +import pytest + +from sphinx_pyrepl_web import autodoc_bootstrap_source + + +@pytest.mark.parametrize( + ("module", "fullname", "objtype", "expected"), + [ + ( + "pyrepl_test_pkg.demo", + "example_generator", + "function", + "from pyrepl_test_pkg.demo import example_generator\n", + ), + ( + "installed_pkg", + "Widget", + "class", + "from installed_pkg import Widget\n", + ), + ( + "pkg.mod", + "Widget.label", + "method", + "from pkg.mod import Widget\n", + ), + ( + "pyrepl_test_pkg.demo", + "", + "module", + "import pyrepl_test_pkg.demo\n", + ), + (None, "foo", "function", None), + ], + ids=["function", "class", "method", "module", "missing-module"], +) +def test_autodoc_bootstrap_source(module, fullname, objtype, expected): + assert autodoc_bootstrap_source(module, fullname, objtype) == expected diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..de0668f --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,20 @@ +import pytest +from unittest.mock import MagicMock + +from tests.support import WHEEL_PATH +from sphinx_pyrepl_web import _autodoc_packages + + +@pytest.mark.parametrize( + ("configured", "expected"), + [ + (WHEEL_PATH, WHEEL_PATH), + ("", None), + (None, None), + ], + ids=["wheel-path", "empty-string", "none"], +) +def test_autodoc_packages(configured, expected): + app = MagicMock() + app.config.pyrepl_autodoc_packages = configured + assert _autodoc_packages(app) == expected diff --git a/tests/unit/test_copy_assets.py b/tests/unit/test_copy_assets.py new file mode 100644 index 0000000..78c182e --- /dev/null +++ b/tests/unit/test_copy_assets.py @@ -0,0 +1,55 @@ +import json +from unittest.mock import MagicMock + +from docutils import nodes +from docutils.utils import new_document + +from sphinx_pyrepl_web import PYREPL_DIR, REPLAY_FILES_KEY, copy_asset_files, transform_doctest_blocks + + +def test_copy_asset_files_skips_non_html_builder(tmp_path): + outdir = tmp_path / "out" + outdir.mkdir() + + app = MagicMock() + app.builder.format = "latex" + app.builder.outdir = str(outdir) + app.env.metadata = { + "index": { + REPLAY_FILES_KEY: json.dumps({"index-1.py": "print('hi')\n"}), + } + } + + copy_asset_files(app, None) + + assert not any(outdir.iterdir()) + assert not (outdir / "pyrepl.js").exists() + assert not (outdir / "_static").exists() + for asset in PYREPL_DIR.iterdir(): + if asset.is_file(): + assert not (outdir / asset.name).exists() + + +def test_copy_asset_files_skips_empty_replay_metadata(tmp_path): + app = MagicMock() + app.builder.format = "html" + app.builder.outdir = str(tmp_path / "out") + app.env.metadata = {"index": {REPLAY_FILES_KEY: json.dumps({})}} + copy_asset_files(app, None) + assert not (tmp_path / "out" / "_static" / "pyrepl").exists() + + +def test_transform_skips_empty_converted_doctest(): + app = MagicMock() + app.config.pyrepl_doctest_blocks = "all" + app.env.docname = "index" + app.env.metadata = {"index": {}} + app.builder.format = "html" + + doctree = new_document("") + block = nodes.doctest_block("", "") + doctree += block + + transform_doctest_blocks(app, doctree) + assert len(list(doctree.findall(nodes.doctest_block))) == 1 + assert len(list(doctree.findall(nodes.raw))) == 0 diff --git a/tests/unit/test_doctest_to_replay_source.py b/tests/unit/test_doctest_to_replay_source.py new file mode 100644 index 0000000..bd9a5ec --- /dev/null +++ b/tests/unit/test_doctest_to_replay_source.py @@ -0,0 +1,64 @@ +import pytest + +from sphinx_pyrepl_web import doctest_to_replay_source + +MULTILINE_CLASS_EXPECTED = "class Foo:\n x = 1\n\nFoo()\n" + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + ( + ">>> add(1, 2)\n3\n>>> add(0, 0)\n0", + "add(1, 2)\n\nadd(0, 0)\n", + ), + ( + ">>> print([i for i in example_generator(4)])\n[0, 1, 2, 3]", + "print([i for i in example_generator(4)])\n", + ), + ( + ">>> class Foo:\n... x = 1\n...\n>>> Foo()\n", + MULTILINE_CLASS_EXPECTED, + ), + ( + ( + ">>> from naming import Name\n" + ">>> class MyName(Name):\n" + "... config = dict(base=r'\\w+')\n" + "...\n" + ">>> n = MyName()\n" + "'{base}'" + ), + ( + "from naming import Name\n" + "\n" + "class MyName(Name):\n" + " config = dict(base=r'\\w+')\n" + "\n" + "n = MyName()\n" + ), + ), + ( + [ + ">>> class Foo:", + "... x = 1", + "...", + ">>> Foo()", + ], + MULTILINE_CLASS_EXPECTED, + ), + ("not a doctest", ""), + ([">>> ..."], "...\n"), + ], + ids=[ + "strips-output", + "example-generator", + "multiline-class", + "name-class", + "directive-lines", + "non-doctest", + "explicit-ellipsis", + ], +) +def test_doctest_to_replay_source(source, expected): + assert doctest_to_replay_source(source) == expected From 1e722ff47560abca15442517413d272150af5df9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Sun, 5 Jul 2026 14:24:54 +1000 Subject: [PATCH 07/16] remove silent from directive as there's no use case for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian López Barrón --- README.md | 29 ++++++++++---------------- docs/example.rst | 2 +- sphinx_pyrepl_web/__init__.py | 6 +----- tests/doctree/test_pyrepl_directive.py | 15 ------------- 4 files changed, 13 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 4c6d69c..edffe1c 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,6 @@ Sphinx extension to embed [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web) pip install sphinx-pyrepl-web ``` -For development: - -```bash -pip install -e ".[test,docs]" -``` - ## Usage Add the extension to the target project's `conf.py`: @@ -46,20 +40,19 @@ Embed a REPL with the `py-repl` directive: ### Directive options -All options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attributes, with the exception of `silent`: +All options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attributes: -| Option | Description | -|--------|-------------| -| `:theme:` | Color theme (`catppuccin-mocha`, `catppuccin-latte`) | -| `:packages:` | Comma-separated PyPI packages or local wheel paths under `_static/wheels/` | -| `:repl-title:` | Title in the REPL header | -| `:src:` | Path to a Python startup script | +| Option | Description | +|--------|----------------------------------------------------------------| +| `:theme:` | Color theme (`catppuccin-mocha`, `catppuccin-latte`) | +| `:packages:` | Comma-separated PyPI packages, URLs or relative wheel paths | +| `:repl-title:` | Title in the REPL header | +| `:src:` | Path to a Python startup script | | `:replay:` | Replay `:src:` with interactive prompts instead of silent load | -| `:silent:` | Keep `:src:` silent even when combined with a directive body | -| `:no-header:` | Hide the header bar | -| `:no-buttons:` | Hide copy/clear buttons | -| `:readonly:` | Disable input | -| `:no-banner:` | Hide the Python version banner | +| `:no-header:` | Hide the header bar | +| `:no-buttons:` | Hide copy/clear buttons | +| `:readonly:` | Disable input | +| `:no-banner:` | Hide the Python version banner | Python code within the `.. py-repl::` directive is written to `_static/pyrepl/` at build time and emitted as `replay-src`. diff --git a/docs/example.rst b/docs/example.rst index 77573bc..c58f5c2 100644 --- a/docs/example.rst +++ b/docs/example.rst @@ -79,7 +79,7 @@ Inline directive content should follow Doctest-style (``>>>`` / ``...``) and is ... >>> Foo() -Combine a silent bootstrap file with a visible replay body: +Combine a startup script with a visible replay body: .. code-block:: rst diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index 098f020..21d29e2 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -234,7 +234,6 @@ class PyRepl(SphinxDirective): "readonly": directives.flag, "no-banner": directives.flag, "replay": directives.flag, - "silent": directives.flag, } def run(self): @@ -260,7 +259,6 @@ def run(self): has_body = bool(self.content) force_replay = "replay" in self.options - force_silent = "silent" in self.options if "src" in self.options: _, abs_path = self.env.relfn2path(self.options["src"]) @@ -285,11 +283,9 @@ def run(self): startup_files ) + attrs.append(f'src="{rel_src}"') if force_replay and not has_body: - attrs.append(f'src="{rel_src}"') attrs.append("replay") - elif not (force_silent and not has_body): - attrs.append(f'src="{rel_src}"') if has_body: body_text = doctest_to_replay_source(list(self.content)) diff --git a/tests/doctree/test_pyrepl_directive.py b/tests/doctree/test_pyrepl_directive.py index 45b0084..6f759af 100644 --- a/tests/doctree/test_pyrepl_directive.py +++ b/tests/doctree/test_pyrepl_directive.py @@ -80,21 +80,6 @@ def test_pyrepl_directive_options( assert fragment in html -def test_silent_src_omitted_without_body(sphinx_doctree: CreateDoctree): - sphinx_doctree.set_conf({"extensions": ["sphinx_pyrepl_web"], "root_doc": "index"}) - sphinx_doctree.buildername = "html" - (sphinx_doctree.srcdir / "demo.py").write_text("print('hi')\n", encoding="utf-8") - result = sphinx_doctree( - """ -.. py-repl:: - :silent: - :src: demo.py -""" - ) - html = result.pformat() - assert 'src="demo.py"' not in html - - def test_missing_src_file_reports_error(sphinx_doctree: CreateDoctree): sphinx_doctree.set_conf({"extensions": ["sphinx_pyrepl_web"]}) sphinx_doctree.buildername = "html" From 34b0eb8c7aae4db79b6cabbe03a48701e8eb22e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Sun, 5 Jul 2026 14:26:26 +1000 Subject: [PATCH 08/16] moving development docs to its own section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian López Barrón --- README.md | 3 --- docs/development.rst | 13 +++++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 docs/development.rst diff --git a/README.md b/README.md index edffe1c..f88b6cd 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,6 @@ All options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attrib | `:readonly:` | Disable input | | `:no-banner:` | Hide the Python version banner | -Python code within the `.. py-repl::` directive is written to `_static/pyrepl/` at build time and emitted as `replay-src`. - -File paths in `:packages:`, `:src:`, `replay-src`, and `pyrepl_autodoc_packages` are rewritten to page-relative URLs at build time so REPLs work on nested pages (for example `docs/api/...`). PyPI package names, absolute URLs, and paths you write as root-absolute (`/_static/...`) are left unchanged. Optional Sphinx config: diff --git a/docs/development.rst b/docs/development.rst new file mode 100644 index 0000000..00f763f --- /dev/null +++ b/docs/development.rst @@ -0,0 +1,13 @@ +Development +=========== + +.. code-block:: bash + + pip install -e ".[test,docs]" + +Python code within the `.. py-repl::` directive is written to `_static/pyrepl/` at build time and emitted as `replay-src`. + +File paths in `:packages:`, `:src:`, `replay-src`, and `pyrepl_autodoc_packages` are rewritten to page-relative URLs at build time so REPLs work on nested pages (for example `docs/api/...`). PyPI package names, absolute URLs, and paths you write as root-absolute (`/_static/...`) are left unchanged. + +Basic REPL +---------- \ No newline at end of file From 4c15ceb89d805220976f0c9284f794ead08a4465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Sun, 5 Jul 2026 15:08:04 +1000 Subject: [PATCH 09/16] move dev specific docs to their own section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian López Barrón --- README.md | 106 +++++++++---------------------------------- docs/development.rst | 38 ++++++++++++++++ docs/index.rst | 5 +- 3 files changed, 63 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index f88b6cd..2ef0f24 100644 --- a/README.md +++ b/README.md @@ -54,101 +54,39 @@ All options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attrib | `:readonly:` | Disable input | | `:no-banner:` | Hide the Python version banner | +### Sphinx options -Optional Sphinx config: +Enable [doctest style examples](https://docs.python.org/3/library/doctest.html) conversion into pre-configured interactive REPLs -```python -pyrepl_js = "../pyrepl.js" # default; path to the pyrepl-web loader script -pyrepl_doctest_blocks = False # default; see Docstring conversion below -pyrepl_autodoc_packages = None # optional; wheel path or PyPI name for autodoc REPLs -``` - -### Docstring conversion +#### pyrepl_doctest_blocks -Converting doctest examples from docstrings into interactive REPLs is opt-in with `sphinx.ext.autodoc`: - -```python -# conf.py -extensions = [ - "sphinx.ext.autodoc", - "sphinx_pyrepl_web", -] -pyrepl_doctest_blocks = "autodoc" -pyrepl_autodoc_packages = "_static/wheels/my_package-1.0.0-py3-none-any.whl" -``` - -| | `pyrepl_doctest_blocks` options | +| Value | Outcome | |-------------------|-------------------------------------| -| `False` (default) | Disable autodoc conversion | -| `"autodoc"` | Convert doctests found by autodoc | -| `"all"` | Transform every doctest block found | - - -| | `pyrepl_autodoc_packages` options | -|-------------------------|------------------------------------------------------------------| -| unset / `None` / `""` | Replay doctest input only (no wheel install or auto-import) | -| wheel path or PyPI name | Install the package and import the documented object before replay (comma-separated) | - -Autodoc integration assumes a single documented package. The wheel (or PyPI -name) is installed in the browser REPL and the documented name is imported -automatically so doctest examples can use unqualified names. Autodoc still -imports the package on the host at build time. +| `False` (default) | Don't convert doctest blocks | +| `"autodoc"` | Convert doctest blocks from autodoc | +| `"all"` | Convert all doctest blocks | -To build this project's docs locally: +#### pyrepl_autodoc_packages -```bash -pip install -e ".[docs]" -``` +| Value | Outcome | +|-------------------------|--------------------------------------------------------------------| +| `None` (default) | Replay doctest input without preloading packages | +| Wheel path / PyPI names | Install the package and import the documented object before replay | -The `[docs]` extra installs doc build dependencies plus the `pyrepl_test_pkg` -fixture used in the examples. +### Local wheels -### Local Pyodide wheels +Unreleased branches can be available in the REPL by building the corresponding wheel and placing it under Sphinx's `html_static_path`. -Preload a wheel built for Pyodide (for example a wasm extension or an unreleased -branch build) by placing it under the Sphinx static path and referencing it from -``:packages:``. Ensure ``html_static_path`` includes ``"_static"``: +All options combined: ```python -# conf.py -html_static_path = ["_static"] -``` - -```rst -.. py-repl:: - :packages: _static/wheels/myext-pyodide.whl - :src: _static/bootstrap.py - :no-banner: - - >>> import myext - >>> myext.ping() -``` - -Wheels under ``_static/`` are copied into the HTML output when ``_static`` is listed -in ``html_static_path`` (Sphinx does not copy project static files automatically -unless configured). At runtime, [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web) -resolves site-relative wheel paths to absolute URLs before calling -``micropip.install()``. - -**CI tip:** copy each build artifact to a stable docs filename so RST does not -need updating per release, for example -``cp dist/myext-1.2.3-*.whl docs/_static/wheels/myext-pyodide.whl``. - -Ensure the web server serves ``.whl`` files with MIME type ``application/zip`` -(Read the Docs does this by default). - -## Updating pyrepl-web - -Since [chrizzFTD/pyrepl-web](https://github.com/chrizzFTD/pyrepl-web) is a fork, this sphinx extension vendors the JavaScript assets for easier distribution. To update them, run: - -```bash -python scripts/vendor_repl.py -``` +extensions = [ + "sphinx.ext.autodoc", + "sphinx_pyrepl_web", +] -The `grill` branch is used by default. Use the `branch` argument to specify a different one: +html_static_path = ["_static"] -```bash -python scripts/vendor_repl.py --branch custom/feature-branch +pyrepl_doctest_blocks = "autodoc" +pyrepl_autodoc_packages = "_static/wheels/my_package-1.0.0-py3-none-any.whl" ``` - -This requires [git](https://git-scm.com/) and [Bun](https://bun.sh/). diff --git a/docs/development.rst b/docs/development.rst index 00f763f..43a5c4b 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -1,13 +1,51 @@ Development =========== +Git clone, then install: + .. code-block:: bash pip install -e ".[test,docs]" +The `[docs]` extra installs the `pyrepl_test_pkg` fixture used in the examples. + Python code within the `.. py-repl::` directive is written to `_static/pyrepl/` at build time and emitted as `replay-src`. File paths in `:packages:`, `:src:`, `replay-src`, and `pyrepl_autodoc_packages` are rewritten to page-relative URLs at build time so REPLs work on nested pages (for example `docs/api/...`). PyPI package names, absolute URLs, and paths you write as root-absolute (`/_static/...`) are left unchanged. +``pyrepl_js`` (default: ``"../pyrepl.js"``) sets the loader script Sphinx injects on REPL pages; the extension vendors and copies pyrepl-web automatically, so override it only for a custom loader path or CDN. + +wheels +Wheels under ``_static/`` are copied into the HTML output when ``_static`` is listed +in ``html_static_path`` (Sphinx does not copy project static files automatically +unless configured). At runtime, [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web) +resolves site-relative wheel paths to absolute URLs before calling +``micropip.install()``. + +**CI tip:** copy each build artifact to a stable docs filename so RST does not +need updating per release, for example +``cp dist/myext-1.2.3-*.whl docs/_static/wheels/myext-pyodide.whl``. + +Ensure the web server serves ``.whl`` files with MIME type ``application/zip`` +(Read the Docs does this by default). + + +## Updating pyrepl-web + +Since [chrizzFTD/pyrepl-web](https://github.com/chrizzFTD/pyrepl-web) is a fork, this sphinx extension vendors the JavaScript assets for easier distribution. To update them, run: + +```bash +python scripts/vendor_repl.py +``` + +The `grill` branch is used by default. Use the `branch` argument to specify a different one: + +```bash +python scripts/vendor_repl.py --branch custom/feature-branch +``` + +This requires [git](https://git-scm.com/) and [Bun](https://bun.sh/). + + Basic REPL ---------- \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 6744fad..bb9878e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,7 +1,8 @@ .. include:: ../README.md :parser: myst_parser.sphinx_ -.. include:: example.rst - .. toctree:: :maxdepth: 2 + + example + development From 186e581ab6ba1fab1fc2dc7d2bfb740b355439de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Sun, 5 Jul 2026 15:16:36 +1000 Subject: [PATCH 10/16] dev reformat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian López Barrón --- docs/development.rst | 72 ++++++++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 26 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index 43a5c4b..cb5a2d7 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -1,51 +1,71 @@ Development =========== -Git clone, then install: +Setup +----- + +Clone the repository, then install in editable mode with test and docs dependencies: .. code-block:: bash - pip install -e ".[test,docs]" + pip install -e ".[test,docs]" + +The ``[docs]`` extra pulls in doc build dependencies and the ``pyrepl_test_pkg`` +fixture used in the examples. + +Build-time behavior +------------------- -The `[docs]` extra installs the `pyrepl_test_pkg` fixture used in the examples. +Python code within a ``.. py-repl::`` directive is written to ``_static/pyrepl/`` +at build time and emitted as ``replay-src``. -Python code within the `.. py-repl::` directive is written to `_static/pyrepl/` at build time and emitted as `replay-src`. +File paths in ``:packages:``, ``:src:``, ``replay-src``, and +``pyrepl_autodoc_packages`` are rewritten to page-relative URLs so REPLs work on +nested pages (for example ``docs/api/...``). PyPI package names, absolute URLs, +and paths written as root-absolute (``/_static/...``) are left unchanged. -File paths in `:packages:`, `:src:`, `replay-src`, and `pyrepl_autodoc_packages` are rewritten to page-relative URLs at build time so REPLs work on nested pages (for example `docs/api/...`). PyPI package names, absolute URLs, and paths you write as root-absolute (`/_static/...`) are left unchanged. +``pyrepl_js`` (default: ``"../pyrepl.js"``) sets the loader script Sphinx injects +on REPL pages. The extension vendors and copies `pyrepl-web `_ automatically; +override this only when pointing at a custom loader path or CDN. -``pyrepl_js`` (default: ``"../pyrepl.js"``) sets the loader script Sphinx injects on REPL pages; the extension vendors and copies pyrepl-web automatically, so override it only for a custom loader path or CDN. +Static wheels +------------- -wheels -Wheels under ``_static/`` are copied into the HTML output when ``_static`` is listed -in ``html_static_path`` (Sphinx does not copy project static files automatically -unless configured). At runtime, [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web) +Wheels under ``_static/`` are copied into the HTML output when ``_static`` is +listed in ``html_static_path``. At runtime, `pyrepl-web `_ resolves site-relative wheel paths to absolute URLs before calling ``micropip.install()``. -**CI tip:** copy each build artifact to a stable docs filename so RST does not -need updating per release, for example -``cp dist/myext-1.2.3-*.whl docs/_static/wheels/myext-pyodide.whl``. +Paths must use the wheel's actual PyPI-compliant filename (for example +``myext-1.2.3-py3-none-any.whl``). ``micropip`` rejects other names. + +.. tip:: + + Copy each build artifact into ``docs/_static/wheels/`` under its original + filename, then update ``:packages:`` or ``pyrepl_autodoc_packages`` when the + version changes:: + + cp dist/myext-1.2.3-*.whl docs/_static/wheels/ Ensure the web server serves ``.whl`` files with MIME type ``application/zip`` (Read the Docs does this by default). +Updating pyrepl-web +------------------- -## Updating pyrepl-web - -Since [chrizzFTD/pyrepl-web](https://github.com/chrizzFTD/pyrepl-web) is a fork, this sphinx extension vendors the JavaScript assets for easier distribution. To update them, run: +This extension vendors JavaScript from +`pyrepl-web `_'s fork for easier +distribution. To refresh the vendored assets: -```bash -python scripts/vendor_repl.py -``` +.. code-block:: bash -The `grill` branch is used by default. Use the `branch` argument to specify a different one: + python scripts/vendor_repl.py -```bash -python scripts/vendor_repl.py --branch custom/feature-branch -``` +The ``grill`` branch is used by default. Pass ``--branch`` to vendor from another +branch: -This requires [git](https://git-scm.com/) and [Bun](https://bun.sh/). +.. code-block:: bash + python scripts/vendor_repl.py --branch custom/feature-branch -Basic REPL ----------- \ No newline at end of file +This requires `git `_ and `Bun `_. From 68abb6b67371905dec0ce4fc0e9949cf1e852a91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Sun, 5 Jul 2026 15:44:52 +1000 Subject: [PATCH 11/16] doc cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian López Barrón --- README.md | 4 ++-- docs/development.rst | 19 +++++++++++++++++++ docs/example.rst | 13 ++++--------- docs/index.rst | 7 ++++--- sphinx_pyrepl_web/__init__.py | 2 +- 5 files changed, 30 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2ef0f24..7e6e6c5 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ All options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attrib Enable [doctest style examples](https://docs.python.org/3/library/doctest.html) conversion into pre-configured interactive REPLs -#### pyrepl_doctest_blocks +`pyrepl_doctest_blocks`: | Value | Outcome | |-------------------|-------------------------------------| @@ -66,7 +66,7 @@ Enable [doctest style examples](https://docs.python.org/3/library/doctest.html) | `"autodoc"` | Convert doctest blocks from autodoc | | `"all"` | Convert all doctest blocks | -#### pyrepl_autodoc_packages +`pyrepl_autodoc_packages`: | Value | Outcome | |-------------------------|--------------------------------------------------------------------| diff --git a/docs/development.rst b/docs/development.rst index cb5a2d7..c057856 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -13,6 +13,25 @@ Clone the repository, then install in editable mode with test and docs dependenc The ``[docs]`` extra pulls in doc build dependencies and the ``pyrepl_test_pkg`` fixture used in the examples. +Build and preview docs +---------------------- + +Build HTML output from the ``docs/`` directory: + +.. code-block:: bash + + python -m sphinx -b html docs docs/_build + +REPLs load JavaScript, replay scripts, and wheels with ``fetch``, so they do not +work when you open ``index.html`` directly from disk (``file://`` URLs). Serve the +build output over HTTP instead: + +.. code-block:: bash + + python -m http.server --directory docs/_build + +Then open http://localhost:8000/ in a browser. + Build-time behavior ------------------- diff --git a/docs/example.rst b/docs/example.rst index c58f5c2..4e0450e 100644 --- a/docs/example.rst +++ b/docs/example.rst @@ -51,7 +51,7 @@ Rendered result: Replay session -------------- -Inline directive content should follow Doctest-style (``>>>`` / ``...``) and is used as replay prompts. +Inline content should follow `doctest-style `_ (``>>>`` / ``...``) and is used as replay prompts. .. code-block:: rst @@ -123,10 +123,8 @@ Rendered result: Autodoc ------- -When ``pyrepl_doctest_blocks = "autodoc"``, doctest examples in documented -APIs become interactive REPLs. Set ``pyrepl_autodoc_packages`` to install the -documented package from a Pyodide-compatible wheel (or PyPI name) and -automatically import the documented object before replay: +Use ``pyrepl_doctest_blocks = "autodoc"`` for interactive REPL examples. +Set ``pyrepl_autodoc_packages`` to install the documented package and automatically import the documented object before replay: .. code-block:: python @@ -134,12 +132,9 @@ automatically import the documented object before replay: html_static_path = ["_static"] pyrepl_autodoc_packages = "_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl" -Autodoc still imports the package on the host at build time (for example via -``pip install -e ".[docs]"`` in this repository). - Source module: -.. literalinclude:: ../../tests/fixtures/pyrepl_test_pkg/pyrepl_test_pkg/demo.py +.. literalinclude:: ../tests/fixtures/pyrepl_test_pkg/pyrepl_test_pkg/demo.py :language: python RST content: diff --git a/docs/index.rst b/docs/index.rst index bb9878e..069fb1a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,8 +1,9 @@ .. include:: ../README.md :parser: myst_parser.sphinx_ +.. include:: example.rst + +.. include:: development.rst + .. toctree:: :maxdepth: 2 - - example - development diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index 21d29e2..09ad06b 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -53,7 +53,7 @@ def _asset_href_packages(builder: Builder, docname: str, packages: str) -> str: def setup(app: Sphinx): """Setup the extension.""" app.add_config_value("pyrepl_js", "../pyrepl.js", "env") - app.add_config_value("pyrepl_doctest_blocks", False, "env") + app.add_config_value("pyrepl_doctest_blocks", False, "env", types=(bool, str)) app.add_config_value("pyrepl_autodoc_packages", None, "env") app.add_directive("py-repl", PyRepl) app.connect("doctree-read", doctree_read) From 986f99d881c9e7dc52404ee028159f5fde5418f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Sun, 5 Jul 2026 15:52:17 +1000 Subject: [PATCH 12/16] example order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian López Barrón --- docs/example.rst | 49 ++++++++++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/docs/example.rst b/docs/example.rst index 4e0450e..533ce17 100644 --- a/docs/example.rst +++ b/docs/example.rst @@ -120,6 +120,30 @@ Rendered result: :no-header: :no-banner: +Local packages +-------------- + +Use static local wheel packages on ``.. py-repl::`` directives: + +.. code-block:: rst + + .. py-repl:: + :packages: _static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl + :no-header: + :no-banner: + + >>> import pyrepl_test_pkg + >>> pyrepl_test_pkg.ping() + +.. py-repl:: + :packages: _static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl + :no-header: + :no-banner: + + >>> import pyrepl_test_pkg + >>> pyrepl_test_pkg.ping() + + Autodoc ------- @@ -130,6 +154,7 @@ Set ``pyrepl_autodoc_packages`` to install the documented package and automatica # conf.py html_static_path = ["_static"] + pyrepl_doctest_blocks = "autodoc" pyrepl_autodoc_packages = "_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl" Source module: @@ -146,27 +171,3 @@ RST content: Rendered result: .. autofunction:: pyrepl_test_pkg.demo.example_generator - -Local Pyodide wheels --------------------- - -The same wheel can be referenced manually from ``.. py-repl::`` when you want -a standalone REPL without autodoc: - -.. code-block:: rst - - .. py-repl:: - :packages: _static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl - :no-header: - :no-banner: - - >>> import pyrepl_test_pkg - >>> pyrepl_test_pkg.ping() - -.. py-repl:: - :packages: _static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl - :no-header: - :no-banner: - - >>> import pyrepl_test_pkg - >>> pyrepl_test_pkg.ping() From a8305067e9556cc238f8629099f2f089d6e6d227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Sun, 5 Jul 2026 16:09:02 +1000 Subject: [PATCH 13/16] exclulde example.rst from registering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian López Barrón --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 262ee14..9980427 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,7 +16,7 @@ pyrepl_doctest_blocks = "autodoc" pyrepl_autodoc_packages = "_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl" html_static_path = ["_static"] -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "example.rst"] html_sidebars = { "**": [ From 06da0da809832b308b36289d46ecf351e723cea9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Jul 2026 11:33:32 +0000 Subject: [PATCH 14/16] Bump version to 0.3.0 for release Breaking changes since 0.2.0: pyrepl_autodoc_bootstrap removed in favor of pyrepl_autodoc_packages; :silent: directive option removed. Co-authored-by: chrizzftd --- sphinx_pyrepl_web/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx_pyrepl_web/__init__.py b/sphinx_pyrepl_web/__init__.py index 09ad06b..9997be9 100644 --- a/sphinx_pyrepl_web/__init__.py +++ b/sphinx_pyrepl_web/__init__.py @@ -1,6 +1,6 @@ """A Sphinx extension for embedding pyrepl-web Python REPLs in documentation.""" -__version__ = "0.2.0" +__version__ = "0.3.0" import json from doctest import DocTestParser From 4f3cc279e90009f5a70614d779ea0e736bfa2589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Mon, 6 Jul 2026 07:17:46 +1000 Subject: [PATCH 15/16] doc tweaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian López Barrón --- README.md | 16 ++++++++-------- docs/development.rst | 15 +++++---------- docs/example.rst | 3 ++- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 7e6e6c5..d7d223b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ pip install sphinx-pyrepl-web ## Usage -Add the extension to the target project's `conf.py`: +Add the extension to the target project's `conf.py` module: ```python extensions = [ @@ -56,15 +56,15 @@ All options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attrib ### Sphinx options -Enable [doctest style examples](https://docs.python.org/3/library/doctest.html) conversion into pre-configured interactive REPLs +Enable [doctest style examples](https://docs.python.org/3/library/doctest.html) conversion into pre-configured interactive REPLs with: `pyrepl_doctest_blocks`: -| Value | Outcome | -|-------------------|-------------------------------------| -| `False` (default) | Don't convert doctest blocks | -| `"autodoc"` | Convert doctest blocks from autodoc | -| `"all"` | Convert all doctest blocks | +| Value | Outcome | +|-------------------|---------------------------------------| +| `False` (default) | Don't convert doctest blocks | +| `"autodoc"` | Convert doctest blocks from `autodoc` | +| `"all"` | Convert all doctest blocks | `pyrepl_autodoc_packages`: @@ -75,7 +75,7 @@ Enable [doctest style examples](https://docs.python.org/3/library/doctest.html) ### Local wheels -Unreleased branches can be available in the REPL by building the corresponding wheel and placing it under Sphinx's `html_static_path`. +Unreleased package wheels can be available in the REPL by building them under Sphinx's `html_static_path`. All options combined: diff --git a/docs/development.rst b/docs/development.rst index c057856..0a8f867 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -23,7 +23,7 @@ Build HTML output from the ``docs/`` directory: python -m sphinx -b html docs docs/_build REPLs load JavaScript, replay scripts, and wheels with ``fetch``, so they do not -work when you open ``index.html`` directly from disk (``file://`` URLs). Serve the +work when opening ``index.html`` directly from disk (``file://`` URLs). Serve the build output over HTTP instead: .. code-block:: bash @@ -38,7 +38,7 @@ Build-time behavior Python code within a ``.. py-repl::`` directive is written to ``_static/pyrepl/`` at build time and emitted as ``replay-src``. -File paths in ``:packages:``, ``:src:``, ``replay-src``, and +File paths identifiers in ``:packages:``, ``:src:``, ``replay-src``, and ``pyrepl_autodoc_packages`` are rewritten to page-relative URLs so REPLs work on nested pages (for example ``docs/api/...``). PyPI package names, absolute URLs, and paths written as root-absolute (``/_static/...``) are left unchanged. @@ -50,6 +50,9 @@ override this only when pointing at a custom loader path or CDN. Static wheels ------------- +Wheel packages must be `Pyodide `_ compatible (pure-python packages work out of the box). +For ``CPython`` extensions, visit `pyodide-build `_ + Wheels under ``_static/`` are copied into the HTML output when ``_static`` is listed in ``html_static_path``. At runtime, `pyrepl-web `_ resolves site-relative wheel paths to absolute URLs before calling @@ -58,14 +61,6 @@ resolves site-relative wheel paths to absolute URLs before calling Paths must use the wheel's actual PyPI-compliant filename (for example ``myext-1.2.3-py3-none-any.whl``). ``micropip`` rejects other names. -.. tip:: - - Copy each build artifact into ``docs/_static/wheels/`` under its original - filename, then update ``:packages:`` or ``pyrepl_autodoc_packages`` when the - version changes:: - - cp dist/myext-1.2.3-*.whl docs/_static/wheels/ - Ensure the web server serves ``.whl`` files with MIME type ``application/zip`` (Read the Docs does this by default). diff --git a/docs/example.rst b/docs/example.rst index 533ce17..bfc7464 100644 --- a/docs/example.rst +++ b/docs/example.rst @@ -147,7 +147,8 @@ Use static local wheel packages on ``.. py-repl::`` directives: Autodoc ------- -Use ``pyrepl_doctest_blocks = "autodoc"`` for interactive REPL examples. +Use ``pyrepl_doctest_blocks = "autodoc"`` to turn docstrings from ``autodoc`` into interactive REPL examples. + Set ``pyrepl_autodoc_packages`` to install the documented package and automatically import the documented object before replay: .. code-block:: python From aa11938c542e760ebad291ca155ff280d760f536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=B3pez=20Barr=C3=B3n?= Date: Mon, 6 Jul 2026 07:20:14 +1000 Subject: [PATCH 16/16] dot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian López Barrón --- docs/development.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development.rst b/docs/development.rst index 0a8f867..5790472 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -51,7 +51,7 @@ Static wheels ------------- Wheel packages must be `Pyodide `_ compatible (pure-python packages work out of the box). -For ``CPython`` extensions, visit `pyodide-build `_ +For ``CPython`` extensions, visit `pyodide-build `_. Wheels under ``_static/`` are copied into the HTML output when ``_static`` is listed in ``html_static_path``. At runtime, `pyrepl-web `_