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
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
'
83 changes: 83 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions GOMC_REST_VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v1.4.0
115 changes: 115 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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://<this-host>:<port>
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/<pid>/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/<version>.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/<version>.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).
3 changes: 3 additions & 0 deletions checksums/v1.4.0.sha256
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
4d3233c2e4ac83040358e2ef243d86706cbdf6141f92f8b6270f8f32b74a11e5 gomc-rest.exe
4e12c6f27ee75707d7b8a6c54e43e69883250e23da507afbbc95533111df30a1 gomc-rest-linux-amd64
a94bf94e79115d372c06f99ca7e31b38f7025e2578a4d4cd2ca44b5bfeca7165 gomc-rest-linux-arm64
36 changes: 36 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
105 changes: 105 additions & 0 deletions scripts/vendor_binaries.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading