From 341bd8dd3dd3db2d182823b6d5ecd326c539f754 Mon Sep 17 00:00:00 2001 From: lucamacavero Date: Sat, 25 Apr 2026 14:58:11 +0200 Subject: [PATCH 1/3] feat! for a given structure, now the __getitem__ and __getattr__ return the value of the object and not the object itself --- .github/workflows/python-package.yml | 12 +- .github/workflows/release.yml | 102 ++++++ .semversioner/0.6.0.json | 10 + packed_struct/__init__.py | 1 + packed_struct/types.py | 11 +- pyproject.toml | 29 ++ setup.py | 28 -- tests/tests.py | 476 ++++++++++++++++++++++++++- 8 files changed, 634 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .semversioner/0.6.0.json create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 0b9cf0b..ba35fe5 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -31,6 +31,8 @@ jobs: - '3.9' - '3.10' - '3.11' + - '3.12' + - '3.13' steps: - uses: actions/checkout@v3 @@ -40,6 +42,12 @@ jobs: - name: Install build tools run: | python -m pip install --upgrade pip - python -m pip install --upgrade setuptools tox-gh-actions wheel + python -m pip install --upgrade setuptools wheel + python -m pip install --upgrade semversioner + - name: Install dependencies + run: | + python -m pip install -e . - name: Run tests - run: tox -vv + run: | + python tests/tests.py + \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6fb379a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,102 @@ +name: Release Version + +on: + push: + branches: + - main + +jobs: + release: + # Skip se il commit contiene [skip ci] + if: "!contains(github.event.head_commit.message, '[skip ci]')" + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install semversioner + run: pip install semversioner + + - name: Determine version bump type + id: version_type + run: | + MESSAGE=$(git log -1 --pretty=%s) + if [[ "$MESSAGE" =~ ^feat!: ]]; then + echo "type=major" >> $GITHUB_OUTPUT + echo "should_release=true" >> $GITHUB_OUTPUT + elif [[ "$MESSAGE" =~ ^feat: ]]; then + echo "type=minor" >> $GITHUB_OUTPUT + echo "should_release=true" >> $GITHUB_OUTPUT + elif [[ "$MESSAGE" =~ ^fix: ]]; then + echo "type=patch" >> $GITHUB_OUTPUT + echo "should_release=true" >> $GITHUB_OUTPUT + else + echo "should_release=false" >> $GITHUB_OUTPUT + echo "Commit message does not follow conventional commits (feat:, feat!:, fix:)" + fi + + - name: Add version change + if: steps.version_type.outputs.should_release == 'true' + run: | + semversioner add-change --type ${{ steps.version_type.outputs.type }} -d "version bump" + + - name: Get next version + if: steps.version_type.outputs.should_release == 'true' + id: next_version + run: | + TAG=$(semversioner next-version) + echo "tag=${TAG}" >> $GITHUB_OUTPUT + echo "Next version will be: ${TAG}" + + - name: Release version with semversioner + if: steps.version_type.outputs.should_release == 'true' + run: semversioner release + + - name: Update __init__.py with new version + if: steps.version_type.outputs.should_release == 'true' + run: | + TAG=${{ steps.next_version.outputs.tag }} + sed -i "s/__version__.*/__version__ = \"${TAG}\"/" packed_struct/__init__.py + echo "Updated packed_struct/__init__.py with version ${TAG}" + + - name: Commit and push changes + if: steps.version_type.outputs.should_release == 'true' + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git add .semversioner packed_struct/__init__.py + git commit -m "Release version ${{ steps.next_version.outputs.tag }} [skip ci]" + git push origin ${{ github.ref_name }} + + - name: Create and push tag + if: steps.version_type.outputs.should_release == 'true' + run: | + TAG=${{ steps.next_version.outputs.tag }} + git tag -a "${TAG}" -m "Release ${TAG}" + git push origin "${TAG}" + + - name: Create GitHub Release + if: steps.version_type.outputs.should_release == 'true' + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.next_version.outputs.tag }} + release_name: Release ${{ steps.next_version.outputs.tag }} + body: | + Release version ${{ steps.next_version.outputs.tag }} + + Commit: ${{ github.event.head_commit.message }} + draft: false + prerelease: false diff --git a/.semversioner/0.6.0.json b/.semversioner/0.6.0.json new file mode 100644 index 0000000..d31048c --- /dev/null +++ b/.semversioner/0.6.0.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "description": "version bump", + "type": "patch" + } + ], + "created_at": "2026-04-14T12:31:46+00:00", + "version": "0.6.0" +} \ No newline at end of file diff --git a/packed_struct/__init__.py b/packed_struct/__init__.py index e4c869a..d28592b 100644 --- a/packed_struct/__init__.py +++ b/packed_struct/__init__.py @@ -1 +1,2 @@ from .types import * +__version__ = "0.7.0" \ No newline at end of file diff --git a/packed_struct/types.py b/packed_struct/types.py index 7a3b06a..e796562 100644 --- a/packed_struct/types.py +++ b/packed_struct/types.py @@ -219,13 +219,16 @@ def __init__(self, data_dict: dict) -> None: self._fmt = None def __getitem__(self, data): - return self._data[data] + try: + return self._data[data].value + except KeyError: + raise KeyError(f"Data {data} not found in struct") def __getattr__(self, data): try: - return self._data[data] - except: - raise AttributeError + return self._data[data].value + except KeyError: + raise AttributeError(f"Data {data} not found in struct") def __repr__(self) -> str: representation = {} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..707c981 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel", "bitstruct"] +build-backend = "setuptools.build_meta" + +[project] +name = "py-packed-struct" +dynamic = ["version"] +description = "An implementation of C-like packed structures in Python" +readme = "README.md" +requires-python = ">=3.5" +authors = [ + { name = "Luca Macavero", email = "luca.macavero@gmail.com" } +] +maintainers = [ + { name = "Luca Macavero", email = "luca.macavero@gmail.com" } +] +dependencies = [ + "bitstruct" +] + +[project.urls] +Homepage = "https://github.com/lu-maca/py-packed-struct" + +[tool.setuptools] +packages = { find = {} } +include-package-data = true + +[tool.setuptools.dynamic] +version = {attr = "packed_struct.__version__"} diff --git a/setup.py b/setup.py deleted file mode 100644 index 082b6f0..0000000 --- a/setup.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -from pathlib import Path -from setuptools import setup, find_packages - -this_directory = Path(__file__).parent -long_description = (this_directory / "README.md").read_text() - - -required_packages = ["bitstruct"] -setup( - name="py-packed-struct", - version=0.6, - author="Luca Macavero", - author_email="luca.macavero@gmail.com", - maintainer="Luca Macavero", - maintainer_email="luca.macavero@gmail.com", - url="https://github.com/lu-maca/py-packed-struct", - description="An implementation of C-like packed structures in Python", - long_description=long_description, - long_description_content_type="text/markdown", - python_requires=">=3.5", - packages=find_packages(), - setup_requires=["wheel"] + required_packages, - install_requires=required_packages, - include_package_data=True, -) diff --git a/tests/tests.py b/tests/tests.py index 30dc0c2..93be933 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -4,7 +4,7 @@ from packed_struct import * -class TypesTest: +class UnsignedIntTest: @test def test_unsigned_int_correct(): @@ -53,6 +53,480 @@ def test_unsigned_int_null_bits(): ) +class SignedIntTest: + + @test + def test_signed_int_correct(): + # correct case + bits = 8 + data = c_signed_int(bits) + + non_blocking_assert(data.fmt == f"s{bits}", f"signed int format not correct, expected s{bits}, current {data.fmt}") + non_blocking_assert(data.size == bits, f"signed int size not correct, expected {bits}, current {data.size}") + + @test + def test_signed_int_negative_bits(): + # wrong case + bits = -4 + try: + data = c_signed_int(bits) + raise TestException() + except Exception as e: + if isinstance(e, TestException): + non_blocking_assert(False, "signed int allows for negative integer size") + else: + non_blocking_assert( + str(e) == "Number of bits shall be a positive integer", + f"wrong exception (expected: Number of bits shall be a positive integer, current {str(e)})", + ) + + @test + def test_signed_int_null_bits(): + # wrong case + bits = 0 + try: + data = c_signed_int(bits) + raise TestException() + except Exception as e: + if isinstance(e, TestException): + non_blocking_assert(False, "signed int allows for 0 size") + else: + non_blocking_assert( + str(e) == "Number of bits shall be a positive integer", + f"wrong exception (expected: Number of bits shall be a positive integer, current {str(e)})", + ) + + +class FloatTest: + + @test + def test_float_16_bits(): + bits = 16 + data = c_float(bits) + non_blocking_assert(data.fmt == f"f{bits}", f"float format not correct, expected f{bits}, current {data.fmt}") + non_blocking_assert(data.size == bits, f"float size not correct, expected {bits}, current {data.size}") + + @test + def test_float_32_bits(): + bits = 32 + data = c_float(bits) + non_blocking_assert(data.fmt == f"f{bits}", f"float format not correct, expected f{bits}, current {data.fmt}") + non_blocking_assert(data.size == bits, f"float size not correct, expected {bits}, current {data.size}") + + @test + def test_float_64_bits(): + bits = 64 + data = c_float(bits) + non_blocking_assert(data.fmt == f"f{bits}", f"float format not correct, expected f{bits}, current {data.fmt}") + non_blocking_assert(data.size == bits, f"float size not correct, expected {bits}, current {data.size}") + + @test + def test_float_invalid_bits(): + # Test with 8 bits (invalid) + bits = 8 + try: + data = c_float(bits) + raise TestException() + except Exception as e: + if isinstance(e, TestException): + non_blocking_assert(False, "float allows for 8 bit size") + else: + expected_msg = f"Float must be of 16, 32 or 64 bits (requested: {bits}). See https://bitstruct.readthedocs.io/en/latest/#performance." + non_blocking_assert( + str(e) == expected_msg, + f"wrong exception (expected: {expected_msg}, current {str(e)})", + ) + + @test + def test_float_invalid_24_bits(): + # Test with 24 bits (invalid) + bits = 24 + try: + data = c_float(bits) + raise TestException() + except Exception as e: + if isinstance(e, TestException): + non_blocking_assert(False, "float allows for 24 bit size") + else: + expected_msg = f"Float must be of 16, 32 or 64 bits (requested: {bits}). See https://bitstruct.readthedocs.io/en/latest/#performance." + non_blocking_assert( + str(e) == expected_msg, + f"wrong exception (expected: {expected_msg}, current {str(e)})", + ) + + +class BoolTest: + + @test + def test_bool_correct(): + bits = 1 + data = c_bool(bits) + non_blocking_assert(data.fmt == f"b{bits}", f"bool format not correct, expected b{bits}, current {data.fmt}") + non_blocking_assert(data.size == bits, f"bool size not correct, expected {bits}, current {data.size}") + + @test + def test_bool_multiple_bits(): + bits = 4 + data = c_bool(bits) + non_blocking_assert(data.fmt == f"b{bits}", f"bool format not correct, expected b{bits}, current {data.fmt}") + non_blocking_assert(data.size == bits, f"bool size not correct, expected {bits}, current {data.size}") + + @test + def test_bool_negative_bits(): + bits = -1 + try: + data = c_bool(bits) + raise TestException() + except Exception as e: + if isinstance(e, TestException): + non_blocking_assert(False, "bool allows for negative integer size") + else: + non_blocking_assert( + str(e) == "Number of bits shall be a positive integer", + f"wrong exception (expected: Number of bits shall be a positive integer, current {str(e)})", + ) + + @test + def test_bool_null_bits(): + bits = 0 + try: + data = c_bool(bits) + raise TestException() + except Exception as e: + if isinstance(e, TestException): + non_blocking_assert(False, "bool allows for 0 size") + else: + non_blocking_assert( + str(e) == "Number of bits shall be a positive integer", + f"wrong exception (expected: Number of bits shall be a positive integer, current {str(e)})", + ) + + +class CharTest: + + @test + def test_char_correct(): + bits = 8 + data = c_char(bits) + non_blocking_assert(data.fmt == f"t{bits}", f"char format not correct, expected t{bits}, current {data.fmt}") + non_blocking_assert(data.size == bits, f"char size not correct, expected {bits}, current {data.size}") + + @test + def test_char_multiple_bytes(): + bits = 80 # 10 chars + data = c_char(bits) + non_blocking_assert(data.fmt == f"t{bits}", f"char format not correct, expected t{bits}, current {data.fmt}") + non_blocking_assert(data.size == bits, f"char size not correct, expected {bits}, current {data.size}") + + @test + def test_char_not_multiple_of_8(): + bits = 7 + try: + data = c_char(bits) + # UserWarning doesn't raise, so we check if warning is issued + # This is a design issue in the original code + non_blocking_assert(data.fmt == f"t{bits}", "char was created despite not being multiple of 8") + except UserWarning as e: + expected_msg = "char must be contained in multiples of 8 bits (see https://bitstruct.readthedocs.io/en/latest/#performance)" + non_blocking_assert( + str(e) == expected_msg, + f"wrong warning (expected: {expected_msg}, current {str(e)})", + ) + + @test + def test_char_negative_bits(): + bits = -8 + try: + data = c_char(bits) + raise TestException() + except Exception as e: + if isinstance(e, TestException): + non_blocking_assert(False, "char allows for negative integer size") + else: + non_blocking_assert( + str(e) == "Number of bits shall be a positive integer", + f"wrong exception (expected: Number of bits shall be a positive integer, current {str(e)})", + ) + + +class RawBytesTest: + + @test + def test_raw_bytes_correct(): + bits = 8 + data = c_raw_bytes(bits) + non_blocking_assert(data.fmt == f"r{bits}", f"raw bytes format not correct, expected r{bits}, current {data.fmt}") + non_blocking_assert(data.size == bits, f"raw bytes size not correct, expected {bits}, current {data.size}") + + @test + def test_raw_bytes_multiple(): + bits = 64 + data = c_raw_bytes(bits) + non_blocking_assert(data.fmt == f"r{bits}", f"raw bytes format not correct, expected r{bits}, current {data.fmt}") + non_blocking_assert(data.size == bits, f"raw bytes size not correct, expected {bits}, current {data.size}") + + @test + def test_raw_bytes_negative_bits(): + bits = -8 + try: + data = c_raw_bytes(bits) + raise TestException() + except Exception as e: + if isinstance(e, TestException): + non_blocking_assert(False, "raw bytes allows for negative integer size") + else: + non_blocking_assert( + str(e) == "Number of bits shall be a positive integer", + f"wrong exception (expected: Number of bits shall be a positive integer, current {str(e)})", + ) + + @test + def test_raw_bytes_null_bits(): + bits = 0 + try: + data = c_raw_bytes(bits) + raise TestException() + except Exception as e: + if isinstance(e, TestException): + non_blocking_assert(False, "raw bytes allows for 0 size") + else: + non_blocking_assert( + str(e) == "Number of bits shall be a positive integer", + f"wrong exception (expected: Number of bits shall be a positive integer, current {str(e)})", + ) + + +class PaddingTest: + + @test + def test_padding_correct(): + bits = 8 + data = c_padding(bits) + non_blocking_assert(data.fmt == f"u{bits}", f"padding format not correct, expected u{bits}, current {data.fmt}") + non_blocking_assert(data.size == bits, f"padding size not correct, expected {bits}, current {data.size}") + non_blocking_assert(data.value == 0, f"padding value not 0, current {data.value}") + + @test + def test_padding_value_is_zero(): + bits = 16 + data = c_padding(bits) + non_blocking_assert(data.value == 0, f"padding value should be 0, current {data.value}") + + @test + def test_padding_negative_bits(): + bits = -4 + try: + data = c_padding(bits) + raise TestException() + except Exception as e: + if isinstance(e, TestException): + non_blocking_assert(False, "padding allows for negative integer size") + else: + non_blocking_assert( + str(e) == "Number of bits shall be a positive integer", + f"wrong exception (expected: Number of bits shall be a positive integer, current {str(e)})", + ) + + +class ArrayTest: + + @test + def test_array_correct(): + type_size = 8 + array_size = 5 + data = c_array(c_unsigned_int, type_size, array_size) + + expected_size = type_size * array_size + non_blocking_assert(data.size == expected_size, f"array size not correct, expected {expected_size}, current {data.size}") + non_blocking_assert(len(data.value) == array_size, f"array length not correct, expected {array_size}, current {len(data.value)}") + + @test + def test_array_format(): + type_size = 8 + array_size = 3 + data = c_array(c_unsigned_int, type_size, array_size) + + expected_fmt = "u8u8u8" + non_blocking_assert(data.fmt == expected_fmt, f"array format not correct, expected {expected_fmt}, current {data.fmt}") + + @test + def test_array_set_value(): + type_size = 8 + array_size = 3 + data = c_array(c_unsigned_int, type_size, array_size) + + values = [1, 2, 3] + data.set_value(values) + + for i, val in enumerate(values): + non_blocking_assert(data[i].value == val, f"array[{i}] value not set correctly, expected {val}, current {data[i].value}") + + @test + def test_array_negative_size(): + try: + data = c_array(c_unsigned_int, 8, -1) + raise TestException() + except UserWarning as e: + expected_msg = "array_size must be positive" + non_blocking_assert( + str(e) == expected_msg, + f"wrong warning (expected: {expected_msg}, current {str(e)})", + ) + except Exception as e: + if isinstance(e, TestException): + non_blocking_assert(False, "array allows for negative array_size") + + @test + def test_array_not_multiple_of_8(): + try: + data = c_array(c_unsigned_int, 7, 3) + raise TestException() + except UserWarning as e: + expected_msg = "type_size_bits must be a multiples of 8 bits" + non_blocking_assert( + str(e) == expected_msg, + f"wrong warning (expected: {expected_msg}, current {str(e)})", + ) + except Exception as e: + if isinstance(e, TestException): + non_blocking_assert(False, "array allows for type_size_bits not multiple of 8") + + @test + def test_array_wrong_value_length(): + data = c_array(c_unsigned_int, 8, 3) + try: + data.set_value([1, 2]) # Too few values + raise TestException() + except UserWarning as e: + non_blocking_assert( + "Length of array" in str(e), + f"wrong warning message for incorrect value length: {str(e)}", + ) + except Exception as e: + if isinstance(e, TestException): + non_blocking_assert(False, "array set_value allows for incorrect length") + + @test + def test_array_iteration(): + data = c_array(c_unsigned_int, 8, 3) + count = 0 + for item in data: + count += 1 + non_blocking_assert(count == 3, f"array iteration failed, expected 3 items, got {count}") + + +class StructTest: + + @test + def test_struct_creation(): + person = Struct({"name": c_char(10*8), "age": c_unsigned_int(8)}) + + expected_size = 10*8 + 8 + non_blocking_assert(person.size == expected_size, f"struct size not correct, expected {expected_size}, current {person.size}") + + @test + def test_struct_empty(): + try: + data = Struct({}) + raise TestException() + except Exception as e: + if isinstance(e, TestException): + non_blocking_assert(False, "struct allows for empty dict") + else: + expected_msg = "Empty structure cannot be created" + non_blocking_assert( + str(e) == expected_msg, + f"wrong exception (expected: {expected_msg}, current {str(e)})", + ) + + @test + def test_struct_invalid_type(): + try: + data = Struct({"name": "not a type", "age": 25}) + raise TestException() + except Exception as e: + if isinstance(e, TestException): + non_blocking_assert(False, "struct allows for invalid types") + else: + non_blocking_assert( + "shall be of type Type or Struct" in str(e), + f"wrong exception message: {str(e)}", + ) + + @test + def test_struct_set_data(): + person = Struct({"age": c_unsigned_int(8), "weight": c_float(32)}) + person.set_data(age=25, weight=75.5) + + age_value = person._data["age"].value + weight_value = person._data["weight"].value + + non_blocking_assert(age_value == 25, f"struct age not set correctly, expected 25, current {age_value}") + non_blocking_assert(abs(weight_value - 75.5) < 0.01, f"struct weight not set correctly, expected 75.5, current {weight_value}") + + @test + def test_struct_set_data_invalid_key(): + person = Struct({"age": c_unsigned_int(8)}) + try: + person.set_data(name="John") + raise TestException() + except AttributeError as e: + non_blocking_assert( + "not found" in str(e), + f"wrong exception for invalid key: {str(e)}", + ) + except Exception as e: + if isinstance(e, TestException): + non_blocking_assert(False, "struct set_data allows for invalid key") + + @test + def test_struct_format(): + person = Struct({"age": c_unsigned_int(8), "weight": c_float(32)}) + expected_fmt = "u8f32" + non_blocking_assert(person.fmt == expected_fmt, f"struct format not correct, expected {expected_fmt}, current {person.fmt}") + + @test + def test_struct_pack(): + person = Struct({"age": c_unsigned_int(8), "weight": c_float(32)}) + person.set_data(age=25, weight=75.5) + + packed = person.pack() + non_blocking_assert(isinstance(packed, bytes), f"pack should return bytes, got {type(packed)}") + non_blocking_assert(len(packed) > 0, "packed data should not be empty") + + @test + def test_struct_pack_without_data(): + person = Struct({"age": c_unsigned_int(8)}) + try: + packed = person.pack() + raise TestException() + except Exception as e: + if isinstance(e, TestException): + non_blocking_assert(False, "struct pack allows for uninitialized data") + else: + expected_msg = "You have to initialize all data." + non_blocking_assert( + str(e) == expected_msg, + f"wrong exception (expected: {expected_msg}, current {str(e)})", + ) + + @test + def test_struct_nested(): + inner = Struct({"x": c_unsigned_int(8), "y": c_unsigned_int(8)}) + outer = Struct({"position": inner, "value": c_unsigned_int(16)}) + + expected_size = 8 + 8 + 16 + non_blocking_assert(outer.size == expected_size, f"nested struct size not correct, expected {expected_size}, current {outer.size}") + + @test + def test_struct_get_data(): + person = Struct({"age": c_unsigned_int(8)}) + data = person.get_data() + + non_blocking_assert(isinstance(data, dict), f"get_data should return dict, got {type(data)}") + non_blocking_assert("age" in data, "get_data should contain 'age' key") + + ################ # # RUN From ffd99851cc2db8b7c6cf35829f7cc65be3b7783f Mon Sep 17 00:00:00 2001 From: lucamacavero Date: Sat, 25 Apr 2026 15:19:50 +0200 Subject: [PATCH 2/3] fix after ut --- .github/workflows/release.yml | 12 + README.md | 408 ++++++++++++++++++++++++++++------ packed_struct/types.py | 3 +- pyproject.toml | 2 +- 4 files changed, 355 insertions(+), 70 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6fb379a..98445fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,3 +100,15 @@ jobs: Commit: ${{ github.event.head_commit.message }} draft: false prerelease: false + + - name: Build package + if: steps.version_type.outputs.should_release == 'true' + run: | + python -m pip install --upgrade build + python -m build + + - name: Publish to PyPI + if: steps.version_type.outputs.should_release == 'true' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/README.md b/README.md index f6158e3..6747b34 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,366 @@ # py-packed-struct -An implementation of C-like packed structures in Python based on the [`bitstruct`](https://bitstruct.readthedocs.io/en/latest/index.html) package -**py-packed-struct** allows to define C-like structures in an elegant way and to convert them into `bytes` objects without having to specify the format as required by `struct`: -```python -# with struct ->>> from struct import * ->>> one, two, three = 1, 2, 3 ->>> pack(">bhl", one, two, three) -b'\x01\x00\x02\x00\x00\x00\x03' - -# with py-packed-struct ->>> from packed_struct import * ->>> s = Struct({"one": c_signed_int(8), "two": c_signed_int(16), "three": c_signed_int(32) }) ->>> s.set_data(one = 1, two = 2, three = 3) ->>> serialized = s.pack(byte_endianness="big") ->>> print(serialized) -b'\x01\x00\x02\x00\x00\x00\x03' ->>> ->>> s.unpack(serialized) ->>> {'one': 1, 'two': 2, 'three': 3} -``` -Who needs to remember struct format strings? :) +[![PyPI version](https://badge.fury.io/py/py-packed-struct.svg)](https://badge.fury.io/py/py-packed-struct) +[![Python 3.6+](https://img.shields.io/badge/python-3.6+-blue.svg)](https://www.python.org/downloads/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Tests](https://github.com/lu-maca/py-packed-struct/actions/workflows/python-package.yml/badge.svg)](https://github.com/lu-maca/py-packed-struct/actions/workflows/python-package.yml) + +A Python library for defining and manipulating C-like packed structures with support for bit-fields, nested structures, and various data types. + +## Overview + +**py-packed-struct** provides an elegant, Pythonic interface for working with binary data structures that are compatible with C's packed structures. Built on top of the [`bitstruct`](https://bitstruct.readthedocs.io/en/latest/index.html) library, it eliminates the need to manually craft format strings while offering powerful features like nested structures, bit-field manipulation, and automatic serialization/deserialization. + +### Key Features + +- **Intuitive API**: Define structures using native Python syntax instead of cryptic format strings +- **Type Safety**: Strongly-typed data fields with validation +- **Bit-Level Precision**: Full support for bit-fields and non-byte-aligned data +- **Nested Structures**: Create complex hierarchical data structures +- **Endianness Control**: Support for big-endian, little-endian, and native byte order +- **Array Support**: Built-in support for fixed-size arrays +- **C Compatibility**: Generate structures that are binary-compatible with C's `__attribute__((packed))` structs +- **Zero Dependencies**: Only requires `bitstruct` package -In addition, **py-packed-struct** allows to work with bit-fields and nested structures (see [examples](https://github.com/lu-maca/py-packed-struct/tree/main/examples)). +## Installation + +Install via pip: -Installation ----- ```bash pip install py-packed-struct ``` -Supported features ----- -- C-like struct -- bit-fields handling -- byte endianess -- (TODO) bit endianness +Requirements: +- Python >= 3.6 +- bitstruct + +## Quick Start + +Here's a comparison between the standard library's `struct` module and `py-packed-struct`: + +```python +# Using Python's built-in struct module +from struct import pack +one, two, three = 1, 2, 3 +data = pack(">bhl", one, two, three) +# Result: b'\x01\x00\x02\x00\x00\x00\x03' + +# Using py-packed-struct +from packed_struct import Struct, c_signed_int + +s = Struct({ + "one": c_signed_int(8), + "two": c_signed_int(16), + "three": c_signed_int(32) +}) +s.set_data(one=1, two=2, three=3) +data = s.pack(byte_endianness="big") +# Result: b'\x01\x00\x02\x00\x00\x00\x03' + +# Unpack back to dictionary +values = s.unpack(data, byte_endianness="big") +# Result: {'one': 1, 'two': 2, 'three': 3} +``` + +## API Reference + +### Data Types + +All data types inherit from the base `Type` class and support bit-level precision: + +#### Integer Types + +- **`c_unsigned_int(bits)`**: Unsigned integer + ```python + age = c_unsigned_int(8) # 8-bit unsigned int (0-255) + ``` + +- **`c_signed_int(bits)`**: Signed integer (two's complement) + ```python + temperature = c_signed_int(16) # 16-bit signed int (-32768 to 32767) + ``` + +#### Floating Point Types + +- **`c_float(bits)`**: IEEE 754 floating-point number + - Supported sizes: 16, 32, or 64 bits + ```python + weight = c_float(32) # 32-bit float + ``` + +#### Boolean Type + +- **`c_bool(bits)`**: Boolean value + ```python + flag = c_bool(1) # Single bit boolean + ``` + +#### Character/Text Types + +- **`c_char(bits)`**: Text string (UTF-8 encoded) + - Size must be multiple of 8 bits + ```python + name = c_char(80) # 10-character string (10 * 8 bits) + ``` + +#### Raw Data Type + +- **`c_raw_bytes(bits)`**: Raw binary data + ```python + buffer = c_raw_bytes(64) # 8 bytes of raw data + ``` + +#### Padding + +- **`c_padding(bits)`**: Reserved/padding space (always zero) + ```python + padding = c_padding(16) # 16 bits of padding + ``` + +#### Arrays + +- **`c_array(type, type_size_bits, array_size)`**: Fixed-size array + - `type_size_bits` must be multiple of 8 + ```python + # Array of 5 unsigned 8-bit integers + values = c_array(c_unsigned_int, 8, 5) + values.set_value([10, 20, 30, 40, 50]) + ``` + +### Struct Class + +The `Struct` class represents a collection of typed fields: + +```python +person = Struct({ + "name": c_char(10*8), # 10 characters + "age": c_unsigned_int(8), # 1 byte + "height": c_float(32) # 4 bytes +}) +``` + +#### Methods + +- **`set_data(**kwargs)`**: Set values for fields + ```python + person.set_data(name="Alice", age=30, height=165.5) + ``` + +- **`pack(byte_endianness="=")`**: Serialize to bytes + - `byte_endianness`: `"big"`, `"little"`, or `"="` (native) + ```python + binary_data = person.pack(byte_endianness="big") + ``` + +- **`unpack(byte_string, byte_endianness="=", text_encoding="utf-8", text_errors="strict")`**: Deserialize from bytes + ```python + values = person.unpack(binary_data, byte_endianness="big") + ``` + +- **`get_data()`**: Get dictionary of all fields + ```python + fields = person.get_data() + ``` + +#### Properties + +- **`size`**: Total size in bits +- **`fmt`**: Format string (bitstruct format) +- **`value`**: List of all field values + +#### Accessing Field Values + +```python +# Using dictionary syntax +name = person["name"] + +# Using attribute syntax +age = person.age +``` + +### Nested Structures +Structures can be nested to create complex hierarchical data: -Example ----- -This example can be found in [`example/mqtt`](https://github.com/lu-maca/py-packed-struct/tree/main/examples/mqtt). `publisher.py` publishes a message on a MQTT topic and `subscriber.c` is subscribed to that topic. The publisher publishes the following structure: ```python -person = Struct( - { - "name": c_char(10*8), - "age": c_unsigned_int(8), - "weight": c_float(32), - "dresses": Struct( - { - "tshirt": c_char(10*8), - "shorts": c_char(10*8), - "shoes": Struct( - { - "number": c_unsigned_int(8), - "brand": c_char(10*8) - } - ) - } - ) - } -) -# set data values +address = Struct({ + "street": c_char(20*8), + "number": c_unsigned_int(16) +}) + +person = Struct({ + "name": c_char(10*8), + "age": c_unsigned_int(8), + "address": address # Nested structure +}) + +person.set_data(name="Bob", age=25) +person["address"].set_data(street="Main St", number=123) +``` + +## Examples + +### Bit-Fields + +Working with individual bits and bit-fields: + +```python +from packed_struct import Struct, c_unsigned_int, c_bool + +# Define a status register with bit-fields +status = Struct({ + "enabled": c_bool(1), # 1 bit + "ready": c_bool(1), # 1 bit + "error": c_bool(1), # 1 bit + "reserved": c_unsigned_int(5), # 5 bits padding + "code": c_unsigned_int(8) # 8 bits +}) + +status.set_data(enabled=True, ready=False, error=False, reserved=0, code=42) +packed = status.pack() +``` + +### Nested Structures Example + +A complete example demonstrating nested structures (from [`examples/mqtt`](https://github.com/lu-maca/py-packed-struct/tree/main/examples/mqtt)): + +```python +from packed_struct import Struct, c_char, c_unsigned_int, c_float + +# Define nested shoe structure +shoes = Struct({ + "number": c_unsigned_int(8), + "brand": c_char(10*8) +}) + +# Define clothing structure with nested shoes +clothes = Struct({ + "tshirt": c_char(10*8), + "shorts": c_char(10*8), + "shoes": shoes # Nested structure +}) + +# Define person structure with nested clothes +person = Struct({ + "name": c_char(10*8), + "age": c_unsigned_int(8), + "weight": c_float(32), + "dresses": clothes # Nested structure +}) + +# Set values person.set_data(name="Luca", age=29, weight=76.9) -person.dresses.set_data(tshirt="foo", shorts="boo") -person.dresses.shoes.set_data(number=42, brand="bar") +person["dresses"].set_data(tshirt="foo", shorts="boo") +person["dresses"]["shoes"].set_data(number=42, brand="bar") + +# Serialize +binary_data = person.pack() ``` -The subscriber copies the incoming buffer in the following `struct`: + +This Python structure is binary-compatible with the following C structure: + ```c typedef struct __attribute__((packed)) { - uint8_t number; - char brand[10]; + uint8_t number; + char brand[10]; } shoes_t; typedef struct __attribute__((packed)) { - char tshirt[10]; - char shorts[10]; - shoes_t shoes; + char tshirt[10]; + char shorts[10]; + shoes_t shoes; } clothes_t; typedef struct __attribute__((packed)) { - char name[10]; - uint8_t age; - float weight; - clothes_t clothes; -} person; + char name[10]; + uint8_t age; + float weight; + clothes_t clothes; +} person_t; +``` + +### Array Example + +```python +from packed_struct import Struct, c_array, c_unsigned_int + +# Array of integers +data = Struct({ + "header": c_unsigned_int(16), + "values": c_array(c_unsigned_int, 8, 10) # 10 bytes +}) + +data.set_data(header=0xFFFF) +data["values"].set_value([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + +# Iterate over array elements +for i, element in enumerate(data["values"]): + print(f"values[{i}] = {element.value}") +``` + +## Use Cases + +- **Embedded Systems**: Communicate with hardware devices using binary protocols +- **Network Protocols**: Implement custom network packet formats +- **File Formats**: Read/write binary file formats +- **IoT Applications**: Exchange data between Python and C/C++ applications +- **Data Serialization**: Efficient binary serialization for size-constrained environments + +## Supported Features + +| Feature | Status | +|---------|--------| +| C-like structures | ✅ Supported | +| Bit-fields | ✅ Supported | +| Nested structures | ✅ Supported | +| Arrays | ✅ Supported | +| Byte endianness | ✅ Supported (big, little, native) | +| Bit endianness | 🚧 Planned | +| Dynamic arrays | 🚧 Planned | + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change. + +### Development Setup + +```bash +# Clone the repository +git clone https://github.com/lu-maca/py-packed-struct.git +cd py-packed-struct + +# Install in development mode +pip install -e . + +# Run tests +python tests/tests.py +``` + +### Running Tests + +The project includes comprehensive unit tests covering all data types and functionality: + +```bash +python tests/tests.py ``` -The result is the following: +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Links + +- **PyPI**: https://pypi.org/project/py-packed-struct/ +- **GitHub**: https://github.com/lu-maca/py-packed-struct +- **Documentation**: https://github.com/lu-maca/py-packed-struct/tree/main/examples +- **Issue Tracker**: https://github.com/lu-maca/py-packed-struct/issues + +## Acknowledgments + +Built on top of the excellent [`bitstruct`](https://bitstruct.readthedocs.io/en/latest/index.html) library by Erik Moqvist. + +--- -![example mqtt](https://github.com/lu-maca/py-packed-struct/assets/65252677/997cadce-d79d-4117-b693-dc025957ebf9) +**Author**: Luca Macavero diff --git a/packed_struct/types.py b/packed_struct/types.py index e796562..17c4b62 100644 --- a/packed_struct/types.py +++ b/packed_struct/types.py @@ -1,6 +1,7 @@ """This module wraps the `bitstruct` package to implement a C like packed struct (https://bitstruct.readthedocs.io/en/latest/index.html)""" import bitstruct as bstruct +from typing import Union BYTE_ENDIANNESS = { "=": "", @@ -151,7 +152,7 @@ def __iter__(self): def __getitem__(self, idx): return self.value[idx] - def set_value(self, values: list | str): + def set_value(self, values: Union[list, str]): if isinstance(values, str) and len(values) < len(self.value): values += "\0" * (len(self.value) - len(values)) if len(values) != len(self.value): diff --git a/pyproject.toml b/pyproject.toml index 707c981..c42761d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "py-packed-struct" dynamic = ["version"] description = "An implementation of C-like packed structures in Python" readme = "README.md" -requires-python = ">=3.5" +requires-python = ">=3.6" authors = [ { name = "Luca Macavero", email = "luca.macavero@gmail.com" } ] From 940df919480ac9ee8d2e0a81fb5a680cf3be9c4c Mon Sep 17 00:00:00 2001 From: lucamacavero Date: Sat, 25 Apr 2026 15:27:07 +0200 Subject: [PATCH 3/3] remove support for python<3.8 --- .github/workflows/python-package.yml | 2 -- README.md | 4 ++-- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index ba35fe5..e7acdc1 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -25,8 +25,6 @@ jobs: - macos-latest - windows-latest python-version: - - '3.6' - - '3.7' - '3.8' - '3.9' - '3.10' diff --git a/README.md b/README.md index 6747b34..ffe4d7c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # py-packed-struct [![PyPI version](https://badge.fury.io/py/py-packed-struct.svg)](https://badge.fury.io/py/py-packed-struct) -[![Python 3.6+](https://img.shields.io/badge/python-3.6+-blue.svg)](https://www.python.org/downloads/) +[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Tests](https://github.com/lu-maca/py-packed-struct/actions/workflows/python-package.yml/badge.svg)](https://github.com/lu-maca/py-packed-struct/actions/workflows/python-package.yml) @@ -31,7 +31,7 @@ pip install py-packed-struct ``` Requirements: -- Python >= 3.6 +- Python >= 3.8 - bitstruct ## Quick Start diff --git a/pyproject.toml b/pyproject.toml index c42761d..65a3af3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "py-packed-struct" dynamic = ["version"] description = "An implementation of C-like packed structures in Python" readme = "README.md" -requires-python = ">=3.6" +requires-python = ">=3.8" authors = [ { name = "Luca Macavero", email = "luca.macavero@gmail.com" } ]