wokwi-client 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. wokwi_client-0.0.1/.editorconfig +19 -0
  2. wokwi_client-0.0.1/.github/workflows/ci.yaml +28 -0
  3. wokwi_client-0.0.1/.github/workflows/docs.yaml +37 -0
  4. wokwi_client-0.0.1/.github/workflows/release.yaml +38 -0
  5. wokwi_client-0.0.1/.gitignore +58 -0
  6. wokwi_client-0.0.1/.pre-commit-config.yaml +33 -0
  7. wokwi_client-0.0.1/.ruff.toml +21 -0
  8. wokwi_client-0.0.1/CHANGELOG.md +5 -0
  9. wokwi_client-0.0.1/LICENSE +21 -0
  10. wokwi_client-0.0.1/PKG-INFO +64 -0
  11. wokwi_client-0.0.1/README.md +38 -0
  12. wokwi_client-0.0.1/mkdocs.yml +51 -0
  13. wokwi_client-0.0.1/mypy.ini +14 -0
  14. wokwi_client-0.0.1/pyproject.toml +97 -0
  15. wokwi_client-0.0.1/src/wokwi_client/__init__.py +18 -0
  16. wokwi_client-0.0.1/src/wokwi_client/__main__.py +10 -0
  17. wokwi_client-0.0.1/src/wokwi_client/__version__.py +18 -0
  18. wokwi_client-0.0.1/src/wokwi_client/cli/__init__.py +15 -0
  19. wokwi_client-0.0.1/src/wokwi_client/client.py +182 -0
  20. wokwi_client-0.0.1/src/wokwi_client/constants.py +13 -0
  21. wokwi_client-0.0.1/src/wokwi_client/event_queue.py +57 -0
  22. wokwi_client-0.0.1/src/wokwi_client/exceptions.py +12 -0
  23. wokwi_client-0.0.1/src/wokwi_client/file_ops.py +24 -0
  24. wokwi_client-0.0.1/src/wokwi_client/models.py +17 -0
  25. wokwi_client-0.0.1/src/wokwi_client/protocol_types.py +43 -0
  26. wokwi_client-0.0.1/src/wokwi_client/serial.py +16 -0
  27. wokwi_client-0.0.1/src/wokwi_client/simulation.py +33 -0
  28. wokwi_client-0.0.1/src/wokwi_client/transport.py +152 -0
  29. wokwi_client-0.0.1/tests/__init__.py +3 -0
  30. wokwi_client-0.0.1/tests/test_cli.py +20 -0
  31. wokwi_client-0.0.1/tests/test_hello_esp32.py +26 -0
@@ -0,0 +1,19 @@
1
+ # EditorConfig helps maintain consistent coding styles between editors & IDEs
2
+ # https://editorconfig.org
3
+ root = true
4
+
5
+ [*]
6
+ charset = utf-8
7
+ end_of_line = lf
8
+ indent_style = space
9
+ indent_size = 4
10
+ insert_final_newline = true
11
+ trim_trailing_whitespace = true
12
+
13
+ # Keep README & docs within the same column width as code
14
+ [*.{py,toml,yaml,yml,md}]
15
+ max_line_length = 100
16
+
17
+ # Make makes you use tabs
18
+ [Makefile]
19
+ indent_style = tab
@@ -0,0 +1,28 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+ branches:
7
+ - main
8
+
9
+ jobs:
10
+ test:
11
+ strategy:
12
+ matrix:
13
+ py: ["3.9", "3.10", "3.11", "3.12", "3.13"]
14
+ runs-on: ubuntu-24.04
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.py }}
20
+ - run: pip install hatch
21
+ - run: hatch run ruff format --check .
22
+ - run: hatch run ruff check .
23
+ - run: hatch run mypy .
24
+ - name: Run a Wokwi CI server
25
+ uses: wokwi/wokwi-ci-server-action@v1
26
+ - run: hatch run dev:pytest
27
+ env:
28
+ WOKWI_CLI_TOKEN: ${{ secrets.WOKWI_CLI_TOKEN }}
@@ -0,0 +1,37 @@
1
+ name: Docs
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ paths:
7
+ - "docs/**"
8
+ - "mkdocs.yml"
9
+ - "src/**"
10
+ - ".github/workflows/docs.yml"
11
+
12
+ permissions:
13
+ pages: write
14
+ id-token: write
15
+
16
+ jobs:
17
+ build-deploy:
18
+ runs-on: ubuntu-24.04
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+
22
+ - uses: actions/setup-python@v5
23
+ with:
24
+ python-version: "3.12"
25
+
26
+ - run: pip install hatch
27
+
28
+ - name: Build & version docs
29
+ run: hatch run mkdocs build --strict
30
+
31
+ - uses: actions/upload-pages-artifact@v3
32
+ with:
33
+ path: site
34
+
35
+ - uses: actions/deploy-pages@v4
36
+ with:
37
+ artifact_name: github-pages
@@ -0,0 +1,38 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*.*.*"
7
+
8
+ permissions:
9
+ id-token: write # OIDC token for trusted publishing
10
+ contents: read
11
+
12
+ jobs:
13
+ build-and-publish:
14
+ runs-on: ubuntu-24.04
15
+ environment: pypi
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: "3.12"
22
+
23
+ - name: Build wheel & sdist
24
+ run: |
25
+ pip install --quiet hatch
26
+ hatch build
27
+
28
+ - name: Publish to PyPI
29
+ uses: pypa/gh-action-pypi-publish@release/v1
30
+ with:
31
+ verbose: true # shows twine output
32
+
33
+ - uses: softprops/action-gh-release@v2
34
+ with:
35
+ generate_release_notes: true
36
+ files: |
37
+ dist/*.whl
38
+ dist/*.tar.gz
@@ -0,0 +1,58 @@
1
+ # Python artifacts
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.so
5
+ *.pyd
6
+
7
+ # Packaging / build
8
+ build/
9
+ dist/
10
+ *.egg-info/
11
+ .eggs/
12
+
13
+ # Hatch & virtual-envs
14
+ .hatch/ # local env metadata/cache
15
+ .env/
16
+ .venv/
17
+ venv/
18
+ env/
19
+ env.bak/
20
+ *.virtualenv
21
+
22
+ # Test & coverage outputs
23
+ .coverage*
24
+ htmlcov/
25
+ .tox/
26
+ .nox/
27
+ pytest_cache/
28
+ .mypy_cache/
29
+ .pytest_cache/
30
+ *.log
31
+
32
+ # Lint / formatter caches
33
+ .ruff_cache/
34
+ .black/
35
+ .pyre/
36
+
37
+ # Documentation build (mkdocs)
38
+ site/
39
+ *.html
40
+
41
+ # IDE/editor settings
42
+ .vscode/
43
+ .idea/
44
+ *.sublime-project
45
+ *.sublime-workspace
46
+ *.code-workspace
47
+
48
+ # OS miscellany
49
+ .DS_Store
50
+ Thumbs.db
51
+ desktop.ini
52
+
53
+ # Logs & temp files
54
+ *.log
55
+ *.tmp
56
+ *.swp
57
+ *.swo
58
+ *~
@@ -0,0 +1,33 @@
1
+ # .pre-commit-config.yaml – patched
2
+ repos:
3
+ # Ruff: formatter + linter
4
+ - repo: https://github.com/astral-sh/ruff-pre-commit
5
+ rev: v0.12.0
6
+ hooks:
7
+ - id: ruff-format
8
+ - id: ruff
9
+ args: [--fix]
10
+
11
+ # mypy (strict)
12
+ - repo: https://github.com/pre-commit/mirrors-mypy
13
+ rev: v1.16.1
14
+ hooks:
15
+ - id: mypy
16
+ additional_dependencies:
17
+ [pydantic==2.8.0, typing-extensions, types-click, types-requests]
18
+
19
+ # Blacken-docs
20
+ - repo: https://github.com/asottile/blacken-docs
21
+ rev: "1.19.1"
22
+ hooks:
23
+ - id: blacken-docs
24
+
25
+ # Basic hygiene hooks (official, maintained)
26
+ - repo: https://github.com/pre-commit/pre-commit-hooks
27
+ rev: v5.0.0
28
+ hooks:
29
+ - id: end-of-file-fixer
30
+ - id: trailing-whitespace
31
+
32
+ default_language_version:
33
+ python: python3.12
@@ -0,0 +1,21 @@
1
+ # https://docs.astral.sh/ruff/configuration/
2
+ target-version = "py39"
3
+ line-length = 100
4
+ src = ["src", "tests"]
5
+
6
+ # Enable rule bundles
7
+ lint.select = [
8
+ "E", # pycodestyle errors
9
+ "F", # pyflakes
10
+ "I", # isort
11
+ "UP", # pyupgrade
12
+ "PL", # pylint-worthwhile subset
13
+ ]
14
+
15
+ lint.ignore = [
16
+ "E501", # handled by formatter
17
+ ]
18
+
19
+ # Per-folder overrides --------------------------------------------------------
20
+ [lint.per-file-ignores]
21
+ "tests/**/*.py" = ["PLR2004"] # magic-numbers okay in tests
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.0.1 - 2025-06-29
4
+
5
+ Initial alpha release.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Wokwi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: wokwi-client
3
+ Version: 0.0.1
4
+ Summary: Typed, asyncio-friendly Python SDK for the Wokwi Simulation API
5
+ Project-URL: Documentation, https://github.com/wokwi/wokwi-python-client#readme
6
+ Project-URL: Issues, https://github.com/wokwi/wokwi-python-client/issues
7
+ Project-URL: Source, https://github.com/wokwi/wokwi-python-client
8
+ Author-email: Uri Shaked <uri@wokwi.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: api,arduino,asyncio,avr,esp32,python,rp2040,simulation,wokwi
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Topic :: Software Development :: Embedded Systems
16
+ Classifier: Topic :: System :: Emulators
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.9
19
+ Requires-Dist: pydantic>=2.8
20
+ Requires-Dist: websockets<13,>=12
21
+ Provides-Extra: cli
22
+ Requires-Dist: click<9,>=8.1; extra == 'cli'
23
+ Requires-Dist: typer[all]>=0.12; extra == 'cli'
24
+ Requires-Dist: types-click; extra == 'cli'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # Wokwi Python Client 🚀
28
+
29
+ Typed, asyncio-friendly Python SDK for the **Wokwi Simulation API**
30
+
31
+ [![PyPI version](https://img.shields.io/pypi/v/wokwi-client?logo=pypi)](https://pypi.org/project/wokwi-client/)
32
+ [![Python versions](https://img.shields.io/pypi/pyversions/wokwi-client)](https://pypi.org/project/wokwi-client/)
33
+ [![CI](https://github.com/wokwi/wokwi-python-client/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/wokwi/wokwi-python-client/actions/workflows/ci.yml)
34
+ [![License: MIT](https://img.shields.io/github/license/wokwi/wokwi-python-client)](LICENSE)
35
+
36
+ > **TL;DR:** Run and control your Wokwi simulations from Python with first-class type hints and zero boilerplate.
37
+
38
+ ---
39
+
40
+ ## Installation requirements
41
+
42
+ Python ≥ 3.9
43
+
44
+ An API token from [https://wokwi.com/dashboard/ci](https://wokwi.com/dashboard/ci).
45
+
46
+ ## Running the examples
47
+
48
+ The basic example is in the [examples/hello_esp32/main.py](examples/hello_esp32/main.py) file. It shows how to:
49
+
50
+ - Connect to the Wokwi Simulator
51
+ - Upload a diagram and firmware files
52
+ - Start a simulation
53
+ - Monitor the serial output
54
+
55
+ You can run the example with:
56
+
57
+ ```bash
58
+ pip install -e .[dev]
59
+ python -m examples.hello_esp32.main
60
+ ```
61
+
62
+ ## License
63
+
64
+ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
@@ -0,0 +1,38 @@
1
+ # Wokwi Python Client 🚀
2
+
3
+ Typed, asyncio-friendly Python SDK for the **Wokwi Simulation API**
4
+
5
+ [![PyPI version](https://img.shields.io/pypi/v/wokwi-client?logo=pypi)](https://pypi.org/project/wokwi-client/)
6
+ [![Python versions](https://img.shields.io/pypi/pyversions/wokwi-client)](https://pypi.org/project/wokwi-client/)
7
+ [![CI](https://github.com/wokwi/wokwi-python-client/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/wokwi/wokwi-python-client/actions/workflows/ci.yml)
8
+ [![License: MIT](https://img.shields.io/github/license/wokwi/wokwi-python-client)](LICENSE)
9
+
10
+ > **TL;DR:** Run and control your Wokwi simulations from Python with first-class type hints and zero boilerplate.
11
+
12
+ ---
13
+
14
+ ## Installation requirements
15
+
16
+ Python ≥ 3.9
17
+
18
+ An API token from [https://wokwi.com/dashboard/ci](https://wokwi.com/dashboard/ci).
19
+
20
+ ## Running the examples
21
+
22
+ The basic example is in the [examples/hello_esp32/main.py](examples/hello_esp32/main.py) file. It shows how to:
23
+
24
+ - Connect to the Wokwi Simulator
25
+ - Upload a diagram and firmware files
26
+ - Start a simulation
27
+ - Monitor the serial output
28
+
29
+ You can run the example with:
30
+
31
+ ```bash
32
+ pip install -e .[dev]
33
+ python -m examples.hello_esp32.main
34
+ ```
35
+
36
+ ## License
37
+
38
+ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
@@ -0,0 +1,51 @@
1
+ site_name: Wokwi Python Client Library
2
+ site_description: Typed, asyncio-friendly Python SDK for the Wokwi Simulation API
3
+ site_url: https://wokwi.github.io/wokwi-python-client/
4
+
5
+ repo_url: https://github.com/wokwi/wokwi-python-client
6
+ repo_name: wokwi-python-client
7
+ edit_uri: "https://github.com/wokwi/wokwi-python-client/edit/main/docs"
8
+
9
+ # ── Theme ───────────────────────────────────────────────────────────────
10
+ theme:
11
+ name: material
12
+ language: en
13
+ features:
14
+ - navigation.tabs
15
+ - navigation.instant
16
+ - content.code.copy
17
+ - content.action.edit
18
+
19
+ # ── Docs structure ------------------------------------------------------
20
+ nav:
21
+ - Home: index.md
22
+ - Reference:
23
+ - API: reference/wokwi_client.md
24
+
25
+ # ── Plugins -------------------------------------------------------------
26
+ plugins:
27
+ - search
28
+ - mkdocstrings:
29
+ handlers:
30
+ python:
31
+ paths: ["src"]
32
+ options:
33
+ show_signature_annotations: true
34
+ merge_init_into_class: true
35
+ docstring_style: google
36
+ - autorefs # automatic cross-page links
37
+
38
+ # ── Markdown extensions -------------------------------------------------
39
+ markdown_extensions:
40
+ - admonition
41
+ - pymdownx.highlight
42
+ - pymdownx.inlinehilite
43
+ - pymdownx.superfences
44
+ - pymdownx.tabbed
45
+ - toc:
46
+ permalink: "¶"
47
+
48
+ # ── Extra build settings ------------------------------------------------
49
+ strict: true # fail the build on broken links
50
+ watch:
51
+ - src # live-reload when source code changes
@@ -0,0 +1,14 @@
1
+ [mypy]
2
+ python_version = 3.9
3
+ plugins = pydantic.mypy
4
+
5
+ strict = True
6
+ show_error_codes = True
7
+ pretty = True
8
+ warn_unused_ignores = True
9
+ warn_return_any = True
10
+ warn_unreachable = True
11
+
12
+ # Silence import-time noise from non-typed deps
13
+ [mypy-typer.*]
14
+ ignore_missing_imports = True
@@ -0,0 +1,97 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25", "hatch-vcs>=0.4"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "wokwi-client"
7
+ dynamic = ["version"]
8
+ description = "Typed, asyncio-friendly Python SDK for the Wokwi Simulation API"
9
+ readme = "README.md"
10
+ authors = [{ name = "Uri Shaked", email = "uri@wokwi.com" }]
11
+ license = "MIT"
12
+ keywords = [
13
+ "wokwi",
14
+ "simulation",
15
+ "api",
16
+ "python",
17
+ "asyncio",
18
+ "esp32",
19
+ "rp2040",
20
+ "avr",
21
+ "arduino",
22
+ ]
23
+ requires-python = ">=3.9"
24
+ classifiers = [
25
+ "Development Status :: 3 - Alpha",
26
+ "License :: OSI Approved :: MIT License",
27
+ "Programming Language :: Python",
28
+ "Topic :: Software Development :: Embedded Systems",
29
+ "Topic :: System :: Emulators",
30
+ "Typing :: Typed",
31
+ ]
32
+ dependencies = ["pydantic>=2.8", "websockets>=12,<13"]
33
+
34
+ [project.urls]
35
+ Documentation = "https://github.com/wokwi/wokwi-python-client#readme"
36
+ Issues = "https://github.com/wokwi/wokwi-python-client/issues"
37
+ Source = "https://github.com/wokwi/wokwi-python-client"
38
+
39
+ [project.optional-dependencies]
40
+ cli = ["typer[all]>=0.12", "click>=8.1,<9", "types-click"]
41
+
42
+
43
+ [project.scripts]
44
+ wokwi-client = "wokwi_client.cli:wokwi_client"
45
+
46
+ [tool.hatch.version]
47
+ source = "vcs"
48
+ tag-pattern = "v(?P<version>.*)"
49
+
50
+ [tool.hatch.build.targets.sdist]
51
+ exclude = ["/docs", "/examples"]
52
+
53
+ [tool.pytest.ini_options]
54
+ addopts = "--strict-markers --cov=wokwi_client --cov-report=term-missing"
55
+
56
+ # Ruff (acts as both linter and formatter)
57
+ [tool.ruff]
58
+ target-version = "py39"
59
+ line-length = 100
60
+ lint.select = ["E", "F", "I", "UP", "PL"]
61
+
62
+ [tool.hatch.envs.types]
63
+ extra-dependencies = ["mypy>=1.0.0"]
64
+ [tool.hatch.envs.types.scripts]
65
+ check = "mypy --install-types --non-interactive {args:src/wokwi_client tests}"
66
+
67
+ [tool.coverage.run]
68
+ source_pkgs = ["wokwi_client", "tests"]
69
+ branch = true
70
+ parallel = true
71
+ omit = ["src/wokwi_client/__about__.py"]
72
+
73
+ [tool.coverage.paths]
74
+ wokwi_client = ["src/wokwi_client", "*/wokwi-client/src/wokwi_client"]
75
+ tests = ["tests", "*/wokwi-client/tests"]
76
+
77
+ [tool.coverage.report]
78
+ exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]
79
+
80
+ [tool.mypy]
81
+ strict = true
82
+ plugins = ["pydantic.mypy"]
83
+
84
+ [tool.hatch.envs.default]
85
+ dependencies = [
86
+ "ruff>=0.12.0",
87
+ "mypy>=1.16.1",
88
+ "pymdown-extensions",
89
+ "mkdocs-material[extensions]",
90
+ "mkdocstrings[python]",
91
+ "types-requests",
92
+ ]
93
+
94
+ [tool.hatch.envs.dev]
95
+ template = "default"
96
+ features = ["cli"]
97
+ extra-dependencies = ["pytest", "pytest-cov"]
@@ -0,0 +1,18 @@
1
+ """
2
+ Wokwi Python Client Library
3
+
4
+ Typed, asyncio-friendly Python SDK for the Wokwi Simulation API.
5
+
6
+ Provides the WokwiClient class for connecting to, controlling, and monitoring Wokwi simulations from Python.
7
+ """
8
+
9
+ # SPDX-FileCopyrightText: 2025-present CodeMagic LTD
10
+ #
11
+ # SPDX-License-Identifier: MIT
12
+
13
+ from .__version__ import get_version
14
+ from .client import WokwiClient
15
+ from .constants import GET_TOKEN_URL
16
+
17
+ __version__ = get_version()
18
+ __all__ = ["WokwiClient", "__version__", "GET_TOKEN_URL"]
@@ -0,0 +1,10 @@
1
+ # SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ import sys
6
+
7
+ if __name__ == "__main__":
8
+ from wokwi_client.cli import wokwi_client
9
+
10
+ sys.exit(wokwi_client())
@@ -0,0 +1,18 @@
1
+ # SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ try: # works for normal + editable installs
8
+ __version__: str = version(__name__.replace("_", "-"))
9
+ except PackageNotFoundError: # running from a raw source tree
10
+ try:
11
+ # if you use hatch-vcs, this tiny module is auto-generated at build time
12
+ from ._version import version as __version__ # type: ignore
13
+ except ModuleNotFoundError:
14
+ __version__ = "0.0.0+local"
15
+
16
+
17
+ def get_version() -> str:
18
+ return __version__
@@ -0,0 +1,15 @@
1
+ # SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ import click
5
+
6
+ from wokwi_client.__version__ import get_version
7
+
8
+
9
+ @click.group(
10
+ context_settings={"help_option_names": ["-h", "--help"]},
11
+ invoke_without_command=True,
12
+ )
13
+ @click.version_option(version=get_version(), prog_name="wokwi-client")
14
+ def wokwi_client() -> None:
15
+ click.echo("Hello Virtual World!")
@@ -0,0 +1,182 @@
1
+ # SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ from .__version__ import get_version
9
+ from .constants import DEFAULT_WS_URL
10
+ from .event_queue import EventQueue
11
+ from .file_ops import upload, upload_file
12
+ from .protocol_types import EventMessage, ResponseMessage
13
+ from .serial import monitor_lines
14
+ from .simulation import pause, restart, resume, start
15
+ from .transport import Transport
16
+
17
+
18
+ class WokwiClient:
19
+ """
20
+ Asynchronous client for the Wokwi Simulation API.
21
+
22
+ This class provides methods to connect to the Wokwi simulator, upload files, control simulations,
23
+ and monitor serial output. It is designed to be asyncio-friendly and easy to use in Python scripts
24
+ and applications.
25
+ """
26
+
27
+ version: str
28
+ last_pause_nanos: int
29
+
30
+ def __init__(self, token: str, server: Optional[str] = None):
31
+ """
32
+ Initialize the WokwiClient.
33
+
34
+ Args:
35
+ token: API token for authentication (get from https://wokwi.com/dashboard/ci).
36
+ server: Optional custom server URL. Defaults to the public Wokwi server.
37
+ """
38
+ self.version = get_version()
39
+ self._transport = Transport(token, server or DEFAULT_WS_URL)
40
+ self.last_pause_nanos = 0
41
+ self._transport.add_event_listener("sim:pause", self._on_pause)
42
+ self._pause_queue = EventQueue(self._transport, "sim:pause")
43
+
44
+ async def connect(self) -> dict[str, Any]:
45
+ """
46
+ Connect to the Wokwi simulator server.
47
+
48
+ Returns:
49
+ A dictionary with server information (e.g., version).
50
+ """
51
+ return await self._transport.connect()
52
+
53
+ async def disconnect(self) -> None:
54
+ """
55
+ Disconnect from the Wokwi simulator server.
56
+ """
57
+ await self._transport.close()
58
+
59
+ async def upload(self, name: str, content: bytes) -> ResponseMessage:
60
+ """
61
+ Upload a file to the simulator from bytes content.
62
+
63
+ Args:
64
+ name: The name to use for the uploaded file.
65
+ content: The file content as bytes.
66
+
67
+ Returns:
68
+ The response message from the server.
69
+ """
70
+ return await upload(self._transport, name, content)
71
+
72
+ async def upload_file(
73
+ self, filename: str, local_path: Optional[Path] = None
74
+ ) -> ResponseMessage:
75
+ """
76
+ Upload a local file to the simulator.
77
+
78
+ Args:
79
+ filename: The name to use for the uploaded file.
80
+ local_path: Optional path to the local file. If not provided, uses filename as the path.
81
+
82
+ Returns:
83
+ The response message from the server.
84
+ """
85
+ return await upload_file(self._transport, filename, local_path)
86
+
87
+ async def start_simulation(
88
+ self,
89
+ firmware: str,
90
+ elf: str,
91
+ pause: bool = False,
92
+ chips: list[str] = [],
93
+ ) -> ResponseMessage:
94
+ """
95
+ Start a new simulation with the given parameters.
96
+
97
+ The firmware and ELF files must be uploaded to the simulator first using the
98
+ `upload()` or `upload_file()` methods. The firmware and ELF files are required
99
+ for the simulation to run.
100
+
101
+ The optional `chips` parameter can be used to load custom chips into the simulation.
102
+ For each custom chip, you need to upload two files:
103
+ - A JSON file with the chip definition, called `<chip_name>.chip.json`.
104
+ - A binary file with the chip firmware, called `<chip_name>.chip.bin`.
105
+
106
+ For example, to load the `inverter` chip, you need to upload the `inverter.chip.json`
107
+ and `inverter.chip.bin` files. Then you can pass `["inverter"]` to the `chips` parameter,
108
+ and reference it in your diagram.json file by adding a part with the type `chip-inverter`.
109
+
110
+ Args:
111
+ firmware: The firmware binary filename.
112
+ elf: The ELF file filename.
113
+ pause: Whether to start the simulation paused (default: False).
114
+ chips: List of custom chips to load into the simulation (default: empty list).
115
+
116
+ Returns:
117
+ The response message from the server.
118
+ """
119
+ return await start(
120
+ self._transport,
121
+ firmware=firmware,
122
+ elf=elf,
123
+ pause=pause,
124
+ chips=chips,
125
+ )
126
+
127
+ async def pause_simulation(self) -> ResponseMessage:
128
+ """
129
+ Pause the running simulation.
130
+
131
+ Returns:
132
+ The response message from the server.
133
+ """
134
+ return await pause(self._transport)
135
+
136
+ async def resume_simulation(self, pause_after: Optional[int] = None) -> ResponseMessage:
137
+ """
138
+ Resume the simulation, optionally pausing after a given number of nanoseconds.
139
+
140
+ Args:
141
+ pause_after: Number of nanoseconds to run before pausing again (optional).
142
+
143
+ Returns:
144
+ The response message from the server.
145
+ """
146
+ return await resume(self._transport, pause_after)
147
+
148
+ async def wait_until_simulation_time(self, seconds: float) -> None:
149
+ """
150
+ Pause and resume the simulation until the given simulation time (in seconds) is reached.
151
+
152
+ Args:
153
+ seconds: The simulation time to wait for, in seconds.
154
+ """
155
+ await pause(self._transport)
156
+ remaining_nanos = seconds * 1e9 - self.last_pause_nanos
157
+ if remaining_nanos > 0:
158
+ self._pause_queue.flush()
159
+ await resume(self._transport, int(remaining_nanos))
160
+ await self._pause_queue.get()
161
+
162
+ async def restart_simulation(self, pause: bool = False) -> ResponseMessage:
163
+ """
164
+ Restart the simulation, optionally starting paused.
165
+
166
+ Args:
167
+ pause: Whether to start the simulation paused (default: False).
168
+
169
+ Returns:
170
+ The response message from the server.
171
+ """
172
+ return await restart(self._transport, pause)
173
+
174
+ async def serial_monitor_cat(self) -> None:
175
+ """
176
+ Print serial monitor output to stdout as it is received from the simulation.
177
+ """
178
+ async for line in monitor_lines(self._transport):
179
+ print(line.decode("utf-8"), end="", flush=True)
180
+
181
+ def _on_pause(self, event: EventMessage) -> None:
182
+ self.last_pause_nanos = int(event["nanos"])
@@ -0,0 +1,13 @@
1
+ # SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ DEFAULT_WS_URL = "wss://wokwi.com/api/ws/beta"
6
+ GET_TOKEN_URL = "https://wokwi.com/dashboard/ci"
7
+
8
+ MSG_TYPE_ERROR = "error"
9
+ MSG_TYPE_RESPONSE = "response"
10
+ MSG_TYPE_EVENT = "event"
11
+ MSG_TYPE_COMMAND = "command"
12
+ MSG_TYPE_HELLO = "hello"
13
+ PROTOCOL_VERSION = 1
@@ -0,0 +1,57 @@
1
+ # SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """
6
+ Queue-based event subscription helper for Transport.
7
+
8
+ Usage:
9
+ with EventQueue(transport, "serial-monitor:data") as queue:
10
+ # Use the queue to get events, calling get() or get_nowait()
11
+ event = await queue.get()
12
+ # do something with the event
13
+ ...
14
+ """
15
+
16
+ import asyncio
17
+
18
+ from .protocol_types import EventMessage
19
+ from .transport import Transport
20
+
21
+
22
+ class EventQueue:
23
+ """A queue for events from a specific event type."""
24
+
25
+ def __init__(self, transport: Transport, event_type: str) -> None:
26
+ self._queue: asyncio.Queue[EventMessage] = asyncio.Queue()
27
+ self._transport = transport
28
+ self._event_type = event_type
29
+
30
+ def listener(event: EventMessage) -> None:
31
+ self._queue.put_nowait(event)
32
+
33
+ self._listener = listener
34
+ self._transport.add_event_listener(self._event_type, self._listener)
35
+
36
+ def close(self) -> None:
37
+ """Close the queue. This is useful when you want to stop listening for events."""
38
+ self._transport.remove_event_listener(self._event_type, self._listener)
39
+
40
+ async def get(self) -> EventMessage:
41
+ """Get an event from the queue. Blocks until an event is available."""
42
+ return await self._queue.get()
43
+
44
+ def get_nowait(self) -> EventMessage:
45
+ """Get an event from the queue. Raises QueueEmpty if no event is available."""
46
+ return self._queue.get_nowait()
47
+
48
+ def flush(self) -> None:
49
+ """Flush the queue. This is useful when you want to wait for all events to be processed."""
50
+ while not self._queue.empty():
51
+ self._queue.get_nowait()
52
+
53
+ def __enter__(self) -> "EventQueue":
54
+ return self
55
+
56
+ def __exit__(self, exc_type: type, exc_value: Exception, traceback: object) -> None:
57
+ self.close()
@@ -0,0 +1,12 @@
1
+ # SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+
6
+ class WokwiError(Exception): ...
7
+
8
+
9
+ class ProtocolError(WokwiError): ...
10
+
11
+
12
+ class ServerError(WokwiError): ...
@@ -0,0 +1,24 @@
1
+ # SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ import base64
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from .models import UploadParams
10
+ from .protocol_types import ResponseMessage
11
+ from .transport import Transport
12
+
13
+
14
+ async def upload_file(
15
+ transport: Transport, filename: str, local_path: Optional[Path] = None
16
+ ) -> ResponseMessage:
17
+ path = Path(local_path or filename)
18
+ content = path.read_bytes()
19
+ return await upload(transport, filename, content)
20
+
21
+
22
+ async def upload(transport: Transport, name: str, content: bytes) -> ResponseMessage:
23
+ params = UploadParams(name=name, binary=base64.b64encode(content).decode())
24
+ return await transport.request("file:upload", params.model_dump())
@@ -0,0 +1,17 @@
1
+ # SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class UploadParams(BaseModel):
9
+ name: str
10
+ binary: str # base64
11
+
12
+
13
+ class SimulationParams(BaseModel):
14
+ firmware: str
15
+ elf: str
16
+ pause: bool = False
17
+ chips: list[str] = Field(default_factory=list)
@@ -0,0 +1,43 @@
1
+ # SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ from typing import Any, TypedDict, Union
6
+
7
+
8
+ class HelloMessage(TypedDict):
9
+ type: str # "hello"
10
+ protocolVersion: int
11
+ appName: str
12
+ appVersion: str
13
+
14
+
15
+ class CommandMessage(TypedDict, total=False):
16
+ type: str # "command"
17
+ command: str
18
+ id: str
19
+ params: dict[str, Any]
20
+
21
+
22
+ class ResponseMessage(TypedDict):
23
+ type: str # "response"
24
+ command: str
25
+ id: str
26
+ result: dict[str, Any]
27
+ error: bool
28
+
29
+
30
+ class ErrorResult(TypedDict):
31
+ code: int
32
+ message: str
33
+
34
+
35
+ class EventMessage(TypedDict):
36
+ type: str # "event"
37
+ event: str
38
+ payload: dict[str, Any]
39
+ nanos: float
40
+ paused: bool
41
+
42
+
43
+ IncomingMessage = Union[HelloMessage, ResponseMessage, EventMessage]
@@ -0,0 +1,16 @@
1
+ # SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ from collections.abc import AsyncGenerator
6
+
7
+ from .event_queue import EventQueue
8
+ from .transport import Transport
9
+
10
+
11
+ async def monitor_lines(transport: Transport) -> AsyncGenerator[bytes, None]:
12
+ await transport.request("serial-monitor:listen", {})
13
+ with EventQueue(transport, "serial-monitor:data") as queue:
14
+ while True:
15
+ event_msg = await queue.get()
16
+ yield bytes(event_msg["payload"]["bytes"])
@@ -0,0 +1,33 @@
1
+ # SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ from typing import Optional
6
+
7
+ from .protocol_types import ResponseMessage
8
+ from .transport import Transport
9
+
10
+
11
+ async def start(
12
+ transport: Transport,
13
+ *,
14
+ firmware: str,
15
+ elf: str,
16
+ pause: bool = False,
17
+ chips: list[str] = [],
18
+ ) -> ResponseMessage:
19
+ return await transport.request(
20
+ "sim:start", {"firmware": firmware, "elf": elf, "pause": pause, "chips": chips}
21
+ )
22
+
23
+
24
+ async def pause(transport: Transport) -> ResponseMessage:
25
+ return await transport.request("sim:pause", {})
26
+
27
+
28
+ async def resume(transport: Transport, pause_after: Optional[int] = None) -> ResponseMessage:
29
+ return await transport.request("sim:resume", {"pauseAfter": pause_after})
30
+
31
+
32
+ async def restart(transport: Transport, pause: bool = False) -> ResponseMessage:
33
+ return await transport.request("sim:restart", {"pause": pause})
@@ -0,0 +1,152 @@
1
+ # SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import warnings
9
+ from typing import Any, Callable, Optional, cast
10
+
11
+ import websockets
12
+
13
+ from .__version__ import get_version
14
+ from .constants import (
15
+ DEFAULT_WS_URL,
16
+ MSG_TYPE_EVENT,
17
+ MSG_TYPE_HELLO,
18
+ MSG_TYPE_RESPONSE,
19
+ PROTOCOL_VERSION,
20
+ )
21
+ from .exceptions import ProtocolError, ServerError, WokwiError
22
+ from .protocol_types import EventMessage, HelloMessage, IncomingMessage, ResponseMessage
23
+
24
+ TRANSPORT_DEFAULT_WS_URL = os.getenv("WOKWI_CLI_SERVER", DEFAULT_WS_URL)
25
+
26
+
27
+ class Transport:
28
+ def __init__(self, token: str, url: str = TRANSPORT_DEFAULT_WS_URL):
29
+ self._token = token
30
+ self._url = url
31
+ self._next_id = 1
32
+ self._ws: Optional[websockets.WebSocketClientProtocol] = None
33
+ self._event_listeners: dict[str, list[Callable[[EventMessage], Any]]] = {}
34
+ self._response_futures: dict[str, asyncio.Future[ResponseMessage]] = {}
35
+ self._recv_task: Optional[asyncio.Task[None]] = None
36
+ self._closed = False
37
+
38
+ async def connect(self) -> dict[str, Any]:
39
+ self._ws = await websockets.connect(
40
+ self._url,
41
+ extra_headers={
42
+ "Authorization": f"Bearer {self._token}",
43
+ "User-Agent": f"wokwi-client-py/{get_version()}",
44
+ },
45
+ )
46
+ hello: IncomingMessage = await self._recv()
47
+ if hello["type"] != MSG_TYPE_HELLO or hello.get("protocolVersion") != PROTOCOL_VERSION:
48
+ raise ProtocolError(f"Unsupported protocol handshake: {hello}")
49
+ hello_msg = cast(HelloMessage, hello)
50
+ self._closed = False
51
+ # Start background message processor
52
+ self._recv_task = asyncio.create_task(self._background_recv())
53
+ return {"version": hello_msg["appVersion"]}
54
+
55
+ async def close(self) -> None:
56
+ self._closed = True
57
+ if self._recv_task:
58
+ self._recv_task.cancel()
59
+ try:
60
+ await self._recv_task
61
+ except asyncio.CancelledError:
62
+ pass
63
+ if self._ws:
64
+ await self._ws.close()
65
+
66
+ def add_event_listener(self, event_type: str, listener: Callable[[EventMessage], Any]) -> None:
67
+ """Register a listener for a specific event type."""
68
+ if event_type not in self._event_listeners:
69
+ self._event_listeners[event_type] = []
70
+ self._event_listeners[event_type].append(listener)
71
+
72
+ def remove_event_listener(
73
+ self, event_type: str, listener: Callable[[EventMessage], Any]
74
+ ) -> None:
75
+ """Remove a previously registered listener for a specific event type."""
76
+ if event_type in self._event_listeners:
77
+ self._event_listeners[event_type] = [
78
+ registered_listener
79
+ for registered_listener in self._event_listeners[event_type]
80
+ if registered_listener != listener
81
+ ]
82
+ if not self._event_listeners[event_type]:
83
+ del self._event_listeners[event_type]
84
+
85
+ async def _dispatch_event(self, event_msg: EventMessage) -> None:
86
+ listeners = self._event_listeners.get(event_msg["event"], [])
87
+ for listener in listeners:
88
+ result = listener(event_msg)
89
+ if hasattr(result, "__await__"):
90
+ await result
91
+
92
+ async def request(self, command: str, params: dict[str, Any]) -> ResponseMessage:
93
+ msg_id = str(self._next_id)
94
+ self._next_id += 1
95
+ if self._ws is None:
96
+ raise WokwiError("Not connected")
97
+ loop = asyncio.get_running_loop()
98
+ future: asyncio.Future[ResponseMessage] = loop.create_future()
99
+ self._response_futures[msg_id] = future
100
+ await self._ws.send(
101
+ json.dumps({"type": "command", "command": command, "params": params, "id": msg_id})
102
+ )
103
+ try:
104
+ resp_msg_resp = await future
105
+ if resp_msg_resp.get("error"):
106
+ result = resp_msg_resp["result"]
107
+ raise ServerError(result["message"])
108
+ return resp_msg_resp
109
+ finally:
110
+ del self._response_futures[msg_id]
111
+
112
+ async def _background_recv(self) -> None:
113
+ try:
114
+ while not self._closed and self._ws is not None:
115
+ msg: IncomingMessage = await self._recv()
116
+ if msg["type"] == MSG_TYPE_EVENT:
117
+ resp_msg_event = cast(EventMessage, msg)
118
+ await self._dispatch_event(resp_msg_event)
119
+ elif msg["type"] == MSG_TYPE_RESPONSE:
120
+ resp_msg_resp = cast(ResponseMessage, msg)
121
+ future = self._response_futures.get(resp_msg_resp["id"])
122
+ if future is None or future.done():
123
+ continue
124
+ future.set_result(resp_msg_resp)
125
+ except (websockets.ConnectionClosed, asyncio.CancelledError):
126
+ pass
127
+ except Exception as e:
128
+ warnings.warn(f"Background recv error: {e}", RuntimeWarning)
129
+
130
+ async def _recv(self) -> IncomingMessage:
131
+ if self._ws is None:
132
+ raise WokwiError("Not connected")
133
+ raw_message = await self._ws.recv()
134
+ while isinstance(raw_message, bytes):
135
+ warnings.warn("Unexpected binary message received and skipped", RuntimeWarning)
136
+ raw_message = await self._ws.recv()
137
+ try:
138
+ message = json.loads(raw_message)
139
+ except json.JSONDecodeError as e:
140
+ raise WokwiError(f"Failed to parse message: {raw_message}") from e
141
+ if "type" not in message:
142
+ raise WokwiError(f"Invalid message: {message}")
143
+ if message["type"] == "error":
144
+ raise WokwiError(f"Server error: {message['message']}")
145
+ if message["type"] == "response" and message.get("error"):
146
+ result = (
147
+ message["result"]
148
+ if "result" in message
149
+ else {"code": -1, "message": "Unknown error"}
150
+ )
151
+ raise WokwiError(f"Server error {result['code']}: {result['message']}")
152
+ return cast(IncomingMessage, message)
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2
+ #
3
+ # SPDX-License-Identifier: MIT
@@ -0,0 +1,20 @@
1
+ # SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ import re
6
+
7
+ from click.testing import CliRunner
8
+
9
+ from wokwi_client.__version__ import get_version
10
+ from wokwi_client.cli import wokwi_client
11
+
12
+
13
+ def test_version_option() -> None:
14
+ """`wokwi --version` prints the same __version__ string and exits with 0."""
15
+ runner = CliRunner()
16
+ result = runner.invoke(wokwi_client, ["--version"])
17
+
18
+ assert result.exit_code == 0, result.output
19
+ pattern = rf"wokwi.*{re.escape(get_version())}"
20
+ assert re.search(pattern, result.output), result.output
@@ -0,0 +1,26 @@
1
+ # SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+
9
+
10
+ def test_hello_esp32_example() -> None:
11
+ """`python -m examples.hello_esp32.main` runs the hello_esp32 example and exits with 0."""
12
+
13
+ assert os.environ.get("WOKWI_CLI_TOKEN") is not None, (
14
+ "WOKWI_CLI_TOKEN environment variable is not set. You can get it from https://wokwi.com/dashboard/ci."
15
+ )
16
+
17
+ result = subprocess.run(
18
+ [sys.executable, "-m", "examples.hello_esp32.main"],
19
+ check=False,
20
+ capture_output=True,
21
+ text=True,
22
+ env={**os.environ, "WOKWI_SLEEP_TIME": "1"},
23
+ )
24
+
25
+ assert result.returncode == 0
26
+ assert "main_task: Calling app_main()" in result.stdout