Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
name: Python Package

# Builds the naja-schematic wheel/sdist (Python backend + the WASM viewer
# bundle), tests it against published najaeda, and on a `python-v<version>`
# tag publishes it to PyPI.
#
# Publishing uses PyPI trusted publishing (no token): the PyPI project must
# list this repository, this workflow file and the `pypi` environment as a
# trusted publisher.

on:
push:
branches: [ main ]
tags: [ "python-v*" ]
pull_request:
branches: [ main ]
workflow_dispatch:

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
# imgui for the build, naja only for its version header
# (CMakeLists.txt reads NajaVersion.h.in); not recursive.
submodules: true

- name: Set up Emscripten
uses: mymindstorm/setup-emsdk@v14
with:
version: latest

- name: Build the viewer bundle
run: |
emcmake cmake -S . -B build-wasm-module \
-DCMAKE_BUILD_TYPE=Release \
-DNAJA_SCHEMATIC_WASM_MODULE=ON
cmake --build build-wasm-module -j
cp build-wasm-module/naja-schematic.js python/naja_schematic/static/
cp LICENSE python/LICENSE

- name: Check the tag matches the package version
if: startsWith(github.ref, 'refs/tags/python-v')
run: |
version=$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' python/naja_schematic/__init__.py)
test "python-v$version" = "${GITHUB_REF_NAME}" || {
echo "Tag ${GITHUB_REF_NAME} does not match __version__ $version"; exit 1; }

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Build wheel and sdist
run: |
python -m pip install build
python -m build python --outdir dist

- name: Check the bundle is in both archives
run: |
unzip -l dist/*.whl | grep -q 'naja_schematic/static/naja-schematic.js'
tar tzf dist/*.tar.gz | grep -q 'naja_schematic/static/naja-schematic.js'

- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

test:
needs: build
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest, macos-latest ]
python-version: [ "3.10", "3.13" ]
# Oldest najaeda the package declares support for, and the newest.
najaeda: [ "==0.7.24", "" ]
steps:
- name: Checkout code
uses: actions/checkout@v5

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- uses: actions/download-artifact@v4
with:
name: dist
path: dist/

- name: Install the built wheel
shell: bash
run: |
python -m pip install "$(ls dist/*.whl)[test]" "najaeda${{ matrix.najaeda }}"

- name: Run tests against the installed package
# From outside python/, so the tests import the installed wheel
# rather than the source tree.
working-directory: ${{ runner.temp }}
run: python -m pytest -v ${{ github.workspace }}/python/tests

- name: Smoke-test the CLI
shell: bash
run: |
echo '{"request":"load_root"}' \
| naja-schematic --xilinx --verilog python/tests/data/design.v --stdio \
| grep -q '"root_response"'

publish:
needs: [ build, test ]
if: startsWith(github.ref, 'refs/tags/python-v')
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/

- uses: pypa/gh-action-pypi-publish@release/v1
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,11 @@ imgui.ini
# Private, local-only working notes (never shared/committed).
notes/
naja_sv_diagnostics.log
scripts/__pycache__/najaeda_server.cpython-314.pyc

# Viewer bundle: built by CI (-DNAJA_SCHEMATIC_WASM_MODULE=ON), shipped in the wheel.
python/naja_schematic/static/naja-schematic.js
python/dist/
*.egg-info/
python/LICENSE
.pytest_cache/
__pycache__/
85 changes: 65 additions & 20 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,11 @@ standalone build, a `--diagnosis <path>` CLI flag (`main_native.cpp`) that
loads a design and a diagnosis JSON in one command, so an external caller
(a script, or naja-agent's skill) can open a fully annotated view without a
human clicking through File > Open .../Load Diagnosis JSON... by hand. This
closes the "opening a view" half of the loop for native builds; the WASM/
browser build still has no equivalent one-shot launch path (see "Wire
protocol" below — it depends on a live server + browser tab already being
connected). What's still missing upstream either way: neither kepler-formal
closes the "opening a view" half of the loop for native builds. The browser
build has the same one-shot launch through the `naja_schematic` Python
package's CLI (`naja-schematic --verilog d.v --diagnosis diag.json --open`,
see "Python package" below), and notebooks get it through
`naja_schematic.show(diagnosis=...)`. What's still missing upstream either way: neither kepler-formal
nor naja-scope emits `diagnosis_response` JSON today, so an adapter that
turns kepler-formal's log/text output into `diagnosis_response` items is the
remaining piece to actually produce the file this flag consumes.
Expand Down Expand Up @@ -116,8 +117,10 @@ emrun --port 8080 naja-schematic.html
The WASM app doesn't link naja directly — it talks to a netlist server over a
WebSocket instead. Start the server first, optionally with `--verilog <path>`
(a single Verilog netlist) and `--liberty <path> [<path>...]` (one flag,
listing the Liberty files defining its cell library — see the `argparse`
setup in `najaeda_server.py`'s `__main__` block):
listing the Liberty files defining its cell library — see
`build_arg_parser()` in `python/naja_schematic/server.py`;
`scripts/najaeda_server.py` is just a wrapper that runs that CLI from the
source tree without installing the package):

```bash
python3 scripts/najaeda_server.py # serves ws://localhost:8081/ws
Expand Down Expand Up @@ -150,6 +153,38 @@ assume a reader moving between modes can reuse the same invocation shape.
(`scripts/test_server.py` is a minimal canned-response stub for protocol
testing without a real netlist backend.)

### Python package (`python/`, PyPI `naja-schematic`)

`python/naja_schematic` packages the Python side of the protocol with the
WASM viewer, so najaeda users get a viewer from `pip install` alone:

- `protocol.py` — the Python protocol implementation: `handle_request(dict)
-> [dict]`, transport-agnostic, answering from the live `NLUniverse`. This
is the file to keep in step with `LocalSNLProvider.cpp`.
- `server.py` — the `naja-schematic` CLI (same flags as above, plus `--host`,
`--open`, `--diagnosis <json>`, `--stdio`) and its two transports: a
WebSocket server that also serves the viewer page on the same port
(`static/index.html` sets `Module.najaWsUrl` from `location`), and JSON
lines on stdin/stdout for a host that relays messages itself. In `--stdio`
mode fd 1 is pointed at stderr (naja's C++ logger writes to stdout) and
protocol output goes to a private dup of the original stdout. With
`--diagnosis`, a `diagnosis_response` is pushed after every
`root_response` (the viewer clears diagnoses on each root load).
- `widget.py` — `naja_schematic.show()`: an anywidget for Jupyter/Colab/
VSCode notebooks. Its ES module is the bundle + `static/widget.js`; the
viewer's requests come back over the widget comm channel as
`{"json": "<message>"}` and are answered in the kernel, so the view shows
the netlist as edited by earlier cells. `Schematic.annotate(items)`
pushes diagnoses.

The viewer bundle `static/naja-schematic.js` is **not** checked in: it's the
WASM target configured with `-DNAJA_SCHEMATIC_WASM_MODULE=ON` (single file,
wasm inlined, `createNajaSchematic({canvas, ...})` factory, one instance per
canvas), built by `.github/workflows/python-package.yml`, which also tests
the wheel and publishes it to PyPI on a `python-v<version>` tag (version in
`naja_schematic/__init__.py`). For local work set `NAJA_SCHEMATIC_BUNDLE` to
a locally built bundle (see `_bundle.py`). Tests: `pytest python/tests`.

## Architecture

### Dual-mode, shared core
Expand All @@ -161,16 +196,24 @@ data is sourced:
`while` loop calling `appFrame()`. Backed by `LocalSNLProvider`, which loads
netlists directly through the naja SNL C++ API in-process (Verilog,
SystemVerilog, or pre-built SNL directories).
- **`src/main_wasm.cpp`** — browser/VSCode webview: SDL2 + OpenGL ES via
Emscripten's `emscripten_set_main_loop()`. Backed by `WebSocketProvider`,
a thin wrapper around `WebSocketClient` that connects to
`ws://localhost:8081/ws` (implemented by `scripts/najaeda_server.py`, a
Python asyncio server built on `najaeda`).
- **`src/main_wasm.cpp`** — browser/VSCode webview/notebook: SDL2 + OpenGL
ES via Emscripten's `emscripten_set_main_loop()`. Backed by
`JsBridgeProvider` when the host page sets `Module.najaSend` (embedded
mode: the host owns the transport, requests go out through
`Module.najaSend(json)` and replies come back through the embind-exported
`Module.deliverMessage(json)` — what the notebook widget uses), otherwise
by `WebSocketProvider`, a thin wrapper around `WebSocketClient` that
connects to `Module.najaWsUrl` or `ws://localhost:8081/ws` (served by the
`naja_schematic` Python package, see above). `main()` maps SDL's
hard-coded `"#canvas"` selector to the instance's own `Module.canvas`
(`specialHTMLTargets`), so several viewers can share a page, and with
`Module.najaEmbedded` limits keyboard capture to the focused canvas.

Both providers implement **`INetlistProvider`** (`src/INetlistProvider.h`):
`send()`, `on_open()`/`on_message()`/`on_close()`/`on_error()` callback
registration, and `start()`. `LocalSNLProvider::start()` fires `on_open`
synchronously and answers requests in-process; `WebSocketProvider` is a passive
synchronously and answers requests in-process, as does `JsBridgeProvider`'s
(the host channel is already up); `WebSocketProvider` is a passive
wrapper since the underlying socket connects in its constructor.

`src/AppLogic.h/.cpp` holds the logic shared by both entry points:
Expand All @@ -191,7 +234,7 @@ Requests/responses are JSON with a `"request"`/`"response"` type field (e.g.
`load_root`, `load_instance`, `load_primitives`, `load_terms`, `load_nets`,
`load_equipotential` → `*_response`). Both `LocalSNLProvider` (native,
`buildRootResponse()`/`buildInstancesResponse()`/etc.) and
`najaeda_server.py` (WASM/browser) must independently implement this same
`python/naja_schematic/protocol.py` (WASM/browser/notebook) must independently implement this same
protocol — when changing one side, check the other.

`load_nets`/`nets_response` mirrors `load_terms`/`terms_response` exactly
Expand All @@ -208,7 +251,7 @@ Both `nets_response` and the `has_nets` flag filter out anonymous scalar
constant nets (unnamed 1'b0/1'b1 tie-offs, e.g. naja's implicit tie-off of
an unconnected input) — structural noise, not user-authored signals — via
`isAnonymousConstantNet()`/`hasVisibleNets()` (`LocalSNLProvider.cpp`) and
`is_anonymous_constant_net()`/`has_nets()` (`najaeda_server.py`), the same
`is_anonymous_constant_net()`/`has_nets()` (`protocol.py`), the same
spirit as `hasVisiblePrimitiveInstances()` filtering `isAssign()`
primitives. A *named* or *bus* constant is still shown — the filter is
deliberately narrow (unnamed **and** scalar **and** constant 0/1) so it
Expand All @@ -235,7 +278,7 @@ entered it through (the start pin, or the input pin of the cell being crossed;
several if the net is reached more than once) -- *not* every reader on the net,
unlike `equipotential_response`. Nets are returned breadth-first from the
requested one and de-duplicated, capped at `kMaxTraceNets` (`MAX_TRACE_NETS` in
`najaeda_server.py`, 500) with `truncated: true` when hit. `AppLogic.cpp` adds
`protocol.py`, 500) with `truncated: true` when hit. `AppLogic.cpp` adds
each to `GUIData` in order; the incremental layout in `EquipotentialView.cpp`
relies on that ordering (each net shares an already-placed instance with an
earlier one) to chain the cone right-to-left. Reachable from the tree
Expand Down Expand Up @@ -281,17 +324,19 @@ the already-loaded netlist rather than loading anything:
]
}
```
`najaeda_server.py` doesn't implement this yet (no upstream data source —
see Vision above); `scripts/test_server.py` sends a canned example after
`load_root` as a demo/test fixture. Native/standalone mode has no server at
Nothing upstream produces it yet (see Vision above), so the Python side
only relays a file or list it's given: the `naja-schematic --diagnosis
<json>` CLI and `Schematic.annotate()`/`show(diagnosis=...)` in notebooks
push it after each `root_response`; `scripts/test_server.py` sends a canned
example after `load_root` as a demo/test fixture. Native/standalone mode has no server at
all, so it gets diagnosis data via **File > Load Diagnosis JSON...**
(reads a `{"items": [...]}` file or a bare array through the same
`DiagnosisItem` parser) instead.

`get_properties`/`properties_response` is a general name/value inspector for
whatever object the UI asks about — an instance (including the top design
itself), a term/pin, or a net — answered by both `LocalSNLProvider`
(`buildPropertiesResponse()`) and `najaeda_server.py` the same request/
(`buildPropertiesResponse()`) and `protocol.py` the same request/
response way as `load_terms` etc. (unlike `diagnosis_response`, it's not a
push). The object is identified the same way `DiagnosisItem` identifies
things — a slash-joined instance-name path, root excluded — rather than
Expand Down Expand Up @@ -378,7 +423,7 @@ stable inputs for an external tool like kepler-formal to reference).
`get_properties` reuses this same path/pathKey convention (`splitPathKey()`
in `Types.h` is the inverse of `pathKey()`) so its request-building code in
`NetlistTree.cpp`/`EquipotentialView.cpp` and its resolution code in
`LocalSNLProvider.cpp`/`najaeda_server.py` need no id/name translation layer
`LocalSNLProvider.cpp`/`protocol.py` need no id/name translation layer
of their own.

### VSCode integration
Expand Down
24 changes: 22 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ if(EMSCRIPTEN_BUILD)
${CORE_SOURCES}
src/WebSocketClient.cpp
src/WebSocketProvider.cpp
src/JsBridgeProvider.cpp
src/main_wasm.cpp
)

Expand All @@ -113,17 +114,36 @@ if(EMSCRIPTEN_BUILD)
"-sEXIT_RUNTIME=0"
"-sEXPORTED_RUNTIME_METHODS=['requestFullscreen']"
"-sWASM=1"
"--shell-file=${CMAKE_SOURCE_DIR}/src/shell_minimal.html"
"-lwebsocket.js"
"-lembind"
"-fexceptions"
"-sEXCEPTION_STACK_TRACES=1"
"-sEXCEPTION_CATCHING_ALLOWED=['main','naja-schematic']"
)

# Default: a full page (naja-schematic.html + .js + .wasm) for emrun.
# NAJA_SCHEMATIC_WASM_MODULE: one self-contained naja-schematic.js (wasm
# inlined) defining a createNajaSchematic({canvas, ...}) factory, so a host
# page can embed one viewer per canvas -- what the naja_schematic Python
# package ships (its index.html and Jupyter widget both load it).
option(NAJA_SCHEMATIC_WASM_MODULE
"Build the WASM viewer as an embeddable single-file JS module" OFF)
if(NAJA_SCHEMATIC_WASM_MODULE)
list(APPEND EM_LINK_FLAGS
"-sMODULARIZE=1"
"-sEXPORT_NAME=createNajaSchematic"
"-sSINGLE_FILE=1"
"-sENVIRONMENT=web"
)
set(NAJA_SCHEMATIC_WASM_SUFFIX ".js")
else()
list(APPEND EM_LINK_FLAGS "--shell-file=${CMAKE_SOURCE_DIR}/src/shell_minimal.html")
set(NAJA_SCHEMATIC_WASM_SUFFIX ".html")
endif()

target_compile_options(naja-schematic PRIVATE ${EM_COMPILE_FLAGS})
target_link_options(naja-schematic PRIVATE ${EM_LINK_FLAGS})
set_target_properties(naja-schematic PROPERTIES SUFFIX ".html")
set_target_properties(naja-schematic PROPERTIES SUFFIX ${NAJA_SCHEMATIC_WASM_SUFFIX})
target_compile_definitions(naja-schematic PRIVATE
NAJA_SCHEMATIC_GIT_HASH="${NAJA_SCHEMATIC_GIT_HASH}"
NAJA_VERSION_STRING="${NAJA_VERSION}"
Expand Down
18 changes: 18 additions & 0 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ path = [
".github/workflows/emscripten.yml",
".github/workflows/native-linux.yml",
".github/workflows/native-macos.yml",
".github/workflows/python-package.yml",
".github/workflows/reuse.yml",
".gitignore",
".gitmodules",
Expand All @@ -19,6 +20,21 @@ path = [
"CMakeLists.txt",
"CMakePresets.json",
"README.md",
"python/README.md",
"python/naja_schematic/__init__.py",
"python/naja_schematic/__main__.py",
"python/naja_schematic/_bundle.py",
"python/naja_schematic/protocol.py",
"python/naja_schematic/server.py",
"python/naja_schematic/static/index.html",
"python/naja_schematic/static/widget.js",
"python/naja_schematic/widget.py",
"python/pyproject.toml",
"python/tests/conftest.py",
"python/tests/data/design.v",
"python/tests/test_protocol.py",
"python/tests/test_transports.py",
"python/tests/test_widget.py",
"scripts/fakeram45_64x32.lib",
"scripts/najaeda_server.py",
"scripts/test_server.py",
Expand All @@ -36,6 +52,8 @@ path = [
"src/GUIData.cpp",
"src/GUIData.h",
"src/INetlistProvider.h",
"src/JsBridgeProvider.cpp",
"src/JsBridgeProvider.h",
"src/imgui_user_config.h",
"src/LocalSNLProvider.cpp",
"src/LocalSNLProvider.h",
Expand Down
Loading
Loading