From 55d2129ca5fdc0a12bfd18de1f8408d7a7739a41 Mon Sep 17 00:00:00 2001 From: Chen Heroy Date: Fri, 7 Aug 2026 12:59:28 -0600 Subject: [PATCH 01/12] update to robinraju/release-downloader@v1.13 --- .github/workflows/pytest.yml | 2 +- .github/workflows/release.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 7a92056..feb8789 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -80,7 +80,7 @@ jobs: # wheel for proplib python packages includes all binaries, so that the wheel is inherently cross-platform. - name: Download required ${{ env.LIBRARY_RELEASE_TAG }} binaries if: ${{ steps.cache-restore.outputs.cache-hit != 'true' }} - uses: robinraju/release-downloader@v1 + uses: robinraju/release-downloader@v1.13 with: repository: ${{ env.LIBRARY_BASE_REPO }} tag: ${{ env.LIBRARY_RELEASE_TAG }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e2cd00..79b2c0f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,7 @@ jobs: # Only the binaries required for the current platform are downloaded. Note that the distributed # wheel for proplib python packages includes all binaries, so that the wheel is inherently cross-platform. - name: Download required ${{ env.LIBRARY_RELEASE_TAG }} Windows binaries - uses: robinraju/release-downloader@v1 + uses: robinraju/release-downloader@v1.13 with: repository: ${{ env.LIBRARY_BASE_REPO }} tag: ${{ env.LIBRARY_RELEASE_TAG }} @@ -50,7 +50,7 @@ jobs: out-file-path: ${{ env.LIBRARY_DESTINATION_DIRECTORY }} - name: Download required ${{ env.LIBRARY_RELEASE_TAG }} Linux binaries - uses: robinraju/release-downloader@v1 + uses: robinraju/release-downloader@v1.13 with: repository: ${{ env.LIBRARY_BASE_REPO }} tag: ${{ env.LIBRARY_RELEASE_TAG }} @@ -60,7 +60,7 @@ jobs: out-file-path: ${{ env.LIBRARY_DESTINATION_DIRECTORY }} - name: Download required ${{ env.LIBRARY_RELEASE_TAG }} macOS binaries - uses: robinraju/release-downloader@v1 + uses: robinraju/release-downloader@v1.13 with: repository: ${{ env.LIBRARY_BASE_REPO }} tag: ${{ env.LIBRARY_RELEASE_TAG }} From 2cadbbd32e524afa2ff8dc5650244bb5a3a75ed4 Mon Sep 17 00:00:00 2001 From: Chen Heroy Date: Fri, 21 Aug 2026 14:36:23 -0600 Subject: [PATCH 02/12] optimize workflows --- .github/workflows/cff-validator.yml | 35 ++++++--- .github/workflows/pytest.yml | 110 ++++++++++++++++++++++------ .github/workflows/release.yml | 30 ++++---- pyproject.toml | 1 + 4 files changed, 129 insertions(+), 47 deletions(-) diff --git a/.github/workflows/cff-validator.yml b/.github/workflows/cff-validator.yml index e6b95a5..3a55d13 100644 --- a/.github/workflows/cff-validator.yml +++ b/.github/workflows/cff-validator.yml @@ -1,27 +1,38 @@ name: Validate CITATION.cff on: - push: - branches: ["main", "dev"] - paths: - - 'CITATION.cff' - - '.github/workflows/cff-validator.yml' - pull_request: - branches: ["main", "dev"] - paths: - - 'CITATION.cff' - - '.github/workflows/cff-validator.yml' + ## Auto-triggers are disabled by default for this template. Uncomment the + ## push/pull_request blocks below to enable validation on branches/PRs. Until + ## then, the workflow runs only via manual dispatch (Actions tab → Run workflow). + #push: + # branches: ["main"] + # paths: + # - 'CITATION.cff' + # - '.github/workflows/cff-validator.yml' + #pull_request: + # branches: ["main"] + # paths: + # - 'CITATION.cff' + # - '.github/workflows/cff-validator.yml' workflow_dispatch: +# Cancel in-progress runs for the same ref when a new run is triggered +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: Validate-CITATION-cff: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 # current stable Ubuntu (Aug 2026) name: Validate CITATION.cff env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Validate CITATION.cff uses: dieghernan/cff-validator@v5 diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index feb8789..bd1f0f5 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -1,19 +1,36 @@ name: Test with pytest on: + ## Auto-triggers are disabled by default for this template. Uncomment the + ## push/pull_request blocks below to enable CI on branches/PRs. Until then, + ## the workflow runs only via manual dispatch (Actions tab → Run workflow). workflow_dispatch: - push: - branches: - - main - pull_request: - branches: - - main - - dev + #push: + # branches: + # - main + # paths: + # - 'src/**' + # - 'tests/**' + # - 'pyproject.toml' + # - '.github/workflows/pytest.yml' + #pull_request: + # branches: + # - main + # paths: + # - 'src/**' + # - 'tests/**' + # - 'pyproject.toml' + # - '.github/workflows/pytest.yml' concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true +# Least-privilege default token permissions +permissions: + contents: read + pull-requests: read + # TODO-TEMPLATE: Populate environment variables below # LIBRARY_BASE_REPO: The base repo with the C++ implementation, e.g. "NTIA/itm" # LIBRARY_RELEASE_TAG: The Git tag identifying a release. Can be a pre-release. @@ -24,47 +41,96 @@ env: LIBRARY_DESTINATION_DIRECTORY: 'src/ITS/PropLibTemplate/' jobs: + should-run: + runs-on: ubuntu-24.04 # current stable Ubuntu (Aug 2026) + outputs: + should_run: ${{ steps.decide.outputs.should_run }} + steps: + - name: Skip duplicate runs for merged PRs + id: decide + uses: actions/github-script@v9 + with: + script: | + if (context.eventName !== 'push') { + core.setOutput('should_run', 'true'); + return; + } + + const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.sha + }); + + const hasMainPullRequest = pullRequests.some((pullRequest) => pullRequest.base.ref === 'main'); + core.setOutput('should_run', hasMainPullRequest ? 'false' : 'true'); + run-all-tests: + if: ${{ needs.should-run.outputs.should_run == 'true' }} + needs: should-run name: ${{ matrix.platform.os-name }} / Py${{ matrix.py }} runs-on: ${{ matrix.platform.os-runner }} strategy: fail-fast: false matrix: + # As of Aug 2026, current stable runner images are windows-2025, ubuntu-24.04, + # and macos-15 (arm64) / macos-15-intel. platform: - os-name: 'Windows (64-bit)' - os-runner: 'windows-latest' + os-runner: 'windows-2025' # current stable, Aug 2026 arch-id: 'x64' release-file-pattern: '*-x64.dll' - os-name: 'Windows (32-bit)' - os-runner: 'windows-latest' + os-runner: 'windows-2025' # current stable, Aug 2026 arch-id: 'x86' release-file-pattern: '*-x86.dll' - os-name: 'macOS (intel/x64)' - os-runner: 'macos-15' + os-runner: 'macos-15' # Intel via macos-15 x64 image, current stable (Aug 2026) arch-id: 'x64' release-file-pattern: '*.dylib' - os-name: 'macOS (apple/arm64)' - os-runner: 'macos-latest' + os-runner: 'macos-15' # Apple silicon (arm64), current stable (Aug 2026) arch-id: 'arm64' release-file-pattern: '*.dylib' - os-name: 'Linux (Ubuntu)' - os-runner: 'ubuntu-latest' + os-runner: 'ubuntu-24.04' # current stable, Aug 2026 arch-id: 'x64' release-file-pattern: '*.so' - py: # Python versions to test on all platforms - - "3.10" - - "3.11" - - "3.12" - - "3.13" + # Non-Linux runners test only the supported bounds (oldest + newest Python) + # to save runner minutes; Linux covers the full supported range. The bounds + # match pyproject `requires-python = ">=3.9"` (newest = 3.14). + py: + - "3.9" - "3.14" - exclude: - # Exclude python 3.10 on macos-15 + include: + # Full Python-version coverage on Linux only. + - platform: + os-name: 'Linux (Ubuntu)' + os-runner: 'ubuntu-24.04' + arch-id: 'x64' + release-file-pattern: '*.so' + py: "3.10" + - platform: + os-name: 'Linux (Ubuntu)' + os-runner: 'ubuntu-24.04' + arch-id: 'x64' + release-file-pattern: '*.so' + py: "3.11" + - platform: + os-name: 'Linux (Ubuntu)' + os-runner: 'ubuntu-24.04' + arch-id: 'x64' + release-file-pattern: '*.so' + py: "3.12" - platform: - os-runner: 'macos-15' - py: '3.10' + os-name: 'Linux (Ubuntu)' + os-runner: 'ubuntu-24.04' + arch-id: 'x64' + release-file-pattern: '*.so' + py: "3.13" steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: submodules: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 79b2c0f..148010e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,22 +1,26 @@ # Action builds a universal (Win32/Win64/macOS-universal/Linux-x64) Python wheel # from the source code, using Hatchling, and uploads it as an artifact. An sdist (.tar.gz) is # also uploaded, which includes all platform shared library files. These artifacts should be -# used when creating new releases on PyPI and GitHub. The action is triggered by pushes into `main` -# or pull_requests into `main` or `dev` (for testing). To aid in releases, the workflow is -# also triggered when new SemVer tags are created. +# used when creating new releases on PyPI and GitHub. The action is triggered when a new SemVer +# tag (v*) is pushed, and can also be run manually via workflow_dispatch. name: Build Release Artifacts on: - workflow_dispatch: + ## This release build is triggered when a SemVer tag (v*) is pushed, and can + ## also be run manually via workflow_dispatch. Branch/PR auto-triggers are + ## intentionally not used here. push: - branches: - - main tags: - 'v[0-9]+.*' - pull_request: - branches: - - main - - dev + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +# Least-privilege default token permissions +permissions: + contents: read # TODO-TEMPLATE: Populate environment variables below # LIBRARY_BASE_REPO: The base repo with the C++ implementation, e.g. "NTIA/itm" @@ -30,10 +34,10 @@ env: jobs: build_wheel: name: Build a universal, cross-platform wheel - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 # current stable Ubuntu (Aug 2026) steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: submodules: true @@ -70,7 +74,7 @@ jobs: out-file-path: ${{ env.LIBRARY_DESTINATION_DIRECTORY }} - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13' diff --git a/pyproject.toml b/pyproject.toml index d89f81c..f78a26c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] [project.optional-dependencies] From 81183de2bc2194e725aa660e082fb58273833c0e Mon Sep 17 00:00:00 2001 From: Chen Heroy Date: Tue, 25 Aug 2026 10:45:18 -0600 Subject: [PATCH 03/12] update comments --- .github/workflows/cff-validator.yml | 2 +- .github/workflows/pytest.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cff-validator.yml b/.github/workflows/cff-validator.yml index 3a55d13..c80e6ae 100644 --- a/.github/workflows/cff-validator.yml +++ b/.github/workflows/cff-validator.yml @@ -1,7 +1,7 @@ name: Validate CITATION.cff on: - ## Auto-triggers are disabled by default for this template. Uncomment the + ## Auto-triggers are disabled by default. Uncomment the ## push/pull_request blocks below to enable validation on branches/PRs. Until ## then, the workflow runs only via manual dispatch (Actions tab → Run workflow). #push: diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index bd1f0f5..76aa8de 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -1,7 +1,7 @@ name: Test with pytest on: - ## Auto-triggers are disabled by default for this template. Uncomment the + ## Auto-triggers are disabled by default. Uncomment the ## push/pull_request blocks below to enable CI on branches/PRs. Until then, ## the workflow runs only via manual dispatch (Actions tab → Run workflow). workflow_dispatch: From 0550a0c0244eb38fb77e142d80e70ed5011a6e7b Mon Sep 17 00:00:00 2001 From: Chen Heroy Date: Thu, 3 Sep 2026 09:36:01 -0600 Subject: [PATCH 04/12] improve template --- .github/workflows/pytest.yml | 10 +-- .github/workflows/release.yml | 10 +-- pyproject.toml | 9 +- src/ITS/PropLibTemplate/__init__.py | 7 +- src/ITS/PropLibTemplate/proplib_loader.py | 56 ++++++++----- src/ITS/PropLibTemplate/proplib_template.py | 24 +++++- tests/__init__.py | 1 + tests/test_proplib_loader.py | 91 +++++++++++++++++++++ tests/test_utils.py | 50 +++++++++-- 9 files changed, 214 insertions(+), 44 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_proplib_loader.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 76aa8de..a22c18a 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -32,13 +32,13 @@ permissions: pull-requests: read # TODO-TEMPLATE: Populate environment variables below -# LIBRARY_BASE_REPO: The base repo with the C++ implementation, e.g. "NTIA/itm" -# LIBRARY_RELEASE_TAG: The Git tag identifying a release. Can be a pre-release. -# LIBRARY_DESTINATION_DIRECTORY: Path in this repo where shared library files should be placed +# LIBRARY_BASE_REPO: The base repo with the C++ implementation, e.g. "NTIA/itm". +# LIBRARY_RELEASE_TAG: The Git tag identifying the binary release to test. Can be a pre-release. +# LIBRARY_DESTINATION_DIRECTORY: Path in this repo where shared library files should be placed. env: LIBRARY_BASE_REPO: NTIA/proplib-template - LIBRARY_RELEASE_TAG: v1.0 - LIBRARY_DESTINATION_DIRECTORY: 'src/ITS/PropLibTemplate/' + LIBRARY_RELEASE_TAG: v1.2 + LIBRARY_DESTINATION_DIRECTORY: 'src/ITS/PropLibTemplate' jobs: should-run: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 148010e..e4386d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,13 +23,13 @@ permissions: contents: read # TODO-TEMPLATE: Populate environment variables below -# LIBRARY_BASE_REPO: The base repo with the C++ implementation, e.g. "NTIA/itm" -# LIBRARY_RELEASE_TAG: The Git tag identifying a release. Can be a pre-release. -# LIBRARY_DESTINATION_DIRECTORY: Path in this repo where shared library files should be placed +# LIBRARY_BASE_REPO: The base repo with the C++ implementation, e.g. "NTIA/itm". +# LIBRARY_RELEASE_TAG: The Git tag identifying the binary release to test. Can be a pre-release. +# LIBRARY_DESTINATION_DIRECTORY: Path in this repo where shared library files should be placed. env: LIBRARY_BASE_REPO: NTIA/proplib-template - LIBRARY_RELEASE_TAG: v1.0 - LIBRARY_DESTINATION_DIRECTORY: 'src/ITS/PropLibTemplate/' + LIBRARY_RELEASE_TAG: v1.2 + LIBRARY_DESTINATION_DIRECTORY: 'src/ITS/PropLibTemplate' jobs: build_wheel: diff --git a/pyproject.toml b/pyproject.toml index f78a26c..2aa50ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,8 @@ tests = [ dev = [ "hatchling>=1.25.0,<2.0", "pre-commit>=4.0.1,<5.0", - "proplib-template[tests]", # TODO-TEMPLATE set to this package + "pytest>=8.2.0,<9.0", + "pytest-cov>=6.0.0,<7.0", ] [project.urls] @@ -65,5 +66,9 @@ ignore-vcs = true ignore-vcs = true [tool.cibuildwheel] -test-command = "pytest ." +test-command = "pytest tests" test-requires = "pytest" + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] diff --git a/src/ITS/PropLibTemplate/__init__.py b/src/ITS/PropLibTemplate/__init__.py index 98e008e..a870a7b 100644 --- a/src/ITS/PropLibTemplate/__init__.py +++ b/src/ITS/PropLibTemplate/__init__.py @@ -3,6 +3,7 @@ __version__ = "1.0.0" # TODO-TEMPLATE import the functions and objects for the package to expose -# from .proplib_template import ( - -# ) +from .proplib_template import ( + GetLibraryName, + GetLibraryVersion, +) diff --git a/src/ITS/PropLibTemplate/proplib_loader.py b/src/ITS/PropLibTemplate/proplib_loader.py index 21702e3..f93cc9d 100644 --- a/src/ITS/PropLibTemplate/proplib_loader.py +++ b/src/ITS/PropLibTemplate/proplib_loader.py @@ -44,19 +44,30 @@ import platform import struct -from ctypes import * +from ctypes import CDLL, POINTER, c_char_p, c_int, cast from pathlib import Path class PropLibCDLL(CDLL): - def __init__(self, name): + """Load a shared library and expose common error.""" + def __init__(self, name: str) -> None: full_name = self.get_lib_name(name) + if not Path(full_name).is_file(): + raise FileNotFoundError( + f"Shared library '{name}' was not found at '{full_name}'." + ) super().__init__(full_name) + # Define expected function prototypes self.GetReturnStatusCharArray.restype = POINTER(c_char_p) self.GetReturnStatusCharArray.argtypes = (c_int,) - self.FreeReturnStatusCharArray.restype = None - self.FreeReturnStatusCharArray.argtypes = (POINTER(c_char_p),) + self.FreeCharArray.restype = None + self.FreeCharArray.argtypes = (POINTER(c_char_p),) + self.GetLibraryNameCharArray.restype = POINTER(c_char_p) + self.GetLibraryNameCharArray.argtypes = None + self.GetLibraryVersionCharArray.restype = POINTER(c_char_p) + self.GetLibraryVersionCharArray.argtypes = None + @staticmethod def get_lib_name(lib_name: str) -> str: @@ -72,24 +83,24 @@ def get_lib_name(lib_name: str) -> str: :return: The full filename, including path and extension, of the library. """ # Load the compiled library - if platform.uname()[0] == "Windows": + system = platform.system() + if system == "Windows": arch = struct.calcsize("P") * 8 # 32 or 64 if arch == 64: - lib_name += "-x64.dll" + suffix = "-x64.dll" elif arch == 32: - lib_name += "-x86.dll" + suffix = "-x86.dll" else: raise RuntimeError( "Failed to determine system architecture for DLL loading" ) - elif platform.uname()[0] == "Linux": - lib_name += "-x86_64.so" - elif platform.uname()[0] == "Darwin": - lib_name += "-universal.dylib" + elif system == "Linux": + suffix = "-x86_64.so" + elif system == "Darwin": + suffix = "-universal.dylib" else: - raise NotImplementedError("Your OS is not yet supported") - # Library should be in the same directory as this file - lib_path = Path(__file__).parent / lib_name + raise NotImplementedError(f"Unsupported operating system: {system}") + lib_path = Path(__file__).parent / f"{lib_name}{suffix}" return str(lib_path.resolve()) def err_check(self, rtn_code: int) -> None: @@ -105,8 +116,15 @@ def err_check(self, rtn_code: int) -> None: """ if rtn_code == 0: return - else: - msg = self.GetReturnStatusCharArray(c_int(rtn_code)) - msg_str = cast(msg, c_char_p).value.decode("utf-8") - self.FreeReturnStatusCharArray(msg) - raise RuntimeError(msg_str) + + msg = self.GetReturnStatusCharArray(c_int(rtn_code)) + try: + msg_bytes = cast(msg, c_char_p).value + if msg_bytes is None: + raise RuntimeError( + f"Library call failed with code {rtn_code}, but no error text was returned." + ) + msg_str = msg_bytes.decode("utf-8") + finally: + self.FreeCharArray(msg) + raise RuntimeError(msg_str) diff --git a/src/ITS/PropLibTemplate/proplib_template.py b/src/ITS/PropLibTemplate/proplib_template.py index 737dcb7..9f9fb41 100644 --- a/src/ITS/PropLibTemplate/proplib_template.py +++ b/src/ITS/PropLibTemplate/proplib_template.py @@ -3,8 +3,10 @@ from .proplib_loader import PropLibCDLL -# TODO-TEMPLATE: Load the shared library. Example: -# lib = PropLibCDLL("P2108-1.0") + +# TODO-TEMPLATE: Rename to your library's main entry point, e.g., "P2108-1.0" +# Load the shared library. +lib = PropLibCDLL("PropLibTemplate-1.2") # Define function prototypes # TODO-TEMPLATE add function prototypes here. Each function should have @@ -17,4 +19,20 @@ # POINTER(c_double), # ) -# TODO-TEMPLATE: Populate this file with wrapper functions which call the library + +def GetLibraryName() -> str: + return __read_char_array(lib.GetLibraryNameCharArray()) + + +def GetLibraryVersion() -> str: + return __read_char_array(lib.GetLibraryVersionCharArray()) + + +def __read_char_array(msg) -> str: + try: + msg_bytes = cast(msg, c_char_p).value + if msg_bytes is None: + raise RuntimeError("The TODO-TEMPLATE library returned an empty text response.") + return msg_bytes.decode("utf-8") + finally: + lib.FreeCharArray(msg) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..b25dcc2 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for the TODO-TEMPLATE Python wrapper.""" diff --git a/tests/test_proplib_loader.py b/tests/test_proplib_loader.py new file mode 100644 index 0000000..1876960 --- /dev/null +++ b/tests/test_proplib_loader.py @@ -0,0 +1,91 @@ +from ctypes import POINTER, c_char_p, cast + +import pytest +from ITS.PropLibTemplate.proplib_loader import PropLibCDLL + + +@pytest.mark.parametrize( + ("system_name", "pointer_size", "suffix"), + [ + ("Windows", 8, "-x64.dll"), + ("Windows", 4, "-x86.dll"), + ("Linux", 8, "-x86_64.so"), + ("Darwin", 8, "-universal.dylib"), + ], +) +def test_get_lib_name_uses_expected_platform_suffix( + monkeypatch: pytest.MonkeyPatch, + system_name: str, + pointer_size: int, + suffix: str, +) -> None: + monkeypatch.setattr("ITS.PropLibTemplate.proplib_loader.platform.system", lambda: system_name) + monkeypatch.setattr("ITS.PropLibTemplate.proplib_loader.struct.calcsize", lambda _: pointer_size) + + lib_path = PropLibCDLL.get_lib_name("Example-1.0") + + assert lib_path.endswith(suffix) + assert "Example-1.0" in lib_path + + +def test_get_lib_name_rejects_unknown_platform( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("ITS.PropLibTemplate.proplib_loader.platform.system", lambda: "Plan9") + + with pytest.raises(NotImplementedError, match="Unsupported operating system: Plan9"): + PropLibCDLL.get_lib_name("Example-1.0") + + +def test_constructor_raises_clear_error_for_missing_library( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_path = r"C:\missing\Example-1.0-x64.dll" + monkeypatch.setattr(PropLibCDLL, "get_lib_name", staticmethod(lambda _: fake_path)) + + with pytest.raises(FileNotFoundError, match="Shared library 'Example-1.0' was not found"): + PropLibCDLL("Example-1.0") + + +def test_err_check_returns_for_success() -> None: + class FakeLibrary: + pass + + PropLibCDLL.err_check(FakeLibrary(), 0) + + +def test_err_check_raises_library_message() -> None: + error_message = b"example failure" + freed_messages: list[object] = [] + + class FakeLibrary: + @staticmethod + def GetReturnStatusCharArray(_code): + return cast(c_char_p(error_message), POINTER(c_char_p)) + + @staticmethod + def FreeCharArray(message) -> None: + freed_messages.append(message) + + with pytest.raises(RuntimeError, match="example failure"): + PropLibCDLL.err_check(FakeLibrary(), 5) + + assert len(freed_messages) == 1 + + +def test_err_check_handles_missing_error_text() -> None: + freed_messages: list[object] = [] + + class FakeLibrary: + @staticmethod + def GetReturnStatusCharArray(_code): + return cast(c_char_p(None), POINTER(c_char_p)) + + @staticmethod + def FreeCharArray(message) -> None: + freed_messages.append(message) + + with pytest.raises(RuntimeError, match="no error text was returned"): + PropLibCDLL.err_check(FakeLibrary(), 9) + + assert len(freed_messages) == 1 diff --git a/tests/test_utils.py b/tests/test_utils.py index f1e6b82..64dbe5e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,10 +1,13 @@ import csv from pathlib import Path +from typing import Any + +from ITS import PropLibTemplate # Test data is expected to exist in tests/data # TODO-TEMPLATE: Remove the '#' in the line below after adding your test data submodule TEST_DATA_DIR = Path(__file__).parent # / "data" -ABSTOL__DB = 0.1 # Absolute tolerance, in dB, to ensure outputs match expected value +ABSTOL__DB = 0.001 # Absolute tolerance, in dB, to ensure outputs match expected value # Check if test data directory exists and is not empty if not TEST_DATA_DIR.exists() or not any(TEST_DATA_DIR.iterdir()): @@ -15,10 +18,26 @@ # TODO-TEMPLATE: Update CSV reader based on test data CSV structure -def read_csv_test_data(filename: str): - with open(TEST_DATA_DIR / filename) as f: - reader = csv.reader(f) - next(reader) # Skip header row +def _resolve_test_data_file(filename: str, data_dir: Path | None = None) -> Path: + """Resolve a CSV test-data file and raise a clear error when it is missing.""" + + base_dir = data_dir or TEST_DATA_DIR + file_path = base_dir / filename + if not file_path.is_file(): + raise FileNotFoundError( + f"Test data file '{filename}' was not found in '{base_dir}'. " + "Clone or populate the test-data submodule before running data-backed tests." + ) + return file_path + + +def read_csv_test_data(filename: str, data_dir: Path | None = None): + """Yield ``(*inputs, rtn, output)`` tuples from a simple numeric CSV file.""" + + file_path = _resolve_test_data_file(filename, data_dir) + with file_path.open(encoding="utf_8_sig", newline="") as infile: + reader = csv.reader(infile) + next(reader) for row in reader: # yields (*inputs, rtn, output) yield tuple(map(float, row[:-2])), int(row[-2]), float(row[-1]) @@ -30,8 +49,15 @@ def read_csv_test_data(filename: str): csv_to_test_dict("TestData.csv", {"rtn" : int, "input_1" : float, "input_2" : int, "output" : float}), """ -def csv_to_test_dict(filename, type_dict): - with open(TEST_DATA_DIR / filename, mode='r', encoding='utf_8_sig') as infile: +def csv_to_test_dict( + filename: str, + type_dict: dict[str, type[Any]], + data_dir: Path | None = None, +): + """Yield dictionaries converted from CSV rows using the supplied type map.""" + + file_path = _resolve_test_data_file(filename, data_dir) + with file_path.open(encoding="utf_8_sig", newline="") as infile: reader = csv.reader(infile, skipinitialspace=True) keys = next(reader) for row in reader: @@ -45,3 +71,13 @@ def csv_to_test_dict(filename, type_dict): # TODO-TEMPLATE: Delete this dummy test and write your own in another file. def test_always_pass(): return + + +def test_LibraryName(): + name = PropLibTemplate.GetLibraryName() + assert name == 'PropLibTemplate' + + +def test_LibraryVersion(): + version = PropLibTemplate.GetLibraryVersion() + assert version == '1.2' \ No newline at end of file From 977e08b719a7400425ff5510169f221a933bec9e Mon Sep 17 00:00:00 2001 From: Chen Heroy Date: Thu, 3 Sep 2026 09:46:12 -0600 Subject: [PATCH 05/12] submodule token --- .github/workflows/pytest.yml | 6 ++++-- .github/workflows/release.yml | 2 -- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index a22c18a..6e3f60a 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -131,8 +131,10 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v7 - with: - submodules: true + #with: + ## TODO: needs to remove token if NTIA submodule repos are public + #token: ${{ secrets.READONLY_TOKEN }} # Used to checkout non-public repo submodule + #submodules: true # Cache key is unique to the combination of runner OS + architecture (matrix.arch-id) + release tag - name: Restore ${{ env.LIBRARY_RELEASE_TAG }} binaries from cache if available diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4386d7..a5adc24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,8 +38,6 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v7 - with: - submodules: true # Only the binaries required for the current platform are downloaded. Note that the distributed # wheel for proplib python packages includes all binaries, so that the wheel is inherently cross-platform. From 1d5bba1e41bfd0eb49d288149750a0056580b4c1 Mon Sep 17 00:00:00 2001 From: Chen Heroy Date: Thu, 3 Sep 2026 09:53:17 -0600 Subject: [PATCH 06/12] add token for downloading C++ library --- .github/workflows/pytest.yml | 2 ++ .github/workflows/release.yml | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 6e3f60a..c26656b 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -150,6 +150,8 @@ jobs: if: ${{ steps.cache-restore.outputs.cache-hit != 'true' }} uses: robinraju/release-downloader@v1.13 with: + # TODO: needs to remove token once C++ library is released to the public + token: ${{ secrets.READONLY_TOKEN }} # Used to download non-public repo release repository: ${{ env.LIBRARY_BASE_REPO }} tag: ${{ env.LIBRARY_RELEASE_TAG }} fileName: ${{ matrix.platform.release-file-pattern }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5adc24..9aee4db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,6 +44,8 @@ jobs: - name: Download required ${{ env.LIBRARY_RELEASE_TAG }} Windows binaries uses: robinraju/release-downloader@v1.13 with: + # TODO: needs to remove token once C++ library is released to the public + token: ${{ secrets.READONLY_TOKEN }} # Used to download non-public repo release repository: ${{ env.LIBRARY_BASE_REPO }} tag: ${{ env.LIBRARY_RELEASE_TAG }} fileName: '*.dll' @@ -54,6 +56,8 @@ jobs: - name: Download required ${{ env.LIBRARY_RELEASE_TAG }} Linux binaries uses: robinraju/release-downloader@v1.13 with: + # TODO: needs to remove token once C++ library is released to the public + token: ${{ secrets.READONLY_TOKEN }} # Used to download non-public repo release repository: ${{ env.LIBRARY_BASE_REPO }} tag: ${{ env.LIBRARY_RELEASE_TAG }} fileName: '*.so' @@ -64,6 +68,8 @@ jobs: - name: Download required ${{ env.LIBRARY_RELEASE_TAG }} macOS binaries uses: robinraju/release-downloader@v1.13 with: + # TODO: needs to remove token once C++ library is released to the public + token: ${{ secrets.READONLY_TOKEN }} # Used to download non-public repo release repository: ${{ env.LIBRARY_BASE_REPO }} tag: ${{ env.LIBRARY_RELEASE_TAG }} fileName: '*.dylib' From 1f69da79acd60870f6ca7f39f246676f04f35aaa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:57:16 +0000 Subject: [PATCH 07/12] Initial plan From 44ec88a702fab74209572ef52294c3817431931e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:59:26 +0000 Subject: [PATCH 08/12] fix: make test helper type hints Python 3.9 compatible Co-authored-by: cheroy-ntia <58193992+cheroy-ntia@users.noreply.github.com> --- tests/test_utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 64dbe5e..b53a05c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,6 @@ import csv from pathlib import Path -from typing import Any +from typing import Any, Optional from ITS import PropLibTemplate @@ -18,7 +18,7 @@ # TODO-TEMPLATE: Update CSV reader based on test data CSV structure -def _resolve_test_data_file(filename: str, data_dir: Path | None = None) -> Path: +def _resolve_test_data_file(filename: str, data_dir: Optional[Path] = None) -> Path: """Resolve a CSV test-data file and raise a clear error when it is missing.""" base_dir = data_dir or TEST_DATA_DIR @@ -31,7 +31,7 @@ def _resolve_test_data_file(filename: str, data_dir: Path | None = None) -> Path return file_path -def read_csv_test_data(filename: str, data_dir: Path | None = None): +def read_csv_test_data(filename: str, data_dir: Optional[Path] = None): """Yield ``(*inputs, rtn, output)`` tuples from a simple numeric CSV file.""" file_path = _resolve_test_data_file(filename, data_dir) @@ -52,7 +52,7 @@ def read_csv_test_data(filename: str, data_dir: Path | None = None): def csv_to_test_dict( filename: str, type_dict: dict[str, type[Any]], - data_dir: Path | None = None, + data_dir: Optional[Path] = None, ): """Yield dictionaries converted from CSV rows using the supplied type map.""" From 88f5dcddb9ef88aac8ad7ae53f4e2c2d7babe82e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:05:32 +0000 Subject: [PATCH 09/12] Initial plan From 0411648dabdcfbb41608040318f4ef74199756d5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:07:33 +0000 Subject: [PATCH 10/12] fix: run macOS x64 pytest job on intel runner Co-authored-by: cheroy-ntia <58193992+cheroy-ntia@users.noreply.github.com> --- .github/workflows/pytest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index c26656b..c492c6b 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -85,7 +85,7 @@ jobs: arch-id: 'x86' release-file-pattern: '*-x86.dll' - os-name: 'macOS (intel/x64)' - os-runner: 'macos-15' # Intel via macos-15 x64 image, current stable (Aug 2026) + os-runner: 'macos-15-intel' # Explicit Intel runner required for x64 Python setup arch-id: 'x64' release-file-pattern: '*.dylib' - os-name: 'macOS (apple/arm64)' From d4a7e08d01ea16a1644fc48e9a2a6900e3950b0c Mon Sep 17 00:00:00 2001 From: Chen Heroy Date: Thu, 3 Sep 2026 10:57:35 -0600 Subject: [PATCH 11/12] reformet --- .github/workflows/pytest.yml | 82 +++++++++++++++++------------------ .github/workflows/release.yml | 6 +-- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index c492c6b..8f5ea60 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -4,7 +4,6 @@ on: ## Auto-triggers are disabled by default. Uncomment the ## push/pull_request blocks below to enable CI on branches/PRs. Until then, ## the workflow runs only via manual dispatch (Actions tab → Run workflow). - workflow_dispatch: #push: # branches: # - main @@ -21,6 +20,7 @@ on: # - 'tests/**' # - 'pyproject.toml' # - '.github/workflows/pytest.yml' + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} @@ -36,9 +36,9 @@ permissions: # LIBRARY_RELEASE_TAG: The Git tag identifying the binary release to test. Can be a pre-release. # LIBRARY_DESTINATION_DIRECTORY: Path in this repo where shared library files should be placed. env: - LIBRARY_BASE_REPO: NTIA/proplib-template - LIBRARY_RELEASE_TAG: v1.2 - LIBRARY_DESTINATION_DIRECTORY: 'src/ITS/PropLibTemplate' + LIBRARY_BASE_REPO: NTIA/proplib-template + LIBRARY_RELEASE_TAG: v1.2 + LIBRARY_DESTINATION_DIRECTORY: 'src/ITS/PropLibTemplate' jobs: should-run: @@ -129,45 +129,45 @@ jobs: release-file-pattern: '*.so' py: "3.13" steps: - - name: Check out repository - uses: actions/checkout@v7 - #with: - ## TODO: needs to remove token if NTIA submodule repos are public - #token: ${{ secrets.READONLY_TOKEN }} # Used to checkout non-public repo submodule - #submodules: true + - name: Check out repository + uses: actions/checkout@v7 + #with: + ## TODO: needs to remove token if NTIA submodule repos are public + #token: ${{ secrets.READONLY_TOKEN }} # Used to checkout non-public repo submodule + #submodules: true - # Cache key is unique to the combination of runner OS + architecture (matrix.arch-id) + release tag - - name: Restore ${{ env.LIBRARY_RELEASE_TAG }} binaries from cache if available - id: cache-restore - uses: actions/cache@v6 - with: - key: ${{ runner.os }}-${{ matrix.platform.arch-id }}-${{ env.LIBRARY_RELEASE_TAG }} - path: ${{ env.LIBRARY_DESTINATION_DIRECTORY}}/${{ matrix.platform.release-file-pattern }} + # Cache key is unique to the combination of runner OS + architecture (matrix.arch-id) + release tag + - name: Restore ${{ env.LIBRARY_RELEASE_TAG }} binaries from cache if available + id: cache-restore + uses: actions/cache@v6 + with: + key: ${{ runner.os }}-${{ matrix.platform.arch-id }}-${{ env.LIBRARY_RELEASE_TAG }} + path: ${{ env.LIBRARY_DESTINATION_DIRECTORY}}/${{ matrix.platform.release-file-pattern }} - # Only the binaries required for the current platform are downloaded. Note that the distributed - # wheel for proplib python packages includes all binaries, so that the wheel is inherently cross-platform. - - name: Download required ${{ env.LIBRARY_RELEASE_TAG }} binaries - if: ${{ steps.cache-restore.outputs.cache-hit != 'true' }} - uses: robinraju/release-downloader@v1.13 - with: - # TODO: needs to remove token once C++ library is released to the public - token: ${{ secrets.READONLY_TOKEN }} # Used to download non-public repo release - repository: ${{ env.LIBRARY_BASE_REPO }} - tag: ${{ env.LIBRARY_RELEASE_TAG }} - fileName: ${{ matrix.platform.release-file-pattern }} - tarBall: false - zipBall: false - out-file-path: ${{ env.LIBRARY_DESTINATION_DIRECTORY }} + # Only the binaries required for the current platform are downloaded. Note that the distributed + # wheel for proplib python packages includes all binaries, so that the wheel is inherently cross-platform. + - name: Download required ${{ env.LIBRARY_RELEASE_TAG }} binaries + if: ${{ steps.cache-restore.outputs.cache-hit != 'true' }} + uses: robinraju/release-downloader@v1.13 + with: + # TODO: needs to remove token once C++ library is released to the public + token: ${{ secrets.READONLY_TOKEN }} # Used to download non-public repo release + repository: ${{ env.LIBRARY_BASE_REPO }} + tag: ${{ env.LIBRARY_RELEASE_TAG }} + fileName: ${{ matrix.platform.release-file-pattern }} + tarBall: false + zipBall: false + out-file-path: ${{ env.LIBRARY_DESTINATION_DIRECTORY }} - - name: Set up Python ${{ matrix.py }} - uses: actions/setup-python@v6 - with: - architecture: ${{ matrix.platform.arch-id }} - python-version: ${{ matrix.py }} - cache: 'pip' + - name: Set up Python ${{ matrix.py }} + uses: actions/setup-python@v6 + with: + architecture: ${{ matrix.platform.arch-id }} + python-version: ${{ matrix.py }} + cache: 'pip' - - name: Install dependencies for testing - run: python -m pip install -e .[tests] + - name: Install dependencies for testing + run: python -m pip install -e .[tests] - - name: Run pytest - run: pytest --cov-report=term-missing --no-cov-on-fail --cov + - name: Run pytest + run: pytest --cov-report=term-missing --no-cov-on-fail --cov diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9aee4db..e0d832c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,9 +27,9 @@ permissions: # LIBRARY_RELEASE_TAG: The Git tag identifying the binary release to test. Can be a pre-release. # LIBRARY_DESTINATION_DIRECTORY: Path in this repo where shared library files should be placed. env: - LIBRARY_BASE_REPO: NTIA/proplib-template - LIBRARY_RELEASE_TAG: v1.2 - LIBRARY_DESTINATION_DIRECTORY: 'src/ITS/PropLibTemplate' + LIBRARY_BASE_REPO: NTIA/proplib-template + LIBRARY_RELEASE_TAG: v1.2 + LIBRARY_DESTINATION_DIRECTORY: 'src/ITS/PropLibTemplate' jobs: build_wheel: From ee31647a7fab6ce47a4148814a19996e27c76e25 Mon Sep 17 00:00:00 2001 From: Chen Heroy Date: Thu, 3 Sep 2026 14:46:11 -0600 Subject: [PATCH 12/12] add submodule --- .github/workflows/pytest.yml | 2 ++ .github/workflows/release.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 9448ac3..66f3a1f 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -130,6 +130,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v7 + with: + submodules: true # Cache key is unique to the combination of runner OS + architecture (matrix.arch-id) + release tag - name: Restore ${{ env.LIBRARY_RELEASE_TAG }} binaries from cache if available diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 587d831..7280523 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,6 +37,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v7 + with: + submodules: true # Only the binaries required for the current platform are downloaded. Note that the distributed # wheel for proplib python packages includes all binaries, so that the wheel is inherently cross-platform.