gomc-rest 0.1.0__py3-none-win_amd64.whl

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.
gomc_rest/__init__.py ADDED
@@ -0,0 +1,103 @@
1
+ """gomc-rest (Python, Pattern B).
2
+
3
+ Bundles the gomc-rest server binary and offers two entry points over the same
4
+ PLCClient (from gomc-rest-client):
5
+
6
+ * ``launch()`` — auto-start the bundled server as a subprocess (optionally
7
+ exposed to the network) and talk to it.
8
+ * ``connect()`` — act as a client to a gomc-rest server that is already
9
+ running elsewhere, without starting anything.
10
+
11
+ The HTTP layer is provided entirely by gomc-rest-client; this package only adds
12
+ binary bundling and process lifecycle.
13
+
14
+ import gomc_rest
15
+
16
+ with gomc_rest.launch(plc_host="192.168.0.1") as plc:
17
+ plc.read("D100", 3)
18
+
19
+ plc = gomc_rest.connect("http://192.168.0.1:8080", token="...")
20
+ plc.read("D100", 3)
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from gomc_rest_client import (
26
+ GomcRestBusyError,
27
+ GomcRestPLCProtocolError,
28
+ GomcRestUnauthorizedError,
29
+ PLCClient,
30
+ )
31
+
32
+ from ._server import Server
33
+
34
+ __version__ = "0.1.0"
35
+
36
+
37
+ def launch(
38
+ plc_host: str = "192.168.0.1",
39
+ plc_port: int = 5007,
40
+ server_mode: bool = False,
41
+ token: str | None = None,
42
+ extra_args: list[str] | None = None,
43
+ startup_timeout: float = 10.0,
44
+ ) -> Server:
45
+ """Start the bundled gomc-rest server and return a managed Server.
46
+
47
+ Use as a context manager to get the PLCClient and auto-stop the server::
48
+
49
+ with gomc_rest.launch(plc_host="192.168.0.1") as plc:
50
+ plc.read("D100", 3)
51
+
52
+ A random bearer token is generated per launch and required by the server,
53
+ so even another process on this host that learns the port cannot call the
54
+ API. Pass an explicit ``token`` to share with another app, or ``token=""``
55
+ to disable auth (closed-network use). The token is available as
56
+ ``server.token``.
57
+
58
+ By default the server also binds to loopback only. Set ``server_mode=True``
59
+ to bind all interfaces and let other apps on the network call the REST API
60
+ (give them ``server.token``). The server has no TLS — only expose it on a
61
+ trusted network.
62
+
63
+ ``extra_args`` are passed through to the gomc-rest binary (e.g.
64
+ ``["-enable-remote"]`` or ``["-readonly"]``).
65
+ """
66
+ return Server(
67
+ plc_host=plc_host,
68
+ plc_port=plc_port,
69
+ server_mode=server_mode,
70
+ token=token,
71
+ extra_args=extra_args,
72
+ startup_timeout=startup_timeout,
73
+ )
74
+
75
+
76
+ def connect(
77
+ base_url: str = "http://127.0.0.1:8080",
78
+ token: str | None = None,
79
+ ) -> PLCClient:
80
+ """Return a PLCClient for a gomc-rest server that is already running.
81
+
82
+ Use this to talk to a server started elsewhere — another machine, a shared
83
+ instance, or one you launched with ``server_mode=True`` — without spawning
84
+ the bundled binary. Supports the context-manager protocol::
85
+
86
+ with gomc_rest.connect("http://192.168.0.1:8080", token="...") as plc:
87
+ plc.read("D100", 3)
88
+
89
+ For the bundled, auto-started server use ``launch()`` instead.
90
+ """
91
+ return PLCClient(base_url, token=token)
92
+
93
+
94
+ __all__ = [
95
+ "launch",
96
+ "connect",
97
+ "Server",
98
+ "PLCClient",
99
+ "GomcRestBusyError",
100
+ "GomcRestPLCProtocolError",
101
+ "GomcRestUnauthorizedError",
102
+ "__version__",
103
+ ]
gomc_rest/_binaries.py ADDED
@@ -0,0 +1,70 @@
1
+ """Locate the bundled gomc-rest server binary for the current platform."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import stat
7
+ from pathlib import Path
8
+
9
+ _BINARIES_DIR = Path(__file__).parent / "binaries"
10
+
11
+ # (system, machine) -> bundled binary file name.
12
+ # Machine names are normalised to the keys below before lookup.
13
+ _BINARY_NAMES = {
14
+ ("windows", "amd64"): "gomc-rest.exe",
15
+ ("linux", "amd64"): "gomc-rest-linux-amd64",
16
+ ("linux", "arm64"): "gomc-rest-linux-arm64",
17
+ }
18
+
19
+ _MACHINE_ALIASES = {
20
+ "x86_64": "amd64",
21
+ "amd64": "amd64",
22
+ "aarch64": "arm64",
23
+ "arm64": "arm64",
24
+ }
25
+
26
+
27
+ def _normalise_platform() -> tuple[str, str]:
28
+ system = platform.system().lower()
29
+ machine = _MACHINE_ALIASES.get(platform.machine().lower(), platform.machine().lower())
30
+ return system, machine
31
+
32
+
33
+ def binary_path() -> Path:
34
+ """Return the path to the bundled server binary for this platform.
35
+
36
+ Raises RuntimeError if the platform is unsupported or the binary is missing
37
+ from the package (e.g. a sdist install without the platform wheel).
38
+ """
39
+ system, machine = _normalise_platform()
40
+ name = _BINARY_NAMES.get((system, machine))
41
+ if name is None:
42
+ raise RuntimeError(
43
+ f"Unsupported platform {system}/{machine}. "
44
+ f"gomc-rest ships binaries for: {sorted(_BINARY_NAMES)}."
45
+ )
46
+
47
+ path = _BINARIES_DIR / name
48
+ if not path.exists():
49
+ raise RuntimeError(
50
+ f"Bundled server binary '{name}' was not found at {path}. "
51
+ "Install the platform-specific wheel, or place the binary there "
52
+ "(see gomc_rest/binaries/README.md)."
53
+ )
54
+
55
+ # Wheels preserve the executable bit set at build time; only repair it when
56
+ # it is actually missing (e.g. an sdist/VCS checkout). Chmod'ing
57
+ # unconditionally breaks the common root-installs / non-root-runs setup,
58
+ # where the running user does not own an already-executable binary.
59
+ if system != "windows" and not path.stat().st_mode & stat.S_IXUSR:
60
+ try:
61
+ mode = path.stat().st_mode
62
+ path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
63
+ except PermissionError as exc:
64
+ raise RuntimeError(
65
+ f"Bundled binary {path} is not executable and its permissions "
66
+ "cannot be changed by this user. Reinstall the wheel or add the "
67
+ "executable bit."
68
+ ) from exc
69
+
70
+ return path
gomc_rest/_server.py ADDED
@@ -0,0 +1,137 @@
1
+ """Launch and manage the bundled gomc-rest server as a subprocess (Pattern B)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import os
7
+ import secrets
8
+ import socket
9
+ import subprocess
10
+ import time
11
+
12
+ from gomc_rest_client import PLCClient
13
+
14
+ from ._binaries import binary_path
15
+
16
+
17
+ def _free_port() -> int:
18
+ """Pick a currently-free TCP port on the loopback interface."""
19
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
20
+ sock.bind(("127.0.0.1", 0))
21
+ return sock.getsockname()[1]
22
+
23
+
24
+ class Server:
25
+ """Owns a gomc-rest subprocess and a PLCClient pointed at it.
26
+
27
+ Prefer the context-manager form::
28
+
29
+ with Server(plc_host="192.168.0.1") as plc:
30
+ plc.read("D100", 3)
31
+
32
+ When not used as a context manager the subprocess is still terminated at
33
+ interpreter exit via atexit.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ plc_host: str = "192.168.0.1",
39
+ plc_port: int = 5007,
40
+ server_mode: bool = False,
41
+ token: str | None = None,
42
+ extra_args: list[str] | None = None,
43
+ startup_timeout: float = 10.0,
44
+ ) -> None:
45
+ self._port = _free_port()
46
+ self.base_url = f"http://127.0.0.1:{self._port}"
47
+
48
+ # A per-launch random token is generated by default, so even another
49
+ # process on this host that learns the port cannot call the API without
50
+ # it. token is None -> generate; token == "" -> disable auth; otherwise
51
+ # use the given token.
52
+ if token is None:
53
+ self.token = secrets.token_urlsafe(24)
54
+ elif token == "":
55
+ self.token = None
56
+ else:
57
+ self.token = token
58
+
59
+ # Default: bind to loopback only, so no other host can reach the
60
+ # server. server_mode binds all interfaces so other apps on the
61
+ # network can call the REST API (give them self.token).
62
+ listen = f":{self._port}" if server_mode else f"127.0.0.1:{self._port}"
63
+ args = [
64
+ str(binary_path()),
65
+ "-listen", listen,
66
+ "-host", plc_host,
67
+ "-port", str(plc_port),
68
+ *(extra_args or []),
69
+ ]
70
+ # Pass the token via the environment, not argv, so it is not exposed in
71
+ # the process list / /proc/<pid>/cmdline. Set GOMCR_TOKEN explicitly
72
+ # (empty when disabled) so an inherited value can't enable auth on the
73
+ # server while the client stays unauthenticated.
74
+ env = os.environ.copy()
75
+ env["GOMCR_TOKEN"] = self.token or ""
76
+ self._proc = subprocess.Popen(args, env=env)
77
+ atexit.register(self.close)
78
+ self.client = PLCClient(self.base_url, token=self.token)
79
+ try:
80
+ self._wait_until_healthy(startup_timeout)
81
+ self._check_version()
82
+ except BaseException:
83
+ # Never leave the subprocess running if startup validation fails;
84
+ # the caller never receives a Server to close() itself.
85
+ self.close()
86
+ raise
87
+
88
+ def _wait_until_healthy(self, timeout: float) -> None:
89
+ deadline = time.monotonic() + timeout
90
+ while time.monotonic() < deadline:
91
+ if self._proc.poll() is not None:
92
+ raise RuntimeError(
93
+ f"gomc-rest exited during startup (code {self._proc.returncode})."
94
+ )
95
+ try:
96
+ self.client.health()
97
+ return
98
+ except Exception:
99
+ time.sleep(0.1)
100
+ raise TimeoutError(f"gomc-rest did not become healthy within {timeout}s.")
101
+
102
+ def _check_version(self) -> None:
103
+ if not self.client.is_supported_version():
104
+ raise RuntimeError(
105
+ f"Bundled gomc-rest {self.client.version()} is not supported by "
106
+ "the installed gomc-rest-client. Update the bundled binary."
107
+ )
108
+
109
+ def close(self) -> None:
110
+ """Release the client session and terminate the subprocess.
111
+
112
+ Safe to call more than once.
113
+ """
114
+ atexit.unregister(self.close)
115
+
116
+ client = getattr(self, "client", None)
117
+ if client is not None:
118
+ try:
119
+ client.__exit__(None, None, None)
120
+ except Exception:
121
+ pass
122
+
123
+ proc = getattr(self, "_proc", None)
124
+ if proc is None or proc.poll() is not None:
125
+ return
126
+ proc.terminate()
127
+ try:
128
+ proc.wait(timeout=5)
129
+ except subprocess.TimeoutExpired:
130
+ proc.kill()
131
+ proc.wait()
132
+
133
+ def __enter__(self) -> PLCClient:
134
+ return self.client
135
+
136
+ def __exit__(self, *exc) -> None:
137
+ self.close()
@@ -0,0 +1,4 @@
1
+ # Vendored at build time by scripts/vendor_binaries.py — never commit binaries.
2
+ *
3
+ !.gitignore
4
+ !README.md
@@ -0,0 +1,15 @@
1
+ # Bundled server binaries
2
+
3
+ This directory holds the platform-specific `gomc-rest` server binaries that the
4
+ Python package auto-launches (Pattern B):
5
+
6
+ | Platform | File name |
7
+ | --------------- | ------------------------ |
8
+ | Windows (amd64) | `gomc-rest.exe` |
9
+ | Linux (amd64) | `gomc-rest-linux-amd64` |
10
+ | Linux (arm64) | `gomc-rest-linux-arm64` |
11
+
12
+ The binaries are **not committed to git**. They are vendored in at release time
13
+ from a pinned [gomc-rest](https://github.com/Moge800/gomc-rest) version, and each
14
+ platform wheel ships only its matching binary. `_binaries.py` resolves the
15
+ correct file for the current platform at runtime.
Binary file
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: gomc-rest
3
+ Version: 0.1.0
4
+ Summary: Talk to Mitsubishi PLCs from Python over the MC protocol via a bundled, auto-launched gomc-rest server — with optional REST API exposure.
5
+ Project-URL: Homepage, https://github.com/Moge800/gomc_rest_python
6
+ Project-URL: Upstream server, https://github.com/Moge800/gomc-rest
7
+ Project-URL: HTTP client, https://github.com/Moge800/gomc_rest_client
8
+ Author: Moge800
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: gomc-rest,mc-protocol,mitsubishi,plc,slmp
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: gomc-rest-client<0.11,>=0.10.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ # gomc-rest (Python)
17
+
18
+ English / [日本語](https://github.com/Moge800/gomc_rest_python/blob/main/README_JP.md)
19
+
20
+ **A Python library for talking to Mitsubishi PLCs.** Read and write PLC devices
21
+ over the MC protocol (3E/4E frames) — the protocol is handled for you by a bundled
22
+ [gomc-rest](https://github.com/Moge800/gomc-rest) server that the package
23
+ auto-launches, so you never have to start or distribute an executable yourself.
24
+
25
+ **Bonus — expose a REST API.** Enable `server_mode` to make the bundled
26
+ server's REST API reachable from other apps on your network (a GUI, another
27
+ machine, another language); and `connect()` talks to a gomc-rest server that is
28
+ already running elsewhere. See [Access control](#access-control).
29
+
30
+ Under the hood the HTTP layer is provided by
31
+ [gomc-rest-client](https://github.com/Moge800/gomc_rest_client); this package
32
+ adds the bundled binary and its process lifecycle.
33
+
34
+ ```text
35
+ your Python process
36
+ └─ gomc_rest.launch()
37
+ ├─ spawns the bundled gomc-rest on a free loopback port ── MC protocol ──▶ PLC
38
+ └─ returns a Server that provides a PLCClient pointed at it
39
+ ```
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install gomc-rest
45
+ ```
46
+
47
+ ## Usage
48
+
49
+ ```python
50
+ import gomc_rest
51
+
52
+ with gomc_rest.launch(plc_host="192.168.0.1") as plc:
53
+ values = plc.read("D100", 3)
54
+ plc.write("D100", [10, 20, 30])
55
+ # the bundled server is stopped automatically on exit
56
+ ```
57
+
58
+ `launch()` returns a `Server`; using it as a context manager yields a
59
+ `PLCClient` (see gomc-rest-client for the full read/write/remote API) and stops
60
+ the server on exit. Without `with`, the server is stopped at interpreter exit.
61
+
62
+ Pass server flags through `extra_args`:
63
+
64
+ ```python
65
+ with gomc_rest.launch(plc_host="192.168.0.1", extra_args=["-enable-remote"]) as plc:
66
+ plc.remote_run()
67
+ ```
68
+
69
+ ### Client mode (connect to an existing server)
70
+
71
+ To talk to a gomc-rest server that is already running elsewhere — a shared
72
+ instance, another machine, or one you launched with `server_mode=True` — use
73
+ `connect()` instead of starting the bundled binary:
74
+
75
+ ```python
76
+ with gomc_rest.connect("http://192.168.0.1:8080", token="...") as plc:
77
+ plc.read("D100", 3)
78
+ ```
79
+
80
+ Both `launch()` and `connect()` hand you the same `PLCClient`, so one package
81
+ covers "bundle and run the server" and "just be a client".
82
+
83
+ `connect()` needs no bundled binary, so it works even on platforms without a
84
+ prebuilt wheel (macOS, Windows arm64, glibc < 2.34): there `pip install
85
+ gomc-rest` installs from the sdist, and only `launch()` is unavailable (it
86
+ raises a clear error).
87
+
88
+ ## Access control
89
+
90
+ Two independent layers protect the server, both on by default:
91
+
92
+ 1. **Per-launch bearer token.** A random token is generated each launch and
93
+ required by the server, so even another process on the same host that
94
+ discovers the port cannot call the API. It is set automatically on the
95
+ returned client and exposed as `server.token`. Pass an explicit `token=` to
96
+ share with another app, or `token=""` to disable auth (closed-network use).
97
+ 2. **Loopback binding.** By default the server binds to `127.0.0.1`, so no
98
+ other host can reach it.
99
+
100
+ Set `server_mode=True` to bind all interfaces so other apps on the network
101
+ (e.g. gomc-rest-gui, curl from another machine) can call it — give them
102
+ `server.token`:
103
+
104
+ ```python
105
+ server = gomc_rest.launch(plc_host="192.168.0.1", server_mode=True)
106
+ print(server.base_url) # other apps connect to http://<this-host>:<port>
107
+ print(server.token) # ...with this bearer token
108
+ try:
109
+ server.client.read("D100", 3)
110
+ finally:
111
+ server.close()
112
+ ```
113
+
114
+ The server has no TLS — only enable `server_mode` on a trusted network.
115
+
116
+ **Threat model.** The token is passed to the server via the `GOMCR_TOKEN`
117
+ environment variable (not the command line), so it does not appear in the
118
+ process list. It protects against other hosts and other OS users. It does
119
+ **not** protect against another process running as the **same OS user**, which
120
+ can read the server's environment (e.g. `/proc/<pid>/environ`); the OS user
121
+ boundary is the trust boundary here.
122
+
123
+ ## Versions
124
+
125
+ This package bundles a pinned `gomc-rest` binary (currently **v1.4.0**, set in
126
+ `GOMC_REST_VERSION`) that must satisfy `gomc-rest-client`'s
127
+ `MINIMUM_SUPPORTED_GOMC_REST_VERSION`; `launch()` verifies this on startup. The
128
+ `gomc-rest-client` dependency is capped (`>=0.10.0,<0.11`) so a future client
129
+ that raises its minimum server version can't be installed without also bumping
130
+ the bundled binary.
131
+
132
+ ## Releasing / bundled binaries
133
+
134
+ The bundled server version is pinned in `GOMC_REST_VERSION`. Binaries are not
135
+ committed to git; they are fetched from the matching gomc-rest GitHub release.
136
+
137
+ - Locally: `python scripts/vendor_binaries.py` downloads all three binaries
138
+ into `src/gomc_rest/binaries/`, verifying each against the trusted SHA-256
139
+ values committed in `checksums/<version>.sha256`.
140
+ - On a `v*` tag, `.github/workflows/release.yml` builds one platform-specific
141
+ wheel per OS (each bundling only its matching binary) and publishes to PyPI
142
+ via trusted publishing. The release job verifies the tag equals
143
+ `project.version`; `workflow_dispatch` builds wheels for verification only and
144
+ never publishes.
145
+
146
+ To cut a release:
147
+
148
+ 1. To change the bundled server, edit `GOMC_REST_VERSION` (keep it within the
149
+ range accepted by the pinned `gomc-rest-client`) and add a matching
150
+ `checksums/<version>.sha256` with the trusted SHA-256 of each asset. If the
151
+ new binaries change their glibc requirement, update the `plat` tags in
152
+ `release.yml` accordingly.
153
+ 2. Bump the package version in **both** `pyproject.toml` (`project.version`)
154
+ and `src/gomc_rest/__init__.py` (`__version__`) — they must match.
155
+ 3. Tag that exact version, e.g. `git tag v0.2.0 && git push origin v0.2.0`
156
+ (the tag must equal `project.version` or the release job fails).
@@ -0,0 +1,10 @@
1
+ gomc_rest/__init__.py,sha256=-sTEjHSEuOnept3GO28dl3-PoqLxSECbKa5lsferUl4,3252
2
+ gomc_rest/_binaries.py,sha256=wYhp6VcO5VfmztM2oIZWIVF_QHL0ADixkpCpuPwQ4qk,2521
3
+ gomc_rest/_server.py,sha256=GztYu0DVn3lqSJhjH9-8EkOfpMzJbeumVgtBA3SWx9M,4748
4
+ gomc_rest/binaries/.gitignore,sha256=iLgtA0Ah3vJKRQIDAoelvPL2Msv251d-qTqdFAt6dyo,111
5
+ gomc_rest/binaries/README.md,sha256=pfAsf-Bwa5_NOitEvLKXfp2ROKgQRjmlc35bnyM_1N4,688
6
+ gomc_rest/binaries/gomc-rest.exe,sha256=TTIzwuSsgwQDWOLvJD2GcGy99hQfkvi2Jw-PMrdKEeU,11051520
7
+ gomc_rest-0.1.0.dist-info/METADATA,sha256=R1w5UHJfQ8ury7TqA39QwxLm8_elUc9XvYYBh2TN-c0,6439
8
+ gomc_rest-0.1.0.dist-info/WHEEL,sha256=pp_iTzO0EBcCvlZZV2dJYp6L2tdDWBsn1ceo50wEt8Q,94
9
+ gomc_rest-0.1.0.dist-info/licenses/LICENSE,sha256=z2FbQ_Nsco0Ai59dmX5-WcROjekvhYSkiud0M7Q00Kg,1085
10
+ gomc_rest-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-win_amd64
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Moge800
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.