Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 66 additions & 4 deletions pdoc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from contextlib import contextmanager
from copy import copy
from dataclasses import is_dataclass
from fnmatch import fnmatchcase
from functools import cached_property, lru_cache, reduce, partial, wraps
from itertools import tee, groupby
from types import FunctionType, ModuleType
Expand Down Expand Up @@ -379,18 +380,40 @@ def get_indent(line):
return vars, instance_vars


# Characters that turn a `__pdoc__` key into an fnmatch wildcard pattern.
_PDOC_WILDCARD_CHARS = ('*', '?', '[')


def _is_pdoc_wildcard(key: str) -> bool:
"""Returns `True` if `key` is a wildcard `__pdoc__` pattern (e.g. `*.foo`)."""
return any(char in key for char in _PDOC_WILDCARD_CHARS)


def _pdoc_matches(pattern: str, *names: str) -> bool:
"""Returns `True` if any of `names` matches the fnmatch `pattern`."""
return any(fnmatchcase(name, pattern) for name in names)


@lru_cache()
def _is_whitelisted(name: str, doc_obj: Union['Module', 'Class']):
"""
Returns `True` if `name` (relative or absolute refname) is
contained in some module's __pdoc__ with a truish value.

A `__pdoc__` key containing wildcard characters (`*?[`) is matched
against the refname with `fnmatch`.
"""
refname = f'{doc_obj.refname}.{name}'
module: Optional[Module] = doc_obj.module
while module:
qualname = refname[len(module.refname) + 1:]
if module.__pdoc__.get(qualname) or module.__pdoc__.get(refname):
return True
for key, value in module.__pdoc__.items():
if not value:
continue
if key in (qualname, refname):
return True
if _is_pdoc_wildcard(key) and _pdoc_matches(key, qualname, refname):
return True
module = module.supermodule
return False

Expand All @@ -400,13 +423,21 @@ def _is_blacklisted(name: str, doc_obj: Union['Module', 'Class']):
"""
Returns `True` if `name` (relative or absolute refname) is
contained in some module's __pdoc__ with value False.

A `__pdoc__` key containing wildcard characters (`*?[`) is matched
against the refname with `fnmatch`.
"""
refname = f'{doc_obj.refname}.{name}'
module: Optional[Module] = doc_obj.module
while module:
qualname = refname[len(module.refname) + 1:]
if module.__pdoc__.get(qualname) is False or module.__pdoc__.get(refname) is False:
return True
for key, value in module.__pdoc__.items():
if value is not False:
continue
if key in (qualname, refname):
return True
if _is_pdoc_wildcard(key) and _pdoc_matches(key, qualname, refname):
return True
module = module.supermodule
return False

Expand Down Expand Up @@ -871,6 +902,18 @@ def _link_inheritance(self):
if docstring is True:
continue

# Wildcard keys (e.g. `'*.model_fields': False`) are matched
# against member refnames by fnmatch in _is_whitelisted() /
# _is_blacklisted(), so a class' *own* matching members are
# already filtered out at construction time. Here we only need
# to drop matching members that were pulled in via inheritance,
# and, unlike exact keys, a pattern is never expected to name an
# existing member, so we don't warn that it "does not exist".
if _is_pdoc_wildcard(name):
if docstring in (False, None):
self._blacklist_matching(name)
continue

refname = f"{self.refname}.{name}"
if docstring in (False, None):
if docstring is None:
Expand Down Expand Up @@ -917,6 +960,25 @@ def _link_inheritance(self):

self._is_inheritance_linked = True

def _blacklist_matching(self, pattern: str):
"""
Drop class members whose refname matches the fnmatch `pattern`.

This handles members pulled into subclasses via inheritance, which
aren't filtered by `_is_blacklisted()` at construction time. A class'
own members matching the pattern are already excluded by then.
"""
modprefix = self.refname + '.'
for cls in _filter_type(Class, self.doc):
for name in list(cls.doc):
refname = cls.doc[name].refname
qualname = (refname[len(modprefix):]
if refname.startswith(modprefix) else refname)
if _pdoc_matches(pattern, qualname, refname):
del cls.doc[name]
self._context.pop(refname, None)
self._context.blacklisted.add(refname)

def text(self, **kwargs) -> str:
"""
Returns the documentation for this module as plain text.
Expand Down
8 changes: 8 additions & 0 deletions pdoc/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ Conversely, if `__pdoc__[key] = True`, then `key` (and its public members) will
include documentation of [private objects][public-private],
including special functions such as `__call__`, which are ignored by default.

A `key` that contains any of the wildcard characters `*`, `?`, or `[` is
treated as an [fnmatch] pattern and matched against member reference names.
This is handy for excluding members contributed by a base class from every
subclass at once, e.g. `__pdoc__ = {'*.model_fields': False}` to hide a
Pydantic field from all models in the module.

[fnmatch]: https://docs.python.org/3/library/fnmatch.html

Alternatively, the _values_ of `__pdoc__` can be the **overriding docstrings**.
This feature is useful when there's no feasible way of
attaching a docstring to something. A good example is a
Expand Down
39 changes: 39 additions & 0 deletions pdoc/test/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,45 @@ def test__pdoc__dict(self):
self.assertEqual(cm, [])
self.assertNotIn('downloaded_modules', mod.doc)

def test__pdoc__wildcard(self):
# GH-449: https://github.com/pdoc3/pdoc/issues/449
module = pdoc.import_module(EXAMPLE_MODULE)

# A wildcard key blacklists every matching member, including the
# ones subclasses only have through inheritance.
with patch.object(module, '__pdoc__', {'*.inherited': False}):
pdoc.reset()
mod = pdoc.Module(module)
with warnings.catch_warnings(record=True) as cm:
warnings.simplefilter('always')
pdoc.link_inheritance()
# A pattern is not expected to name an existing member, so it
# must not raise the "key does not exist" warning.
self.assertEqual(
[str(w.message) for w in cm if 'does not exist' in str(w.message)], [])
self.assertNotIn('inherited', mod.doc['A'].doc)
self.assertNotIn('inherited', mod.doc['B'].doc)
self.assertNotIn('inherited', mod.doc['C'].doc)
# Non-matching members are left untouched.
self.assertIn('overridden', mod.doc['B'].doc)

# A trailing wildcard blacklists a whole class' members.
with patch.object(module, '__pdoc__', {'B.*': False}):
pdoc.reset()
mod = pdoc.Module(module)
pdoc.link_inheritance()
self.assertIn('B', mod.doc)
self.assertNotIn('f', mod.doc['B'].doc)
self.assertNotIn('inherited', mod.doc['B'].doc)

# A wildcard with a truish value whitelists matching members,
# here the otherwise-hidden dunder inherited from `A`.
with patch.object(module, '__pdoc__', {'*.__call__': True}):
pdoc.reset()
mod = pdoc.Module(module)
pdoc.link_inheritance()
self.assertIn('__call__', mod.doc['A'].doc)

def test_class_wrappers(self):
"""
Check that decorated classes are unwrapped properly.
Expand Down
Loading