Skip to content

Commit

Permalink
Add 'devpiserver_on_toxresult_store' and 'devpiserver_on_toxresult_up…
Browse files Browse the repository at this point in the history
…load_forbidden' hooks.
  • Loading branch information
fschulze committed May 15, 2024
1 parent 408883a commit 240ac2a
Show file tree
Hide file tree
Showing 8 changed files with 140 additions and 10 deletions.
2 changes: 1 addition & 1 deletion server/devpi_server/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '6.11.0'
__version__ = '6.12.0.dev0'
32 changes: 31 additions & 1 deletion server/devpi_server/hookspecs.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@

from __future__ import annotations
from pluggy import HookspecMarker
from typing import Optional
from typing import TYPE_CHECKING


if TYPE_CHECKING:
from devpi_server.views import ToxResultHandling
from pyramid.request import Request


hookspec = HookspecMarker("devpiserver")

Expand Down Expand Up @@ -255,6 +263,28 @@ def devpiserver_on_replicated_file(stage, project, version, link, serial, back_s
"""Called when a file was downloaded from master on replica."""


@hookspec(firstresult=True)
def devpiserver_on_toxresult_store(request: Request, tox_result_handling: ToxResultHandling) -> Optional[ToxResultHandling]:
"""Called when a toxresult is about to be stored.
Stops at first non-None result.
:returns: A ToxResultHandling object which determines how the upload is processed.
"""


@hookspec(firstresult=True)
def devpiserver_on_toxresult_upload_forbidden(request: Request, tox_result_handling: ToxResultHandling) -> Optional[str]:
"""Called when the permission check for toxresult upload failed.
Stops at first non-None result.
:returns: A ToxResultHandling object which determines whether an error
with message (default using ``block``) or a success with a message
(using ``skip``) is returned.
"""


@hookspec
def devpiserver_metrics(request):
""" called for status view.
Expand Down
52 changes: 46 additions & 6 deletions server/devpi_server/views.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from __future__ import annotations
import contextlib
import os
import re
Expand Down Expand Up @@ -50,6 +51,7 @@
from .log import thread_push_log, thread_pop_log, threadlog

from .auth import Auth
import attrs

devpiweb_hookimpl = HookimplMarker("devpiweb")
server_version = devpi_server.__version__
Expand Down Expand Up @@ -412,6 +414,34 @@ def devpiserver_authcheck_forbidden(request):
return True


TOXRESULT_UPLOAD_FORBIDDEN = (
"No permission to upload tox results. "
"You can use the devpi test --no-upload option to skip the upload.")


@attrs.define(frozen=True)
class ToxResultHandling:
_block: bool = attrs.field(default=False, alias="_block")
_skip: bool = attrs.field(default=False, alias="_skip")
msg: None | str = None

def block(self, msg=TOXRESULT_UPLOAD_FORBIDDEN):
return ToxResultHandling(_block=True, msg=msg)

def skip(self, msg=None):
return ToxResultHandling(_skip=True, msg=msg)


@hookimpl(trylast=True)
def devpiserver_on_toxresult_store(request, tox_result_handling):
return tox_result_handling


@hookimpl(trylast=True)
def devpiserver_on_toxresult_upload_forbidden(request, tox_result_handling):
return tox_result_handling


def version_in_filename(version, filename):
if version is None:
# no version set, so skip check
Expand Down Expand Up @@ -533,12 +563,22 @@ def authcheck_view(self):
request_method="POST")
def post_toxresult(self):
if not self.request.has_permission("toxresult_upload"):
apireturn(403, "no permission to upload tox results")
stage = self.context.stage
relpath = self.request.path_info.strip("/")
link = stage.get_link_from_entrypath(relpath)
if link is None or link.rel != "releasefile":
apireturn(404, message="no release file found at %s" % relpath)
default_tox_result_handling = ToxResultHandling().block(TOXRESULT_UPLOAD_FORBIDDEN)
tox_result_handling = self.xom.config.hook.devpiserver_on_toxresult_upload_forbidden(
request=self.request, tox_result_handling=default_tox_result_handling)
else:
stage = self.context.stage
relpath = self.request.path_info.strip("/")
link = stage.get_link_from_entrypath(relpath)
if link is None or link.rel != "releasefile":
apireturn(404, message="no release file found at %s" % relpath)
default_tox_result_handling = ToxResultHandling()
tox_result_handling = self.xom.config.hook.devpiserver_on_toxresult_store(
request=self.request, tox_result_handling=default_tox_result_handling)
if tox_result_handling._block:
apireturn(403, tox_result_handling.msg)
if tox_result_handling._skip:
apireturn(200, tox_result_handling.msg)
# the getjson call validates that we got valid json
getjson(self.request)
# but we store the original body
Expand Down
1 change: 1 addition & 0 deletions server/news/toxresultblockskip.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added ``devpiserver_on_toxresult_store`` hook to allow blocking or skipping a toxresult upload on more specific conditions as ``acl_toxresult_upload`` would allow.
1 change: 1 addition & 0 deletions server/news/toxresultforbidden.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added ``devpiserver_on_toxresult_upload_forbidden`` hook to allow returning a custom message and result (403 or 200).
2 changes: 1 addition & 1 deletion server/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def get_changelog():
'Documentation': 'https://doc.devpi.net',
'Source Code': 'https://github.com/devpi/devpi'
},
version='6.11.0',
version='6.12.0.dev0',
maintainer="Florian Schulze",
maintainer_email="mail@pyfidelity.com",
packages=[
Expand Down
2 changes: 1 addition & 1 deletion server/test_devpi_server/functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ def test_toxresult_forbidden(self, mapp, server_version):
if callable(data):
# in devpi-client .json is a method
data = data()
assert data['message'] == 'no permission to upload tox results'
assert 'no permission to upload tox results' in data['message'].lower()
info = mapp.getjson(f"/{mapp.api.stagename}/hello")
(href,) = (x['href'] for x in info["result"]["1.0"]["+links"])
assert 'toxresult' not in href
Expand Down
58 changes: 58 additions & 0 deletions server/test_devpi_server/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2178,6 +2178,64 @@ def test_delete_removed_toxresult(mapp, testapp, tox_result_data):
testapp.delete(toxlink2.href, status=410)


def test_plugin_toxresult_upload_block(makemapp, maketestapp, makexom, tox_result_data):
class Plugin:
@hookimpl
def devpiserver_on_toxresult_store(self, tox_result_handling):
return tox_result_handling.block("custom block")
xom = makexom(plugins=[Plugin()])
testapp = maketestapp(xom)
mapp = makemapp(testapp)
api = mapp.create_and_use()
mapp.upload_file_pypi("pkg6-2.6.tgz", b"123", "pkg6", "2.6")
vv = get_view_version_links(testapp, api.index, "pkg6", "2.6")
(link1,) = vv.get_links()
r = mapp.upload_toxresult(link1.href, json.dumps(tox_result_data), code=403)
assert r.json['message'] == 'custom block'
# make sure the upload was blocked
(link2,) = vv.get_links()
assert link1.href == link2.href


def test_plugin_toxresult_upload_forbidden_skip(makemapp, maketestapp, makexom, tox_result_data):
class Plugin:
@hookimpl
def devpiserver_on_toxresult_upload_forbidden(self, tox_result_handling):
return tox_result_handling.skip("custom message")
xom = makexom(plugins=[Plugin()])
testapp = maketestapp(xom)
mapp = makemapp(testapp)
api = mapp.create_and_use(indexconfig=dict(acl_toxresult_upload=''))
mapp.upload_file_pypi("pkg6-2.6.tgz", b"123", "pkg6", "2.6")
vv = get_view_version_links(testapp, api.index, "pkg6", "2.6")
(link1,) = vv.get_links()
r = mapp.upload_toxresult(link1.href, json.dumps(tox_result_data), code=200)
assert r.json['message'] == 'custom message'
# make sure the upload was blocked
(link2,) = vv.get_links()
assert link1.href == link2.href


def test_plugin_toxresult_upload_skip(makemapp, maketestapp, makexom, tox_result_data):
class Plugin:
@hookimpl
def devpiserver_on_toxresult_store(self, tox_result_handling):
return tox_result_handling.skip("custom skip")
xom = makexom(plugins=[Plugin()])
testapp = maketestapp(xom)
mapp = makemapp(testapp)
api = mapp.create_and_use()
mapp.upload_file_pypi("pkg6-2.6.tgz", b"123", "pkg6", "2.6")
vv = get_view_version_links(testapp, api.index, "pkg6", "2.6")
(link1,) = vv.get_links()
r = mapp.upload_toxresult(link1.href, json.dumps(tox_result_data), code=200)
assert r.json['message'] == 'custom skip'
vv = get_view_version_links(testapp, api.index, "pkg6", "2.6")
# make sure the upload was skipped
(link2,) = vv.get_links()
assert link1.href == link2.href


@proj
def test_upload_docs_no_version(mapp, testapp, proj):
api = mapp.create_and_use()
Expand Down

0 comments on commit 240ac2a

Please sign in to comment.