diff --git a/.github/workflows/cff-validator.yml b/.github/workflows/cff-validator.yml index e6b95a5..c80e6ae 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. 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 e8d33ee..23337a5 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -1,99 +1,168 @@ name: Test with pytest 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). + #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' workflow_dispatch: - push: - branches: - - main - pull_request: - branches: - - main - - dev 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 + +# 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/LFMF - LIBRARY_RELEASE_TAG: v1.1 - LIBRARY_DESTINATION_DIRECTORY: 'src/ITS/Propagation/LFMF' + LIBRARY_BASE_REPO: NTIA/LFMF + LIBRARY_RELEASE_TAG: v1.2-rc.2 + LIBRARY_DESTINATION_DIRECTORY: 'src/ITS/Propagation/LFMF' 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' # Explicit Intel runner required for x64 Python setup 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 - with: - submodules: true + - 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 - 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 - with: - 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: + 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 f029087..e80e421 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,42 +1,49 @@ # 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 +# 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/LFMF - LIBRARY_RELEASE_TAG: v1.1 - LIBRARY_DESTINATION_DIRECTORY: 'src/ITS/Propagation/LFMF/' + LIBRARY_BASE_REPO: NTIA/LFMF + LIBRARY_RELEASE_TAG: v1.2-rc.2 + LIBRARY_DESTINATION_DIRECTORY: 'src/ITS/Propagation/LFMF' 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@v6 + 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. - 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 }} @@ -46,7 +53,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 }} @@ -56,7 +63,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 }} @@ -66,7 +73,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/.zenodo.json b/.zenodo.json index 833748a..8e6cc3e 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -38,5 +38,5 @@ "resource_type": "publication-softwaredocumentation" } ], - "version": "1.1.0" + "version": "1.2.0" } diff --git a/CITATION.cff b/CITATION.cff index 9aed93e..ecede78 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -39,4 +39,4 @@ keywords: - propagation - lfmf - antennas -version: 1.1.0 +version: 1.2.0 diff --git a/pyproject.toml b/pyproject.toml index 89d5040..35ab7f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,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] @@ -46,7 +47,8 @@ tests = [ dev = [ "hatchling>=1.25.0,<2.0", "pre-commit>=4.0.1,<5.0", - "proplib-lfmf[tests]", + "pytest>=8.2.0,<9.0", + "pytest-cov>=6.0.0,<7.0", ] [project.urls] @@ -68,5 +70,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/Propagation/LFMF/LFMF.py b/src/ITS/Propagation/LFMF/LFMF.py index 50dbaa0..04d5cc6 100644 --- a/src/ITS/Propagation/LFMF/LFMF.py +++ b/src/ITS/Propagation/LFMF/LFMF.py @@ -32,7 +32,7 @@ class LFMFResult(Structure): # Load the shared library -lib = PropLibCDLL("LFMF-1.1") +lib = PropLibCDLL("LFMF-1.2") # Define function prototypes lib.LFMF.restype = c_int @@ -106,3 +106,21 @@ def __convertResultStruct(c_result): result.method = SolutionMethod(c_result.method) return result + + +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) \ No newline at end of file diff --git a/src/ITS/Propagation/LFMF/__init__.py b/src/ITS/Propagation/LFMF/__init__.py index a0bde28..e00adf3 100644 --- a/src/ITS/Propagation/LFMF/__init__.py +++ b/src/ITS/Propagation/LFMF/__init__.py @@ -1,10 +1,12 @@ # Version X.Y.Z: X.Y is the version of the C++ source, # and Z is the version of this Python wrapper -__version__ = "1.1.0" +__version__ = "1.2.0" from .LFMF import ( LFMF, Polarization, SolutionMethod, - LFMFResult + LFMFResult, + GetLibraryName, + GetLibraryVersion, ) diff --git a/src/ITS/Propagation/LFMF/proplib_loader.py b/src/ITS/Propagation/LFMF/proplib_loader.py index 21702e3..f93cc9d 100644 --- a/src/ITS/Propagation/LFMF/proplib_loader.py +++ b/src/ITS/Propagation/LFMF/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/tests/__init__.py b/tests/__init__.py index e69de29..82a8571 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for the LFMF Python wrapper.""" diff --git a/tests/test_lfmf.py b/tests/test_lfmf.py index c420f49..792e50d 100644 --- a/tests/test_lfmf.py +++ b/tests/test_lfmf.py @@ -1,7 +1,6 @@ import pytest from ITS.Propagation import LFMF - from .test_utils import ABSTOL__DB, read_csv_test_data diff --git a/tests/test_proplib_loader.py b/tests/test_proplib_loader.py new file mode 100644 index 0000000..e7e1013 --- /dev/null +++ b/tests/test_proplib_loader.py @@ -0,0 +1,91 @@ +from ctypes import POINTER, c_char_p, cast + +import pytest +from ITS.Propagation.LFMF.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.Propagation.LFMF.proplib_loader.platform.system", lambda: system_name) + monkeypatch.setattr("ITS.Propagation.LFMF.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.Propagation.LFMF.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 1699a2e..d06b22d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,8 @@ import csv from pathlib import Path +from typing import Optional + +from ITS.Propagation import LFMF # Test data is expected to exist in tests/data TEST_DATA_DIR = Path(__file__).parent / "data" @@ -14,10 +17,36 @@ # Read CSV into dictionary and convert to specified data type -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: 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 + 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: Optional[Path] = 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, *outputs) yield tuple(map(float, row[:-5])), int(row[-5]), tuple(map(float, row[-4:])) + + +def test_LibraryName(): + name = LFMF.GetLibraryName() + assert name == 'LFMF' + + +def test_LibraryVersion(): + version = LFMF.GetLibraryVersion() + assert version == '1.2' \ No newline at end of file