diff --git a/swh/web/browse/utils.py b/swh/web/browse/utils.py
index 2cfeb070..99bd5464 100644
--- a/swh/web/browse/utils.py
+++ b/swh/web/browse/utils.py
@@ -1,723 +1,721 @@
-# Copyright (C) 2017-2020 The Software Heritage developers
+# Copyright (C) 2017-2021 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
import base64
import stat
import textwrap
from threading import Lock
import magic
import sentry_sdk
from django.core.cache import cache
from django.utils.html import escape
from django.utils.safestring import mark_safe
from swh.web.common import archive, highlightjs
from swh.web.common.exc import NotFoundExc
from swh.web.common.utils import (
browsers_supported_image_mimes,
format_utc_iso_date,
reverse,
rst_to_html,
)
from swh.web.config import get_config
def get_directory_entries(sha1_git):
"""Function that retrieves the content of a directory
from the archive.
The directories entries are first sorted in lexicographical order.
Sub-directories and regular files are then extracted.
Args:
sha1_git: sha1_git identifier of the directory
Returns:
A tuple whose first member corresponds to the sub-directories list
and second member the regular files list
Raises:
NotFoundExc if the directory is not found
"""
cache_entry_id = "directory_entries_%s" % sha1_git
cache_entry = cache.get(cache_entry_id)
if cache_entry:
return cache_entry
entries = list(archive.lookup_directory(sha1_git))
for e in entries:
e["perms"] = stat.filemode(e["perms"])
if e["type"] == "rev":
# modify dir entry name to explicitly show it points
# to a revision
e["name"] = "%s @ %s" % (e["name"], e["target"][:7])
dirs = [e for e in entries if e["type"] in ("dir", "rev")]
files = [e for e in entries if e["type"] == "file"]
dirs = sorted(dirs, key=lambda d: d["name"])
files = sorted(files, key=lambda f: f["name"])
cache.set(cache_entry_id, (dirs, files))
return dirs, files
_lock = Lock()
def get_mimetype_and_encoding_for_content(content):
"""Function that returns the mime type and the encoding associated to
a content buffer using the magic module under the hood.
Args:
content (bytes): a content buffer
Returns:
A tuple (mimetype, encoding), for instance ('text/plain', 'us-ascii'),
associated to the provided content.
"""
m = magic.Magic(mime=True, mime_encoding=True)
mime_encoding = m.from_buffer(content)
mime_type, encoding = mime_encoding.split(";")
encoding = encoding.replace(" charset=", "")
return mime_type, encoding
# maximum authorized content size in bytes for HTML display
# with code highlighting
content_display_max_size = get_config()["content_display_max_size"]
def _re_encode_content(mimetype, encoding, content_data):
# encode textual content to utf-8 if needed
if mimetype.startswith("text/"):
# probably a malformed UTF-8 content, re-encode it
# by replacing invalid chars with a substitution one
if encoding == "unknown-8bit":
content_data = content_data.decode("utf-8", "replace").encode("utf-8")
elif encoding not in ["utf-8", "binary"]:
content_data = content_data.decode(encoding, "replace").encode("utf-8")
elif mimetype.startswith("application/octet-stream"):
# file may detect a text content as binary
# so try to decode it for display
encodings = ["us-ascii", "utf-8"]
encodings += ["iso-8859-%s" % i for i in range(1, 17)]
for enc in encodings:
try:
content_data = content_data.decode(enc).encode("utf-8")
except Exception as exc:
sentry_sdk.capture_exception(exc)
else:
# ensure display in content view
encoding = enc
mimetype = "text/plain"
break
return mimetype, encoding, content_data
def request_content(
query_string, max_size=content_display_max_size, re_encode=True,
):
"""Function that retrieves a content from the archive.
Raw bytes content is first retrieved, then the content mime type.
If the mime type is not stored in the archive, it will be computed
using Python magic module.
Args:
query_string: a string of the form "[ALGO_HASH:]HASH" where
optional ALGO_HASH can be either ``sha1``, ``sha1_git``,
``sha256``, or ``blake2s256`` (default to ``sha1``) and HASH
the hexadecimal representation of the hash value
max_size: the maximum size for a content to retrieve (default to 1MB,
no size limit if None)
Returns:
A tuple whose first member corresponds to the content raw bytes
and second member the content mime type
Raises:
NotFoundExc if the content is not found
"""
content_data = archive.lookup_content(query_string)
filetype = None
language = None
# requests to the indexer db may fail so properly handle
# those cases in order to avoid content display errors
try:
filetype = archive.lookup_content_filetype(query_string)
language = archive.lookup_content_language(query_string)
except Exception as exc:
sentry_sdk.capture_exception(exc)
mimetype = "unknown"
encoding = "unknown"
if filetype:
mimetype = filetype["mimetype"]
encoding = filetype["encoding"]
# workaround when encountering corrupted data due to implicit
# conversion from bytea to text in the indexer db (see T818)
# TODO: Remove that code when all data have been correctly converted
if mimetype.startswith("\\"):
filetype = None
if not max_size or content_data["length"] < max_size:
try:
content_raw = archive.lookup_content_raw(query_string)
except Exception as exc:
sentry_sdk.capture_exception(exc)
raise NotFoundExc(
"The bytes of the content are currently not available "
"in the archive."
)
else:
content_data["raw_data"] = content_raw["data"]
if not filetype:
mimetype, encoding = get_mimetype_and_encoding_for_content(
content_data["raw_data"]
)
if re_encode:
mimetype, encoding, raw_data = _re_encode_content(
mimetype, encoding, content_data["raw_data"]
)
content_data["raw_data"] = raw_data
else:
content_data["raw_data"] = None
content_data["mimetype"] = mimetype
content_data["encoding"] = encoding
if language:
content_data["language"] = language["lang"]
else:
content_data["language"] = "not detected"
return content_data
def prepare_content_for_display(content_data, mime_type, path):
"""Function that prepares a content for HTML display.
The function tries to associate a programming language to a
content in order to perform syntax highlighting client-side
using highlightjs. The language is determined using either
the content filename or its mime type.
If the mime type corresponds to an image format supported
by web browsers, the content will be encoded in base64
for displaying the image.
Args:
content_data (bytes): raw bytes of the content
mime_type (string): mime type of the content
path (string): path of the content including filename
Returns:
A dict containing the content bytes (possibly different from the one
provided as parameter if it is an image) under the key 'content_data
and the corresponding highlightjs language class under the
key 'language'.
"""
language = highlightjs.get_hljs_language_from_filename(path)
if not language:
language = highlightjs.get_hljs_language_from_mime_type(mime_type)
if not language:
language = "nohighlight"
- elif mime_type.startswith("application/"):
- mime_type = mime_type.replace("application/", "text/")
if mime_type.startswith("image/"):
if mime_type in browsers_supported_image_mimes:
content_data = base64.b64encode(content_data).decode("ascii")
if mime_type.startswith("image/svg"):
mime_type = "image/svg+xml"
- if mime_type.startswith("text/"):
+ if mime_type.startswith("text/") or mime_type.startswith("application/"):
content_data = content_data.decode("utf-8", errors="replace")
return {"content_data": content_data, "language": language, "mimetype": mime_type}
def gen_link(url, link_text=None, link_attrs=None):
"""
Utility function for generating an HTML link to insert
in Django templates.
Args:
url (str): an url
link_text (str): optional text for the produced link,
if not provided the url will be used
link_attrs (dict): optional attributes (e.g. class)
to add to the link
Returns:
An HTML link in the form 'link_text '
"""
attrs = " "
if link_attrs:
for k, v in link_attrs.items():
attrs += '%s="%s" ' % (k, v)
if not link_text:
link_text = url
link = '%s ' % (attrs, escape(url), escape(link_text))
return mark_safe(link)
def _snapshot_context_query_params(snapshot_context):
query_params = {}
if not snapshot_context:
return query_params
if snapshot_context and snapshot_context["origin_info"]:
origin_info = snapshot_context["origin_info"]
snp_query_params = snapshot_context["query_params"]
query_params = {"origin_url": origin_info["url"]}
if "timestamp" in snp_query_params:
query_params["timestamp"] = snp_query_params["timestamp"]
if "visit_id" in snp_query_params:
query_params["visit_id"] = snp_query_params["visit_id"]
if "snapshot" in snp_query_params and "visit_id" not in query_params:
query_params["snapshot"] = snp_query_params["snapshot"]
elif snapshot_context:
query_params = {"snapshot": snapshot_context["snapshot_id"]}
if snapshot_context["release"]:
query_params["release"] = snapshot_context["release"]
elif snapshot_context["branch"] and snapshot_context["branch"] not in (
"HEAD",
snapshot_context["revision_id"],
):
query_params["branch"] = snapshot_context["branch"]
elif snapshot_context["revision_id"]:
query_params["revision"] = snapshot_context["revision_id"]
return query_params
def gen_revision_url(revision_id, snapshot_context=None):
"""
Utility function for generating an url to a revision.
Args:
revision_id (str): a revision id
snapshot_context (dict): if provided, generate snapshot-dependent
browsing url
Returns:
str: The url to browse the revision
"""
query_params = _snapshot_context_query_params(snapshot_context)
# remove query parameters not needed for a revision view
query_params.pop("revision", None)
query_params.pop("release", None)
return reverse(
"browse-revision", url_args={"sha1_git": revision_id}, query_params=query_params
)
def gen_revision_link(
revision_id,
shorten_id=False,
snapshot_context=None,
link_text="Browse",
link_attrs={"class": "btn btn-default btn-sm", "role": "button"},
):
"""
Utility function for generating a link to a revision HTML view
to insert in Django templates.
Args:
revision_id (str): a revision id
shorten_id (boolean): whether to shorten the revision id to 7
characters for the link text
snapshot_context (dict): if provided, generate snapshot-dependent
browsing link
link_text (str): optional text for the generated link
(the revision id will be used by default)
link_attrs (dict): optional attributes (e.g. class)
to add to the link
Returns:
str: An HTML link in the form 'revision_id '
"""
if not revision_id:
return None
revision_url = gen_revision_url(revision_id, snapshot_context)
if shorten_id:
return gen_link(revision_url, revision_id[:7], link_attrs)
else:
if not link_text:
link_text = revision_id
return gen_link(revision_url, link_text, link_attrs)
def gen_directory_link(
sha1_git,
snapshot_context=None,
link_text="Browse",
link_attrs={"class": "btn btn-default btn-sm", "role": "button"},
):
"""
Utility function for generating a link to a directory HTML view
to insert in Django templates.
Args:
sha1_git (str): directory identifier
link_text (str): optional text for the generated link
(the directory id will be used by default)
link_attrs (dict): optional attributes (e.g. class)
to add to the link
Returns:
An HTML link in the form 'link_text '
"""
if not sha1_git:
return None
query_params = _snapshot_context_query_params(snapshot_context)
directory_url = reverse(
"browse-directory", url_args={"sha1_git": sha1_git}, query_params=query_params
)
if not link_text:
link_text = sha1_git
return gen_link(directory_url, link_text, link_attrs)
def gen_snapshot_link(
snapshot_id,
snapshot_context=None,
link_text="Browse",
link_attrs={"class": "btn btn-default btn-sm", "role": "button"},
):
"""
Utility function for generating a link to a snapshot HTML view
to insert in Django templates.
Args:
snapshot_id (str): snapshot identifier
link_text (str): optional text for the generated link
(the snapshot id will be used by default)
link_attrs (dict): optional attributes (e.g. class)
to add to the link
Returns:
An HTML link in the form 'link_text '
"""
query_params = _snapshot_context_query_params(snapshot_context)
snapshot_url = reverse(
"browse-snapshot",
url_args={"snapshot_id": snapshot_id},
query_params=query_params,
)
if not link_text:
link_text = snapshot_id
return gen_link(snapshot_url, link_text, link_attrs)
def gen_content_link(
sha1_git,
snapshot_context=None,
link_text="Browse",
link_attrs={"class": "btn btn-default btn-sm", "role": "button"},
):
"""
Utility function for generating a link to a content HTML view
to insert in Django templates.
Args:
sha1_git (str): content identifier
link_text (str): optional text for the generated link
(the content sha1_git will be used by default)
link_attrs (dict): optional attributes (e.g. class)
to add to the link
Returns:
An HTML link in the form 'link_text '
"""
if not sha1_git:
return None
query_params = _snapshot_context_query_params(snapshot_context)
content_url = reverse(
"browse-content",
url_args={"query_string": "sha1_git:" + sha1_git},
query_params=query_params,
)
if not link_text:
link_text = sha1_git
return gen_link(content_url, link_text, link_attrs)
def get_revision_log_url(revision_id, snapshot_context=None):
"""
Utility function for getting the URL for a revision log HTML view
(possibly in the context of an origin).
Args:
revision_id (str): revision identifier the history heads to
snapshot_context (dict): if provided, generate snapshot-dependent
browsing link
Returns:
The revision log view URL
"""
query_params = {}
if snapshot_context:
query_params = _snapshot_context_query_params(snapshot_context)
query_params["revision"] = revision_id
if snapshot_context and snapshot_context["origin_info"]:
revision_log_url = reverse("browse-origin-log", query_params=query_params)
elif snapshot_context:
url_args = {"snapshot_id": snapshot_context["snapshot_id"]}
del query_params["snapshot"]
revision_log_url = reverse(
"browse-snapshot-log", url_args=url_args, query_params=query_params
)
else:
revision_log_url = reverse(
"browse-revision-log", url_args={"sha1_git": revision_id}
)
return revision_log_url
def gen_revision_log_link(
revision_id,
snapshot_context=None,
link_text="Browse",
link_attrs={"class": "btn btn-default btn-sm", "role": "button"},
):
"""
Utility function for generating a link to a revision log HTML view
(possibly in the context of an origin) to insert in Django templates.
Args:
revision_id (str): revision identifier the history heads to
snapshot_context (dict): if provided, generate snapshot-dependent
browsing link
link_text (str): optional text to use for the generated link
(the revision id will be used by default)
link_attrs (dict): optional attributes (e.g. class)
to add to the link
Returns:
An HTML link in the form
'link_text '
"""
if not revision_id:
return None
revision_log_url = get_revision_log_url(revision_id, snapshot_context)
if not link_text:
link_text = revision_id
return gen_link(revision_log_url, link_text, link_attrs)
def gen_person_mail_link(person, link_text=None):
"""
Utility function for generating a mail link to a person to insert
in Django templates.
Args:
person (dict): dictionary containing person data
(*name*, *email*, *fullname*)
link_text (str): optional text to use for the generated mail link
(the person name will be used by default)
Returns:
str: A mail link to the person or the person name if no email is
present in person data
"""
person_name = person["name"] or person["fullname"] or "None"
if link_text is None:
link_text = person_name
person_email = person["email"] if person["email"] else None
if person_email is None and "@" in person_name and " " not in person_name:
person_email = person_name
if person_email:
return gen_link(url="mailto:%s" % person_email, link_text=link_text)
else:
return person_name
def gen_release_link(
sha1_git,
snapshot_context=None,
link_text="Browse",
link_attrs={"class": "btn btn-default btn-sm", "role": "button"},
):
"""
Utility function for generating a link to a release HTML view
to insert in Django templates.
Args:
sha1_git (str): release identifier
link_text (str): optional text for the generated link
(the release id will be used by default)
link_attrs (dict): optional attributes (e.g. class)
to add to the link
Returns:
An HTML link in the form 'link_text '
"""
query_params = _snapshot_context_query_params(snapshot_context)
release_url = reverse(
"browse-release", url_args={"sha1_git": sha1_git}, query_params=query_params
)
if not link_text:
link_text = sha1_git
return gen_link(release_url, link_text, link_attrs)
def format_log_entries(revision_log, per_page, snapshot_context=None):
"""
Utility functions that process raw revision log data for HTML display.
Its purpose is to:
* add links to relevant browse views
* format date in human readable format
* truncate the message log
Args:
revision_log (list): raw revision log as returned by the swh-web api
per_page (int): number of log entries per page
snapshot_context (dict): if provided, generate snapshot-dependent
browsing link
"""
revision_log_data = []
for i, rev in enumerate(revision_log):
if i == per_page:
break
author_name = "None"
author_fullname = "None"
committer_fullname = "None"
if rev["author"]:
author_name = gen_person_mail_link(rev["author"])
author_fullname = rev["author"]["fullname"]
if rev["committer"]:
committer_fullname = rev["committer"]["fullname"]
author_date = format_utc_iso_date(rev["date"])
committer_date = format_utc_iso_date(rev["committer_date"])
tooltip = "revision %s\n" % rev["id"]
tooltip += "author: %s\n" % author_fullname
tooltip += "author date: %s\n" % author_date
tooltip += "committer: %s\n" % committer_fullname
tooltip += "committer date: %s\n\n" % committer_date
if rev["message"]:
tooltip += textwrap.indent(rev["message"], " " * 4)
revision_log_data.append(
{
"author": author_name,
"id": rev["id"][:7],
"message": rev["message"],
"date": author_date,
"commit_date": committer_date,
"url": gen_revision_url(rev["id"], snapshot_context),
"tooltip": tooltip,
}
)
return revision_log_data
# list of common readme names ordered by preference
# (lower indices have higher priority)
_common_readme_names = [
"readme.markdown",
"readme.md",
"readme.rst",
"readme.txt",
"readme",
]
def get_readme_to_display(readmes):
"""
Process a list of readme files found in a directory
in order to find the adequate one to display.
Args:
readmes: a list of dict where keys are readme file names and values
are readme sha1s
Returns:
A tuple (readme_name, readme_sha1)
"""
readme_name = None
readme_url = None
readme_sha1 = None
readme_html = None
lc_readmes = {k.lower(): {"orig_name": k, "sha1": v} for k, v in readmes.items()}
# look for readme names according to the preference order
# defined by the _common_readme_names list
for common_readme_name in _common_readme_names:
if common_readme_name in lc_readmes:
readme_name = lc_readmes[common_readme_name]["orig_name"]
readme_sha1 = lc_readmes[common_readme_name]["sha1"]
readme_url = reverse(
"browse-content-raw",
url_args={"query_string": readme_sha1},
query_params={"re_encode": "true"},
)
break
# otherwise pick the first readme like file if any
if not readme_name and len(readmes.items()) > 0:
readme_name = next(iter(readmes))
readme_sha1 = readmes[readme_name]
readme_url = reverse(
"browse-content-raw",
url_args={"query_string": readme_sha1},
query_params={"re_encode": "true"},
)
# convert rst README to html server side as there is
# no viable solution to perform that task client side
if readme_name and readme_name.endswith(".rst"):
cache_entry_id = "readme_%s" % readme_sha1
cache_entry = cache.get(cache_entry_id)
if cache_entry:
readme_html = cache_entry
else:
try:
rst_doc = request_content(readme_sha1)
readme_html = rst_to_html(rst_doc["raw_data"])
cache.set(cache_entry_id, readme_html)
except Exception as exc:
sentry_sdk.capture_exception(exc)
readme_html = "Readme bytes are not available"
return readme_name, readme_url, readme_html
diff --git a/swh/web/templates/includes/content-display.html b/swh/web/templates/includes/content-display.html
index ccc6f69a..17baae46 100644
--- a/swh/web/templates/includes/content-display.html
+++ b/swh/web/templates/includes/content-display.html
@@ -1,79 +1,79 @@
{% comment %}
Copyright (C) 2017-2020 The Software Heritage developers
See the AUTHORS file at the top-level directory of this distribution
License: GNU Affero General Public License version 3, or any later version
See top-level LICENSE file for more information
{% endcomment %}
{% load swh_templatetags %}
{% include "includes/revision-info.html" %}
{% if snapshot_context and snapshot_context.is_empty %}
{% include "includes/empty-snapshot.html" %}
{% else %}
{% if filename %}
{% endif %}
{% if content_size > max_content_size %}
Content is too large to be displayed (size is greater than {{ max_content_size|filesizeformat }}).
{% elif "inode/x-empty" == mimetype %}
File is empty
{% elif filename and filename|default:""|slice:"-5:" == "ipynb" %}
- {% elif "text/" in mimetype and encoding != "binary" %}
+ {% elif "text/" in mimetype or "application/" in mimetype and encoding != "binary" %}
{% elif mimetype in browsers_supported_image_mimes and content %}
{% elif "application/pdf" == mimetype %}
{% elif content %}
Content with mime type {{ mimetype }} and encoding {{ encoding }} cannot be displayed.
{% else %}
{% include "includes/http-error.html" %}
{% endif %}
{% endif %}
diff --git a/swh/web/tests/browse/views/test_content.py b/swh/web/tests/browse/views/test_content.py
index 231a15f5..0e133126 100644
--- a/swh/web/tests/browse/views/test_content.py
+++ b/swh/web/tests/browse/views/test_content.py
@@ -1,612 +1,617 @@
-# Copyright (C) 2017-2020 The Software Heritage developers
+# Copyright (C) 2017-2021 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
import random
from hypothesis import given
from django.utils.html import escape
from swh.model.identifiers import CONTENT, DIRECTORY, RELEASE, REVISION, SNAPSHOT
from swh.web.browse.snapshot_context import process_snapshot_branches
from swh.web.browse.utils import (
_re_encode_content,
get_mimetype_and_encoding_for_content,
prepare_content_for_display,
)
from swh.web.common.exc import NotFoundExc
from swh.web.common.identifiers import gen_swhid
from swh.web.common.utils import gen_path_info, reverse
from swh.web.tests.django_asserts import assert_contains, assert_not_contains
from swh.web.tests.strategies import (
content,
+ content_application_no_highlight,
content_image_type,
content_text,
content_text_no_highlight,
content_text_non_utf8,
content_unsupported_image_type_rendering,
content_utf8_detected_as_binary,
invalid_sha1,
origin_with_multiple_visits,
unknown_content,
)
from swh.web.tests.utils import check_html_get_response, check_http_get_response
@given(content_text())
def test_content_view_text(client, archive_data, content):
sha1_git = content["sha1_git"]
url = reverse(
"browse-content",
url_args={"query_string": content["sha1"]},
query_params={"path": content["path"]},
)
url_raw = reverse("browse-content-raw", url_args={"query_string": content["sha1"]})
resp = check_html_get_response(
client, url, status_code=200, template_used="browse/content.html"
)
content_display = _process_content_for_display(archive_data, content)
mimetype = content_display["mimetype"]
if mimetype.startswith("text/"):
assert_contains(resp, '' % content_display["language"])
assert_contains(resp, escape(content_display["content_data"]))
assert_contains(resp, url_raw)
swh_cnt_id = gen_swhid(CONTENT, sha1_git)
swh_cnt_id_url = reverse("browse-swhid", url_args={"swhid": swh_cnt_id})
assert_contains(resp, swh_cnt_id)
assert_contains(resp, swh_cnt_id_url)
assert_not_contains(resp, "swh-metadata-popover")
-@given(content_text_no_highlight())
-def test_content_view_text_no_highlight(client, archive_data, content):
- sha1_git = content["sha1_git"]
+@given(content_application_no_highlight(), content_text_no_highlight())
+def test_content_view_no_highlight(client, archive_data, content_app, content_text):
+ for content_ in (content_app, content_text):
+ content = content_
+ sha1_git = content["sha1_git"]
- url = reverse("browse-content", url_args={"query_string": content["sha1"]})
+ url = reverse("browse-content", url_args={"query_string": content["sha1"]})
- url_raw = reverse("browse-content-raw", url_args={"query_string": content["sha1"]})
+ url_raw = reverse(
+ "browse-content-raw", url_args={"query_string": content["sha1"]}
+ )
- resp = check_html_get_response(
- client, url, status_code=200, template_used="browse/content.html"
- )
+ resp = check_html_get_response(
+ client, url, status_code=200, template_used="browse/content.html"
+ )
- content_display = _process_content_for_display(archive_data, content)
+ content_display = _process_content_for_display(archive_data, content)
- assert_contains(resp, '')
- assert_contains(resp, escape(content_display["content_data"]))
- assert_contains(resp, url_raw)
+ assert_contains(resp, '')
+ assert_contains(resp, escape(content_display["content_data"]))
+ assert_contains(resp, url_raw)
- swh_cnt_id = gen_swhid(CONTENT, sha1_git)
- swh_cnt_id_url = reverse("browse-swhid", url_args={"swhid": swh_cnt_id})
+ swh_cnt_id = gen_swhid(CONTENT, sha1_git)
+ swh_cnt_id_url = reverse("browse-swhid", url_args={"swhid": swh_cnt_id})
- assert_contains(resp, swh_cnt_id)
- assert_contains(resp, swh_cnt_id_url)
+ assert_contains(resp, swh_cnt_id)
+ assert_contains(resp, swh_cnt_id_url)
@given(content_text_non_utf8())
def test_content_view_no_utf8_text(client, archive_data, content):
sha1_git = content["sha1_git"]
url = reverse("browse-content", url_args={"query_string": content["sha1"]})
resp = check_html_get_response(
client, url, status_code=200, template_used="browse/content.html"
)
content_display = _process_content_for_display(archive_data, content)
swh_cnt_id = gen_swhid(CONTENT, sha1_git)
swh_cnt_id_url = reverse("browse-swhid", url_args={"swhid": swh_cnt_id})
assert_contains(resp, swh_cnt_id_url)
assert_contains(resp, escape(content_display["content_data"]))
@given(content_image_type())
def test_content_view_image(client, archive_data, content):
url = reverse("browse-content", url_args={"query_string": content["sha1"]})
url_raw = reverse("browse-content-raw", url_args={"query_string": content["sha1"]})
resp = check_html_get_response(
client, url, status_code=200, template_used="browse/content.html"
)
content_display = _process_content_for_display(archive_data, content)
mimetype = content_display["mimetype"]
content_data = content_display["content_data"]
assert_contains(resp, ' ' % (mimetype, content_data))
assert_contains(resp, url_raw)
@given(content_unsupported_image_type_rendering())
def test_content_view_image_no_rendering(client, archive_data, content):
url = reverse("browse-content", url_args={"query_string": content["sha1"]})
resp = check_html_get_response(
client, url, status_code=200, template_used="browse/content.html"
)
mimetype = content["mimetype"]
encoding = content["encoding"]
assert_contains(
resp,
(
f"Content with mime type {mimetype} and encoding {encoding} "
"cannot be displayed."
),
)
@given(content_text())
def test_content_view_text_with_path(client, archive_data, content):
path = content["path"]
url = reverse(
"browse-content",
url_args={"query_string": content["sha1"]},
query_params={"path": path},
)
resp = check_html_get_response(
client, url, status_code=200, template_used="browse/content.html"
)
assert_contains(resp, '' % hljs_language)
assert_contains(resp, escape(content_display["content_data"]))
split_path = path.split("/")
root_dir_sha1 = split_path[0]
filename = split_path[-1]
path = path.replace(root_dir_sha1 + "/", "").replace(filename, "")
swhid_context = {
"anchor": gen_swhid(DIRECTORY, root_dir_sha1),
"path": f"/{path}{filename}",
}
swh_cnt_id = gen_swhid(CONTENT, content["sha1_git"], metadata=swhid_context)
swh_cnt_id_url = reverse("browse-swhid", url_args={"swhid": swh_cnt_id})
assert_contains(resp, swh_cnt_id)
assert_contains(resp, swh_cnt_id_url)
path_info = gen_path_info(path)
root_dir_url = reverse("browse-directory", url_args={"sha1_git": root_dir_sha1})
assert_contains(resp, '', count=len(path_info) + 1)
assert_contains(
resp, '' + root_dir_sha1[:7] + " "
)
for p in path_info:
dir_url = reverse(
"browse-directory",
url_args={"sha1_git": root_dir_sha1},
query_params={"path": p["path"]},
)
assert_contains(resp, '' + p["name"] + " ")
assert_contains(resp, " " + filename + " ")
url_raw = reverse(
"browse-content-raw",
url_args={"query_string": content["sha1"]},
query_params={"filename": filename},
)
assert_contains(resp, url_raw)
url = reverse(
"browse-content",
url_args={"query_string": content["sha1"]},
query_params={"path": filename},
)
resp = check_html_get_response(
client, url, status_code=200, template_used="browse/content.html"
)
assert_not_contains(resp, '{branch_info['name']}")
cnt_swhid = gen_swhid(
CONTENT,
directory_file["checksums"]["sha1_git"],
metadata={
"origin": origin["url"],
"visit": gen_swhid(SNAPSHOT, snapshot["id"]),
"anchor": gen_swhid(REVISION, branch_info["revision"]),
"path": f"/{directory_file['name']}",
},
)
assert_contains(resp, cnt_swhid)
dir_swhid = gen_swhid(
DIRECTORY,
directory,
metadata={
"origin": origin["url"],
"visit": gen_swhid(SNAPSHOT, snapshot["id"]),
"anchor": gen_swhid(REVISION, branch_info["revision"]),
},
)
assert_contains(resp, dir_swhid)
rev_swhid = gen_swhid(
REVISION,
branch_info["revision"],
metadata={
"origin": origin["url"],
"visit": gen_swhid(SNAPSHOT, snapshot["id"]),
},
)
assert_contains(resp, rev_swhid)
snp_swhid = gen_swhid(
SNAPSHOT, snapshot["id"], metadata={"origin": origin["url"],},
)
assert_contains(resp, snp_swhid)
@given(origin_with_multiple_visits())
def test_content_origin_snapshot_release_browse(client, archive_data, origin):
visits = archive_data.origin_visit_get(origin["url"])
visit = random.choice(visits)
snapshot = archive_data.snapshot_get(visit["snapshot"])
snapshot_sizes = archive_data.snapshot_count_branches(visit["snapshot"])
branches, releases, _ = process_snapshot_branches(snapshot)
release_info = random.choice(releases)
directory_content = archive_data.directory_ls(release_info["directory"])
directory_file = random.choice(
[e for e in directory_content if e["type"] == "file"]
)
url = reverse(
"browse-content",
url_args={"query_string": directory_file["checksums"]["sha1"]},
query_params={
"origin_url": origin["url"],
"snapshot": snapshot["id"],
"release": release_info["name"],
"path": directory_file["name"],
},
)
resp = check_html_get_response(
client, url, status_code=200, template_used="browse/content.html"
)
_check_origin_snapshot_related_html(
resp, origin, snapshot, snapshot_sizes, branches, releases
)
assert_contains(resp, directory_file["name"])
assert_contains(resp, f"Release: {release_info['name']} ")
cnt_swhid = gen_swhid(
CONTENT,
directory_file["checksums"]["sha1_git"],
metadata={
"origin": origin["url"],
"visit": gen_swhid(SNAPSHOT, snapshot["id"]),
"anchor": gen_swhid(RELEASE, release_info["id"]),
"path": f"/{directory_file['name']}",
},
)
assert_contains(resp, cnt_swhid)
dir_swhid = gen_swhid(
DIRECTORY,
release_info["directory"],
metadata={
"origin": origin["url"],
"visit": gen_swhid(SNAPSHOT, snapshot["id"]),
"anchor": gen_swhid(RELEASE, release_info["id"]),
},
)
assert_contains(resp, dir_swhid)
rev_swhid = gen_swhid(
REVISION,
release_info["target"],
metadata={
"origin": origin["url"],
"visit": gen_swhid(SNAPSHOT, snapshot["id"]),
},
)
assert_contains(resp, rev_swhid)
rel_swhid = gen_swhid(
RELEASE,
release_info["id"],
metadata={
"origin": origin["url"],
"visit": gen_swhid(SNAPSHOT, snapshot["id"]),
},
)
assert_contains(resp, rel_swhid)
snp_swhid = gen_swhid(
SNAPSHOT, snapshot["id"], metadata={"origin": origin["url"],},
)
assert_contains(resp, snp_swhid)
def _check_origin_snapshot_related_html(
resp, origin, snapshot, snapshot_sizes, branches, releases
):
browse_origin_url = reverse(
"browse-origin", query_params={"origin_url": origin["url"]}
)
assert_contains(resp, f'href="{browse_origin_url}"')
origin_branches_url = reverse(
"browse-origin-branches",
query_params={"origin_url": origin["url"], "snapshot": snapshot["id"]},
)
assert_contains(resp, f'href="{escape(origin_branches_url)}"')
assert_contains(resp, f"Branches ({snapshot_sizes['revision']})")
origin_releases_url = reverse(
"browse-origin-releases",
query_params={"origin_url": origin["url"], "snapshot": snapshot["id"]},
)
assert_contains(resp, f'href="{escape(origin_releases_url)}"')
assert_contains(resp, f"Releases ({snapshot_sizes['release']})")
assert_contains(resp, '', count=len(branches))
assert_contains(resp, ' ', count=len(releases))
def _process_content_for_display(archive_data, content):
content_data = archive_data.content_get_data(content["sha1"])
mime_type, encoding = get_mimetype_and_encoding_for_content(content_data["data"])
mime_type, encoding, content_data = _re_encode_content(
mime_type, encoding, content_data["data"]
)
content_display = prepare_content_for_display(
content_data, mime_type, content["path"]
)
assert type(content_display["content_data"]) == str
return content_display
diff --git a/swh/web/tests/data.py b/swh/web/tests/data.py
index 3c45ed02..d6253622 100644
--- a/swh/web/tests/data.py
+++ b/swh/web/tests/data.py
@@ -1,476 +1,488 @@
-# Copyright (C) 2018-2020 The Software Heritage developers
+# Copyright (C) 2018-2021 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
from copy import deepcopy
from datetime import timedelta
import os
+from pathlib import Path
import random
import time
from typing import Dict, List, Optional, Set
from swh.core.config import merge_configs
from swh.counters import get_counters
from swh.indexer.ctags import CtagsIndexer
from swh.indexer.fossology_license import FossologyLicenseIndexer
from swh.indexer.mimetype import MimetypeIndexer
from swh.indexer.storage import get_indexer_storage
from swh.indexer.storage.model import OriginIntrinsicMetadataRow
from swh.loader.git.from_disk import GitLoaderFromArchive
from swh.model.hashutil import DEFAULT_ALGORITHMS, hash_to_hex
from swh.model.model import (
Content,
Directory,
Origin,
OriginVisit,
OriginVisitStatus,
Snapshot,
)
from swh.search import get_search
from swh.storage import get_storage
from swh.storage.algos.dir_iterators import dir_iterator
from swh.storage.algos.snapshot import snapshot_get_latest
from swh.storage.interface import Sha1
from swh.storage.utils import now
from swh.web import config
from swh.web.browse.utils import (
_re_encode_content,
get_mimetype_and_encoding_for_content,
prepare_content_for_display,
)
from swh.web.common import archive
# Module used to initialize data that will be provided as tests input
# Base content indexer configuration
_TEST_INDEXER_BASE_CONFIG = {
"storage": {"cls": "memory"},
"objstorage": {"cls": "memory", "args": {},},
"indexer_storage": {"cls": "memory", "args": {},},
}
def random_sha1():
return hash_to_hex(bytes(random.randint(0, 255) for _ in range(20)))
def random_sha256():
return hash_to_hex(bytes(random.randint(0, 255) for _ in range(32)))
def random_blake2s256():
return hash_to_hex(bytes(random.randint(0, 255) for _ in range(32)))
def random_content():
return {
"sha1": random_sha1(),
"sha1_git": random_sha1(),
"sha256": random_sha256(),
"blake2s256": random_blake2s256(),
}
_TEST_MIMETYPE_INDEXER_CONFIG = merge_configs(
_TEST_INDEXER_BASE_CONFIG,
{
"tools": {
"name": "file",
"version": "1:5.30-1+deb9u1",
"configuration": {"type": "library", "debian-package": "python3-magic"},
}
},
)
_TEST_LICENSE_INDEXER_CONFIG = merge_configs(
_TEST_INDEXER_BASE_CONFIG,
{
"workdir": "/tmp/swh/indexer.fossology.license",
"tools": {
"name": "nomos",
"version": "3.1.0rc2-31-ga2cbb8c",
"configuration": {"command_line": "nomossa ",},
},
},
)
_TEST_CTAGS_INDEXER_CONFIG = merge_configs(
_TEST_INDEXER_BASE_CONFIG,
{
"workdir": "/tmp/swh/indexer.ctags",
"languages": {"c": "c"},
"tools": {
"name": "universal-ctags",
"version": "~git7859817b",
"configuration": {
"command_line": """ctags --fields=+lnz --sort=no --links=no """
"""--output-format=json """
},
},
},
)
# Lightweight git repositories that will be loaded to generate
# input data for tests
_TEST_ORIGINS = [
{
"type": "git",
"url": "https://github.com/memononen/libtess2",
"archives": ["libtess2.zip"],
"metadata": {
"@context": "https://doi.org/10.5063/schema/codemeta-2.0",
"description": (
"Game and tools oriented refactored version of GLU tessellator."
),
},
},
{
"type": "git",
"url": "https://github.com/wcoder/highlightjs-line-numbers.js",
"archives": [
"highlightjs-line-numbers.js.zip",
"highlightjs-line-numbers.js_visit2.zip",
],
"metadata": {
"@context": "https://doi.org/10.5063/schema/codemeta-2.0",
"description": "Line numbering plugin for Highlight.js",
},
},
{
"type": "git",
"url": "repo_with_submodules",
"archives": ["repo_with_submodules.tgz"],
"metadata": {
"@context": "https://doi.org/10.5063/schema/codemeta-2.0",
"description": "This is just a sample repository with submodules",
},
},
]
_contents = {}
def _add_extra_contents(storage, contents):
pbm_image_data = b"""P1
# PBM example
24 7
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 1 1 1 1 0 0 1 1 1 1 0 0 1 1 1 1 0 0 1 1 1 1 0
0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 1 0
0 1 1 1 0 0 0 1 1 1 0 0 0 1 1 1 0 0 0 1 1 1 1 0
0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0
0 1 0 0 0 0 0 1 1 1 1 0 0 1 1 1 1 0 0 1 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0"""
# add file with mimetype image/x-portable-bitmap in the archive content
pbm_content = Content.from_data(pbm_image_data)
storage.content_add([pbm_content])
contents.add(pbm_content.sha1)
+ # add file with mimetype application/pgp-keys in the archive content
+ gpg_path = os.path.join(
+ os.path.dirname(__file__), "resources/contents/other/extensions/public.gpg"
+ )
+ gpg_content = Content.from_data(Path(gpg_path).read_bytes())
+ storage.content_add([gpg_content])
+ contents.add(gpg_content.sha1)
+
INDEXER_TOOL = {
"tool_name": "swh-web tests",
"tool_version": "1.0",
"tool_configuration": {},
}
ORIGIN_METADATA_KEY = "keywords"
ORIGIN_METADATA_VALUE = "git"
ORIGIN_MASTER_REVISION = {}
def _add_origin(
storage, search, counters, origin_url, visit_type="git", snapshot_branches={}
):
storage.origin_add([Origin(url=origin_url)])
search.origin_update(
[{"url": origin_url, "has_visits": True, "visit_types": [visit_type]}]
)
counters.add("origin", [origin_url])
date = now()
visit = OriginVisit(origin=origin_url, date=date, type=visit_type)
visit = storage.origin_visit_add([visit])[0]
counters.add("origin_visit", [f"{visit.unique_key()}"])
snapshot = Snapshot.from_dict({"branches": snapshot_branches})
storage.snapshot_add([snapshot])
counters.add("snapshot", [snapshot.id])
visit_status = OriginVisitStatus(
origin=origin_url,
visit=visit.visit,
date=date + timedelta(minutes=1),
type=visit.type,
status="full",
snapshot=snapshot.id,
)
storage.origin_visit_status_add([visit_status])
counters.add("origin_visit_status", [f"{visit_status.unique_key()}"])
# Tests data initialization
def _init_tests_data():
# To hold reference to the memory storage
storage = get_storage("memory")
# Create search instance
search = get_search("memory")
search.initialize()
search.origin_update({"url": origin["url"]} for origin in _TEST_ORIGINS)
# create the counters instance
counters = get_counters("memory")
# Create indexer storage instance that will be shared by indexers
idx_storage = get_indexer_storage("memory")
# Declare a test tool for origin intrinsic metadata tests
idx_tool = idx_storage.indexer_configuration_add([INDEXER_TOOL])[0]
INDEXER_TOOL["id"] = idx_tool["id"]
# Load git repositories from archives
for origin in _TEST_ORIGINS:
for i, archive_ in enumerate(origin["archives"]):
if i > 0:
# ensure visit dates will be different when simulating
# multiple visits of an origin
time.sleep(1)
origin_repo_archive = os.path.join(
os.path.dirname(__file__), "resources/repos/%s" % archive_
)
loader = GitLoaderFromArchive(
storage, origin["url"], archive_path=origin_repo_archive,
)
result = loader.load()
assert result["status"] == "eventful"
ori = storage.origin_get([origin["url"]])[0]
origin.update(ori.to_dict()) # add an 'id' key if enabled
search.origin_update(
[{"url": origin["url"], "has_visits": True, "visit_types": ["git"]}]
)
for i in range(250):
_add_origin(
storage,
search,
counters,
origin_url=f"https://many.origins/{i+1}",
visit_type="tar",
)
sha1s: Set[Sha1] = set()
directories = set()
revisions = set()
releases = set()
snapshots = set()
content_path = {}
# Get all objects loaded into the test archive
common_metadata = {ORIGIN_METADATA_KEY: ORIGIN_METADATA_VALUE}
for origin in _TEST_ORIGINS:
snp = snapshot_get_latest(storage, origin["url"])
snapshots.add(hash_to_hex(snp.id))
for branch_name, branch_data in snp.branches.items():
target_type = branch_data.target_type.value
if target_type == "revision":
revisions.add(branch_data.target)
if b"master" in branch_name:
# Add some origin intrinsic metadata for tests
metadata = common_metadata
metadata.update(origin.get("metadata", {}))
origin_metadata = OriginIntrinsicMetadataRow(
id=origin["url"],
from_revision=branch_data.target,
indexer_configuration_id=idx_tool["id"],
metadata=metadata,
mappings=[],
)
idx_storage.origin_intrinsic_metadata_add([origin_metadata])
search.origin_update(
[{"url": origin["url"], "intrinsic_metadata": metadata}]
)
ORIGIN_MASTER_REVISION[origin["url"]] = hash_to_hex(
branch_data.target
)
elif target_type == "release":
release = storage.release_get([branch_data.target])[0]
revisions.add(release.target)
releases.add(hash_to_hex(branch_data.target))
for rev_log in storage.revision_shortlog(set(revisions)):
rev_id = rev_log[0]
revisions.add(rev_id)
for rev in storage.revision_get(revisions):
if rev is None:
continue
dir_id = rev.directory
directories.add(hash_to_hex(dir_id))
for entry in dir_iterator(storage, dir_id):
if entry["type"] == "file":
sha1s.add(entry["sha1"])
content_path[entry["sha1"]] = "/".join(
[hash_to_hex(dir_id), entry["path"].decode("utf-8")]
)
elif entry["type"] == "dir":
directories.add(hash_to_hex(entry["target"]))
_add_extra_contents(storage, sha1s)
# Get all checksums for each content
result: List[Optional[Content]] = storage.content_get(list(sha1s))
contents: List[Dict] = []
for content in result:
assert content is not None
sha1 = hash_to_hex(content.sha1)
content_metadata = {
algo: hash_to_hex(getattr(content, algo)) for algo in DEFAULT_ALGORITHMS
}
path = ""
if content.sha1 in content_path:
path = content_path[content.sha1]
cnt_data = storage.content_get_data(content.sha1)
assert cnt_data is not None
mimetype, encoding = get_mimetype_and_encoding_for_content(cnt_data)
_, _, cnt_data = _re_encode_content(mimetype, encoding, cnt_data)
+
content_display_data = prepare_content_for_display(cnt_data, mimetype, path)
content_metadata.update(
{
"path": path,
"mimetype": mimetype,
"encoding": encoding,
"hljs_language": content_display_data["language"],
+ "raw_data": cnt_data,
"data": content_display_data["content_data"],
}
)
+
_contents[sha1] = content_metadata
contents.append(content_metadata)
# Add the empty directory to the test archive
storage.directory_add([Directory(entries=())])
# Add empty content to the test archive
storage.content_add([Content.from_data(data=b"")])
# Add fake git origin with pull request branches
_add_origin(
storage,
search,
counters,
origin_url="https://git.example.org/project",
snapshot_branches={
b"refs/heads/master": {
"target_type": "revision",
"target": next(iter(revisions)),
},
**{
f"refs/pull/{i}".encode(): {
"target_type": "revision",
"target": next(iter(revisions)),
}
for i in range(300)
},
},
)
counters.add("revision", revisions)
counters.add("release", releases)
counters.add("directory", directories)
counters.add("content", [content["sha1"] for content in contents])
# Return tests data
return {
"search": search,
"storage": storage,
"idx_storage": idx_storage,
"counters": counters,
"origins": _TEST_ORIGINS,
"contents": contents,
"directories": list(directories),
"releases": list(releases),
"revisions": list(map(hash_to_hex, revisions)),
"snapshots": list(snapshots),
"generated_checksums": set(),
}
def _init_indexers(tests_data):
# Instantiate content indexers that will be used in tests
# and force them to use the memory storages
indexers = {}
for idx_name, idx_class, idx_config in (
("mimetype_indexer", MimetypeIndexer, _TEST_MIMETYPE_INDEXER_CONFIG),
("license_indexer", FossologyLicenseIndexer, _TEST_LICENSE_INDEXER_CONFIG),
("ctags_indexer", CtagsIndexer, _TEST_CTAGS_INDEXER_CONFIG),
):
idx = idx_class(config=idx_config)
idx.storage = tests_data["storage"]
idx.objstorage = tests_data["storage"].objstorage
idx.idx_storage = tests_data["idx_storage"]
idx.register_tools(idx.config["tools"])
indexers[idx_name] = idx
return indexers
def get_content(content_sha1):
return _contents.get(content_sha1)
_tests_data = None
_current_tests_data = None
_indexer_loggers = {}
def get_tests_data(reset=False):
"""
Initialize tests data and return them in a dict.
"""
global _tests_data, _current_tests_data
if _tests_data is None:
_tests_data = _init_tests_data()
indexers = _init_indexers(_tests_data)
for (name, idx) in indexers.items():
# pytest makes the loggers use a temporary file; and deepcopy
# requires serializability. So we remove them, and add them
# back after the copy.
_indexer_loggers[name] = idx.log
del idx.log
_tests_data.update(indexers)
if reset or _current_tests_data is None:
_current_tests_data = deepcopy(_tests_data)
for (name, logger) in _indexer_loggers.items():
_current_tests_data[name].log = logger
return _current_tests_data
def override_storages(storage, idx_storage, search, counters):
"""
Helper function to replace the storages from which archive data
are fetched.
"""
swh_config = config.get_config()
swh_config.update(
{
"storage": storage,
"indexer_storage": idx_storage,
"search": search,
"counters": counters,
}
)
archive.storage = storage
archive.idx_storage = idx_storage
archive.search = search
archive.counters = counters
diff --git a/swh/web/tests/resources/contents/other/extensions/public.gpg b/swh/web/tests/resources/contents/other/extensions/public.gpg
new file mode 100644
index 00000000..7214aea3
--- /dev/null
+++ b/swh/web/tests/resources/contents/other/extensions/public.gpg
@@ -0,0 +1,360 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: GnuPG v1
+
+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
+=K5VA
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/swh/web/tests/strategies.py b/swh/web/tests/strategies.py
index dff099fa..c33932f3 100644
--- a/swh/web/tests/strategies.py
+++ b/swh/web/tests/strategies.py
@@ -1,645 +1,658 @@
-# Copyright (C) 2018-2020 The Software Heritage developers
+# Copyright (C) 2018-2021 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
from collections import defaultdict
from datetime import datetime
import random
from hypothesis import assume, settings
from hypothesis.extra.dateutil import timezones
from hypothesis.strategies import (
binary,
characters,
composite,
datetimes,
just,
lists,
sampled_from,
text,
)
from swh.model.hashutil import DEFAULT_ALGORITHMS, hash_to_bytes, hash_to_hex
from swh.model.hypothesis_strategies import origins as new_origin_strategy
from swh.model.hypothesis_strategies import snapshots as new_snapshot
from swh.model.model import (
Content,
Directory,
Person,
Revision,
RevisionType,
TimestampWithTimezone,
)
from swh.storage.algos.revisions_walker import get_revisions_walker
from swh.storage.algos.snapshot import snapshot_get_latest
from swh.web.common.utils import browsers_supported_image_mimes
from swh.web.tests.data import get_tests_data
# Module dedicated to the generation of input data for tests through
# the use of hypothesis.
# Some of these data are sampled from a test archive created and populated
# in the swh.web.tests.data module.
# Set the swh-web hypothesis profile if none has been explicitly set
hypothesis_default_settings = settings.get_profile("default")
if repr(settings()) == repr(hypothesis_default_settings):
settings.load_profile("swh-web")
# The following strategies exploit the hypothesis capabilities
def _filter_checksum(cs):
generated_checksums = get_tests_data()["generated_checksums"]
if not int.from_bytes(cs, byteorder="little") or cs in generated_checksums:
return False
generated_checksums.add(cs)
return True
def _known_swh_object(object_type):
return sampled_from(get_tests_data()[object_type])
def sha1():
"""
Hypothesis strategy returning a valid hexadecimal sha1 value.
"""
return binary(min_size=20, max_size=20).filter(_filter_checksum).map(hash_to_hex)
def invalid_sha1():
"""
Hypothesis strategy returning an invalid sha1 representation.
"""
return binary(min_size=50, max_size=50).filter(_filter_checksum).map(hash_to_hex)
def sha256():
"""
Hypothesis strategy returning a valid hexadecimal sha256 value.
"""
return binary(min_size=32, max_size=32).filter(_filter_checksum).map(hash_to_hex)
def content():
"""
Hypothesis strategy returning a random content ingested
into the test archive.
"""
return _known_swh_object("contents")
def contents():
"""
Hypothesis strategy returning random contents ingested
into the test archive.
"""
return lists(content(), min_size=2, max_size=8)
def empty_content():
"""
Hypothesis strategy returning the empty content ingested
into the test archive.
"""
empty_content = Content.from_data(data=b"").to_dict()
for algo in DEFAULT_ALGORITHMS:
empty_content[algo] = hash_to_hex(empty_content[algo])
return just(empty_content)
def content_text():
"""
Hypothesis strategy returning random textual contents ingested
into the test archive.
"""
return content().filter(lambda c: c["mimetype"].startswith("text/"))
def content_text_non_utf8():
"""
Hypothesis strategy returning random textual contents not encoded
to UTF-8 ingested into the test archive.
"""
return content().filter(
lambda c: c["mimetype"].startswith("text/")
and c["encoding"] not in ("utf-8", "us-ascii")
)
+def content_application_no_highlight():
+ """
+ Hypothesis strategy returning random textual contents with mimetype
+ starting with application/ and no detected programming language to
+ highlight ingested into the test archive.
+ """
+ return content().filter(
+ lambda c: c["mimetype"].startswith("application/")
+ and c["encoding"] != "binary"
+ and c["hljs_language"] == "nohighlight"
+ )
+
+
def content_text_no_highlight():
"""
Hypothesis strategy returning random textual contents with no detected
programming language to highlight ingested into the test archive.
"""
return content().filter(
lambda c: c["mimetype"].startswith("text/")
and c["hljs_language"] == "nohighlight"
)
def content_image_type():
"""
Hypothesis strategy returning random image contents ingested
into the test archive.
"""
return content().filter(lambda c: c["mimetype"] in browsers_supported_image_mimes)
def content_unsupported_image_type_rendering():
"""
Hypothesis strategy returning random image contents ingested
into the test archive that can not be rendered by browsers.
"""
return content().filter(
lambda c: c["mimetype"].startswith("image/")
and c["mimetype"] not in browsers_supported_image_mimes
)
def content_utf8_detected_as_binary():
"""
Hypothesis strategy returning random textual contents detected as binary
by libmagic while they are valid UTF-8 encoded files.
"""
def utf8_binary_detected(content):
if content["encoding"] != "binary":
return False
try:
- content["data"].decode("utf-8")
+ content["raw_data"].decode("utf-8")
except Exception:
return False
else:
return True
return content().filter(utf8_binary_detected)
@composite
def new_content(draw):
blake2s256_hex = draw(sha256())
sha1_hex = draw(sha1())
sha1_git_hex = draw(sha1())
sha256_hex = draw(sha256())
assume(sha1_hex != sha1_git_hex)
assume(blake2s256_hex != sha256_hex)
return {
"blake2S256": blake2s256_hex,
"sha1": sha1_hex,
"sha1_git": sha1_git_hex,
"sha256": sha256_hex,
}
def unknown_content():
"""
Hypothesis strategy returning a random content not ingested
into the test archive.
"""
return new_content().filter(
lambda c: get_tests_data()["storage"].content_get_data(hash_to_bytes(c["sha1"]))
is None
)
def unknown_contents():
"""
Hypothesis strategy returning random contents not ingested
into the test archive.
"""
return lists(unknown_content(), min_size=2, max_size=8)
def directory():
"""
Hypothesis strategy returning a random directory ingested
into the test archive.
"""
return _known_swh_object("directories")
def _directory_with_entry_type(type_):
return directory().filter(
lambda d: any(
[
e["type"] == type_
for e in list(
get_tests_data()["storage"].directory_ls(hash_to_bytes(d))
)
]
)
)
def directory_with_subdirs():
"""
Hypothesis strategy returning a random directory containing
sub directories ingested into the test archive.
"""
return _directory_with_entry_type("dir")
def directory_with_files():
"""
Hypothesis strategy returning a random directory containing
at least one regular file
"""
return _directory_with_entry_type("file")
def empty_directory():
"""
Hypothesis strategy returning the empty directory ingested
into the test archive.
"""
return just(Directory(entries=()).id.hex())
def unknown_directory():
"""
Hypothesis strategy returning a random directory not ingested
into the test archive.
"""
return sha1().filter(
lambda s: len(
list(get_tests_data()["storage"].directory_missing([hash_to_bytes(s)]))
)
> 0
)
def origin():
"""
Hypothesis strategy returning a random origin ingested
into the test archive.
"""
return _known_swh_object("origins")
def origin_with_multiple_visits():
"""
Hypothesis strategy returning a random origin ingested
into the test archive.
"""
ret = []
tests_data = get_tests_data()
storage = tests_data["storage"]
for origin in tests_data["origins"]:
visit_page = storage.origin_visit_get(origin["url"])
if len(visit_page.results) > 1:
ret.append(origin)
return sampled_from(ret)
def origin_with_releases():
"""
Hypothesis strategy returning a random origin ingested
into the test archive.
"""
ret = []
tests_data = get_tests_data()
for origin in tests_data["origins"]:
snapshot = snapshot_get_latest(tests_data["storage"], origin["url"])
if any([b.target_type.value == "release" for b in snapshot.branches.values()]):
ret.append(origin)
return sampled_from(ret)
def origin_with_pull_request_branches():
"""
Hypothesis strategy returning a random origin with pull request branches
ingested into the test archive.
"""
ret = []
tests_data = get_tests_data()
storage = tests_data["storage"]
origins = storage.origin_list(limit=1000)
for origin in origins.results:
snapshot = snapshot_get_latest(storage, origin.url)
if any([b"refs/pull/" in b for b in snapshot.branches]):
ret.append(origin)
return sampled_from(ret)
def new_origin():
"""
Hypothesis strategy returning a random origin not ingested
into the test archive.
"""
return new_origin_strategy().filter(
lambda origin: get_tests_data()["storage"].origin_get([origin.url])[0] is None
)
def new_origins(nb_origins=None):
"""
Hypothesis strategy returning random origins not ingested
into the test archive.
"""
min_size = nb_origins if nb_origins is not None else 2
max_size = nb_origins if nb_origins is not None else 8
size = random.randint(min_size, max_size)
return lists(
new_origin(),
min_size=size,
max_size=size,
unique_by=lambda o: tuple(sorted(o.items())),
)
def visit_dates(nb_dates=None):
"""
Hypothesis strategy returning a list of visit dates.
"""
min_size = nb_dates if nb_dates else 2
max_size = nb_dates if nb_dates else 8
return lists(
datetimes(
min_value=datetime(2015, 1, 1, 0, 0),
max_value=datetime(2018, 12, 31, 0, 0),
timezones=timezones(),
),
min_size=min_size,
max_size=max_size,
unique=True,
).map(sorted)
def release():
"""
Hypothesis strategy returning a random release ingested
into the test archive.
"""
return _known_swh_object("releases")
def releases(min_size=2, max_size=8):
"""
Hypothesis strategy returning random releases ingested
into the test archive.
"""
return lists(release(), min_size=min_size, max_size=max_size)
def unknown_release():
"""
Hypothesis strategy returning a random revision not ingested
into the test archive.
"""
return sha1().filter(
lambda s: get_tests_data()["storage"].release_get([s])[0] is None
)
def revision():
"""
Hypothesis strategy returning a random revision ingested
into the test archive.
"""
return _known_swh_object("revisions")
def unknown_revision():
"""
Hypothesis strategy returning a random revision not ingested
into the test archive.
"""
return sha1().filter(
lambda s: get_tests_data()["storage"].revision_get([hash_to_bytes(s)])[0]
is None
)
@composite
def new_person(draw):
"""
Hypothesis strategy returning random raw swh person data.
"""
name = draw(
text(
min_size=5,
max_size=30,
alphabet=characters(min_codepoint=0, max_codepoint=255),
)
)
email = "%s@company.org" % name
return Person(
name=name.encode(),
email=email.encode(),
fullname=("%s <%s>" % (name, email)).encode(),
)
@composite
def new_swh_date(draw):
"""
Hypothesis strategy returning random raw swh date data.
"""
timestamp = draw(
datetimes(
min_value=datetime(2015, 1, 1, 0, 0), max_value=datetime(2018, 12, 31, 0, 0)
).map(lambda d: int(d.timestamp()))
)
return {
"timestamp": timestamp,
"offset": 0,
"negative_utc": False,
}
@composite
def new_revision(draw):
"""
Hypothesis strategy returning random raw swh revision data
not ingested into the test archive.
"""
return Revision(
directory=draw(sha1().map(hash_to_bytes)),
author=draw(new_person()),
committer=draw(new_person()),
message=draw(text(min_size=20, max_size=100).map(lambda t: t.encode())),
date=TimestampWithTimezone.from_datetime(draw(new_swh_date())),
committer_date=TimestampWithTimezone.from_datetime(draw(new_swh_date())),
synthetic=False,
type=RevisionType.GIT,
)
def revisions(min_size=2, max_size=8):
"""
Hypothesis strategy returning random revisions ingested
into the test archive.
"""
return lists(revision(), min_size=min_size, max_size=max_size)
def unknown_revisions(min_size=2, max_size=8):
"""
Hypothesis strategy returning random revisions not ingested
into the test archive.
"""
return lists(unknown_revision(), min_size=min_size, max_size=max_size)
def snapshot():
"""
Hypothesis strategy returning a random snapshot ingested
into the test archive.
"""
return _known_swh_object("snapshots")
def new_snapshots(nb_snapshots=None):
min_size = nb_snapshots if nb_snapshots else 2
max_size = nb_snapshots if nb_snapshots else 8
return lists(
new_snapshot(min_size=2, max_size=10, only_objects=True),
min_size=min_size,
max_size=max_size,
)
def unknown_snapshot():
"""
Hypothesis strategy returning a random revision not ingested
into the test archive.
"""
return sha1().filter(
lambda s: get_tests_data()["storage"].snapshot_get_branches(hash_to_bytes(s))
is None
)
def _get_origin_dfs_revisions_walker():
tests_data = get_tests_data()
storage = tests_data["storage"]
origin = random.choice(tests_data["origins"][:-1])
snapshot = snapshot_get_latest(storage, origin["url"])
if snapshot.branches[b"HEAD"].target_type.value == "alias":
target = snapshot.branches[b"HEAD"].target
head = snapshot.branches[target].target
else:
head = snapshot.branches[b"HEAD"].target
return get_revisions_walker("dfs", storage, head)
def ancestor_revisions():
"""
Hypothesis strategy returning a pair of revisions ingested into the
test archive with an ancestor relation.
"""
# get a dfs revisions walker for one of the origins
# loaded into the test archive
revisions_walker = _get_origin_dfs_revisions_walker()
master_revisions = []
children = defaultdict(list)
init_rev_found = False
# get revisions only authored in the master branch
for rev in revisions_walker:
for rev_p in rev["parents"]:
children[rev_p].append(rev["id"])
if not init_rev_found:
master_revisions.append(rev)
if not rev["parents"]:
init_rev_found = True
# head revision
root_rev = master_revisions[0]
# pick a random revision, different from head, only authored
# in the master branch
ancestor_rev_idx = random.choice(list(range(1, len(master_revisions) - 1)))
ancestor_rev = master_revisions[ancestor_rev_idx]
ancestor_child_revs = children[ancestor_rev["id"]]
return just(
{
"sha1_git_root": hash_to_hex(root_rev["id"]),
"sha1_git": hash_to_hex(ancestor_rev["id"]),
"children": [hash_to_hex(r) for r in ancestor_child_revs],
}
)
def non_ancestor_revisions():
"""
Hypothesis strategy returning a pair of revisions ingested into the
test archive with no ancestor relation.
"""
# get a dfs revisions walker for one of the origins
# loaded into the test archive
revisions_walker = _get_origin_dfs_revisions_walker()
merge_revs = []
children = defaultdict(list)
# get all merge revisions
for rev in revisions_walker:
if len(rev["parents"]) > 1:
merge_revs.append(rev)
for rev_p in rev["parents"]:
children[rev_p].append(rev["id"])
# find a merge revisions whose parents have a unique child revision
random.shuffle(merge_revs)
selected_revs = None
for merge_rev in merge_revs:
if all(len(children[rev_p]) == 1 for rev_p in merge_rev["parents"]):
selected_revs = merge_rev["parents"]
return just(
{
"sha1_git_root": hash_to_hex(selected_revs[0]),
"sha1_git": hash_to_hex(selected_revs[1]),
}
)
# The following strategies returns data specific to some tests
# that can not be generated and thus are hardcoded.
def contents_with_ctags():
"""
Hypothesis strategy returning contents ingested into the test
archive. Those contents are ctags compatible, that is running
ctags on those lay results.
"""
return just(
{
"sha1s": [
"0ab37c02043ebff946c1937523f60aadd0844351",
"15554cf7608dde6bfefac7e3d525596343a85b6f",
"2ce837f1489bdfb8faf3ebcc7e72421b5bea83bd",
"30acd0b47fc25e159e27a980102ddb1c4bea0b95",
"4f81f05aaea3efb981f9d90144f746d6b682285b",
"5153aa4b6e4455a62525bc4de38ed0ff6e7dd682",
"59d08bafa6a749110dfb65ba43a61963d5a5bf9f",
"7568285b2d7f31ae483ae71617bd3db873deaa2c",
"7ed3ee8e94ac52ba983dd7690bdc9ab7618247b4",
"8ed7ef2e7ff9ed845e10259d08e4145f1b3b5b03",
"9b3557f1ab4111c8607a4f2ea3c1e53c6992916c",
"9c20da07ed14dc4fcd3ca2b055af99b2598d8bdd",
"c20ceebd6ec6f7a19b5c3aebc512a12fbdc9234b",
"e89e55a12def4cd54d5bff58378a3b5119878eb7",
"e8c0654fe2d75ecd7e0b01bee8a8fc60a130097e",
"eb6595e559a1d34a2b41e8d4835e0e4f98a5d2b5",
],
"symbol_name": "ABS",
}
)
def revision_with_submodules():
"""
Hypothesis strategy returning a revision that is known to
point to a directory with revision entries (aka git submodule)
"""
return just(
{
"rev_sha1_git": "ffcb69001f3f6745dfd5b48f72ab6addb560e234",
"rev_dir_sha1_git": "d92a21446387fa28410e5a74379c934298f39ae2",
"rev_dir_rev_path": "libtess2",
}
)