From 3e57327f6a4e4b665cade0ffbb6670fd55efe3a8 Mon Sep 17 00:00:00 2001 From: caterryan Date: Fri, 10 Jul 2026 11:53:27 -0500 Subject: [PATCH 1/2] ci: publish plugin utils to PyPI --- .../publish-influxdata-plugin-utils.yml | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 .github/workflows/publish-influxdata-plugin-utils.yml diff --git a/.github/workflows/publish-influxdata-plugin-utils.yml b/.github/workflows/publish-influxdata-plugin-utils.yml new file mode 100644 index 0000000..e06d756 --- /dev/null +++ b/.github/workflows/publish-influxdata-plugin-utils.yml @@ -0,0 +1,108 @@ +name: Publish influxdata-plugin-utils + +on: + push: + tags: + - 'pkg-v*' + +concurrency: + group: publish-influxdata-plugin-utils-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +env: + PACKAGE_DIR: influxdata-plugin-utils + PACKAGE_NAME: influxdata-plugin-utils + +jobs: + build: + name: Build distribution + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.x" + + - name: Verify tag matches package version + shell: bash + run: | + set -euo pipefail + python - <<'PY' + import ast + import os + import sys + from pathlib import Path + + tag = os.environ["GITHUB_REF_NAME"] + prefix = "pkg-v" + if not tag.startswith(prefix): + print(f"Expected tag to start with {prefix!r}, got {tag!r}", file=sys.stderr) + sys.exit(1) + + expected = tag.removeprefix(prefix) + init_path = Path(os.environ["PACKAGE_DIR"]) / "src" / "influxdata_plugin_utils" / "__init__.py" + module = ast.parse(init_path.read_text()) + actual = None + for node in module.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "__version__": + actual = ast.literal_eval(node.value) + break + if actual is not None: + break + + if actual != expected: + print( + f"Tag {tag!r} resolves to version {expected!r}, but package version is {actual!r}", + file=sys.stderr, + ) + sys.exit(1) + print(f"Publishing {os.environ['PACKAGE_NAME']} {actual}") + PY + + - name: Install build tooling + run: python -m pip install --upgrade build twine + + - name: Build wheel and source distribution + working-directory: influxdata-plugin-utils + run: python -m build + + - name: Check distribution metadata + working-directory: influxdata-plugin-utils + run: python -m twine check dist/* + + - name: Upload distribution artifact + uses: actions/upload-artifact@v5 + with: + name: influxdata-plugin-utils-dist + path: influxdata-plugin-utils/dist/ + if-no-files-found: error + + publish: + name: Publish distribution to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/influxdata-plugin-utils + permissions: + contents: read + id-token: write + steps: + - name: Download distribution artifact + uses: actions/download-artifact@v6 + with: + name: influxdata-plugin-utils-dist + path: dist/ + + - name: Publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 From ff063838b18fd39b480e67de6ec3a5c5ccf6731e Mon Sep 17 00:00:00 2001 From: caterryan Date: Fri, 10 Jul 2026 12:19:27 -0500 Subject: [PATCH 2/2] ci(plugin-utils): harden publish pipeline, add package CI - pin all actions to commit SHAs; publish job holds id-token: write, mutable refs there are a supply-chain risk - gate publish on new test-influxdata-plugin-utils workflow: pytest on 3.11/3.13, build + twine check, change-detection so the always-on result job is safe to require on unrelated PRs - rename release tags pkg-v* -> utils-v* and verify tag/package versions via packaging (PEP 440), failing if __version__ is missing - add parsing and package smoke tests (54 tests) Refs #114 --- .../publish-influxdata-plugin-utils.yml | 51 +++++-- .../test-influxdata-plugin-utils.yml | 130 ++++++++++++++++ influxdata-plugin-utils/CHANGELOG.md | 4 +- influxdata-plugin-utils/tests/test_package.py | 23 +++ influxdata-plugin-utils/tests/test_parsing.py | 139 ++++++++++++++++++ 5 files changed, 330 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/test-influxdata-plugin-utils.yml create mode 100644 influxdata-plugin-utils/tests/test_package.py create mode 100644 influxdata-plugin-utils/tests/test_parsing.py diff --git a/.github/workflows/publish-influxdata-plugin-utils.yml b/.github/workflows/publish-influxdata-plugin-utils.yml index e06d756..deb2efe 100644 --- a/.github/workflows/publish-influxdata-plugin-utils.yml +++ b/.github/workflows/publish-influxdata-plugin-utils.yml @@ -3,7 +3,7 @@ name: Publish influxdata-plugin-utils on: push: tags: - - 'pkg-v*' + - 'utils-v*' concurrency: group: publish-influxdata-plugin-utils-${{ github.ref }} @@ -15,22 +15,33 @@ permissions: env: PACKAGE_DIR: influxdata-plugin-utils PACKAGE_NAME: influxdata-plugin-utils + TAG_PREFIX: utils-v jobs: + test: + name: Test + uses: ./.github/workflows/test-influxdata-plugin-utils.yml + permissions: + contents: read + build: name: Build distribution + needs: test runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.x" + - name: Install build tooling + run: python -m pip install --upgrade build twine packaging + - name: Verify tag matches package version shell: bash run: | @@ -41,8 +52,10 @@ jobs: import sys from pathlib import Path + from packaging.version import InvalidVersion, Version + tag = os.environ["GITHUB_REF_NAME"] - prefix = "pkg-v" + prefix = os.environ["TAG_PREFIX"] if not tag.startswith(prefix): print(f"Expected tag to start with {prefix!r}, got {tag!r}", file=sys.stderr) sys.exit(1) @@ -60,7 +73,18 @@ jobs: if actual is not None: break - if actual != expected: + if actual is None: + print(f"Could not find __version__ in {init_path}", file=sys.stderr) + sys.exit(1) + + try: + tag_version = Version(expected) + package_version = Version(actual) + except InvalidVersion as exc: + print(f"Invalid version: {exc}", file=sys.stderr) + sys.exit(1) + + if tag_version != package_version: print( f"Tag {tag!r} resolves to version {expected!r}, but package version is {actual!r}", file=sys.stderr, @@ -69,22 +93,19 @@ jobs: print(f"Publishing {os.environ['PACKAGE_NAME']} {actual}") PY - - name: Install build tooling - run: python -m pip install --upgrade build twine - - name: Build wheel and source distribution - working-directory: influxdata-plugin-utils + working-directory: ${{ env.PACKAGE_DIR }} run: python -m build - name: Check distribution metadata - working-directory: influxdata-plugin-utils + working-directory: ${{ env.PACKAGE_DIR }} run: python -m twine check dist/* - name: Upload distribution artifact - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: - name: influxdata-plugin-utils-dist - path: influxdata-plugin-utils/dist/ + name: ${{ env.PACKAGE_NAME }}-dist + path: ${{ env.PACKAGE_DIR }}/dist/ if-no-files-found: error publish: @@ -99,10 +120,10 @@ jobs: id-token: write steps: - name: Download distribution artifact - uses: actions/download-artifact@v6 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: influxdata-plugin-utils-dist path: dist/ - name: Publish distribution to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 diff --git a/.github/workflows/test-influxdata-plugin-utils.yml b/.github/workflows/test-influxdata-plugin-utils.yml new file mode 100644 index 0000000..0a5e881 --- /dev/null +++ b/.github/workflows/test-influxdata-plugin-utils.yml @@ -0,0 +1,130 @@ +name: Test influxdata-plugin-utils + +on: + pull_request: + push: + branches: + - main + paths: + - 'influxdata-plugin-utils/**' + - '.github/workflows/test-influxdata-plugin-utils.yml' + - '.github/workflows/publish-influxdata-plugin-utils.yml' + workflow_call: + +permissions: + contents: read + +env: + PACKAGE_DIR: influxdata-plugin-utils + +jobs: + changes: + # Runs on every pull request (no paths filter) so the `result` job below + # always reports a status and can be marked required without wedging + # unrelated PRs. Non-PR events (push, tag publish via workflow_call) + # always run the full suite. + name: Detect package changes + runs-on: ubuntu-latest + outputs: + package: ${{ steps.filter.outputs.package }} + steps: + - name: Checkout + if: github.event_name == 'pull_request' + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Filter changed paths + id: filter + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + if [ "${GITHUB_EVENT_NAME}" != "pull_request" ]; then + echo "package=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + changed=$(git diff --name-only "${BASE_SHA}...HEAD") + echo "Changed files:" + echo "${changed}" + if echo "${changed}" | grep -qE '^(influxdata-plugin-utils/|\.github/workflows/(test|publish)-influxdata-plugin-utils\.yml$)'; then + echo "package=true" >> "$GITHUB_OUTPUT" + else + echo "package=false" >> "$GITHUB_OUTPUT" + fi + + test: + name: Test (Python ${{ matrix.python-version }}) + needs: changes + if: needs.changes.outputs.package == 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.13"] + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package and test dependencies + run: | + python -m pip install --upgrade pip + python -m pip install "./${PACKAGE_DIR}" pytest packaging + + - name: Run tests + working-directory: ${{ env.PACKAGE_DIR }} + run: python -m pytest tests -v + + build-check: + name: Build check + needs: changes + if: needs.changes.outputs.package == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.x" + + - name: Install build tooling + run: python -m pip install --upgrade build twine + + - name: Build wheel and source distribution + working-directory: ${{ env.PACKAGE_DIR }} + run: python -m build + + - name: Check distribution metadata + working-directory: ${{ env.PACKAGE_DIR }} + run: python -m twine check dist/* + + result: + # Single stable check name for branch protection. Passes when package + # jobs were skipped (no package changes in the PR). + name: influxdata-plugin-utils-ci + needs: [changes, test, build-check] + if: always() + runs-on: ubuntu-latest + steps: + - name: Check job results + run: | + results="${{ join(needs.*.result, ' ') }}" + echo "Job results: ${results}" + for r in ${results}; do + if [ "${r}" = "failure" ] || [ "${r}" = "cancelled" ]; then + exit 1 + fi + done diff --git a/influxdata-plugin-utils/CHANGELOG.md b/influxdata-plugin-utils/CHANGELOG.md index cfb4926..ac17e36 100644 --- a/influxdata-plugin-utils/CHANGELOG.md +++ b/influxdata-plugin-utils/CHANGELOG.md @@ -22,5 +22,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `write` — `build_line`, `build_line_typed`, `add_field_with_type`, `write_data` (batching + retry), `BatchLines`. -[Unreleased]: https://github.com/influxdata/influxdb3_plugins/compare/pkg-v0.1.0...HEAD -[0.1.0]: https://github.com/influxdata/influxdb3_plugins/releases/tag/pkg-v0.1.0 \ No newline at end of file +[Unreleased]: https://github.com/influxdata/influxdb3_plugins/compare/utils-v0.1.0...HEAD +[0.1.0]: https://github.com/influxdata/influxdb3_plugins/releases/tag/utils-v0.1.0 \ No newline at end of file diff --git a/influxdata-plugin-utils/tests/test_package.py b/influxdata-plugin-utils/tests/test_package.py new file mode 100644 index 0000000..1ff7ef5 --- /dev/null +++ b/influxdata-plugin-utils/tests/test_package.py @@ -0,0 +1,23 @@ +"""Package-level smoke tests: importability, public API, version sanity.""" + +import importlib + +import influxdata_plugin_utils as pkg + +SUBMODULES = ("cache", "config", "introspection", "parsing", "write") + + +def test_version_is_valid_pep440(): + from packaging.version import Version + + Version(pkg.__version__) + + +def test_submodules_import(): + for name in SUBMODULES: + importlib.import_module(f"influxdata_plugin_utils.{name}") + + +def test_all_exports_resolve(): + for name in pkg.__all__: + assert getattr(pkg, name, None) is not None, f"__all__ export {name!r} missing" diff --git a/influxdata-plugin-utils/tests/test_parsing.py b/influxdata-plugin-utils/tests/test_parsing.py new file mode 100644 index 0000000..c25b335 --- /dev/null +++ b/influxdata-plugin-utils/tests/test_parsing.py @@ -0,0 +1,139 @@ +"""Tests for influxdata_plugin_utils.parsing.""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from influxdata_plugin_utils.parsing import ( + parse_bool, + parse_delimited_list, + parse_int, + parse_key_value, + parse_timedelta, + parse_timestamp_ns, +) + + +class TestParseTimedelta: + @pytest.mark.parametrize( + ("raw", "expected"), + [ + ("30s", timedelta(seconds=30)), + ("5min", timedelta(minutes=5)), + ("1h", timedelta(hours=1)), + ("2d", timedelta(days=2)), + ("1w", timedelta(weeks=1)), + (" 10 S ", timedelta(seconds=10)), + ], + ) + def test_valid_durations(self, raw, expected): + assert parse_timedelta(raw) == expected + + def test_timedelta_passthrough(self): + value = timedelta(seconds=1) + assert parse_timedelta(value) is value + + @pytest.mark.parametrize("raw", ["", "abc", "10", "10 fortnights", "1.5h"]) + def test_invalid_durations(self, raw): + with pytest.raises(ValueError): + parse_timedelta(raw) + + +class TestParseTimestampNs: + @pytest.mark.parametrize( + ("value", "fmt", "expected"), + [ + (1, "s", 1_000_000_000), + ("1700000000", "s", 1_700_000_000_000_000_000), + (1_700_000_000_000, "ms", 1_700_000_000_000_000_000), + (1_700_000_000_000_000, "us", 1_700_000_000_000_000_000), + (1_700_000_000_123_456_789, "ns", 1_700_000_000_123_456_789), + ], + ) + def test_epoch_units(self, value, fmt, expected): + assert parse_timestamp_ns(value, fmt) == expected + + def test_large_ns_epoch_is_exact(self): + # would lose precision if routed through float64 + value = 1_700_000_000_123_456_789 + assert parse_timestamp_ns(str(value), "ns") == value + + def test_datetime_string_utc(self): + assert ( + parse_timestamp_ns("2026-01-01T00:00:00Z", "datetime") + == int(datetime(2026, 1, 1, tzinfo=timezone.utc).timestamp()) * 1_000_000_000 + ) + + def test_naive_datetime_treated_as_utc(self): + naive = datetime(2026, 1, 1) + aware = datetime(2026, 1, 1, tzinfo=timezone.utc) + assert parse_timestamp_ns(naive, "datetime") == int(aware.timestamp()) * 1_000_000_000 + + def test_unknown_format_raises(self): + with pytest.raises(ValueError): + parse_timestamp_ns(1, "minutes") + + def test_datetime_format_rejects_non_datetime(self): + with pytest.raises(ValueError): + parse_timestamp_ns(12345, "datetime") + + +class TestParseInt: + def test_parses_and_strips(self): + assert parse_int(" 42 ") == 42 + + def test_bounds(self): + assert parse_int("5", minimum=5, maximum=5) == 5 + with pytest.raises(ValueError): + parse_int("4", minimum=5) + with pytest.raises(ValueError): + parse_int("6", maximum=5) + + @pytest.mark.parametrize("raw", ["", "abc", "1.5", None]) + def test_invalid(self, raw): + with pytest.raises(ValueError): + parse_int(raw) + + +class TestParseBool: + @pytest.mark.parametrize("raw", [True, "true", "T", "1", "yes", "ON"]) + def test_truthy(self, raw): + assert parse_bool(raw) is True + + @pytest.mark.parametrize("raw", [False, "false", "F", "0", "no", "OFF"]) + def test_falsy(self, raw): + assert parse_bool(raw) is False + + @pytest.mark.parametrize("raw", ["", "maybe", "2"]) + def test_invalid(self, raw): + with pytest.raises(ValueError): + parse_bool(raw) + + +class TestParseDelimitedList: + def test_default_space_separator(self): + assert parse_delimited_list("a b c ") == ["a", "b", "c"] + + def test_custom_separator(self): + assert parse_delimited_list("a, b,,c", sep=",") == ["a", "b", "c"] + + def test_list_passthrough_trims_and_drops_empty(self): + assert parse_delimited_list(["a ", "", 1]) == ["a", "1"] + + +class TestParseKeyValue: + def test_parses_pairs(self): + assert parse_key_value("a=1 b=2") == {"a": "1", "b": "2"} + + def test_custom_separators(self): + assert parse_key_value("a:1;b:2", pair_sep=";", kv_sep=":") == {"a": "1", "b": "2"} + + def test_value_may_contain_kv_sep(self): + assert parse_key_value("url=http://x?a=b") == {"url": "http://x?a=b"} + + def test_dict_passthrough_stringifies(self): + assert parse_key_value({"a": 1}) == {"a": "1"} + + def test_missing_separator_raises(self): + with pytest.raises(ValueError): + parse_key_value("a=1 b")