Provider migration guide — module → provider-scoped templates¶
This guide helps you migrate a provider so it can be rendered using the
provider_scoped_template_context strict mode. The goal is to make each
provider self-contained: templates declared by a provider should only use the
context supplied by that provider (or the mapping's extra_context).
Why migrate?
- Stronger separation of concerns — templates cannot accidentally depend on keys from other providers.
- Easier reasoning and safer upgrades toward the v1 major release.
- Better observability — each
TemplateMappingis provably owned by a provider and can be tested in isolation.
When to migrate
- Start with small/simple providers (few templates / small contexts).
- Migrate providers that are frequently changed or whose templates already reference only local keys.
- Keep large providers for later — migrate incrementally and run CI after each migration.
Quick checklist (practical)
- Convert per-file mappings to
TemplateMappingwhere you need typed extra-context (optional but recommended). - Ensure
create_context()returns only keys required by this provider's templates. Use a PydanticBaseModelreturn type for validation. - Update templates so they reference only keys present in
create_context()or in the mapping'sextra_context. - Add
provider_migrated = Trueat top-level in the provider'srepolish.pyto mark the provider as migrated. - Add/adjust unit tests for the provider to assert provider-scoped rendering behaviour (see test suggestions below).
- The configuration flag now defaults to true; you rarely need to touch it in
your project config. Run
poe ci-checks/ CI to detect remaining cross-provider usage. The staging step records which provider supplied each template, so migrated providers will render all of their files with their own context automatically.
Example: before → after (small provider)
Before (module-style, uses merged context implicitly):
# repolish.py (old)
def create_context():
return {'shared_prefix': 'lib'}
def create_file_mappings(ctx):
# reads ctx from the merged context (may rely on keys from other providers)
return {'src/a.py': 'templates/mod.jinja'}
After (migrated, provider-scoped):
# repolish.py (migrated)
from repolish import TemplateMapping
provider_migrated = True
from pydantic import BaseModel
class Ctx(BaseModel):
shared_prefix: str = 'lib'
def create_context():
return Ctx(shared_prefix='lib')
def create_file_mappings():
# explicit TemplateMapping — extra_context can also be a pydantic model
return {'src/a.py': TemplateMapping('templates/mod.jinja', None)}
Final form — class-based Provider (recommended)
The end-state for a migrated provider is a small, typed Provider subclass.
Class-based providers improve discoverability, make testing easier, and are
fully compatible with the loader (the loader will instantiate the class and
expose the same module-level factory hooks so existing consumers continue to
work).
# repolish.py (class-based final form)
from pydantic import BaseModel
from repolish.loader import Provider, TemplateMapping, FileMode
# mark provider migrated for strict provider-scoped mode
provider_migrated = True
class Ctx(BaseModel):
shared_prefix: str = 'lib'
license: str = 'MIT'
class MyProvider(Provider[Ctx, BaseModel]):
def get_provider_name(self) -> str:
return 'my-provider'
def create_context(self) -> Ctx:
return Ctx(shared_prefix='acme', license='Apache-2.0')
# Optional: instance-level factory (loader will forward this to the
# module-level `create_file_mappings()` callable so existing code paths
# remain unchanged).
def create_file_mappings(self):
return {
'src/__init__.py': TemplateMapping('pkg_init.jinja', None),
'README.md': TemplateMapping('readme.jinja', None, file_mode=FileMode.CREATE_ONLY),
}
# Note: helpers for delete/create-only lists are still supported as
# module-level functions/variables (e.g. `create_delete_files()` /
# `delete_files`, `create_create_only_files()` / `create_only_files`).
# The loader currently forwards *instance-level* `create_file_mappings()`
# and `create_anchors()` from a `Provider` subclass into the module
# namespace so class-based providers can implement those as instance
# methods. If you need to provide delete/create-only helpers from a
# class-based provider, expose small module-level wrappers that call
# into your provider instance (the loader preserves backward-compat
# for these module-level helpers).
How the loader recognizes the class
- The loader imports the provider module (
repolish.py) into amodule_dict. - It scans exported values and uses
inspect.isclass()+issubclass(..., Provider)to find anyProvidersubclasses (seerepolish.loader.orchestrator._find_provider_class).
Only one subclass may be exported; the old behaviour chose the first class
encountered which could hide accidental imports of helper providers. The
loader now looks at the module's __all__ list (if present) and will use the
single provider class named there. This lets you freely import other provider
implementations at the top level so long as only the intended class is
included in __all__. Exporting multiple providers either via __all__ or by
omitting __all__ still results in a runtime error with a helpful message
directing you to the __all__ mechanism.
- If a provider class is selected, the loader instantiates it and injects
instance-backed callables into the module dict (e.g.
create_context,create_file_mappings,create_anchors) so the rest of the loader works exactly the same as with module-style providers (seerepolish.loader.orchestrator._inject_provider_instance).
Practical notes
- Set
provider_migrated = Trueat module level to mark the provider as migrated; only migrated providers will have their mappings rendered against their own context. Non-migrated providers continue to receive merged context even if the legacyprovider_scoped_template_contextflag were to be set to false. - The class-based API is optional but recommended for larger providers and when you want compile/test-time reassurance (Pydantic types give IDE + validation benefits).
- Existing module-style providers continue to work until you opt into strict provider-scoped rendering.
Testing suggestions
- Unit: verify
Providers.provider_contexts[provider_id]contains the keys you expect after loading providers. - Integration: enable
provider_scoped_template_contextlocally and run rendering tests that exercise per-mapping templates for that provider. - Regression: add a test that fails if the provider's template references a key not present in its own context (ensures future changes remain self-contained).
Troubleshooting & migration patterns
- Template needs a value from another provider:
- Prefer moving the responsibility to the provider that declares the template
(duplicate the small value into its
create_context()), or - Use
TemplateMapping(..., extra_context=...)to provide the needed key at mapping time, or -
Keep the template under the provider that owns the required context.
-
Large providers with many interdependent templates:
- Migrate incrementally — split provider responsibilities if sensible.
-
Add tests for each sub-area during the migration.
-
Want a smoother rollout for many providers:
- Migrate provider code and add
provider_migrated = Truelocally. - Run CI (the configuration flag is already true by default) in a feature branch.
- Fix templates that fail; repeat until the branch is green.
- Merge and enable the flag in the mainline once providers are migrated.
Commands & quick checks
- Run unit/tests:
poe ci-checksorpytest -q - Verify provider flags: inspect
Providers.provider_migratedin the loader output or add a unit test asserting the flag is present.
Final notes
- This migration is opt‑in: enabling
provider_scoped_template_contextdoes not immediately break existing module-style providers. Only providers that have opted in viaprovider_migrated = Trueare isolated; others still render with the merged context. You can gradually migrate providers and flip the flag at your own pace. - If you want, I can update the example providers in
examples/to show a full end‑to‑end migrated provider.