This is part 7 of the series. It continues the mycloud provider from Part 6, but the lint hook below works on any Pyvider component.
Get the code: provide-io/pyvider-tutorial, directory part7-provider-linting.
git clone https://github.com/provide-io/pyvider-tutorial.git
cd pyvider-tutorial/part7-provider-linting
uv sync --frozen
Provider linting is for useful advice that should not make otherwise valid configuration fail. In this part, mycloud_server.web is still valid when its name contains prod; the provider simply suggests making the environment explicit instead of hiding it in a name.
The example locks the versions it was checked against: Pyvider 0.8.1 and TofuSoup 0.8.2, with OpenTofu 1.13.0-rc1. Start from that directory so the source, suites, and commands below stay together.
Write one advisory rule
Give every rule a stable, namespaced address. Groups are addresses too: they let an operator select a useful family of rules without listing every member.
PRODUCTION_NAME = "example/mycloud:production-name"
ALL_LINTS = "example/mycloud:all"
NAMING = "example/mycloud:naming"
Start with the behavior you want. This test selects the exact rule and checks the diagnostic an operator will read:
import pytest
from pyvider.lint import LintContext, LintSelector
from my_provider.server import PRODUCTION_NAME, Server, ServerConfig
@pytest.mark.asyncio
async def test_production_name_is_advisory() -> None:
findings = await Server().lint(
LintContext(
config=ServerConfig(name="web-prod"),
selector=LintSelector.parse((PRODUCTION_NAME,)),
)
)
assert len(findings) == 1
assert findings[0].rule == PRODUCTION_NAME
assert findings[0].attribute_path == "name"
Run it before adding the hook so you see it fail for the missing behavior:
uv run pytest tests/test_linting.py -q
Now implement the smallest useful rule on the resource:
from pyvider.lint import LintContext, LintFinding
class Server(BaseResource):
async def lint(
self, ctx: LintContext[ServerConfig]
) -> tuple[LintFinding, ...]:
if not ctx.enabled(PRODUCTION_NAME, ALL_LINTS, NAMING):
return ()
name = getattr(ctx.config, "name", None)
if not isinstance(name, str) or "prod" not in name.lower():
return ()
return (
LintFinding(
rule=PRODUCTION_NAME,
groups=(ALL_LINTS, NAMING),
summary="Production environment is encoded in the server name",
detail=(
"Explicit environment metadata is easier to review and automate. "
"Exclude !example/mycloud:production-name when this naming "
"convention is deliberate."
),
attribute_path="name",
),
)
The type check before .lower() matters. Configuration can be absent or unknown during validation, and a guidance rule should never turn that normal state into a provider crash. Add cases for an ordinary name, an empty or missing value, an unknown value, and every selector path you support.
LintFinding is different from a validation error: it advises, names the rule that spoke, and points to the relevant top-level attribute. Pyvider transports it as a warning and keeps validation moving. If a lint hook itself fails, Pyvider reports a warning instead of taking down the provider process.
Turn the rule on
Provider lint rules are off by default. Select a rule or group for one process with PYVIDER_LINT:
PYVIDER_LINT=example/mycloud:all tofu validate
PYVIDER_LINT=example/mycloud:naming tofu validate
An exact exclusion wins over a selected group, so a team can adopt a group and document one intentional exception:
PYVIDER_LINT='example/mycloud:all,!example/mycloud:production-name' tofu validate
Set an explicitly empty value to disable provider linting for that process:
PYVIDER_LINT='' tofu validate
For a persistent project default, put the same selector beside the provider in pyvider.toml:
[lint]
rules = ["example/mycloud:all", "!example/mycloud:production-name"]
PYVIDER_LINT overrides the file when it is present, including when it is empty. That makes CI selection reproducible without rewriting a developer’s project configuration.
Verify the packaged provider
A unit test proves the hook. The end-to-end check should prove the provider binary your users will actually run.
Package the tutorial provider, then point TofuSoup at that executable:
uv run flavor pack --manifest pyproject.toml
mv dist/terraform-provider-mycloud.psp dist/terraform-provider-mycloud
chmod +x dist/terraform-provider-mycloud
The tutorial’s lint.soup.toml holds the provider address, the triggering resource configuration, and the selector passed to the provider:
version = 1
[provider]
source = "registry.opentofu.org/example/mycloud"
version = "0.1.0"
[provider.environment]
PYVIDER_LINT = "example/mycloud:all"
[opentofu]
fixture = "lint-fixture"
lint = "all"
[[case]]
kind = "resource"
type_name = "mycloud_server"
config = { name = "web-prod" }
expect = [{ severity = "warning", summary = "Production environment is encoded in the server name (example/mycloud:production-name)" }]
Run the direct lane first. It calls the packaged provider’s validation surface and checks the expected finding:
uvx --from tofusoup==0.8.2 soup lint lint.soup.toml \
--provider "$PWD/dist/terraform-provider-mycloud" \
--lane direct
The second lane is tied to OpenTofu’s experimental implementation. The example’s installer downloads the official archive, verifies its checksum, and keeps it local to the tutorial. Provision OpenTofu 1.13.0-rc1 and verify that exact executable before using it:
./install-opentofu.sh 1.13.0-rc1
opentofu_rc1="$PWD/.cache/opentofu/1.13.0-rc1/tofu"
test -x "$opentofu_rc1"
"$opentofu_rc1" version | grep -Fx "OpenTofu v1.13.0-rc1"
Now run the OpenTofu experimental lint validation lane against the same provider binary and fixture:
uvx --from tofusoup==0.8.2 soup lint lint.soup.toml \
--provider "$PWD/dist/terraform-provider-mycloud" \
--opentofu "$opentofu_rc1" \
--lane opentofu
Keep separate suite variants for the exact exclusion and the empty selector. For those runs, assert that the provider produces no selected finding; a zero process exit by itself does not prove that suppression worked.
Watch the complete check
This recording belongs to the Part 7 fixture above. It uses only the same public commands: test the rule (the suite covers exclusion and disablement), package the provider, run the direct lane, and run OpenTofu experimental lint validation. Playback is paced for reading: idle build time is shortened and meaningful results are held on screen.
pyvider-components 0.8.0 includes seven first-party rules, but those are not the only rules Pyvider can run. The example/mycloud:production-name hook above shows how any provider author can add one of their own.
What each lane proves
TofuSoup’s direct provider lane reaches all seven Pyvider validation surfaces: provider configuration, managed resources, data sources, ephemeral resources, list resources, actions, and state stores. The terraform-provider-pyvider release proof records 7 of 7 there.
OpenTofu experimental lint validation in 1.13.0-rc1 exercises the four paths that release candidate can reach: provider configuration, managed resources, data sources, and ephemeral resources. That is 4 of 7, not evidence that OpenTofu supports all Pyvider surfaces.
As of 22 September 2026, upstream provider-defined lint integration is still in progress. Treat the OpenTofu result as experimental reachability evidence, not as a promise about the final protocol. Pyvider’s author API, rule IDs, groups, and selectors are supported independently and will follow the upstream transport as it settles.
For the complete evidence table, all seven first-party rules, and links to the upstream work, see Provider linting.