"""The one renderer of the pipeline documentation page.
Three callers render ``annotate_doc_pipeline_template.jinja``: the
``annotate_doc`` CLI, the web API's download endpoint, and the
``annotation_pipeline`` resource implementation. Each used to bind the
template and build its own ``res_url``/``hist_url`` pair, and the copies
drifted -- ``d8624b787`` moved the CLI's addresses onto the GRR's public
mirror and left the endpoint's on the repository's own url, where they
stayed wrong for two months (#841, #952).
The addresses are a *policy*, not a constant, so they are injected rather
than hardcoded. Two callers want the public-mirror policy and get it by
default; the resource implementation publishes its pages from inside the
GRR tree and passes repository-relative addresses instead.
That policy is **one object**, not one callable per address (#970). #952
injected the pair as two independent arguments, which left a public
resource address beside a relative histogram address representable -- the
same drift as #841, merely moved up a level and into a single call. Both
policies live here, beside the renderer that chooses between them, and
the repository-relative one needs nothing but the page's own resource.
"""
from __future__ import annotations
from typing import Protocol
from urllib.parse import quote
from gain import logging
from gain.annotation.annotation_pipeline import AnnotationPipeline
from gain.genomic_resources.genomic_scores import GenomicScore
from gain.genomic_resources.repository import GenomicResource
from gain.templates import get_template
logger = logging.getLogger(__name__)
DOC_TEMPLATE_NAME = "annotate_doc_pipeline_template.jinja"
[docs]
class PipelineDocAddresses(Protocol):
"""Where a rendered page points, for every kind of thing it points at.
One object rather than a callable per address: the two are a single
policy, and a page that mixes them -- a resource named on the public
mirror beside a histogram named relatively -- is incoherent. Passed
separately that pairing was merely unlikely; passed together it is
unrepresentable (#970).
"""
[docs]
def resource_url(self, resource: GenomicResource) -> str:
"""Address of the resource's documentation page."""
...
[docs]
def histogram_url(
self, score: GenomicScore, score_id: str,
) -> str | None:
"""Address of the score's histogram image for ``score_id``.
``None`` when the score has no histogram to show.
"""
...
[docs]
class PublicMirrorAddresses:
"""Address everything on the GRR's public mirror.
What a reader who is *not* browsing the GRR tree needs: the page may
be downloaded, or served from somewhere else entirely, so nothing on
it may be relative to where it happens to sit.
"""
[docs]
def resource_url(self, resource: GenomicResource) -> str:
return resource.get_public_url()
[docs]
def histogram_url(
self, score: GenomicScore, score_id: str,
) -> str | None:
return score.get_histogram_image_public_url(score_id)
#: The policy every caller gets unless it says otherwise. Stateless, so
#: one shared instance rather than one per render.
PUBLIC_MIRROR_ADDRESSES = PublicMirrorAddresses()
[docs]
class RepositoryRelativeAddresses:
"""Address managed targets relative to the repository root.
The policy the ``annotation_pipeline`` resource implementation wants:
its pages are published *inside* the GRR tree, under the pipeline's
own resource id, so a reader browsing that tree resolves the links
without ever leaving it. Anything outside the managed GRR cannot be
reached that way and falls back to the mirror, with a warning.
Built from the pipeline's own resource, and needs nothing else -- no
implementation, no repository handle.
"""
def __init__(self, resource: GenomicResource) -> None:
self.resource = resource
@property
def _prefix_to_root_dir(self) -> str:
return "/".join([".."] * len(self.resource.resource_id.split("/")))
def _is_managed(self, url: str, target: GenomicResource) -> bool:
"""Whether ``url`` is published under the same root as this page.
This is the whole of the rule the two addresses share, warning
included: only a target under the managed GRR can be reached by a
relative path from inside the tree, and a curator who has pointed
a pipeline at some other repository wants to hear about it. The
*probe* differs per address -- see the call sites -- but the test
and the warning are decided once, here.
"""
if self.resource.get_repo_url() in url:
return True
logger.warning(
"Referencing resource outside managed GRR %s", target.get_id(),
)
return False
[docs]
def resource_url(self, resource: GenomicResource) -> str:
if not self._is_managed(resource.get_url(), resource):
return resource.get_public_url()
return "/".join([self._prefix_to_root_dir, resource.resource_id])
[docs]
def histogram_url(self, score: GenomicScore, score_id: str) -> str | None:
"""Address the score's histogram image, if it has one.
Probed differently from :meth:`resource_url`, and deliberately:
what has to be under the managed GRR is the *image*, not the
score's own page.
"""
# The probe doubles as the "is there an image at all" guard, and
# has to answer that *before* the containment rule is consulted:
# a score with no histogram has no address either way, managed or
# not, and must not draw a warning about the repository it is in.
image_url = score.get_histogram_image_url(score_id)
if image_url is None:
return None
if not self._is_managed(image_url, score.resource):
return score.get_histogram_image_public_url(score_id)
# Quoted, unlike the resource id above: this tail is built from
# the score id and reaches the page as a bare `src`. It is itself
# a two-segment path, which is why `quote` is left to spare "/".
return "/".join([
self._prefix_to_root_dir,
score.resource.resource_id,
quote(score.get_histogram_image_filename(score_id)),
])
[docs]
def render_pipeline_doc(
pipeline: AnnotationPipeline,
*,
pipeline_path: str | None = None,
addresses: PipelineDocAddresses = PUBLIC_MIRROR_ADDRESSES,
) -> str:
"""Render the documentation page for ``pipeline``.
``pipeline_path`` is shown on the page when given; the callers that
have no file to name leave it ``None``, which renders the same page as
omitting it entirely.
"""
template = get_template(DOC_TEMPLATE_NAME)
return template.render(
pipeline=pipeline,
pipeline_path=pipeline_path,
# The template still asks for the two addresses by name, and this
# is the one place they are taken apart -- so the pair it renders
# always comes from a single policy.
res_url=addresses.resource_url,
hist_url=addresses.histogram_url,
)