diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..ac660d7 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,61 @@ +# This is a GitHub workflow defining a set of jobs with a set of steps. +# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions +# +name: Test + +on: + pull_request: + paths-ignore: + - "**.md" + - ".github/workflows/*" + - "!.github/workflows/test.yaml" + push: + paths-ignore: + - "**.md" + - ".github/workflows/*" + - "!.github/workflows/test.yaml" + branches-ignore: + - "dependabot/**" + - "pre-commit-ci-update-config" + tags: + - "**" + workflow_dispatch: + +jobs: + pytest: + runs-on: ubuntu-22.04 + + strategy: + fail-fast: false + matrix: + include: + # NOTE: jinja2<3.1 is added as a workaround to ensure we can test + # against sphinx 2 and 3 that otherwise breaks, see + # https://github.com/sphinx-doc/sphinx/issues/10291#issuecomment-1079709635. + # + - python-version: "3.8" + sphinx-version: "2.*" + traitlets-version: "4.*" + pip-install-addition: "'jinja2<3.1'" + - python-version: "3.9" + sphinx-version: "3.*" + traitlets-version: "4.*" + pip-install-addition: "'jinja2<3.1'" + - python-version: "3.10" + sphinx-version: "4.*" + traitlets-version: "5.*" + pip-install-addition: "" + - python-version: "3.11" + sphinx-version: "5.*" + traitlets-version: "5.*" + pip-install-addition: "" + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: "${{ matrix.python-version }}" + + - run: pip install ".[test]" "sphinx==${{ matrix.sphinx-version }}" "traitlets==${{ matrix.traitlets-version }}" ${{ matrix.pip-install-addition }} + + - run: pytest diff --git a/.gitignore b/.gitignore index 894a44c..94dbd57 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ +# Manually added parts to .gitignore +# ---------------------------------- +# + +# Python .gitignore from https://github.com/github/gitignore/blob/HEAD/Python.gitignore +# ------------------------------------------------------------------------------------- +# # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -20,6 +27,7 @@ parts/ sdist/ var/ wheels/ +share/python-wheels/ *.egg-info/ .installed.cfg *.egg @@ -38,14 +46,17 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ +.nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover +*.py,cover .hypothesis/ .pytest_cache/ +cover/ # Translations *.mo @@ -55,6 +66,7 @@ coverage.xml *.log local_settings.py db.sqlite3 +db.sqlite3-journal # Flask stuff: instance/ @@ -67,16 +79,49 @@ instance/ docs/_build/ # PyBuilder +.pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints -# pyenv -.python-version +# IPython +profile_default/ +ipython_config.py -# celery beat schedule file +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff celerybeat-schedule +celerybeat.pid # SageMath parsed files *.sage.py @@ -102,3 +147,21 @@ venv.bak/ # mypy .mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/MANIFEST.in b/MANIFEST.in index 9187fe8..9a338fd 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,5 @@ include MANIFEST.in -recursive-include autodoc_traits * +include autodoc_traits.py include LICENSE include *.md diff --git a/README.md b/README.md index 6123dc4..73fb3ef 100644 --- a/README.md +++ b/README.md @@ -5,19 +5,21 @@ [![Discourse](https://img.shields.io/badge/help_forum-discourse-blue?logo=discourse)](https://discourse.jupyter.org/c/jupyterhub) [![Gitter](https://img.shields.io/badge/social_chat-gitter-blue?logo=gitter)](https://gitter.im/jupyterhub/jupyterhub) -`autodoc-traits` is a Sphinx extension that influences -[`sphinx.ext.autodoc`][]'s provided [Sphinx directives][], specifically -[`autoclass`][] and [`autoattribute`][], to better document classes with -[Traitlets][] based configuration. - -The `autoclass` directive is updated to document class attributes inheriting -from [`traitlets.TraitType`][] by default. The `autoattribute` directive is -updated to provide a header looking like `default_url c.KubeSpawner.default_url -= Unicode('')`. - -The extension also provides the `autoconfigurable` directive mapping to the -`autoclass` directive, and the `autotrait` directive mapping to the -`autoattributes` directive. +`autodoc-traits` is a Sphinx extension that builds on [`sphinx.ext.autodoc`][] +to better document classes with [Traitlets][] based configuration. +`autodoc-traits` provides the [Sphinx directives][] `autoconfigurable` (use with +classes) and `autotrait` (use with the traitlets based configuration options). + +The `sphinx.ext.autodoc` provided directive [`automodule`][], which can overview +classes, will with `autodoc-traits` enabled use `autoconfigurable` over +[`autoclass`][] for classes has trait based configuration. Similarly, the +`sphinx.ext.autodoc` provided `autoclass` directive will use `autotrait` over +[`autoattribute`][] if configured to present the traitlets attributes normally +not presented. + +The `autoattribute` directive will provide a header looking like `trait +c.SampleConfigurable.trait = Bool(False)`, and as docstring it will use the +trait's configured help text. ## How to use it @@ -42,13 +44,20 @@ The extension also provides the `autoconfigurable` directive mapping to the ] ``` -3. Make use of a `sphinx.ext.autodoc` Sphinx directive like `autoclass`, or - `automodule` that make use of `autoclass`: +3. Make use of the `sphinx.ext.autodoc` Sphinx directive like `automodule` that + document classes, the `autodoc_traits` provided `autoconfigurable` that + documents traitlets configurable classes, or the `autodoc_traits` provided + `autotrait` that documents individual traitlets configuration options: From a .rst document: ```rst - .. autoclass:: KubeSpawner + .. automodule:: sample_module + :members: + + .. autoconfigurable:: sample_module.SampleConfigurable + + .. autotrait:: sample_module.SampleConfigurable.trait ``` ## Use with MyST Parser @@ -60,7 +69,7 @@ From a .md document, with `myst-parser`: ````markdown ```{eval-rst} -.. autoclass:: KubeSpawner +.. autoconfigurable:: sample_module.SampleConfigurable ``` ```` diff --git a/autodoc_traits.py b/autodoc_traits.py index 7f808d4..82001f8 100644 --- a/autodoc_traits.py +++ b/autodoc_traits.py @@ -14,70 +14,182 @@ class ConfigurableDocumenter(ClassDocumenter): - """Specialized Documenter subclass for traits with config=True""" + """ + Specialized Documenter subclass for traits with config=True + + Links to relevant source code in sphinx.ext.autodoc: + - Documenter: https://github.com/sphinx-doc/sphinx/blob/v6.0.0b2/sphinx/ext/autodoc/__init__.py#L270-L299 + - ClassDocumenter: https://github.com/sphinx-doc/sphinx/blob/v6.0.0b2/sphinx/ext/autodoc/__init__.py#L1395-L1408 + """ + # objtype: The suffix to "auto" for a Sphinx directive name that will be + # created (and the default value for "directivetype"). objtype = "configurable" + + # directivetype: Clarifies that this Documenter class could be capable of + # documenting this kind of members of other parent like + # Documenter classes. directivetype = "class" + # priority: Declares this class' priority for use if multiple classes + # "can_document_member" of the "directivetype". This is only + # relevant if a parent class has a member. + # + # ConfigurableDocumenter can document traitlets configurable + # classes, so a parent like Documenter class can be the + # ModuleDocumenter. + # + priority = 100 # higher priority than ClassDocumenter's 10 + + @classmethod + def can_document_member(cls, member, membername, isattr, parent): + """ + If the member is a class with traitlets then we can document it, and we + will document it thanks to a high priority. + + This function is not considered if the ``autoconfigurable`` or + ``autoclass`` directives are called directly. can_document_member is + only used by other parent like Documenter classes having members of this + class' configured "documentertype" - such as ModuleDocumenter. + """ + return isinstance(member, MetaHasTraits) + def get_object_members(self, want_all): - """Add traits with .tag(config=True) to members list""" + """ + This get_object_members function override is a hack, manipulating + __doc__ values of trait configuration objects, but otherwise behaving + exactly like the super class get_object_members. + + It sets truthy strings to the class' traits __doc__ attributes. They + will otherwise be filtered out by the Documenter.filter_members + function, unless ``undoc-members`` option is set. + + Links to relevant source code in sphinx.ext.autodoc: + - Documenter.filter_members: https://github.com/sphinx-doc/sphinx/blob/v6.0.0b2/sphinx/ext/autodoc/__init__.py#L616-L769 + - Documenter.get_object_members: https://github.com/sphinx-doc/sphinx/blob/v6.0.0b2/sphinx/ext/autodoc/__init__.py#L607-L614 + - ClassDocumenter.get_object_members: https://github.com/sphinx-doc/sphinx/blob/v6.0.0b2/sphinx/ext/autodoc/__init__.py#L1656-L1674 + - traitlets.HasTraits.class_traits: https://github.com/ipython/traitlets/blob/v5.6.0/traitlets/traitlets.py#L1620-L1652 + """ check, members = super().get_object_members(want_all) - if not isinstance(self.object, MetaHasTraits): - return check, members - # The directive can have been passed a inherited-members option to - # influence it, and we rely on two traitlets provided functions for it. + + truthy_string = ( + "A hack is used by autodoc_traits since 1.1.0 for trait " + "configurations, updating trait configuration's __doc__ to this " + "truthy string as required to make sphinx.ext.autodoc behave as " + " wanted." + ) + for trait in self.object.class_traits(config=True).values(): + trait.__doc__ = truthy_string + + # We add all traits, also the inherited, bypassing :members: and + # :inherit-members: options. # - # class_own_traits returns the class own defined traits, while - # class_traits includes super classes' defined traits. + # FIXME: We have been adding the trait_members unconditionally, but + # should we keep doing that? # - # class_traits definition: https://github.com/ipython/traitlets/blob/v5.6.0/traitlets/traitlets.py#L1620-L1652 - # class_own_traits definition: https://github.com/ipython/traitlets/blob/v5.6.0/traitlets/traitlets.py#L1654-L1665 + # See https://github.com/jupyterhub/autodoc-traits/issues/27 # - get_traits = ( - # FIXME: Is this backwards? - # Tracked in https://github.com/jupyterhub/autodoc-traits/issues/19 - # - self.object.class_own_traits - if self.options.inherited_members - else self.object.class_traits - ) - trait_members = [] - for name, trait in sorted(get_traits(config=True).items()): - # put help in __doc__ where autodoc will look for it - trait.__doc__ = trait.help - trait_members.append((name, trait)) - # Remove duplicates between members and trait_members. We - # can't use sets, because not all items are hashable. Modify - # trait_members in place for returning. - for item in members: - if item not in trait_members: - trait_members.append(item) - return check, trait_members + trait_members = self.object.class_traits(config=True).items() + for trait in trait_members: + if trait not in members: + members.append(trait) + + return check, members class TraitDocumenter(AttributeDocumenter): + """ + Links to relevant source code in sphinx.ext.autodoc: + - Documenter: https://github.com/sphinx-doc/sphinx/blob/v6.0.0b2/sphinx/ext/autodoc/__init__.py#L270-L299 + - AttributeDocumenter: https://github.com/sphinx-doc/sphinx/blob/v6.0.0b2/sphinx/ext/autodoc/__init__.py#L2509-L2524 + """ + + # objtype: The suffix to "auto" for a Sphinx directive name that will be + # created (and the default value for "directivetype"). objtype = "trait" + + # directivetype: Clarifies that this Documenter class could be capable of + # documenting this kind of members of other parent like + # Documenter classes. directivetype = "attribute" - member_order = 1 - priority = 100 + + # priority: Declares this class' priority for use if multiple classes + # "can_document_member" of the "directivetype". This is only + # relevant if a parent class has a member. + # + # TraitsDocumenter can document traitlets type attributes, so the + # parent like Documenter class is typically ClassDocumenter, but + # can also be the ConfigurableDocumenter. + # + priority = 100 # AttributeDocumenter has 10 + + # order: order if the autodoc_member_order in conf.py is set to "groupwise", + # by default it is "alphabetical", where lowest order comes first. + # Since traits are relevant configuration, we declare the lowest + # order for high visual priority. + member_order = 0 # AttributeDocumenter has 60 @classmethod def can_document_member(cls, member, membername, isattr, parent): + """ + If the member is a traitlets type we can document it, and we will + document it thanks to a high priority. + + This function is not considered if the ``autotrait`` or + ``autoattribute`` directives are called directly. can_document_member is + only used by other parent like Documenter classes having members of this + class' configured "documentertype" - such as ClassDocumenter. + """ return isinstance(member, TraitType) def add_directive_header(self, sig): - default = self.object.get_default_value() - if default is Undefined: - default_s = "" + """ + add_directive_header is called by the base class' Documenter.generate + method. It is provided by both AttributeDocumenter and Documenter. This + override retains use of the super classes implementations, but influence + them. + + For functions, the directive header describes the function's call + signature, but not the function's docstring. + + Similarly, we look to emit rST to describe how the traitlets + configuration option can be configured and its default value. + + - Documenter.generate: https://github.com/sphinx-doc/sphinx/blob/v6.0.0b2/sphinx/ext/autodoc/__init__.py#L918-L929 + - AttributeDocumenter.add_directive_header: https://github.com/sphinx-doc/sphinx/blob/v6.0.0b2/sphinx/ext/autodoc/__init__.py#L2592-L2620 + - Documenter.add_directive_header: https://github.com/sphinx-doc/sphinx/blob/v6.0.0b2/sphinx/ext/autodoc/__init__.py#L504-L524 + """ + default_value = self.object.get_default_value() + if default_value is Undefined: + default_value = "" else: - default_s = repr(default) - self.options.annotation = "c.{name} = {trait}({default})".format( - name=self.format_name(), - trait=self.object.__class__.__name__, - default=default_s, + default_value = repr(default_value) + + self.options.annotation = "c.{name} = {traitlets_type}({default_value})".format( + name=self.format_name(), # TestConfigurator.trait + traitlets_type=self.object.__class__.__name__, # Bool + default_value=default_value, ) + super().add_directive_header(sig) + def get_doc(self): + """ + get_doc (get docstring) is called by add_content, which is called by + generate. We override it to not unconditionally provide the docstring of + the traitlets type, but instead provide the traits help text if its + available. + + Links to relevant source code in sphinx.ext.autodoc: + - Documenter.generate: https://github.com/sphinx-doc/sphinx/blob/v6.0.0b2/sphinx/ext/autodoc/__init__.py#L918-L929 + - AttributeDocumenter.add_content: https://github.com/sphinx-doc/sphinx/blob/v6.0.0b2/sphinx/ext/autodoc/__init__.py#L2655-L2663 + - Documenter.add_content: https://github.com/sphinx-doc/sphinx/blob/v6.0.0b2/sphinx/ext/autodoc/__init__.py#L568-L605 + - AttributeDocumenter.get_doc: https://github.com/sphinx-doc/sphinx/blob/v6.0.0b2/sphinx/ext/autodoc/__init__.py#L2639-L2653 + """ + if isinstance(self.object.help, str): + return [[self.object.help]] + return super().get_doc() + def setup(app): """ diff --git a/pyproject.toml b/pyproject.toml index afbf576..e5d3f10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,10 +5,7 @@ [project] name = "autodoc-traits" description = "Sphinx extension to autodoc traitlets" -readme = "README.md" -requires-python = ">=3.7" -license = {file = "LICENSE"} -keywords = ["repo2docker", "jupyterhub"] +keywords = ["sphinx", "extension", "autodoc", "traitlets"] authors = [ {name = "Jupyter Development Team", email = "jupyter@googlegroups.com"}, ] @@ -17,11 +14,14 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: 3", ] +readme = "README.md" +license = {file = "LICENSE"} +dynamic = ["version"] +requires-python = ">=3.7" dependencies = [ - "sphinx", - "traitlets", + "sphinx>=2", + "traitlets>=4", ] -dynamic = ["version"] [project.optional-dependencies] test = [ @@ -46,6 +46,7 @@ build-backend = "hatchling.build" [tool.hatch.version] path = "autodoc_traits.py" + # autoflake is used for autoformatting Python code # # ref: https://github.com/PyCQA/autoflake#readme @@ -56,6 +57,7 @@ remove-all-unused-imports = true remove-duplicate-keys = true #remove-unused-variables = true + # isort is used for autoformatting Python code # # ref: https://pycqa.github.io/isort/ @@ -81,6 +83,14 @@ target_version = [ ] +# pytest is used for running Python based tests +# +# ref: https://docs.pytest.org/en/stable/ +# +[tool.pytest.ini_options] +addopts = "--verbose --color=yes --durations=10 --ignore=tests/docs/sample_module.py" + + # tbump is used to simplify and standardize the release process when updating # the version, making a git commit and tag, and pushing changes. # diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..c43d299 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,29 @@ +# About the test suite + +We use `pytest` to run `sphinx-build` against the Sphinx project in the `docs/` +folder. Besides concluding that the build succeeds without warnings, we look for +strings in the .html files to estimate if it has rendered as it should or not. + +The test .rst documents include some descriptions about whats tested. + +## Inspecting the test documentation + +`sphinx-autobuild` is convenient to use when looking to inspect or expand the +content in the docs/ folder, which rendered is whats inspected by the test +suite. + +```shell +pip install sphinx-autobuild + +cd docs +# --watch: we watch python files influencing the built documentation +# --pre-build: we rebuild from scratch as changes to the python files +# can influence all built html +sphinx-autobuild \ + --open-browser \ + --watch="test_module.py" \ + --watch="../../autodoc_traits.py" \ + --pre-build="rm -rf build" \ + source \ + build +``` diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..875f81d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,80 @@ +import glob +import os +import shutil +import sys +import tempfile +from functools import partial + +import pytest + +if sys.version_info >= (3, 8): + copy_tree = partial(shutil.copytree, dirs_exist_ok=True) +else: + # use deprecated distutils on Python < 3.8 + # when shutil.copytree added dirs_exist_ok support + from distutils.dir_util import copy_tree + + +@pytest.fixture +def temp_docs_dir(): + """ + This fixture provides a temporary directory with files copied from the + tests/docs directory. + """ + with tempfile.TemporaryDirectory() as temp_dir: + tests_dir = os.path.abspath(os.path.dirname(__file__)) + test_docs_dir = os.path.join(tests_dir, "docs") + + # populate content + copy_tree(test_docs_dir, temp_dir) + + yield temp_dir + + +@pytest.fixture +def get_glob_filtered_temp_docs_dir(temp_docs_dir): + """ + This fixture provides function that returns a path to a temp docs directory + based on tests/docs, but filtered to only retain .rst files globbed by + provide glob_patterns relative to the source/ directory. + + Note that to test specific documents, including those that are expected to + raise errors, we could use the Sphinx configuration "include_patterns". + Sadly its only available in Sphinx 5.1+ so it would constrain us to test + against Sphinx 5.1+. Due to that, we rely on this fixture instead. + """ + + def _filter_source_dir_func(glob_patterns): + old_cwd = os.getcwd() + try: + os.chdir(os.path.join(temp_docs_dir, "source")) + source_rst_files = set(glob.glob("**/*.rst", recursive=True)) + files_to_retain = set() + for p in glob_patterns: + files_to_retain = files_to_retain.union( + set(glob.glob(p, recursive=True)) + ) + + if not source_rst_files.intersection(files_to_retain): + print("glob_patterns", glob_patterns) + print("source_rst_files", source_rst_files) + print("files_to_retain", files_to_retain) + raise ValueError( + "provided glob_patterns found no .rst in the source folder to retain!" + ) + + for f in source_rst_files.difference(files_to_retain): + os.remove(f) + + print() + print( + f"Fixture get_glob_filtered_temp_docs_dir provided the directory {temp_docs_dir}:" + ) + for f in glob.glob("**/*.rst", recursive=True): + print(f"- {f}") + print() + finally: + os.chdir(old_cwd) + return temp_docs_dir + + yield _filter_source_dir_func diff --git a/tests/docs/sample_module.py b/tests/docs/sample_module.py new file mode 100644 index 0000000..3738b24 --- /dev/null +++ b/tests/docs/sample_module.py @@ -0,0 +1,83 @@ +""" +sample_module docstring + +This module provides a module and classes with and without traits to test +autodoc_traits against. It does not contain tests. +""" + +from traitlets import Bool +from traitlets.config.configurable import Configurable + + +class SampleConfigurable(Configurable): + """SampleConfigurable docstring""" + + non_trait = False + + @property + def non_trait_property(self): + """non_trait_property docstring""" + + trait = Bool( + help="""trait help text""", + config=True, + ) + trait_nohelp = Bool( + config=True, + ) + trait_noconfig = Bool( + help="""trait_noconfig help text""", + ) + + def method(self): + """method docstring""" + + +class SampleConfigurableSubclass(SampleConfigurable): + """SampleConfigurableSubclass docstring""" + + subclass_non_trait = False + + @property + def subclass_non_trait_property(self): + """subclass_non_trait_property docstring""" + + subclass_trait = Bool( + config=True, + help="""subclass_trait help text""", + ) + subclass_trait_nohelp = Bool( + config=True, + ) + subclass_trait_noconfig = Bool( + help="""subclass_trait_noconfig help text""", + ) + + def subclass_method(self): + """subclass_method docstring""" + + +class SampleNonConfigurable: + """SampleNonConfigurable docstring""" + + non_trait = False + + @property + def non_trait_property(self): + """non_trait_property docstring""" + + def method(self): + """method docstring""" + + +class SampleNonConfigurableSubclass(SampleNonConfigurable): + """SampleNonConfigurableSubclass docstring""" + + non_trait = False + + @property + def subclass_non_trait_property(self): + """subclass_non_trait_property docstring""" + + def subclass_method(self): + """subclass_method docstring""" diff --git a/tests/docs/source/autoclass/members.rst b/tests/docs/source/autoclass/members.rst new file mode 100644 index 0000000..5381a8d --- /dev/null +++ b/tests/docs/source/autoclass/members.rst @@ -0,0 +1,11 @@ +autoclass - members +=================== + +The ``members`` option without specified members should present all class +members. + +In this test expect no trait members to show up. + +.. autoclass:: sample_module.SampleConfigurable + :noindex: + :members: diff --git a/tests/docs/source/autoclass/undoc_members.rst b/tests/docs/source/autoclass/undoc_members.rst new file mode 100644 index 0000000..2205c5d --- /dev/null +++ b/tests/docs/source/autoclass/undoc_members.rst @@ -0,0 +1,11 @@ +autoclass - undoc-members +========================= + +The ``members`` and ``undoc-members`` option combined should present all class +members, including the traits. And with autodoc_traits installed, the attributes +should be presented with their help strings as docstrings. + +.. autoclass:: sample_module.SampleConfigurable + :noindex: + :members: + :undoc-members: diff --git a/tests/docs/source/autoconfigurable/exclude_members.rst b/tests/docs/source/autoconfigurable/exclude_members.rst new file mode 100644 index 0000000..5cee7b5 --- /dev/null +++ b/tests/docs/source/autoconfigurable/exclude_members.rst @@ -0,0 +1,14 @@ +autoconfigurable - exclude-members +================================== + +The ``exclude-members`` option should work to exclude specific members, trait +members and and non-traits members alike. + +In this test we provide ``members`` and then exclude the members ``trait`` +and ``method`` by specifying them with ``exclude-members`` that otherwise ought +to show up, and check that they aren't showing up. + +.. autoconfigurable:: sample_module.SampleConfigurable + :noindex: + :members: + :exclude-members: trait,method diff --git a/tests/docs/source/autoconfigurable/inherited_members.rst b/tests/docs/source/autoconfigurable/inherited_members.rst new file mode 100644 index 0000000..2ea3205 --- /dev/null +++ b/tests/docs/source/autoconfigurable/inherited_members.rst @@ -0,0 +1,9 @@ +autoconfigurable - inherited-members +==================================== + +With the ``inherited-members`` option, we expect traits from the superclass +SampleConfigurable to show up as well as traits from SampleConfigurableSubclass. + +.. autoconfigurable:: sample_module.SampleConfigurableSubclass + :noindex: + :inherited-members: diff --git a/tests/docs/source/autoconfigurable/members.rst b/tests/docs/source/autoconfigurable/members.rst new file mode 100644 index 0000000..bace0a7 --- /dev/null +++ b/tests/docs/source/autoconfigurable/members.rst @@ -0,0 +1,12 @@ +autoconfigurable - members +========================== + +The ``members`` option without specified members should present all class +members. + +In this test expect all trait members with ``.config(True)`` to show up, even +those inherited from super classes. + +.. autoconfigurable:: sample_module.SampleConfigurableSubclass + :noindex: + :members: diff --git a/tests/docs/source/autoconfigurable/no_members.rst b/tests/docs/source/autoconfigurable/no_members.rst new file mode 100644 index 0000000..2e6378a --- /dev/null +++ b/tests/docs/source/autoconfigurable/no_members.rst @@ -0,0 +1,11 @@ +autoconfigurable - no members +============================= + +Without the ``members`` option, one may expect no members to show up, but we +present all configurable traits still, including inherited configurable traits, +see `Issue27`_. + +.. _Issue27: https://github.com/jupyterhub/autodoc-traits/issues/27 + +.. autoconfigurable:: sample_module.SampleConfigurableSubclass + :noindex: diff --git a/tests/docs/source/autoconfigurable/non_configurable_raises_error.rst b/tests/docs/source/autoconfigurable/non_configurable_raises_error.rst new file mode 100644 index 0000000..966e332 --- /dev/null +++ b/tests/docs/source/autoconfigurable/non_configurable_raises_error.rst @@ -0,0 +1,11 @@ +autoconfigurable - non configurable class +========================================= + +Test that we error when used on non-configurable classes. + +.. note:: + This file is targeted by ``exclude_patterns`` in ``source/conf.py`` + to avoid failing unless we want to explicitly test such failure. + +.. autoconfigurable:: sample_module.SampleNonConfigurable + :noindex: diff --git a/tests/docs/source/autoconfigurable/specified_members.rst b/tests/docs/source/autoconfigurable/specified_members.rst new file mode 100644 index 0000000..c37ddfc --- /dev/null +++ b/tests/docs/source/autoconfigurable/specified_members.rst @@ -0,0 +1,15 @@ +autoconfigurable - specified members +==================================== + +The ``members`` option with specified members should only present the specified +members. + +In this test we list the member ``method`` and ``trait_nohelp``. One may expect +no members besides these to show up, but we present them and all trait members +still, see `Issue27`_. + +.. _Issue27: https://github.com/jupyterhub/autodoc-traits/issues/27 + +.. autoconfigurable:: sample_module.SampleConfigurable + :noindex: + :members: method,trait_nohelp diff --git a/tests/docs/source/automodule/members.rst b/tests/docs/source/automodule/members.rst new file mode 100644 index 0000000..ad12fed --- /dev/null +++ b/tests/docs/source/automodule/members.rst @@ -0,0 +1,9 @@ +automodule - members +==================== + +Test that we can present a module with a mix of traitlets configurable member +classes and normal classes, and they all show up. + +.. automodule:: sample_module + :noindex: + :members: diff --git a/tests/docs/source/autotrait/help.rst b/tests/docs/source/autotrait/help.rst new file mode 100644 index 0000000..be5e876 --- /dev/null +++ b/tests/docs/source/autotrait/help.rst @@ -0,0 +1,7 @@ +autotrait - help +================ + +Test that we present the trait's provided ``help``. + +.. autotrait:: sample_module.SampleConfigurable.trait + :noindex: diff --git a/tests/docs/source/autotrait/noconfig.rst b/tests/docs/source/autotrait/noconfig.rst new file mode 100644 index 0000000..b5ac431 --- /dev/null +++ b/tests/docs/source/autotrait/noconfig.rst @@ -0,0 +1,8 @@ +autotrait - no config +===================== + +Test that we still present a trait without ``config=True`` if directly requested +via the ``autotrait`` directive. + +.. autotrait:: sample_module.SampleConfigurable.trait_noconfig + :noindex: diff --git a/tests/docs/source/autotrait/nohelp.rst b/tests/docs/source/autotrait/nohelp.rst new file mode 100644 index 0000000..36878fc --- /dev/null +++ b/tests/docs/source/autotrait/nohelp.rst @@ -0,0 +1,8 @@ +autotrait - no help +=================== + +Test that we fall back to present the trait's header even if it lacks a ``help`` +string. + +.. autotrait:: sample_module.SampleConfigurable.trait_nohelp + :noindex: diff --git a/tests/docs/source/autotrait/non_trait_raises_error.rst b/tests/docs/source/autotrait/non_trait_raises_error.rst new file mode 100644 index 0000000..d908fe9 --- /dev/null +++ b/tests/docs/source/autotrait/non_trait_raises_error.rst @@ -0,0 +1,11 @@ +autotrait - non trait attribute +=============================== + +Test that we error when used on non-trait attributes. + +.. note:: + This file is targeted by ``exclude_patterns`` in ``source/conf.py`` + to avoid failing unless we want to explicitly test such failure. + +.. autotrait:: sample_module.SampleConfigurable.non_trait + :noindex: diff --git a/tests/docs/source/conf.py b/tests/docs/source/conf.py new file mode 100644 index 0000000..4ce6d98 --- /dev/null +++ b/tests/docs/source/conf.py @@ -0,0 +1,20 @@ +import os +import sys + +project = "autodoc_traits tests" +extensions = [ + "autodoc_traits", + # sphinx.ext.napoleon is added to avoid warnings if testing with + # :inherited-members:` where the super classe traitlets.HasTraits has numpy + # or google-format docstrings that the napoleon can help interpret. + "sphinx.ext.napoleon", +] + +# ensure sample_module.py is on path +tests_dir = os.path.join(os.path.dirname(__file__), "..") +tests_dir = os.path.abspath(tests_dir) +sys.path.insert(0, tests_dir) + +# Don't parse .rst documents expected to raise an error unless we want to test +# that specifically. +exclude_patterns = ["**/*_raises_error.rst"] diff --git a/tests/docs/source/index.rst b/tests/docs/source/index.rst new file mode 100644 index 0000000..8afbc9b --- /dev/null +++ b/tests/docs/source/index.rst @@ -0,0 +1,9 @@ +autodoc_traits tests +==================== + +.. toctree ref: https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-toctree +.. toctree:: + :maxdepth: 2 + :glob: + + */* diff --git a/tests/test_autodoc_traits.py b/tests/test_autodoc_traits.py new file mode 100644 index 0000000..fc205ff --- /dev/null +++ b/tests/test_autodoc_traits.py @@ -0,0 +1,165 @@ +import os +import subprocess + +import pytest + + +def test_sphinx_build_all_docs(temp_docs_dir, monkeypatch): + """ + Tests that the docs folder builds without warnings. + """ + monkeypatch.chdir(temp_docs_dir) + + subprocess.run( + ["sphinx-build", "--color", "-W", "--keep-going", "source", "build"], + check=True, + text=True, + ) + + +@pytest.mark.parametrize( + "rst_file_to_test, strings_in_html, strings_not_in_html", + [ + ( + "autoclass/members.rst", + [], + [ + "c.SampleConfigurable.trait", + ], + ), + ( + "autoclass/undoc_members.rst", + [ + "c.SampleConfigurable.trait", + ], + [], + ), + ( + "autoconfigurable/exclude_members.rst", + ["c.SampleConfigurable.trait_nohelp"], + [ + "trait help text", + "method docstring", + ], + ), + ( + "autoconfigurable/inherited_members.rst", + [ + "c.SampleConfigurableSubclass.trait", + "c.SampleConfigurableSubclass.subclass_trait", + "method docstring", + ], + [], + ), + ( + "autoconfigurable/members.rst", + [ + "c.SampleConfigurableSubclass.subclass_trait", + "c.SampleConfigurableSubclass.trait", + "method docstring", + ], + [ + "trait_noconfig help text", + ], + ), + ( + "autoconfigurable/no_members.rst", + [ + "c.SampleConfigurableSubclass.subclass_trait", + "c.SampleConfigurableSubclass.trait", + ], + [ + "trait_noconfig help text", + "method docstring", + ], + ), + ("autoconfigurable/non_configurable_raises_error.rst", [], []), + ( + "autoconfigurable/specified_members.rst", + [ + "method docstring", + "c.SampleConfigurable.trait_nohelp", + "trait help text", + ], + [], + ), + ( + "automodule/members.rst", + [ + "sample_module docstring", + "SampleConfigurable docstring", + "SampleConfigurableSubclass docstring", + "SampleNonConfigurable docstring", + "SampleNonConfigurableSubclass docstring", + ], + [], + ), + ( + "autotrait/help.rst", + [ + "c.SampleConfigurable.trait", + "trait help text", + ], + [], + ), + ( + "autotrait/noconfig.rst", + [ + "c.SampleConfigurable.trait_noconfig", + ], + [], + ), + ( + "autotrait/nohelp.rst", + [ + "c.SampleConfigurable.trait_nohelp", + ], + [], + ), + ("autotrait/non_trait_raises_error.rst", [], []), + ], +) +def test_sphinx_build_file( + get_glob_filtered_temp_docs_dir, + monkeypatch, + rst_file_to_test, + strings_in_html, + strings_not_in_html, +): + """ + Tests that individual .rst documents in the docs folder builds without + warnings, emits .html with certain strings, and emits .html without certain + strings. + """ + temp_docs_dir = get_glob_filtered_temp_docs_dir(["index.rst", rst_file_to_test]) + monkeypatch.chdir(temp_docs_dir) + + p = subprocess.run( + [ + "sphinx-build", + "--color", + "-W", + "--keep-going", + "-D", + "exclude_patterns=", + "source", + "build", + ], + text=True, + ) + if "raises_error" in rst_file_to_test: + assert p.returncode > 0 + return + assert p.returncode == 0 + + html_file_to_inspect = os.path.join( + "build", rst_file_to_test.replace(".rst", ".html") + ) + with open(html_file_to_inspect) as f: + html = f.read() + + for s in strings_in_html: + assert s in html + + for s in strings_not_in_html: + assert s not in html diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py new file mode 100644 index 0000000..daf1618 --- /dev/null +++ b/tests/test_fixtures.py @@ -0,0 +1,30 @@ +import os + + +def test_temp_docs_dir(temp_docs_dir): + """ + Verify that we get a reference to a temporary directory with source/conf.py + in it. + """ + assert os.path.isdir(os.path.join(temp_docs_dir, "source")) + assert os.path.isfile(os.path.join(temp_docs_dir, "source/conf.py")) + + +def test_get_glob_filtered_temp_docs_dir(get_glob_filtered_temp_docs_dir): + """ + Verify that we get a reference to a temporary directory with: + - source/conf.py + - index.rst files retained + - filtered .rst files removed + - non-filtered .rst files retained + """ + temp_docs_dir = get_glob_filtered_temp_docs_dir( + ["index.rst", "automodule/members.rst"] + ) + assert os.path.isdir(os.path.join(temp_docs_dir, "source")) + assert os.path.isfile(os.path.join(temp_docs_dir, "source/conf.py")) + assert os.path.isfile(os.path.join(temp_docs_dir, "source/index.rst")) + assert os.path.isfile(os.path.join(temp_docs_dir, "source/automodule/members.rst")) + assert not os.path.isfile( + os.path.join(temp_docs_dir, "source/autoclass/members.rst") + )