Skip to content

Commit

Permalink
Added code references to search results.
Browse files Browse the repository at this point in the history
  • Loading branch information
sarahboyce committed Feb 25, 2025
1 parent 0070473 commit 586f1e7
Show file tree
Hide file tree
Showing 13 changed files with 848 additions and 464 deletions.
32 changes: 32 additions & 0 deletions djangoproject/scss/_style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2635,6 +2635,38 @@ table.docutils th {
color: var(--search-mark-text);
}
}

.code-links {
margin-top: 15px;
margin-left: 10px;

a {
&:active,
&:focus,
&:hover {
code {
color: var(--primary);
}
.meta {
color: var(--text-light);
}
}
}

code {
color: var(--primary-accent);
font-weight: 700;
}

div {
margin: 10px 0;

.meta {
margin: 5px 0 0;
color: var(--body-fg);
}
}
}
}

.list-links-small {
Expand Down
39 changes: 39 additions & 0 deletions docs/builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from functools import cached_property

from sphinxcontrib.serializinghtml import JSONHTMLBuilder


class PythonObjectsJSONHTMLBuilder(JSONHTMLBuilder):
name = "pyjson"

@cached_property
def domain_objects(self):
domain = self.env.get_domain("py")
return [item for item in domain.get_objects() if item[2] != "module"]

def get_doc_context(self, docname, body, metatags):
out_dict = super().get_doc_context(docname, body, metatags)
python_objects = self.get_python_objects(docname)
out_dict["python_objects"] = python_objects
out_dict["python_objects_search"] = " ".join(
# Keeps the code suffix to improve the search results for terms such as
# "select" for QuerySet.select_related.
[key.split(".")[-1] for key in python_objects.keys()]
)
return out_dict

def get_python_objects(self, docname):
entries = {}
for name, _, _, obj_docname, _, _ in self.domain_objects:
if obj_docname == docname:
code_path = name.split(".")[-2:]
if code_path[0][0].isupper():
short_name = ".".join(code_path)
else:
short_name = code_path[-1]
entries[short_name] = name
return entries


def setup(app):
app.add_builder(PythonObjectsJSONHTMLBuilder)
50 changes: 28 additions & 22 deletions docs/management/commands/update_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@

from django.conf import settings
from django.core.management import BaseCommand, call_command
from django.utils.translation import to_locale
from sphinx.application import Sphinx
from sphinx.errors import SphinxError

from ...models import DocumentRelease

Expand Down Expand Up @@ -55,7 +56,7 @@ def handle(self, **kwargs):
self.update_index = kwargs["update_index"]
self.purge_cache = kwargs["purge_cache"]

self.default_builders = ["json", "djangohtml"]
self.default_builders = ["pyjson", "djangohtml"]
default_docs_version = DocumentRelease.objects.get(
is_default=True
).release.version
Expand Down Expand Up @@ -172,32 +173,37 @@ def build_doc_release(self, release, force=False):
if build_dir.exists():
shutil.rmtree(str(build_dir))
build_dir.mkdir(parents=True)
app = Sphinx(
srcdir=str(source_dir),
confdir=str(source_dir),
outdir=str(build_dir),
doctreedir=str(build_dir / ".doctrees"),
buildername=builder,
status=sys.stdout,
verbosity=1,
confoverrides={
"extensions": [
"djangodocs",
"sphinx.ext.extlinks",
"sphinx.ext.intersphinx",
"sphinx.ext.autosectionlabel",
"sphinx.ext.linkcode",
"docs.builder",
]
},
)

if self.verbosity >= 2:
self.stdout.write(f" building {builder} ({source_dir} -> {build_dir})")
try:
# Translated docs builds generate a lot of warnings, so send
# stderr to stdout to be logged (rather than generating an
# email)
subprocess.check_call(
[
"sphinx-build",
"-b",
builder,
"-D",
"language=%s" % to_locale(release.lang),
"-j",
"auto",
"-Q" if self.verbosity == 0 else "-q",
str(source_dir), # Source file directory
str(build_dir), # Destination directory
],
stderr=sys.stdout,
)
except subprocess.CalledProcessError:
app.build()
except SphinxError as e:
self.stderr.write(
"sphinx-build returned an error (release %s, builder %s)"
% (release, builder)
"sphinx-build returned an error (release %s, builder %s): %s"
% (release, builder, str(e))
)
return

Expand Down Expand Up @@ -252,8 +258,8 @@ def zipfile_inclusion_filter(file_path):
if self.verbosity >= 2:
self.stdout.write(" reindexing...")

json_built_dir = parent_build_dir.joinpath("_built", "json")
documents = gen_decoded_documents(json_built_dir)
pyjson_built_dir = parent_build_dir.joinpath("_built", "pyjson")
documents = gen_decoded_documents(pyjson_built_dir)
release.sync_to_db(documents)

def update_git(self, url, destdir, changed_dir="."):
Expand Down
25 changes: 14 additions & 11 deletions docs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import html
import json
import operator
from functools import reduce
from functools import partial, reduce
from pathlib import Path

from django.conf import settings
Expand Down Expand Up @@ -252,6 +252,12 @@ def search(self, query_text, release):
query_text, config=models.F("config"), search_type="websearch"
)
search_rank = SearchRank(models.F("search"), search_query)
search = partial(
SearchHeadline,
start_sel=START_SEL,
stop_sel=STOP_SEL,
config=models.F("config"),
)
base_qs = (
self.prefetch_related(
Prefetch(
Expand All @@ -264,21 +270,18 @@ def search(self, query_text, release):
)
.filter(release_id=release.id)
.annotate(
headline=SearchHeadline(
"title",
headline=search("title", search_query),
highlight=search(
KeyTextTransform("body", "metadata"),
search_query,
start_sel=START_SEL,
stop_sel=STOP_SEL,
config=models.F("config"),
),
highlight=SearchHeadline(
KeyTextTransform("body", "metadata"),
searched_python_objects=search(
KeyTextTransform("python_objects_search", "metadata"),
search_query,
start_sel=START_SEL,
stop_sel=STOP_SEL,
config=models.F("config"),
highlight_all=True,
),
breadcrumbs=models.F("metadata__breadcrumbs"),
python_objects=models.F("metadata__python_objects"),
)
.only(
"path",
Expand Down
13 changes: 13 additions & 0 deletions docs/templates/docs/search_results.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ <h2 class="result-title">
{% if result.highlight %}
…&nbsp;{{ result.highlight|cut:"¶"|safe }}&nbsp;…
{% endif %}
{% code_links result.searched_python_objects result.python_objects as result_code_links %}
{% if result_code_links %}
<div class="code-links">
{% for name, value in result_code_links.items %}
<a href="{% url 'document-detail' lang=result.release.lang version=result.release.version url=result.path host 'docs' %}#{{ value.full_path }}">
<div>
<code>{{ name }}</code>
{% if value.module_path %}<div class="meta">{{ value.module_path }}</div>{% endif %}
</div>
</a>
{% endfor %}
</div>
{% endif %}
</dd>
{% endfor %}
</dl>
Expand Down
28 changes: 27 additions & 1 deletion docs/templatetags/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from ..forms import DocSearchForm
from ..models import DocumentRelease
from ..search import START_SEL, STOP_SEL
from ..utils import get_doc_path, get_doc_root
from ..utils import get_doc_path, get_doc_root, get_module_path

register = template.Library()

Expand Down Expand Up @@ -121,3 +121,29 @@ def generate_scroll_to_text_fragment(highlighted_text):
# Due to Python code such as timezone.now(), remove the space after a bracket.
single_spaced = re.sub(r"([(\[])\s", r"\1", single_spaced)
return f"#:~:text={quote(single_spaced)}"


@register.simple_tag(name="code_links")
def code_links(searched_python_objects, python_objects):
if not searched_python_objects or START_SEL not in searched_python_objects:
return {}
python_objects_matched_short_names = [
word.replace(START_SEL, "").replace(STOP_SEL, "")
for word in searched_python_objects.split(" ")
if START_SEL in word
]
matched_reference = {}
# Map "select_related" to "QuerySet.select_related" in code_references.
reference_map = {key.split(".")[-1]: key for key in python_objects.keys()}
for short_name in python_objects_matched_short_names:
if full_path := python_objects.get(short_name):
matched_reference[short_name] = {
"full_path": full_path,
"module_path": get_module_path(short_name, full_path),
}
elif name := reference_map.get(short_name):
matched_reference[name] = {
"full_path": python_objects[name],
"module_path": get_module_path(name, python_objects[name]),
}
return dict(sorted(matched_reference.items()))
Empty file added docs/tests/__init__.py
Empty file.
63 changes: 63 additions & 0 deletions docs/tests/test_builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from unittest.mock import Mock, patch

from django.test import SimpleTestCase

from ..builder import PythonObjectsJSONHTMLBuilder


class TestPythonObjectsJSONHTMLBuilder(SimpleTestCase):
def setUp(self):
self.app = Mock()
self.env = Mock()
self.app.doctreedir = "/tmp"
self.env.get_domain = Mock()
self.mock_domain = Mock()
self.env.get_domain.return_value = self.mock_domain
self.builder = PythonObjectsJSONHTMLBuilder(self.app, self.env)

def test_domain_objects_excludes_modules(self):
self.mock_domain.get_objects.return_value = [
("module1", "module1", "module", "doc1", "", ""),
("ClassA", "ClassA", "class", "doc2", "", ""),
("function_b", "function_b", "function", "doc2", "", ""),
]

expected_objects = [
("ClassA", "ClassA", "class", "doc2", "", ""),
("function_b", "function_b", "function", "doc2", "", ""),
]
self.assertEqual(self.builder.domain_objects, expected_objects)

def test_get_python_objects(self):
self.mock_domain.get_objects.return_value = [
(
"module1.ClassA.method",
"module1.ClassA.method",
"method",
"doc1",
"",
"",
),
("module1.ClassA", "module1.ClassA", "class", "doc1", "", ""),
("module1.function_b", "module1.function_b", "function", "doc1", "", ""),
]
expected_result = {
"ClassA": "module1.ClassA",
"ClassA.method": "module1.ClassA.method",
"function_b": "module1.function_b",
}
self.assertEqual(self.builder.get_python_objects("doc1"), expected_result)

@patch("docs.builder.JSONHTMLBuilder.get_doc_context")
def test_get_doc_context(self, mock_super_get_doc_context):
mock_super_get_doc_context.return_value = {}
self.mock_domain.get_objects.return_value = [
("module1", "module1", "module", "doc1", "", ""),
("module1.ClassA", "module1.ClassA", "class", "doc1", "", ""),
("function_b", "function_b", "function", "doc2", "", ""),
]
result = self.builder.get_doc_context("doc1", "", "")
self.assertIn("python_objects", result)
self.assertIn("python_objects_search", result)
self.assertEqual(result["python_objects"], {"ClassA": "module1.ClassA"})
self.assertEqual(result["python_objects_search"], "ClassA")
Loading

0 comments on commit 586f1e7

Please sign in to comment.