diff --git a/src/lob_hlpr/__init__.py b/src/lob_hlpr/__init__.py index 5f1d317..730e565 100644 --- a/src/lob_hlpr/__init__.py +++ b/src/lob_hlpr/__init__.py @@ -4,5 +4,10 @@ """ from lob_hlpr.hlpr import LobHlpr +from lob_hlpr.lib_types import FirmwareID, FirmwareVersion -__all__ = ["LobHlpr"] +__all__ = [ + "LobHlpr", + "FirmwareID", + "FirmwareVersion", +] diff --git a/src/lob_hlpr/hlpr.py b/src/lob_hlpr/hlpr.py index 7d0b466..5db81fc 100644 --- a/src/lob_hlpr/hlpr.py +++ b/src/lob_hlpr/hlpr.py @@ -6,6 +6,8 @@ from dataclasses import asdict from datetime import datetime +from lob_hlpr.lib_types import FirmwareID + class LobHlpr: """Helper functions for Lobaro tools.""" @@ -202,3 +204,30 @@ def extract_identifier_from_hexfile(hex_str: str): if len(identifiers) == 0: raise ValueError("No firmware identifier found in hexfile") return identifiers + + @staticmethod + def fw_id_from_fw_file(fw_file: str, contains: str | None = None) -> FirmwareID: + """Extract the firmware identifier from a firmware file. + + Args: + fw_file (str): The path to the firmware file. + contains (str): Optional filter to make sure result contains. + + Returns: + FirmwareID: The firmware identifier. + + Raises: + ValueError: If no or too many firmware identifier is found in the file. + """ + with open(fw_file) as f: + hex_str = f.read() + identifiers = LobHlpr.extract_identifier_from_hexfile(hex_str) + if contains: + identifiers = [i for i in identifiers if contains in i] + if not identifiers: + raise ValueError(f"No firmware identifier found in {fw_file}") + if len(identifiers) > 1: + raise ValueError( + f"Multiple firmware identifiers found in {fw_file}: {identifiers}" + ) + return FirmwareID(identifiers[0]) diff --git a/src/lob_hlpr/lib_types.py b/src/lob_hlpr/lib_types.py new file mode 100644 index 0000000..8844885 --- /dev/null +++ b/src/lob_hlpr/lib_types.py @@ -0,0 +1,117 @@ +import re +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class FirmwareVersion: + """Represents a firmware version, parsing all components used by Lobaro firmware. + + Raises: + ValueError: If the provided version string does not match the expected format. + """ + + version_string: str + """The complete version string to be parsed.""" + + major: int = 0 + """Major version number.""" + + minor: int = 0 + """Minor version number.""" + + patch: int = 0 + """Patch version number.""" + + commits: int = 0 + """Number of commits since the last version tag.""" + + commit: str | None = None + """Commit hash associated with this version.""" + + dirty: bool = False + """Indicates if there are uncommitted changes in the source.""" + + unknown: bool = False + """Flag for any unknown additional information in the version string.""" + + _VERSION_REGEX = re.compile( + r"(?P\d+)\.(?P\d+)\.(?P\d+)" + r"(?:-(?P\d+)-g(?P[0-9a-f]+))?" + r"(?P-dirty)?(?P-unknown)?" + ) + """Precompiled regex for parsing all parts of a version string.""" + + def __post_init__(self): + """Parses the version string and updates the object's attributes accordingly. + + This method is automatically called after the data class has been initialized. + """ + m = self._VERSION_REGEX.match(self.version_string) + try: + self.major = int(m.group("major")) + self.minor = int(m.group("minor")) + self.patch = int(m.group("patch")) + self.commits = int(m.group("commits") or 0) + self.commit = m.group("commit") + self.dirty = bool(m.group("dirty")) + self.unknown = bool(m.group("unknown")) + except (AttributeError, ValueError, TypeError, IndexError, KeyError) as exc: + raise ValueError( + f"Invalid version string: '{self.version_string}'" + ) from exc + + +@dataclass +class FirmwareID: + """Represents a firmware id, parsing all components used by Lobaro firmware. + + Raises: + ValueError: If the provided id does not match the expected format. + """ + + id_string: str + """The complete firmware identifier string to be parsed.""" + + name: str | None = None + """The name of the firmware.""" + + version: FirmwareVersion | None = None + """The version of the firmware.""" + + variants: list[str] | None = None + """A list of all variants of the firmware, e.g. ['hw3', 'alt_phy'].""" + + built: str | None = None + """The build date of firmware, e.g. 'Jan 01 2021 00:00:00'""" + + _IDENTIFIER_RE = re.compile( + # Name group, non-greedy match up to the first space + r"^(?P.+?)\s+" + # Version group, matches a semantic versioning pattern + r"v(?P[0-9]+(?:\.[0-9]+){2}(?:-[\w]+)?(?:-\d+-g[0-9a-f]+)?)" + # Optional variant group, matches anything after a '+' until a space or end + r"(?:\+(?P[^\s]+))?" + # Optional additional group, + # matches before parentheses if not directly next to variant + r"(?:\s+(?P[^\(\)]+?))?" + # Date group, matches everything inside the parentheses, made entirely optional + r"(?:\s*\((?P.+?)\))?$", + ) + """Precompiled regex for parsing all parts of a firmware identifier string.""" + + def __post_init__(self): + """Parses the firmware identifier string and updates attributes accordingly.""" + try: + m = self._IDENTIFIER_RE.match(self.id_string) + groups = m.groupdict() + self.name = groups["name"] + self.version = FirmwareVersion(groups["version"]) + self.variants = groups["variant"].split(".") if groups["variant"] else [] + built = groups.get("date", None) + if built: + self.built = datetime.strptime(built, "%b %d %Y %H:%M:%S").isoformat() + except (AttributeError, ValueError, TypeError, IndexError, KeyError) as exc: + raise ValueError( + f"Invalid firmware identifier string: '{self.id_string}'" + ) from exc diff --git a/tests/files/fw-test-file.hex b/tests/files/fw-test-file.hex new file mode 100644 index 0000000..45f7c91 --- /dev/null +++ b/tests/files/fw-test-file.hex @@ -0,0 +1,19 @@ +:020000040000FA +:100400003E3D3D484558494E464F3D3D3E6170702A +:100410002D6D6375626F6F742D6E72663931363073 +:100420002D7365632076312E332E302B4D422054B0 +:100430005A32202853657020203920323032312042 +:1004400031323A32383A3533293C3D3D48455849F6 +:070450004E464F3D3D3C000C +:10C800003E3D3D484558494E464F3D3D3E61707066 +:10C810002D626F6F742D6E7266393136302D7365EF +:10C82000632076312E382E3220545A322028466525 +:10C830006220203820323032332031373A30333AD8 +:10C840003337293C3D3D484558494E464F3D3D3CD8 +:100800003E3D3D484558494E464F3D3D3E61707026 +:100810002D6E7266393136302D776D6275732076A4 +:10082000302E32332E362B68773320545A3220281C +:100830004A616E20323720323032342031343A323D +:10084000303A3436293C3D3D484558494E464F3DA7 +:100850003D3C7365637572653D312E382E303B70BB +:00000001FF diff --git a/tests/test_firmware_id.py b/tests/test_firmware_id.py new file mode 100644 index 0000000..3bc87b3 --- /dev/null +++ b/tests/test_firmware_id.py @@ -0,0 +1,80 @@ +import pytest + +from lob_hlpr import ( # Adjust the import path as necessary + FirmwareID, + FirmwareVersion, +) + + +def test_valid_firmware_id_with_variants(): + """Tests if the firmware ID can be created with variants.""" + id_str = ( + "app-nrf9160-wmbus v0.24.1-9-g8ad003f+hw3.alt_phy TZ2 (Mar 11 2024 13:57:40)" + ) + firmware_id = FirmwareID(id_str) + assert firmware_id.name == "app-nrf9160-wmbus" + assert isinstance(firmware_id.version, FirmwareVersion) + assert firmware_id.version.version_string == "0.24.1-9-g8ad003f" + assert firmware_id.variants == ["hw3", "alt_phy"] + assert firmware_id.built == "2024-03-11T13:57:40" + + +def test_valid_firmware_id_unknown(): + """Tests if the firmware ID can be created with 'unknown' in the version string.""" + id_str = "app-nrf9160-wmbus v0.0.0-unknown+hw3 TZ2 (Oct 12 2023 10:45:49)" + firmware_id = FirmwareID(id_str) + assert firmware_id.name == "app-nrf9160-wmbus" + assert isinstance(firmware_id.version, FirmwareVersion) + assert firmware_id.version.version_string == "0.0.0-unknown" + assert firmware_id.variants == ["hw3"] + assert firmware_id.built == "2023-10-12T10:45:49" + + +def test_valid_firmware_id_without_variants(): + """Tests if the firmware ID can be created without variants.""" + id_str = "Lobaro v1.2.3 (Jan 01 2021 00:00:00)" + firmware_id = FirmwareID(id_str) + assert firmware_id.name == "Lobaro" + assert isinstance(firmware_id.version, FirmwareVersion) + assert firmware_id.version.version_string == "1.2.3" + assert firmware_id.variants == [] + assert firmware_id.built == "2021-01-01T00:00:00" + + +def test_valid_firmware_without_date(): + """Tests if the firmware ID can be created without variants.""" + id_str = "Lobaro v1.2.3" + firmware_id = FirmwareID(id_str) + assert firmware_id.name == "Lobaro" + assert isinstance(firmware_id.version, FirmwareVersion) + assert firmware_id.version.version_string == "1.2.3" + assert firmware_id.variants == [] + assert firmware_id.built is None + + +def test_firmware_id_with_invalid_date(): + """Tests if the firmware ID can be created with an invalid date format.""" + id_str = "Lobaro v1.2.3+hw3 (Invalid Date Format)" + with pytest.raises(ValueError): + FirmwareID(id_str) + + +def test_firmware_id_with_incomplete_string(): + """Tests if the firmware ID can be created with an incomplete string.""" + id_str = "Lobaro v1.2" + with pytest.raises(ValueError): + FirmwareID(id_str) + + +def test_firmware_id_with_no_version(): + """Tests if the firmware ID can be created without a version.""" + id_str = "Lobaro v (Jan 01 2021 00:00:00)" + with pytest.raises(ValueError): + FirmwareID(id_str) + + +def test_firmware_id_with_no_name(): + """Tests if the firmware ID can be created without a name.""" + id_str = " v1.2.3 (Jan 01 2021 00:00:00)" + with pytest.raises(ValueError): + FirmwareID(id_str) diff --git a/tests/test_firmware_version.py b/tests/test_firmware_version.py new file mode 100644 index 0000000..df06597 --- /dev/null +++ b/tests/test_firmware_version.py @@ -0,0 +1,58 @@ +import pytest + +from lob_hlpr import FirmwareVersion + + +def test_valid_version(): + """Test with a valid version string.""" + version = FirmwareVersion("1.2.3-4-gabc123-dirty") + assert version.major == 1 + assert version.minor == 2 + assert version.patch == 3 + assert version.commits == 4 + assert version.commit == "abc123" + assert version.dirty is True + assert version.unknown is False + + +def test_valid_version_with_unknown(): + """Test with a version string that includes 'unknown'.""" + version = FirmwareVersion("1.2.3-unknown") + assert version.major == 1 + assert version.minor == 2 + assert version.patch == 3 + assert version.commits == 0 + assert version.commit is None + assert version.dirty is False + assert version.unknown is True + + +def test_version_without_optional_parts(): + """Test with a version string that does not include optional parts.""" + version = FirmwareVersion("1.2.3") + assert version.major == 1 + assert version.minor == 2 + assert version.patch == 3 + assert version.commits == 0 + assert version.commit is None + assert version.dirty is False + assert version.unknown is False + + +def test_invalid_version(): + """Test with an invalid version string.""" + with pytest.raises(ValueError): + FirmwareVersion("invalid.version.string") + + +def test_empty_version(): + """Test with an empty version string.""" + with pytest.raises(ValueError): + FirmwareVersion("") + + +def test_partial_version(): + """Test with a partial version string.""" + # Assuming partial versions are not allowed and should raise an error + with pytest.raises(ValueError): + FirmwareVersion("1.2") diff --git a/tests/test_lob_hlpr.py b/tests/test_lob_hlpr.py index b98a2a1..cdbb432 100644 --- a/tests/test_lob_hlpr.py +++ b/tests/test_lob_hlpr.py @@ -193,3 +193,21 @@ def test_format_unix_timestamp(): timestamp = hlp.format_unix_timestamp(1732266521241) # Test if it is in the correct format assert timestamp == "2024-11-22_10-08-41" or timestamp == "2024-11-22_09-08-41" + + +def test_fw_id_from_fw_file_passes(): + """Test FirmwareID from firmware file.""" + # Test with a valid firmware file + test_file = "tests/files/fw-test-file.hex" + res = hlp.fw_id_from_fw_file(test_file, contains="app-nrf9160-wmbus") + assert "app-nrf9160-wmbus" in res.name + + +def test_fw_id_from_fw_file_fail(): + """Test FirmwareID from firmware file.""" + # Test with a valid firmware file + test_file = "tests/files/fw-test-file.hex" + with pytest.raises(ValueError): + hlp.fw_id_from_fw_file(test_file, contains="non-existing") + with pytest.raises(ValueError): + hlp.fw_id_from_fw_file(test_file)