This guide provides foundational best practices for developing Pyvider providers, focusing on design patterns, code organization, and development standards. These patterns are derived from real-world usage and the battle-tested pyvider-components repository.
🤖 AI-Generated Content
This documentation was generated with AI assistance and is still being audited. Some, or potentially a lot, of this information may be inaccurate. Learn more.
For operational concerns like error handling, logging, performance, testing, and security, see the Production Readiness Guide.
# Good naming@register_resource("pyvider_file_content")# prefix_noun@register_data_source("pyvider_env_variables")# prefix_noun_plural@register_function(name="format_string")# verb_noun# Bad naming@register_resource("pyvider_manage_file")# don't use verbs for resources@register_data_source("pyvider_api")# too generic
# Good: Clear, documented schema@classmethoddefget_schema(cls)->PvsSchema:returns_resource({"filename":a_str(required=True,description="Path to the file to manage"),"content":a_str(required=True,description="Content to write to the file"),"content_hash":a_str(computed=True,description="SHA256 hash of the file content"),"exists":a_bool(computed=True,description="Whether the file exists on disk"),})# Bad: No descriptions, unclear names@classmethoddefget_schema(cls)->PvsSchema:returns_resource({"f":a_str(required=True),# What is 'f'?"c":a_str(required=True),# What is 'c'?"h":a_str(computed=True),# No context})
# Computed attributes are calculated by the provider"content_hash":a_str(computed=True)# Provider calculates"exists":a_bool(computed=True)# Provider determines# Required/optional attributes come from user"filename":a_str(required=True)# User must provide"permissions":a_str(default="644")# User can override
frompyvider.schemaimporta_str,a_num# Validate inputs to prevent errors@classmethoddefget_schema(cls)->PvsSchema:returns_resource({"port":a_num(required=True,validators=[lambdax:1<=x<=65535or"Port must be 1-65535"]),"protocol":a_str(required=True,validators=[lambdax:xin["http","https"]or"Must be http or https"]),})
# Good: Flat, focused schema{"name":a_str(required=True),"size":a_num(default=10),"enabled":a_bool(default=True),}# Avoid: Deeply nested complexity without good reason{"config":a_obj({"section1":a_obj({"subsection":a_obj({"deep_value":a_str()# Too deep})})})}
@register_resource("pyvider_example")classExampleResource(BaseResource):asyncdefread(self,ctx:ResourceContext)->ExampleState|None:""" Read current state. Return None if resource doesn't exist. Called during refresh and before updates. """ifnotresource_exists:returnNonereturnExampleState(...)asyncdef_create(self,ctx:ResourceContext,base_plan:dict)->tuple[dict|None,None]:""" Create new resource. Return (state_dict, None). """# Create the resourceresult=awaitself.api.create(...)return{**base_plan,"id":result.id},Noneasyncdef_update(self,ctx:ResourceContext,base_plan:dict)->tuple[dict|None,None]:""" Update existing resource. Return (state_dict, None). """# Update the resourceawaitself.api.update(ctx.state.id,...)returnbase_plan,Noneasyncdef_delete(self,ctx:ResourceContext)->None:""" Delete resource. No return value. """awaitself.api.delete(ctx.state.id)
asyncdefread(self,ctx:ResourceContext)->FileContentState|None:"""Return None if resource doesn't exist."""filename=ctx.state.filenameifctx.stateelsectx.config.filenamepath=Path(filename)# Don't raise errors for missing resourcesifnotpath.is_file():logger.debug("File does not exist",path=str(path))returnNone# Terraform will handle this# Read and return statecontent=safe_read_text(path)returnFileContentState(...)
fromattrsimportdefine,field@define(frozen=True)# ImmutableclassFileContentConfig:"""Configuration for file content resource."""filename:str=field()content:str=field()@filename.validatordef_validate_filename(self,attribute,value):ifnotvalue:raiseValueError("filename cannot be empty")@define(frozen=True)classFileContentState:"""State for file content resource."""filename:str=field()content:str=field()exists:bool|None=field(default=None)content_hash:str|None=field(default=None)
fromtypingimportAnyclassMyResource(BaseResource["my_resource",MyState,MyConfig]):config_class=MyConfigstate_class=MyStateasyncdefread(self,ctx:ResourceContext)->MyState|None:"""Type hints help catch errors early."""result:MyState|None=awaitself._fetch_state()returnresultasyncdef_create(self,ctx:ResourceContext,base_plan:dict[str,Any])->tuple[dict[str,Any]|None,bytes|None]:"""Clear parameter and return types."""pass
# Instead of duplicating code across resourcesfrompyvider.capabilitiesimportrequires_capability@register_resource("my_resource")@requires_capability("caching")classMyResource(BaseResource):asyncdefread(self,ctx:ResourceContext)->State|None:# Use shared caching capabilitycached=awaitself.capabilities.caching.get(cache_key)ifcached:returncachedresult=awaitself._fetch_from_api()awaitself.capabilities.caching.set(cache_key,result)returnresult
@register_resource("pyvider_file_content")classFileContentResource(BaseResource):""" Manages file content with atomic writes and content tracking. This resource creates and manages text files on the local filesystem. It provides: - Atomic write operations to prevent partial writes - SHA256 content hashing for change detection - Automatic existence checking Example: resource "pyvider_file_content" "config" { filename = "/tmp/app.conf" content = "key=value" } Attributes: filename: Path to the file (relative paths recommended) content: Text content to write exists: (computed) Whether file exists content_hash: (computed) SHA256 hash of content """asyncdefread(self,ctx:ResourceContext)->FileContentState|None:""" Read current file state. Returns None if the file doesn't exist, triggering Terraform to recreate it. This is the correct behavior for resources deleted outside of Terraform. Args: ctx: Resource context with state and config Returns: Current file state or None if file doesn't exist """pass
# Wrong: External state storageclassMyResource(BaseResource):_cache={}# Class variable - BAD!asyncdef_create(self,ctx:ResourceContext,base_plan:dict)->tuple[dict|None,None]:result=awaitself.api.create()self._cache[result.id]=result# State leak!return{...},None# Correct: State only in TerraformclassMyResource(BaseResource):asyncdef_create(self,ctx:ResourceContext,base_plan:dict)->tuple[dict|None,None]:result=awaitself.api.create()# Return all state, don't store locallyreturn{"id":result.id,"data":result.data,},None
# Wrong: Removing required attribute@classmethoddefget_schema(cls)->PvsSchema:returns_resource({# "filename": a_str(required=True), # REMOVED - breaks existing configs!"path":a_str(required=True),# NEW NAME - breaking change})# Correct: Add new attribute, deprecate old one@classmethoddefget_schema(cls)->PvsSchema:returns_resource({"filename":a_str(description="(Deprecated) Use 'path' instead"),"path":a_str(description="Path to the file"),# Support both, migrate users gradually})
# Wrong: Swallowing errorsasyncdefread(self,ctx:ResourceContext)->State|None:try:returnawaitself._read_from_api()exceptException:returnNone# User never knows what went wrong# Correct: Handle specific errors, re-raise unexpected onesasyncdefread(self,ctx:ResourceContext)->State|None:try:returnawaitself._read_from_api()exceptNotFoundError:# Expected - resource was deletedreturnNoneexceptExceptionase:# Unexpected - let it propagate with contextlogger.error("Unexpected error reading resource",error=str(e))raise
The best way to learn is by studying working code. Check out pyvider-components for:
Production-focused implementations: file_content, local_directory, http_api, and more
100+ working examples: Complete Terraform configurations
Comprehensive tests: See how to test every scenario
Real-world patterns: Learn from battle-tested code
Remember: The goal is to build providers that are reliable, secure, maintainable, and delightful to use. Follow these best practices, and your users will thank you!