diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8ed0073..f11d06d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -310,6 +310,215 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + # ------------------------------------------ Dependency Bounds ------------------------------------------ + # `unit-tests` installs what uv.lock pins, so it never exercises the ranges in pyproject.toml. + # Upper bounds are tracked separately: an upstream release could turn such a job red on its own. + + # Deterministic enough to gate on, since floors only move when pyproject.toml does. Development + # dependencies mask some floors; `dependency-lower-bounds-no-harness` below reaches those. + dependency-lower-bounds: + env: + # workaround for Rich table column width + COLUMNS: 140 + if: | + always() && !cancelled() && + !contains(needs.*.result, 'failure') && + !contains(needs.*.result, 'cancelled') && + needs.files-changed.outputs.python == 'true' + needs: ["prepare-environment", "files-changed", "yaml-lint", "python-lint"] + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: "Check out repository code" + uses: "actions/checkout@v7" + # Oldest supported interpreter: pyarrow 14, pyyaml 6.0 and ujson 5.0 have no wheels past + # cp312, so a newer one would build them from source. Bump when requires-python changes. + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.10" + - name: Install UV + uses: astral-sh/setup-uv@v7 + with: + version: "${{ needs.prepare-environment.outputs.UV_VERSION }}" + python-version: "3.10" + # Rewrites uv.lock in the checkout, which is discarded with the runner. + - name: "Re-resolve at the declared lower bounds" + run: uv lock --resolution lowest-direct + - name: Install dependencies + run: uv sync --all-groups --all-extras + - name: "Report the resolved versions" + run: uv pip list + - name: Unit Tests + run: uv run pytest --cov infrahub_sdk tests/unit/ + + # Proves a plain install works on every supported interpreter, which the uv.lock-based jobs cannot. + install-matrix: + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + if: | + always() && !cancelled() && + !contains(needs.*.result, 'failure') && + !contains(needs.*.result, 'cancelled') && + needs.files-changed.outputs.python == 'true' + needs: ["prepare-environment", "files-changed", "yaml-lint", "python-lint"] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: "Check out repository code" + uses: "actions/checkout@v7" + with: + # hatch-vcs derives the version from git tags, and a shallow clone has none. + fetch-depth: 0 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + - name: Install UV + uses: astral-sh/setup-uv@v7 + with: + version: "${{ needs.prepare-environment.outputs.UV_VERSION }}" + python-version: ${{ matrix.python-version }} + - name: "Build the wheel" + run: uv build --wheel --out-dir dist/ + # Not `uv sync`: a fresh resolution of the built wheel is what a user actually gets. + - name: "Install the wheel with every extra" + run: | + uv venv --python ${{ matrix.python-version }} .venv-install + uv pip install --python .venv-install "$(echo dist/*.whl)[all]" + - name: "Report the resolved versions" + run: uv pip list --python .venv-install + # Run from elsewhere: `python -` puts the working directory on sys.path, so from the checkout + # this would import the source tree rather than the wheel. + - name: "Import every shipped module" + working-directory: ${{ runner.temp }} + run: | + ${{ github.workspace }}/.venv-install/bin/python - <<'PY' + import importlib, pkgutil, sys + import infrahub_sdk + + failures = [] + for module in pkgutil.walk_packages(infrahub_sdk.__path__, "infrahub_sdk."): + try: + importlib.import_module(module.name) + except BaseException as exc: # noqa: BLE001 + failures.append(f"{module.name}: {type(exc).__name__}: {exc}") + for failure in failures: + print(f"FAIL {failure}") + print(f"{len(failures)} module(s) failed to import") + sys.exit(1 if failures else 0) + PY + - name: "Run the CLI" + run: | + .venv-install/bin/infrahubctl --help + .venv-install/bin/infrahubctl schema --help + .venv-install/bin/infrahubctl branch --help + + # With no dependency groups, nothing pulls pytest, pydantic, anyio or typing-extensions above + # their declared floors, so this is the only job that validates those four. httpx and + # graphql-core stay out of reach: ariadne-codegen requires httpx>=0.28 and graphql-core>=3.2. + # It cannot run the unit suite without the harness, so it checks the surface a user touches. + dependency-lower-bounds-no-harness: + if: | + always() && !cancelled() && + !contains(needs.*.result, 'failure') && + !contains(needs.*.result, 'cancelled') && + needs.files-changed.outputs.python == 'true' + needs: ["prepare-environment", "files-changed", "yaml-lint", "python-lint"] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: "Check out repository code" + uses: "actions/checkout@v7" + with: + # hatch-vcs derives the version from git tags, and a shallow clone has none. + fetch-depth: 0 + # The oldest supported interpreter, for the same wheel-availability reason as the job above. + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.10" + - name: Install UV + uses: astral-sh/setup-uv@v7 + with: + version: "${{ needs.prepare-environment.outputs.UV_VERSION }}" + python-version: "3.10" + - name: "Build the wheel" + run: uv build --wheel --out-dir dist/ + # From pyproject.toml, not the wheel: `--resolution lowest-direct` only lowers what is named + # on the command line, so asking for the wheel leaves its dependencies at their newest. + - name: "Resolve the extras at their declared lower bounds" + run: | + uv pip compile pyproject.toml --extra ctl --extra testing \ + --resolution lowest-direct --python-version 3.10 -o floors.txt + - name: "Install the wheel against those floors, with no dependency groups" + run: | + uv venv --python 3.10 .venv-floors + uv pip install --python .venv-floors -r floors.txt + uv pip install --python .venv-floors --no-deps "$(echo dist/*.whl)" + - name: "Report the resolved versions" + run: uv pip list --python .venv-floors + # From outside the checkout, so this tests the wheel in site-packages and not the source tree. + - name: "Import every shipped module" + working-directory: ${{ runner.temp }} + run: | + ${{ github.workspace }}/.venv-floors/bin/python - <<'PY' + import importlib, pkgutil, sys + import infrahub_sdk + + # `testing.docker` needs the testcontainers extra, which is deliberately absent here. + skip = {"infrahub_sdk.testing.docker"} + failures = [] + for module in pkgutil.walk_packages(infrahub_sdk.__path__, "infrahub_sdk."): + if module.name in skip: + continue + try: + importlib.import_module(module.name) + except BaseException as exc: # noqa: BLE001 + failures.append(f"{module.name}: {type(exc).__name__}: {exc}") + for failure in failures: + print(f"FAIL {failure}") + print(f"{len(failures)} module(s) failed to import") + sys.exit(1 if failures else 0) + PY + - name: "Run the CLI" + run: | + .venv-floors/bin/infrahubctl --help + .venv-floors/bin/infrahubctl schema --help + .venv-floors/bin/infrahubctl branch --help + # Collection is where the plugin does its work; importing it would prove far less. + - name: "Collect a test through the bundled pytest plugin" + working-directory: ${{ runner.temp }} + run: | + rm -rf plugin-check && mkdir -p plugin-check/templates && cd plugin-check + cat > infrahub_config.yml <<'YAML' + --- + jinja2_transforms: + - name: bgp_config + description: "Template for BGP config base" + query: "bgp_sessions" + template_path: "templates/bgp_config.j2" + YAML + cat > test_bgp.yml <<'YAML' + --- + infrahub_tests: + - resource: "Jinja2Transform" + resource_name: "bgp_config" + tests: + - name: smoke + spec: + kind: "jinja2-transform-smoke" + YAML + printf 'router bgp {{ asn }}\n' > templates/bgp_config.j2 + # The default shell is `bash -e` without pipefail, so a pytest failure would otherwise + # be reported as a grep failure. + set -o pipefail + ${{ github.workspace }}/.venv-floors/bin/python -m pytest \ + --infrahub-repo-config=infrahub_config.yml --strict-markers --collect-only -q \ + | tee collected.txt + grep -q "infrahub_jinja2_transform__bgp_config__smoke" collected.txt + # ------------------------------------------ Integration Tests ------------------------------------------ integration-tests-latest-infrahub: if: | diff --git a/changelog/+dependency-bound-ci.housekeeping.md b/changelog/+dependency-bound-ci.housekeeping.md new file mode 100644 index 00000000..9f852cd0 --- /dev/null +++ b/changelog/+dependency-bound-ci.housekeeping.md @@ -0,0 +1 @@ +Added two CI jobs covering the declared dependency ranges, which the existing test matrix never exercised because it installs exactly what `uv.lock` pins. One re-resolves at the declared lower bounds and runs the unit tests; the other builds the wheel and confirms it installs, imports and runs the CLI on every supported Python with a fresh resolution. Upper bounds are tracked separately, since any upstream release could otherwise turn an unrelated pull request red. diff --git a/changelog/+dependency-floor-corrections.fixed.md b/changelog/+dependency-floor-corrections.fixed.md new file mode 100644 index 00000000..b24bea0a --- /dev/null +++ b/changelog/+dependency-floor-corrections.fixed.md @@ -0,0 +1,7 @@ +Two declared lower bounds were wrong and have been corrected. + +`typer` now requires `>=0.16.0`, up from `>=0.15.0`. Combined with the `click>=8.3` the SDK already required, typer 0.15 crashed on any `infrahubctl --help` with `TypeError: Parameter.make_metavar() missing 1 required positional argument`, because click 8.3 changed that signature and typer only adapted in 0.16. The old floor advertised a combination that could not work. + +`Jinja2` now requires `>=3.1.5`, up from `>=3`. On 3.1.4 and earlier, template error reporting points at the wrong template when a nested template uses an undefined variable, and omits the source path when an imported template is missing. + +If you pin either package below its new floor, installing the SDK now fails while resolving instead of breaking once you run it. diff --git a/pyproject.toml b/pyproject.toml index 4a1ca73e..7fc4dd69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,9 @@ dependencies = [ "httpx>=0.20", "anyio>=3.3.0", # 3.3.0 introduced `anyio.Path`, used by the async file handler "typing-extensions>=4.4.0", # 4.4.0 introduced PEP 696 `TypeVar(default=...)` - "Jinja2>=3", # template rendering in `template/` and `protocols_generator/` + # 3.1.5 for the error positions the template error reporting relies on; 3.1.4 reports + # the wrong template for a nested undefined and omits the source path on a missing file. + "Jinja2>=3.1.5", # template rendering in `template/` and `protocols_generator/` "pyyaml>=6", # YAML parsing in `yaml.py`, `spec/`, `template/infrahub_filters.py` and the pytest plugin "rich>=12", # `Traceback`/`Frame`/`Syntax` in the Jinja error model, and transfer progress bars "ujson>=5", @@ -51,7 +53,10 @@ infrahubctl = "infrahub_sdk.ctl.cli:app" ctl = [ "pyarrow>=14", "ruamel.yaml>=0.18", # round-trip mode preserves comments when `schema format` rewrites a file - "typer>=0.15.0", + # 0.16 is the first release that calls click's `Parameter.make_metavar()` with the `ctx` + # argument click 8.3 made mandatory. Below it, any `--help` raises TypeError against the + # `click` floor below. Widening this means lowering that floor, not this one. + "typer>=0.16.0", # Not imported directly; constrains the `click` that `typer` resolves to. "click>=8.3,<9", "ariadne-codegen==0.18.0", @@ -89,7 +94,7 @@ tests = [ "pytest-xdist>=3.3.1", ] lint = [ - "yamllint", + "yamllint>=1.35", "mypy==2.3.1", "ruff==0.15.12", "astroid>=3.1,<4.0", @@ -97,8 +102,8 @@ lint = [ "rumdl==0.2.28", ] types = [ - "types-ujson", - "types-pyyaml", + "types-ujson>=5.10", + "types-pyyaml>=6.0.12", "types-python-slugify>=8.0.0.3", ] docs = [ @@ -109,10 +114,10 @@ dev = [ {include-group = "lint"}, {include-group = "types"}, {include-group = "docs"}, - "ipython", + "ipython>=8.18", "requests>=2.33.0", "prek>=0.3.0", - "codecov", + "codecov>=2.1", "invoke>=2.2.1", "towncrier>=24.8.0", ] diff --git a/uv.lock b/uv.lock index 61ff7ef6..f5fdbaf1 100644 --- a/uv.lock +++ b/uv.lock @@ -851,7 +851,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.20" }, { name = "infrahub-testcontainers", marker = "extra == 'all'", specifier = ">=1.7.3" }, { name = "infrahub-testcontainers", marker = "extra == 'testcontainers'", specifier = ">=1.7.3" }, - { name = "jinja2", specifier = ">=3" }, + { name = "jinja2", specifier = ">=3.1.5" }, { name = "netutils", specifier = ">=1.0.0" }, { name = "packaging", marker = "extra == 'all'", specifier = ">=21.0" }, { name = "packaging", marker = "extra == 'testcontainers'", specifier = ">=21.0" }, @@ -867,8 +867,8 @@ requires-dist = [ { name = "ruamel-yaml", marker = "extra == 'all'", specifier = ">=0.18" }, { name = "ruamel-yaml", marker = "extra == 'ctl'", specifier = ">=0.18" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=1.1.0" }, - { name = "typer", marker = "extra == 'all'", specifier = ">=0.15.0" }, - { name = "typer", marker = "extra == 'ctl'", specifier = ">=0.15.0" }, + { name = "typer", marker = "extra == 'all'", specifier = ">=0.16.0" }, + { name = "typer", marker = "extra == 'ctl'", specifier = ">=0.16.0" }, { name = "typing-extensions", specifier = ">=4.4.0" }, { name = "ujson", specifier = ">=5" }, { name = "whenever", specifier = ">=0.9.3,<0.10.0" }, @@ -878,10 +878,10 @@ provides-extras = ["all", "ctl", "testcontainers", "testing"] [package.metadata.requires-dev] dev = [ { name = "astroid", specifier = ">=3.1,<4.0" }, - { name = "codecov" }, + { name = "codecov", specifier = ">=2.1" }, { name = "infrahub-testcontainers", specifier = ">=1.7.3" }, { name = "invoke", specifier = ">=2.2.1" }, - { name = "ipython" }, + { name = "ipython", specifier = ">=8.18" }, { name = "jsonschema", specifier = ">=4.25.1" }, { name = "mdxify", specifier = ">=0.2.23" }, { name = "mypy", specifier = "==2.3.1" }, @@ -898,9 +898,9 @@ dev = [ { name = "towncrier", specifier = ">=24.8.0" }, { name = "ty", specifier = "==0.0.14" }, { name = "types-python-slugify", specifier = ">=8.0.0.3" }, - { name = "types-pyyaml" }, - { name = "types-ujson" }, - { name = "yamllint" }, + { name = "types-pyyaml", specifier = ">=6.0.12" }, + { name = "types-ujson", specifier = ">=5.10" }, + { name = "yamllint", specifier = ">=1.35" }, ] docs = [{ name = "mdxify", specifier = ">=0.2.23" }] lint = [ @@ -909,7 +909,7 @@ lint = [ { name = "ruff", specifier = "==0.15.12" }, { name = "rumdl", specifier = "==0.2.28" }, { name = "ty", specifier = "==0.0.14" }, - { name = "yamllint" }, + { name = "yamllint", specifier = ">=1.35" }, ] tests = [ { name = "infrahub-testcontainers", specifier = ">=1.7.3" }, @@ -923,8 +923,8 @@ tests = [ ] types = [ { name = "types-python-slugify", specifier = ">=8.0.0.3" }, - { name = "types-pyyaml" }, - { name = "types-ujson" }, + { name = "types-pyyaml", specifier = ">=6.0.12" }, + { name = "types-ujson", specifier = ">=5.10" }, ] [[package]]