Skip to content

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.
  • ValidatorFn receives the provider context and the resolved file path.
  • ValidationResult.status is a ValidationStatus enum, not a loose boolean.
  • ValidationStatus.PASS, WARNING, and ERROR let 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:

Standalone
└── p@
    └── ◌ README.md  developer owned
        validators:
          - ✓ lint

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:

return {
    'config.toml': {
        'header': header,
        'endline': endline,
    }
}

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:

config.toml
  validators:
    - ✗ lint: disabled
    - ✓ schema
    - ✓ header
    - ✓ endline

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:

  1. direct method on the provider, when overridden
  2. matching mode handler for the current workspace mode
  3. 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 in root or standalone mode — use create_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_root in create_file_mappings() to discover template files dynamically (e.g. self.templates_root.glob('**/*.jinja')) instead of hardcoding paths.
  • finalize_context() runs before create_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 whose get_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.mode inside provide_inputs() to decide what (if anything) to broadcast.
  • Project-configurable context: a provider can expose an *_args or *_config field in its context model as a hook for projects to influence how context is derived. The project sets the field via context_overrides: and the provider reads it in finalize_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
# repolish.yaml — project-side override
context_overrides:
  my_provider_args:
    api_version: v2

This is a convention, not a framework feature. The provider decides which fields to treat as inputs and how to act on them.