diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f4341e9..a30e310 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -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' diff --git a/README.md b/README.md index 1c00024..3858733 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: diff --git a/README_JP.md b/README_JP.md new file mode 100644 index 0000000..81f1bac --- /dev/null +++ b/README_JP.md @@ -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//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/.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/.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` と一致しないとリリースジョブは失敗します)。 diff --git a/src/gomc_rest/__init__.py b/src/gomc_rest/__init__.py index 1796172..a81fdec 100644 --- a/src/gomc_rest/__init__.py +++ b/src/gomc_rest/__init__.py @@ -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 @@ -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", diff --git a/tests/test_connect.py b/tests/test_connect.py new file mode 100644 index 0000000..3cd59c1 --- /dev/null +++ b/tests/test_connect.py @@ -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)