Skip to content

Commit 0a933ab

Browse files
authored
Support for namespace packages (#871)
1 parent 2b5e910 commit 0a933ab

File tree

32 files changed

+655
-121
lines changed

32 files changed

+655
-121
lines changed

README.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,20 @@ in development
7474
^^^^^^^^^^^^^^
7575

7676
* Drop support for Python 3.8.
77+
* Add support for `Namespace Packages <https://packaging.python.org/en/latest/guides/packaging-namespace-packages>`_:
78+
79+
- Support implicit native namespace packages (PEP 420). Get rid of the error: ``Source directory lacks __init__.py``.
80+
- Some limited support for legacy namespace packages is included as well (with ``declare_namespace(__name__)`` or ``__path__ = extend_path(__path__, __name__)``).
81+
- Better messages are now triggered when there is a module/package name collision (exit code will not change though).
82+
7783
* Signatures of function definitions are now wrapped onto several lines when the function has the focus.
7884
* The first parameter of classmethods and methods (``cls`` or ``self``) is colored in gray so it's clear that these are not part of the API.
7985
* When pydoctor encounters an invalid signature, it shows (…) as the signature instead of the misleading zero argument signature.
8086
* Improve field tables so the correspondence with the description column is more legible.
8187
* Highlighting in readthedocs theme now cover the whole docstring content
8288
instead of just the signature.
8389

90+
8491
pydoctor 24.11.2
8592
^^^^^^^^^^^^^^^^
8693

pydoctor/astbuilder.py

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1163,7 +1163,21 @@ def __init__(self, system: model.System):
11631163
self.currentMod: Optional[model.Module] = None # current module, set when visiting ast.Module.
11641164

11651165
self._stack: List[model.Documentable] = []
1166-
self.ast_cache: Dict[Path, Optional[ast.Module]] = {}
1166+
1167+
1168+
def parseFile(self, path: Path, ctx: model.Module) -> Optional[ast.Module]:
1169+
try:
1170+
return self.system._ast_parser.parseFile(path)
1171+
except Exception as e:
1172+
ctx.report(f"cannot parse file, {e}")
1173+
return None
1174+
1175+
def parseString(self, string:str, ctx: model.Module) -> Optional[ast.Module]:
1176+
try:
1177+
return self.system._ast_parser.parseString(string)
1178+
except Exception:
1179+
ctx.report("cannot parse string")
1180+
return None
11671181

11681182
def _push(self,
11691183
cls: Type[DocumentableT],
@@ -1269,28 +1283,55 @@ def processModuleAST(self, mod_ast: ast.Module, mod: model.Module) -> None:
12691283
vis.extensions.attach_visitor(vis)
12701284
vis.walkabout(mod_ast)
12711285

1272-
def parseFile(self, path: Path, ctx: model.Module) -> Optional[ast.Module]:
1286+
class SyntaxTreeParser:
1287+
"""
1288+
Responsible to read files and cache their parsed tree.
1289+
"""
1290+
1291+
class _Error:
1292+
"""
1293+
Errors are cached as instances of this class instead of base exceoptions
1294+
in order to avoid cycles with the locals.
1295+
"""
1296+
1297+
def __init__(self, exception: type[Exception], args: tuple[Any, ...]):
1298+
self._exce = exception
1299+
self._args = args
1300+
1301+
def exception(self) -> Exception:
1302+
return self._exce(*self._args)
1303+
1304+
def __init__(self) -> None:
1305+
self.ast_cache: Dict[Path, ast.Module | SyntaxTreeParser._Error] = {}
1306+
1307+
def parseFile(self, path: Path) -> ast.Module:
12731308
try:
1274-
return self.ast_cache[path]
1309+
r = self.ast_cache[path]
12751310
except KeyError:
1276-
mod: Optional[ast.Module] = None
1311+
tree: ast.Module | SyntaxTreeParser._Error
12771312
try:
1278-
mod = parseFile(path)
1279-
except (SyntaxError, ValueError) as e:
1280-
ctx.report(f"cannot parse file, {e}")
1281-
1282-
self.ast_cache[path] = mod
1283-
return mod
1313+
tree = parseFile(path)
1314+
return tree
1315+
except Exception as e:
1316+
tree = SyntaxTreeParser._Error(type(e), e.args)
1317+
raise
1318+
finally:
1319+
self.ast_cache[path] = tree
1320+
else:
1321+
if isinstance(r, SyntaxTreeParser._Error):
1322+
raise r.exception()
1323+
return r
12841324

1285-
def parseString(self, py_string:str, ctx: model.Module) -> Optional[ast.Module]:
1325+
def parseString(self, string:str) -> ast.Module:
12861326
mod = None
12871327
try:
1288-
mod = _parse(py_string)
1289-
except (SyntaxError, ValueError):
1290-
ctx.report("cannot parse string")
1328+
mod = _parse(string)
1329+
except (SyntaxError, ValueError) as e:
1330+
raise SyntaxError("cannot parse string") from e
12911331
return mod
12921332

12931333
model.System.defaultBuilder = ASTBuilder
1334+
model.System.syntaxTreeParser = SyntaxTreeParser
12941335

12951336
def findModuleLevelAssign(mod_ast: ast.Module) -> Iterator[Tuple[str, ast.Assign]]:
12961337
"""

pydoctor/astutils.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,3 +737,102 @@ class Precedence(object):
737737

738738
del _op_data, _index, _precedence_data, _symbol_data, _deprecated
739739
# This was part of the astor library for Python AST manipulation.
740+
741+
742+
class _OldSchoolNamespacePackageVis(ast.NodeVisitor):
743+
744+
is_namespace_package: bool = False
745+
746+
def visit_Module(self, node: ast.Module) -> None:
747+
try:
748+
self.generic_visit(node)
749+
except StopIteration:
750+
pass
751+
752+
def visit_skip(self, node: ast.AST) -> None:
753+
pass
754+
755+
visit_FunctionDef = visit_AsyncFunctionDef = visit_ClassDef = visit_skip
756+
visit_AugAssign = visit_skip
757+
visit_Return = visit_Raise = visit_Assert = visit_skip
758+
visit_Pass = visit_Break = visit_Continue = visit_Delete = visit_skip
759+
visit_Global = visit_Nonlocal = visit_skip
760+
visit_Import = visit_ImportFrom = visit_skip
761+
762+
def visit_Expr(self, node: ast.Expr) -> None:
763+
# Search for ast.Expr nodes that contains a call to a name or attribute
764+
# access of "declare_namespace" and a single argument: __name__
765+
if not isinstance(val:=node.value, ast.Call):
766+
return
767+
if not isinstance(func:=val.func, (ast.Name, ast.Attribute)):
768+
return
769+
if isinstance(func, ast.Name) and func.id == 'declare_namespace' or \
770+
isinstance(func, ast.Attribute) and func.attr == 'declare_namespace':
771+
# checks the arguments are the basic one, not custom
772+
try:
773+
arg1, = (*val.args, *(k.value for k in val.keywords))
774+
except ValueError:
775+
raise StopIteration
776+
if not isinstance(arg1, ast.Name) or arg1.id != '__name__':
777+
raise StopIteration
778+
779+
self.is_namespace_package = True
780+
raise StopIteration
781+
782+
def visit_Assign(self, node: ast.Assign) -> None:
783+
# search for assignments nodes that contains a call in the
784+
# rhs to name or attribute acess of "extend_path" and two arguments:
785+
# __path__ and __name__.
786+
787+
if not any(isinstance(t, ast.Name) and t.id == '__path__' for t in node.targets):
788+
return
789+
if not isinstance(val:=node.value, ast.Call):
790+
return
791+
if not isinstance(func:=val.func, (ast.Name, ast.Attribute)):
792+
return
793+
if isinstance(func, ast.Name) and func.id == 'extend_path' or \
794+
isinstance(func, ast.Attribute) and func.attr == 'extend_path':
795+
# checks the arguments are the basic one, not custom
796+
try:
797+
arg1, arg2 = (*val.args, *(k.value for k in val.keywords))
798+
except ValueError:
799+
raise StopIteration
800+
if (not isinstance(arg1, ast.Name)) or arg1.id != '__path__':
801+
raise StopIteration
802+
if (not isinstance(arg2, ast.Name)) or arg2.id != '__name__':
803+
raise StopIteration
804+
805+
self.is_namespace_package = True
806+
raise StopIteration
807+
808+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
809+
setattr(node, 'targets', [node.target])
810+
try:
811+
self.visit_Assign(node) # type:ignore[arg-type]
812+
finally:
813+
delattr(node, 'targets')
814+
815+
def is_old_school_namespace_package(tree: ast.Module) -> bool:
816+
"""
817+
Returns True if the module is a pre PEP 420 namespace package::
818+
819+
from pkgutil import extend_path
820+
__path__ = extend_path(__path__, __name__)
821+
# OR
822+
import pkg_resources
823+
pkg_resources.declare_namespace(__name__)
824+
# OR
825+
__import__('pkg_resources').declare_namespace(__name__)
826+
# OR
827+
import pkg_resources
828+
pkg_resources.declare_namespace(name=__name__)
829+
830+
The following code will return False, tho::
831+
832+
from pkgutil import extend_path
833+
__path__ = extend_path(__path__, __name__ + '.impl')
834+
835+
"""
836+
v =_OldSchoolNamespacePackageVis()
837+
v.visit(tree)
838+
return v.is_namespace_package

pydoctor/epydoc2stan.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,7 @@ def format_kind(kind: model.DocumentableKind, plural: bool = False) -> str:
948948
Transform a `model.DocumentableKind` Enum value to string.
949949
"""
950950
names = {
951+
model.DocumentableKind.NAMESPACE_PACKAGE : 'Namespace Package',
951952
model.DocumentableKind.PACKAGE : 'Package',
952953
model.DocumentableKind.MODULE : 'Module',
953954
model.DocumentableKind.INTERFACE : 'Interface',
@@ -1169,6 +1170,30 @@ def get_constructors_extra(cls:model.Class) -> ParsedDocstring | None:
11691170
set_node_attributes(document, children=elements)
11701171
return ParsedRstDocstring(document, ())
11711172

1173+
def get_namespace_docstring(ns: model.Package) -> str:
1174+
"""
1175+
Get a useful description about this namespace package.
1176+
"""
1177+
# Something like:
1178+
# Contains 1 known namespace packages, 3 known packages, 2 known modules
1179+
# Empty
1180+
if not ns.contents:
1181+
text = 'Empty'
1182+
else:
1183+
sub_objects_total_count: DefaultDict[model.DocumentableKind, int] = defaultdict(int)
1184+
for sub_ob in ns.contents.values():
1185+
kind = sub_ob.kind
1186+
if kind is not None:
1187+
sub_objects_total_count[kind] += 1
1188+
1189+
text = 'Contains ' + ', '.join(
1190+
f"{sub_objects_total_count[kind]} known "
1191+
f"{format_kind(kind, plural=sub_objects_total_count[kind]>=2).lower()}"
1192+
for kind in sorted(sub_objects_total_count, key=(lambda x:x.value))
1193+
) + '.'
1194+
1195+
return text
1196+
11721197
_empty = inspect.Parameter.empty
11731198
_POSITIONAL_ONLY = inspect.Parameter.POSITIONAL_ONLY
11741199
_POSITIONAL_OR_KEYWORD = inspect.Parameter.POSITIONAL_OR_KEYWORD
@@ -1333,4 +1358,4 @@ def function_signature_len(func: model.Function | model.FunctionOverload) -> int
13331358
name_len = len(ctx.name)
13341359
signature_len = len(psig.to_text())
13351360
return name_len + signature_len
1336-
1361+

0 commit comments

Comments
 (0)