From c5f538c33146ee4ae56a3205ba4fa2b34191413c Mon Sep 17 00:00:00 2001
From: gefrus112 <329268472+gefrus112@users.noreply.github.com>
Date: Thu, 22 Oct 2026 13:16:21 +0000
Subject: [PATCH 1/2] Add safe app runtime and Windows EXE builder
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
---
.github/workflows/windows-exe-smoke.yml | 49 ++
.gitignore | 4 +-
.vscode/tasks.json | 16 +
README.md | 50 +-
docs.html | 79 ++-
docs/LANGUAGE.md | 48 +-
download.html | 47 +-
editors/vscode/README.md | 7 +-
editors/vscode/snippets/pyv2.json | 23 +
examples/software/comet_notes.pyv2 | 27 +
guide.html | 25 +-
index.html | 6 +-
install.bat | 1 +
install.sh | 1 +
playground.html | 1 +
playground.js | 30 +-
pyproject.toml | 6 +-
pyv2.js | 82 +++
pyv2/__init__.py | 3 +-
pyv2/assets/pyv2.js | 82 +++
pyv2/builder.py | 684 ++++++++++++++++++++++++
pyv2/cli.py | 263 +++++++--
pyv2/interpreter.py | 205 ++++++-
pyv2/launcher.py | 61 ++-
pyv2/runtime.py | 171 ++++++
style.css | 11 +
tests/test_apps.py | 226 ++++++++
tests/test_games_site.py | 14 +
tests/test_launcher.py | 9 +
tests/test_parity.py | 2 +
tutorial.html | 3 +
31 files changed, 2129 insertions(+), 107 deletions(-)
create mode 100644 .github/workflows/windows-exe-smoke.yml
create mode 100644 examples/software/comet_notes.pyv2
create mode 100644 pyv2/builder.py
create mode 100644 pyv2/runtime.py
create mode 100644 tests/test_apps.py
diff --git a/.github/workflows/windows-exe-smoke.yml b/.github/workflows/windows-exe-smoke.yml
new file mode 100644
index 0000000..b6ac8f1
--- /dev/null
+++ b/.github/workflows/windows-exe-smoke.yml
@@ -0,0 +1,49 @@
+name: Windows EXE builder
+
+on:
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - "pyv2/**"
+ - "examples/software/**"
+ - "pyproject.toml"
+ - ".github/workflows/windows-exe-smoke.yml"
+ push:
+ branches: [main]
+ paths:
+ - "pyv2/**"
+ - "examples/software/**"
+ - "pyproject.toml"
+ - ".github/workflows/windows-exe-smoke.yml"
+
+permissions:
+ contents: read
+
+jobs:
+ build-and-run:
+ runs-on: windows-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ cache: pip
+ - name: Install Python v2 and native builder
+ run: python -m pip install . pyinstaller
+ - name: Validate the production app example
+ run: pyv2 check examples/software/comet_notes.pyv2
+ - name: Build a real one-file Windows executable
+ run: pyv2 build examples/software/comet_notes.pyv2 --native --output build/windows-smoke
+ - name: Run the packaged executable
+ shell: pwsh
+ run: |
+ $output = & ".\build\windows-smoke\dist\Comet-Notes.exe"
+ $output | Write-Host
+ if ($LASTEXITCODE -ne 0) { throw "Packaged app exited with $LASTEXITCODE" }
+ if (($output -join "`n") -notmatch "Use --add") { throw "Packaged app smoke output was incomplete" }
+ - uses: actions/upload-artifact@v4
+ with:
+ name: comet-notes-windows-exe
+ path: build/windows-smoke/dist/Comet-Notes.exe
+ if-no-files-found: error
diff --git a/.gitignore b/.gitignore
index 7446761..216ad3b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,7 +2,9 @@ __pycache__/
*.pyc
.DS_Store
-# Python packaging outputs
+# Python packaging and local app outputs
build/
dist/
*.egg-info/
+.pyv2-data/
+.builder/
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 8d8aaaa..db071d7 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -48,6 +48,22 @@
"presentation": { "reveal": "always", "panel": "new" },
"problemMatcher": []
},
+ {
+ "label": "Check current Python v2 app",
+ "type": "shell",
+ "command": "python3",
+ "args": ["-m", "pyv2", "check", "${file}"],
+ "options": { "cwd": "${workspaceFolder}" },
+ "problemMatcher": []
+ },
+ {
+ "label": "Build current app: Windows kit",
+ "type": "shell",
+ "command": "python3",
+ "args": ["-m", "pyv2", "build", "${file}", "--output", "${workspaceFolder}/build/windows"],
+ "options": { "cwd": "${workspaceFolder}" },
+ "problemMatcher": []
+ },
{
"label": "Run Python v2 tests",
"type": "shell",
diff --git a/README.md b/README.md
index 84792f8..3f47ac0 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,8 @@ The product is: 384
| `assert` is a keyword with moods | `assert(cond, msg)` is a function |
| trailing commas *sometimes* | trailing commas & multi-line everything |
| one runtime | two parity-tested runtimes — CLI **and** browser |
+| ad-hoc file access | capability-based app data, atomic saves, and read-only assets |
+| packaging guesswork | validated projects, reproducible kits, and real Windows `.exe` builds |
## 🚀 Quick start
@@ -79,13 +81,50 @@ $ python3 -m pyhope --demo # engine demo
$ python3 -m pyv2 examples/games/monster_arena.pyv2
```
+## 🏗️ Build production Windows applications
+
+Python v2 now includes the **Sudbury Build Tools**: app metadata, strict JSON,
+command-line arguments, sandboxed UTF-8 files, atomic saves, read-only bundled
+assets, native dialogs, validation, integrity manifests, and an inspectable
+Windows packaging pipeline.
+
+```console
+# Create a complete application project
+$ pyv2 new windows-app "Comet Tasks"
+
+# Develop and validate it
+$ pyv2 Comet-Tasks/main.pyv2 --add "Ship the app"
+$ pyv2 check Comet-Tasks
+
+# Create a reproducible Windows build kit from any operating system
+$ pyv2 build Comet-Tasks
+
+# On Windows: build the real one-file Comet-Tasks.exe
+> pyv2 build Comet-Tasks --native
+```
+
+Each project includes `pyv2app.json`, VS Code tasks, a readable batch builder,
+a PowerShell builder, app assets, and a GitHub Actions Windows workflow. Every
+build kit vendors the exact Python v2 runtime and hashes its inputs. The actual
+`.exe` is produced by PyInstaller **on Windows**; macOS and Linux create
+the same reviewable build kit or can use the included Windows CI workflow.
+Python v2 never disguises a non-Windows binary as an `.exe`. Generated files
+are not Authenticode-signed automatically; public distributors should add their
+own protected signing certificate in Windows CI.
+
+Packaged apps store writable data under
+`%LOCALAPPDATA%\\PythonV2Apps\\APP-ID`. Absolute paths, traversal, symlink
+escapes, oversized text, and writes to bundled assets are blocked by default.
+See [`examples/software/comet_notes.pyv2`](examples/software/comet_notes.pyv2).
+
## 🖥️ Python v2 Sudbury Launcher
Version 1.1 ships Python v2 as its own lightweight desktop application. The
workspace provides Home, Code Studio, a path-safe atomic file service, project
wizard, eight packaged CC0 assets, Games/GPU, detected editor connections,
local settings, docs, a polished offline personal/portfolio/game website
-starter, and a preview server—all using the Python standard library. Open it with `pyv2-launcher`,
+starter, Windows app project generation, and a preview server—all using the
+Python standard library. Open it with `pyv2-launcher`,
`pyhope-studio`, or `pyhope --studio`.
## 🎮 PyHope game engine
@@ -167,8 +206,8 @@ The short version:
$ python3 -m unittest discover -s tests -t .
```
-**92 tests pass:** 63 language tests, 9 PyHope engine tests, 12 workspace/Launcher tests,
-7 arcade/Playground site tests, plus a parity harness that runs 21 programs (including error paths) through both the
+**108 tests pass:** 63 language tests, 9 PyHope engine tests, 14 production-app/build tests,
+13 workspace/Launcher tests, 8 arcade/Playground site tests, plus a parity harness that runs 25 programs (including error paths) through both the
Python and JavaScript runtimes and demands byte-identical output.
## 📦 Repo layout
@@ -184,7 +223,7 @@ tutorial.html 10 runnable language lessons + browser sandbox
guide.html 50 concise language/engine learning topics
games.html 10-game arcade, GPU lab, animated sprites, Python v2 source
arcade.js seven-game shared Canvas engine for Pocket Arcade
-pyv2/ interpreter, CLI, desktop Launcher, packaged browser runtime
+pyv2/ interpreter, safe app runtime, Windows builder, CLI, desktop Launcher
pyhope/ engine, CLI, and packaged offline CC0 asset library
pyv2.js twin language runtime in JavaScript
game.js CPU sprite raycaster (monsters + weapon)
@@ -196,7 +235,8 @@ install.bat Windows installer for pyv2 + PyHope
install.sh macOS/Linux installer for pyv2 + PyHope
editors/vscode/ VS Code syntax highlighting + snippets
examples/games/ playable Python v2 game/source examples
-tests/ language, parity, and PyHope engine tests
+examples/software/ persistent JSON and safe app-data examples
+tests/ language, app builder, parity, site, workspace, and engine tests
docs/LANGUAGE.md full language reference & grammar
```
diff --git a/docs.html b/docs.html
index 84147e9..b6e434f 100644
--- a/docs.html
+++ b/docs.html
@@ -319,7 +319,7 @@
App layout
Home Workspace overview and shortcuts to every main tool
Code Studio Open, edit, save, and run .pyv2; output/errors stay in the app
Files Safe local workspace service: create, read, atomically save, rename, and delete files without path traversal
- New Project Create Website, CLI app, or Game starters without overwriting files
+ New Project Create Website, Windows app, CLI app, or Game starters without overwriting files
Assets Browse and export eight packaged original CC0 PyHope SVGs offline
Games & GPU Open the 10-game arcade, animated sprites, and 9M GPU Lab
Connected Editors Detect VS Code, VSCodium, Zed, Sublime Text, and PyCharm from PATH; no account or token
@@ -350,8 +350,71 @@ Build a website with Python v2
end
print("GAME|Comet Circuit|Racing|Three-lap AI racing.|games.html#comet-circuit")
+
+ 07 Production Apps & Windows EXE Builder
+ Sudbury Build Tools turn a .pyv2 program into a validated,
+ inspectable application project and—when the native build runs on
+ Windows—a real one-file .exe. Python v2 does not rename a
+ Linux or macOS binary and pretend it is a Windows executable.
+
+ Create, run, check, and build
+ # Complete console application with JSON persistence
+pyv2 new windows-app "Comet Tasks"
+
+# Or create a dialog-based app with no terminal window
+pyv2 new windows-app "Hello Window" --windowed
+
+pyv2 Comet-Tasks/main.pyv2 --add "Ship the EXE"
+pyv2 check Comet-Tasks
+pyv2 build Comet-Tasks
+
+# Run this final command on Windows with PyInstaller installed.
+# The generated build-windows.bat handles that installation for you.
+pyv2 build Comet-Tasks --native
+
+
+ Generated file Purpose
+
+ main.pyv2Readable application source using arguments, strict JSON, and persistent app data
+ pyv2app.jsonName, stable app ID, version, publisher, console/windowed mode, icon, and assets
+ build-windows.batValidates and builds the real EXE on Windows without administrator access
+ .github/workflows/windows-exe.ymlOptional Windows CI build and downloadable executable artifact
+ build/windows/manifest.jsonSHA-256 source and build-input integrity record
+ runtime/pyv2Exact vendored language runtime used for the build—no moving runtime dependency
+ app.spec + launcher.pyReviewable PyInstaller recipe and embedded Python v2 host
+
+
+
+ Application APIs
+ info = app_info() # name, version, platform, safe data directory
+commands = args() # values after the .pyv2 filename / EXE
+settings = {theme: "night", zoom: 1.25}
+write_text("settings.json", json_encode(settings))
+loaded = json_decode(read_text("settings.json"))
+print(loaded.theme)
+
+# Other safe services:
+# append_text, file_exists, list_dir, make_dir, delete_file
+# read_asset, asset_exists, sleep_ms, ui_message, ui_ask
+ During development, writable data stays in .pyv2-data/
+ beside the program. Packaged apps use
+ %LOCALAPPDATA%\PythonV2Apps\APP-ID. Paths are relative and
+ sandboxed; atomic writes, UTF-8 validation, an 8 MB text limit, asset
+ size validation, symlink-escape checks, and unmanaged-build-directory
+ protection are enabled by default.
+
+ Building from macOS or Linux:
+ pyv2 build still creates the complete Windows kit. Copy it
+ to Windows and run build.bat, or push the generated project
+ and run its GitHub Actions workflow on a Windows runner. PyInstaller
+ cannot cross-compile Windows PE files from another operating system.
+ Signing note: generated executables are reproducible but
+ not Authenticode-signed automatically. Public distributors should add
+ their own protected code-signing certificate in Windows CI; never commit
+ a certificate or password to the project.
+
- 07 PyHope Game Engine
+ 08 PyHope Game Engine
PyHope is Python v2's own dependency-free game engine. It is not
pygame and it installs no native libraries. The portable Python module
handles game logic and terminal previews; the website adds Canvas and
@@ -403,7 +466,7 @@
Your first PyHope scene
assets/sprites/.
- 07 Language Quick Reference
+ 09 Language Quick Reference
The full spec (grammar, semantics, error kinds) lives in
docs/LANGUAGE.md .
Here is the day-to-day core.
@@ -464,12 +527,17 @@ Builtins
keys values has getMap access
sort sort_by joinHelpers
assert(cond, msg?)Raises AssertionError when falsy
- chr ord clock inputMisc (input is CLI-only)
+ json_encode json_decodeStrict JSON for persistent application data
+ args app_infoApplication arguments, identity, platform, and capability details
+ read_text write_text append_textSandboxed UTF-8 files with atomic replacement
+ file_exists list_dir make_dir delete_fileSafe app-data management
+ read_asset asset_exists ui_message ui_askPackaged assets and native dialogs
+ chr ord clock input sleep_msCharacters, timing, and desktop input
- 08 Try It Right Here
+ 10 Try It Right Here
No install needed to experiment — this is the real interpreter,
running in your browser. Edit and hit Run.
@@ -516,6 +584,7 @@
Documentation
Tutorial
50-topic guide
Sudbury Launcher
+
Windows EXE builder
PyHope engine
Installation
VS Code setup
diff --git a/docs/LANGUAGE.md b/docs/LANGUAGE.md
index de102c0..6f9fa8b 100644
--- a/docs/LANGUAGE.md
+++ b/docs/LANGUAGE.md
@@ -195,10 +195,28 @@ hero.scores.push(30)
| `sort(list)` `sort_by(list, fn)` `join(list, sep)` | helpers |
| `assert(cond, msg?)` | raise `AssertionError` when falsy |
| `chr(n)` `ord(char)` | character codes |
-| `clock()` | seconds on the wall clock |
+| `clock()` | high-resolution elapsed seconds |
| `input(prompt?)` | read a line (CLI only; browser raises a friendly error) |
-
-### 3.1 Methods
+| `args()` | command-line arguments passed after the app filename |
+| `app_info()` | app name, version, ID, platform, data directory, and asset capability |
+| `json_encode(value)` / `json_decode(text)` | strict JSON serialization for maps and lists |
+| `read_text(path)` / `write_text(path, text)` / `append_text(path, text)` | UTF-8 app-data files; writes are atomic by default |
+| `file_exists(path)` / `list_dir(path?)` / `make_dir(path)` / `delete_file(path)` | sandboxed app-data management |
+| `read_asset(path)` / `asset_exists(path)` | read-only files bundled with a packaged app |
+| `sleep_ms(milliseconds)` | pause a desktop/CLI app for at most 60 seconds |
+| `ui_message(title, text)` / `ui_ask(title, prompt, default?)` | native dialogs in packaged/desktop apps |
+
+### 3.1 App capability safety
+
+Desktop services are explicit capabilities. A CLI source file receives a
+writable `.pyv2-data/` directory beside the source and optional read-only
+`assets/`. A packaged Windows app stores data under
+`%LOCALAPPDATA%\\PythonV2Apps\\APP-ID`. Absolute paths, `..` traversal, symlink
+escapes, non-UTF-8 reads, and text larger than 8 MB are rejected with
+`CapabilityError`. Browser programs keep JSON, `args()`, and `app_info()` but
+cannot touch the visitor's filesystem.
+
+### 3.2 Methods
**Strings:** `upper lower title trim chars reverse lines count slice(a, b?)
to_number repeat(n) split(sep) join(list) contains starts_with ends_with
@@ -217,7 +235,7 @@ Errors are `Kind: message at line N` with an optional call trace:
`SyntaxError`, `NameError`, `TypeError`, `IndexError`, `KeyError`,
`ZeroDivisionError`, `ValueError`, `OverflowError`, `AssertionError`,
-`AttributeError`, `RuntimeError`.
+`AttributeError`, `CapabilityError`, `RuntimeError`.
The CLI exits with status 1 on error; the playground prints the error in red
and keeps your code.
@@ -269,16 +287,26 @@ fnexpr := "fn" "(" params ")" ":" block "end"
## 7. CLI
```console
-$ python3 -m pyv2 script.pyv2 # run a file
-$ python3 -m pyv2 -c 'print(42)' # run a snippet
-$ python3 -m pyv2 # REPL (... continues blocks)
-$ python3 -m pyv2 --version
+$ pyv2 script.pyv2 --list # run an app with arguments
+$ pyv2 -c 'print(42)' # run a snippet
+$ pyv2 # REPL (... continues blocks)
+$ pyv2 check MyApp # syntax + metadata + path audit
+$ pyv2 new windows-app "My App" # complete EXE-ready project
+$ pyv2 build MyApp # build kit on any operating system
+$ pyv2 build MyApp --native # real one-file .exe on Windows
+$ pyv2 doctor # show local build readiness
```
+A generated app contains readable source, `pyv2app.json`, VS Code tasks,
+sandboxed-data examples, `build-windows.bat`, and a Windows GitHub Actions
+workflow. PyInstaller must run on Windows to create a Windows PE executable;
+macOS/Linux builds produce the honest reproducible kit rather than relabeling a
+non-Windows binary.
+
## 8. Parity guarantee
-The suite runs **92 tests**: 63 language tests, 9 PyHope engine tests, 7 arcade/Playground site tests,
-12 workspace/Launcher tests, and one parity harness that sends 21 programs (including error paths) through
+The suite runs **108 tests**: 63 language tests, 9 PyHope engine tests, 14 production-app/build tests,
+8 arcade/Playground site tests, 13 workspace/Launcher tests, and one parity harness that sends 25 programs (including error paths) through
**both** language runtimes and compares outputs byte for byte. If the twins
ever disagree, the suite fails. That's the deal.
diff --git a/download.html b/download.html
index cc224cd..caae905 100644
--- a/download.html
+++ b/download.html
@@ -61,7 +61,8 @@
Downloads & Installation
Get the Python v2 language on your machine. It is free ,
open source , and needs nothing but Python 3.10+.
Version 1.1 “Sudbury” includes our own desktop Launcher ,
- offline website builder, and dependency-free PyHope game engine —
+ offline website builder, production app APIs, a Windows EXE
+ builder , and dependency-free PyHope game engine —
no pygame or Electron required.
@@ -92,6 +93,7 @@ One installer. Your whole workspace.
Export eight original CC0 PyHope sprites offline
Detect and open VS Code, VSCodium, Zed, Sublime, or PyCharm
Create and preview complete Python v2 website projects
+ Create validated Windows apps with JSON, safe data, dialogs, and EXE workflows
Configure workspace, editor, font, and autosave settings
Open the 10-game arcade and 9M GPU tools
No pygame, Electron, cloud account, or paid dependency
@@ -143,8 +145,41 @@ Manual / portable
Play the games ·
Get free CC0 sprites
+
+ 03 Build Real Windows EXE Applications
+ Installed with the normal Python v2 installer. Sudbury
+ Build Tools create console or windowed applications with command-line
+ arguments, strict JSON, sandboxed persistent files, atomic saves,
+ bundled read-only assets, and native message/input dialogs.
+
+
+ 1. Create
+ pyv2 new windows-app "My App"
+# add --windowed for a dialog app
+
+
+ 2. Validate
+ pyv2 check My-App
+pyv2 doctor
+
+
+ 3. Build
+ pyv2 build My-App
+# Windows:
+pyv2 build My-App --native
+
+
+ The project includes a readable build-windows.bat,
+ PowerShell script, PyInstaller recipe, SHA-256 manifest, VS Code tasks,
+ and Windows GitHub Actions workflow. Native .exe creation
+ installs PyInstaller into a build environment on Windows; normal Python
+ v2 apps and the language runtime remain dependency-free. Other systems
+ produce the complete kit rather than a mislabeled binary.
+ Full app and
+ EXE guide →
+
- 03 Get the Language Manually
+ 04 Get the Language Manually
Pick your favorite flavor:
@@ -176,12 +211,12 @@
What's in the box
Examples examples/hello, fib, fizzbuzz, terminal_raycast
VS Code extension editors/vscode/Syntax highlighting + snippets
Dev container .devcontainer/Ready environment for VS Code / Codespaces
-
Tests tests/92 tests: language + parity + PyHope + workspace + portfolio Playground + arcade
+
Tests tests/108 tests: language + app/EXE builder + parity + PyHope + workspace + Playground + arcade
-
04 Install on Your Machine
+
05 Install on Your Machine
One requirement: Python 3.10 or newer
(python.org/downloads ).
Python v2 itself has zero dependencies.
@@ -229,7 +264,7 @@
Make it your own
- 05 Install in Your Editor
+ 06 Install in Your Editor
Full step-by-step guides live in the
documentation . The quick tour:
@@ -263,7 +298,7 @@ 05 Install in Your Editor
- 06 Learn the Language
+ 07 Learn the Language
Installed? Nice. Next stop:
The tutorial — 10 short
diff --git a/editors/vscode/README.md b/editors/vscode/README.md
index 35aa3d3..0c77b4f 100644
--- a/editors/vscode/README.md
+++ b/editors/vscode/README.md
@@ -29,9 +29,10 @@ code --install-extension pyv2-0.1.0.vsix
- Colorized `.pyv2` files (keywords, f-strings, numbers, comments, operators)
- Smart indentation (`fn`/`if`/`while`/`for` indent, `end`/`elif`/`else` dedent)
-- Snippets: `fn`, `if`, `ifelse`, `while`, `for`, `forr`, `fstr`, `map`, `main`
-- Run the current file with **Ctrl+Shift+B** (task included in the repo's
- `.vscode/tasks.json`)
+- Snippets: `fn`, `if`, `ifelse`, `while`, `for`, `forr`, `fstr`, `map`,
+ `main`, `appdata`, and `dialog`
+- Run the current file with **Ctrl+Shift+B**, validate application metadata,
+ or create a Windows build kit from the included `.vscode/tasks.json` tasks
## Not using VS Code?
diff --git a/editors/vscode/snippets/pyv2.json b/editors/vscode/snippets/pyv2.json
index 39aa88e..1fba756 100644
--- a/editors/vscode/snippets/pyv2.json
+++ b/editors/vscode/snippets/pyv2.json
@@ -85,5 +85,28 @@
"main()"
],
"description": "Program skeleton"
+ },
+ "persistent-app": {
+ "prefix": "appdata",
+ "body": [
+ "info = app_info()",
+ "data = ${1:{}}",
+ "if file_exists(\"${2:data.json}\"):",
+ " data = json_decode(read_text(\"${2:data.json}\"))",
+ "end",
+ "${3:# update data}",
+ "write_text(\"${2:data.json}\", json_encode(data))"
+ ],
+ "description": "Safe persistent app-data pattern"
+ },
+ "native-dialog": {
+ "prefix": "dialog",
+ "body": [
+ "answer = ui_ask(\"${1:App}\", \"${2:Question}\", \"${3:Default}\")",
+ "if answer != nil:",
+ " ui_message(\"${1:App}\", answer)",
+ "end"
+ ],
+ "description": "Native packaged-app dialog"
}
}
diff --git a/examples/software/comet_notes.pyv2 b/examples/software/comet_notes.pyv2
new file mode 100644
index 0000000..b1ba065
--- /dev/null
+++ b/examples/software/comet_notes.pyv2
@@ -0,0 +1,27 @@
+# Comet Notes — production app APIs in one small program.
+# Run: pyv2 examples/software/comet_notes.pyv2 --add "My first note"
+# Then: pyv2 examples/software/comet_notes.pyv2 --list
+
+commands = args()
+notes = []
+if file_exists("notes.json"):
+ notes = json_decode(read_text("notes.json"))
+end
+
+if len(commands) >= 2 and commands[0] == "--add":
+ note = {text: commands[1], created_at: clock()}
+ notes.push(note)
+ write_text("notes.json", json_encode(notes))
+ print("Saved note", len(notes))
+elif len(commands) >= 1 and commands[0] == "--list":
+ if len(notes) == 0:
+ print("No notes yet.")
+ end
+ for note in notes:
+ print("•", note.text)
+ end
+else:
+ info = app_info()
+ print(info.name, "running on", info.platform)
+ print("Use --add followed by text, or --list.")
+end
diff --git a/guide.html b/guide.html
index cabc382..ca3b7c9 100644
--- a/guide.html
+++ b/guide.html
@@ -330,14 +330,14 @@ Read errors
Use the REPL
Run pyv2 with no file for an interactive prompt; Ctrl-D exits.
python3 -m pyv2
-
+
39
Tools
- CLI flags
- Check the version or run a source file. Output and errors are terminal friendly.
- pyv2 --version
-pyv2 examples/fizzbuzz.pyv2
+ Application CLI
+ Create, validate, and package production apps; pass arguments after the source filename.
+ pyv2 new windows-app "My App"
+pyv2 check My-App
+pyv2 build My-App
40
Tools
@@ -354,9 +354,9 @@ Dev containers
42
Tools
Playground + Sudbury Workspace
- The Playground turns Python v2 profile, project, and game data into a responsive portfolio with live preview and inspectable HTML. The desktop app adds multi-file projects, assets, editors, and settings.
+ The Playground builds responsive portfolios. The desktop app adds safe files, assets, editors, and Website, Game, CLI, or Windows EXE-ready project creation.
pyhope --studio
-# or open playground.html
+# New Project → Windows app
43
PyHope
@@ -401,12 +401,13 @@ Free CC0 sprites
9M GPU renderer
WebGL2 live mode is 720p; benchmark mode ray-casts 4096×2304 = 9,437,184 pixels.
games.html#gpu
-
+
50
Ship
- Release your game
- Keep assets licensed, test on keyboard and touch-sized screens, then publish with GitHub Pages.
- python3 -m unittest discover -s tests -t .
+ Release software or games
+ Run tests, validate app metadata, create an integrity-tracked kit, then build the real EXE on Windows or publish games with GitHub Pages.
+ pyv2 check My-App
+pyv2 build My-App --native
diff --git a/index.html b/index.html
index 376e699..4243888 100644
--- a/index.html
+++ b/index.html
@@ -146,7 +146,7 @@ Your profile, projects, and games—one template
»»> Get Started
Python v2 is free and open source. Run from source or use
the installer to get the Sudbury desktop app , language,
- and PyHope together. Full guides for VS Code ,
+ Windows EXE builder, and PyHope together. Full guides for VS Code ,
dev containers , desktop and
any editor are on the
Downloads page and in the
@@ -381,8 +381,8 @@
»»> News & Events
Oct 22, 2026
Python v2 1.1 "Sudbury" released
- Python v2 gets its own desktop Launcher and website builder—plus
- PyHope sprites, games, installers, 50 guides, and a 9M GPU lab.
+ Python v2 gets its own desktop Launcher, portfolio builder, and
+ Windows EXE toolchain—plus safe app APIs, PyHope, and a 9M GPU lab.
Read more →
diff --git a/install.bat b/install.bat
index d395fba..8b8a0d6 100644
--- a/install.bat
+++ b/install.bat
@@ -46,6 +46,7 @@ if errorlevel 1 goto :pip_error
echo [4/4] Running checks and creating the Python v2 app shortcut...
py -m pyv2 --version
+py -m pyv2 doctor
py -m pyhope --check
py -c "from pyv2.launcher import safe_name; assert safe_name('Python v2') == 'Python-v2'"
if errorlevel 1 goto :check_error
diff --git a/install.sh b/install.sh
index 3f88147..0b7b4a2 100755
--- a/install.sh
+++ b/install.sh
@@ -50,6 +50,7 @@ python3 -m pip install --user --upgrade "$INSTALL_DIR"
echo "[4/4] Running checks and creating the Python v2 app..."
python3 -m pyv2 --version
+python3 -m pyv2 doctor
python3 -m pyhope --check
python3 -c "from pyv2.launcher import safe_name; assert safe_name('Python v2') == 'Python-v2'"
diff --git a/playground.html b/playground.html
index 6ec446b..1b0cf92 100644
--- a/playground.html
+++ b/playground.html
@@ -60,6 +60,7 @@ Playground workspace
Python v2 quick tour
Website starter template
+ Production app + Windows EXE data
PyHope game data
Classic Python quick tour
Error lab
diff --git a/playground.js b/playground.js
index 66bead3..f210d5f 100644
--- a/playground.js
+++ b/playground.js
@@ -40,7 +40,7 @@ skills = ["Python v2", "PyHope", "Web design", "Canvas games", "Creative coding"
stats = [
{value: "10", label: "Playable games"},
{value: "8", label: "Open assets"},
- {value: "92+", label: "Passing tests"},
+ {value: "108+", label: "Passing tests"},
]
projects = [
{title: "Python v2", kind: "Language", description: "A friendlier Python remake with browser and desktop runtimes.", link: "https://github.com/gefrus112/pythonv2"},
@@ -68,6 +68,32 @@ end
for game in games:
print("GAME|" + game.title + "|" + game.genre + "|" + game.description + "|" + game.link)
end`,
+ },
+ "app-v2": {
+ mode: "pyv2", file: "comet-notes.pyv2",
+ source: `# Production app data model — this part runs in browser and desktop.
+# Download it, then use: pyv2 build comet-notes.pyv2
+info = app_info()
+settings = {
+ app_name: "Comet Notes",
+ version: "1.0.0",
+ theme: "night",
+ autosave: true,
+}
+notes = [
+ {title: "Design the app", done: true},
+ {title: "Build the Windows EXE", done: false},
+]
+
+encoded = json_encode({settings: settings, notes: notes})
+restored = json_decode(encoded)
+print(restored.settings.app_name, "v" + restored.settings.version)
+for note in restored.notes:
+ print(note.done ? "[x]" : "[ ]", note.title)
+end
+print("platform:", info.platform)
+print("Next: download source, run 'pyv2 new windows-app', then 'pyv2 build'.")
+# Desktop/EXE apps also get safe read_text, write_text, assets, args, and dialogs.`,
},
"game-v2": {
mode: "pyv2", file: "game-data.pyv2",
@@ -155,7 +181,7 @@ print("average:", divide(42, len(items)))`,
email: "hello@example.com",
availability: "Available for select projects",
skills: ["Python v2", "PyHope", "Web design", "Canvas games", "Creative coding", "UI systems"],
- stats: [{ value: "10", label: "Playable games" }, { value: "8", label: "Open assets" }, { value: "92+", label: "Passing tests" }],
+ stats: [{ value: "10", label: "Playable games" }, { value: "8", label: "Open assets" }, { value: "108+", label: "Passing tests" }],
projects: [
{ title: "Python v2", kind: "Language", description: "A friendlier Python remake with browser and desktop runtimes.", link: "https://github.com/gefrus112/pythonv2" },
{ title: "PyHope Arcade", kind: "Games", description: "Ten original shooters, platformers, racers, and arcade experiments.", link: "https://gefrus112.github.io/pythonv2/games.html" },
diff --git a/pyproject.toml b/pyproject.toml
index b2bf7fa..4c3cc69 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,13 +10,17 @@ readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [{ name = "Python v2 community" }]
-keywords = ["language", "interpreter", "game-engine", "raycaster", "pyhope"]
+keywords = ["language", "interpreter", "windows-exe", "app-builder", "game-engine", "raycaster", "pyhope"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
+ "Operating System :: Microsoft :: Windows",
]
+[project.optional-dependencies]
+windows-build = ["pyinstaller>=6.0; platform_system == 'Windows'"]
+
[project.scripts]
pyv2 = "pyv2.cli:main"
pyv2-launcher = "pyv2.launcher:main"
diff --git a/pyv2.js b/pyv2.js
index c86c25d..a979ba6 100644
--- a/pyv2.js
+++ b/pyv2.js
@@ -749,6 +749,50 @@
return "unknown";
}
+ function jsonToNative(value, seen) {
+ if (value === null || typeof value === "boolean" || typeof value === "string") return value;
+ if (typeof value === "number") {
+ if (!Number.isFinite(value)) throw new Error("non-finite numbers are not valid JSON");
+ if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
+ throw new Error("integer exceeds the cross-runtime safe range");
+ }
+ return value;
+ }
+ const active = seen || new Set();
+ if (value instanceof PyList) {
+ if (active.has(value)) throw new Error("circular data is not valid JSON");
+ active.add(value);
+ const out = value.items.map((item) => jsonToNative(item, active));
+ active.delete(value);
+ return out;
+ }
+ if (value instanceof PyMap) {
+ if (active.has(value)) throw new Error("circular data is not valid JSON");
+ active.add(value);
+ const out = {};
+ for (const [key, item] of value.entries()) {
+ if (typeof key !== "string") throw new Error("JSON map keys must be strings");
+ out[key] = jsonToNative(item, active);
+ }
+ active.delete(value);
+ return out;
+ }
+ throw new Error(typeName(value) + " is not valid JSON");
+ }
+
+ function nativeToJsonValue(value) {
+ if (typeof value === "number" && Number.isInteger(value) && !Number.isSafeInteger(value)) {
+ throw new Error("integer exceeds the cross-runtime safe range");
+ }
+ if (Array.isArray(value)) return new PyList(value.map(nativeToJsonValue));
+ if (value !== null && typeof value === "object") {
+ const result = new PyMap();
+ Object.entries(value).forEach(([key, item]) => result.set(key, nativeToJsonValue(item)));
+ return result;
+ }
+ return value;
+ }
+
// ------------------------------------------------------------------
// Environments
// ------------------------------------------------------------------
@@ -1280,6 +1324,44 @@
def("input", (interp, args, line) => {
throw interp.error("RuntimeError", "input() is not available in the browser playground", line);
}, null);
+ def("args", () => new PyList([]), 0);
+ def("app_info", () => {
+ const info = new PyMap();
+ info.set("name", "Python v2 Program"); info.set("version", "1.0.0");
+ info.set("id", "python-v2-program"); info.set("platform", "browser");
+ info.set("data_dir", null); info.set("has_assets", false); info.set("max_text_bytes", 0);
+ return info;
+ }, 0);
+ def("json_encode", (interp, args, line) => {
+ try { return JSON.stringify(jsonToNative(args[0])); }
+ catch (error) { throw interp.error("ValueError", "json_encode(): " + error.message, line); }
+ }, 1);
+ def("json_decode", (interp, args, line) => {
+ if (typeof args[0] !== "string") throw interp.error("TypeError", "json_decode() needs a string", line);
+ try { return nativeToJsonValue(JSON.parse(args[0])); }
+ catch (error) { throw interp.error("ValueError", "json_decode(): invalid JSON", line); }
+ }, 1);
+ ["read_text", "write_text", "append_text", "file_exists", "make_dir", "list_dir",
+ "delete_file", "read_asset", "asset_exists", "sleep_ms"].forEach((name) => {
+ def(name, (interp, args, line) => {
+ throw interp.error("CapabilityError", name + "() is available in desktop and CLI apps, not the browser", line);
+ }, null);
+ });
+ def("ui_message", (interp, args, line) => {
+ if (typeof args[0] !== "string" || typeof args[1] !== "string") {
+ throw interp.error("TypeError", "ui_message() needs title and message strings", line);
+ }
+ if (typeof global.alert !== "function") throw interp.error("CapabilityError", "message dialogs are unavailable", line);
+ global.alert(args[0] + "\n\n" + args[1]); return null;
+ }, 2);
+ def("ui_ask", (interp, args, line) => {
+ if (typeof args[0] !== "string" || typeof args[1] !== "string") {
+ throw interp.error("TypeError", "ui_ask() needs title and prompt strings", line);
+ }
+ if (typeof global.prompt !== "function") throw interp.error("CapabilityError", "input dialogs are unavailable", line);
+ const answer = global.prompt(args[0] + "\n\n" + args[1], args.length > 2 ? display(args[2]) : "");
+ return answer === null ? null : answer;
+ }, null);
def("chr", (interp, args, line) => String.fromCodePoint(intArg(interp, args[0], "chr()", line)), 1);
def("ord", (interp, args, line) => {
const s = args[0];
diff --git a/pyv2/__init__.py b/pyv2/__init__.py
index 2537f5c..0f2efee 100644
--- a/pyv2/__init__.py
+++ b/pyv2/__init__.py
@@ -7,7 +7,8 @@
from .interpreter import Interpreter, Pyv2Error, run, run_capture
from .parser import parse, ParseError
+from .runtime import AppRuntime, CapabilityError
__version__ = "1.1.0"
__all__ = ["Interpreter", "Pyv2Error", "run", "run_capture", "parse",
- "ParseError", "__version__"]
+ "ParseError", "AppRuntime", "CapabilityError", "__version__"]
diff --git a/pyv2/assets/pyv2.js b/pyv2/assets/pyv2.js
index c86c25d..a979ba6 100644
--- a/pyv2/assets/pyv2.js
+++ b/pyv2/assets/pyv2.js
@@ -749,6 +749,50 @@
return "unknown";
}
+ function jsonToNative(value, seen) {
+ if (value === null || typeof value === "boolean" || typeof value === "string") return value;
+ if (typeof value === "number") {
+ if (!Number.isFinite(value)) throw new Error("non-finite numbers are not valid JSON");
+ if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
+ throw new Error("integer exceeds the cross-runtime safe range");
+ }
+ return value;
+ }
+ const active = seen || new Set();
+ if (value instanceof PyList) {
+ if (active.has(value)) throw new Error("circular data is not valid JSON");
+ active.add(value);
+ const out = value.items.map((item) => jsonToNative(item, active));
+ active.delete(value);
+ return out;
+ }
+ if (value instanceof PyMap) {
+ if (active.has(value)) throw new Error("circular data is not valid JSON");
+ active.add(value);
+ const out = {};
+ for (const [key, item] of value.entries()) {
+ if (typeof key !== "string") throw new Error("JSON map keys must be strings");
+ out[key] = jsonToNative(item, active);
+ }
+ active.delete(value);
+ return out;
+ }
+ throw new Error(typeName(value) + " is not valid JSON");
+ }
+
+ function nativeToJsonValue(value) {
+ if (typeof value === "number" && Number.isInteger(value) && !Number.isSafeInteger(value)) {
+ throw new Error("integer exceeds the cross-runtime safe range");
+ }
+ if (Array.isArray(value)) return new PyList(value.map(nativeToJsonValue));
+ if (value !== null && typeof value === "object") {
+ const result = new PyMap();
+ Object.entries(value).forEach(([key, item]) => result.set(key, nativeToJsonValue(item)));
+ return result;
+ }
+ return value;
+ }
+
// ------------------------------------------------------------------
// Environments
// ------------------------------------------------------------------
@@ -1280,6 +1324,44 @@
def("input", (interp, args, line) => {
throw interp.error("RuntimeError", "input() is not available in the browser playground", line);
}, null);
+ def("args", () => new PyList([]), 0);
+ def("app_info", () => {
+ const info = new PyMap();
+ info.set("name", "Python v2 Program"); info.set("version", "1.0.0");
+ info.set("id", "python-v2-program"); info.set("platform", "browser");
+ info.set("data_dir", null); info.set("has_assets", false); info.set("max_text_bytes", 0);
+ return info;
+ }, 0);
+ def("json_encode", (interp, args, line) => {
+ try { return JSON.stringify(jsonToNative(args[0])); }
+ catch (error) { throw interp.error("ValueError", "json_encode(): " + error.message, line); }
+ }, 1);
+ def("json_decode", (interp, args, line) => {
+ if (typeof args[0] !== "string") throw interp.error("TypeError", "json_decode() needs a string", line);
+ try { return nativeToJsonValue(JSON.parse(args[0])); }
+ catch (error) { throw interp.error("ValueError", "json_decode(): invalid JSON", line); }
+ }, 1);
+ ["read_text", "write_text", "append_text", "file_exists", "make_dir", "list_dir",
+ "delete_file", "read_asset", "asset_exists", "sleep_ms"].forEach((name) => {
+ def(name, (interp, args, line) => {
+ throw interp.error("CapabilityError", name + "() is available in desktop and CLI apps, not the browser", line);
+ }, null);
+ });
+ def("ui_message", (interp, args, line) => {
+ if (typeof args[0] !== "string" || typeof args[1] !== "string") {
+ throw interp.error("TypeError", "ui_message() needs title and message strings", line);
+ }
+ if (typeof global.alert !== "function") throw interp.error("CapabilityError", "message dialogs are unavailable", line);
+ global.alert(args[0] + "\n\n" + args[1]); return null;
+ }, 2);
+ def("ui_ask", (interp, args, line) => {
+ if (typeof args[0] !== "string" || typeof args[1] !== "string") {
+ throw interp.error("TypeError", "ui_ask() needs title and prompt strings", line);
+ }
+ if (typeof global.prompt !== "function") throw interp.error("CapabilityError", "input dialogs are unavailable", line);
+ const answer = global.prompt(args[0] + "\n\n" + args[1], args.length > 2 ? display(args[2]) : "");
+ return answer === null ? null : answer;
+ }, null);
def("chr", (interp, args, line) => String.fromCodePoint(intArg(interp, args[0], "chr()", line)), 1);
def("ord", (interp, args, line) => {
const s = args[0];
diff --git a/pyv2/builder.py b/pyv2/builder.py
new file mode 100644
index 0000000..f402d43
--- /dev/null
+++ b/pyv2/builder.py
@@ -0,0 +1,684 @@
+"""Safe Windows executable project and build tooling for Python v2.
+
+PyInstaller cannot cross-compile a Windows PE executable from macOS or Linux.
+This module therefore has two honest stages:
+
+* every platform can validate an app and create a reproducible Windows build kit;
+* Windows can turn that kit into a real one-file ``.exe`` with ``--native``.
+
+Generated projects also include a GitHub Actions Windows workflow, so developers
+on any operating system can produce the executable on a Windows runner.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+import base64
+import hashlib
+import importlib.util
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import sys
+from typing import Any
+
+from .parser import ParseError, parse
+
+
+CONFIG_NAME = "pyv2app.json"
+KIT_MARKER = ".pyv2-windows-build-kit"
+MAX_ASSET_BYTES = 100 * 1024 * 1024
+SOURCE_REPOSITORY = "https://github.com/gefrus112/pythonv2.git@main"
+
+
+class BuildError(ValueError):
+ """A clear configuration, validation, or native-build failure."""
+
+
+@dataclass(frozen=True)
+class AppConfig:
+ project_root: Path
+ entry: Path
+ name: str
+ executable_name: str
+ app_id: str
+ version: str
+ description: str
+ publisher: str
+ console: bool
+ icon: Path | None
+ assets: tuple[Path, ...]
+
+
+@dataclass(frozen=True)
+class BuildResult:
+ config: AppConfig
+ kit_dir: Path
+ manifest: Path
+ executable: Path | None = None
+
+
+def executable_name(name: str) -> str:
+ """Return a Windows-safe executable stem."""
+ cleaned = re.sub(r"[^A-Za-z0-9._ -]+", "", " ".join(str(name).split()))
+ cleaned = re.sub(r"[ .]+", "-", cleaned).strip("-._")
+ if cleaned.upper() in {
+ "CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(1, 10)),
+ *(f"LPT{i}" for i in range(1, 10)),
+ }:
+ cleaned = "app-" + cleaned.lower()
+ return (cleaned or "python-v2-app")[:64]
+
+
+def application_id(name: str) -> str:
+ value = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
+ return (value or "python-v2-app")[:80]
+
+
+def _inside(root: Path, raw: str | Path, label: str, *, must_exist: bool = True) -> Path:
+ requested = Path(raw)
+ if requested.is_absolute():
+ raise BuildError(f"{label} must be a relative path inside the project")
+ target = (root / requested).resolve()
+ try:
+ target.relative_to(root)
+ except ValueError as exc:
+ raise BuildError(f"{label} escapes the project directory") from exc
+ if must_exist and not target.exists():
+ raise BuildError(f"{label} does not exist: {raw}")
+ return target
+
+
+def _read_json(path: Path) -> dict[str, Any]:
+ try:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ except OSError as exc:
+ raise BuildError(f"cannot read {path}: {exc}") from exc
+ except json.JSONDecodeError as exc:
+ raise BuildError(f"invalid {CONFIG_NAME} at line {exc.lineno}: {exc.msg}") from exc
+ if not isinstance(value, dict):
+ raise BuildError(f"{CONFIG_NAME} must contain a JSON object")
+ return value
+
+
+def load_app_config(
+ target: str | Path,
+ *,
+ name: str | None = None,
+ icon: str | Path | None = None,
+ console: bool | None = None,
+) -> AppConfig:
+ """Load and strictly validate a source file or app project."""
+ requested = Path(target).expanduser().resolve()
+ if requested.is_dir():
+ root = requested
+ config_path = root / CONFIG_NAME
+ raw = _read_json(config_path) if config_path.is_file() else {}
+ entry_name = raw.get("entry", "main.pyv2")
+ elif requested.is_file():
+ root = requested.parent
+ config_path = root / CONFIG_NAME
+ raw = _read_json(config_path) if config_path.is_file() else {}
+ entry_name = requested.name
+ else:
+ raise BuildError(f"app source or project not found: {target}")
+
+ allowed_fields = {"name", "id", "entry", "version", "description", "publisher",
+ "console", "icon", "assets"}
+ unknown = sorted(set(raw) - allowed_fields)
+ if unknown:
+ raise BuildError(f"unknown {CONFIG_NAME} field(s): {', '.join(unknown)}")
+
+ if not isinstance(entry_name, str) or not entry_name.strip():
+ raise BuildError("entry must be a non-empty relative path")
+ entry = _inside(root, entry_name, "entry")
+ if not entry.is_file() or entry.suffix.lower() != ".pyv2":
+ raise BuildError("entry must be an existing .pyv2 file")
+
+ app_name = name if name is not None else raw.get("name", entry.stem.replace("_", " ").title())
+ if not isinstance(app_name, str) or not app_name.strip():
+ raise BuildError("name must be a non-empty string")
+ app_name = " ".join(app_name.split())
+ if len(app_name) > 80:
+ raise BuildError("name must be 80 characters or fewer")
+
+ version = raw.get("version", "1.0.0")
+ if not isinstance(version, str) or not re.fullmatch(r"\d{1,5}(?:\.\d{1,5}){0,3}", version):
+ raise BuildError("version must contain one to four numeric parts, such as 1.0.0")
+ description = raw.get("description", f"A Windows app built with Python v2")
+ publisher = raw.get("publisher", "Python v2 developer")
+ if not isinstance(description, str) or len(description) > 240:
+ raise BuildError("description must be a string of 240 characters or fewer")
+ if not isinstance(publisher, str) or len(publisher) > 120:
+ raise BuildError("publisher must be a string of 120 characters or fewer")
+
+ config_console = raw.get("console", True) if console is None else console
+ if not isinstance(config_console, bool):
+ raise BuildError("console must be true or false")
+
+ icon_name = icon if icon is not None else raw.get("icon")
+ icon_path = None
+ if icon_name:
+ if not isinstance(icon_name, (str, Path)):
+ raise BuildError("icon must be a relative .ico path")
+ icon_path = _inside(root, icon_name, "icon")
+ if not icon_path.is_file() or icon_path.suffix.lower() != ".ico":
+ raise BuildError("Windows app icons must be existing .ico files")
+
+ raw_assets = raw.get("assets", ["assets"] if (root / "assets").is_dir() else [])
+ if not isinstance(raw_assets, list) or not all(isinstance(item, str) for item in raw_assets):
+ raise BuildError("assets must be a list of relative file or directory paths")
+ assets: list[Path] = []
+ total_size = 0
+ for item in raw_assets:
+ asset = _inside(root, item, "asset")
+ entries = [asset] if asset.is_file() else list(asset.rglob("*"))
+ if any(path.is_symlink() for path in entries):
+ raise BuildError(f"asset trees cannot contain symbolic links: {item}")
+ files = [path for path in entries if path.is_file()]
+ for path in files:
+ try:
+ path.resolve().relative_to(root)
+ except ValueError as exc:
+ raise BuildError(f"asset escapes the project directory: {path}") from exc
+ total_size += sum(path.stat().st_size for path in files)
+ if total_size > MAX_ASSET_BYTES:
+ raise BuildError("assets exceed the 100 MB build safety limit")
+ assets.append(asset)
+
+ raw_id = raw.get("id", app_name)
+ if not isinstance(raw_id, str) or not raw_id.strip():
+ raise BuildError("id must be a non-empty string")
+
+ source = entry.read_text(encoding="utf-8")
+ try:
+ parse(source)
+ except ParseError as exc:
+ raise BuildError(f"SyntaxError in {entry.name} at line {exc.line}: {exc.message}") from exc
+
+ return AppConfig(
+ project_root=root,
+ entry=entry,
+ name=app_name,
+ executable_name=executable_name(app_name),
+ app_id=application_id(raw_id),
+ version=version,
+ description=description,
+ publisher=publisher,
+ console=config_console,
+ icon=icon_path,
+ assets=tuple(assets),
+ )
+
+
+def check_app(target: str | Path) -> AppConfig:
+ """Validate syntax, metadata, paths, icon, and bundled asset limits."""
+ return load_app_config(target)
+
+
+def _version_tuple(version: str) -> tuple[int, int, int, int]:
+ values = [int(part) for part in version.split(".")]
+ return tuple((values + [0, 0, 0, 0])[:4]) # type: ignore[return-value]
+
+
+def _launcher_source(config: AppConfig, source: str) -> str:
+ encoded = base64.b64encode(source.encode("utf-8")).decode("ascii")
+ metadata = {
+ "name": config.name,
+ "version": config.version,
+ "id": config.app_id,
+ "console": config.console,
+ }
+ return f'''# Generated by Python v2 Sudbury Build Tools. Do not edit by hand.
+from __future__ import annotations
+import base64
+import os
+from pathlib import Path
+import sys
+
+from pyv2.interpreter import Interpreter, Pyv2Error
+from pyv2.parser import ParseError
+from pyv2.runtime import AppRuntime
+
+APP = {metadata!r}
+SOURCE = base64.b64decode({encoded!r}).decode("utf-8")
+
+
+def _asset_root() -> Path:
+ base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
+ return base / "assets"
+
+
+def _data_root() -> Path:
+ if os.name == "nt":
+ base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
+ else:
+ base = Path.home() / ".local" / "share"
+ return base / "PythonV2Apps" / APP["id"]
+
+
+def _message(title: str, text: str) -> None:
+ from tkinter import messagebox
+ messagebox.showinfo(title, text)
+
+
+def _ask(title: str, prompt: str, default: str) -> str | None:
+ from tkinter import simpledialog
+ return simpledialog.askstring(title, prompt, initialvalue=default)
+
+
+def main() -> int:
+ output = []
+ runtime = AppRuntime(
+ app_name=APP["name"], app_version=APP["version"], app_id=APP["id"],
+ data_root=_data_root(), asset_root=_asset_root() if _asset_root().is_dir() else None,
+ argv=sys.argv[1:], message_handler=_message, ask_handler=_ask,
+ )
+ writer = print if APP["console"] else output.append
+ reader = input if APP["console"] else (lambda prompt="": _ask(APP["name"], prompt, "") or "")
+ try:
+ Interpreter(output=writer, read_input=reader, app_runtime=runtime).run(SOURCE)
+ except ParseError as exc:
+ message = f"SyntaxError: {{exc.message}} at line {{exc.line}}"
+ except Pyv2Error as exc:
+ message = exc.format()
+ except Exception as exc:
+ message = f"HostError: {{exc}}"
+ else:
+ if output:
+ _message(APP["name"], "\\n".join(output))
+ return 0
+ if APP["console"]:
+ print(message, file=sys.stderr)
+ else:
+ from tkinter import messagebox
+ messagebox.showerror(APP["name"], message)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+'''
+
+
+def _version_file(config: AppConfig) -> str:
+ version = _version_tuple(config.version)
+ version_text = ", ".join(str(value) for value in version)
+ dotted = ".".join(str(value) for value in version)
+ # Values use repr and validated bounded strings, avoiding executable code injection.
+ return f'''VSVersionInfo(
+ ffi=FixedFileInfo(filevers=({version_text}), prodvers=({version_text}), mask=0x3f,
+ flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0, date=(0, 0)),
+ kids=[StringFileInfo([StringTable('040904B0', [
+ StringStruct('CompanyName', {config.publisher!r}),
+ StringStruct('FileDescription', {config.description!r}),
+ StringStruct('FileVersion', {dotted!r}),
+ StringStruct('InternalName', {config.executable_name!r}),
+ StringStruct('OriginalFilename', {config.executable_name + '.exe'!r}),
+ StringStruct('ProductName', {config.name!r}),
+ StringStruct('ProductVersion', {dotted!r})
+ ])]), VarFileInfo([VarStruct('Translation', [1033, 1200])])]
+)
+'''
+
+
+def _spec_file(config: AppConfig, has_assets: bool, has_icon: bool) -> str:
+ console = "True" if config.console else "False"
+ icon = repr("app.ico") if has_icon else "None"
+ asset_code = "datas = [('assets', 'assets')]" if has_assets else "datas = []"
+ return f'''# Reproducible PyInstaller recipe generated by Python v2.
+{asset_code}
+a = Analysis(['launcher.py'], pathex=['runtime'], binaries=[], datas=datas,
+ hiddenimports=[], hookspath=[], hooksconfig={{}},
+ runtime_hooks=[], excludes=[], noarchive=False)
+pyz = PYZ(a.pure)
+exe = EXE(pyz, a.scripts, a.binaries, a.datas, [], name={config.executable_name!r},
+ debug=False, bootloader_ignore_signals=False, strip=False, upx=True,
+ console={console}, icon={icon}, version='version_info.txt')
+'''
+
+
+def _batch_file(config: AppConfig) -> str:
+ return f'''@echo off
+setlocal EnableExtensions
+cd /d "%~dp0"
+title Build {config.executable_name}
+echo [1/4] Checking Python...
+where py >nul 2>nul || (echo Python 3.10+ is required.& exit /b 1)
+if not exist ".builder\\Scripts\\python.exe" py -3 -m venv .builder
+call ".builder\\Scripts\\activate.bat"
+echo [2/4] Installing PyInstaller in the local build environment...
+python -m pip install --upgrade pip pyinstaller
+if errorlevel 1 exit /b 1
+echo [3/4] Validating source with the bundled Python v2 runtime...
+set "PYTHONPATH=%CD%\\runtime"
+python -c "from pathlib import Path; from pyv2.parser import parse; parse(Path('source.pyv2').read_text(encoding='utf-8'))"
+if errorlevel 1 exit /b 1
+echo [4/4] Creating the Windows executable...
+python -m PyInstaller --clean --noconfirm --distpath dist --workpath .work app.spec
+if errorlevel 1 exit /b 1
+echo.
+echo SUCCESS: %CD%\\dist\\{config.executable_name}.exe
+exit /b 0
+'''
+
+
+def _powershell_file(config: AppConfig) -> str:
+ return f'''$ErrorActionPreference = "Stop"
+Set-Location $PSScriptRoot
+if (-not (Test-Path ".builder\\Scripts\\python.exe")) {{ py -3 -m venv .builder }}
+& ".builder\\Scripts\\python.exe" -m pip install --upgrade pip pyinstaller
+$env:PYTHONPATH = Join-Path $PSScriptRoot "runtime"
+& ".builder\\Scripts\\python.exe" -c "from pathlib import Path; from pyv2.parser import parse; parse(Path('source.pyv2').read_text(encoding='utf-8'))"
+& ".builder\\Scripts\\python.exe" -m PyInstaller --clean --noconfirm --distpath dist --workpath .work app.spec
+Write-Host "Built $PSScriptRoot\\dist\\{config.executable_name}.exe" -ForegroundColor Green
+'''
+
+
+def _building_guide(config: AppConfig) -> str:
+ mode = "console" if config.console else "windowed"
+ return f'''# Build {config.name} for Windows
+
+This is a readable, reproducible Python v2 Windows build kit. The app source is
+`source.pyv2`; `launcher.py` embeds that exact source; `app.spec` creates a
+single-file **{mode}** executable named `{config.executable_name}.exe`.
+
+## On Windows
+
+1. Install Python 3.10 or newer from python.org and select **Add Python to PATH**.
+2. Double-click `build.bat` (or run `powershell -File build.ps1`).
+3. Find the executable in `dist\\{config.executable_name}.exe`.
+
+The first build installs only PyInstaller into the local `.builder` virtual
+environment. The exact Python v2 runtime used to make this kit is vendored under
+`runtime/` and covered by `manifest.json`. No administrator access is required.
+Review the two build scripts before running them; no mystery launcher is included.
+
+## Data safety
+
+The packaged app can only use Python v2 file functions inside
+`%LOCALAPPDATA%\\PythonV2Apps\\{config.app_id}`. Bundled `assets/` are read-only.
+Absolute paths, traversal, and symlink escapes are rejected by the runtime.
+
+## Building from macOS or Linux
+
+PyInstaller does not cross-compile Windows PE files. Commit the generated app
+project and use its Windows GitHub Actions workflow, or copy this kit to a
+Windows machine. This limitation is reported rather than pretending a non-Windows
+file is an `.exe`.
+'''
+
+
+def _copy_runtime(destination: Path) -> None:
+ """Vendor the exact pure-Python runtime used to prepare this kit."""
+ source = Path(__file__).resolve().parent
+ target = destination / "runtime" / "pyv2"
+ target.mkdir(parents=True, exist_ok=True)
+ for module in sorted(source.glob("*.py")):
+ shutil.copy2(module, target / module.name)
+
+
+def _copy_assets(config: AppConfig, destination: Path) -> bool:
+ if not config.assets:
+ return False
+ root = destination / "assets"
+ root.mkdir(parents=True, exist_ok=True)
+ for asset in config.assets:
+ relative = asset.relative_to(config.project_root)
+ # A configured top-level assets directory maps to the executable's
+ # assets root rather than producing assets/assets.
+ if relative.as_posix() == "assets":
+ target = root
+ elif relative.parts and relative.parts[0] == "assets":
+ target = root.joinpath(*relative.parts[1:])
+ else:
+ target = root / relative
+ if asset.is_dir():
+ shutil.copytree(asset, target, dirs_exist_ok=True)
+ else:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(asset, target)
+ return True
+
+
+def _hash_manifest(directory: Path, config: AppConfig) -> Path:
+ files: dict[str, str] = {}
+ for path in sorted(directory.rglob("*")):
+ if path.is_file() and path.name not in {"manifest.json", KIT_MARKER}:
+ files[path.relative_to(directory).as_posix()] = hashlib.sha256(path.read_bytes()).hexdigest()
+ payload = {
+ "format": 1,
+ "builder": "Python v2 Sudbury Build Tools",
+ "app": {"name": config.name, "id": config.app_id, "version": config.version,
+ "executable": config.executable_name + ".exe", "console": config.console},
+ "source_sha256": hashlib.sha256(config.entry.read_bytes()).hexdigest(),
+ "files": files,
+ }
+ path = directory / "manifest.json"
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ return path
+
+
+def prepare_windows_build(
+ target: str | Path,
+ output: str | Path | None = None,
+ *,
+ name: str | None = None,
+ icon: str | Path | None = None,
+ console: bool | None = None,
+) -> BuildResult:
+ """Validate an app and create a Windows build kit on any platform."""
+ config = load_app_config(target, name=name, icon=icon, console=console)
+ kit = (Path(output).expanduser() if output else
+ config.project_root / "build" / "windows").resolve()
+ if kit.exists() and any(kit.iterdir()):
+ if not (kit / KIT_MARKER).is_file():
+ raise BuildError(f"refusing to replace non-build directory: {kit}")
+ for child in kit.iterdir():
+ if child.is_dir():
+ shutil.rmtree(child)
+ else:
+ child.unlink()
+ kit.mkdir(parents=True, exist_ok=True)
+ (kit / KIT_MARKER).write_text("Python v2 Windows build kit\n", encoding="utf-8")
+
+ source = config.entry.read_text(encoding="utf-8")
+ (kit / "source.pyv2").write_text(source, encoding="utf-8")
+ (kit / "launcher.py").write_text(_launcher_source(config, source), encoding="utf-8")
+ _copy_runtime(kit)
+ has_assets = _copy_assets(config, kit)
+ has_icon = config.icon is not None
+ if config.icon:
+ shutil.copy2(config.icon, kit / "app.ico")
+ (kit / "version_info.txt").write_text(_version_file(config), encoding="utf-8")
+ (kit / "app.spec").write_text(_spec_file(config, has_assets, has_icon), encoding="utf-8")
+ (kit / "build.bat").write_text(_batch_file(config), encoding="utf-8", newline="\r\n")
+ (kit / "build.ps1").write_text(_powershell_file(config), encoding="utf-8")
+ (kit / "BUILDING.md").write_text(_building_guide(config), encoding="utf-8")
+ config_copy = {
+ "name": config.name, "id": config.app_id, "entry": "source.pyv2",
+ "version": config.version, "description": config.description,
+ "publisher": config.publisher, "console": config.console,
+ "assets": ["assets"] if has_assets else [],
+ }
+ (kit / CONFIG_NAME).write_text(json.dumps(config_copy, indent=2) + "\n", encoding="utf-8")
+ manifest = _hash_manifest(kit, config)
+ return BuildResult(config=config, kit_dir=kit, manifest=manifest)
+
+
+def build_native_windows(result: BuildResult, *, python: str | None = None) -> BuildResult:
+ """Build the kit as a real Windows executable using local PyInstaller."""
+ if os.name != "nt":
+ raise BuildError(
+ "native Windows .exe builds require Windows; use the generated build.bat "
+ "or GitHub Actions workflow from macOS/Linux"
+ )
+ if importlib.util.find_spec("PyInstaller") is None:
+ raise BuildError(
+ "PyInstaller is not installed; run 'py -m pip install pyinstaller' "
+ "or double-click the generated build.bat"
+ )
+ executable = python or sys.executable
+ command = [
+ executable, "-m", "PyInstaller", "--clean", "--noconfirm",
+ "--distpath", "dist", "--workpath", ".work", "app.spec",
+ ]
+ completed = subprocess.run(command, cwd=result.kit_dir, text=True, capture_output=True)
+ if completed.returncode:
+ detail = (completed.stderr or completed.stdout).strip()
+ raise BuildError(f"PyInstaller failed ({completed.returncode}): {detail[-2000:]}")
+ exe = result.kit_dir / "dist" / f"{result.config.executable_name}.exe"
+ if not exe.is_file():
+ raise BuildError(f"PyInstaller finished but did not create {exe}")
+ return BuildResult(result.config, result.kit_dir, result.manifest, exe)
+
+
+def _starter_source(name: str, windowed: bool) -> str:
+ safe_name = name.replace("\\", "\\\\").replace('"', '\\"')
+ if windowed:
+ return f'''# {safe_name} — a windowed Python v2 app
+info = app_info()
+user = ui_ask(info.name, "What is your name?", "Developer")
+if user != nil:
+ visits = 1
+ if file_exists("visits.json"):
+ saved = json_decode(read_text("visits.json"))
+ visits = saved.visits + 1
+ end
+ write_text("visits.json", json_encode({{visits: visits}}))
+ ui_message("Welcome", f"Hello, {{user}}! This is visit {{visits}}.")
+end
+'''
+ return f'''# {safe_name} — a production-ready Python v2 console app
+info = app_info()
+commands = args()
+tasks = []
+if file_exists("tasks.json"):
+ tasks = json_decode(read_text("tasks.json"))
+end
+
+if len(commands) >= 2 and commands[0] == "--add":
+ tasks.push({{title: commands[1], done: false}})
+ write_text("tasks.json", json_encode(tasks))
+ print("Added:", commands[1])
+elif len(commands) >= 1 and commands[0] == "--list":
+ if len(tasks) == 0:
+ print("No tasks yet.")
+ end
+ for task in tasks:
+ print(task.done ? "[x]" : "[ ]", task.title)
+ end
+else:
+ print(info.name, "v" + info.version)
+ print("Usage:")
+ print(" ", info.id + ".exe --add task-name")
+ print(" ", info.id + ".exe --list")
+end
+'''
+
+
+def _project_readme(name: str, windowed: bool) -> str:
+ mode = "windowed" if windowed else "console"
+ return f'''# {name}
+
+A {mode} Windows application powered by Python v2.
+
+## Develop
+
+```console
+pyv2 check main.pyv2
+pyv2 main.pyv2 --list
+```
+
+Relative file functions are sandboxed under `.pyv2-data` while developing and
+under `%LOCALAPPDATA%\\PythonV2Apps` in the packaged application.
+
+## Build a real Windows EXE
+
+On Windows, run `build-windows.bat`. On any platform, run `pyv2 build .` to
+create an inspectable build kit. The included GitHub Actions workflow can build
+the `.exe` on a Windows runner when you push this project to GitHub.
+'''
+
+
+WINDOWS_WORKFLOW = f'''name: Build Windows EXE
+on:
+ workflow_dispatch:
+ push:
+ tags: ["v*"]
+permissions:
+ contents: read
+jobs:
+ windows-exe:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - name: Install Python v2 and PyInstaller
+ run: python -m pip install pyinstaller "git+{SOURCE_REPOSITORY}"
+ - name: Validate app
+ run: pyv2 check .
+ - name: Build integrity-tracked executable
+ run: pyv2 build . --native --output build/windows
+ - uses: actions/upload-artifact@v4
+ with:
+ name: windows-executable
+ path: build/windows/dist/*.exe
+ if-no-files-found: error
+'''
+
+
+def create_windows_project(
+ parent: str | Path,
+ name: str,
+ *,
+ windowed: bool = False,
+) -> Path:
+ """Create a complete Windows app project without overwriting existing files."""
+ app_name = " ".join(name.strip().split()) or "My Python v2 App"
+ project = Path(parent).expanduser().resolve() / executable_name(app_name)
+ project.mkdir(parents=True, exist_ok=True)
+ files = {
+ "main.pyv2": _starter_source(app_name, windowed),
+ CONFIG_NAME: json.dumps({
+ "name": app_name,
+ "id": application_id(app_name),
+ "entry": "main.pyv2",
+ "version": "1.0.0",
+ "description": f"{app_name}, built with Python v2",
+ "publisher": "Your name",
+ "console": not windowed,
+ "assets": ["assets"],
+ }, indent=2) + "\n",
+ "README.md": _project_readme(app_name, windowed),
+ "assets/README.md": "Place bundled read-only app assets in this folder.\n",
+ ".github/workflows/windows-exe.yml": WINDOWS_WORKFLOW,
+ ".gitignore": ".pyv2-data/\nbuild/\n.builder/\n__pycache__/\n",
+ "build-windows.bat": f'''@echo off
+setlocal
+py -m pip install --user pyinstaller "git+{SOURCE_REPOSITORY}"
+if errorlevel 1 exit /b 1
+py -m pyv2 check . || exit /b 1
+py -m pyv2 build . --native --output build\\windows
+''',
+ ".vscode/settings.json": json.dumps({"files.associations": {"*.pyv2": "pythonv2"}}, indent=2) + "\n",
+ ".vscode/tasks.json": json.dumps({
+ "version": "2.0.0",
+ "tasks": [
+ {"label": "Python v2: Check app", "type": "shell", "command": "pyv2 check .", "problemMatcher": []},
+ {"label": "Python v2: Build Windows kit", "type": "shell", "command": "pyv2 build .", "problemMatcher": []},
+ ],
+ }, indent=2) + "\n",
+ }
+ for relative, content in files.items():
+ path = project / relative
+ if path.exists():
+ continue
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return project
diff --git a/pyv2/cli.py b/pyv2/cli.py
index 12e3b90..eca1375 100644
--- a/pyv2/cli.py
+++ b/pyv2/cli.py
@@ -1,17 +1,31 @@
-"""Command line interface: python3 -m pyv2 [file | -c code]."""
+"""Python v2 command line, app validator, and Windows executable builder."""
from __future__ import annotations
+import argparse
+import importlib.util
+import json
+import os
+from pathlib import Path
+import platform
import sys
from . import __version__
+from .builder import (
+ BuildError,
+ build_native_windows,
+ check_app,
+ create_windows_project,
+ prepare_windows_build,
+)
from .interpreter import Interpreter, Pyv2Error
from .parser import ParseError
+from .runtime import AppRuntime
BANNER = f"""Python v2 {__version__} (the snake, but better)
-Type Py v2 code, then press Ctrl-D to run... no — every line runs immediately.
-Builtins: print, len, range, str, int, float, list/map methods, f-strings.
+Builtins include collections, JSON, sandboxed app files, arguments, and native dialogs.
+Build command: pyv2 build APP --native New app: pyv2 new windows-app NAME
"""
WELCOME = f"Python v2 {__version__} — type away, end blocks with 'end'. Ctrl-D to quit."
@@ -21,48 +35,223 @@ def _read_input(prompt: str = "") -> str:
return input(prompt)
+def _dialog_message(title: str, message: str) -> None:
+ from tkinter import messagebox
+ messagebox.showinfo(title, message)
+
+
+def _dialog_ask(title: str, prompt: str, default: str) -> str | None:
+ from tkinter import simpledialog
+ return simpledialog.askstring(title, prompt, initialvalue=default)
+
+
+def _script_runtime(path: Path, app_args: list[str]) -> AppRuntime:
+ root = path.resolve().parent
+ name = path.stem.replace("_", " ").title()
+ version = "1.0.0"
+ app_id = path.stem.lower().replace("_", "-")
+ config_path = root / "pyv2app.json"
+ if config_path.is_file():
+ try:
+ config = check_app(root)
+ name, version, app_id = config.name, config.version, config.app_id
+ except BuildError:
+ # Running a source file should still report language errors normally;
+ # `pyv2 check .` is the strict metadata validation command.
+ pass
+ asset_root = root / "assets"
+ return AppRuntime(
+ app_name=name,
+ app_version=version,
+ app_id=app_id,
+ data_root=root / ".pyv2-data",
+ asset_root=asset_root if asset_root.is_dir() else None,
+ argv=app_args,
+ message_handler=_dialog_message,
+ ask_handler=_dialog_ask,
+ )
+
+
+def _print_help() -> None:
+ print(BANNER.rstrip())
+ print("\nusage:")
+ print(" pyv2 SCRIPT.pyv2 [app arguments] run an application")
+ print(" pyv2 -c CODE [app arguments] run source text")
+ print(" pyv2 check APP validate source and app metadata")
+ print(" pyv2 new windows-app NAME create a complete Windows app")
+ print(" pyv2 build APP [--native] create a kit or real Windows .exe")
+ print(" pyv2 doctor inspect build readiness")
+ print(" pyv2 start the REPL")
+ print("\nRun `pyv2 COMMAND --help` for command-specific options.")
+
+
+def _check_command(argv: list[str]) -> int:
+ parser = argparse.ArgumentParser(prog="pyv2 check", description="Validate a Python v2 app before packaging.")
+ parser.add_argument("app", help=".pyv2 source file or project containing pyv2app.json")
+ parser.add_argument("--json", action="store_true", dest="as_json", help="print machine-readable results")
+ options = parser.parse_args(argv)
+ try:
+ config = check_app(options.app)
+ except BuildError as exc:
+ if options.as_json:
+ print(json.dumps({"ok": False, "error": str(exc)}))
+ else:
+ print(f"check failed: {exc}", file=sys.stderr)
+ return 1
+ result = {
+ "ok": True,
+ "name": config.name,
+ "id": config.app_id,
+ "version": config.version,
+ "entry": str(config.entry),
+ "mode": "console" if config.console else "windowed",
+ "assets": len(config.assets),
+ }
+ if options.as_json:
+ print(json.dumps(result, sort_keys=True))
+ else:
+ print(f"✓ {config.name} {config.version} is ready to build")
+ print(f" entry: {config.entry}")
+ print(f" mode: {result['mode']} • assets: {result['assets']}")
+ return 0
+
+
+def _new_command(argv: list[str]) -> int:
+ parser = argparse.ArgumentParser(prog="pyv2 new", description="Create a production-ready Python v2 project.")
+ subparsers = parser.add_subparsers(dest="kind", required=True)
+ windows = subparsers.add_parser("windows-app", help="create a Windows EXE application project")
+ windows.add_argument("name", help="display and project name")
+ windows.add_argument("--directory", "-d", default=".", help="parent directory (default: current directory)")
+ windows.add_argument("--windowed", action="store_true", help="use dialogs and hide the console window")
+ options = parser.parse_args(argv)
+ try:
+ project = create_windows_project(options.directory, options.name, windowed=options.windowed)
+ except (BuildError, OSError, ValueError) as exc:
+ print(f"could not create app: {exc}", file=sys.stderr)
+ return 1
+ print(f"✓ Created {'windowed' if options.windowed else 'console'} Windows app: {project}")
+ print(f" check: pyv2 check {project}")
+ print(f" kit: pyv2 build {project}")
+ print(" exe: run build-windows.bat on Windows or use the included GitHub workflow")
+ return 0
+
+
+def _build_command(argv: list[str]) -> int:
+ parser = argparse.ArgumentParser(
+ prog="pyv2 build",
+ description="Create an inspectable Windows build kit or a real .exe on Windows.",
+ )
+ parser.add_argument("app", help=".pyv2 source file or app project")
+ parser.add_argument("--output", "-o", help="build-kit directory (default: APP/build/windows)")
+ parser.add_argument("--name", help="override the app and executable name")
+ parser.add_argument("--icon", help="relative .ico file inside the project")
+ parser.add_argument("--native", action="store_true", help="run PyInstaller now (requires Windows)")
+ mode = parser.add_mutually_exclusive_group()
+ mode.add_argument("--console", action="store_true", help="build with a terminal window")
+ mode.add_argument("--windowed", action="store_true", help="build without a terminal window")
+ options = parser.parse_args(argv)
+ console = True if options.console else False if options.windowed else None
+ try:
+ result = prepare_windows_build(
+ options.app,
+ options.output,
+ name=options.name,
+ icon=options.icon,
+ console=console,
+ )
+ if options.native:
+ result = build_native_windows(result)
+ except (BuildError, OSError) as exc:
+ print(f"build failed: {exc}", file=sys.stderr)
+ return 1
+ print(f"✓ Validated {result.config.name} {result.config.version}")
+ print(f"✓ Windows build kit: {result.kit_dir}")
+ print(f"✓ Integrity manifest: {result.manifest}")
+ if result.executable:
+ print(f"✓ Windows executable: {result.executable}")
+ elif os.name == "nt":
+ print("Next: run the generated build.bat, or add --native after installing PyInstaller.")
+ else:
+ print("Next: build on Windows with build.bat or the generated GitHub Actions workflow.")
+ print("PyInstaller cannot cross-compile a Windows .exe from this operating system.")
+ return 0
+
+
+def _doctor_command(argv: list[str]) -> int:
+ parser = argparse.ArgumentParser(prog="pyv2 doctor", description="Report Windows build readiness.")
+ parser.add_argument("--json", action="store_true", dest="as_json")
+ options = parser.parse_args(argv)
+ pyinstaller = importlib.util.find_spec("PyInstaller") is not None
+ report = {
+ "python_v2": __version__,
+ "python": platform.python_version(),
+ "platform": platform.platform(),
+ "windows": os.name == "nt",
+ "pyinstaller": pyinstaller,
+ "can_build_native_windows_exe": os.name == "nt" and pyinstaller,
+ "can_create_windows_kit": True,
+ }
+ if options.as_json:
+ print(json.dumps(report, sort_keys=True))
+ else:
+ print(f"Python v2 {report['python_v2']} build doctor")
+ print(f" host: {report['platform']}")
+ print(f" Python: {report['python']}")
+ print(f" PyInstaller: {'ready' if pyinstaller else 'not installed'}")
+ print(" build kit: ready")
+ if report["can_build_native_windows_exe"]:
+ print(" Windows EXE: ready")
+ elif os.name == "nt":
+ print(" Windows EXE: install PyInstaller or use generated build.bat")
+ else:
+ print(" Windows EXE: use generated build.bat or GitHub Actions on Windows")
+ return 0
+
+
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
- if "--version" in argv or "-V" in argv:
+ if not argv:
+ return repl()
+ if argv[0] in ("--version", "-V"):
print(f"Python v2 {__version__}")
return 0
-
- if "--help" in argv or "-h" in argv:
- print("usage: python3 -m pyv2 [options] [script.pyv2]")
- print(" python3 -m pyv2 -c 'code'")
- print(" python3 -m pyv2 # start the REPL")
- print("options:")
- print(" -c CODE run CODE and exit")
- print(" -V print version")
- print(" -h show this help")
+ if argv[0] in ("--help", "-h", "help"):
+ _print_help()
return 0
+ if argv[0] == "check":
+ return _check_command(argv[1:])
+ if argv[0] == "new":
+ return _new_command(argv[1:])
+ if argv[0] == "build":
+ return _build_command(argv[1:])
+ if argv[0] == "doctor":
+ return _doctor_command(argv[1:])
- if argv and argv[0] == "-c":
+ if argv[0] == "-c":
source = argv[1] if len(argv) > 1 else ""
- return run_source(source, "")
-
- if argv:
- path = argv[0]
- try:
- with open(path, "r", encoding="utf-8") as fh:
- source = fh.read()
- except OSError as exc:
- print(f"pyv2: cannot open {path!r}: {exc.strerror}", file=sys.stderr)
- return 1
- return run_source(source, path)
+ local_assets = Path.cwd() / "assets"
+ runtime = AppRuntime(data_root=Path.cwd() / ".pyv2-data",
+ asset_root=local_assets if local_assets.is_dir() else None,
+ argv=argv[2:], message_handler=_dialog_message, ask_handler=_dialog_ask)
+ return run_source(source, "", runtime)
- return repl()
+ path = Path(argv[0]).expanduser()
+ try:
+ source = path.read_text(encoding="utf-8")
+ except OSError as exc:
+ print(f"pyv2: cannot open {str(path)!r}: {exc.strerror}", file=sys.stderr)
+ return 1
+ return run_source(source, str(path), _script_runtime(path, argv[1:]))
-def run_source(source: str, name: str) -> int:
- interp = Interpreter(output=_write, read_input=_read_input)
+def run_source(source: str, name: str, app_runtime: AppRuntime | None = None) -> int:
+ interp = Interpreter(output=_write, read_input=_read_input, app_runtime=app_runtime)
try:
interp.run(source)
return 0
except ParseError as exc:
- print(f"SyntaxError: {exc.message} at line {exc.line} ({name})",
- file=sys.stderr)
+ print(f"SyntaxError: {exc.message} at line {exc.line} ({name})", file=sys.stderr)
return 1
except Pyv2Error as exc:
print(exc.format(), file=sys.stderr)
@@ -74,7 +263,11 @@ def run_source(source: str, name: str) -> int:
def repl() -> int:
print(WELCOME)
- interp = Interpreter(output=_write, read_input=_read_input)
+ local_assets = Path.cwd() / "assets"
+ runtime = AppRuntime(data_root=Path.cwd() / ".pyv2-data",
+ asset_root=local_assets if local_assets.is_dir() else None,
+ message_handler=_dialog_message, ask_handler=_dialog_ask)
+ interp = Interpreter(output=_write, read_input=_read_input, app_runtime=runtime)
buffer: list[str] = []
while True:
try:
@@ -95,7 +288,6 @@ def repl() -> int:
buffer.append(line)
source = "\n".join(buffer)
- # Keep reading while blocks are open (unbalanced 'end' count).
if _open_blocks(source) > 0:
continue
buffer.clear()
@@ -107,21 +299,18 @@ def repl() -> int:
print(exc.format(), file=sys.stderr)
except RecursionError:
print("RuntimeError: maximum recursion depth exceeded", file=sys.stderr)
- return 0
def _open_blocks(source: str) -> int:
"""Rough count of open blocks for REPL continuation prompts."""
- from .lexer import tokenize, LexError
+ from .lexer import LexError, tokenize
try:
tokens = tokenize(source + "\n")
except LexError:
return 0
depth = 0
for tok in tokens:
- if tok.type == "KEYWORD" and tok.value in ("if", "while", "for"):
- depth += 1
- elif tok.type == "KEYWORD" and tok.value == "fn":
+ if tok.type == "KEYWORD" and tok.value in ("if", "while", "for", "fn"):
depth += 1
elif tok.type == "KEYWORD" and tok.value == "end":
depth -= 1
diff --git a/pyv2/interpreter.py b/pyv2/interpreter.py
index ad52ed3..3e4b71e 100644
--- a/pyv2/interpreter.py
+++ b/pyv2/interpreter.py
@@ -2,10 +2,12 @@
from __future__ import annotations
+import json
import math
import time
from .parser import parse, ParseError
+from .runtime import AppRuntime, CapabilityError
# --------------------------------------------------------------------------
@@ -265,10 +267,11 @@ def define_in_root(self, name: str, value) -> None:
# Interpreter
# --------------------------------------------------------------------------
class Interpreter:
- def __init__(self, output=None, read_input=None):
+ def __init__(self, output=None, read_input=None, app_runtime: AppRuntime | None = None):
self.globals = Env(scope_root=True)
self.output = output if output is not None else self._default_output
self.read_input = read_input
+ self.app_runtime = app_runtime
self.trace: list[str] = []
self.builtins = self._make_builtins()
@@ -887,6 +890,177 @@ def _input(interp, args, line):
raise interp.error("RuntimeError", "input() is not available here", line)
return interp.read_input(prompt)
+ def _runtime(interp, service, line):
+ if interp.app_runtime is None:
+ raise interp.error(
+ "CapabilityError",
+ f"{service} is unavailable; run this as a desktop app or CLI script",
+ line,
+ )
+ return interp.app_runtime
+
+ def _string_arg(interp, value, who, line):
+ if not isinstance(value, str):
+ raise interp.error("TypeError", f"{who} needs a string", line)
+ return value
+
+ def _runtime_call(interp, service, line, operation):
+ runtime = _runtime(interp, service, line)
+ try:
+ return operation(runtime)
+ except CapabilityError as exc:
+ raise interp.error("CapabilityError", str(exc), line) from exc
+
+ def _args(interp, args, line):
+ return list(interp.app_runtime.argv) if interp.app_runtime is not None else []
+
+ def _app_info(interp, args, line):
+ if interp.app_runtime is not None:
+ return interp.app_runtime.info()
+ return {
+ "name": "Python v2 Program",
+ "version": "1.0.0",
+ "id": "python-v2-program",
+ "platform": "embedded",
+ "data_dir": None,
+ "has_assets": False,
+ "max_text_bytes": 0,
+ }
+
+ def _json_encode(interp, args, line):
+ def validate(value, active):
+ if value is None or isinstance(value, (bool, str)):
+ return
+ if isinstance(value, int):
+ if abs(value) > 9_007_199_254_740_991:
+ raise ValueError("integer exceeds the cross-runtime safe range")
+ return
+ if isinstance(value, float):
+ if not math.isfinite(value):
+ raise ValueError("non-finite numbers are not valid JSON")
+ return
+ if isinstance(value, (list, dict)):
+ marker = id(value)
+ if marker in active:
+ raise ValueError("circular data is not valid JSON")
+ active.add(marker)
+ if isinstance(value, dict):
+ for key, item in value.items():
+ if not isinstance(key, str):
+ raise ValueError("JSON map keys must be strings")
+ validate(item, active)
+ else:
+ for item in value:
+ validate(item, active)
+ active.remove(marker)
+ return
+ raise ValueError(f"{type_name(value)} is not valid JSON")
+ try:
+ validate(args[0], set())
+ return json.dumps(args[0], ensure_ascii=False, separators=(",", ":"),
+ allow_nan=False)
+ except (TypeError, ValueError, OverflowError) as exc:
+ raise interp.error("ValueError", f"json_encode(): {exc}", line) from exc
+
+ def _json_decode(interp, args, line):
+ source = _string_arg(interp, args[0], "json_decode()", line)
+ def reject_constant(value):
+ raise ValueError(f"non-standard number {value!r}")
+ def safe_integer(value):
+ parsed = int(value)
+ if abs(parsed) > 9_007_199_254_740_991:
+ raise ValueError("integer exceeds the cross-runtime safe range")
+ return parsed
+ try:
+ value = json.loads(source, parse_constant=reject_constant, parse_int=safe_integer)
+ except (json.JSONDecodeError, RecursionError, ValueError) as exc:
+ if isinstance(exc, json.JSONDecodeError):
+ message = exc.msg
+ elif isinstance(exc, RecursionError):
+ message = "JSON is nested too deeply"
+ else:
+ message = str(exc)
+ raise interp.error("ValueError", f"json_decode(): {message}", line) from exc
+ if not isinstance(value, (type(None), bool, int, float, str, list, dict)):
+ raise interp.error("ValueError", "json_decode(): unsupported JSON value", line)
+ return value
+
+ def _read_text(interp, args, line):
+ path = _string_arg(interp, args[0], "read_text()", line)
+ return _runtime_call(interp, "read_text()", line,
+ lambda runtime: runtime.read_text(path))
+
+ def _write_text(interp, args, line):
+ path = _string_arg(interp, args[0], "write_text()", line)
+ content = _string_arg(interp, args[1], "write_text()", line)
+ return _runtime_call(interp, "write_text()", line,
+ lambda runtime: runtime.write_text(path, content))
+
+ def _append_text(interp, args, line):
+ path = _string_arg(interp, args[0], "append_text()", line)
+ content = _string_arg(interp, args[1], "append_text()", line)
+ return _runtime_call(interp, "append_text()", line,
+ lambda runtime: runtime.write_text(path, content, append=True))
+
+ def _file_exists(interp, args, line):
+ path = _string_arg(interp, args[0], "file_exists()", line)
+ return _runtime_call(interp, "file_exists()", line,
+ lambda runtime: runtime.exists(path))
+
+ def _make_dir(interp, args, line):
+ path = _string_arg(interp, args[0], "make_dir()", line)
+ return _runtime_call(interp, "make_dir()", line,
+ lambda runtime: runtime.make_dir(path))
+
+ def _list_dir(interp, args, line):
+ if len(args) > 1:
+ raise interp.error("TypeError", "list_dir() expects zero or one argument", line)
+ path = _string_arg(interp, args[0], "list_dir()", line) if args else "."
+ return _runtime_call(interp, "list_dir()", line,
+ lambda runtime: runtime.list_dir(path))
+
+ def _delete_file(interp, args, line):
+ path = _string_arg(interp, args[0], "delete_file()", line)
+ return _runtime_call(interp, "delete_file()", line,
+ lambda runtime: runtime.delete_file(path))
+
+ def _read_asset(interp, args, line):
+ path = _string_arg(interp, args[0], "read_asset()", line)
+ return _runtime_call(interp, "read_asset()", line,
+ lambda runtime: runtime.read_text(path, asset=True))
+
+ def _asset_exists(interp, args, line):
+ path = _string_arg(interp, args[0], "asset_exists()", line)
+ return _runtime_call(interp, "asset_exists()", line,
+ lambda runtime: runtime.exists(path, asset=True))
+
+ def _sleep_ms(interp, args, line):
+ duration = _num_arg(interp, args[0], "sleep_ms()", line)
+ if duration < 0 or duration > 60_000:
+ raise interp.error("ValueError", "sleep_ms() must be between 0 and 60000", line)
+ time.sleep(duration / 1000)
+ return None
+
+ def _ui_message(interp, args, line):
+ title = _string_arg(interp, args[0], "ui_message()", line)
+ message = _string_arg(interp, args[1], "ui_message()", line)
+ runtime = _runtime(interp, "ui_message()", line)
+ if runtime.message_handler is None:
+ raise interp.error("CapabilityError", "native message dialogs are unavailable", line)
+ runtime.message_handler(title, message)
+ return None
+
+ def _ui_ask(interp, args, line):
+ if len(args) not in (2, 3):
+ raise interp.error("TypeError", "ui_ask() expects two or three arguments", line)
+ title = _string_arg(interp, args[0], "ui_ask()", line)
+ prompt = _string_arg(interp, args[1], "ui_ask()", line)
+ default = _string_arg(interp, args[2], "ui_ask()", line) if len(args) > 2 else ""
+ runtime = _runtime(interp, "ui_ask()", line)
+ if runtime.ask_handler is None:
+ raise interp.error("CapabilityError", "native input dialogs are unavailable", line)
+ return runtime.ask_handler(title, prompt, default)
+
def _chr(interp, args, line):
n = _int_arg(interp, args[0], "chr()", line)
return chr(n)
@@ -945,6 +1119,22 @@ def key(v):
"assert": Builtin("assert", _assert, None),
"clock": Builtin("clock", _clock, 0),
"input": Builtin("input", _input, None),
+ "args": Builtin("args", _args, 0),
+ "app_info": Builtin("app_info", _app_info, 0),
+ "json_encode": Builtin("json_encode", _json_encode, 1),
+ "json_decode": Builtin("json_decode", _json_decode, 1),
+ "read_text": Builtin("read_text", _read_text, 1),
+ "write_text": Builtin("write_text", _write_text, 2),
+ "append_text": Builtin("append_text", _append_text, 2),
+ "file_exists": Builtin("file_exists", _file_exists, 1),
+ "make_dir": Builtin("make_dir", _make_dir, 1),
+ "list_dir": Builtin("list_dir", _list_dir, None),
+ "delete_file": Builtin("delete_file", _delete_file, 1),
+ "read_asset": Builtin("read_asset", _read_asset, 1),
+ "asset_exists": Builtin("asset_exists", _asset_exists, 1),
+ "sleep_ms": Builtin("sleep_ms", _sleep_ms, 1),
+ "ui_message": Builtin("ui_message", _ui_message, 2),
+ "ui_ask": Builtin("ui_ask", _ui_ask, None),
"chr": Builtin("chr", _chr, 1),
"ord": Builtin("ord", _ord, 1),
}
@@ -1224,21 +1414,24 @@ def type_name(value) -> str:
# --------------------------------------------------------------------------
# Convenience
# --------------------------------------------------------------------------
-def run(source: str, output=None, read_input=None):
- interp = Interpreter(output=output, read_input=read_input)
+def run(source: str, output=None, read_input=None, app_runtime: AppRuntime | None = None):
+ interp = Interpreter(output=output, read_input=read_input, app_runtime=app_runtime)
interp.run(source)
return interp
-def run_capture(source: str):
- """Run source, returning (output_text, error_or_None)."""
+def run_capture(source: str, app_runtime: AppRuntime | None = None):
+ """Run source, returning ``(output_text, error_or_None)``.
+
+ Host capabilities remain disabled unless an :class:`AppRuntime` is passed.
+ """
lines: list[str] = []
def output(text: str) -> None:
lines.append(text)
try:
- run(source, output=output)
+ run(source, output=output, app_runtime=app_runtime)
return "\n".join(lines), None
except Pyv2Error as exc:
return "\n".join(lines), exc.format()
diff --git a/pyv2/launcher.py b/pyv2/launcher.py
index 602e439..fd8f04c 100644
--- a/pyv2/launcher.py
+++ b/pyv2/launcher.py
@@ -3,7 +3,8 @@
The workspace uses tkinter from the Python standard library. It can edit and
run .pyv2 files, manage a safe local workspace, export packaged PyHope assets,
connect installed code editors, create projects, preview generated websites,
-and open the PyHope games and documentation. No Electron or pygame required.
+scaffold safe Windows EXE projects, and open the PyHope games and documentation.
+No Electron or pygame required.
"""
from __future__ import annotations
@@ -23,8 +24,10 @@
import webbrowser
from . import __version__
+from .builder import create_windows_project
from .interpreter import Interpreter, Pyv2Error
from .parser import ParseError
+from .runtime import AppRuntime
APP_TITLE = f"Python v2 + PyHope {__version__} — Sudbury Workspace"
SITE_URL = "https://gefrus112.github.io/pythonv2/"
@@ -227,13 +230,15 @@ def _runtime_bytes() -> bytes:
def create_project(parent: str | Path, name: str, kind: str) -> Path:
"""Create a Python v2 project and return its directory.
- Kinds: ``website``, ``cli``, and ``game``. Existing files are never
- overwritten, which makes the launcher safe to use around real projects.
+ Kinds: ``website``, ``cli``, ``game``, and ``windows``. Existing files are
+ never overwritten, which makes the launcher safe to use around real projects.
"""
display_name = " ".join(name.strip().split()) or "My Python v2 Project"
+ kind = kind.lower()
+ if kind == "windows":
+ return create_windows_project(parent, display_name)
target = Path(parent).expanduser().resolve() / safe_name(display_name)
target.mkdir(parents=True, exist_ok=True)
- kind = kind.lower()
source_name = display_name.replace("\\", "\\\\").replace('"', '\\"')
if kind == "website":
@@ -568,7 +573,7 @@ def show_home(self):
font=("Consolas", 10, "bold")).pack(anchor="w", padx=24, pady=(20, 5))
self.tk.Label(hero, text="The snake, but better — now with its own app.", bg="#203d59", fg="white",
font=("Segoe UI", 19, "bold")).pack(anchor="w", padx=24)
- self.tk.Label(hero, text="Edit .pyv2 • manage files • export assets • connect editors • build websites and games",
+ self.tk.Label(hero, text="Edit .pyv2 • manage safe app data • build websites, games, and real Windows EXE projects",
bg="#203d59", fg="#c9deee", font=("Segoe UI", 10)).pack(anchor="w", padx=24, pady=(5, 20))
grid = self.tk.Frame(self.content, bg=self.BG)
@@ -578,7 +583,8 @@ def show_home(self):
cards = [
("Code Studio", "Edit and run .pyv2 programs with a built-in output console.", "Open editor", self.show_editor, self.BLUE),
("Workspace Files", "Create, read, save, rename, and safely delete files inside your own workspace.", "Manage files", self.show_files, "#44a36f"),
- ("Website Builder", "Create a complete browser project generated by Python v2.", "New website", self.show_projects, self.RED),
+ ("Website Builder", "Create a complete browser project generated by Python v2.", "New website", lambda: self.show_projects("Website"), self.RED),
+ ("Windows EXE Builder", "Create a validated app, reproducible Windows build kit, and real .exe workflow.", "New Windows app", lambda: self.show_projects("Windows app"), "#d67b35"),
("Asset Library", "Export eight packaged CC0 PyHope characters, enemies, weapons, cars, and coins.", "Browse assets", self.show_assets, "#8f2c52"),
("PyHope Arcade", "Open 10 shooters, platformers, racers, and the 9.4M GPU lab.", "Play games", self.show_games, "#7655b7"),
("Connected Editors", "Detect VS Code, VSCodium, Zed, Sublime Text, and PyCharm.", "Check editors", self.show_editors, self.YELLOW),
@@ -647,7 +653,20 @@ def run_editor(self):
self.current_file.write_text(self.editor.get("1.0", "end-1c"), encoding="utf-8")
self._set_status("Autosaved " + str(self.current_file))
lines = []
- interp = Interpreter(output=lines.append, read_input=lambda prompt="": self.simpledialog.askstring("Python v2 input", prompt) or "")
+ app_root = self.current_file.parent if self.current_file else self.file_service.root
+ assets = app_root / "assets"
+ runtime = AppRuntime(
+ app_name=self.current_file.stem if self.current_file else "Python v2 Studio App",
+ data_root=app_root / ".pyv2-data",
+ asset_root=assets if assets.is_dir() else None,
+ message_handler=lambda title, message: self.messagebox.showinfo(title, message),
+ ask_handler=lambda title, prompt, default: self.simpledialog.askstring(title, prompt, initialvalue=default),
+ )
+ interp = Interpreter(
+ output=lines.append,
+ read_input=lambda prompt="": self.simpledialog.askstring("Python v2 input", prompt) or "",
+ app_runtime=runtime,
+ )
try:
interp.run(self.editor.get("1.0", "end-1c"))
text, error = "\n".join(lines), False
@@ -955,9 +974,9 @@ def _save_settings(self, workspace: str, editor: str, font_size: str, autosave:
except Exception as exc:
self.messagebox.showerror("Could not save settings", str(exc))
- def show_projects(self):
+ def show_projects(self, initial_kind="Website"):
self._clear()
- self._page_head("New Project", "Start a website, command-line app, or Python v2 game. Existing files are never overwritten.")
+ self._page_head("New Project", "Start a website, command-line app, PyHope game, or production Windows EXE project. Existing files are never overwritten.")
form = self.tk.Frame(self.content, bg=self.PANEL, padx=24, pady=24)
form.pack(fill="x", padx=32)
self.tk.Label(form, text="Project name", bg=self.PANEL, fg=self.TEXT).grid(row=0, column=0, sticky="w", pady=7)
@@ -971,8 +990,8 @@ def show_projects(self):
self.ttk.Button(form, text="Browse", style="Dark.TButton",
command=lambda: self._browse_into(location)).grid(row=1, column=2)
self.tk.Label(form, text="Project type", bg=self.PANEL, fg=self.TEXT).grid(row=2, column=0, sticky="w", pady=7)
- kind = self.ttk.Combobox(form, state="readonly", values=["Website", "CLI app", "Game"], font=("Segoe UI", 10))
- kind.set("Website")
+ kind = self.ttk.Combobox(form, state="readonly", values=["Website", "Windows app", "CLI app", "Game"], font=("Segoe UI", 10))
+ kind.set(initial_kind if initial_kind in {"Website", "Windows app", "CLI app", "Game"} else "Website")
kind.grid(row=2, column=1, sticky="ew", padx=12, pady=7)
form.columnconfigure(1, weight=1)
self.ttk.Button(form, text="Create Project", style="Accent.TButton",
@@ -982,13 +1001,18 @@ def show_projects(self):
info = self.tk.Frame(self.content, bg=self.BG)
info.pack(fill="x", padx=32, pady=22)
descriptions = [
- ("Website", "A polished personal profile, portfolio, and PyHope game showcase generated by app.pyv2, with an offline runtime and local preview."),
+ ("Website", "A polished personal profile, portfolio, and PyHope game showcase generated by app.pyv2."),
+ ("Windows app", "A production app with sandboxed data, JSON, arguments, native dialogs, EXE scripts, integrity manifest, and GitHub build workflow."),
("CLI app", "A clean main.pyv2 starter that runs with pyv2 main.pyv2."),
("Game", "A complete terminal battle game ready to expand with PyHope sprites and maps."),
]
+ for column in range(2):
+ info.columnconfigure(column, weight=1, uniform="project-kinds")
+ colors = [self.BLUE, "#d67b35", self.RED, "#44a36f"]
for i, (title, text) in enumerate(descriptions):
- card = self._card(info, title, text, "Choose " + title, lambda value=title: kind.set(value), [self.BLUE, self.RED, "#44a36f"][i])
- card.pack(side="left", fill="both", expand=True, padx=(0 if i == 0 else 6, 0 if i == 2 else 6))
+ row, column = divmod(i, 2)
+ card = self._card(info, title, text, "Choose " + title, lambda value=title: kind.set(value), colors[i])
+ card.grid(row=row, column=column, sticky="nsew", padx=(0 if column == 0 else 6, 0 if column == 1 else 6), pady=6)
def _browse_into(self, entry):
chosen = self.filedialog.askdirectory(title="Choose project folder")
@@ -997,7 +1021,7 @@ def _browse_into(self, entry):
entry.insert(0, chosen)
def _create_from_form(self, location, name, label):
- kind = {"Website": "website", "CLI app": "cli", "Game": "game"}.get(label, "website")
+ kind = {"Website": "website", "Windows app": "windows", "CLI app": "cli", "Game": "game"}.get(label, "website")
try:
project = create_project(location, name, kind)
except Exception as exc:
@@ -1012,6 +1036,13 @@ def _create_from_form(self, location, name, label):
self.preview_website(project)
else:
self.show_editor()
+ if kind == "windows":
+ self.messagebox.showinfo(
+ "Windows app created",
+ "Edit main.pyv2, then run 'pyv2 check .' and 'pyv2 build .'.\n\n"
+ "On Windows, build-windows.bat creates the real one-file EXE. "
+ "The project also includes a Windows GitHub Actions workflow.",
+ )
def open_in_vscode(self):
project = self.current_project
diff --git a/pyv2/runtime.py b/pyv2/runtime.py
new file mode 100644
index 0000000..ebe8063
--- /dev/null
+++ b/pyv2/runtime.py
@@ -0,0 +1,171 @@
+"""Capability-based host services for production Python v2 applications.
+
+The language runtime stays deterministic and browser-friendly by default.
+Desktop callers explicitly provide :class:`AppRuntime` when a program may use
+arguments, app metadata, sandboxed files, bundled assets, or native dialogs.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+import os
+from pathlib import Path
+import platform
+import tempfile
+from typing import Callable, Iterable
+
+
+MAX_TEXT_BYTES = 8 * 1024 * 1024
+
+
+class CapabilityError(ValueError):
+ """Raised when a program requests a service outside its sandbox."""
+
+
+@dataclass
+class AppRuntime:
+ """Explicit host capabilities made available to a Python v2 program.
+
+ ``data_root`` is the only writable tree. ``asset_root`` is an optional,
+ read-only tree. Both use resolved paths and reject absolute paths, ``..``,
+ and symlink escapes. This gives packaged apps useful persistence without
+ granting broad access to the user's machine.
+ """
+
+ app_name: str = "Python v2 App"
+ app_version: str = "1.0.0"
+ app_id: str = "python-v2-app"
+ data_root: Path | str | None = None
+ asset_root: Path | str | None = None
+ argv: Iterable[str] = field(default_factory=tuple)
+ platform_name: str = field(default_factory=lambda: platform.system().lower() or os.name)
+ message_handler: Callable[[str, str], None] | None = None
+ ask_handler: Callable[[str, str, str], str | None] | None = None
+ max_text_bytes: int = MAX_TEXT_BYTES
+
+ def __post_init__(self) -> None:
+ self.data_root = Path(self.data_root).expanduser().resolve() if self.data_root else None
+ self.asset_root = Path(self.asset_root).expanduser().resolve() if self.asset_root else None
+ self.argv = tuple(str(value) for value in self.argv)
+ if self.max_text_bytes < 1:
+ raise ValueError("max_text_bytes must be positive")
+
+ def _resolve(self, root: Path | None, relative: str, service: str) -> Path:
+ if root is None:
+ raise CapabilityError(f"{service} is not available in this runtime")
+ if not isinstance(relative, str) or not relative.strip():
+ raise CapabilityError(f"{service} path must be a non-empty string")
+ requested = Path(relative)
+ if requested.is_absolute():
+ raise CapabilityError(f"{service} paths must be relative")
+ target = (root / requested).resolve()
+ try:
+ target.relative_to(root)
+ except ValueError as exc:
+ raise CapabilityError(f"{service} path escapes the app sandbox") from exc
+ return target
+
+ def data_path(self, relative: str) -> Path:
+ return self._resolve(self.data_root, relative, "file access")
+
+ def asset_path(self, relative: str) -> Path:
+ return self._resolve(self.asset_root, relative, "asset access")
+
+ def read_text(self, relative: str, *, asset: bool = False) -> str:
+ path = self.asset_path(relative) if asset else self.data_path(relative)
+ try:
+ size = path.stat().st_size
+ if size > self.max_text_bytes:
+ raise CapabilityError(
+ f"text file is too large ({size} bytes; limit is {self.max_text_bytes})"
+ )
+ return path.read_text(encoding="utf-8")
+ except CapabilityError:
+ raise
+ except UnicodeDecodeError as exc:
+ raise CapabilityError("file is not valid UTF-8 text") from exc
+ except OSError as exc:
+ raise CapabilityError(str(exc)) from exc
+
+ def write_text(self, relative: str, content: str, *, append: bool = False) -> str:
+ encoded = content.encode("utf-8")
+ if len(encoded) > self.max_text_bytes:
+ raise CapabilityError(
+ f"text is too large ({len(encoded)} bytes; limit is {self.max_text_bytes})"
+ )
+ path = self.data_path(relative)
+ try:
+ if append and path.exists() and path.stat().st_size + len(encoded) > self.max_text_bytes:
+ raise CapabilityError(
+ f"appended file would exceed the {self.max_text_bytes}-byte text limit"
+ )
+ path.parent.mkdir(parents=True, exist_ok=True)
+ if append:
+ with path.open("a", encoding="utf-8", newline="") as handle:
+ handle.write(content)
+ else:
+ # Atomic replacement protects settings and saves from partial writes.
+ fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
+ handle.write(content)
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(temporary, path)
+ except Exception:
+ try:
+ os.unlink(temporary)
+ except OSError:
+ pass
+ raise
+ except OSError as exc:
+ raise CapabilityError(str(exc)) from exc
+ return path.relative_to(self.data_root).as_posix() # type: ignore[arg-type]
+
+ def exists(self, relative: str, *, asset: bool = False) -> bool:
+ path = self.asset_path(relative) if asset else self.data_path(relative)
+ return path.is_file()
+
+ def make_dir(self, relative: str) -> str:
+ path = self.data_path(relative)
+ try:
+ path.mkdir(parents=True, exist_ok=True)
+ except OSError as exc:
+ raise CapabilityError(str(exc)) from exc
+ return path.relative_to(self.data_root).as_posix() # type: ignore[arg-type]
+
+ def list_dir(self, relative: str = ".") -> list[str]:
+ path = self.data_path(relative)
+ try:
+ if not path.is_dir():
+ raise CapabilityError(f"not a directory: {relative}")
+ return sorted(item.name for item in path.iterdir())
+ except CapabilityError:
+ raise
+ except OSError as exc:
+ raise CapabilityError(str(exc)) from exc
+
+ def delete_file(self, relative: str) -> bool:
+ path = self.data_path(relative)
+ try:
+ if not path.exists():
+ return False
+ if not path.is_file():
+ raise CapabilityError("delete_file() only removes files")
+ path.unlink()
+ return True
+ except CapabilityError:
+ raise
+ except OSError as exc:
+ raise CapabilityError(str(exc)) from exc
+
+ def info(self) -> dict[str, object]:
+ return {
+ "name": self.app_name,
+ "version": self.app_version,
+ "id": self.app_id,
+ "platform": self.platform_name,
+ "data_dir": str(self.data_root) if self.data_root else None,
+ "has_assets": self.asset_root is not None,
+ "max_text_bytes": self.max_text_bytes,
+ }
diff --git a/style.css b/style.css
index 256c503..884f2c6 100644
--- a/style.css
+++ b/style.css
@@ -701,6 +701,17 @@ kbd {
max-width: 52rem;
}
.steps strong { color: var(--blue-ink); }
+.callout {
+ max-width: 52rem;
+ margin: 18px 0;
+ padding: 17px 20px;
+ border: 1px solid #b9cee0;
+ border-left: 5px solid var(--blue-light);
+ border-radius: 10px;
+ color: #31516a;
+ background: #edf6fc;
+}
+.callout strong { color: var(--blue-ink); }
.checklist {
list-style: none; padding: 0; max-width: 52rem;
diff --git a/tests/test_apps.py b/tests/test_apps.py
new file mode 100644
index 0000000..6b1c739
--- /dev/null
+++ b/tests/test_apps.py
@@ -0,0 +1,226 @@
+"""Production app runtime and Windows build-tool tests."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+
+from pyv2.builder import (
+ BuildError,
+ KIT_MARKER,
+ build_native_windows,
+ check_app,
+ create_windows_project,
+ executable_name,
+ load_app_config,
+ prepare_windows_build,
+)
+from pyv2.interpreter import run_capture
+from pyv2.runtime import AppRuntime
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class TestAppRuntime(unittest.TestCase):
+ def test_json_round_trip(self):
+ source = '''data = {name: "Ada", enabled: true, scores: [2, 4, 8]}
+text = json_encode(data)
+copy = json_decode(text)
+print(text)
+print(copy.name, copy.scores[2])'''
+ output, error = run_capture(source)
+ self.assertIsNone(error)
+ self.assertEqual(output, '{"name":"Ada","enabled":true,"scores":[2,4,8]}\nAda 8')
+ _, error = run_capture('json_decode("NaN")')
+ self.assertIn("ValueError: json_decode()", error)
+ _, error = run_capture('json_encode({1: "numeric key"})')
+ self.assertIn("JSON map keys must be strings", error)
+ _, error = run_capture("json_encode(9007199254740992)")
+ self.assertIn("safe range", error)
+ _, error = run_capture('json_decode("9007199254740992")')
+ self.assertIn("safe range", error)
+
+ def test_sandboxed_files_arguments_and_metadata(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ assets = root / "assets"
+ assets.mkdir()
+ (assets / "welcome.txt").write_text("hello asset", encoding="utf-8")
+ runtime = AppRuntime(
+ app_name="Comet Notes", app_version="2.3.1", app_id="comet-notes",
+ data_root=root / "data", asset_root=assets, argv=["--save", "Ada"],
+ )
+ source = '''make_dir("notes")
+write_text("notes/user.json", json_encode({name: args()[1]}))
+append_text("notes/log.txt", "saved\\n")
+print(json_decode(read_text("notes/user.json")).name)
+print(read_asset("welcome.txt"))
+print(file_exists("notes/log.txt"), asset_exists("welcome.txt"))
+print(list_dir("notes"))
+print(app_info().name, app_info().version)'''
+ output, error = run_capture(source, app_runtime=runtime)
+ self.assertIsNone(error)
+ self.assertEqual(
+ output,
+ 'Ada\nhello asset\ntrue true\n["log.txt", "user.json"]\nComet Notes 2.3.1',
+ )
+ self.assertEqual((root / "data/notes/log.txt").read_text(), "saved\n")
+
+ def test_file_sandbox_rejects_traversal_and_absolute_paths(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ runtime = AppRuntime(data_root=Path(tmp) / "data")
+ for path in ("../escape.txt", str(Path(tmp).resolve() / "absolute.txt")):
+ _, error = run_capture(f'write_text({json.dumps(path)}, "no")', app_runtime=runtime)
+ self.assertIn("CapabilityError", error)
+ self.assertFalse((Path(tmp) / "escape.txt").exists())
+ limited = AppRuntime(data_root=Path(tmp) / "limited", max_text_bytes=5)
+ output, error = run_capture('write_text("x.txt", "12345")\nappend_text("x.txt", "6")',
+ app_runtime=limited)
+ self.assertIn("would exceed", error)
+ self.assertEqual((Path(tmp) / "limited/x.txt").read_text(), "12345")
+
+ def test_file_services_require_an_explicit_host_capability(self):
+ _, error = run_capture('write_text("x.txt", "no")')
+ self.assertIn("CapabilityError", error)
+ output, error = run_capture("print(len(args()), app_info().platform)")
+ self.assertIsNone(error)
+ self.assertEqual(output, "0 embedded")
+
+ def test_ui_callbacks_are_explicit(self):
+ messages = []
+ runtime = AppRuntime(
+ data_root=Path.cwd(),
+ message_handler=lambda title, text: messages.append((title, text)),
+ ask_handler=lambda title, prompt, default: default + "!",
+ )
+ output, error = run_capture(
+ 'ui_message("Ready", "Safe app")\nprint(ui_ask("Name", "Who?", "Ada"))',
+ app_runtime=runtime,
+ )
+ self.assertIsNone(error)
+ self.assertEqual(output, "Ada!")
+ self.assertEqual(messages, [("Ready", "Safe app")])
+
+
+class TestWindowsBuilder(unittest.TestCase):
+ def test_windows_reserved_and_unsafe_names_are_sanitized(self):
+ self.assertEqual(executable_name("My / Great:* App"), "My-Great-App")
+ self.assertEqual(executable_name("CON"), "app-con")
+
+ def test_create_validate_and_run_console_project(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ project = create_windows_project(tmp, "Comet Tasks")
+ config = check_app(project)
+ self.assertEqual(config.executable_name, "Comet-Tasks")
+ self.assertTrue(config.console)
+ self.assertTrue((project / ".github/workflows/windows-exe.yml").is_file())
+ runtime = AppRuntime(data_root=project / ".pyv2-data", argv=["--add", "Ship EXE"])
+ output, error = run_capture((project / "main.pyv2").read_text(), app_runtime=runtime)
+ self.assertIsNone(error)
+ self.assertEqual(output, "Added: Ship EXE")
+ saved = json.loads((project / ".pyv2-data/tasks.json").read_text())
+ self.assertEqual(saved, [{"title": "Ship EXE", "done": False}])
+
+ def test_windowed_project_uses_dialog_apis(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ project = create_windows_project(tmp, "Hello Window", windowed=True)
+ config = check_app(project)
+ self.assertFalse(config.console)
+ source = (project / "main.pyv2").read_text()
+ self.assertIn("ui_ask", source)
+ self.assertIn("ui_message", source)
+
+ def test_build_kit_is_reproducible_and_inspectable(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ project = create_windows_project(tmp, "Comet Tasks")
+ result = prepare_windows_build(project, Path(tmp) / "kit")
+ expected = {
+ KIT_MARKER, "BUILDING.md", "app.spec", "build.bat", "build.ps1",
+ "launcher.py", "manifest.json", "pyv2app.json", "source.pyv2",
+ "version_info.txt", "assets", "runtime",
+ }
+ self.assertEqual({path.name for path in result.kit_dir.iterdir()}, expected)
+ compile((result.kit_dir / "launcher.py").read_text(), "launcher.py", "exec")
+ manifest = json.loads(result.manifest.read_text())
+ self.assertEqual(manifest["app"]["executable"], "Comet-Tasks.exe")
+ source_hash = hashlib.sha256((project / "main.pyv2").read_bytes()).hexdigest()
+ self.assertEqual(manifest["source_sha256"], source_hash)
+ self.assertIn("pathex=['runtime']", (result.kit_dir / "app.spec").read_text())
+ self.assertTrue((result.kit_dir / "runtime/pyv2/interpreter.py").is_file())
+ self.assertIn("runtime/pyv2/interpreter.py", manifest["files"])
+ self.assertIn("dist\\Comet-Tasks.exe", (result.kit_dir / "BUILDING.md").read_text())
+
+ def test_build_refuses_to_replace_unmanaged_directory(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ project = create_windows_project(tmp, "Safe Build")
+ output = Path(tmp) / "important"
+ output.mkdir()
+ (output / "keep.txt").write_text("do not delete")
+ with self.assertRaisesRegex(BuildError, "refusing to replace"):
+ prepare_windows_build(project, output)
+ self.assertEqual((output / "keep.txt").read_text(), "do not delete")
+
+ def test_config_rejects_escape_and_broken_source(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ project = root / "app"
+ project.mkdir()
+ (root / "outside.pyv2").write_text('print("no")')
+ (project / "pyv2app.json").write_text(json.dumps({"entry": "../outside.pyv2", "name": "Unsafe"}))
+ with self.assertRaisesRegex(BuildError, "escapes"):
+ load_app_config(project)
+ (project / "main.pyv2").write_text("if true print(1)")
+ (project / "pyv2app.json").write_text(json.dumps({"entry": "main.pyv2", "name": "Broken"}))
+ with self.assertRaisesRegex(BuildError, "SyntaxError"):
+ load_app_config(project)
+ (project / "main.pyv2").write_text('print("ok")')
+ (project / "pyv2app.json").write_text(json.dumps({
+ "entry": "main.pyv2", "name": "Unknown", "mystery": True,
+ }))
+ with self.assertRaisesRegex(BuildError, "unknown pyv2app.json field"):
+ load_app_config(project)
+
+ @unittest.skipIf(os.name == "nt", "symlink creation is privilege-dependent on Windows")
+ def test_assets_reject_symlink_escape(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ project = create_windows_project(root, "Linked Assets")
+ outside = root / "secret.txt"
+ outside.write_text("do not bundle")
+ (project / "assets/secret-link.txt").symlink_to(outside)
+ with self.assertRaisesRegex(BuildError, "symbolic links"):
+ load_app_config(project)
+
+ @unittest.skipIf(os.name == "nt", "non-Windows honesty check")
+ def test_native_build_reports_cross_compile_limit(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ project = create_windows_project(tmp, "Honest Build")
+ result = prepare_windows_build(project)
+ with self.assertRaisesRegex(BuildError, "require Windows"):
+ build_native_windows(result)
+
+ def test_cli_new_check_build_and_doctor(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ commands = [
+ [sys.executable, "-m", "pyv2", "new", "windows-app", "CLI App", "-d", tmp],
+ [sys.executable, "-m", "pyv2", "check", str(Path(tmp) / "CLI-App")],
+ [sys.executable, "-m", "pyv2", "build", str(Path(tmp) / "CLI-App"),
+ "--output", str(Path(tmp) / "kit")],
+ [sys.executable, "-m", "pyv2", "doctor", "--json"],
+ ]
+ for command in commands:
+ completed = subprocess.run(command, cwd=ROOT, text=True, capture_output=True)
+ self.assertEqual(completed.returncode, 0, completed.stderr)
+ doctor = json.loads(completed.stdout)
+ self.assertTrue(doctor["can_create_windows_kit"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_games_site.py b/tests/test_games_site.py
index 97c7c3d..f7e9d1a 100644
--- a/tests/test_games_site.py
+++ b/tests/test_games_site.py
@@ -49,6 +49,7 @@ def test_playground_has_dual_runtime_terminals_and_workshop(self):
'id="site-html-source"', 'id="copy-website-html"',
'data-site-view="preview"', 'data-site-view="html"',
'data-preview-size="mobile"', 'data-preview-size="desktop"',
+ '',
'src="classic-python.js"', 'src="playground.js"',
):
self.assertIn(required, playground)
@@ -71,6 +72,19 @@ def test_portfolio_starter_emits_projects_games_and_profile(self):
self.assertGreaterEqual(sum(line.startswith("SKILL|") for line in lines), 6)
self.assertIn("PythonV2PortfolioTemplate", script)
+ def test_production_app_playground_starter_runs(self):
+ script = (ROOT / "playground.js").read_text(encoding="utf-8")
+ match = re.search(
+ r'"app-v2": \{[\s\S]*?source: `([\s\S]*?)`,\n \},',
+ script,
+ )
+ self.assertIsNotNone(match)
+ output, error = run_capture(match.group(1))
+ self.assertIsNone(error)
+ self.assertIn("Comet Notes v1.0.0", output)
+ self.assertIn("Build the Windows EXE", output)
+ self.assertIn("platform: embedded", output)
+
def test_classic_python_compatibility_smoke(self):
completed = subprocess.run(
["node", str(ROOT / "tests" / "classic_python_smoke.js")],
diff --git a/tests/test_launcher.py b/tests/test_launcher.py
index 2b4c255..ecd42d6 100644
--- a/tests/test_launcher.py
+++ b/tests/test_launcher.py
@@ -59,6 +59,15 @@ def test_website_template_is_portfolio_and_game_showcase(self):
self.assertIn('id="game-grid"', page)
self.assertIn('id="contact"', page)
+ def test_create_windows_app_project(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ project = create_project(tmp, "Comet Desktop", "windows")
+ self.assertTrue((project / "main.pyv2").is_file())
+ self.assertTrue((project / "pyv2app.json").is_file())
+ self.assertTrue((project / "build-windows.bat").is_file())
+ self.assertTrue((project / ".github/workflows/windows-exe.yml").is_file())
+ parse((project / "main.pyv2").read_text())
+
def test_existing_files_are_not_overwritten(self):
with tempfile.TemporaryDirectory() as tmp:
project = create_project(tmp, "Safe", "cli")
diff --git a/tests/test_parity.py b/tests/test_parity.py
index d0f8f26..53688f8 100644
--- a/tests/test_parity.py
+++ b/tests/test_parity.py
@@ -71,6 +71,8 @@
''',
"truthy": 'print(bool(0), bool(""), bool([]), bool({}), bool(nil), bool(false))\nprint(nil or "default", 1 and 2, 0 and 2, "" or 0 or "fb")\nprint(1 == 1.0, 1 == true, nil == nil, [1, [2]] == [1, [2]])',
"builtins": 'print(abs(-5), min(3, 1, 2), max([3, 1, 2]))\nprint(floor(2.7), ceil(2.1), round(2.5), round(-2.5), sqrt(9))\nprint(int("42"), int(3.9), int(-3.9), float("2.5"), str(99))\nprint(type(1), type("a"), type([]), type({}), type(nil))\nprint(chr(65), ord("A"), sort([3, 1, 2]), join(["a", "b"], "-"))\nprint(len(range(10)), len(range(2, 10, 3)), repr("hi"), repr([1, "a"]))',
+ "json": 'data = {name: "Ada", active: true, scores: [1, 2.5, nil]}\nencoded = json_encode(data)\nprint(encoded)\ncopy = json_decode(encoded)\nprint(copy.name, copy.active, copy.scores)',
+ "app_basics": 'print(args())\ninfo = app_info()\nprint(info.name, info.version, info.has_assets, info.data_dir)',
"errors": 'print(nope)',
"errors2": 'xs = [1]\nprint(xs[5])',
"errors3": 'print("a" - 1)',
diff --git a/tutorial.html b/tutorial.html
index cda47a2..0dd1c7d 100644
--- a/tutorial.html
+++ b/tutorial.html
@@ -371,6 +371,9 @@ ★ What's Next
machine and run examples/fizzbuzz.pyv2.
Read the docs — full
builtin & method reference, plus editor setup.
+ Build a Windows app
+ with pyv2 new windows-app "My App", safe JSON data, and a
+ reproducible one-file EXE workflow.
Play the CPU raycaster
and then read examples/terminal_raycast.pyv2 —
the whole engine in one file.
From 77f1bc75492700f641152ce21146153d25d60a87 Mon Sep 17 00:00:00 2001
From: gefrus112 <329268472+gefrus112@users.noreply.github.com>
Date: Thu, 22 Oct 2026 13:21:04 +0000
Subject: [PATCH 2/2] Harden Windows workflow command discovery
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
---
.github/workflows/windows-exe-smoke.yml | 4 ++--
pyv2/builder.py | 4 ++--
pyv2/cli.py | 12 ++++++------
3 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/.github/workflows/windows-exe-smoke.yml b/.github/workflows/windows-exe-smoke.yml
index b6ac8f1..2aa1916 100644
--- a/.github/workflows/windows-exe-smoke.yml
+++ b/.github/workflows/windows-exe-smoke.yml
@@ -32,9 +32,9 @@ jobs:
- name: Install Python v2 and native builder
run: python -m pip install . pyinstaller
- name: Validate the production app example
- run: pyv2 check examples/software/comet_notes.pyv2
+ run: python -m pyv2 check examples/software/comet_notes.pyv2
- name: Build a real one-file Windows executable
- run: pyv2 build examples/software/comet_notes.pyv2 --native --output build/windows-smoke
+ run: python -m pyv2 build examples/software/comet_notes.pyv2 --native --output build/windows-smoke
- name: Run the packaged executable
shell: pwsh
run: |
diff --git a/pyv2/builder.py b/pyv2/builder.py
index f402d43..ebd6aea 100644
--- a/pyv2/builder.py
+++ b/pyv2/builder.py
@@ -622,9 +622,9 @@ def _project_readme(name: str, windowed: bool) -> str:
- name: Install Python v2 and PyInstaller
run: python -m pip install pyinstaller "git+{SOURCE_REPOSITORY}"
- name: Validate app
- run: pyv2 check .
+ run: python -m pyv2 check .
- name: Build integrity-tracked executable
- run: pyv2 build . --native --output build/windows
+ run: python -m pyv2 build . --native --output build/windows
- uses: actions/upload-artifact@v4
with:
name: windows-executable
diff --git a/pyv2/cli.py b/pyv2/cli.py
index eca1375..ff5adab 100644
--- a/pyv2/cli.py
+++ b/pyv2/cli.py
@@ -110,7 +110,7 @@ def _check_command(argv: list[str]) -> int:
if options.as_json:
print(json.dumps(result, sort_keys=True))
else:
- print(f"✓ {config.name} {config.version} is ready to build")
+ print(f"[OK] {config.name} {config.version} is ready to build")
print(f" entry: {config.entry}")
print(f" mode: {result['mode']} • assets: {result['assets']}")
return 0
@@ -129,7 +129,7 @@ def _new_command(argv: list[str]) -> int:
except (BuildError, OSError, ValueError) as exc:
print(f"could not create app: {exc}", file=sys.stderr)
return 1
- print(f"✓ Created {'windowed' if options.windowed else 'console'} Windows app: {project}")
+ print(f"[OK] Created {'windowed' if options.windowed else 'console'} Windows app: {project}")
print(f" check: pyv2 check {project}")
print(f" kit: pyv2 build {project}")
print(" exe: run build-windows.bat on Windows or use the included GitHub workflow")
@@ -164,11 +164,11 @@ def _build_command(argv: list[str]) -> int:
except (BuildError, OSError) as exc:
print(f"build failed: {exc}", file=sys.stderr)
return 1
- print(f"✓ Validated {result.config.name} {result.config.version}")
- print(f"✓ Windows build kit: {result.kit_dir}")
- print(f"✓ Integrity manifest: {result.manifest}")
+ print(f"[OK] Validated {result.config.name} {result.config.version}")
+ print(f"[OK] Windows build kit: {result.kit_dir}")
+ print(f"[OK] Integrity manifest: {result.manifest}")
if result.executable:
- print(f"✓ Windows executable: {result.executable}")
+ print(f"[OK] Windows executable: {result.executable}")
elif os.name == "nt":
print("Next: run the generated build.bat, or add --native after installing PyInstaller.")
else: