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.
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 only contains providers that are active
in the current run. In a monorepo root run this means root-level providers only;
member providers are processed in separate per-package runs and will not appear
here. If you need data that originates in a member, use the provide_inputs()
push pattern from the member toward the root instead.
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.