Discovery and Resolution Architecture¶
This is the canonical contributor reference for how discovery, normalization, classification, and parameter resolution work.
Purpose¶
This document explains how wagtail_unveil discovers URLs, normalizes route strings, classifies URLs as testable or untestable, resolves supported parameterized admin routes, and emits the final public dataclasses. It is intended for contributors and coding agents working on discovery behavior.
For a maintainer-facing visual companion to the authoritative rules here, see discovery-workflows.md.
Primary implementation files:
wagtail_unveil/discovery/backend.pywagtail_unveil/discovery/backend_resolution.pywagtail_unveil/discovery/extensions.pywagtail_unveil/discovery/frontend.pywagtail_unveil/discovery/frontend_resolution.pywagtail_unveil/discovery/utils.pywagtail_unveil/settings.py
Shared Discovery Building Blocks¶
The discovery modules now follow the same internal phase model:
- discover raw candidates
- normalize route metadata
- classify testability and skip reasons
- resolve supported parameterized admin routes
- emit the final public dataclass instances
Shared low-level helpers underpin both admin and frontend discovery:
walk_patterns(patterns, prefix="", namespace="")recursively walks DjangoURLResolverandURLPatternobjects and yields(route, name, namespace, callback)tuples.clean_regex_route(route)removes^and$anchors and converts named regex groups such as(?P<pk>...)into path-style placeholders such as<pk>.route_has_parameters(route)identifies normalized routes that still contain<...>placeholders.route_contains_regex(route)identifies normalized routes that still contain regex constructs such as groups, character classes, escapes, or wildcard quantifiers, and should remain visible but untestable in frontend discovery.
Normalization is intentionally partial. Some routes still contain regex constructs after cleanup, which means they may be discovered but remain untestable, or in the admin case may be skipped entirely if they still look unsafe for direct testing.
Admin Discovery Flow¶
get_admin_urls() in wagtail_unveil/discovery/backend.py is now a small orchestrator over explicit phases:
_discover_admin_routes()walks the root URL resolver withwalk_patterns()and keeps only raw routes whose route string starts withadmin/._normalize_admin_route()cleans the route withclean_regex_route(), drops routes that still contain unsafe regex metacharacters after cleanup, appliesWAGTAIL_UNVEIL_SKIP_URL_PREFIXES, and computes normalized metadata such ashas_parametersandview_name._classify_admin_route()assigns testability for hard-coded non-testable names and serve-readiness checks, and marks parameterized routes as needing resolution without immediately assigningURL requires parameters._finalize_admin_route()attempts backend parameter resolution for routes that need it._finalize_admin_route()applies a final GET-compatibility check so routes that only support non-GET methods remain visible but untestable._finalize_admin_route()emits the finalBackendURL, assigningURL requires parametersonly if resolution fails.
Current hard-coded non-testable names:
wagtailadmin_logout->POST-only viewwagtailadmin_block_preview->POST-only viewprocess_import->POST-only viewlock->POST-only viewunlock->POST-only viewwagtailadmin_error_test->Intentional error endpointfind->Requires query parameters
There are also namespace-specific readiness checks:
- document admin URLs in
wagtaildocs,wagtaildocs_chooser, andwagtailadmin_api:documentsare marked untestable ifwagtaildocs_serveis not registered - image URL generator endpoints in
wagtailimagesare marked untestable ifwagtailimages_serveis not registered
Parameter Resolution Strategy¶
Backend parameter resolution lives in wagtail_unveil/discovery/backend_resolution.py.
If you are using wagtail-unveil in your own project and want to extend URL resolution for a package such as wagtail-modeladmin, start with the user-facing recipe: Add Custom Admin URL Resolvers.
resolve_parameterized_url() attempts to turn a parameterized admin route into a real path that can be tested. It follows an explicit strategy pipeline:
- If the namespace is
wagtailsettings, delegate to_resolve_settings_url()and stop there. - Try to infer a model from callback metadata via
_get_model_from_callback(). - If a callback model is found, select a representative instance with
_get_instance_for_model(). - Apply registered admin instance resolvers from
get_registered_admin_instance_resolvers(): wagtail_unveilregisters its own built-in Wagtail namespace resolvers through the same hook system- installed Wagtail packages can register
AdminInstanceResolverobjects fromwagtail_unveil.discovery.extensionsvia theregister_unveil_admin_instance_resolvershook - hook-provided
matchesandresolverfields must be callable; invalid hook results are logged and skipped without aborting discovery - non-override resolvers act as fallbacks when no earlier instance has been selected
- override resolvers can replace an earlier instance choice or intentionally fail closed
- The currently registered built-in resolver rules cover:
wagtailformsfalls back to the first live form page instance when no earlier instance existswagtailadmin_workflowsusage views override earlier model-derived instances with the firstWorkflowinstance, and fail closed if no workflow existswagtailadmin_workflows:tasksresolves the GET-safe task routesedit_taskandtask_chosenfrom the first availableTaskinstance- Reverse the URL with
resolve_parameterized_url_with_instance()using the selected instance. - If no instance can be selected, keep the route visible but untestable.
The wagtailadmin_pages per-type behavior does not come from get_registered_admin_instance_resolvers(). It is handled only by a core backend discovery special-case in wagtail_unveil/discovery/backend.py, where _iter_page_backed_admin_urls() expands:
- an explicit safe allowlist of single-parameter page routes such as edit, delete, copy, move, privacy, and revisions index into one backend row per concrete non-root page type, using a representative instance for each type
wagtailadmin_pages:add_subpageinto one backend row per concrete non-root parent page type that exposes at least one creatable child model at that specific parent page
Third-party and other non-core namespace-specific behavior should still use project-level resolver hooks rather than new package-specific branches in core discovery.
wagtail_unveil/discovery/backend_resolution.py keeps most of its helpers internal, but exposes the small cross-module helper layer used by backend discovery: resolve_parameterized_url() and resolve_parameterized_url_with_instance().
_get_model_from_callback() still checks callback init kwargs first, then inspects classes exposed on callback.view_class and callback.cls, including direct model attributes plus cached-property and MRO-based variants of model. For treebeard-backed models, _get_instance_for_model() excludes the root node (depth=1) before selecting the first instance.
Internal resolution returns a _ParameterizedURLResolution object with resolved_route, resolved, method, detail, and attempts. This metadata is internal only. It exists to make the fallback order and failure path easier to debug and test. Public JSON responses still expose only the existing BackendURL fields.
Resolved URLs are stored in resolved_route on BackendURL. If reversal fails or no suitable instance exists, the parameterized URL remains in the results but is marked untestable with URL requires parameters during final emission rather than during initial classification. Override resolvers can invalidate an earlier candidate instance when the route requires a different model type. If a resolver matches or resolver callable raises at runtime, discovery records an internal <label>:error attempt, logs a warning, and continues with the remaining strategies.
The built-in wagtailadmin_pages expansion is intentionally allowlisted rather than a blanket fallback for every page route so state-dependent routes such as convert_alias can remain visible but untestable when a representative page would not produce a valid GET target. add_subpage is treated separately and only resolves when a compatible parent page exists, rather than merely any non-root page. The built-in workflow task resolver is also allowlisted to GET-safe routes rather than attempting every single-parameter workflow action view.
Settings Resolution Nuances¶
_resolve_settings_url() handles wagtailsettings routes separately because those routes use keyword arguments instead of positional arguments.
- It iterates over registered settings models from the Wagtail settings registry.
- It fills
app_nameandmodel_namefrom model metadata. - For routes containing
<int:pk>: BaseSiteSettingURLs useinstance.site_idBaseGenericSettingURLs useinstance.pkpreview_on_editis only considered when previewable settings support is available and the model subclassesPreviewableMixin.- It records whether resolution failed because no settings instances existed or because reversal failed for every registered model.
This logic exists to support the currently targeted Wagtail versions, including differences around previewable settings URLs and how the pk parameter is interpreted.
Frontend Discovery Flow¶
get_frontend_urls() now orchestrates the same phased flow for two sources:
- page-derived candidates from
_discover_page_candidates() - resolver-derived candidates from
_discover_resolver_candidates()
Each candidate is classified by _classify_frontend_candidate() and emitted as a FrontendURL by _build_frontend_url().
wagtail_unveil/discovery/frontend_resolution.py keeps most of its helper functions internal, but exposes a small public helper layer for cross-module use: get_default_site(), join_frontend_paths(), resolve_routable_page_url(), get_wagtail_api_detail_resolved_url(), and is_supported_wagtail_api_find_route().
Frontend Page Discovery¶
_discover_page_candidates() builds page-derived frontend candidates in this order:
- Iterate
Page.objects.live().specific(). - Skip the base
Pagetype. - Read
page.urldefensively so pages that error during URL generation do not break discovery. - Convert absolute page URLs to path-only values via
urlparse(). - Apply
WAGTAIL_UNVEIL_SKIP_URL_PREFIXES. - Resolve each page's owning
Siteand record whether it belongs to the default site. - Emit one base page candidate for the page URL.
- If the page is a
FormMixinsubclass, add a second landing-page candidate and leave classification to a later phase. - If the page is a
RoutablePageMixinsubclass, add sub-route candidates from_discover_routable_page_candidates(). - During candidate discovery,
_discover_page_candidates()enforcesWAGTAIL_UNVEIL_PAGES_PER_TYPEinline viaincluded_pages_by_type, grouping on(page_type, page_title)so each selected page keeps all of its related entries before routable expansion.
WAGTAIL_UNVEIL_PAGES_PER_TYPE affects page-derived URLs only. Resolver-derived frontend URLs are not limited by that setting.
Routable Page Special Cases¶
_discover_routable_page_candidates() discovers routes defined with RoutablePageMixin.get_subpage_urls() and handles them as follows:
- use
pattern.pattern._routewhen available - otherwise fall back to
pattern.pattern._regexplusclean_regex_route() - skip the empty index route so the base page URL is not duplicated
- construct a full URL by joining the page path and sub-route
- apply skip prefixes to the full URL
- record whether the sub-route contains
<...>placeholders - record whether the normalized sub-route still contains regex constructs such as
(or\w - attempt best-effort concrete resolution for single-parameter path-style sub-routes using safe descendant-derived values and store the result in
resolved_url - leave the final testability decision to classification
This means routable pages contribute both their base page URL and any additional sub-routes. Path-parameter sub-routes remain visible and become directly testable when a concrete resolved_url can be inferred, otherwise they remain visible but untestable with URL requires parameters. Regex-backed sub-routes remain visible but untestable with URL contains regex patterns.
Frontend Resolver Discovery¶
_discover_resolver_candidates() walks the root resolver and emits non-admin candidates with these exclusions and rules:
- Walk all URL patterns with
walk_patterns(). - Exclude routes under
admin/. - Normalize the route with
clean_regex_route(). - Exclude routes whose normalized path is under
admin/. - Exclude routes whose namespace is
wagtail_unveil. - Apply
WAGTAIL_UNVEIL_SKIP_URL_PREFIXESagainst the normalized route, which allows projects to exclude mounts such asdjango-admin/consistently for bothpath()andre_path(). - Record whether the normalized route contains
<...>placeholders. - Record whether the normalized route still contains regex groups such as
(. - Detect supported Wagtail API resolver routes by callback metadata.
- Infer concrete
resolved_urlvalues for supported detail-style endpoints when safe representative objects exist. - Leave query-driven
find_viewroutes visible but classify them as requiring query parameters. - Emit the final
FrontendURLonly after classification and build steps.
Resolver-derived URLs are still included when untestable so they remain visible in reports and API responses.
Testability Classification¶
Discovery and testability are separate concepts in this package. A URL can be discovered successfully and still be intentionally marked untestable. Untestable URLs remain in the output with a skip_reason so contributors can see why they are excluded from direct GET testing.
Classification is the main step that should decide why a route is not directly GET-testable:
- backend classification owns hard-coded non-testable names and serve-readiness checks
- backend finalization owns the final GET-compatibility check for resolved admin routes such as POST-only reorder views
- frontend classification owns
Requires POST submission,Requires query parameters,URL requires parameters, andURL contains regex patterns - frontend classification allows parameterized page routes to remain testable when a concrete
resolved_urlis available - frontend classification also marks non-default-site page URLs as untestable to avoid cross-host false positives in report testing
- backend finalization adds
URL requires parametersonly when a route required resolution and no concrete path could be produced
Current skip reasons used by the discovery layer:
POST-only viewIntentional error endpointRequires query parametersRequires path("documents/", include(wagtaildocs_urls)) in URLconfRequires path("images/", include(wagtailimages_urls)) in URLconfURL requires parametersURL contains regex patternsRequires POST submissionBelongs to non-default site host(or...: <hostname>/...: <hostname>:<port>when a hostname is available)
Known Limitations and Intentional Exclusions¶
Current intentional boundaries:
- admin routes that still contain unsafe regex constructs after cleanup are skipped entirely
- unresolved parameterized admin URLs remain visible but untestable
- multi-parameter admin routes are usually not auto-resolvable
- frontend resolver routes with regex constructs are discovered but not directly testable
- parameterized routable sub-routes are discovered but not directly testable
- form landing pages are represented but are not GET-testable
- page URLs discovered from non-default Wagtail
Siterecords are represented but marked untestable because report testing uses the current host - package routes in the
wagtail_unveilnamespace are intentionally excluded from frontend discovery - configured skip prefixes can remove page, admin, resolver, and routable URLs from discovery output
Where To Change This Behavior¶
If you need to change discovery behavior, start in these files:
wagtail_unveil/discovery/backend.pywagtail_unveil/discovery/backend_resolution.pywagtail_unveil/discovery/extensions.pywagtail_unveil/discovery/frontend.pywagtail_unveil/discovery/frontend_resolution.pywagtail_unveil/discovery/utils.pywagtail_unveil/settings.py
Then verify the intended behavior in:
tests/test_admin_urls.pytests/test_backend_resolution.pytests/test_discovery_extensions.pytests/test_frontend.pytests/test_frontend_resolution.pytests/test_settings.py
Code And Test Map¶
The main behavior is currently implemented in code and verified in tests:
wagtail_unveil/discovery/backend.pyandtests/test_admin_urls.pywagtail_unveil/discovery/backend_resolution.pyandtests/test_backend_resolution.pywagtail_unveil/discovery/frontend.pyandtests/test_frontend.pywagtail_unveil/discovery/frontend_resolution.pyandtests/test_frontend_resolution.pywagtail_unveil/settings.pyandtests/test_settings.py
Use the tests as verification of current behavior, not as the primary contributor-facing explanation of how discovery works. This document should be the first place a contributor reads when changing discovery and resolution logic.