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
35 changes: 33 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
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.
# gomc-rest binary) plus a binary-free sdist, and publish to PyPI on a version
# tag. The sdist is the client-only fallback: on platforms without a matching
# wheel (macOS, Windows arm64, glibc < 2.34), pip builds it so connect() still
# works (launch() then raises a clear "binary not found/unsupported" error).
on:
push:
tags:
Expand Down Expand Up @@ -64,9 +67,37 @@ jobs:
name: wheel-${{ matrix.plat }}
path: dist/*.whl

build-sdist:
name: sdist (client-only fallback)
runs-on: ubuntu-latest
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'])")
test "$tag" = "$proj" || { echo "::error::tag $GITHUB_REF_NAME does not match project.version $proj"; exit 1; }

- name: Build sdist (no binary bundled)
run: |
python -m pip install --upgrade build
python -m build --sdist

- uses: actions/upload-artifact@v4
with:
name: sdist
path: dist/*.tar.gz

publish:
name: publish to PyPI
needs: build-wheels
needs: [build-wheels, build-sdist]
# 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'
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# gomc-rest (Python)

English / [日本語](https://github.com/Moge800/gomc_rest_python/blob/main/README_JP.md)

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
Expand Down Expand Up @@ -44,6 +46,25 @@ with gomc_rest.launch(plc_host="192.168.0.1", extra_args=["-enable-remote"]) as
plc.remote_run()
```

### Client mode (connect to an existing server)

To talk to a gomc-rest server that is already running elsewhere — a shared
instance, another machine, or one you launched with `server_mode=True` — use
`connect()` instead of starting the bundled binary:

```python
with gomc_rest.connect("http://192.168.0.1:8080", token="...") as plc:
plc.read("D100", 3)
```

Both `launch()` and `connect()` hand you the same `PLCClient`, so one package
covers "bundle and run the server" and "just be a client".

`connect()` needs no bundled binary, so it works even on platforms without a
prebuilt wheel (macOS, Windows arm64, glibc < 2.34): there `pip install
gomc-rest` installs from the sdist, and only `launch()` is unavailable (it
raises a clear error).

## Access control

Two independent layers protect the server, both on by default:
Expand Down
138 changes: 138 additions & 0 deletions README_JP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# gomc-rest (Python)

[English](README.md) / 日本語

三菱 PLC を [gomc-rest](https://github.com/Moge800/gomc-rest) 経由で操作する
Python パッケージです。**パターンB** を採用しており、`gomc-rest` サーバの
バイナリを同梱して subprocess として自動起動します。そのため利用者が exe を
自分で起動・配布する必要はありません。

HTTP 通信層は [gomc-rest-client](https://github.com/Moge800/gomc_rest_client)
が提供します。本パッケージはそこに「バイナリの同梱」と「プロセスのライフ
サイクル管理」だけを足したものです。

```text
あなたの Python プロセス
└─ gomc_rest.launch()
├─ 同梱 exe (gomc-rest) を空きループバックポートで起動 ── MCプロトコル ──▶ PLC
└─ そこを指す gomc_rest_client.PLCClient を返す
```

## インストール

```bash
pip install gomc-rest
```

## 使い方

```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])
# with を抜けると同梱サーバは自動的に停止します
```

`launch()` は `Server` を返します。context manager として使うと `PLCClient`
(read/write/remote の全 API は gomc-rest-client を参照)が得られ、終了時に
サーバを停止します。`with` を使わない場合は、インタプリタ終了時に停止します。

サーバのフラグは `extra_args` で渡せます:

```python
with gomc_rest.launch(plc_host="192.168.0.1", extra_args=["-enable-remote"]) as plc:
plc.remote_run()
```

### クライアントモード(既存サーバへ接続)

既に別の場所で動いている gomc-rest サーバ(共有インスタンス、別マシン、
`server_mode=True` で起動したサーバなど)に繋ぐ場合は、同梱バイナリを起動せず
`connect()` を使います:

```python
with gomc_rest.connect("http://192.168.0.1:8080", token="...") as plc:
plc.read("D100", 3)
```

`launch()` も `connect()` も同じ `PLCClient` を返すため、「サーバを同梱して
起動する」用途と「クライアントとして繋ぐだけ」の用途が 1 パッケージで揃います。

`connect()` は同梱バイナリを必要としないため、ビルド済み wheel が無い
プラットフォーム(macOS、Windows arm64、glibc 2.34 未満)でも動作します。
そうした環境では `pip install gomc-rest` が sdist からインストールされ、
`launch()` だけが使えません(呼ぶと明確なエラーになります)。

## アクセス制御

既定で 2 つの独立した層がサーバを保護します(両方とも有効):

1. **起動ごとの bearer トークン。** 起動のたびにランダムなトークンを生成し、
サーバ側で必須にします。そのため、ポートを発見した同一ホストの別プロセス
であってもトークン無しでは API を叩けません。トークンは返却される
クライアントへ自動設定され、`server.token` で参照できます。明示的に
`token=` を渡せば他アプリと共有でき、`token=""` で認証を無効化できます
(クローズドネットワーク用途)。
2. **ループバックバインド。** 既定では `127.0.0.1` にバインドするため、他の
ホストからは到達できません。

`server_mode=True` にすると全インターフェースにバインドし、ネットワーク上の
他アプリ(gomc-rest-gui、別マシンの curl など)から呼べます。その際は
`server.token` を相手に渡してください:

```python
server = gomc_rest.launch(plc_host="192.168.0.1", server_mode=True)
print(server.base_url) # 他アプリは http://<このホスト>:<ポート> に接続
print(server.token) # ...このトークンを添えて
try:
server.client.read("D100", 3)
finally:
server.close()
```

サーバは TLS を持ちません。`server_mode` は信頼できるネットワークでのみ
有効化してください。

**脅威モデル。** トークンはコマンドライン引数ではなく `GOMCR_TOKEN` 環境変数
でサーバへ渡すため、プロセス一覧には現れません。これにより他ホスト・他 OS
ユーザからは保護されます。ただし、**同一 OS ユーザ**で動く別プロセスは
サーバの環境(例: `/proc/<pid>/environ`)を読めるため、そこに対しては保護
**できません**。ここでの信頼境界は OS ユーザです。

## バージョン

本パッケージは固定された `gomc-rest` バイナリ(現在 **v1.4.0**、`GOMC_REST_VERSION`
で指定)を同梱します。これは `gomc-rest-client` の
`MINIMUM_SUPPORTED_GOMC_REST_VERSION` を満たす必要があり、`launch()` が起動時に
検証します。依存 `gomc-rest-client` は範囲を固定(`>=0.10.0,<0.11`)しており、
将来クライアントが最低対応サーバ版を引き上げても、同梱バイナリを更新せずに
インストールが壊れることはありません。

## リリース / 同梱バイナリ

同梱サーバのバージョンは `GOMC_REST_VERSION` で固定します。バイナリは git に
コミットせず、対応する gomc-rest の GitHub リリースから取得します。

- ローカル: `python scripts/vendor_binaries.py` が 3 つのバイナリを
`src/gomc_rest/binaries/` にダウンロードし、`checksums/<version>.sha256` に
コミットされた信頼 SHA-256 値と照合します。
- `v*` タグの push 時: `.github/workflows/release.yml` が OS ごとに
プラットフォーム別 wheel を 1 つずつビルド(各 wheel に対応バイナリのみ同梱)し、
trusted publishing で PyPI に公開します。リリースジョブはタグが
`project.version` と一致することを検証します。`workflow_dispatch` は wheel の
検証ビルドのみで、公開は行いません。

リリース手順:

1. 同梱サーバを変更する場合は `GOMC_REST_VERSION` を編集し(固定中の
`gomc-rest-client` が受け付ける範囲に収めること)、各アセットの信頼
SHA-256 を記した `checksums/<version>.sha256` を追加します。新しい
バイナリの glibc 要求が変わる場合は、`release.yml` の `plat` タグも更新します。
2. パッケージのバージョンを `pyproject.toml`(`project.version`)と
`src/gomc_rest/__init__.py`(`__version__`)の**両方**で更新します
(両者は一致している必要があります)。
3. そのバージョンと完全に一致するタグを切ります。例:
`git tag v0.2.0 && git push origin v0.2.0`
(タグが `project.version` と一致しないとリリースジョブは失敗します)。
36 changes: 32 additions & 4 deletions src/gomc_rest/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
"""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.
Bundles the gomc-rest server binary and offers two entry points over the same
PLCClient (from gomc-rest-client):

* ``launch()`` — auto-start the bundled server as a subprocess (optionally
exposed to the network) and talk to it.
* ``connect()`` — act as a client to a gomc-rest server that is already
running elsewhere, without starting anything.

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)

plc = gomc_rest.connect("http://192.168.0.1:8080", token="...")
plc.read("D100", 3)
"""

from __future__ import annotations
Expand Down Expand Up @@ -64,8 +73,27 @@ def launch(
)


def connect(
base_url: str = "http://127.0.0.1:8080",
token: str | None = None,
) -> PLCClient:
"""Return a PLCClient for a gomc-rest server that is already running.

Use this to talk to a server started elsewhere — another machine, a shared
instance, or one you launched with ``server_mode=True`` — without spawning
the bundled binary. Supports the context-manager protocol::

with gomc_rest.connect("http://192.168.0.1:8080", token="...") as plc:
plc.read("D100", 3)

For the bundled, auto-started server use ``launch()`` instead.
"""
return PLCClient(base_url, token=token)


__all__ = [
"launch",
"connect",
"Server",
"PLCClient",
"GomcRestBusyError",
Expand Down
29 changes: 29 additions & 0 deletions tests/test_connect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Tests for client mode: connect() builds a PLCClient and spawns nothing."""

from __future__ import annotations

import subprocess

from gomc_rest_client import PLCClient

import gomc_rest


def test_connect_returns_client_with_url_and_token():
plc = gomc_rest.connect("http://plc.example:8080", token="tok123")
assert isinstance(plc, PLCClient)
assert plc.base_url == "http://plc.example:8080"
assert plc._auth_headers.get("Authorization") == "Bearer tok123"


def test_connect_without_token_sends_no_auth():
plc = gomc_rest.connect("http://plc.example:8080")
assert not plc._auth_headers


def test_connect_does_not_spawn_subprocess(monkeypatch):
def _boom(*args, **kwargs):
raise AssertionError("connect() must not start a subprocess")

monkeypatch.setattr(subprocess, "Popen", _boom)
assert isinstance(gomc_rest.connect("http://plc.example:8080"), PLCClient)
Loading