Skip to content

Commit

Permalink
Prepare for python release (#134)
Browse files Browse the repository at this point in the history
* Generate __all__ for python schemas

* Update schema and imports

* Rename package to justbe-webview

* Add github action to publish python package
  • Loading branch information
zephraph authored Feb 19, 2025
1 parent a317380 commit 640910e
Show file tree
Hide file tree
Showing 16 changed files with 316 additions and 88 deletions.
82 changes: 82 additions & 0 deletions .github/workflows/publish-python.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: Publish Python Client
on:
workflow_run:
workflows: ["Verify", "Release Rust Binary"]
types:
- completed
branches: [main]
pull_request:
paths:
- 'src/clients/python/**'

jobs:
publish:
runs-on: ubuntu-latest
# Only run if the workflow_run was successful (for the main branch case)
if: github.event_name == 'pull_request' || github.event.workflow_run.conclusion == 'success'

defaults:
run:
working-directory: src/clients/python

permissions:
contents: write
id-token: write

steps:
- uses: actions/checkout@v4

- name: Install mise
uses: jdx/mise-action@v2

- name: Install tools
run: mise install

- name: Get current version
id: current_version
run: echo "version=$(grep '^version = ' pyproject.toml | cut -d'"' -f2)" >> $GITHUB_OUTPUT

- name: Get published version
id: published_version
run: |
if ! version=$(curl -sf https://pypi.org/pypi/justbe-webview/json | jq -r '.info.version // "0.0.0"'); then
echo "Failed to fetch version from PyPI, using 0.0.0"
version="0.0.0"
fi
echo "version=$version" >> $GITHUB_OUTPUT
- name: Build package
if: ${{ steps.current_version.outputs.version != steps.published_version.outputs.version || github.event_name == 'pull_request' }}
run: uv build

- name: Dry run publish to PyPI
if: ${{ github.event_name == 'pull_request' }}
run: |
echo "Would publish version ${{ steps.current_version.outputs.version }} to PyPI"
echo "Current published version: ${{ steps.published_version.outputs.version }}"
echo "Package contents:"
ls -l dist/
echo "Archive contents:"
tar tzf dist/*.tar.gz | sort
- name: Publish to PyPI
if: ${{ steps.current_version.outputs.version != steps.published_version.outputs.version && github.event_name == 'workflow_run' }}
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: src/clients/python/dist/
verbose: true

- name: Tag and push if versions differ
if: ${{ steps.current_version.outputs.version != steps.published_version.outputs.version && github.event_name == 'workflow_run' }}
run: |
# Ensure the tag doesn't already exist
if ! git rev-parse "python-v${{ steps.current_version.outputs.version }}" >/dev/null 2>&1; then
git config user.name github-actions
git config user.email [email protected]
git tag -a python-v${{ steps.current_version.outputs.version }} -m "Release ${{ steps.current_version.outputs.version }}"
git push origin python-v${{ steps.current_version.outputs.version }}
else
echo "Tag python-v${{ steps.current_version.outputs.version }} already exists, skipping tag creation"
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2 changes: 1 addition & 1 deletion mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ description = "Generate the python client"
run = "deno run -A scripts/generate-schema/index.ts --language python"
depends = ["gen:rust"]
sources = ["schemas/*", "scripts/generate-schema.ts"]
outputs = ["src/clients/python/src/webview_python/schemas/*.py"]
outputs = ["src/clients/python/src/justbe_webview/schemas/*.py"]

## Debug

Expand Down
70 changes: 70 additions & 0 deletions scripts/generate-schema/gen-python.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { assertEquals } from "jsr:@std/assert";
import dedent from "npm:dedent";
import { extractExportedNames } from "./gen-python.ts";

Deno.test("extractExportedNames - extracts class names", () => {
const content = dedent`
class MyClass:
pass
class AnotherClass(msgspec.Struct):
field: str
def not_a_class():
pass
`;
assertEquals(extractExportedNames(content), ["AnotherClass", "MyClass"]);
});

Deno.test("extractExportedNames - extracts enum assignments", () => {
const content = dedent`
MyEnum = Union[ClassA, ClassB]
AnotherEnum = Union[ClassC, ClassD, ClassE]
not_an_enum = "something else"
`;
assertEquals(extractExportedNames(content), ["AnotherEnum", "MyEnum"]);
});

Deno.test("extractExportedNames - extracts both classes and enums", () => {
const content = dedent`
class MyClass:
pass
MyEnum = Union[ClassA, ClassB]
class AnotherClass:
field: str
AnotherEnum = Union[ClassC, ClassD]
`;
assertEquals(extractExportedNames(content), [
"AnotherClass",
"AnotherEnum",
"MyClass",
"MyEnum",
]);
});

Deno.test("extractExportedNames - handles empty content", () => {
assertEquals(extractExportedNames(""), []);
});

Deno.test("extractExportedNames - ignores indented class definitions and enum assignments", () => {
const content = dedent`
def some_function():
class IndentedClass:
pass
IndentedEnum = Union[ClassA, ClassB]
class TopLevelClass:
pass
TopLevelEnum = Union[ClassC, ClassD]
`;
assertEquals(extractExportedNames(content), [
"TopLevelClass",
"TopLevelEnum",
]);
});
29 changes: 28 additions & 1 deletion scripts/generate-schema/gen-python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,28 @@ const header = (relativePath: string) =>
"from typing import Any, Literal, Optional, Union\n" +
"import msgspec\n\n";

export function extractExportedNames(content: string): string[] {
const names = new Set<string>();

// Match class definitions and enum assignments
const classRegex = /^class\s+([a-zA-Z_][a-zA-Z0-9_]*)/gm;
const enumRegex = /^([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*Union\[/gm;

let match;
while ((match = classRegex.exec(content)) !== null) {
names.add(match[1]);
}
while ((match = enumRegex.exec(content)) !== null) {
names.add(match[1]);
}

return [...names].sort();
}

export function generateAll(names: string[]): string {
return `__all__ = ${JSON.stringify(names, null, 4)}\n\n`;
}

// Track generated definitions to avoid duplicates
const generatedDefinitions = new Set<string>();
const generatedDependentClasses = new Set<string>();
Expand All @@ -21,7 +43,12 @@ export function generatePython(
// Only include header for the first schema
const shouldIncludeHeader = generatedDefinitions.size === 0;
const content = generateTypes(doc, name);
return (shouldIncludeHeader ? header(relativePath) : "") + content;

let output = "";
if (shouldIncludeHeader) {
output += header(relativePath);
}
return output + content;
}

function generateTypes(
Expand Down
20 changes: 17 additions & 3 deletions scripts/generate-schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ import { join } from "jsr:@std/path";
import { parseArgs } from "jsr:@std/cli/parse-args";
import type { JSONSchema } from "../../json-schema.d.ts";
import { generateTypeScript } from "./gen-typescript.ts";
import { generatePython } from "./gen-python.ts";
import {
extractExportedNames,
generateAll,
generatePython,
} from "./gen-python.ts";
import { parseSchema } from "./parser.ts";

const schemasDir = new URL("../../schemas", import.meta.url).pathname;
const tsSchemaDir = new URL("../../src/clients/deno", import.meta.url).pathname;
const pySchemaDir =
new URL("../../src/clients/python/src/webview_python", import.meta.url)
new URL("../../src/clients/python/src/justbe_webview", import.meta.url)
.pathname;

async function ensureDir(dir: string) {
Expand Down Expand Up @@ -80,8 +84,18 @@ async function main() {
const pyContent = schemas.map((doc) =>
generatePython(doc, doc.title, relativePath)
).join("\n\n\n");

// Extract all exported names and generate __all__
const exportedNames = extractExportedNames(pyContent);
const allContent = generateAll(exportedNames);

// Insert __all__ after the header (which is in the first schema's output)
const headerEndIndex = pyContent.indexOf("\n\n") + 2;
const finalContent = pyContent.slice(0, headerEndIndex) + allContent +
pyContent.slice(headerEndIndex);

const pyFilePath = join(pySchemaDir, "schemas.py");
await Deno.writeTextFile(pyFilePath, pyContent);
await Deno.writeTextFile(pyFilePath, finalContent);
console.log(`Generated Python schemas: ${pyFilePath}`);
}

Expand Down
2 changes: 1 addition & 1 deletion scripts/sync-versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ await Deno.writeTextFile(denoPath, updatedDenoContent);
console.log(`✓ Updated Deno BIN_VERSION to ${latestVersion}`);

// ===== Update Python Client BIN_VERSION =====
const pythonInitPath = "./src/clients/python/src/webview_python/__init__.py";
const pythonInitPath = "./src/clients/python/src/justbe_webview/__init__.py";
const pythonInitContent = await Deno.readTextFile(pythonInitPath);

const updatedPythonInitContent = pythonInitContent.replace(
Expand Down
11 changes: 5 additions & 6 deletions src/clients/python/examples/ipc.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "webview-python",
# "justbe-webview",
# ]
#
# [tool.uv.sources]
# webview-python = { path = "../" }
# justbe-webview = { path = "../" }
# ///
import asyncio

from webview_python import WebView, WebViewOptions, WebViewContentHtml
from webview_python.schemas.WebViewMessage import IpcNotification
from justbe_webview import WebView, Options, ContentHtml, IpcNotification


async def main():
print("Creating webview")
config = WebViewOptions(
config = Options(
title="Simple",
load=WebViewContentHtml(
load=ContentHtml(
html='<button onclick="window.ipc.postMessage(`button clicked ${Date.now()}`)">Click me</button>'
),
ipc=True,
Expand Down
21 changes: 9 additions & 12 deletions src/clients/python/examples/load_html.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,27 @@
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "webview-python",
# "justbe-webview",
# ]
#
# [tool.uv.sources]
# webview-python = { path = "../" }
# justbe-webview = { path = "../" }
# ///
import sys
import asyncio
from pathlib import Path

from webview_python import (
from justbe_webview import (
WebView,
WebViewOptions,
WebViewContentHtml,
WebViewNotification,
Options,
ContentHtml,
Notification,
)


async def main():
print("Creating webview")
config = WebViewOptions(
config = Options(
title="Load Html Example",
load=WebViewContentHtml(
load=ContentHtml(
html="<h1>Initial html</h1>",
# Note: This origin is used with a custom protocol so it doesn't match
# https://example.com. This doesn't need to be set, but can be useful if
Expand All @@ -36,7 +34,7 @@ async def main():

async with WebView(config) as webview:

async def handle_start(event: WebViewNotification):
async def handle_start(event: Notification):
await webview.open_devtools()
await webview.load_html("<h1>Updated html!</h1>")

Expand All @@ -47,4 +45,3 @@ async def handle_start(event: WebViewNotification):

if __name__ == "__main__":
asyncio.run(main())

22 changes: 9 additions & 13 deletions src/clients/python/examples/load_url.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,27 @@
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "webview-python",
# "justbe-webview",
# ]
#
# [tool.uv.sources]
# webview-python = { path = "../" }
# justbe-webview = { path = "../" }
# ///
import sys
import asyncio
import time
from pathlib import Path

from webview_python import (
from justbe_webview import (
WebView,
WebViewOptions,
WebViewContentUrl,
WebViewNotification,
Options,
ContentUrl,
Notification,
)


async def main():
print("Creating webview")
config = WebViewOptions(
config = Options(
title="Load Url Example",
load=WebViewContentUrl(
load=ContentUrl(
url="https://example.com",
headers={
"Content-Type": "text/html",
Expand All @@ -36,7 +33,7 @@ async def main():

async with WebView(config) as webview:

async def handle_start(event: WebViewNotification):
async def handle_start(event: Notification):
await webview.open_devtools()
await asyncio.sleep(2) # Sleep for 2 seconds
await webview.load_url(
Expand All @@ -53,4 +50,3 @@ async def handle_start(event: WebViewNotification):

if __name__ == "__main__":
asyncio.run(main())

Loading

0 comments on commit 640910e

Please sign in to comment.