diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8d30a7e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: ci + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install package and test deps + run: python -m pip install -e . pytest + + - name: Vendor the Linux server binary + run: python scripts/vendor_binaries.py --only gomc-rest-linux-amd64 + + - name: Run tests + run: python -m pytest -v + + # Verify the dynamically-linked amd64 binary actually starts on its declared + # glibc floor (manylinux_2_34 == glibc 2.34), not just on the latest runner. + floor-glibc-amd64: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Smoke test on glibc 2.34 + run: | + docker run --rm -v "$PWD":/w -w /w \ + quay.io/pypa/manylinux_2_34_x86_64 bash -c ' + PY=/opt/python/cp311-cp311/bin/python + "$PY" -m pip install -e . pytest && + "$PY" scripts/vendor_binaries.py --only gomc-rest-linux-amd64 && + "$PY" -m pytest -v + ' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f4341e9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,83 @@ +name: release + +# Build one platform-specific wheel per OS (each bundling only its matching +# gomc-rest binary) and publish to PyPI on a version tag. +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + build-wheels: + name: wheel (${{ matrix.plat }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + asset: gomc-rest.exe + plat: win_amd64 + # amd64 binary is dynamically linked and needs GLIBC_2.34, so it must + # be tagged manylinux_2_34 (not 2014/glibc 2.17) or pip would install + # it on older systems where it fails with "GLIBC_2.34 not found". + - os: ubuntu-latest + asset: gomc-rest-linux-amd64 + plat: manylinux_2_34_x86_64 + # arm64 binary is statically linked (no glibc dependency), so the + # widest valid tag is fine. + - os: ubuntu-24.04-arm + asset: gomc-rest-linux-arm64 + plat: manylinux2014_aarch64 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Check tag matches project version + if: github.ref_type == 'tag' + shell: bash + run: | + tag="${GITHUB_REF_NAME#v}" + proj=$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_bytes().decode())['project']['version'])") + echo "tag=$tag project.version=$proj" + test "$tag" = "$proj" || { echo "::error::tag $GITHUB_REF_NAME does not match project.version $proj"; exit 1; } + + - name: Install build tooling + run: python -m pip install --upgrade build wheel + + - name: Vendor the matching server binary + run: python scripts/vendor_binaries.py --only ${{ matrix.asset }} + + - name: Build wheel + run: python -m build --wheel + + - name: Re-tag wheel for this platform + shell: bash + run: python -m wheel tags --platform-tag ${{ matrix.plat }} --remove dist/*.whl + + - uses: actions/upload-artifact@v4 + with: + name: wheel-${{ matrix.plat }} + path: dist/*.whl + + publish: + name: publish to PyPI + needs: build-wheels + # Only publish for real version tags; workflow_dispatch builds wheels for + # verification but must never publish from an arbitrary branch. + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write # trusted publishing (OIDC) — no API token needed + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/GOMC_REST_VERSION b/GOMC_REST_VERSION new file mode 100644 index 0000000..0d0c52f --- /dev/null +++ b/GOMC_REST_VERSION @@ -0,0 +1 @@ +v1.4.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c00024 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# gomc-rest (Python) + +Python package for talking to Mitsubishi PLCs via +[gomc-rest](https://github.com/Moge800/gomc-rest) — **Pattern B**: the +`gomc-rest` server binary is bundled and auto-launched as a subprocess, so you +never have to start or distribute the executable yourself. + +The HTTP layer is provided by +[gomc-rest-client](https://github.com/Moge800/gomc_rest_client); this package +adds only the bundled binary and its process lifecycle. + +```text +your Python process +└─ gomc_rest.launch() + ├─ spawns gomc-rest (bundled exe) on a free loopback port ── MC protocol ──▶ PLC + └─ returns a gomc_rest_client.PLCClient pointed at it +``` + +## Install + +```bash +pip install gomc-rest +``` + +## Usage + +```python +import gomc_rest + +with gomc_rest.launch(plc_host="192.168.0.1") as plc: + values = plc.read("D100", 3) + plc.write("D100", [10, 20, 30]) +# the bundled server is stopped automatically on exit +``` + +`launch()` returns a `Server`; using it as a context manager yields a +`PLCClient` (see gomc-rest-client for the full read/write/remote API) and stops +the server on exit. Without `with`, the server is stopped at interpreter exit. + +Pass server flags through `extra_args`: + +```python +with gomc_rest.launch(plc_host="192.168.0.1", extra_args=["-enable-remote"]) as plc: + plc.remote_run() +``` + +## Access control + +Two independent layers protect the server, both on by default: + +1. **Per-launch bearer token.** A random token is generated each launch and + required by the server, so even another process on the same host that + discovers the port cannot call the API. It is set automatically on the + returned client and exposed as `server.token`. Pass an explicit `token=` to + share with another app, or `token=""` to disable auth (closed-network use). +2. **Loopback binding.** By default the server binds to `127.0.0.1`, so no + other host can reach it. + +Set `server_mode=True` to bind all interfaces so other apps on the network +(e.g. gomc-rest-gui, curl from another machine) can call it — give them +`server.token`: + +```python +server = gomc_rest.launch(plc_host="192.168.0.1", server_mode=True) +print(server.base_url) # other apps connect to http://: +print(server.token) # ...with this bearer token +try: + server.client.read("D100", 3) +finally: + server.close() +``` + +The server has no TLS — only enable `server_mode` on a trusted network. + +**Threat model.** The token is passed to the server via the `GOMCR_TOKEN` +environment variable (not the command line), so it does not appear in the +process list. It protects against other hosts and other OS users. It does +**not** protect against another process running as the **same OS user**, which +can read the server's environment (e.g. `/proc//environ`); the OS user +boundary is the trust boundary here. + +## Versions + +This package bundles a pinned `gomc-rest` binary (currently **v1.4.0**, set in +`GOMC_REST_VERSION`) that must satisfy `gomc-rest-client`'s +`MINIMUM_SUPPORTED_GOMC_REST_VERSION`; `launch()` verifies this on startup. The +`gomc-rest-client` dependency is capped (`>=0.10.0,<0.11`) so a future client +that raises its minimum server version can't be installed without also bumping +the bundled binary. + +## Releasing / bundled binaries + +The bundled server version is pinned in `GOMC_REST_VERSION`. Binaries are not +committed to git; they are fetched from the matching gomc-rest GitHub release. + +- Locally: `python scripts/vendor_binaries.py` downloads all three binaries + into `src/gomc_rest/binaries/`, verifying each against the trusted SHA-256 + values committed in `checksums/.sha256`. +- On a `v*` tag, `.github/workflows/release.yml` builds one platform-specific + wheel per OS (each bundling only its matching binary) and publishes to PyPI + via trusted publishing. The release job verifies the tag equals + `project.version`; `workflow_dispatch` builds wheels for verification only and + never publishes. + +To cut a release: + +1. To change the bundled server, edit `GOMC_REST_VERSION` (keep it within the + range accepted by the pinned `gomc-rest-client`) and add a matching + `checksums/.sha256` with the trusted SHA-256 of each asset. If the + new binaries change their glibc requirement, update the `plat` tags in + `release.yml` accordingly. +2. Bump the package version in **both** `pyproject.toml` (`project.version`) + and `src/gomc_rest/__init__.py` (`__version__`) — they must match. +3. Tag that exact version, e.g. `git tag v0.2.0 && git push origin v0.2.0` + (the tag must equal `project.version` or the release job fails). diff --git a/checksums/v1.4.0.sha256 b/checksums/v1.4.0.sha256 new file mode 100644 index 0000000..0e1f04b --- /dev/null +++ b/checksums/v1.4.0.sha256 @@ -0,0 +1,3 @@ +4d3233c2e4ac83040358e2ef243d86706cbdf6141f92f8b6270f8f32b74a11e5 gomc-rest.exe +4e12c6f27ee75707d7b8a6c54e43e69883250e23da507afbbc95533111df30a1 gomc-rest-linux-amd64 +a94bf94e79115d372c06f99ca7e31b38f7025e2578a4d4cd2ca44b5bfeca7165 gomc-rest-linux-arm64 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0444bd7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "gomc-rest" +version = "0.1.0" +description = "Pattern-B wrapper for gomc-rest: bundles the gomc-rest server binary and auto-launches it, exposing a Python client for Mitsubishi PLCs." +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "Moge800" }] +keywords = ["mitsubishi", "plc", "mc-protocol", "slmp", "gomc-rest"] + +# HTTP layer is delegated to gomc-rest-client (pattern A). We only add the +# bundled-binary + subprocess lifecycle on top. +dependencies = [ + # Cap the client to a range known to accept the bundled server version + # (GOMC_REST_VERSION). A future client that raises its minimum server + # version must not be pulled in without bumping the bundled binary too. + "gomc-rest-client>=0.10.0,<0.11", +] + +[project.urls] +Homepage = "https://github.com/Moge800/gomc_rest_python" +"Upstream server" = "https://github.com/Moge800/gomc-rest" +"HTTP client" = "https://github.com/Moge800/gomc_rest_client" + +[tool.hatch.build.targets.wheel] +packages = ["src/gomc_rest"] + +# The platform-specific server binaries live under gomc_rest/binaries/ and are +# shipped as package data. (Per-platform wheels are produced at release time; +# see binaries/README.md.) +[tool.hatch.build.targets.wheel.force-include] +"src/gomc_rest/binaries" = "gomc_rest/binaries" diff --git a/scripts/vendor_binaries.py b/scripts/vendor_binaries.py new file mode 100644 index 0000000..9b6a9ed --- /dev/null +++ b/scripts/vendor_binaries.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Download the pinned gomc-rest server binaries into the package. + +The binaries are not committed to git; this script fetches them from the +gomc-rest GitHub release named in the GOMC_REST_VERSION file (override with +the GOMC_REST_VERSION env var) and writes them into src/gomc_rest/binaries/. + +Usage: + python scripts/vendor_binaries.py # all platforms + python scripts/vendor_binaries.py --only gomc-rest-linux-amd64 +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import sys +import urllib.request +from pathlib import Path + +REPO = "Moge800/gomc-rest" +ASSETS = [ + "gomc-rest.exe", + "gomc-rest-linux-amd64", + "gomc-rest-linux-arm64", +] + +_ROOT = Path(__file__).resolve().parent.parent +_DEST = _ROOT / "src" / "gomc_rest" / "binaries" +_CHECKSUMS = _ROOT / "checksums" + + +def _version() -> str: + env = os.environ.get("GOMC_REST_VERSION") + if env: + return env.strip() + return (_ROOT / "GOMC_REST_VERSION").read_text().strip() + + +def _expected_checksums(version: str) -> dict[str, str]: + """Load the trusted SHA-256 values pinned in this repo for `version`. + + These are committed, not fetched from the same release, so swapping a + release asset after the fact is detected. Missing file -> fail closed. + """ + path = _CHECKSUMS / f"{version}.sha256" + if not path.exists(): + raise RuntimeError( + f"No pinned checksums for {version} at {path}. Add the trusted " + "SHA-256 values before vendoring." + ) + sums: dict[str, str] = {} + for line in path.read_text().splitlines(): + line = line.strip() + if not line: + continue + digest, name = line.split() + sums[name] = digest + return sums + + +def _download(version: str, asset: str, expected: dict[str, str]) -> None: + url = f"https://github.com/{REPO}/releases/download/{version}/{asset}" + out = _DEST / asset + print(f" {asset} <- {url}") + urllib.request.urlretrieve(url, out) + + want = expected.get(asset) + if want is None: + out.unlink(missing_ok=True) + raise RuntimeError(f"No pinned checksum for {asset} in {version}.sha256.") + got = hashlib.sha256(out.read_bytes()).hexdigest() + if got != want: + out.unlink(missing_ok=True) + raise RuntimeError( + f"Checksum mismatch for {asset}: expected {want}, got {got}." + ) + + if not asset.endswith(".exe"): + out.chmod(0o755) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--only", + choices=ASSETS, + help="Download a single binary instead of all (used for per-platform wheels).", + ) + args = parser.parse_args() + + version = _version() + expected = _expected_checksums(version) + targets = [args.only] if args.only else ASSETS + _DEST.mkdir(parents=True, exist_ok=True) + print(f"Vendoring gomc-rest {version} into {_DEST}") + for asset in targets: + _download(version, asset, expected) + print("Done (checksums verified).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/gomc_rest/__init__.py b/src/gomc_rest/__init__.py new file mode 100644 index 0000000..1796172 --- /dev/null +++ b/src/gomc_rest/__init__.py @@ -0,0 +1,75 @@ +"""gomc-rest (Python, Pattern B). + +Bundles the gomc-rest server binary, auto-launches it as a subprocess, and +hands back a PLCClient (from gomc-rest-client) pointed at it. The HTTP layer is +provided entirely by gomc-rest-client; this package only adds binary bundling +and process lifecycle. + + import gomc_rest + + with gomc_rest.launch(plc_host="192.168.0.1") as plc: + plc.read("D100", 3) +""" + +from __future__ import annotations + +from gomc_rest_client import ( + GomcRestBusyError, + GomcRestPLCProtocolError, + GomcRestUnauthorizedError, + PLCClient, +) + +from ._server import Server + +__version__ = "0.1.0" + + +def launch( + plc_host: str = "192.168.0.1", + plc_port: int = 5007, + server_mode: bool = False, + token: str | None = None, + extra_args: list[str] | None = None, + startup_timeout: float = 10.0, +) -> Server: + """Start the bundled gomc-rest server and return a managed Server. + + Use as a context manager to get the PLCClient and auto-stop the server:: + + with gomc_rest.launch(plc_host="192.168.0.1") as plc: + plc.read("D100", 3) + + A random bearer token is generated per launch and required by the server, + so even another process on this host that learns the port cannot call the + API. Pass an explicit ``token`` to share with another app, or ``token=""`` + to disable auth (closed-network use). The token is available as + ``server.token``. + + By default the server also binds to loopback only. Set ``server_mode=True`` + to bind all interfaces and let other apps on the network call the REST API + (give them ``server.token``). The server has no TLS — only expose it on a + trusted network. + + ``extra_args`` are passed through to the gomc-rest binary (e.g. + ``["-enable-remote"]`` or ``["-readonly"]``). + """ + return Server( + plc_host=plc_host, + plc_port=plc_port, + server_mode=server_mode, + token=token, + extra_args=extra_args, + startup_timeout=startup_timeout, + ) + + +__all__ = [ + "launch", + "Server", + "PLCClient", + "GomcRestBusyError", + "GomcRestPLCProtocolError", + "GomcRestUnauthorizedError", + "__version__", +] diff --git a/src/gomc_rest/_binaries.py b/src/gomc_rest/_binaries.py new file mode 100644 index 0000000..81450cc --- /dev/null +++ b/src/gomc_rest/_binaries.py @@ -0,0 +1,70 @@ +"""Locate the bundled gomc-rest server binary for the current platform.""" + +from __future__ import annotations + +import platform +import stat +from pathlib import Path + +_BINARIES_DIR = Path(__file__).parent / "binaries" + +# (system, machine) -> bundled binary file name. +# Machine names are normalised to the keys below before lookup. +_BINARY_NAMES = { + ("windows", "amd64"): "gomc-rest.exe", + ("linux", "amd64"): "gomc-rest-linux-amd64", + ("linux", "arm64"): "gomc-rest-linux-arm64", +} + +_MACHINE_ALIASES = { + "x86_64": "amd64", + "amd64": "amd64", + "aarch64": "arm64", + "arm64": "arm64", +} + + +def _normalise_platform() -> tuple[str, str]: + system = platform.system().lower() + machine = _MACHINE_ALIASES.get(platform.machine().lower(), platform.machine().lower()) + return system, machine + + +def binary_path() -> Path: + """Return the path to the bundled server binary for this platform. + + Raises RuntimeError if the platform is unsupported or the binary is missing + from the package (e.g. a sdist install without the platform wheel). + """ + system, machine = _normalise_platform() + name = _BINARY_NAMES.get((system, machine)) + if name is None: + raise RuntimeError( + f"Unsupported platform {system}/{machine}. " + f"gomc-rest ships binaries for: {sorted(_BINARY_NAMES)}." + ) + + path = _BINARIES_DIR / name + if not path.exists(): + raise RuntimeError( + f"Bundled server binary '{name}' was not found at {path}. " + "Install the platform-specific wheel, or place the binary there " + "(see gomc_rest/binaries/README.md)." + ) + + # Wheels preserve the executable bit set at build time; only repair it when + # it is actually missing (e.g. an sdist/VCS checkout). Chmod'ing + # unconditionally breaks the common root-installs / non-root-runs setup, + # where the running user does not own an already-executable binary. + if system != "windows" and not path.stat().st_mode & stat.S_IXUSR: + try: + mode = path.stat().st_mode + path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + except PermissionError as exc: + raise RuntimeError( + f"Bundled binary {path} is not executable and its permissions " + "cannot be changed by this user. Reinstall the wheel or add the " + "executable bit." + ) from exc + + return path diff --git a/src/gomc_rest/_server.py b/src/gomc_rest/_server.py new file mode 100644 index 0000000..75a672a --- /dev/null +++ b/src/gomc_rest/_server.py @@ -0,0 +1,137 @@ +"""Launch and manage the bundled gomc-rest server as a subprocess (Pattern B).""" + +from __future__ import annotations + +import atexit +import os +import secrets +import socket +import subprocess +import time + +from gomc_rest_client import PLCClient + +from ._binaries import binary_path + + +def _free_port() -> int: + """Pick a currently-free TCP port on the loopback interface.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +class Server: + """Owns a gomc-rest subprocess and a PLCClient pointed at it. + + Prefer the context-manager form:: + + with Server(plc_host="192.168.0.1") as plc: + plc.read("D100", 3) + + When not used as a context manager the subprocess is still terminated at + interpreter exit via atexit. + """ + + def __init__( + self, + plc_host: str = "192.168.0.1", + plc_port: int = 5007, + server_mode: bool = False, + token: str | None = None, + extra_args: list[str] | None = None, + startup_timeout: float = 10.0, + ) -> None: + self._port = _free_port() + self.base_url = f"http://127.0.0.1:{self._port}" + + # A per-launch random token is generated by default, so even another + # process on this host that learns the port cannot call the API without + # it. token is None -> generate; token == "" -> disable auth; otherwise + # use the given token. + if token is None: + self.token = secrets.token_urlsafe(24) + elif token == "": + self.token = None + else: + self.token = token + + # Default: bind to loopback only, so no other host can reach the + # server. server_mode binds all interfaces so other apps on the + # network can call the REST API (give them self.token). + listen = f":{self._port}" if server_mode else f"127.0.0.1:{self._port}" + args = [ + str(binary_path()), + "-listen", listen, + "-host", plc_host, + "-port", str(plc_port), + *(extra_args or []), + ] + # Pass the token via the environment, not argv, so it is not exposed in + # the process list / /proc//cmdline. Set GOMCR_TOKEN explicitly + # (empty when disabled) so an inherited value can't enable auth on the + # server while the client stays unauthenticated. + env = os.environ.copy() + env["GOMCR_TOKEN"] = self.token or "" + self._proc = subprocess.Popen(args, env=env) + atexit.register(self.close) + self.client = PLCClient(self.base_url, token=self.token) + try: + self._wait_until_healthy(startup_timeout) + self._check_version() + except BaseException: + # Never leave the subprocess running if startup validation fails; + # the caller never receives a Server to close() itself. + self.close() + raise + + def _wait_until_healthy(self, timeout: float) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if self._proc.poll() is not None: + raise RuntimeError( + f"gomc-rest exited during startup (code {self._proc.returncode})." + ) + try: + self.client.health() + return + except Exception: + time.sleep(0.1) + raise TimeoutError(f"gomc-rest did not become healthy within {timeout}s.") + + def _check_version(self) -> None: + if not self.client.is_supported_version(): + raise RuntimeError( + f"Bundled gomc-rest {self.client.version()} is not supported by " + "the installed gomc-rest-client. Update the bundled binary." + ) + + def close(self) -> None: + """Release the client session and terminate the subprocess. + + Safe to call more than once. + """ + atexit.unregister(self.close) + + client = getattr(self, "client", None) + if client is not None: + try: + client.__exit__(None, None, None) + except Exception: + pass + + proc = getattr(self, "_proc", None) + if proc is None or proc.poll() is not None: + return + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + + def __enter__(self) -> PLCClient: + return self.client + + def __exit__(self, *exc) -> None: + self.close() diff --git a/src/gomc_rest/binaries/.gitignore b/src/gomc_rest/binaries/.gitignore new file mode 100644 index 0000000..4fd9294 --- /dev/null +++ b/src/gomc_rest/binaries/.gitignore @@ -0,0 +1,4 @@ +# Vendored at build time by scripts/vendor_binaries.py — never commit binaries. +* +!.gitignore +!README.md diff --git a/src/gomc_rest/binaries/README.md b/src/gomc_rest/binaries/README.md new file mode 100644 index 0000000..2eb4e40 --- /dev/null +++ b/src/gomc_rest/binaries/README.md @@ -0,0 +1,15 @@ +# Bundled server binaries + +This directory holds the platform-specific `gomc-rest` server binaries that the +Python package auto-launches (Pattern B): + +| Platform | File name | +| --------------- | ------------------------ | +| Windows (amd64) | `gomc-rest.exe` | +| Linux (amd64) | `gomc-rest-linux-amd64` | +| Linux (arm64) | `gomc-rest-linux-arm64` | + +The binaries are **not committed to git**. They are vendored in at release time +from a pinned [gomc-rest](https://github.com/Moge800/gomc-rest) version, and each +platform wheel ships only its matching binary. `_binaries.py` resolves the +correct file for the current platform at runtime. diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..2f99693 --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,40 @@ +"""Smoke test: the bundled server starts with default settings and is healthy. + +Skipped automatically when the server binary has not been vendored (so the +suite still passes locally without a download); CI vendors it first. +""" + +from __future__ import annotations + +import pytest + +import gomc_rest +from gomc_rest._binaries import binary_path + + +def _binary_available() -> bool: + try: + binary_path() + return True + except RuntimeError: + return False + + +pytestmark = pytest.mark.skipif( + not _binary_available(), reason="server binary not vendored" +) + + +def test_launch_default_is_healthy(): + # Default settings generate a random token and pass it to both ends. + with gomc_rest.launch() as plc: + assert isinstance(plc.health(), dict) + + +def test_launch_auth_disabled(): + server = gomc_rest.launch(token="") + try: + assert server.token is None + assert isinstance(server.client.health(), dict) + finally: + server.close()