Provider Python API¶
This page covers the Python side of writing a provider: how to define a typed
context model, how to use the Provider base class, how to attach per-file
extra context, and how to coordinate with other providers. For how context
sources are merged and how to override values from repolish.yaml, see
Context.
Defining a context model¶
Every provider should return a typed Pydantic model from create_context(). Use
BaseContext as the base class - it adds the built-in repolish namespace
(repo owner/name, year) without requiring you to import Pydantic directly:
from repolish import BaseContext, BaseInputs, Provider
class Ctx(BaseContext):
python_version: str = '3.11'
use_ruff: bool = True
class MyProvider(Provider[Ctx, BaseInputs]):
def create_context(self) -> Ctx:
return Ctx()
Provider is generic in two parameters: the context type and the input schema
(what this provider will accept from other providers via provide_inputs()). If
your provider does not receive inputs, use BaseInputs as the placeholder.
Per-file extra context with TemplateMapping¶
create_file_mappings() can attach per-file extra context using
TemplateMapping. This lets you reuse a single template to generate multiple
files with different typed parameters:
from pydantic import BaseModel
from repolish import BaseContext, BaseInputs, Provider, TemplateMapping
class ModuleCtx(BaseModel):
module: str
class Ctx(BaseContext):
pass
class MyProvider(Provider[Ctx, BaseInputs]):
def create_context(self) -> Ctx:
return Ctx()
def create_file_mappings(self, context: Ctx):
return {
'src/a.py': TemplateMapping('module.py.jinja', ModuleCtx(module='a')),
'src/b.py': TemplateMapping('module.py.jinja', ModuleCtx(module='b')),
}
During rendering, the template receives the provider's context merged with
the extra_context fields. Extra context keys shadow provider context keys of
the same name for that file only.
You can inspect what context a specific file received after repolish apply by
looking at .repolish/_/file-ctx/file-context.<slug>.json. See
Inspecting context after an apply.
File validators¶
create_file_validators() lets a provider enforce project-level correctness
checks against files in the project or render tree. This is a good fit for rules
like "config.toml needs a generated header", "the file must end with a
newline", or "the selected Python version matches the project settings".
A validator does not need a matching create_file_mappings() entry. Providers
may validate an existing project file that is already present in the repo and is
not rendered by repolish at all. In that case the file is still shown in the
summary, but the status is explicit: there is no file in the render stage for
it, so the summary shows a non-write marker instead of a green written marker.
The registry is keyed by destination path and then by validator name. Each validator can be registered either as a bare callable or as a typed spec with options:
from pathlib import Path
from repolish import BaseContext, BaseInputs, Provider
from repolish.providers.models import (
FileValidatorOptions,
FileValidatorSpec,
ValidationResult,
ValidationStatus,
)
class Ctx(BaseContext):
pass
class MyProvider(Provider[Ctx, BaseInputs]):
def create_context(self) -> Ctx:
return Ctx()
def create_file_validators(self, context: Ctx):
def lint(context: Ctx, path: Path):
text = path.read_text(encoding='utf-8')
return ValidationResult(
status=ValidationStatus.PASS
if text.startswith('generated by repolish')
else ValidationStatus.ERROR,
message='missing generated header',
path=str(path),
validator_name='lint',
)
def schema(context: Ctx, path: Path):
text = path.read_text(encoding='utf-8')
return ValidationResult(
status=ValidationStatus.PASS
if 'version' in text
else ValidationStatus.ERROR,
message='missing version key',
path=str(path),
validator_name='schema',
)
return {
'config.toml': {
'lint': lint,
'schema': FileValidatorSpec(
fn=schema,
options=FileValidatorOptions(enabled=True),
),
}
}
The public contract is intentionally typed:
FileValidatorOptions(enabled=True)turns a validator on or off.FileValidatorSpec(fn=..., options=...)is the structured form.ValidatorFnreceives the provider context and the resolved file path.ValidationResult.statusis aValidationStatusenum, not a loose boolean.ValidationStatus.PASS,WARNING, andERRORlet the UI and strict mode distinguish success, warnings, and failures clearly.
The repo config is the final authority: provider defaults are merged first, then project overrides are applied. That means a project can enable a default-off validator, disable a default-on validator, or keep a provider-specified validator visible but inactive in the summary without removing it from the registry.
Validator-only registrations are also additive. If a provider defines validation
against README.md without creating a file mapping for it, that check still
runs against the actual repo file and is reported separately from "written"
files. The summary uses a distinct marker to communicate this state:
This means the file is still meaningful to the provider, but it was not staged or materialized by repolish during this run.
Disabling validators in repolish.yaml¶
Validator overrides follow the same pattern as file mapping overrides. The
project may disable a validator without deleting the provider declaration, which
keeps the disabled state visible in the repolish apply summary instead of
silently erasing it:
providers:
my_provider:
provider_root: ./my_provider
overrides:
validators:
config.toml:
lint: false
schema: true
When this is set, lint remains in the registry but is treated as disabled
during execution. The user still sees that the validator existed and that it was
intentionally turned off.
Paused files skip validators entirely
If a destination file is listed
in paused_files, repolish skips that file before rendering or validation. In
that state, the file is shown as paused, and the validator list for that file
is intentionally not shown because the validators never ran.
This is by design: a paused file is treated as intentionally out of scope for the current apply run, so its validator results are not meaningful until the file is unpaused.
Multiple providers can contribute to the same file¶
Validator registries are additive. Providers do not fight for the same file path; they can each contribute their own validators to the same destination and the results are shown together in the apply summary:
This means a provider can enforce a generated-file header while another provider checks trailing whitespace or schema validity, without one silently replacing the other.
Apply summary output¶
When repolish apply runs, validators are rendered beneath the affected file as
separate lines so each result is easy to read and debug:
The same output model is used for warnings and failures:
config.toml
validators:
- ⚠ lint: config is missing a recommended key
- ✗ schema: missing version key
This keeps the signal explicit:
- green check mark: validator passed
- red cross: validator failed and reports why
- yellow warning: validator ran and produced a warning
- yellow cross: validator exists but is disabled by config
- hollow marker (
◌): file has validation hooks but no file was staged for it in this apply run; the path is provider-owned, but not written
That makes it easy to understand both the provider's intent and the project's local overrides without needing to read the provider source or documentation just to know what one of the checks was doing.
Monorepo mode and validator inheritance¶
Validator hooks follow the same mode-aware dispatch as the other provider hooks.
A provider can define create_file_validators() directly on the base provider,
and it is also valid to attach mode-specific handlers such as root_mode,
member_mode, and standalone_mode that implement their own validator logic.
The dispatch order is:
- direct method on the provider, when overridden
- matching mode handler for the current workspace mode
- the base no-op default
In practice, this means you do not need to duplicate validator definitions for all three modes unless a given mode needs different checks. The same validator registry contract applies in every mode, and the current workspace mode decides which handler is selected.
Cross-provider coordination¶
provide_inputs()¶
A provider can send typed messages to other providers. Declare the output type
in the provide_inputs() return and the receiving provider declares the input
type as the second generic parameter of Provider:
from pydantic import BaseModel
from repolish import BaseContext, FinalizeContextOptions, Provider, ProviderEntry
class PythonInput(BaseModel):
python_version: str
class WorkspaceCtx(BaseContext):
python_version: str = '3.11'
class WorkspaceProvider(Provider[WorkspaceCtx, PythonInput]):
def create_context(self) -> WorkspaceCtx:
return WorkspaceCtx()
# Called after all providers have emitted their initial contexts
def finalize_context(
self,
opt: FinalizeContextOptions[WorkspaceCtx, PythonInput],
) -> WorkspaceCtx:
if opt.received_inputs:
opt.own_context.python_version = opt.received_inputs[0].python_version
return opt.own_context
finalize_context()¶
Override finalize_context() when you need to derive values after all providers
have emitted their initial context. It runs in a second pass so it can read the
fully assembled context from all peers.
Reading a peer's context directly¶
Both provide_inputs() and finalize_context() receive opt.all_providers - a
snapshot of every ProviderEntry the loader knows about. The
get_provider_context() helper lets you pull a specific provider's context out
of that list by class, without requiring that provider to broadcast anything:
from repolish import BaseContext, BaseInputs, FinalizeContextOptions, Provider, get_provider_context
class MyCtx(BaseContext):
python_version: str = '3.11'
class MyProvider(Provider[MyCtx, BaseInputs]):
def finalize_context(
self,
opt: FinalizeContextOptions[MyCtx, BaseInputs],
) -> MyCtx:
from other_provider.repolish import OtherProvider
peer = get_provider_context(OtherProvider, opt.all_providers)
if peer is not None:
opt.own_context.python_version = peer.python_version
return opt.own_context
This is the lighter alternative when you only need to read - the peer provider
does not need to implement provide_inputs() at all.
Monorepo note: opt.all_providers is scoped to the providers active in the
current run, not the entire workspace. In a monorepo root pass, that means the
root-level providers for that run; member providers are processed in separate
per-package passes and do not appear here. Likewise, ModeHandler dispatch is
chosen from the active workspace mode (root, member, or standalone), so a
validator or hook you see in one run may not exist in another. If you need data
that originates in a member package, prefer the provide_inputs() push pattern
from the member toward the root instead of reading a peer from all_providers.
Options dataclasses¶
The two hooks that participate in cross-provider coordination receive an options dataclass instead of a plain context.
ProvideInputsOptions[ContextT]¶
Passed to provide_inputs():
| Field | Type | Description |
|---|---|---|
own_context |
ContextT |
This provider's current context object |
all_providers |
list[ProviderEntry] |
Snapshot of every loaded provider |
provider_index |
int |
Position of this provider in load order |
FinalizeContextOptions[ContextT, InputT]¶
Passed to finalize_context():
| Field | Type | Description |
|---|---|---|
own_context |
ContextT |
Context before input merging |
received_inputs |
list[InputT] |
Payloads from providers whose schema matched |
all_providers |
list[ProviderEntry] |
Snapshot of every loaded provider |
provider_index |
int |
Position of this provider in load order |
Helper functions¶
call_provider_method(inst, method_name, arg)¶
Routes a provider hook call through the mode handler dispatch. Orchestration
code uses this instead of calling hooks directly so that Provider itself has
no knowledge of mode dispatch.
from repolish import call_provider_method
result = call_provider_method(provider_instance, 'create_file_mappings', context)
Resolution: reads context.repolish.workspace.mode, looks up the matching
root_mode / member_mode / standalone_mode handler, and calls the method on
it. Falls back to the provider's own implementation when no handler is
registered for the current mode.
get_provider_context(provider_cls, providers)¶
Returns the context object for a specific provider class from the provider
registry, without requiring that provider to broadcast anything via
provide_inputs():
from repolish import get_provider_context
peer_ctx = get_provider_context(OtherProvider, opt.all_providers)
Returns None if no matching provider is loaded. Passing the bare Provider
base class is rejected (it would match everything).
Promoting files to the repo root¶
In a monorepo, a member provider can push files to the monorepo root
directory using promote_file_mappings(). This is useful for files that belong
to the whole workspace — shared CI workflows, a root-level CODEOWNERS, a
common Makefile target — but whose content is derived from member-level
context.
from repolish import BaseContext, BaseInputs, ModeHandler, Provider, TemplateMapping
class Ctx(BaseContext):
python_version: str = '3.11'
class MemberHandler(ModeHandler[Ctx, BaseInputs]):
def create_file_mappings(self, context: Ctx):
# files inside the member package
return {'pyproject.toml': 'pyproject.toml.jinja'}
def promote_file_mappings(self, context: Ctx):
# these paths land at the monorepo root, not the member directory
return {
'.github/workflows/ci.yaml': TemplateMapping(
source_template='ci.yaml.jinja',
promote_conflict='identical',
),
}
class MyProvider(Provider[Ctx, BaseInputs]):
member_mode = MemberHandler
Destination paths in promote_file_mappings() are resolved relative to the
monorepo root, not the member directory. Repolish collects all promotions
after every member session has run and writes them during the root pass.
Conflict resolution¶
When two members promote the same destination path,
TemplateMapping.promote_conflict controls what happens:
| Strategy | Behaviour |
|---|---|
"identical" |
Both outputs must be byte-for-byte equal; fail loudly if they differ (default) |
"last_wins" |
The last member session processed silently wins |
"error" |
Fail immediately on any conflict, regardless of content |
The "identical" default is deliberately strict: if two members both produce
the same CI workflow from the same template the rendered bytes must match — a
divergence is a bug worth surfacing.
Restrictions¶
promote_file_mappings()is only meaningful in member mode. Repolish emits a warning and ignores the return value when called inrootorstandalonemode — usecreate_file_mappings()for files that should land in the root or standalone project directory.- If the rendered source file is missing from the member's render output, repolish logs a warning and skips that entry.
- Use a
ModeHandler(see Mode Handlers) to keep the member-only logic cleanly separated from root and standalone behaviour.
Anchors¶
create_anchors() returns a mapping of named text blocks that other templates
can reference with the repolish-start / repolish-end comment pair. This
allows a provider to inject or update a specific region of a file without owning
the whole file:
class MyProvider(Provider[Ctx, BaseInputs]):
def create_anchors(self, context: Ctx) -> dict[str, str]:
return {
'build-matrix': f'python-version: ["{context.python_version}"]',
}
Any template — from any provider — can then declare the target region:
# .github/workflows/ci.yaml
jobs:
test:
strategy:
matrix:
## repolish-start[build-matrix]
python-version: ['3.11']
## repolish-end[build-matrix]
After repolish apply the bracketed region is replaced with the anchor value.
Anchors from multiple providers are merged; if two providers declare the same
anchor key the later provider in load order wins.
Tips¶
- Keep
create_context()small and focused - move data-gathering logic into private helper functions (see the Quick Start for an example of this pattern). - Use
self.templates_rootincreate_file_mappings()to discover template files dynamically (e.g.self.templates_root.glob('**/*.jinja')) instead of hardcoding paths. finalize_context()runs beforecreate_file_mappings(), so any context values derived from received inputs are available when you build your file mappings.- The loader routes
provide_inputs()payloads by schema match, not by name. A payload is delivered to every provider whoseget_inputs_schema()returns a compatible type - if no receiver declares a matching schema the payload is silently dropped. - When implementing a provider that supports multiple workspace modes, check
opt.own_context.repolish.workspace.modeinsideprovide_inputs()to decide what (if anything) to broadcast. - Project-configurable context: a provider can expose an
*_argsor*_configfield in its context model as a hook for projects to influence how context is derived. The project sets the field viacontext_overrides:and the provider reads it infinalize_context()(which runs after all overrides are applied) to generate the rest of its context:
from pydantic import BaseModel
from repolish import BaseContext, BaseInputs, FinalizeContextOptions, Provider
class MyProviderArgs(BaseModel):
api_version: str = 'v1'
class MyProviderCtx(BaseContext):
my_provider_args: MyProviderArgs = MyProviderArgs()
api_url: str = 'https://api.example.com/v1'
class MyProvider(Provider[MyProviderCtx, BaseInputs]):
def create_context(self) -> MyProviderCtx:
return MyProviderCtx()
def finalize_context(
self,
opt: FinalizeContextOptions[MyProviderCtx, BaseInputs],
) -> MyProviderCtx:
ver = opt.own_context.my_provider_args.api_version
opt.own_context.api_url = f'https://api.example.com/{ver}'
return opt.own_context
This is a convention, not a framework feature. The provider decides which fields to treat as inputs and how to act on them.