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
82 changes: 76 additions & 6 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,78 @@ name: Python Bindings CI

on:
push:
branches:
- main
tags:
- 'python-v*'
paths:
- 'python/**'
- 'src/**'
- 'crates/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.github/workflows/python.yml'
pull_request:
branches:
- main
paths:
- 'python/**'
- 'src/**'
- 'crates/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.github/workflows/python.yml'
workflow_dispatch:

permissions:
contents: read

jobs:
test:
name: Test Python API
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./python
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Validate package versions and release tag
shell: bash
run: |
python - <<'PY'
import os
import tomllib
from pathlib import Path

cargo_version = tomllib.loads(Path("Cargo.toml").read_text())["package"]["version"]
project_version = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]
if cargo_version != project_version:
raise SystemExit(
f"Cargo.toml ({cargo_version}) and pyproject.toml ({project_version}) versions differ"
)

if os.environ.get("GITHUB_REF_TYPE") == "tag":
expected_tag = f"python-v{project_version}"
actual_tag = os.environ["GITHUB_REF_NAME"]
if actual_tag != expected_tag:
raise SystemExit(f"release tag {actual_tag!r} must be {expected_tag!r}")
PY
- name: Build test wheel
uses: PyO3/maturin-action@v1
with:
args: --release --out dist --find-interpreter
working-directory: ./python
- name: Install test wheel
run: python -m pip install --force-reinstall dist/*.whl
- name: Run Python API tests
run: python -m unittest discover -s tests -v

linux:
needs: test
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/python-v')
runs-on: ${{ matrix.platform.runner }}
defaults:
run:
Expand Down Expand Up @@ -50,6 +113,8 @@ jobs:
path: ./python/dist

musllinux:
needs: test
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/python-v')
runs-on: ${{ matrix.platform.runner }}
defaults:
run:
Expand Down Expand Up @@ -85,6 +150,8 @@ jobs:
path: ./python/dist

windows:
needs: test
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/python-v')
runs-on: ${{ matrix.platform.runner }}
defaults:
run:
Expand Down Expand Up @@ -116,6 +183,8 @@ jobs:
path: ./python/dist

macos:
needs: test
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/python-v')
runs-on: ${{ matrix.platform.runner }}
defaults:
run:
Expand Down Expand Up @@ -146,6 +215,8 @@ jobs:
path: ./python/dist

sdist:
needs: test
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/python-v')
runs-on: ubuntu-latest
defaults:
run:
Expand All @@ -167,19 +238,18 @@ jobs:
release:
name: Release
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./python
if: startsWith(github.ref, 'refs/tags/python-v')
needs: [linux, musllinux, windows, macos, sdist]
steps:
- uses: actions/download-artifact@v4
with:
path: ./python
path: ./python/dist
merge-multiple: true
- name: Publish to PyPI
uses: PyO3/maturin-action@v1
env:
MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
with:
command: upload
args: --non-interactive --skip-existing wheels-*/*
working-directory: ./python
args: --non-interactive --skip-existing dist/*
working-directory: ./python
3 changes: 3 additions & 0 deletions python/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
/target
/dist
__pycache__/
*.py[cod]
7 changes: 4 additions & 3 deletions python/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ description = "A Python binding for the M-Bus parser"
license = "MIT"

[dependencies]
m-bus-parser = { path = "..", version = "0.1.3", features = ["std", "serde"] }
m-bus-parser = { path = "..", version = "0.1.3", features = ["std", "serde", "decryption"] }
serde_json = "1.0"
pyo3 = { version = "0.29.0", features = ["extension-module","generate-import-lib"] }
hex = "0.4.2"
pyo3 = { version = "0.29.0", features = ["extension-module", "generate-import-lib"] }
hex = "0.4"

[lib]
name = "pymbusparser"
crate-type = ["cdylib"]

[package.metadata.release]
tag-name = "python-v{{version}}"
90 changes: 68 additions & 22 deletions python/README_python.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,82 @@
# Python bindings
# pymbusparser

Rust lib aims to be accessible from a Python module. This is done by using the `PyO3` crate.
See the rust project for more documentation on the Rust side of things [on github](https://github.com/maebli/m-bus-parser).
Python bindings for the Rust [`m-bus-parser`](https://github.com/maebli/m-bus-parser)
library. The package parses wired and wireless M-Bus frames and supports AES
decryption for the security modes implemented by the Rust library.

## Development
## Installation

```console
python -m pip install pymbusparser
```

- `pipx install maturin` to install `maturin` globally.
- `maturin develop` to build the Rust lib and create a Python module in the current environment.
- Currently this creates a release in the target directory that is one hierachy up. This is not ideal and will be fixed in the future.
- after calling the maturin develop command cd one up and `pip install target/..` and then run `python` and `from pymbusparser import pymbus` to test the module.
- to test inside `REPL` run `python` and then `import p
## API

## Publishing
`parse` is the main entry point. It accepts either hexadecimal text or raw bytes
and returns ordinary Python dictionaries and lists:

- `maturin publish`
- username = __token__
- passwrod = api key from pypi
```python
from pymbusparser import parse

## Usage
telegram = """
68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00
04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B
00 00 00 0A 5A 88 12 0A 5E 16 05 0B 61 23 77 00 02 6C
8C 11 02 27 37 0D 0F 60 00 67 16
"""

`pip install pymbusparser`
frame = parse(telegram)
print(frame["frame"])
print(frame.get("data_records"))
```

Use `parse_records` when the input contains application-layer records without a
link-layer frame:

```python
from pymbusparser import m_bus_parse,parse_application_layer
from pymbusparser import parse_records

records = parse_records("2f2f0413fce0f5052f2f2f2f2f2f2f2f")
```

Use `render` for text-oriented output. Supported formats are `json`, `yaml`,
`csv`, `table`, `mermaid`, `annotated`, `annotated-text`, and `hexview`:

```python
from pymbusparser import render

print(render(telegram, "table"))
```

print(m_bus_parse("68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00 04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B 00 00 00 0A 5A 88 12 0A 5E 16 05 0B 61 23 77 00 02 6C 8C 11 02 27 37 0D 0F 60 00 67 16","table"))
## Decryption

print(m_bus_parse("68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00 04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B 00 00 00 0A 5A 88 12 0A 5E 16 05 0B 61 23 77 00 02 6C 8C 11 02 27 37 0D 0F 60 00 67 16","json"))
The wheel is built with decryption support. Pass a 16-byte AES key as bytes or
as a 32-digit hexadecimal string:

```python
from pymbusparser import parse

decoded = parse(encrypted_telegram, key=bytes.fromhex("00112233445566778899aabbccddeeff"))
```

Malformed frames, keys, and output formats raise `ValueError`; unsupported input
types raise `TypeError`.

## Compatibility

`m_bus_parse` and `parse_application_layer` remain available for callers that
expect serialized strings. New code should use `parse`, `parse_records`, and
`render`.

## Development

print(m_bus_parse("68 3D 3D 68 08 01 72 00 51 20 02 82 4D 02 04 00 88 00 00 04 07 00 00 00 00 0C 15 03 00 00 00 0B 2E 00 00 00 0B 3B 00 00 00 0A 5A 88 12 0A 5E )16 05 0B 61 23 77 00 02 6C 8C 11 02 27 37 0D 0F 60 00 67 16","yml"))
From this directory:

# note this is not as pretty as the function before, still TODO, currently it just outputs structs of RUST in string
print(parse_application_layer("2f2f0413fce0f5052f2f2f2f2f2f2f2f"))
```console
python -m pip install maturin
maturin develop
python -m unittest discover -s tests -v
```

```
Release artifacts are built and published by the `Python Bindings CI` workflow
when a matching `python-v*` tag is pushed.
Empty file added python/py.typed
Empty file.
29 changes: 29 additions & 0 deletions python/pymbusparser.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from typing import Any, Dict, List, Literal, Optional, Union

DataInput = Union[str, bytes, bytearray]
KeyInput = Union[str, bytes, bytearray]
OutputFormat = Literal[
"json",
"yaml",
"yml",
"csv",
"table",
"mermaid",
"annotated",
"annotated-text",
"hexview",
]

__version__: str
__all__: List[str]

def parse(data: DataInput, *, key: Optional[KeyInput] = None) -> Dict[str, Any]: ...
def parse_records(data: DataInput) -> List[Any]: ...
def render(
data: DataInput,
format: OutputFormat = "json",
*,
key: Optional[KeyInput] = None,
) -> str: ...
def parse_application_layer(data_record: str) -> str: ...
def m_bus_parse(data: str, format: OutputFormat, key: Optional[str] = None) -> str: ...
13 changes: 13 additions & 0 deletions python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ description = "Python bindings for m-bus-parser written in Rust"
authors = [{name = "Michael Aebli"}]
license = { text = "MIT" }
readme = "README_python.md"
requires-python = ">=3.9"
keywords = ["m-bus", "wmbus", "parser", "metering"]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python :: 3",
"Programming Language :: Rust",
"Typing :: Typed",
]

[project.urls]
Homepage = "https://maebli.github.io/"
Repository = "https://github.com/maebli/m-bus-parser"
Issues = "https://github.com/maebli/m-bus-parser/issues"


[build-system]
Expand Down
Loading
Loading