Skip to content

Commit

Permalink
Implement review suggestion to support request method restrictions
Browse files Browse the repository at this point in the history
  • Loading branch information
d-maurer committed Feb 20, 2024
1 parent faba0d6 commit fa44bc3
Show file tree
Hide file tree
Showing 6 changed files with 198 additions and 59 deletions.
5 changes: 5 additions & 0 deletions docs/zdgbook/ObjectPublishing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ the future.
If you decorate a method or class with ``zpublsh(False)``,
you explicitly mark it or its instances, respectively, as not
zpublishable.
If you decorate a method with ``zpublish(methods=...)``
where the `...` is either a single request method name
or a sequence of request method names,
you specify that the object is zpublishable only for the mentioned request
methods.

Another requirement is that a publishable object must not have a name
that begins with an underscore. These two restrictions are designed to
Expand Down
8 changes: 5 additions & 3 deletions src/App/ProductContext.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
from OFS.ObjectManager import ObjectManager
from zope.interface import implementedBy
from ZPublisher import zpublish
from ZPublisher import zpublish_marked
from ZPublisher import zpublish_wrap


if not hasattr(Products, 'meta_types'):
Expand Down Expand Up @@ -100,7 +102,7 @@ class will be registered.
productObject = self.__prod
pid = productObject.id

if instance_class is not None:
if instance_class is not None and not zpublish_marked(instance_class):
zpublish(instance_class)

if permissions:
Expand Down Expand Up @@ -140,7 +142,7 @@ class will be registered.
name = method.__name__
aliased = 0
if name not in OM.__dict__:
method = zpublish(True, method)
method = zpublish_wrap(method)
setattr(OM, name, method)
setattr(OM, name + '__roles__', pr)
if aliased:
Expand Down Expand Up @@ -201,7 +203,7 @@ class __FactoryDispatcher__(FactoryDispatcher):
else:
name = os.path.split(method.__name__)[-1]
if name not in productObject.__dict__:
m[name] = zpublish(True, method)
m[name] = zpublish_wrap(method)
m[name + '__roles__'] = pr

def getApplication(self):
Expand Down
73 changes: 48 additions & 25 deletions src/ZPublisher/BaseRequest.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def publishTraverse(self, request, name):
except TypeError: # unsubscriptable
raise KeyError(name)

ensure_publishable(subobject, URL)
self.request.ensure_publishable(subobject)
return subobject

def browserDefault(self, request):
Expand Down Expand Up @@ -490,6 +490,7 @@ def traverse(self, path, response=None, validated_hook=None):
self.roles = getRoles(
object, '__call__',
object.__call__, self.roles)
self.ensure_publishable(object.__call__, True)
if request._hacked_path:
i = URL.rfind('/')
if i > 0:
Expand Down Expand Up @@ -673,6 +674,52 @@ def _hold(self, object):
if self._held is not None:
self._held = self._held + (object, )

def ensure_publishable(self, obj, for_call=False):
"""raise ``Forbidden`` unless *obj* is publishable.
*for_call* tells us whether we are called for the ``__call__``
method. In general, its publishablity is determined by
its ``__self__`` but it might have more restrictive prescriptions.
"""
url, default = self["URL"], None
if for_call:
url += ".__call__"
default = True
publishable = getattr(obj, _ZPUBLISH_ATTR, default)
# ``publishable`` is either ``None``, ``True``, ``False`` or
# a tuple of allowed request methods.
if publishable is True: # explicitely marked as publishable
return
elif publishable is False: # explicitely marked as not publishable
raise Forbidden(
f"The object at {url} is marked as not publishable")
elif publishable is not None:
# a tuple of allowed request methods
request_method = (getattr(self, "environ", None)
and self.environ.get("REQUEST_METHOD"))
if (request_method is None # noqa: E271
or request_method.upper() not in publishable):
raise Forbidden(
f"The object at {url} does not support "
f"{request_method} requests")
return
# ``publishable`` is ``None``

# Check that built-in types aren't publishable.
if not typeCheck(obj):
raise Forbidden(
"The object at %s is not publishable." % url)
# Ensure that the object has a docstring
doc = getattr(obj, '__doc__', None)
if not doc:
raise Forbidden(
f"The object at {url} has an empty or missing "
"docstring. Objects must either be marked via "
"to `ZPublisher.zpublish` decorator or have a docstring to be "
"published.")
if deprecate_docstrings:
warn(DocstringWarning(url))


def exec_callables(callables):
result = None
Expand Down Expand Up @@ -769,30 +816,6 @@ def typeCheck(obj, deny=itypes):
deprecate_docstrings = environ.get("ZPUBLISHER_DEPRECATE_DOCSTRINGS")


def ensure_publishable(obj, url):
"""raise ``Forbidden`` unless *obj* at *url* is publishable."""
publishable = getattr(obj, _ZPUBLISH_ATTR, None)
if publishable: # explicitely marked as publishable
return
if publishable is not None: # explicitely marked as not publishable
raise Forbidden(
f"The object at {url} is marked as not publishable")
# Check that built-in types aren't publishable.
if not typeCheck(obj):
raise Forbidden(
"The object at %s is not publishable." % url)
# Ensure that the object has a docstring
doc = getattr(obj, '__doc__', None)
if not doc:
raise Forbidden(
f"The object at {url} has an empty or missing "
"docstring. Objects must either be marked via "
"to `ZPublisher.zpublish` decorator or have a docstring to be "
"published.")
if deprecate_docstrings:
warn(DocstringWarning(url))


class DocstringWarning(DeprecationWarning):
def __str__(self):
return (f"The object at {self.args[0]} uses deprecated docstring "
Expand Down
64 changes: 49 additions & 15 deletions src/ZPublisher/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def reraise(self):
_ZPUBLISH_ATTR = "__zpublishable__"


def zpublish(publish=True, callable=None):
def zpublish(publish=True, *, methods=None):
"""decorator signaling design for/not for publication.
Usage:
Expand All @@ -54,32 +54,66 @@ def f(...): ...
def f(...): ...
``ZPublisher`` should not publish ``f``
@zpublish(methods="METHOD")
def f(...):
``ZPublisher`` should publish ``f`` for request method *METHOD*
zpublish(methods=("M1", "M2", ...))
def f(...):
``ZPublisher`` should publish ``f`` for all
request methods mentioned in *methods*.
@zpublish...
class C: ...
instances of ``C`` can/can not be published by ``ZPublisher``.
zpublish(publish=..., callable=obj)
returns a wrapper for callable *obj* with the same signature;
*publish* determines its publishability.
``Zpublish(f)`` is equivalent to ``zpublish(True)(f)`` if
``zpublish(f)`` is equivalent to ``zpublish(True)(f)`` if
``f`` is not a boolean.
"""
if callable is not None:
assert isinstance(publish, bool), "publish must be a boolean"

@zpublish(publish)
@wraps(callable)
def wrapper(*args, **kw):
return callable(*args, **kw)
wrapper.__signature__ = signature(callable, follow_wrapped=False)
return wrapper

if not isinstance(publish, bool):
return zpublish(True)(publish)

if methods is not None:
assert publish
publish = ((methods.upper(),) if isinstance(methods, str)
else tuple(m.upper() for m in methods) if methods
else False)

def wrap(f):
# *publish* is either ``True``, ``False`` or a tuple
# of allowed request methods
setattr(f, _ZPUBLISH_ATTR, publish)
return f

return wrap


def zpublish_mark(obj):
"""the ``zpublis`` indication at *obj*."""
return getattr(obj, _ZPUBLISH_ATTR, None)


def zpublish_marked(obj):
"""true if *obj* carries a publication indication."""
return zpublish_mark(obj) is not None


def zpublish_wrap(callable):
"""wrap *callable* to provide a publication indication.
Return *callable* unchanged if it already carries a
publication indication;
otherwise, return a signature preserving wrapper
allowing publication.
"""
if zpublish_marked(callable):
return callable

@zpublish
@wraps(callable)
def wrapper(*args, **kw):
return callable(*args, **kw)
wrapper.__signature__ = signature(callable)
return wrapper
33 changes: 17 additions & 16 deletions src/ZPublisher/tests/testBaseRequest.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import unittest

from zExceptions import Forbidden
from zExceptions import NotFound
from zope.interface import implementer
from zope.publisher.interfaces import IPublishTraverse
from zope.publisher.interfaces import NotFound as ztkNotFound
from ZPublisher import _ZPUBLISH_ATTR
from ZPublisher import zpublish


Expand Down Expand Up @@ -468,24 +468,25 @@ def test_docstring_deprecation(self):
finally:
BaseRequest.deprecate_docstrings = deprecate

def test_zpublish_false(self):
def test_zpublish___call__(self):
root, folder = self._makeRootAndFolder(False)
r = self._makeOne(root)
# Note: ``zpublish`` should not get applied to a persistent object
zpublish(False)(folder)
self.assertRaises(NotFound, r.traverse, 'folder')

def test_zpublish_callable(self):
from inspect import signature

def f(x, a=1):
return x, a
@zpublish(methods="POST")
def __call__(self):
pass

w = zpublish(True, f)
self.assertIs(getattr(w, _ZPUBLISH_ATTR), True)
self.assertEqual(signature(w), signature(f))
self.assertEqual(w(0), (0, 1))
self.assertFalse(hasattr(f, "__zpublishable__"))
folder.__class__.__call__ = __call__
# no request method
r = self._makeOne(root)
self.assertRaises(Forbidden, r.traverse, 'folder')
# wrong request method
r = self._makeOne(root)
r.environ = dict(REQUEST_METHOD="get")
self.assertRaises(Forbidden, r.traverse, 'folder')
# correct request method
r = self._makeOne(root)
r.environ = dict(REQUEST_METHOD="post")
r.traverse('folder')

def test_traverse_simple_string(self):
root, folder = self._makeRootAndFolder()
Expand Down
74 changes: 74 additions & 0 deletions src/ZPublisher/tests/test_zpublish.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
##############################################################################
#
# Copyright (c) 2024 Zope Foundation and Contributors.
#
# This software is subject to the provisions of the Zope Public License,
# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution.
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
# FOR A PARTICULAR PURPOSE.
#
##############################################################################
"""``zpublish`` related tests."""

from inspect import signature
from unittest import TestCase

from .. import zpublish
from .. import zpublish_mark
from .. import zpublish_marked
from .. import zpublish_wrap


class ZpublishTests(TestCase):
def test_zpublish_true(self):
@zpublish
def f():
pass

self.assertIs(zpublish_mark(f), True)
self.assertTrue(zpublish_marked(f))

def test_zpublish_false(self):
@zpublish(False)
def f():
pass

self.assertIs(zpublish_mark(f), False)
self.assertTrue(zpublish_marked(f))

def test_zpublish_method(self):
@zpublish(methods="method")
def f():
pass

self.assertEqual(zpublish_mark(f), ("METHOD",))
self.assertTrue(zpublish_marked(f))

def test_zpublish_methods(self):
@zpublish(methods="m1 m2".split())
def f():
pass

self.assertEqual(zpublish_mark(f), ("M1", "M2"))
self.assertTrue(zpublish_marked(f))

def test_zpublish_marked(self):
def f():
pass

self.assertFalse(zpublish_marked(f))
zpublish(f)
self.assertTrue(zpublish_marked(f))

def test_zpublish_wrap(self):
def f():
pass

self.assertFalse(zpublish_marked(f))
wrapper = zpublish_wrap(f)
self.assertFalse(zpublish_marked(f))
self.assertIs(zpublish_mark(wrapper), True)
self.assertEqual(signature(wrapper), signature(f))
self.assertIs(wrapper, zpublish_wrap(wrapper))

0 comments on commit fa44bc3

Please sign in to comment.