From c3191fb55bb71c112650515cd39d642df100b61c Mon Sep 17 00:00:00 2001 From: anemeth Date: Fri, 14 Aug 2026 12:42:50 -0700 Subject: [PATCH 1/5] fix: Convert pypi package version reporting to SemVer for BHE --- .github/workflows/test.yml | 4 + src/openhound/core/clients/bhe_version.py | 29 +++++++ src/openhound/core/clients/bloodhound.py | 9 ++- .../core/clients/bloodhound_enterprise.py | 3 +- tests/test_bhe_job_scheduling.py | 13 ++- tests/test_bhe_version.py | 81 +++++++++++++++++++ 6 files changed, 127 insertions(+), 12 deletions(-) create mode 100644 src/openhound/core/clients/bhe_version.py create mode 100644 tests/test_bhe_version.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3a75f7a0..fa8795db 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,6 +56,10 @@ jobs: run: | .venv/bin/pytest tests/test_extensions_format.py -v + - name: Run BHE version boundary tests + run: | + .venv/bin/pytest tests/test_bhe_version.py -v + - name: Run BHE job scheduling test run: | .venv/bin/pytest tests/test_bhe_job_scheduling.py -v diff --git a/src/openhound/core/clients/bhe_version.py b/src/openhound/core/clients/bhe_version.py new file mode 100644 index 00000000..efd256c5 --- /dev/null +++ b/src/openhound/core/clients/bhe_version.py @@ -0,0 +1,29 @@ +import re + +_BHE_PACKAGE_VERSION = re.compile( + r"^(?P0|[1-9]\d*)" + r"\.(?P0|[1-9]\d*)" + r"\.(?P0|[1-9]\d*)" + r"(?:rc(?P0|[1-9]\d*))?$" +) + +class UnsupportedBHEVersion(ValueError): + """Raised when the package version cannot be represented safely for BHE.""" + +def render_bhe_version(package_version: str) -> str: + """Convert a supported Python package version to BHE wire format.""" + match = _BHE_PACKAGE_VERSION.fullmatch(package_version) + if match is None: + raise UnsupportedBHEVersion( + "OpenHound package version " + f"{package_version!r} cannot be reported to BloodHound Enterprise; " + "supported forms are MAJOR.MINOR.PATCH and MAJOR.MINOR.PATCHrcN" + ) + + release = ( + f"v{match.group('major')}." + f"{match.group('minor')}." + f"{match.group('patch')}" + ) + rc = match.group("rc") + return f"{release}-rc{rc}" if rc is not None else release diff --git a/src/openhound/core/clients/bloodhound.py b/src/openhound/core/clients/bloodhound.py index 19062702..b09505d3 100644 --- a/src/openhound/core/clients/bloodhound.py +++ b/src/openhound/core/clients/bloodhound.py @@ -11,6 +11,7 @@ from dlt.common.exceptions import DltException import openhound +from .bhe_version import render_bhe_version from .models import ( AssetGroupsTags, @@ -36,6 +37,8 @@ def __init__(self, reason: str, code: int): class BloodHoundClient(ABC): def __init__(self, base_uri: str = "http://localhost:8000"): self.base_uri = base_uri + self.bhe_version = render_bhe_version(openhound.__version__) + self.user_agent = f"openhound/{self.bhe_version}" @abstractmethod def request( @@ -177,7 +180,7 @@ def request( sig = base64.b64encode(digester.digest()).decode() headers = { - "User-Agent": f"openhound/{openhound.__version__}", + "User-Agent": self.user_agent, "Authorization": f"bhesignature {self.token_id}", "RequestDate": datetime_formatted, "Signature": sig, @@ -196,6 +199,8 @@ class BloodHoundJWT(BloodHoundClient): def __init__(self, token: str, base_uri: str = "http://localhost:8000"): super().__init__(base_uri=base_uri) self.token = token + self.bhe_version = render_bhe_version(openhound.__version__) + self.user_agent = f"openhound/{self.bhe_version}" def request( self, @@ -205,7 +210,7 @@ def request( extra_headers: dict[str, str] | None = None, ): headers = { - "User-Agent": f"openhound/{openhound.__version__}", + "User-Agent": self.user_agent, "Content-Type": "application/json", "Authorization": f"Bearer {self.token}", } diff --git a/src/openhound/core/clients/bloodhound_enterprise.py b/src/openhound/core/clients/bloodhound_enterprise.py index a5d31c49..4517d1b0 100644 --- a/src/openhound/core/clients/bloodhound_enterprise.py +++ b/src/openhound/core/clients/bloodhound_enterprise.py @@ -3,7 +3,6 @@ import socket from enum import Enum -import openhound from openhound.core.clients.bloodhound import BloodHound from openhound.core.clients.models.jobs import ( JobsAvailable, @@ -73,7 +72,7 @@ def update_client_metadata(self) -> None: payload = { "Address": ip_address, "Hostname": hostname, - "Version": openhound.__version__, + "Version": self.bhe_version, } body = json.dumps(payload) diff --git a/tests/test_bhe_job_scheduling.py b/tests/test_bhe_job_scheduling.py index 6e712a05..89a3be49 100644 --- a/tests/test_bhe_job_scheduling.py +++ b/tests/test_bhe_job_scheduling.py @@ -9,7 +9,7 @@ from fastapi import FastAPI, Request, Response from fastapi.testclient import TestClient -from openhound.core.clients import bloodhound_enterprise +from openhound.core.clients import bloodhound, bloodhound_enterprise from openhound.core.clients.bloodhound_enterprise import JobStatus from openhound.core.models.graph import Graph from openhound.scheduler import service as scheduler_service @@ -86,6 +86,7 @@ async def update_client(body: dict): @pytest.fixture def mock_service(mock_bloodhound_api, monkeypatch): + monkeypatch.setattr(bloodhound.openhound, "__version__", "0.3.0rc1") """Patches requests.requests so that our mocked BloodHound API will be used for testing the service. Args: @@ -128,7 +129,6 @@ def mock_request(method, url, **kwargs): def test_client_update_sends_metadata(mock_service, mock_bloodhound_api, monkeypatch): - monkeypatch.setattr(bloodhound_enterprise.openhound, "__version__", "1.2.3") monkeypatch.setattr(bloodhound_enterprise.socket, "gethostname", lambda: "test-host") monkeypatch.setattr( bloodhound_enterprise.socket, @@ -141,15 +141,13 @@ def test_client_update_sends_metadata(mock_service, mock_bloodhound_api, monkeyp assert mock_bloodhound_api.app.state.client_update_payload == { "Address": "192.0.2.10", "Hostname": "test-host", - "Version": "1.2.3", + "Version": "v0.3.0-rc1", } def test_client_update_uses_unknown_when_hostname_lookup_fails( mock_service, mock_bloodhound_api, monkeypatch ): - monkeypatch.setattr(bloodhound_enterprise.openhound, "__version__", "1.2.3") - def raise_error(): raise OSError("hostname unavailable") @@ -160,14 +158,13 @@ def raise_error(): assert mock_bloodhound_api.app.state.client_update_payload == { "Address": "unknown", "Hostname": "unknown", - "Version": "1.2.3", + "Version": "v0.3.0-rc1", } def test_client_update_uses_unknown_when_ip_lookup_fails( mock_service, mock_bloodhound_api, monkeypatch ): - monkeypatch.setattr(bloodhound_enterprise.openhound, "__version__", "1.2.3") monkeypatch.setattr(bloodhound_enterprise.socket, "gethostname", lambda: "test-host") def raise_error(hostname: str): @@ -180,7 +177,7 @@ def raise_error(hostname: str): assert mock_bloodhound_api.app.state.client_update_payload == { "Address": "unknown", "Hostname": "test-host", - "Version": "1.2.3", + "Version": "v0.3.0-rc1", } diff --git a/tests/test_bhe_version.py b/tests/test_bhe_version.py new file mode 100644 index 00000000..39430151 --- /dev/null +++ b/tests/test_bhe_version.py @@ -0,0 +1,81 @@ +from unittest.mock import Mock + +import pytest + +from openhound.core.clients import bloodhound +from openhound.core.clients.bhe_version import ( + UnsupportedBHEVersion, + render_bhe_version, +) +from openhound.core.clients.bloodhound import BloodHound, BloodHoundJWT + +@pytest.mark.parametrize( + ("package_version", "reported_version"), + [ + ("0.3.0", "v0.3.0"), + ("0.3.0rc1", "v0.3.0-rc1"), + ("12.34.56rc10", "v12.34.56-rc10"), + ], +) +def test_render_bhe_version(package_version, reported_version): + assert render_bhe_version(package_version) == reported_version + +@pytest.mark.parametrize( + "package_version", + [ + "unknown", + "", + "0.3", + "v0.3.0", + "0.3.0-rc1", + "0.3.0a1", + "0.3.0b1", + "0.3.0.dev1", + "0.3.0.post1", + "0.3.0+container.1", + "1!0.3.0", + "01.3.0", + ], +) +def test_render_bhe_version_rejects_unsupported_versions(package_version): + with pytest.raises( + UnsupportedBHEVersion, + match="supported forms are MAJOR.MINOR.PATCH", + ): + render_bhe_version(package_version) + +@pytest.mark.parametrize( + ("client_factory", "authorization"), + [ + ( + lambda: BloodHound(token_key="key", token_id="id"), + "bhesignature id", + ), + ( + lambda: BloodHoundJWT(token="jwt"), + "Bearer jwt", + ), + ], +) +def test_bhe_clients_use_reported_version( + monkeypatch, client_factory, authorization +): + monkeypatch.setattr(bloodhound.openhound, "__version__", "0.3.0rc1") + request = Mock(return_value=Mock(status_code=200)) + monkeypatch.setattr(bloodhound.requests, "request", request) + + client_factory().request("GET", "/test") + + headers = request.call_args.kwargs["headers"] + assert headers["User-Agent"] == "openhound/v0.3.0-rc1" + assert headers["Authorization"] == authorization + +def test_bhe_client_rejects_unsupported_version_before_request(monkeypatch): + monkeypatch.setattr(bloodhound.openhound, "__version__", "unknown") + request = Mock() + monkeypatch.setattr(bloodhound.requests, "request", request) + + with pytest.raises(UnsupportedBHEVersion, match="'unknown'"): + BloodHound(token_key="key", token_id="id") + + request.assert_not_called() From 496a13063dd8da0bd6eb59ddeac0cbe28e611e64 Mon Sep 17 00:00:00 2001 From: anemeth Date: Fri, 14 Aug 2026 13:01:31 -0700 Subject: [PATCH 2/5] fix: Add support for -dev0 local builds --- src/openhound/core/clients/bhe_version.py | 22 +++++++++++++--------- src/openhound/core/clients/bloodhound.py | 4 +--- tests/test_bhe_job_scheduling.py | 8 ++++++-- tests/test_bhe_version.py | 12 ++++++++---- 4 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/openhound/core/clients/bhe_version.py b/src/openhound/core/clients/bhe_version.py index efd256c5..4ee7481d 100644 --- a/src/openhound/core/clients/bhe_version.py +++ b/src/openhound/core/clients/bhe_version.py @@ -4,12 +4,15 @@ r"^(?P0|[1-9]\d*)" r"\.(?P0|[1-9]\d*)" r"\.(?P0|[1-9]\d*)" - r"(?:rc(?P0|[1-9]\d*))?$" + r"(?:rc(?P0|[1-9]\d*))?" + r"(?:\.dev(?P0|[1-9]\d*))?$" ) + class UnsupportedBHEVersion(ValueError): """Raised when the package version cannot be represented safely for BHE.""" + def render_bhe_version(package_version: str) -> str: """Convert a supported Python package version to BHE wire format.""" match = _BHE_PACKAGE_VERSION.fullmatch(package_version) @@ -17,13 +20,14 @@ def render_bhe_version(package_version: str) -> str: raise UnsupportedBHEVersion( "OpenHound package version " f"{package_version!r} cannot be reported to BloodHound Enterprise; " - "supported forms are MAJOR.MINOR.PATCH and MAJOR.MINOR.PATCHrcN" + "supported forms are MAJOR.MINOR.PATCH[rcN][.devN]" ) - release = ( - f"v{match.group('major')}." - f"{match.group('minor')}." - f"{match.group('patch')}" - ) - rc = match.group("rc") - return f"{release}-rc{rc}" if rc is not None else release + release = f"v{match.group('major')}.{match.group('minor')}.{match.group('patch')}" + prerelease = [] + if (rc := match.group("rc")) is not None: + prerelease.append(f"rc{rc}") + if (dev := match.group("dev")) is not None: + prerelease.append(f"dev{dev}") + + return f"{release}-{'.'.join(prerelease)}" if prerelease else release diff --git a/src/openhound/core/clients/bloodhound.py b/src/openhound/core/clients/bloodhound.py index b09505d3..9195df49 100644 --- a/src/openhound/core/clients/bloodhound.py +++ b/src/openhound/core/clients/bloodhound.py @@ -11,8 +11,8 @@ from dlt.common.exceptions import DltException import openhound -from .bhe_version import render_bhe_version +from .bhe_version import render_bhe_version from .models import ( AssetGroupsTags, CustomNodes, @@ -199,8 +199,6 @@ class BloodHoundJWT(BloodHoundClient): def __init__(self, token: str, base_uri: str = "http://localhost:8000"): super().__init__(base_uri=base_uri) self.token = token - self.bhe_version = render_bhe_version(openhound.__version__) - self.user_agent = f"openhound/{self.bhe_version}" def request( self, diff --git a/tests/test_bhe_job_scheduling.py b/tests/test_bhe_job_scheduling.py index 89a3be49..6e483635 100644 --- a/tests/test_bhe_job_scheduling.py +++ b/tests/test_bhe_job_scheduling.py @@ -129,7 +129,9 @@ def mock_request(method, url, **kwargs): def test_client_update_sends_metadata(mock_service, mock_bloodhound_api, monkeypatch): - monkeypatch.setattr(bloodhound_enterprise.socket, "gethostname", lambda: "test-host") + monkeypatch.setattr( + bloodhound_enterprise.socket, "gethostname", lambda: "test-host" + ) monkeypatch.setattr( bloodhound_enterprise.socket, "gethostbyname", @@ -165,7 +167,9 @@ def raise_error(): def test_client_update_uses_unknown_when_ip_lookup_fails( mock_service, mock_bloodhound_api, monkeypatch ): - monkeypatch.setattr(bloodhound_enterprise.socket, "gethostname", lambda: "test-host") + monkeypatch.setattr( + bloodhound_enterprise.socket, "gethostname", lambda: "test-host" + ) def raise_error(hostname: str): raise OSError(f"{hostname} unavailable") diff --git a/tests/test_bhe_version.py b/tests/test_bhe_version.py index 39430151..12af07ac 100644 --- a/tests/test_bhe_version.py +++ b/tests/test_bhe_version.py @@ -9,17 +9,22 @@ ) from openhound.core.clients.bloodhound import BloodHound, BloodHoundJWT + @pytest.mark.parametrize( ("package_version", "reported_version"), [ ("0.3.0", "v0.3.0"), ("0.3.0rc1", "v0.3.0-rc1"), + ("0.3.0dev1", "v0.3.0-dev1"), + ("0.3.0.dev0", "v0.3.0-dev0"), + ("0.3.0rc2.dev3", "v0.3.0-rc2.dev3"), ("12.34.56rc10", "v12.34.56-rc10"), ], ) def test_render_bhe_version(package_version, reported_version): assert render_bhe_version(package_version) == reported_version + @pytest.mark.parametrize( "package_version", [ @@ -30,7 +35,6 @@ def test_render_bhe_version(package_version, reported_version): "0.3.0-rc1", "0.3.0a1", "0.3.0b1", - "0.3.0.dev1", "0.3.0.post1", "0.3.0+container.1", "1!0.3.0", @@ -44,6 +48,7 @@ def test_render_bhe_version_rejects_unsupported_versions(package_version): ): render_bhe_version(package_version) + @pytest.mark.parametrize( ("client_factory", "authorization"), [ @@ -57,9 +62,7 @@ def test_render_bhe_version_rejects_unsupported_versions(package_version): ), ], ) -def test_bhe_clients_use_reported_version( - monkeypatch, client_factory, authorization -): +def test_bhe_clients_use_reported_version(monkeypatch, client_factory, authorization): monkeypatch.setattr(bloodhound.openhound, "__version__", "0.3.0rc1") request = Mock(return_value=Mock(status_code=200)) monkeypatch.setattr(bloodhound.requests, "request", request) @@ -70,6 +73,7 @@ def test_bhe_clients_use_reported_version( assert headers["User-Agent"] == "openhound/v0.3.0-rc1" assert headers["Authorization"] == authorization + def test_bhe_client_rejects_unsupported_version_before_request(monkeypatch): monkeypatch.setattr(bloodhound.openhound, "__version__", "unknown") request = Mock() From 838ee968abcb78714d210f41b2ee5651388a904a Mon Sep 17 00:00:00 2001 From: anemeth Date: Fri, 14 Aug 2026 13:10:22 -0700 Subject: [PATCH 3/5] fix: Pull latest tag in CI for version tests --- .github/workflows/test.yml | 2 ++ src/openhound/core/clients/bhe_version.py | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fa8795db..43575ac5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,6 +24,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up python uses: actions/setup-python@v5 diff --git a/src/openhound/core/clients/bhe_version.py b/src/openhound/core/clients/bhe_version.py index 4ee7481d..13dc8ab9 100644 --- a/src/openhound/core/clients/bhe_version.py +++ b/src/openhound/core/clients/bhe_version.py @@ -5,7 +5,7 @@ r"\.(?P0|[1-9]\d*)" r"\.(?P0|[1-9]\d*)" r"(?:rc(?P0|[1-9]\d*))?" - r"(?:\.dev(?P0|[1-9]\d*))?$" + r"(?:\.?dev(?P0|[1-9]\d*))?$" ) @@ -20,7 +20,8 @@ def render_bhe_version(package_version: str) -> str: raise UnsupportedBHEVersion( "OpenHound package version " f"{package_version!r} cannot be reported to BloodHound Enterprise; " - "supported forms are MAJOR.MINOR.PATCH[rcN][.devN]" + "supported forms are MAJOR.MINOR.PATCH[rcN], optionally followed " + "by devN or .devN" ) release = f"v{match.group('major')}.{match.group('minor')}.{match.group('patch')}" From 5b9d9ab6c52b6d686610852500a03e705d50e468 Mon Sep 17 00:00:00 2001 From: anemeth Date: Fri, 14 Aug 2026 13:29:44 -0700 Subject: [PATCH 4/5] chore: Allow optional '.' for rc suffix --- src/openhound/core/clients/bhe_version.py | 2 +- tests/test_bhe_version.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/openhound/core/clients/bhe_version.py b/src/openhound/core/clients/bhe_version.py index 13dc8ab9..8936b066 100644 --- a/src/openhound/core/clients/bhe_version.py +++ b/src/openhound/core/clients/bhe_version.py @@ -4,7 +4,7 @@ r"^(?P0|[1-9]\d*)" r"\.(?P0|[1-9]\d*)" r"\.(?P0|[1-9]\d*)" - r"(?:rc(?P0|[1-9]\d*))?" + r"(?:\.?rc(?P0|[1-9]\d*))?" r"(?:\.?dev(?P0|[1-9]\d*))?$" ) diff --git a/tests/test_bhe_version.py b/tests/test_bhe_version.py index 12af07ac..9d7f286d 100644 --- a/tests/test_bhe_version.py +++ b/tests/test_bhe_version.py @@ -15,6 +15,7 @@ [ ("0.3.0", "v0.3.0"), ("0.3.0rc1", "v0.3.0-rc1"), + ("0.3.0.rc1", "v0.3.0-rc1"), ("0.3.0dev1", "v0.3.0-dev1"), ("0.3.0.dev0", "v0.3.0-dev0"), ("0.3.0rc2.dev3", "v0.3.0-rc2.dev3"), From be448ebafc08424aa0d5fa1c7e7188bf2b55d3c5 Mon Sep 17 00:00:00 2001 From: anemeth Date: Fri, 14 Aug 2026 14:22:30 -0700 Subject: [PATCH 5/5] fix: Scrub -dev suffix from output --- src/openhound/core/clients/bhe_version.py | 5 +---- tests/test_bhe_version.py | 6 +++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/openhound/core/clients/bhe_version.py b/src/openhound/core/clients/bhe_version.py index 8936b066..31d98231 100644 --- a/src/openhound/core/clients/bhe_version.py +++ b/src/openhound/core/clients/bhe_version.py @@ -20,15 +20,12 @@ def render_bhe_version(package_version: str) -> str: raise UnsupportedBHEVersion( "OpenHound package version " f"{package_version!r} cannot be reported to BloodHound Enterprise; " - "supported forms are MAJOR.MINOR.PATCH[rcN], optionally followed " - "by devN or .devN" + "supported forms are MAJOR.MINOR.PATCH[rcN]" ) release = f"v{match.group('major')}.{match.group('minor')}.{match.group('patch')}" prerelease = [] if (rc := match.group("rc")) is not None: prerelease.append(f"rc{rc}") - if (dev := match.group("dev")) is not None: - prerelease.append(f"dev{dev}") return f"{release}-{'.'.join(prerelease)}" if prerelease else release diff --git a/tests/test_bhe_version.py b/tests/test_bhe_version.py index 9d7f286d..e3432bfc 100644 --- a/tests/test_bhe_version.py +++ b/tests/test_bhe_version.py @@ -16,9 +16,9 @@ ("0.3.0", "v0.3.0"), ("0.3.0rc1", "v0.3.0-rc1"), ("0.3.0.rc1", "v0.3.0-rc1"), - ("0.3.0dev1", "v0.3.0-dev1"), - ("0.3.0.dev0", "v0.3.0-dev0"), - ("0.3.0rc2.dev3", "v0.3.0-rc2.dev3"), + ("0.3.0dev1", "v0.3.0"), + ("0.3.0.dev0", "v0.3.0"), + ("0.3.0rc2.dev3", "v0.3.0-rc2"), ("12.34.56rc10", "v12.34.56-rc10"), ], )