Skip to content
This repository was archived by the owner on Nov 3, 2023. It is now read-only.

Commit 2aa3aa7

Browse files
gbroquestibdexsambhav
authored
Correctly detect publicity of modules inside directories (#494)
* Fix Module.is_public() when the module is not a the root * Add PR reference * Use pathlib * Use pathlib instead of os for test_module_publicity * Update release notes for #493 * Use forward slash '/' operator instead of .joinpath() * Fix pull-request number in release notes * Fix publicity of module in private package * Update test_module_publicity docstring * Add test for directory starting with double underscore * Make packages containing double-underscore public * Add test to assert __init__ module is public * Make modules in a __private_package private * Fix lint errors from lines being too long * Update publicity.rst with more information * Parameterize module publicity tests and include .py file extension in test path parameters * Make module publicity determination respect $PYTHONPATH * Fix line-length issue * Reword comment * Add tests with the same path over different sys.path cases * Add note about checking sys.path for determining publicity * Apply suggestions from code review Co-authored-by: Thibault Derousseaux <[email protected]> Co-authored-by: Thibault Derousseaux <[email protected]> Co-authored-by: Sambhav Kothari <[email protected]>
1 parent b0f7d62 commit 2aa3aa7

File tree

5 files changed

+110
-11
lines changed

5 files changed

+110
-11
lines changed

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,6 @@ docs/snippets/error_code_table.rst
5454

5555
# PyCharm files
5656
.idea
57+
58+
# VS Code
59+
.vscode/

docs/release_notes.rst

+2-2
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ Bug Fixes
2020
The bug caused some argument names to go unreported in D417 (#448).
2121
* Fixed an issue where skipping errors on module level docstring via #noqa
2222
failed when there where more prior comments (#446).
23-
* Support backslash-continued descriptions in docstrings
24-
(#472).
23+
* Support backslash-continued descriptions in docstrings (#472).
24+
* Correctly detect publicity of modules inside directories (#470, #494).
2525

2626

2727
5.0.2 - January 8th, 2020

docs/snippets/publicity.rst

+9-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ Publicity for all constructs is determined as follows: a construct is
1010
considered *public* if -
1111

1212
1. Its immediate parent is public *and*
13-
2. Its name does not contain a single leading underscore.
13+
2. Its name does *not* start with a single or double underscore.
14+
15+
a. Note, names that start and end with a double underscore are *public* (e.g. ``__init__.py``).
1416

1517
A construct's immediate parent is the construct that contains it. For example,
1618
a method's parent is a class object. A class' parent is usually a module, but
@@ -25,6 +27,12 @@ a class called ``_Foo`` is considered private. A method ``bar`` in ``_Foo`` is
2527
also considered private since its parent is a private class, even though its
2628
name does not begin with a single underscore.
2729

30+
Note, a module's parent is recursively checked upward until we reach a directory
31+
in ``sys.path`` to avoid considering the complete filepath of a module.
32+
For example, consider the module ``/_foo/bar/baz.py``.
33+
If ``PYTHONPATH`` is set to ``/``, then ``baz.py`` is *private*.
34+
If ``PYTHONPATH`` is set to ``/_foo/``, then ``baz.py`` is *public*.
35+
2836
Modules are parsed to look if ``__all__`` is defined. If so, only those top
2937
level constructs are considered public. The parser looks for ``__all__``
3038
defined as a literal list or tuple. As the parser doesn't execute the module,

src/pydocstyle/parser.py

+32-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""Python code parser."""
22

3+
import sys
34
import textwrap
45
import tokenize as tk
56
from itertools import chain, dropwhile
67
from re import compile as re
78
from io import StringIO
9+
from pathlib import Path
810

911
from .utils import log
1012

@@ -117,7 +119,36 @@ def is_public(self):
117119
118120
This helps determine if it requires a docstring.
119121
"""
120-
return not self.name.startswith('_') or self.name.startswith('__')
122+
module_name = Path(self.name).stem
123+
return (
124+
not self._is_inside_private_package() and
125+
self._is_public_name(module_name)
126+
)
127+
128+
def _is_inside_private_package(self):
129+
"""Return True if the module is inside a private package."""
130+
path = Path(self.name).parent # Ignore the actual module's name.
131+
syspath = [Path(p) for p in sys.path] # Convert to pathlib.Path.
132+
133+
# Bail if we are at the root directory or in `PYTHONPATH`.
134+
while path != path.parent and path not in syspath:
135+
if self._is_private_name(path.name):
136+
return True
137+
path = path.parent
138+
139+
return False
140+
141+
def _is_public_name(self, module_name):
142+
"""Determine whether a "module name" (i.e. module or package name) is public."""
143+
return (
144+
not module_name.startswith('_') or (
145+
module_name.startswith('__') and module_name.endswith('__')
146+
)
147+
)
148+
149+
def _is_private_name(self, module_name):
150+
"""Determine whether a "module name" (i.e. module or package name) is private."""
151+
return not self._is_public_name(module_name)
121152

122153
def __str__(self):
123154
return 'at module level'

src/tests/parser_test.py

+64-7
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import sys
55
import pytest
66
import textwrap
7+
from pathlib import Path
78

89
from pydocstyle.parser import Parser, ParseError
910

@@ -562,19 +563,75 @@ def test_matrix_multiplication_with_decorators(code):
562563
assert inner_function.decorators[0].name == 'a'
563564

564565

565-
def test_module_publicity():
566-
"""Test that a module that has a single leading underscore is private."""
566+
@pytest.mark.parametrize("public_path", (
567+
Path(""),
568+
Path("module.py"),
569+
Path("package") / "module.py",
570+
Path("package") / "__init__.py",
571+
Path("") / "package" / "module.py",
572+
Path("") / "__dunder__" / "package" / "module.py"
573+
))
574+
def test_module_publicity_with_public_path(public_path):
575+
"""Test module publicity with public path.
576+
577+
Module names such as my_module.py are considered public.
578+
579+
Special "dunder" modules,
580+
with leading and trailing double-underscores (e.g. __init__.py) are public.
581+
582+
The same rules for publicity apply to both packages and modules.
583+
"""
567584
parser = Parser()
568585
code = CodeSnippet("")
569-
570-
module = parser.parse(code, "filepath")
586+
module = parser.parse(code, str(public_path))
571587
assert module.is_public
572588

573-
module = parser.parse(code, "_filepath")
589+
590+
@pytest.mark.parametrize("private_path", (
591+
# single underscore
592+
Path("_private_module.py"),
593+
Path("_private_package") / "module.py",
594+
Path("_private_package") / "package" / "module.py",
595+
Path("") / "_private_package" / "package" / "module.py",
596+
597+
# double underscore
598+
Path("__private_module.py"),
599+
Path("__private_package") / "module.py",
600+
Path("__private_package") / "package" / "module.py",
601+
Path("") / "__private_package" / "package" / "module.py"
602+
))
603+
def test_module_publicity_with_private_paths(private_path):
604+
"""Test module publicity with private path.
605+
606+
Module names starting with single or double-underscore are private.
607+
For example, _my_private_module.py and __my_private_module.py.
608+
609+
Any module within a private package is considered private.
610+
611+
The same rules for publicity apply to both packages and modules.
612+
"""
613+
parser = Parser()
614+
code = CodeSnippet("")
615+
module = parser.parse(code, str(private_path))
574616
assert not module.is_public
575617

576-
module = parser.parse(code, "__filepath")
577-
assert module.is_public
618+
619+
@pytest.mark.parametrize("syspath,is_public", (
620+
("/", False),
621+
("_foo/", True),
622+
))
623+
def test_module_publicity_with_different_sys_path(syspath,
624+
is_public,
625+
monkeypatch):
626+
"""Test module publicity for same path and different sys.path."""
627+
parser = Parser()
628+
code = CodeSnippet("")
629+
630+
monkeypatch.syspath_prepend(syspath)
631+
632+
path = Path("_foo") / "bar" / "baz.py"
633+
module = parser.parse(code, str(path))
634+
assert module.is_public == is_public
578635

579636

580637
def test_complex_module():

0 commit comments

Comments
 (0)